每日算法 — 使用java实现Lights Out:GF2高斯消元与状态空间最优解

游戏简介

Lights Out(熄灯游戏)是一款经典的电子益智游戏,由Tiger Electronics于1995年推出。游戏板是一个5×5的方格,每个格子包含一盏灯,灯要么亮着要么熄灭。玩家点击任意一个格子时,该格子及其上下左右相邻格子的灯状态都会翻转(亮变灭、灭变亮)。游戏目标是在最少的点击次数内将所有灯熄灭。

本文用Java实现Lights Out求解器,重点讲解GF(2)域上的高斯消元算法以及状态空间分析。

核心算法设计

1. 游戏板与状态表示

Lights Out的核心是一个二进制状态向量:每个灯只有亮(1)和灭(0)两种状态。一个5×5的棋盘可以用25位二进制数表示,或用长度为25的布尔数组表示。

public class LightsOutSolver {
    private static final int SIZE = 5;           // 棋盘尺寸
    private static final int N = SIZE * SIZE;    // 总灯数

    // 当前棋盘状态,true表示灯亮,false表示灯灭
    private boolean[] board;

    public LightsOutSolver() {
        this.board = new boolean[N];
    }

    /**
     * 从二维坐标(r,c)转换为一维索引
     */
    private int idx(int r, int c) {
        return r * SIZE + c;
    }

    /**
     * 初始化随机棋盘状态
     */
    public void randomizeBoard() {
        Random rand = new Random();
        for (int i = 0; i < N; i++) {
            board[i] = rand.nextBoolean();
        }
    }

    /**
     * 设置指定位置的灯状态
     */
    public void setLight(int r, int c, boolean on) {
        board[idx(r, c)] = on;
    }

    /**
     * 打印当前棋盘状态
     */
    public void printBoard() {
        for (int r = 0; r < SIZE; r++) {
            for (int c = 0; c < SIZE; c++) {
                System.out.print(board[idx(r, c)] ? "■ " : "□ ");
            }
            System.out.println();
        }
        System.out.println();
    }

    /**
     * 检查是否所有灯都已熄灭
     */
    public boolean isSolved() {
        for (boolean b : board) {
            if (b) return false;
        }
        return true;
    }
}

2. 点击操作与状态翻转

点击一个格子会翻转该格子及其四个相邻格子的状态。在GF(2)域上,翻转等价于与1进行异或运算。

public class LightsOutSolver {
    /**
     * 点击指定位置,翻转该位置及其相邻位置的状态
     * 在GF(2)中,翻转等价于 XOR 1
     * @param r 行坐标
     * @param c 列坐标
     */
    public void press(int r, int c) {
        // 翻转自身
        board[idx(r, c)] ^= true;

        // 翻转四个相邻方向
        int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
        for (int[] d : dirs) {
            int nr = r + d[0];
            int nc = c + d[1];
            if (nr >= 0 && nr < SIZE && nc >= 0 && nc < SIZE) {
                board[idx(nr, nc)] ^= true;
            }
        }
    }

    /**
     * 获取点击(r,c)对所有灯的影响向量
     * 返回长度为N的布尔数组,表示该操作会翻转哪些灯
     */
    public boolean[] getPressEffect(int r, int c) {
        boolean[] effect = new boolean[N];
        effect[idx(r, c)] = true;

        int[][] dirs = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
        for (int[] d : dirs) {
            int nr = r + d[0];
            int nc = c + d[1];
            if (nr >= 0 && nr < SIZE && nc >= 0 && nc < SIZE) {
                effect[idx(nr, nc)] = true;
            }
        }
        return effect;
    }
}

3. GF(2)高斯消元求解

Lights Out的核心数学模型是:将25个可能的点击操作视为25个未知变量,每个灯的最终状态是若干个操作影响之和(在GF(2)中即异或)。这构成了一个在二元域GF(2)上的线性方程组,可以用高斯消元求解。

系数矩阵A的每一行代表一盏灯,每一列代表一个点击操作。A[i][j] = 1 表示点击第j个位置会影响第i盏灯。

