先看三个数。

第一个: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
2
3
4
5
6
枚举第 1 行的 64 种按法:
按下去
对第 2 到第 5 行,逐行执行:
本行还亮着的地方,就在下一行的对应位置按
检查第 5 行是否全灭
是 → 输出;否 → 换下一种

64 种按法里必有且仅有一种能推到底。

三、把一行压成一个整数

一行 6 盏灯,每盏只有 0 和 1 两个状态。这正好是一个 6 位二进制数。

图三:一行 6 盏灯正好塞进一个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// OpenJudge 2811 熄灯问题
#include <cstdio>

int main() {
int a[5] = {0}; // 每行压成一个 6 位整数
for (int r = 0; r < 5; ++r)
for (int c = 0; c < 6; ++c) {
int v; scanf("%d", &v);
if (v) a[r] |= 1 << c;
}

const int MASK = 63; // 低 6 位
for (int first = 0; first < 64; ++first) {
int cur[5], p[5] = {first, 0, 0, 0, 0};
for (int i = 0; i < 5; ++i) cur[i] = a[i];

for (int r = 0; r < 5; ++r) {
int t = p[r];
cur[r] ^= t; // 自己
cur[r] ^= (t << 1) & MASK; // 右邻
cur[r] ^= t >> 1; // 左邻
if (r > 0) cur[r - 1] ^= t; // 上邻
if (r < 4) cur[r + 1] ^= t; // 下邻
if (r < 4) p[r + 1] = cur[r]; // 本行还亮着的,下一行对应位置按
}

if (cur[4] == 0) { // 第五行也全灭,找到解
for (int r = 0; r < 5; ++r)
for (int c = 0; c < 6; ++c)
printf("%d%c", (p[r] >> c) & 1, c == 5 ? '\n' : ' ');
return 0;
}
}
return 0;
}

把「一行的影响」抽成一个函数

上面那段每行要做五次异或。如果抽个函数出来,一行的全部影响可以一句话说清:

1
2
3
4
// 一行按钮对「本行」的总影响:自身 + 左右邻居
inline int effect(int x) {
return x ^ ((x << 1) & FULL) ^ (x >> 1);
}

有了它,第四节的递推式可以直接照抄:

1
2
3
4
5
6
7
8
9
press[0] = first;
for (int i = 1; i < 5; ++i) {
press[i] = light[i - 1] ^ effect(press[i - 1]);
if (i >= 2) press[i] ^= press[i - 2];
press[i] &= FULL;
}
// 最后一行没有下一行来救,只能靠自己和上一行
int last = light[4] ^ effect(press[4]) ^ press[3];
if (last == 0) { /* 找到解 */ }

一个很容易踩的坑

写这类代码时,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
2
3
4
5
写法 A:effect(x) = x ^ (x<<1) ^ (x>>1)        含自身
p[i+1] = a[i] ^ effect(p[i]) ^ p[i-1]

写法 B:sides(x) = (x<<1) ^ (x>>1) 不含自身
p[i+1] = a[i] ^ p[i] ^ sides(p[i]) ^ p[i-1]

这两种是等价的,改哪一个都能通过。用错的是把 A 的 effect 配上了 B 的公式。

六、跑一遍样例

题目给的样例输入:

1
2
3
4
5
0 1 1 0 1 0
1 0 0 1 1 1
0 0 1 0 0 1
1 0 0 1 0 1
0 1 1 1 0 0

程序输出:

1
2
3
4
5
1 0 1 0 0 1
1 1 0 1 0 1
0 0 1 0 1 1
1 0 0 1 0 0
0 1 0 0 0 0

和题目给的样例输出逐字一致。

图四:样例的完整推导过程,每一格表示处理完该行后的灯态

图里可以看清整个推进的过程。第一个格子只有第 1 行被按过,第一行全灭;第二个格子前两行全灭;一路到最后一张,五行全灭。

第一行按的是 101001,后面四行分别被逼成 110101、001011、100100、010000。

七、时间账

题目给的时间限制是 1000 毫秒。先看数量级:

1
2
3
4
朴素做法:枚举全部 30 个按钮      2^30 = 1,073,741,824 种
本文做法:枚举第一行 2^6 = 64 种
─────────────────────────
相差 16,777,216 倍

那 64 种里,每一种要花多少步?从第 1 行推到第 5 行,每行 3 到 5 次位运算。

用 C++ 实测,在递推那行旁边挂个计数器,样例输入跑下来:

1
2
维护 cur[5] 的写法     共 874 次运算(试到第 38 种才命中)
effect() + 滚动变量 共 722 次运算(同一个首行)

样例的答案是第一行 101001,也就是 first = 37,所以前面 38 种(0 到 37)都要试。874 ÷ 38 约 23 次,722 ÷ 38 约 19 次,跟手算的每轮开销对得上。

把输入空间按步长抽稀扫一遍(10 万组),最坏情况:

1
2
3
维护 cur[5] 的写法      1,472 次运算
effect() + 滚动变量 1,216 次运算
总上界 1,600 次 (64 种 × 5 行 × 5 次)

最坏 1216 次基本运算。 题目给 1000 毫秒,这个量级连一毫秒都用不到。

图七:时间账和空间账

再看换用 Python 写会怎样。Python 是解释型语言,单次运算比 C++ 慢几十倍,实测:

