每日算法 — 使用java实现熄灯游戏:状态压缩与高斯消元最优解搜索

熄灯游戏(Lights Out)是1995年Tiger Electronics推出的经典掌上逻辑益智游戏。一块由灯格组成的棋盘上,每盏灯初始随机亮灭;玩家点击任意灯格时,该格及其上下左右邻格的开关状态都会翻转。目标是在最少步数内将所有灯熄灭。看似简单的规则背后,隐藏着模2线性代数高斯消元的深刻数学结构。本文将用Java完整实现该游戏的自动求解引擎,从状态压缩到GF(2)域上的方程组求解逐层展开。

一、游戏规则与状态压缩

1.1 为什么可以压缩为位向量

标准的熄灯游戏采用 5×5 棋盘,共25盏灯。每盏灯只有”亮”(1)和”灭”(0)两种状态,天然适合用二进制位表示:

  • 棋盘状态 → 25位无符号整数(int 的低25位即可容纳)
  • 点击操作 → 同样25位掩码,第i位为1表示点击第i格

这种压缩带来巨大优势:状态的”异或”(XOR)操作直接对应灯的翻转逻辑,且所有运算可在CPU寄存器内完成。

1.2 棋盘与邻接掩码生成

将 5×5 棋盘按行优先展平为 0~24 的一维索引。对于每个位置 i,生成一个”影响掩码”:第 i 位及其上下左右邻位均为1。

import java.util.*;

/**
 * 熄灯游戏核心类
 * 棋盘大小可自定义,默认为经典的5×5
 */
class LightsOut {
    final int rows;
    final int cols;
    final int n;           // 总灯数 = rows * cols
    final int[] effect;    // effect[i] = 点击第i格时影响的位掩码

    LightsOut(int rows, int cols) {
        this.rows = rows;
        this.cols = cols;
        this.n = rows * cols;
        this.effect = new int[n];
        buildEffectMasks();
    }

    /**
     * 构建每个位置的影响掩码
     * 点击位置 (r,c) 会翻转自身及上下左右共5个邻格
     */
    private void buildEffectMasks() {
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                int idx = r * cols + c;
                int mask = 0;
                // 自身
                mask |= 1 << idx;
                // 上
                if (r > 0) mask |= 1 << ((r - 1) * cols + c);
                // 下
                if (r + 1 < rows) mask |= 1 << ((r + 1) * cols + c);
                // 左
                if (c > 0) mask |= 1 << (r * cols + (c - 1));
                // 右
                if (c + 1 < cols) mask |= 1 << (r * cols + (c + 1));
                effect[idx] = mask;
            }
        }
    }

    /**
     * 执行一次点击操作
     * @param board 当前棋盘状态(位掩码)
     * @param pos   点击的位置索引
     * @return 操作后的新状态
     */
    int press(int board, int pos) {
        return board ^ effect[pos];
    }

    /**
     * 批量执行一组点击(掩码形式)
     * @param board  当前棋盘状态
     * @param clicks 点击掩码,第i位为1表示点击第i格
     * @return 最终棋盘状态
     */
    int pressAll(int board, int clicks) {
        // 每个被点击的格都会施加其影响掩码
        int result = board;
        for (int i = 0; i < n; i++) {
            if ((clicks & (1 << i)) != 0) {
                result ^= effect[i];
            }
        }
        return result;
    }

    /**
     * 检查是否全部熄灭
     */
    boolean isAllOff(int board) {
        return board == 0;
    }

    /**
     * 打印棋盘状态(用于调试与演示)
     */
    void printBoard(int board) {
        for (int r = 0; r < rows; r++) {
            StringBuilder line = new StringBuilder();
            for (int c = 0; c < cols; c++) {
                int idx = r * cols + c;
                line.append((board & (1 << idx)) != 0 ? "● " : "○ ");
            }
            System.out.println(line.toString().trim());
        }
    }

    /**
     * 生成随机初始状态
     */
    int randomBoard(Random rand) {
        // 5×5 棋盘最多25位,int 完全容纳
        if (n >= 31) {
            int state = 0;
            for (int i = 0; i < n; i++) {
                if (rand.nextBoolean()) state |= (1 << i);
            }
            return state;
        }
        return rand.nextInt(1 << n);
    }
}

二、从游戏到线性方程组