public class LightsOutSolver {
    /**
     * 使用GF(2)高斯消元求解Lights Out
     * @return 解向量,表示每个位置是否需要点击
     */
    public boolean[] solve() {
        // 构造增广矩阵:N行 × (N+1)列
        // 前N列是系数矩阵,最后一列是初始状态(目标全0)
        boolean[][] aug = new boolean[N][N + 1];

        // 填充系数矩阵:aug[i][j] = 1 表示点击位置j会影响灯i
        for (int r = 0; r < SIZE; r++) {
            for (int c = 0; c < SIZE; c++) {
                int col = idx(r, c); // 当前操作对应的列
                boolean[] effect = getPressEffect(r, c);
                for (int i = 0; i < N; i++) {
                    aug[i][col] = effect[i];
                }
            }
        }

        // 填充增广列:初始状态
        for (int i = 0; i < N; i++) {
            aug[i][N] = board[i];
        }

        // GF(2)高斯消元
        int rank = gaussianEliminationGF2(aug);

        // 检查是否有解(无矛盾方程)
        for (int i = rank; i < N; i++) {
            boolean allZero = true;
            for (int j = 0; j < N; j++) {
                if (aug[i][j]) {
                    allZero = false;
                    break;
                }
            }
            if (allZero && aug[i][N]) {
                return null; // 矛盾:0 = 1,无解
            }
        }

        // 回代求解
        boolean[] solution = new boolean[N];
        for (int i = rank - 1; i >= 0; i--) {
            // 找到主元列
            int pivot = -1;
            for (int j = 0; j < N; j++) {
                if (aug[i][j]) {
                    pivot = j;
                    break;
                }
            }
            if (pivot == -1) continue;

            // 计算该变量的值:x_pivot = b_i XOR (sum of other variables)
            boolean sum = aug[i][N];
            for (int j = pivot + 1; j < N; j++) {
                if (aug[i][j]) {
                    sum ^= solution[j];
                }
            }
            solution[pivot] = sum;
        }

        return solution;
    }

    /**
     * GF(2)域上的高斯消元,返回矩阵的秩
     * 消元过程:用异或代替加减法
     */
    private int gaussianEliminationGF2(boolean[][] aug) {
        int row = 0;
        for (int col = 0; col < N && row < N; col++) {
            // 在当前列中寻找主元(值为1的行)
            int pivotRow = -1;
            for (int i = row; i < N; i++) {
                if (aug[i][col]) {
                    pivotRow = i;
                    break;
                }
            }

            if (pivotRow == -1) {
                continue; // 该列没有主元,跳过
            }

            // 交换当前行与主元行
            boolean[] temp = aug[row];
            aug[row] = aug[pivotRow];
            aug[pivotRow] = temp;

            // 消去下方所有行的该列元素
            for (int i = row + 1; i < N; i++) {
                if (aug[i][col]) {
                    // 行i = 行i XOR 行row
                    for (int j = col; j <= N; j++) {
                        aug[i][j] ^= aug[row][j];
                    }
                }
            }
            row++;
        }
        return row;
    }
}

4. 寻找最少步数解

上述高斯消元找到的是一个特解。当方程组有自由变量时(即系数矩阵不满秩),可能存在多个解。我们需要在所有解中找到点击次数最少的一个。

对于5×5的Lights Out,系数矩阵的秩为23,存在2个自由变量,因此最多有4个解。我们可以通过枚举自由变量的所有组合来找到最优解。

public class LightsOutSolver {
    /**
     * 寻找最少点击次数的解
     * @return 最优解向量,如果无解返回null
     */
    public boolean[] solveMinClicks() {
        // 先求一个特解
        boolean[] particular = solve();
        if (particular == null) return null;

        // 对于5x5 Lights Out,矩阵秩为23,自由变量数为2
        // 基础解系最多有 2^2 = 4 个解
        // 这里简化处理:枚举所有可能的解空间

        boolean[] bestSolution = particular;
        int minClicks = countClicks(particular);

        // 寻找零空间中的基向量
        // 零空间向量z满足 A*z = 0 (mod 2)
        // 即:执行z中的点击操作不会改变任何灯的状态

        List<boolean[]> nullSpace = findNullSpace();

        // 枚举零空间中所有向量的组合(2^k种)
        int k = nullSpace.size();
        for (int mask = 0; mask < (1 << k); mask++) {
            boolean[] candidate = particular.clone();
            for (int i = 0; i < k; i++) {
                if ((mask & (1 << i)) != 0) {
                    // candidate = candidate XOR nullSpace[i]
                    for (int j = 0; j < N; j++) {
                        candidate[j] ^= nullSpace.get(i)[j];
                    }
                }
            }
            int clicks = countClicks(candidate);
            if (clicks < minClicks) {
                minClicks = clicks;
                bestSolution = candidate;
            }
        }

        return bestSolution;
    }

