递推算法题:令人费解的开关『拉灯』

467 阅读4分钟

N.Tho.Duc @ unsplash.com

题目来源 AcWing

题目

你玩过“拉灯”游戏吗?

2525 盏灯排成一个 5×55 \times 5 的方形。

每一个灯都有一个开关,游戏者可以改变它的状态。

每一步,游戏者可以改变某一个灯的状态。

游戏者改变一个灯的状态会产生连锁反应:和这个灯上下左右相邻的灯也要相应地改变其状态。

我们用数字 11 表示一盏开着的灯,用数字 00 表示关着的灯。

下面这种状态

10111
01101
10111
10000
11011

在改变了最左上角的灯的状态后将变成:

01111
11101
10111
10000
11011

再改变它正中间的灯后状态将变成:

01111
11001
11001
10100
11011

给定一些游戏的初始状态,编写程序判断游戏者是否可能在 66 步以内使所有的灯都变亮。

输入格式

第一行输入正整数 nn,代表数据中共有 nn 个待解决的游戏初始状态。

以下若干行数据分为 nn 组,每组数据有 55 行,每行 55 个字符。

每组数据描述了一个游戏的初始状态。

各组数据间用一个空行分隔。

输出格式

一共输出 nn 行数据,每行有一个小于等于 66 的整数,它表示对于输入数据中对应的游戏状态最少需要几步才能使所有灯变亮。

对于某一个游戏初始状态,若 66 步以内无法使所有灯变亮,则输出 1-1

数据范围

  • 0n5000 \le n \le 500

输入样例:

3
00111
01011
10001
11010
11100

11101
11101
11110
11111
11111

01111
11111
11111
11111
11111

输出样例:

3
2
-1

题解

首先有三点很重要的性质需要说明:

  • 如果按哪些灯确定了,那么按这些灯的顺序不重要,无论什么顺序,结果都是相同的
  • 我们没有必要按一盏灯两次及以上,因为,按两次,相当于没按,按三次,相当于按两次+一次(也就是一次)

因此:

  • 因为按灯的顺序不重要,我们可以先把第一行的灯都按了
  • 我们发现,第一行想按的灯都按过之后,如果想要让第一行全亮,那么我第二行只能有一种按法,就是按第一行不亮的灯的下面的灯(下面是例子)
第一行状态 10011 (1代表亮的灯)
第二行动作 01100 (1代表按按钮)

那么,我们怎么保证第二行全亮呢?只能用第三行来解决!

那么,我们怎么保证最后一行(第五行)全亮呢?没法保证!

我们发现,如果第一行按法确定了,那么接下来二三四五行的按法和能不能全亮就确定了。

因此,对于任意一种输入状态,我们把第一行 32 种按法全部遍历一遍,看看哪些可以全亮(通过检测第五行状态),这些全亮的种有没有操作次数小于等于 6 的。有的话,就返回这个操作数,否则就返回 -1 呗。

代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;

const int N = 5 + 5;   // 加上 5 更保险
// 注意接收时用字符串更方便,因为输入流每行没有空格
char g[N][N];  // 记录灯目前的情况

// 上右下左中
int dx[5] = {0, 1, 0, -1, 0};
int dy[5] = {1, 0, -1, 0, 0};

// 按第 x 行第 y 列,本身和上下左右五个灯都取反
void turn(int x, int y)
{
    for (int i = 0; i < 5; ++ i)
    {
        int a = x + dx[i], b = y + dy[i];
        if (0 <= a && a < 5 && 0 <= b && b < 5)
            g[a][b] = g[a][b] == '1' ? '0': '1';
    }
}

int work()
{
    int ans = 2e9;
    // 第一层循环,把所有第一行按的情况都遍历
    // k 是被压缩了的状态,最小 0b00000 代表都不按,
    // 最大 0b11111 代表都按
    
    // 备份,因为下面的操作会改变 g
    char backup[N][N];
    memcpy(backup, g, sizeof g);

    for (int k = 0; k < (1 << 5); ++ k)
    {
        // 确保我们的 g 是输入的 g
        memcpy(g, backup, sizeof backup);

        // 当第一行为 k 时,总操作次数是..
        int res = 0;  // 用 res 来记录

        // 执行 k (根据 k 把第一行按了)
        for (int j = 0; j < 5; ++ j)
        {
            if (k >> j & 1)
            {
                res ++;
                turn(0, j);
            }
        }
        
        // 第一行确定了,第二行就确定了
        // 因为只有合理操作第二行
        // 才能把第一行全部点亮
        // 以此类推,第二行定了后,第三行就被第二行决定了
        for (int i = 0; i < 4; ++ i)
        {
            for (int j = 0; j < 5; ++ j)
            {
                if (g[i][j] == '0')
                {
                    res ++;
                    turn(i + 1, j);
                }
            }
        }

        // 上面的操作一定能保证前 4 行全亮
        // 但是第 5 行不一定全亮,第 5 行全亮,才是真正有效的操作
        bool success = true;
        for (int j = 0; j < 5; ++ j)
        {
            if (g[4][j] == '0')
            {
                success = false;
                break;
            }
        }
        
        // 如果是有效的操作,咱看看一共按了几次开关
        if (success) ans = min(res, ans);
    }
    
    // 根据题意返回输出值
    if (ans > 6) return -1;
    return ans;
}

int main()
{
    int n;
    cin >> n;
    while (n -- )
    {
        for (int i = 0; i < 5; ++ i) cin >> g[i];
        printf("%d\n", work());
    }
}