2.1 数学建模

设棋盘有 n 盏灯,当前状态为向量 bb[i] = 1 表示第 i 盏灯亮)。设我们的点击方案为向量 xx[i] = 1 表示点击第 i 格)。因为每盏灯被翻转偶数次等于没翻、奇数次等于翻一次,所以所有运算都在 GF(2)(模2整数域)上进行。

对于第 i 盏灯,它被翻转的总次数等于所有影响它的点击操作之和(模2):

Σ A[i][j] * x[j] ≡ b[i]  (mod 2)

其中 A[i][j] = 1 表示点击第 j 格会影响第 i 盏灯。显然 A 就是上面生成的 effect 矩阵的转置。目标是让所有灯熄灭,即:

A * x ≡ b  (mod 2)

这是一个 n 元模2线性方程组,可用高斯消元求解。

2.2 GF(2) 高斯消元的特殊性

在普通实数域上,高斯消元涉及乘除法;而在 GF(2) 上:

  • 加法 = 异或(XOR)
  • 乘法 = 逻辑与(AND)
  • 没有除法,主元只能是1;若主元为0则找下方行交换
  • 消元时只需用 XOR 替代减法

这使得 GF(2) 上的高斯消元代码异常简洁,且可用位运算对整个行进行并行操作。

三、核心算法:GF(2) 高斯消元求解器

/**
 * GF(2) 域上的高斯消元求解器
 * 求解 A * x = b (mod 2)
 *
 * 增广矩阵有 n 行、n+1 列(最后一列为 b)
 * 每一行用一个 int[] 存储,每个 int 的每一位代表一列
 * 对于 n<=25 的棋盘,一行只需一个 int 即可容纳
 */
class Gf2Solver {

    /**
     * 求解结果封装
     */
    static class Solution {
        // 是否有解
        boolean solvable;
        // 一个特解(若存在)
        int particular;
        // 自由变量掩码:第i位为1表示x[i]是自由变量
        int freeMask;
        // 齐次解空间的基向量(每个向量是一个int掩码)
        List<Integer> nullspaceBasis;

        Solution() {
            nullspaceBasis = new ArrayList<>();
        }
    }

    /**
     * 对增广矩阵执行高斯消元并求解
     *
     * @param aug 增广矩阵,aug[row] 的第0~n-1位是A的行,第n位是b[row]
     * @param n   未知数个数(也是方程个数)
     * @return 求解结果,包含特解与零空间基
     */
    Solution solve(int[] aug, int n) {
        Solution sol = new Solution();
        // 复制矩阵避免修改外部数据
        int[] a = aug.clone();

        // rowForCol[col] = 该列主元所在的行号,-1表示自由变量
        int[] rowForCol = new int[n];
        Arrays.fill(rowForCol, -1);

        int row = 0;
        for (int col = 0; col < n && row < n; col++) {
            // 寻找主元:当前列中从row行开始第一个值为1的行
            int pivot = -1;
            for (int r = row; r < n; r++) {
                if (getBit(a[r], col)) {
                    pivot = r;
                    break;
                }
            }
            if (pivot == -1) {
                // 该列为自由变量
                continue;
            }

            // 交换当前行与主元行
            int tmp = a[row];
            a[row] = a[pivot];
            a[pivot] = tmp;
            rowForCol[col] = row;

            // 消去其他行中该列的1(GF(2)下用XOR)
            for (int r = 0; r < n; r++) {
                if (r != row && getBit(a[r], col)) {
                    a[r] ^= a[row];
                }
            }
            row++;
        }

        // 检查是否有矛盾方程 0 = 1
        for (int r = 0; r < n; r++) {
            boolean allZero = true;
            for (int c = 0; c < n; c++) {
                if (getBit(a[r], c)) {
                    allZero = false;
                    break;
                }
            }
            if (allZero && getBit(a[r], n)) {
                // 0 = 1,无解
                sol.solvable = false;
                return sol;
            }
        }

        sol.solvable = true;

        // 提取特解:主元变量取增广列的值,自由变量取0
        int particular = 0;
        for (int col = 0; col < n; col++) {
            if (rowForCol[col] != -1 && getBit(a[rowForCol[col]], n)) {
                particular |= (1 << col);
            } else if (rowForCol[col] == -1) {
                sol.freeMask |= (1 << col);
            }
        }
        sol.particular = particular;

        // 提取零空间基向量
        // 对每个自由变量,将其设为1、其余自由变量设为0,回代求得主元变量
        for (int freeCol = 0; freeCol < n; freeCol++) {
            if ((sol.freeMask & (1 << freeCol)) == 0) continue;

            int vec = (1 << freeCol); // 自由变量部分
            for (int col = 0; col < n; col++) {
                if (rowForCol[col] == -1) continue;
                // 看该主元行在 freeCol 处是否为1
                if (getBit(a[rowForCol[col]], freeCol)) {
                    vec |= (1 << col);
                }
            }
            sol.nullspaceBasis.add(vec);
        }

        return sol;
    }

