##[$AcWing$ $95$. 费解的开关](https://www.acwing.com/problem/content/97/) ### 一、题目描述 你玩过 **拉灯** 游戏吗? $25$ 盏灯排成一个 $5×5$ 的方形。 每一个灯都有一个开关,游戏者可以改变它的状态。 **每一步,游戏者可以改变某一个灯的状态**。 游戏者改变一个灯的状态会产生连锁反应:**和这个灯上下左右相邻的灯也要相应地改变其状态**。 我们用数字 $1$ 表示一盏开着的灯,用数字 $0$ 表示关着的灯。 下面这种状态 ```cpp {.line-numbers} 10111 01101 10111 10000 11011 ``` 在改变了最左上角的灯的状态后将变成: ```cpp {.line-numbers} 01111 11101 10111 10000 11011 ``` 再改变它正中间的灯后状态将变成: ```cpp {.line-numbers} 01111 11001 11001 10100 11011 ``` 给定一些游戏的初始状态,编写程序判断游戏者是否可能在 $6$ 步以内使所有的灯都变亮。 **输入格式** 第一行输入正整数 $n$,代表数据中共有 $n$ 个待解决的游戏初始状态。 以下若干行数据分为 $n$ 组,每组数据有 $5$ 行,每行 $5$ 个字符。 每组数据描述了一个游戏的初始状态。 各组数据间用一个空行分隔。 **输出格式** 一共输出 $n$ 行数据,每行有一个小于等于 $6$ 的整数,它表示对于输入数据中对应的游戏状态最少需要几步才能使所有灯变亮。 对于某一个游戏初始状态,若 $6$ 步以内无法使所有灯变亮,则输出 $−1$。 **数据范围** $0解释: 为啥非得反着来,逆向思维?正着来,正向思维为啥就不行呢? 你想啊,因为最多输入$n=500$组数据,如果是正向思考的话,就是: 你给我一个现在的状态,我就想办法模拟它每个位置都可能变化,然后进行$bfs$拓展$6$次,一次是$2^{25}$,再乘个$6$,给我$500$组,我还需要再乘个$500$,常数有点大噢~ 如果反着想呢?从结果出发,倒着思考,就是最终全是$1$,它的每个位置都可能变化,就是$25$个数位,枚举二进制来模拟每个变化的情况,变化$6$次记录到$Hash$ 表中,然后你给我一个状态,我就查一下表,查表的效率是$O(1)$的,明显比正着考虑节约了一个常数$500$啊,也就是性能提升了$500$倍。 逆向思维,小学奥数啊~ ```cpp {.line-numbers} #pragma GCC optimize(2) // 累了就吸点氧吧~ #include using namespace std; // 5858 ms // 当前状态,当前到最终状态所需步数 unordered_map step; // 改变这个灯及其上下左右相邻的灯的状态 int turn(int t, int idx) { // idx从0开始 t ^= (1 << idx); // 改变第idx个灯,0->1,1->0,互换状态,其它灯不动 if (idx % 5) t ^= (1 << idx - 1); // 不为最左一列,将左侧灯改变 if (idx >= 5) t ^= (1 << idx - 5); // 不为最上一排,将上侧灯改变 if (idx < 20) t ^= (1 << idx + 5); // 不为最后一排,将下面灯改变 if (idx % 5 < 4) t ^= (1 << idx + 1); // 不为最后一列,将右面灯改变 return t; } /* 我们需要知道某个局面是不是出现过,也就是需要把状态记录下来。 而一个二维的数组状态不好记录,我们喜欢记录一维的。 每个位置不是0,就是1,最多25个格子,联想到状态压缩,用二进制来表示状态。 从输入的状态开始,走6步,可以覆盖到哪些数组状态呢?我们可以用状态压缩+Hash表记录下来,最终判断一下 int x = (1 << 25) - 1;这个状态是不是在Hash表中就行了。 也可以使用逆向思维:从最终状态逆序遍历,找出可以在6步之内到达最终状态的所有可行状态,应该是一样的 */ void bfs() { // 0-2^25-1(25个1),共2^25种状态 // 最终的状态 全都是 `11111` int x = (1 << 25) - 1; // 左移 右移的优先级是最低的,比加减还要低。所以这里的括号是必需的 queue q; q.push(x); step[x] = 0; // 记录这个状态是否出现过,如果出现过,是走几步出现的 while (q.size()) { auto t = q.front(); q.pop(); if (step[t] == 6) break; // 反向最多扩展6步 for (int i = 0; i < 25; i++) { x = turn(t, i); // u:当前状态,i:改变i这一位置,x:将此状态改变后的新状态 if (!step.count(x)) { // 过滤过出现过的状态,只要没有出现过的状态 step[x] = step[t] + 1; q.push(x); } } } } int main() { // 预处理 bfs(); int T; scanf("%d", &T); while (T--) { int g = 0; // g是地图 for (int i = 0; i < 25; i++) { char c; // 注意在scanf读取完一个整数,再次读取char时,要清空缓冲区,用" %c"解决 scanf(" %c", &c); g += ((c - '0') << i); // 25个字符二进制压缩成数字 } if (step.count(g) == 0) puts("-1"); else cout << step[g] << endl; } return 0; } ``` ### 四、递推解法 * 二进制枚举+递推可以解决这道题 $2^5\times 25 \times 5 \times 500$ 咋个递推法?递推嘛,顾名思义,就是一行一行的推! 比如现在给我们一个状态,我们先来看第一行,第一行共$5$个位置,每个位置是不是$0$就是$1$,每个位置都可能改变,改变的总数就是一个类似于二进制的所有组合情况,即$00000 \sim 11111$,这可不是真的让你把每个位置上的状态改变成上面的$00000 \sim 11111$,而是就$00000 \sim 11111$代表了每一种改变的办法,也就是哪个位置按下开关,哪个位置不按下开关,可别想错了。 按完会怎样呢?当然灯的状态会改变了!假设我们选择了一种按法,那么第一行的最终状态可能长成这个样子: ``` 11001 ``` 啊?改变完了出现了两个$0$,这能行啊?最终的答案是要全都是$1$,它现在有两个$0$,是不是不合法,不应该这么按呢?不是啊,因为人家还没有最后收工按完,还继续按,你怎么知道人家以后按完会不会把这两个$0$变成$1$?人家要是可以变回$1$,那就是一个合法状态嘛。 那他能变回$1$吗?有什么办法能把这两个$0$变回$1$呢?仔细读题,我们知道,这时只有一个办法:就是在下一行的同列位置,也就是第$2$行的第$3$列,第$4$列再次按下开关,就能把第一行的两个$0$变成$1$! 也就是说,当我们枚举出第一行的每一种变化(共$32$种变化)后,第二行就不能随意再按了,因为比如 ```cpp {.line-numbers} 11001 10111 ... ... ... ``` 第二行如果你按下了第一列,那么坏了,上面第一行第一列的$1$被你整没了! 第二行如果你按下了第二列,那么坏了,上面第一行第二列的$1$被你整没了! 而第二行第三列,第四列你就必须按下,要不上面第一行的两个$0$就没救了! 噢,我们似乎发现了什么规律: 当第一行确定了状态时,第二行的按法是唯一的,你想改都不行! 当你按唯一的办法按下第二时,我们来到第三行,此时发现,第三行的按法也是唯一的。 .... 当我们来到边界的第五行时,它就不一样了!为什么呢?因为它没有后继了,它要是不全是$1$,后来也没人帮它改了,所以,它必须全是$1$,如果不是的话,那么第一行枚举到的这个状态就是不合法状态。 那还有一个$6$次修改的限制呢?这个放在哪里进行限定呢? 就是每枚举一个状态,在每行不断变化开关时,记录下开关的次数,如果最后第五行的每一个位置都是$1$,并且,次数最少,而且,少于等于$6$次的就是答案。 ```cpp {.line-numbers} #include using namespace std; const int INF = 0x3f3f3f3f; const int N = 6; // char 数组版本 char g[N][N], bg[N][N]; // 工作的临时数组 和 原始状态数组 // 上右下左中 int dx[] = {-1, 0, 1, 0, 0}; int dy[] = {0, 1, 0, -1, 0}; // 按一下第x行第y列的开关 void turn(int x, int y) { for (int i = 0; i < 5; i++) { int tx = x + dx[i], ty = y + dy[i]; if (tx < 0 || tx >= 5 || ty < 0 || ty >= 5) continue; g[tx][ty] ^= 1; //'0':48 '1':49 // (48)_{10}=(110000)_{2} (49)_{10}=(110001)_{2} // 由于48的最后一位是0,而49最后一位是1,所以,异或就可以实现两者之间的切换,真是太神奇了~ } } int main() { int T; cin >> T; while (T--) { // 按一行的字符串读取 for (int i = 0; i < 5; i++) cin >> bg[i]; // 原来的字符状态数组 // 预求最小,先设最大 int res = INF; // 枚举所有可能的操作办法:00000,00001,00010,...,11111,共32种办法 for (int op = 0; op < (2 << 5); op++) { int cnt = 0; memcpy(g, bg, sizeof g); // 将原始状态复制出一份放入g数组,开始尝试 // 操作办法op,共5位,1代表修改,0代表不改,按二进制由右到左可以枚举出这种操作需要改哪几列 for (int i = 0; i < 5; i++) if (op >> i & 1) { turn(0, i); // 你想改就改吧~ cnt++; } // 第二行需要观察第一行,啊~我同位的上一行有一个0!这可是大事,因为我必须补上它,否则就没有人可以补上它了~ for (int i = 0; i < 4; i++) for (int j = 0; j < 5; j++) if (g[i][j] == '0') { turn(i + 1, j); // i+1行必须按下按钮 cnt++; } // 检查最后一行灯是否全亮.因为如果它还有0存在,就没机会补全了 bool success = true; for (int i = 0; i < 5; i++) if (g[4][i] == '0') success = false; if (success) res = min(res, cnt); // 如果成功全亮,那你是多少步呢? } // 题意要求,大于6次,算失败 if (res > 6) res = -1; cout << res << endl; } return 0; } ```