news 2026/10/6 4:41:35

灯泡开关算法题:从暴力模拟到完全平方数的O(1)解法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
灯泡开关算法题:从暴力模拟到完全平方数的O(1)解法

这道题我印象深刻。上个月帮人模拟笔试,一道"灯泡开关",对面哥们看完题目嘴角扬起,心想这是什么送分题,直接两层循环模拟翻转。结果看到参数范围那个瞬间,笑容凝固了——n是十的九次方量级。我当场就笑了,这道题名字起得真客气,看着是人畜无害的模拟,实际是来收编"只会写模拟的人"的。

"灯泡开关"是一道非常典型的笔试筛选器,它想考察的不是你会不会写循环,而是你有没有意识到:有些问题表面上在考编码,实际上在考数学直觉。这道题在LeetCode上是319题,在各大厂笔试里也反复出现,披着模拟的外衣考数论。今天这篇就把它的来龙去脉掰开揉碎讲清楚,看完你不仅在笔试里能秒杀这题,还能顺手看懂它的一整串变形题。

1. 一道看起来能暴力,实际想让你放弃暴力的题

1.1 题目在说什么:以n=10手算一次

先把题目定个型。假设有n个灯泡,编号从1到n,初始全部关闭。接下来进行n轮操作:第i轮,翻转所有编号为i的倍数的灯泡。翻转的意思就是,开着变关,关着变开。问你第n轮操作结束后,有多少个灯泡是亮着的。

听起来很简单对吧?我习惯遇到这种题先手算一个小规模样例,比如n=10,把每一轮的翻转结果列出来,这样比空想直观得多。

灯泡编号被哪些轮次翻转翻转次数最终状态
111亮
21, 22灭
31, 32灭
41, 2, 43亮
51, 52灭
61, 2, 3, 64灭
71, 72灭
81, 2, 4, 84灭
91, 3, 93亮
101, 2, 5, 104灭

n=10的时候,结果是3个灯泡亮着,亮灯编号是1、4、9。到这一步,规律其实已经呼之欲出了——亮着的灯泡编号全是完全平方数:1=1²,4=2²,9=3²。

但注意,这只是n=10的观察结果,还不能当作定论。做笔试题最忌讳的就是"看到三个点就总结规律",你得先明白这规律是怎么来的,才能确定它在大数据范围下依然成立。

1.2 为什么说这道题"阴"

如果说"最终规律是平方数"是终点,那这道题的恶意就藏在起点。如果n给的是10⁹,你老老实实写个模拟,外层循环跑n次,内层循环平均也得跑n/2次,总操作量是十万亿级别,跑完怕是笔试都结束了。

所以这道题真正想干的事,是逼你停下来想:灯泡的翻转次数,到底取决于什么?一旦开始想这个问题,你就会被引导到"约数个数"的方向上。这也是为什么我反复强调,笔试里遇到"看起来能做但数据范围非常离谱"的题,第一反应不应该是优化循环,而是重新审视问题本身。

2. 暴力模拟法:写得出来,不一定跑得完

2.1 朴素的翻转实现

不管怎么说,暴力模拟是理解这道题的第一步。代码写起来很直接:用一个布尔数组记录灯泡状态,第i轮把所有下标为i的倍数的元素取反。