    private boolean getBit(int val, int bit) {
        return (val & (1 << bit)) != 0;
    }
}

四、求解引擎:从棋盘到最少点击方案

4.1 为什么需要搜索零空间

高斯消元给出一个特解 x₀,但若方程组有自由变量,则通解为:

x = x₀ + k₁·v₁ + k₂·v₂ + ...  (mod 2)

其中 v₁, v₂, ... 是零空间的基向量。因为我们要找最少点击次数的方案,所以需要枚举所有 2^d 种组合(d 为零空间维数),统计每种解的1的位数(汉明重量),取最小者。

对于经典 5×5 熄灯游戏,d = 2,即最多只有 2² = 4 种候选解,枚举代价极低。

/**
 * 熄灯游戏求解引擎
 * 将游戏状态转化为线性方程组并求解最优点击方案
 */
class LightsOutSolver {
    private final LightsOut game;
    private final Gf2Solver solver;
    // 预计算好的系数矩阵增广形式(最后一列初始为0,实际求解时替换为b)
    private final int[] baseAug;

    LightsOutSolver(LightsOut game) {
        this.game = game;
        this.solver = new Gf2Solver();
        this.baseAug = buildAugmentedMatrix();
    }

    /**
     * 构建系数矩阵 A 的增广形式(暂不填充常数列 b)
     * A[i][j] = 1 表示点击第j格会影响第i盏灯
     */
    private int[] buildAugmentedMatrix() {
        int n = game.n;
        int[] aug = new int[n];
        for (int i = 0; i < n; i++) {
            // 第 i 行:哪些 effect 掩码包含第 i 位
            int row = 0;
            for (int j = 0; j < n; j++) {
                if ((game.effect[j] & (1 << i)) != 0) {
                    row |= (1 << j);
                }
            }
            aug[i] = row; // 最后一列(第n位)暂时为0
        }
        return aug;
    }

    /**
     * 求解给定棋盘状态的最优点击方案
     * @param board 当前棋盘位掩码
     * @return 最优点击掩码,若无解返回 -1
     */
    int solve(int board) {
        int n = game.n;
        int[] aug = baseAug.clone();
        // 填充增广列(第n位)
        for (int i = 0; i < n; i++) {
            if ((board & (1 << i)) != 0) {
                aug[i] |= (1 << n);
            }
        }

        Gf2Solver.Solution sol = solver.solve(aug, n);
        if (!sol.solvable) {
            return -1;
        }

        // 枚举零空间的所有组合,寻找点击次数最少的解
        int best = sol.particular;
        int bestCount = Integer.bitCount(best);

        int d = sol.nullspaceBasis.size();
        // 枚举 2^d 种线性组合
        for (int mask = 1; mask < (1 << d); mask++) {
            int candidate = sol.particular;
            for (int i = 0; i < d; i++) {
                if ((mask & (1 << i)) != 0) {
                    candidate ^= sol.nullspaceBasis.get(i);
                }
            }
            int cnt = Integer.bitCount(candidate);
            if (cnt < bestCount) {
                bestCount = cnt;
                best = candidate;
            }
        }

        return best;
    }

    /**
     * 将点击掩码转换为人类可读的位置列表
     */
    List<int[]> clicksToPositions(int clicks) {
        List<int[]> list = new ArrayList<>();
        for (int i = 0; i < game.n; i++) {
            if ((clicks & (1 << i)) != 0) {
                list.add(new int[]{i / game.cols, i % game.cols});
            }
        }
        return list;
    }
}

五、完整运行示例

