OpenJudge 2811:熄灯问题
先看三个数。
第一个:1,073,741,824。这是 2 的 30 次方。5 行 6 列一共 30 个按钮,每个按钮只有”按”和”不按”两种状态,所以全部按法的数量就是这个数。十亿这个量级,写个程序一种一种试,试到天荒地老。
第二个:64。这是第一行 6 个按钮的全部按法数量,2 的 6 次方。真正需要遍历的,从头到尾只有这个数。
第三个:1。在 10.7 亿种按法里,正确的那个只有一个。不是”可能有几个”,是数学上确凿的唯一。
这篇文章要讲的就是:为什么 64 次就够了,为什么答案是唯一,以及怎么把时间和空间都压到没有一丝浪费。
一、先把规则读成一句话
题目给的矩阵是 5 行 6 列,每个位置一盏灯。按下一个按钮,它自己和上下左右四盏灯的状态都翻转一次。角上的按钮只影响 3 盏,边上的影响 4 盏,中间的 5 盏。
规则里藏了三条可以直接用的性质:
按两次等于没按。 翻转两次就回到原状。所以每个按钮只有”按”和”不按”两种有意义的状态,按第三第四次的都是白费。
顺序无所谓。 先按 A 再按 B,和先按 B 再按 A,结果完全一样。因为每一次操作都是翻转,翻转是加法,加法满足交换律。
所以整件事就是异或。 每盏灯最终的状态 = 初始状态 异或 所有影响它的按钮的按下情况。全灭意味着这个值对每盏灯都是 0。
把这三条合起来,题目就变成了一道解方程组的题:30 个未知数(每个按钮按不按),30 个方程(每盏灯最后要是 0),运算是异或。
方程组的未知数有 2 的 30 次方种取值,但既然有 30 个方程约束,就不该老老实实枚举。
二、第一刀:第一行定生死
这是这道题最值钱的一句话:
第一行按什么,试完 64 种可能之后,后面四行就全被逼出来了,没有任何选择余地。
为什么?按下一个按钮会翻转自己和上下左右。那么反过来说,第 1 行的灯,能影响它的按钮只有三个来源:第 1 行自己、第 2 行对应位置。第 0 行不存在(那是边界外面)。
所以,一旦第 1 行按完了,第 1 行还剩哪些灯亮着,就只能靠第 2 行来灭。而且按法唯一:第 1 行哪几列还亮着,第 2 行就在那几列按。
第 1 行灭干净了,第 2 行因为被按过而被打乱,那就用第 3 行去灭。一路推下去。
推到第 5 行的时候没有第 6 行来救它了。第 5 行只能靠自己,加上第 4 行按下时的向下影响。这两样加起来必须刚好把第 5 行灭完。如果还剩了亮着的灯,说明一开始第 1 行那个按法不对,换下一种。
这就是全部算法:
1 | 枚举第 1 行的 64 种按法: |
64 种按法里必有且仅有一种能推到底。
三、把一行压成一个整数
一行 6 盏灯,每盏只有 0 和 1 两个状态。这正好是一个 6 位二进制数。
压成整数之后,那些”逐格操作”的循环全部消失,换成了一位一位的位运算:
判断第 j 盏灯亮不亮,就是 (x >> j) & 1。翻转整行,就是异或。翻转邻居,就是左移一位和右移一位。
这个改写带来的好处是常数级的:不用开二维数组,不用写双重循环,一行灯的处理变成两次移位加三次异或。在没有算法级优化空间的时候,常数就是唯一的战场。
四、递推公式
把上面的话写成式子。记第 i 行的初始灯态是 a[i],第 i 行的按法是 p[i](都是 6 位的整数),那么第 i 行的最终状态是:
1 | a[i] ^ p[i] ^ (p[i] << 1) ^ (p[i] >> 1) ^ p[i-1] ^ p[i+1] |
要让前四行全灭,也就是上式对 i = 0..3 都等于 0。把同一行左右邻居的影响合并成 effect(p):
1 | effect(p) = p ^ ((p << 1) & MASK) ^ (p >> 1) // 自身 + 左右邻居 |
移项就得到:
1 | p[i+1] = a[i] ^ effect(p[i]) ^ p[i-1] |
每一行的按法,完全由它自己和上一行决定。 这就是”第一行定生死”的数学形式。
最后一行不需要再算按法,只需要验证:
1 | a[4] ^ effect(p[4]) ^ p[3] == 0 |
注意 effect() 里已经包含了 p 自身,所以递推式里不能再异或一次 p[i]。这一点在第五节会专门讲,它是最容易写错的地方。
五、代码
先给一个直观版,把每一行的灯态显式存下来,逻辑最好读:
1 | // OpenJudge 2811 熄灯问题 |
把「一行的影响」抽成一个函数
上面那段每行要做五次异或。如果抽个函数出来,一行的全部影响可以一句话说清:
1 | // 一行按钮对「本行」的总影响:自身 + 左右邻居 |
有了它,第四节的递推式可以直接照抄:
1 | press[0] = first; |
一个很容易踩的坑
写这类代码时,effect() 到底含不含按钮自身,必须和递推式对齐。这两处只要有一处理解错,程序就是错的,而且错得很隐蔽:它照样能跑,照样能输出,只是答案不对。
举个具体的错误写法:
1 | press[i] = light[i - 1] ^ press[i - 1] ^ effect(press[i - 1]); // 错 |
这里的 effect(press[i-1]) 已经包含了 press[i-1] 自身,前面又异或了一次,两次异或等于没异或,press[i-1] 这一项就被消掉了。递推式丢了一项,输出的按法自然不对。
两种改法,选一种坚持到底:
1 | 写法 A:effect(x) = x ^ (x<<1) ^ (x>>1) 含自身 |
这两种是等价的,改哪一个都能通过。用错的是把 A 的 effect 配上了 B 的公式。
六、跑一遍样例
题目给的样例输入:
1 | 0 1 1 0 1 0 |
程序输出:
1 | 1 0 1 0 0 1 |
和题目给的样例输出逐字一致。
图里可以看清整个推进的过程。第一个格子只有第 1 行被按过,第一行全灭;第二个格子前两行全灭;一路到最后一张,五行全灭。
第一行按的是 101001,后面四行分别被逼成 110101、001011、100100、010000。
七、时间账
题目给的时间限制是 1000 毫秒。先看数量级:
1 | 朴素做法:枚举全部 30 个按钮 2^30 = 1,073,741,824 种 |
那 64 种里,每一种要花多少步?从第 1 行推到第 5 行,每行 3 到 5 次位运算。
用 C++ 实测,在递推那行旁边挂个计数器,样例输入跑下来:
1 | 维护 cur[5] 的写法 共 874 次运算(试到第 38 种才命中) |
样例的答案是第一行 101001,也就是 first = 37,所以前面 38 种(0 到 37)都要试。874 ÷ 38 约 23 次,722 ÷ 38 约 19 次,跟手算的每轮开销对得上。
把输入空间按步长抽稀扫一遍(10 万组),最坏情况:
1 | 维护 cur[5] 的写法 1,472 次运算 |
最坏 1216 次基本运算。 题目给 1000 毫秒,这个量级连一毫秒都用不到。
再看换用 Python 写会怎样。Python 是解释型语言,单次运算比 C++ 慢几十倍,实测:
1 | 维护 cur[] 的写法 55.46 微秒 |
就算用 Python,也是几十微秒的量级,离 1000 毫秒差着四个数量级。
复杂度表
设矩阵有 R 行 C 列(本题 R=5,C=6):
1 | 方案 时间复杂度 空间复杂度 |
从第二行到第三行,位运算把每行 O(C) 的逐格模拟压成 O(1) 的整数运算;从第三行到第四行,滚动变量把辅助空间从 O(R) 压到 O(1)。
有一点要说明白:这两步改善的都是常数因子,没有改变量级。 指数部分 2^C 从”枚举第一行”那一刻就定下来了,后面再怎么优化也是在这个框架里抠细节。对固定的 5×6 矩阵来说,量级早就被规模锁死了,能比的只有常数。
八、空间账
空间这块要先分清两个概念,不然后面容易把话说满。
辅助空间指算法跑起来额外开的临时变量。总空间还要算上必须一直拿着的东西:输入的灯阵、要输出给判题的按法。这两笔不能混在一起算。
先说辅助空间。
维护 cur[5] 的写法要开三个数组:存输入的 a[5]、存每行实时状态的 cur[5]、存按法的 p[5],加起来 15 个 int。
滚动变量版可以更省。回头看第四节那个递推式:
1 | p[i+1] = a[i] ^ effect(p[i]) ^ p[i-1] |
要算 p[i+1],只需要 p[i] 和 p[i-1] 这两个值。再往前的按法早就用不上了。所以不需要 cur[5] 这个数组,也不需要完整的 p[5],只要两个变量来回滚:
1 | for (int first = 0; first < 64; ++first) { |
临时变量只有 pm、pc、nx 三个,辅助空间是 O(1)。相比维护 cur[5] 的写法,省掉了一个完整的数组。
但总空间压不到 O(1)
想输出完整的按键矩阵,就必须把 press[0..4] 存下来。这一块是 O(R),压不掉。
两种做法都绕不开:
边算边存。 开一个 press[5],每推出一行就写进去,最后统一输出。辅助空间从 O(1) 涨回 O(R)。
算完再回放。 找到解之后,从 first 出发把递推式再跑一遍,逐行算出按法直接打印。这样辅助空间能守住 O(1),代价是递推式跑两遍,时间翻倍。
再加上输入的 light[R] 也得留着(每种 first 都要重新读一遍),总空间就是:
1 | 输入 light[R] O(R) |
对本题来说 R 就是 5,这一点差别是 5 个 int 对 15 个 int。 说「空间复杂度 O(1)」在辅助空间的意义上没错,但如果说的是整个程序的内存占用,那是 O(R)。两个说法不能混用。
顺带说一句,朴素 DFS 反而是最费空间的:递归栈要压 30 层,每层还要记灯阵状态,加起来 60 个 int,而且这 60 个 int 要陪着 10.7 亿次枚举一起跑。
九、还能更快吗
能。
枚举量再砍一半。 前面是枚举第 1 行的 6 个按钮,把矩阵转置一下,就变成枚举第 1 列的 5 个按钮,只要 2 的 5 次方 = 32 次。原理完全对称:第 1 列的灯也只能被第 1 列自己和第 2 列影响。
转置之后用同一套逻辑解,再把答案转置回来就行。前面的操作计数里,”滚动变量 + 枚列”那 288 次就是这么来的。
不用枚举。 既然它在 GF(2) 上就是一个线性方程组,可以直接高斯消元。30 个未知数 30 个方程,把增广矩阵消成对角形,一次得到答案。用位运算加速消元,复杂度是 O(30³ / 64),也就是几百次位运算的量级。
代码会长一点,但对本题来说收益不大,因为枚举 64 次本来就够快了。它的价值在于换个角度看问题:一旦意识到这是线性代数,很多直觉上的困惑就消失了。
十、为什么一定有解,而且只有一个
前面反复说”64 种里必有且仅有一种”,这句话不是猜测,是可以证明的。
把 30 个按钮当作未知数,30 盏灯当作方程,系数就是”这个按钮影不影响这盏灯”。整个方程组可以写成一个 30×30 的矩阵,运算在 GF(2) 上(只有 0 和 1,加法就是异或)。
算一下这个矩阵的秩:
1 | 变量(按钮)数 30 |
秩等于 30,满秩,零空间维数是 0。
满秩的含义很直接:矩阵可逆,从灯态到按法的映射是一一对应的。任意给一个初始灯态,都能唯一解出一个按法。零空间维数是 0 则说明解不止存在,而且是唯一的,没有别的按法能达到同样的效果。
所以题目根本不需要你判断”有没有解”,答案永远存在,永远只有一个。前面枚举 64 次,只是找一个确定存在的东西。
我把这个结论实测了一下:随机生成 100 万组初始灯态,全部有解,命中率 100.0000%。另外拿 20 万组随机数据把枚举法和独立实现的高斯消元对拍了一遍,两者给出的解完全一致。
十一、四种解法摆在一起
1 | 方法 检查次数 每次代价 说明 |
这道题的价值不在代码本身,代码就二十来行。价值在于从 10.7 亿砍到 64 的那一刀是怎么切下去的。
题目原文其实已经把提示写出来了:先按第 2 行灭掉第 1 行,再按第 3 行灭掉第 2 行,一路推下去。很多人读到这段会当成”介绍背景”,其实那就是全部解法。
十二、自己动手
想验证前面的数字,几行代码就能复现。
数一下到底跑了多少次运算。 在递推那行旁边加个计数器:
1 | ops = 0 |
样例输入应该数出 722 次左右(累计到命中为止,也就是前面 38 种首行全算上)。如果只数命中的那一轮,是 19 次上下。两个口径都对,别混着比就行。
验证解的唯一性。 把 30 个按钮全部枚举一遍,记录所有能把灯灭光的按法,看是不是只有一个:
1 | sols = [] |
嫌慢就把规模缩小:拿 3×3 的灯阵试,2 的 9 次方只要 512 次,几毫秒就能跑完,同样能看到只有一个解。
测一下满秩。 用上面的方法构造影响矩阵,跑一遍 GF(2) 高斯消元数非零行的个数,应该正好是 30。
十三、最后
回到开头那三个数。
10.7 亿是问题的表面规模,也是吓退人的地方。看到 30 个开关就觉得只能搜索,搜索就必然超时。
64 是引入”第一行定生死”这条性质之后的真实规模。这一刀下去,指数从 30 砍到 6。
1 是答案的个数。它说明这道题从头到尾都在找一样确定存在的东西,不需要考虑无解,不需要考虑多解,甚至不需要考虑剪枝。
至于时间和空间。
时间上,最坏 1216 次基本运算,离 1000 毫秒的限制差着好几个数量级。空间上,辅助变量能压到 O(1),但输出的按键矩阵本身是 O(R),这一块压不掉。
所以真正还能抠的只有常数因子:位运算替掉逐格模拟,滚动变量替掉状态数组。这两步都不会改变量级,只是让常数好看一点。
对这道题来说,真正的杠杆只有一处,就是把”枚举 30 个按钮”换成”枚举第一行”。那一刀落下之后,剩下的都是工程细节。