int bulbSwitch(int n) { vector<char> bulbs(n + 1, 0); // 0表示灭,1表示亮 for (int i = 1; i <= n; i++) { for (int j = i; j <= n; j += i) { bulbs[j] ^= 1; } } int ans = 0; for (int i = 1; i <= n; i++) { if (bulbs[i]) ans++; } return ans; }

这里我用vector<char>而不是vector<bool>,因为vector<bool>在C++里是个特化容器,内部按位存储,虽然省内存,但行为有时不符合常规预期,面试笔试中没必要踩这个坑。内层循环的起始位置是j = i而不是j = 1,理由很简单:编号小于i的灯泡不可能是i的倍数,从i开始遍历能省掉一半无意义的判断。

这个实现的正确性毋庸置疑,但它到底有多慢?你计算一下总的翻转次数:第1轮翻转n次,第2轮翻转n/2次,第3轮翻转n/3次……总次数是:

n/1 + n/2 + n/3 + ... + n/n = n * (1 + 1/2 + 1/3 + ... + 1/n)

括号里的调和级数收敛速度很慢,近似等于ln(n)再加上欧拉常数γ(约0.577)。所以总的操作量大约是n ln n。

2.2 暴力与公式的分水岭在哪里

用具体数字感受一下:

n总翻转次数估算暴力可行性
10⁴约9万次轻松
10⁵约120万次轻松
10⁶约1380万次勉强可以
10⁷约1.6亿次开始吃力
10⁸约18亿次基本超时
10⁹约200亿次彻底没戏

笔试里这题敢把n开到10⁹,就是在明明白白告诉你:别模拟了,去寻找O(1)的解法。但暴力代码也不是白写的,它最大的价值在于:当你在笔试现场拿不准公式的时候,可以用暴力跑n=100以内的数据来验证你的猜想。这是后话,后面我会专门讲这个技巧。

3. 破题的关键一步:翻转次数就是约数个数

3.1 把"轮次"翻译成"整除关系"

现在来干正事。我们单独盯着某一个灯泡,比如编号为k的灯泡,它什么时候会被翻转?

第i轮会翻转所有编号为i的倍数的灯泡,也就是说,灯泡k在第i轮被翻转,当且仅当"k是i的倍数",换个说法就是"i能整除k"。这句话反过来读更顺:k的每一个约数i,都会对应一次翻转。

所以结论很简单也很关键:灯泡k在整个过程中的翻转次数,恰好等于k的正约数个数。数论里通常记作d(k)或τ(k)。

为什么说这个转化是破题点?因为原本的问题是"二维的":你有n个轮次,n个灯泡,看起来像一张n×n的表格。但换个角度后,每个灯泡的状态只取决于它自己的一个一维属性——约数个数。维度直接降下来了,复杂度分析的目标也从"模拟所有轮次"变成了"分析约数个数的性质"。

3.2 约数的"成对出现"与唯一的落单者

有了"翻转次数 = 约数个数"这个翻译,题目就变成了:哪些数k,它的约数个数是奇数?

初始状态灯泡是灭的,翻转一次变亮,翻转两次变灭,翻转三次变亮……所以翻转奇数次,最终就是亮的。问题进一步收敛为:什么样的数有奇数个约数?

答案藏在约数的配对规律里。随便取一个数,比如12,它的约数是1、2、3、4、6、12。你会发现这些约数可以两两配对:(1, 12)、(2, 6)、(3, 4),每一对的乘积都是12。只要d不等于k/d,约数d就总能找到另一个约数和它配对,成对出现的约数贡献了偶数个名额。

那什么时候配对会失败?当d = k/d的时候,也就是k = d²的时候。这个时候d和自己配了对,这个"落单的"约数不会被消耗掉,约数个数就比偶数多了一个变奇数。

我到这一步通常会给学生打个比方:约数配对就像舞会入场券,每个约数都该找个人组队,但完全平方数的中间那个约数比较特殊,它只能自己抱着自己跳,于是队伍数量多了个单数。这个单数的一支队伍,决定了灯泡最终是亮的。

4. 为什么只有完全平方数是特例:数论视角补一刀

4.1 用标准分解式看约数个数的奇偶性

"约数成对出现"这个解释已经很直观了,但笔试里万一遇到追问,面试官可能会想听更严谨的数论表达。这里补一刀:整数k可以写成标准分解式:

k = p1^a1 * p2^a2 * p3^a3 * ... * pm^am

其中p1、p2……是互不相同的质数,a1、a2……是对应的指数。约数个数的计算公式是:

d(k) = (a1 + 1) * (a2 + 1) * (a3 + 1) * ... * (am + 1)

这个公式的来历不复杂:k的任意一个约数,它的每个质因子的指数只能从0到ai之间取,所以每个质因子有ai+1种选择,乘法原理相乘即可。

现在要让d(k)是奇数。奇数乘以奇数才是奇数,只要任何一个因子是偶数,乘积就是偶数。所以每个括号(ai + 1)都必须是奇数。这意味着每个ai都必须是偶数。每个指数都是偶数,这样的数是什么?正是完全平方数。

这个角度实际上给前面的"配对论"提供了代数证明,也能让你在面试时显得更专业。但如果你觉得记公式麻烦,"成对配对"那个思路已经完全够用了,笔试不是写论文,能自圆其说就行。

4.2 答案公式:亮着的灯编号是一串平方数

把前面的结论串起来:

  • 灯泡k翻转次数 = 约数个数d(k)
  • d(k)为奇数当且仅当k是完全平方数
  • 初始熄灭,翻转奇数次后点亮

所以最终亮着的灯泡,其编号一定是1²、2²、3²……这些完全平方数中不超过n的那些。答案是这些编号的个数,也就是满足i²≤n的最大整数i,即⌊√n⌋。

用数学语言写就是:

ans = floor(sqrt(n))

回到n=10的例子,⌊√10⌋ = 3,亮灯编号是1、4、9,完全吻合之前手算的结果。到这儿,题目从一个n×n的模拟问题,变成了一行求平方根的问题,复杂度从O(n log n)直接降到了O(1)或者O(log n)。

5. 五个语言写完这题的O(1)解法,以及防精度坑

5.1 各语言实现对照表

公式已经出来了,代码就没什么技术含量了。关键是各语言在求整数平方根时的微小差异,容易在笔试环境里出问题。

C++:

#include <cmath> class Solution { public: int bulbSwitch(int n) { return (int)sqrt(n); } };

Java:

class Solution { public int bulbSwitch(int n) { return (int)Math.sqrt(n); } }

Python:

import math class Solution: def bulbSwitch(self, n: int) -> int: return math.isqrt(n)

Go:

import "math" func bulbSwitch(n int) int { return int(math.Sqrt(float64(n))) }

JavaScript:

var bulbSwitch = function(n) { return Math.floor(Math.sqrt(n)); };

5.2 浮点开方可能踩的坑与二分替代法

上面这些写法里,C++、Java、Go、JavaScript走的都是浮点开方再转整型的路子。大多数情况下没问题,但做笔试的人应该养成一个习惯:凡是涉及浮点转整数,都要问自己一句"精度够不够稳"。

浮点开方的风险在于:某些完全平方数,比如25,开方后理论结果是5.0,但浮点运算底层算出来的可能是4.999999999999,取整就成4了。这种问题在数据量大的时候不是会不会发生,而是什么时候发生。

我个人的处理习惯是加一个回验,花不了几行代码,但能把精度坑彻底填平:

int bulbSwitch(int n) { int q = (int)sqrt(n); // 如果下一位的平方也不超过n,说明浮点结果偏小了 if ((long long)(q + 1) * (q + 1) <= n) q++; return q; }

注意这里转long long的原因:当q接近46340的时候,q²会逼近int类型的上限2.1×10⁹,直接乘会溢出。笔试环境里这种隐蔽的溢出错,比算法想不出来还要冤。

如果你对浮点完全不放心,还可以用整数二分来求平方根,复杂度O(log n),对n=10⁹来说也就三十次循环,完全可以在毫秒级跑完:

int bulbSwitch(int n) { long long l = 0, r = n, ans = 0; while (l <= r) { long long mid = l + (r - l) / 2; if (mid * mid <= n) { ans = mid; l = mid + 1; } else { r = mid - 1; } } return (int)ans; }

二分法的优势是不依赖浮点环境,纯整数运算没有精度问题,笔试时如果时间紧张、不敢赌sqrt的精度,直接上这个版本最省心。Python的话就没这些纠结,math.isqrt内部就是纯整数的平方根算法,专门为这种场景设计的,性能好而且绝对精确。

5.3 用暴力脚本验证公式

写代码最怕的就是"公式背错了但是自己不知道"。我有一套固定的验证方法:写一个暴力版本,再写一个公式版本,然后在小范围内对拍。保证公式正确性的同时,也能加深自己对题目的理解。

import math def brute(n): bulbs = [False] * (n + 1) for i in range(1, n + 1): for j in range(i, n + 1, i): bulbs[j] = not bulbs[j] return sum(bulbs) def fast(n): return math.isqrt(n) for n in range(1, 1000): if brute(n) != fast(n): print("mismatch at", n) break else: print("all ok")

这段脚本我在笔试复习时跑过很多次,输出"all ok"的那一刻,你对这个公式的信心会变得非常足。这个"暴力+公式对拍"的习惯,不只是这道题适用,几乎所有需要找规律的笔试题都能用。笔试环境如果允许本地写代码,这就是你的私人纠错器。

6. 笔试现场才需要懂的延申:这题的亲戚们

6.1 变体一:输出亮灯编号而不是数量

有些笔试不会问"亮灯有多少个",而是让你"列出所有亮着的灯泡编号"。这个更简单,因为亮灯编号就是1²、2²、3²……一直乘到超过n为止:

vector<long long> getOnBulbs(int n) { vector<long long> ans; for (long long i = 1; i * i <= n; i++) { ans.push_back(i * i); } return ans; }

注意循环变量用long long,因为i*i在i达到46341时就会超出int范围。这个变体的考点其实已经从数学转向了"你有没有处理溢出"的意识。

6.2 变体二:名字相同、解法不同的"灯泡开关II"

LeetCode上还有一道题叫"灯泡开关II",名字看起来和这道题是双胞胎,实际上完全是另一路货色。那道题里有四个功能不同的开关按钮,可以按m次,问最后能得到多少种不同的灯泡状态组合。解法和约数没关系,需要分析按钮操作之间的等价关系,用状态压缩枚举可能的状态。笔试时如果看到"灯泡开关"后面带罗马数字,建议先别急着套平方数结论,把题目完整读一遍再说。

这种"同名不同解"的题目,其实也是出题人故意埋的坑:想考你有没有认真审题。我见过不止一个人看到"灯泡"两个字就开始写sqrt,结果整道题跑偏。

6.3 变体三:关灯游戏与异或方程组

还有一个相关的经典问题叫"Lights Out",中文常翻译成关灯游戏或灭灯游戏。面板是一个m×n的格子矩阵,点一个格子会翻转自己和上下左右五个格子的状态,要求把所有灯熄灭。这类问题的思路和这道题完全不同,不再考约数,而是考线性代数里的异或消元,每个格子的点击次数要么是0要么是1,最后列一个异或方程组解出来即可。

把这个亲戚列出来是想说明一个道理:灯泡开关这系列的题目核心都在"翻转奇偶性"上,落点却各不相同。有的落在约数、有的落在状态组合、有的落在方程组。笔试准备时背结论不如练思维,遇到翻转类题目先想清楚"这个翻转受什么影响、影响能不能用数学结构表达",就成功了一大半。

笔试策略上,我自己总结了一条实战经验:看到n的取值范围大到离谱,同时题面上出现倍数、整除、约数、轮次这类词汇,先别着急开循环,停下来手算几个小样例,观察输出序列的规律,再回头想为什么。这个习惯在"灯泡开关"上帮我省了至少十分钟,那十分钟足够把整张卷子的其他题目再检查一遍。

如果你在考场上实在推不出公式,也有一个保底策略:先提交一版暴力解,保证小数据范围内的分数到手,再利用剩余时间思考优化。很多OJ是按数据范围分档给分的,暴力能拿一部分分,公式解能拿满分,别让"完美主义"害得你连保底分都没拿到。

灯泡开关这道题,表面考的是灯泡和开关,实际考的是你敢不敢在"能写"和"能过"之间选择后者。刷题刷多了你会发现,真正拉开差距的往往不是coding的熟练度,而是遇到反常数据范围时那种"停下来想"的定力。希望这篇把规律讲透、把坑填平的再读版,能让你下次在笔试里遇见它时,多一分从容少一分慌张。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 4:41:33

OpenShell完全指南:自定义经典开始菜单,提升Windows操作效率

1. 项目概述&#xff1a;OpenShell 是一个什么样的存在先聊点实在的。Windows 从 8 代开始把开始菜单改成了全屏磁贴风格&#xff0c;当时很多人直接懵了&#xff0c;尤其是从 Win7 一路用过来的老用户&#xff0c;肌肉记忆里全是"左下角一个小窗口、左边列表右边程序&quo…

作者头像 李华
网站建设 2026/10/6 4:40:13

Vue组件通信全攻略:从props到Pinia的底层原理与实战选型

在Vue项目里泡得久了&#xff0c;你会发现组件和数据通信这两个词几乎贯穿了整个开发周期。不管是刚入门前端的新人&#xff0c;还是已经写了两三年业务的老手&#xff0c;面试也好、实际撸代码也好&#xff0c;大概率都会被这两个话题反复打磨。老实说&#xff0c;Vue的组件体…

作者头像 李华
网站建设 2026/10/6 4:38:51

论文降AI率实操指南:从检测原理到免费改写方法

1. 别急着找工具&#xff1a;先搞懂“AI率”是怎么被算出来的先说一个很多人容易忽略的点&#xff1a;“AI率”并不是某个机构拍脑袋定出来的一个分数&#xff0c;它本质上是检测模型对你论文文本的“机器味”打分。你只有先知道分数怎么来的&#xff0c;才知道怎么不花钱地把它…

作者头像 李华
网站建设 2026/10/6 4:37:43

DEX机制全解:从65K限制到Multidex与改包名实践

不少做 Android 超过三年的同学&#xff0c;应该都经历过一次非常魔幻的现象&#xff1a;明明什么都没改&#xff0c;gradle assembleDebug突然就报了Too many field references: 68224; max is 65536。那一刻你可能第一反应是去加一行multiDexEnabled true&#xff0c;但你要是…

作者头像 李华
网站建设 2026/10/6 4:36:39

宝塔面板API对接指南:自助建站PHP源码自动化部署与二次开发实战

简介&#xff1a;这套2021年PHP自助建站系统源码&#xff0c;是一套基于宝塔面板开发的全开源自助搭建网站平台&#xff0c;适合站长、开发者及建站服务商用于搭建建站业务或学习二次开发。系统基于PHPMYSQL开发&#xff0c;内置论坛、博客、官网等30多套网站程序模板&#xff…

作者头像 李华
网站建设 2026/10/6 4:36:26

测试用例设计核心方法:等价类、边界值、场景法及工程落地实战

1. 测试用例设计到底在解决什么问题“测试用例设计”这五个字&#xff0c;很多刚入行的测试同学以为就是打开Excel表格&#xff0c;把功能点一条一条列出来&#xff0c;写下“输入什么、点哪里、预期什么结果”就完事了。我真见过不少人在面试时被问到“你怎么设计测试用例”&a…

作者头像 李华