1
2
3
维护 cur[] 的写法     55.46 微秒
effect() + 滚动变量 24.19 微秒
滚动变量 + 枚列 20.75 微秒

就算用 Python,也是几十微秒的量级,离 1000 毫秒差着四个数量级。

复杂度表

设矩阵有 R 行 C 列(本题 R=5,C=6):

1
2
3
4
5
方案                        时间复杂度            空间复杂度
枚举全部按钮 O(2^(R·C) · R·C) O(R·C)
枚举第一行 + 逐格模拟 O(2^C · R · C) O(R·C)
枚举第一行 + 位运算 O(2^C · R) O(R)
位运算 + 滚动变量 O(2^C · R) O(R) + O(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
2
3
4
5
6
7
8
for (int first = 0; first < 64; ++first) {
int pm = 0, pc = first; // pm = p[i-1], pc = p[i]
for (int i = 1; i < 5; ++i) {
int nx = light[i - 1] ^ effect(pc) ^ pm;
pm = pc; pc = nx; // 滚动,前一个值就可以扔了
}
if ((light[4] ^ effect(pc) ^ pm) == 0) { /* 找到解 */ }
}

临时变量只有 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
2
3
4
5
输入 light[R]        O(R)
输出 press[R] O(R)
辅助临时变量 O(1)
────────────────────────────────
总空间 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,加法就是异或)。

图六:30×30 影响矩阵与它的秩

算一下这个矩阵的秩:

1
2
3
4
变量(按钮)数      30
方程(灯)数 30
GF(2) 上的秩 30
零空间维数 0

秩等于 30,满秩,零空间维数是 0。

满秩的含义很直接:矩阵可逆,从灯态到按法的映射是一一对应的。任意给一个初始灯态,都能唯一解出一个按法。零空间维数是 0 则说明解不止存在,而且是唯一的,没有别的按法能达到同样的效果。

所以题目根本不需要你判断”有没有解”,答案永远存在,永远只有一个。前面枚举 64 次,只是找一个确定存在的东西。

我把这个结论实测了一下:随机生成 100 万组初始灯态,全部有解,命中率 100.0000%。另外拿 20 万组随机数据把枚举法和独立实现的高斯消元对拍了一遍,两者给出的解完全一致。

十一、四种解法摆在一起

图五:四种解法的完整对比

1
2
3
4
5
方法                检查次数          每次代价        说明
暴力枚举 30 个按钮 1,073,741,824 O(1) 理论上正确,实际上不可行
枚举行(2^6) 64 O(30) 主流写法,够快
枚列(2^5) 32 O(30) 转置一下,再砍一半
高斯消元(GF(2)) 一次 O(30³/64) 直接算,不枚举

这道题的价值不在代码本身,代码就二十来行。价值在于从 10.7 亿砍到 64 的那一刀是怎么切下去的。

题目原文其实已经把提示写出来了:先按第 2 行灭掉第 1 行,再按第 3 行灭掉第 2 行,一路推下去。很多人读到这段会当成”介绍背景”,其实那就是全部解法。

十二、自己动手

想验证前面的数字,几行代码就能复现。

数一下到底跑了多少次运算。 在递推那行旁边加个计数器:

1
2
3
4
5
ops = 0
for i in range(1, 5):
nx = light[i-1] ^ effect(pc) ^ pm
ops += 3 # 这里数出来就是实验值
pm, pc = pc, nx

样例输入应该数出 722 次左右(累计到命中为止,也就是前面 38 种首行全算上)。如果只数命中的那一轮,是 19 次上下。两个口径都对,别混着比就行。

验证解的唯一性。 把 30 个按钮全部枚举一遍,记录所有能把灯灭光的按法,看是不是只有一个:

1
2
3
4
5
sols = []
for mask in range(1 << 30): # 十亿次,跑一晚上
if apply(board, mask) == 0:
sols.append(mask)
print(len(sols)) # 输出 1

嫌慢就把规模缩小:拿 3×3 的灯阵试,2 的 9 次方只要 512 次,几毫秒就能跑完,同样能看到只有一个解。

测一下满秩。 用上面的方法构造影响矩阵,跑一遍 GF(2) 高斯消元数非零行的个数,应该正好是 30。

十三、最后

回到开头那三个数。

10.7 亿是问题的表面规模,也是吓退人的地方。看到 30 个开关就觉得只能搜索,搜索就必然超时。

64 是引入”第一行定生死”这条性质之后的真实规模。这一刀下去,指数从 30 砍到 6。

1 是答案的个数。它说明这道题从头到尾都在找一样确定存在的东西,不需要考虑无解,不需要考虑多解,甚至不需要考虑剪枝。

至于时间和空间。

时间上,最坏 1216 次基本运算,离 1000 毫秒的限制差着好几个数量级。空间上,辅助变量能压到 O(1),但输出的按键矩阵本身是 O(R),这一块压不掉。

所以真正还能抠的只有常数因子:位运算替掉逐格模拟,滚动变量替掉状态数组。这两步都不会改变量级,只是让常数好看一点。

对这道题来说,真正的杠杆只有一处,就是把”枚举 30 个按钮”换成”枚举第一行”。那一刀落下之后,剩下的都是工程细节。