    /**
     * 计算解向量中的点击次数
     */
    private int countClicks(boolean[] solution) {
        int count = 0;
        for (boolean b : solution) {
            if (b) count++;
        }
        return count;
    }

    /**
     * 寻找系数矩阵的零空间基向量
     * 通过高斯消元的行最简形来确定自由变量
     */
    private List<boolean[]> findNullSpace() {
        List<boolean[]> basis = new ArrayList<>();

        // 构造系数矩阵
        boolean[][] A = new boolean[N][N];
        for (int r = 0; r < SIZE; r++) {
            for (int c = 0; c < SIZE; c++) {
                int col = idx(r, c);
                boolean[] effect = getPressEffect(r, c);
                for (int i = 0; i < N; i++) {
                    A[i][col] = effect[i];
                }
            }
        }

        // 高斯消元得到行最简形
        int[] where = new int[N]; // where[col] = 该列主元所在的行,-1表示自由变量
        Arrays.fill(where, -1);
        int row = 0;
        for (int col = 0; col < N && row < N; col++) {
            int sel = -1;
            for (int i = row; i < N; i++) {
                if (A[i][col]) {
                    sel = i;
                    break;
                }
            }
            if (sel == -1) continue;

            boolean[] tmp = A[sel];
            A[sel] = A[row];
            A[row] = tmp;
            where[col] = row;

            for (int i = 0; i < N; i++) {
                if (i != row && A[i][col]) {
                    for (int j = col; j < N; j++) {
                        A[i][j] ^= A[row][j];
                    }
                }
            }
            row++;
        }

        // 对于每个自由变量,构造一个零空间基向量
        for (int freeVar = 0; freeVar < N; freeVar++) {
            if (where[freeVar] == -1) {
                boolean[] vec = new boolean[N];
                vec[freeVar] = true;
                for (int col = 0; col < N; col++) {
                    if (where[col] != -1 && A[where[col]][freeVar]) {
                        vec[col] = true;
                    }
                }
                basis.add(vec);
            }
        }

        return basis;
    }
}

5. 交互式游戏与验证

public class LightsOutSolver {
    /**
     * 应用求解结果到当前棋盘,验证是否全部熄灭
     * @param solution 解向量
     * @return 是否成功求解
     */
    public boolean applySolution(boolean[] solution) {
        if (solution == null) return false;

        System.out.println("应用求解方案...");
        for (int i = 0; i < N; i++) {
            if (solution[i]) {
                int r = i / SIZE;
                int c = i % SIZE;
                press(r, c);
                System.out.println("点击 (" + r + "," + c + ")");
            }
        }

        return isSolved();
    }

    public static void main(String[] args) {
        LightsOutSolver game = new LightsOutSolver();

        System.out.println("=== Lights Out 熄灯游戏求解器 ===");
        System.out.println("初始状态:");
        game.randomizeBoard();
        game.printBoard();

        boolean[] solution = game.solveMinClicks();

        if (solution == null) {
            System.out.println("该状态无解!");
            return;
        }

        int clicks = 0;
        for (boolean b : solution) if (b) clicks++;
        System.out.println("最少需要点击 " + clicks + " 次");

        boolean solved = game.applySolution(solution);
        System.out.println("最终状态:");
        game.printBoard();
        System.out.println("求解" + (solved ? "成功" : "失败"));
    }
}

算法复杂度分析

操作 时间复杂度 空间复杂度
构造系数矩阵 O(N²) O(N²)
GF(2)高斯消元 O(N³) O(N²)
寻找零空间 O(N³) O(N²)
枚举最优解 O(2^k × N),k为自由变量数 O(N)
整体求解 O(N³) O(N²)

对于5×5棋盘(N=25),N³=15625,计算量极小,可瞬间完成求解。

扩展方向

  1. 变体规则:支持”点击翻转对角线邻居”或”点击翻转整行整列”的变体规则,只需修改系数矩阵的构造方式。
  2. 更大棋盘:8×8或N×M棋盘同样适用高斯消元,但时间复杂度变为O((NM)³)。
  3. 逆向问题:给定目标图案(如爱心形状),求解需要什么样的初始状态才能通过点击达到该图案。

小结

Lights Out熄灯游戏看似是一个需要穷举的谜题,但其本质是一个在GF(2)域上的线性方程组。通过高斯消元,我们可以在O(N³)时间内精确求解任意初始状态,而不需要任何启发式搜索。这一思想可以推广到所有”翻转类”谜题:只要翻转规则是线性的(即翻转两次等于不翻转),就可以用同样的线性代数框架求解。