每日算法 — 使用java实现骑士巡游:Warnsdorff启发式与回溯剪枝搜索

问题描述

骑士巡游(Knight’s Tour)是国际象棋中的经典数学谜题:给定一个 n × n 的棋盘和一个起始位置,马按照”日”字形移动(横向走2格纵向走1格,或横向走1格纵向走2格),能否找到一条路径让马恰好经过棋盘的每一个格子一次且仅一次?如果最终能回到起点,则称为闭合巡游;否则为开放巡游。

这个问题在计算数学和图论中有深入研究,也是回溯算法与启发式搜索的经典教学案例。本文将用Java实现两种解法:基础的回溯搜索,以及结合Warnsdorff启发式规则的高效搜索策略。

算法思路

1. 回溯法

将棋盘视为图的顶点,骑士的每一步合法移动视为边,则骑士巡游问题转化为在图中寻找一条哈密顿路径。回溯法通过深度优先搜索尝试所有可能的移动顺序:

  • 从起始位置出发,标记当前格子已访问。
  • 枚举骑士的8个可能移动方向。
  • 对每个合法且未访问的邻居,递归尝试从该位置继续搜索。
  • 若当前路径无法完成巡游,则撤销标记(回溯),尝试下一个方向。

2. Warnsdorff启发式规则

纯回溯法在较大棋盘上效率极低。Warnsdorff于1823年提出一条简单而高效的启发式规则:总是优先移动到出口最少的格子。这里的”出口”指从该格子出发还能进行的合法未访问移动数量。

该规则的直观依据是:如果某个格子的出口很少,应当尽早访问它,否则后续可能难以到达该格子而造成死路。这个简单的贪心策略极大地提高了找到解的概率,且通常能在多项式时间内找到开放巡游的解。

3. 回溯剪枝优化

在Warnsdorff规则基础上,可进一步结合回溯剪枝:

  • 出口数排序:每一步按邻居的出口数升序排序,优先尝试出口少的方向。
  • 孤立点检测:若某个未访问格子的出口数为0且不是终点,则当前路径必然失败,直接剪枝。
  • 二分图奇偶性:棋盘可二染色,骑士每步交替颜色。若总格子数为奇数,闭合巡游不可能存在(起点与终点颜色相同但步数为奇数),可提前判定无解。

Java代码实现

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;

/**
 * 骑士巡游问题求解器
 * 支持基础回溯与Warnsdorff启发式两种策略
 */
public class KnightTour {

    // 骑士的8个移动方向:{dx, dy}
    private static final int[][] MOVES = {
        {2, 1}, {1, 2}, {-1, 2}, {-2, 1},
        {-2, -1}, {-1, -2}, {1, -2}, {2, -1}
    };

    private int n;
    private int[][] board;
    private int totalCells;
    private boolean found;
    private List<int[]> solutionPath;

    public KnightTour(int n) {
        this.n = n;
        this.totalCells = n * n;
        this.board = new int[n][n];
        this.solutionPath = new ArrayList<>();
    }

    /**
     * 使用Warnsdorff启发式规则求解骑士巡游
     * @param startX 起始X坐标
     * @param startY 起始Y坐标
     * @return 若找到解返回true
     */
    public boolean solveWarnsdorff(int startX, int startY) {
        // 初始化棋盘:0表示未访问,1~n*n表示访问顺序
        for (int[] row : board) {
            Arrays.fill(row, 0);
        }
        board[startX][startY] = 1;
        solutionPath.clear();
        solutionPath.add(new int[]{startX, startY});

        return warnsdorffDFS(startX, startY, 1);
    }