public class LightsOutDemo {
    public static void main(String[] args) {
        Random rand = new Random(42);
        LightsOut game = new LightsOut(5, 5);
        LightsOutSolver solver = new LightsOutSolver(game);

        System.out.println("=== 熄灯游戏自动求解演示 ===\n");

        // 生成随机初始状态
        int board = game.randomBoard(rand);
        System.out.println("初始棋盘(●=亮,○=灭):");
        game.printBoard(board);
        System.out.println();

        // 求解
        int clicks = solver.solve(board);
        if (clicks == -1) {
            System.out.println("该状态无解!");
            return;
        }

        System.out.println("最优点击方案(共 " + Integer.bitCount(clicks) + " 步):");
        List<int[]> positions = solver.clicksToPositions(clicks);
        for (int[] p : positions) {
            System.out.println("  点击 (" + p[0] + ", " + p[1] + ")");
        }
        System.out.println();

        // 验证:执行所有点击后是否全灭
        int after = game.pressAll(board, clicks);
        System.out.println("执行点击后的棋盘:");
        game.printBoard(after);
        System.out.println("全部熄灭?" + game.isAllOff(after));
        System.out.println();

        // 演示2:构造一个已知有解的特殊图案
        System.out.println("=== 演示2:全亮棋盘 ===");
        int allOn = (1 << 25) - 1; // 25位全1
        game.printBoard(allOn);
        int clicks2 = solver.solve(allOn);
        System.out.println("最优解步数:" + Integer.bitCount(clicks2));
        int after2 = game.pressAll(allOn, clicks2);
        game.printBoard(after2);
        System.out.println("全部熄灭?" + game.isAllOff(after2));
        System.out.println();

        // 演示3:单次点击后的求解
        System.out.println("=== 演示3:仅中心一格亮 ===");
        int center = 1 << 12; // (2,2) 为中心
        game.printBoard(center);
        int clicks3 = solver.solve(center);
        System.out.println("最优解步数:" + Integer.bitCount(clicks3));
        int after3 = game.pressAll(center, clicks3);
        game.printBoard(after3);
        System.out.println("全部熄灭?" + game.isAllOff(after3));
    }
}

六、复杂度分析

操作 时间复杂度 空间复杂度 说明
影响掩码生成 O(rows × cols) O(n) n = rows×cols
构建系数矩阵 O(n²) O(n) 每行检查n个effect
GF(2)高斯消元 O(n³ / wordsize) O(n) 行操作可用位运算并行
零空间枚举 O(2^d × n) O(n) d为零空间维数,5×5时d=2
单次pressAll O(n) O(1) 遍历所有位

对于经典 5×5 棋盘(n = 25),高斯消元仅需处理 25×25 的二进制矩阵,现代CPU可在微秒级完成。即使扩展到 10×10(n = 100),毫秒级求解也绰绰有余。

七、延伸思考

  1. 变体规则:若点击只翻转邻格而不翻转自身,只需修改 buildEffectMasks 中去掉 mask |= 1 << idx 即可,数学框架完全不变。
  2. 高维扩展:三维熄灯游戏(灯格排成立方体)同样可建模为模2线性方程组,只是 n 增大导致矩阵更大。
  3. 最少步数下界:信息论下界为 log₂(n) / log₂(5) 步(每步最多影响5盏灯),可快速排除不可能解。
  4. 矩阵预求逆:若需要反复求解不同初始状态,可预计算 A 的伪逆矩阵,将每次求解降为 O(n²) 的矩阵向量乘法。
  5. 对称性约简:棋盘具有旋转与镜像对称性,求解时可利用Burnside引理减少重复计算。

八、总结

本文通过Java完整实现了熄灯游戏的自动求解引擎,核心展示了三个算法层面:

  • 状态压缩将二维棋盘压缩为位向量,使状态操作变为高效的位运算。
  • GF(2)线性建模将游戏规则转化为模2线性方程组,揭示出看似随机的翻转行为背后的代数结构。
  • 高斯消元与零空间枚举不仅能判定任意状态是否有解,还能在通解中找到点击次数最少的全局最优策略。

熄灯游戏是理解有限域上线性代数的绝佳入口,其求解思路也广泛应用于纠错码(LDPC解码)、密码学(线性反馈移位寄存器分析)与电路测试等领域。