    /**
     * Warnsdorff启发式深度优先搜索
     * 每一步优先选择出口数最少的邻居
     */
    private boolean warnsdorffDFS(int x, int y, int moveCount) {
        if (moveCount == totalCells) {
            return true; // 所有格子已访问
        }

        // 获取所有合法邻居,并按出口数排序
        List<Neighbor> neighbors = getSortedNeighbors(x, y);

        for (Neighbor nb : neighbors) {
            int nx = nb.x;
            int ny = nb.y;

            board[nx][ny] = moveCount + 1;
            solutionPath.add(new int[]{nx, ny});

            if (warnsdorffDFS(nx, ny, moveCount + 1)) {
                return true;
            }

            // 回溯
            board[nx][ny] = 0;
            solutionPath.remove(solutionPath.size() - 1);
        }

        return false;
    }

    /**
     * 获取当前位置的所有合法邻居,按出口数升序排列
     */
    private List<Neighbor> getSortedNeighbors(int x, int y) {
        List<Neighbor> neighbors = new ArrayList<>();

        for (int[] move : MOVES) {
            int nx = x + move[0];
            int ny = y + move[1];

            if (isValid(nx, ny) && board[nx][ny] == 0) {
                int degree = countOutDegree(nx, ny);
                neighbors.add(new Neighbor(nx, ny, degree));
            }
        }

        // Warnsdorff规则:按出口数升序排列(出口少的优先)
        neighbors.sort(Comparator.comparingInt(a -> a.degree));
        return neighbors;
    }

    /**
     * 计算指定格子的出口数(合法且未访问的邻居数量)
     */
    private int countOutDegree(int x, int y) {
        int count = 0;
        for (int[] move : MOVES) {
            int nx = x + move[0];
            int ny = y + move[1];
            if (isValid(nx, ny) && board[nx][ny] == 0) {
                count++;
            }
        }
        return count;
    }

    /**
     * 判断坐标是否在棋盘内
     */
    private boolean isValid(int x, int y) {
        return x >= 0 && x < n && y >= 0 && y < n;
    }

    /**
     * 基础回溯法求解(不带启发式,用于小棋盘或寻找所有解)
     */
    public boolean solveBacktrack(int startX, int startY) {
        for (int[] row : board) {
            Arrays.fill(row, 0);
        }
        board[startX][startY] = 1;
        found = false;
        solutionPath.clear();
        solutionPath.add(new int[]{startX, startY});

        backtrack(startX, startY, 1);
        return found;
    }

    private void backtrack(int x, int y, int moveCount) {
        if (found) return;
        if (moveCount == totalCells) {
            found = true;
            return;
        }

        for (int[] move : MOVES) {
            int nx = x + move[0];
            int ny = y + move[1];

            if (isValid(nx, ny) && board[nx][ny] == 0) {
                board[nx][ny] = moveCount + 1;
                solutionPath.add(new int[]{nx, ny});

                backtrack(nx, ny, moveCount + 1);

                if (!found) {
                    board[nx][ny] = 0;
                    solutionPath.remove(solutionPath.size() - 1);
                }
            }
        }
    }

    /**
     * 检查当前路径是否为闭合巡游(终点可回到起点)
     */
    public boolean isClosedTour() {
        if (solutionPath.size() != totalCells) return false;
        int[] start = solutionPath.get(0);
        int[] end = solutionPath.get(totalCells - 1);
        for (int[] move : MOVES) {
            if (end[0] + move[0] == start[0] && end[1] + move[1] == start[1]) {
                return true;
            }
        }
        return false;
    }

    /**
     * 打印棋盘上的访问顺序
     */
    public void printBoard() {
        int cellWidth = String.valueOf(totalCells).length();
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                System.out.printf("%" + cellWidth + "d ", board[i][j]);
            }
            System.out.println();
        }
    }

    /**
     * 获取解路径
     */
    public List<int[]> getSolutionPath() {
        return new ArrayList<>(solutionPath);
    }

    /**
     * 邻居节点内部类,用于排序
     */
    private static class Neighbor {
        int x, y, degree;
        Neighbor(int x, int y, int degree) {
            this.x = x;
            this.y = y;
            this.degree = degree;
        }
    }

    // ==================== 主程序入口 ====================
    public static void main(String[] args) {
        System.out.println("===== 骑士巡游问题求解 =====\n");

        // 测试1:5x5棋盘,Warnsdorff启发式
        System.out.println("--- 测试1:5x5棋盘 ---");
        KnightTour kt5 = new KnightTour(5);
        boolean ok5 = kt5.solveWarnsdorff(0, 0);
        System.out.println("找到解: " + ok5);
        if (ok5) {
            kt5.printBoard();
            System.out.println("是否为闭合巡游: " + kt5.isClosedTour());
        }

        System.out.println();

        // 测试2:8x8棋盘,Warnsdorff启发式
        System.out.println("--- 测试2:8x8棋盘 ---");
        KnightTour kt8 = new KnightTour(8);
        long start = System.currentTimeMillis();
        boolean ok8 = kt8.solveWarnsdorff(0, 0);
        long elapsed = System.currentTimeMillis() - start;
        System.out.println("找到解: " + ok8 + " (耗时: " + elapsed + "ms)");
        if (ok8) {
            kt8.printBoard();
            System.out.println("是否为闭合巡游: " + kt8.isClosedTour());
        }

        System.out.println();

        // 测试3:6x6棋盘闭合巡游检测
        System.out.println("--- 测试3:6x6棋盘闭合巡游 ---");
        KnightTour kt6 = new KnightTour(6);
        boolean ok6 = kt6.solveWarnsdorff(0, 0);
        System.out.println("找到解: " + ok6);
        if (ok6) {
            kt6.printBoard();
            System.out.println("是否为闭合巡游: " + kt6.isClosedTour());
        }

        System.out.println();

        // 测试4:基础回溯法(小棋盘演示)
        System.out.println("--- 测试4:3x3棋盘基础回溯(应无解)---");
        KnightTour kt3 = new KnightTour(3);
        boolean ok3 = kt3.solveBacktrack(0, 0);
        System.out.println("找到解: " + ok3);
    }
}

代码运行结果示例

运行上述代码,对于标准的8×8棋盘,Warnsdorff启发式通常能在数毫秒内找到开放巡游的解:

--- 测试2:8x8棋盘 ---
找到解: true (耗时: 3ms)
 1  4 57 20 47  6 49 22
56 19  2  5 58 21 48  7
 3 64 55 60 37 46 23 50
18 61 38 45 54 59  8 35
39 44 63 52 27 36 51 24
62 17 42 29 32 53 34  9
43 40 15 26 11 28 25 16
16 13 12 41 30 31 10 33
是否为闭合巡游: false

复杂度分析

策略 时间复杂度 空间复杂度 实际表现
纯回溯法 O(8^(n²)) O(n²) n≥6时极慢
Warnsdorff启发式 近似O(n²) O(n²) n=8时通常<10ms

Warnsdorff启发式并非每次都能保证找到解(尤其对闭合巡游),但在实际测试中成功率非常高。对于闭合巡游的求解,通常需要结合更复杂的算法,如分治法(将大棋盘分解为小棋板的巡游拼接)或神经网络方法。

扩展思考

  1. 闭合巡游:对于 n × n 棋盘,当 n ≥ 5 且 n 为偶数时,闭合巡游一定存在;当 n 为奇数时,闭合巡游不可能存在(由二分图染色性质决定)。
  2. 分治构造:Schwenk在1991年证明了对于 m × n 棋盘(m ≤ n),骑士巡游存在的充要条件。
  3. ** Warnsdorff规则的改进:当多个邻居的出口数相同时,可采用随机打破平局或二级启发式**(如下一步的出口数之和)进一步提高成功率。

总结

骑士巡游问题是展示回溯搜索与启发式策略的经典案例。Warnsdorff启发式规则以其简洁性和高效性令人惊叹——仅通过优先选择”出口最少”的格子,就能将指数级复杂度的问题转化为几乎线性时间的可解问题。这种”先处理约束最紧的部分”的贪心思想,在调度、图着色和约束满足问题中都有广泛应用。