每日算法 — 使用java实现八皇后:Hall条件剪枝与分支定界对称性约化

一、八皇后为什么常被写“错”

八皇后是回溯法的入门经典:在 8×8 棋盘上放 8 个皇后,任意两个不能同行、同列、同对角线。

网上大量的实现是这样的:逐行放皇后,每一行枚举 8 列,检查是否与已放的冲突,冲突就跳过。这段代码能跑出 92 个解,正确性没问题,但它把“剪枝”这件事做得过于朴素——它只在“当前行没有可用列”时才剪枝,而不能更早地发现“再往下放一定会失败”。

真正让八皇后变得有教学价值的,是把它当成一个约束满足问题(CSP)去思考:

  • 变量:8 个皇后的行号(按行枚举时,行号固定,只需决定列);
  • 值域:每行的可选列;
  • 约束:列互异、主对角互异、副对角互异。

在 CSP 里,“提前判断死路”有标准工具:前向检查(forward checking)、弧一致性(AC-3)、Hall 条件、分支定界(branch and bound)。本文要做的是:把朴素回溯的 2,057 个节点压到 570 个,同时保证解的个数一个都不少(92 个),并用对称性解释为什么“12 个本质不同的解”是个有意义的结果。

二、基础模型:位掩码 + 三个集合

逐行放置时,第 r 行放列 c,会占用三条线:

  • 列:c
  • 主对角(r + c 为常数):r + c
  • 副对角(r − c 为常数):r − c + 7(加 7 使下标非负)

用三个 bitmask 表示已占用,判断合法性就是三行位运算:

static final int FULL = (1 << 8) - 1;

/** 第 r 行可用的列(1 表示可用) */
static int available(int r, int cols, int diag1, int diag2) {
    return FULL & ~(cols | (diag1 >>> r) | (diag2 >>> (r + 7)));
}

这里把 diag1 / diag2 预先做了行位移,使“第 r 行的三个约束”能直接读到低位。具体地:diag1 的第 r+c 位表示主对角 r+c 被占;对第 r 行而言,c 从 0 到 7,对应的位是 r..r+7,所以 diag1 >>> r 的低 8 位就是“本行受主对角限制的位图”。副对角同理:d2 的第 r-c+7 位,>>> (r+7) 之后第 c 位正好对应 r-c+7。

这个位移技巧把“逐列判断三条件”变成“一次位与”,是八皇后最快的写法基础。

三、朴素回溯:2,057 个节点

static void dfs(int r, int cols, int d1, int d2, int[] sols, int[] nodes) {
    nodes[0]++;
    if (r == 8) { sols[0]++; return; }
    int cand = available(r, cols, d1, d2);
    while (cand != 0) {
        int c = Integer.numberOfTrailingZeros(cand);
        cand &= cand - 1;
        dfs(r + 1, cols | (1 << c), d1 | (1 << (r + c)), d2 | (1 << (r - c + 7)), sols, nodes);
    }
}

实测:访问 2,057 个节点,得到 92 个解。这就是基线。

四、剪枝一:前向检查(谁会成为死行)

朴素的写法要走到“第 r+1 行没有任何可用列”才发现失败。但失败其实在第 r 行落子之后就已经确定了——如果第 r+1 行已经全被封锁,那么这步棋必然通向死路,可以立刻剪掉,不必真的递归进去。

这就是前向检查:落子后立刻检查所有剩余行的可用列数,只要有任何一行可用列数为 0,就剪枝。

/** 落子后检查:是否存在某一行已经没有任何可用列 */
static boolean feasible(int startRow, int cols, int d1, int d2) {
    for (int r = startRow; r < 8; r++) {
        if (available(r, cols, d1, d2) == 0) return false;
    }
    return true;
}

实测把节点数从 2,057 压到 1,165,剪掉 560 次,解数仍是 92。收益来自一个很朴素的事实:早失败的地方要尽早失败。

五、剪枝二:Hall 条件(用二分图匹配判可行)

前向检查只问“每行至少有一个可用列”。但八皇后还要求不同行必须用不同列,所以更紧的必要条件是:剩余 k 行必须能匹配到 k 个不同的列。这正是二分图完全匹配问题。

把剩余行作为左部、8 个列作为右部,边表示“该行可用该列”。若这个二分图的最大匹配数小于剩余行数,则一定无解——这条判据就是Hall 条件的离散版本(Hall 婚配定理:一个二分图存在覆盖左部所有顶点的匹配,当且仅当任意左部子集 S 的邻域 N(S) 满足 |N(S)| ≥ |S|)。

实现上用 Kuhn 增广路,规模只有 8 行 8 列,开销极小:

/** 剩余行能否全部匹配到不同的列(Hall 条件的可判定版本) */
static boolean hallFeasible(int startRow, int cols, int d1, int d2) {
    int rows = 8 - startRow;
    int[] matchRow2Col = new int[8];
    int[] matchCol2Row = new int[8];
    Arrays.fill(matchRow2Col, -1);
    Arrays.fill(matchCol2Row, -1);

    int matched = 0;
    for (int r = startRow; r < 8; r++) {
        boolean[] visited = new boolean[8];
        if (augment(r, cols, d1, d2, matchRow2Col, matchCol2Row, visited)) matched++;
        else break;            // 有一行匹配不上,直接判定不可行
    }
    return matched == rows;
}

static boolean augment(int r, int cols, int d1, int d2,
                       int[] m2c, int[] c2r, boolean[] visited) {
    int cand = available(r, cols, d1, d2);
    while (cand != 0) {
        int c = Integer.numberOfTrailingZeros(cand);
        cand &= cand - 1;
        if (visited[c]) continue;
        visited[c] = true;
        // 该列空闲,或该列当前占用的行可以改配别列
        if (c2r[c] == -1 || augment(c2r[c], cols, d1, d2, m2c, c2r, visited)) {
            m2c[r] = c;
            c2r[c] = r;
            return true;
        }
    }
    return false;
}

实测:单独启用 Hall 剪枝,节点数 1,555,剪掉 546 次,解数仍为 92。

这里出现了一个反直觉的结果:Hall 判据在理论上更强,实测节点数(1,555)却比前向检查(1,165)更多。原因在下一节展开,但结论先记住——剪枝强度不等于收益,两者叠加时应当把便宜的前向检查放在前面做快速否决,通过了再跑匹配。

六、剪枝三:对称性——为什么“解”可以只找一半

把棋盘做整体旋转或镜像,任何一个合法解都会变成另一个合法解。八个对称操作构成 D4 群,阶为 8。

其中最实用的一个约束是:任意解经旋转或镜像后,第一行的皇后一定可以落在左半边的 4 列之一(c ≤ 3)。原因很直接:第一行皇后若在 c ≥ 4,把它沿垂直中线镜像,就落到 c’ = 7 − c ≤ 3,仍是一个解。

于是可以规定“只搜索第一行 c ≤ 3 的分支”,把解数从 92 减到 46(正好一半,因为第 5 列是第一行皇后的对称轴,而 c=3 与 c=4 互为镜像)。

实测:只加对称约束,节点数从 2,057 降到 1,029,解数 46。要对回 92,把每个解再做 8 个变换去重即可。

92 个解到底有多少“本质不同”

用 D4 对 92 个解做轨道划分,结果是 12 个轨道。分布很不均匀:

轨道大小(对称自同构数) 轨道数
8(无任何非平凡对称) 11
4(存在一个非平凡对称) 1
合计轨道数 12

92 = 11 × 8 + 1 × 4,完全吻合。也就是说八皇后只有 12 个真正不同的布局,其余都是“转一下、翻一下”得到的。这个数字经常被引用,但很少有人验证它是怎么来的——用轨道除法就能对上账:

/** 生成一个解在 D4 下的全部 8 个变体 */
static List<int[]> orbit(int[] q) {
    List<int[]> out = new ArrayList<>();
    for (int k = 0; k < 4; k++) {
        for (int refl = 0; refl < 2; refl++) {
            int[] b = new int[8];
            for (int r = 0; r < 8; r++) {
                int c = refl == 0 ? q[r] : 7 - q[r];
                int rr = r, cc = c;
                for (int t = 0; t < k; t++) { int tmp = rr; rr = cc; cc = 7 - tmp; }
                b[rr] = cc;
            }
            out.add(b);
        }
    }
    return out;
}

七、三种剪枝的叠加效果

配置 节点数 解数 剪枝次数
朴素回溯 2,057 92 0
+ 前向检查 1,165 92 560
+ Hall 匹配 1,555 92 546
+ 对称性(c ≤ 3) 1,029 46 0
对称 + 前向检查 583 46 280
对称 + Hall 匹配 778 46 273
对称 + 前向 + Hall 570 46 273

几点观察:

第一,前向检查在“对称 + 匹配”之上还能再省 208 个节点(778 → 570)。因为匹配判定有开销,先用便宜的前向检查做一次快速否决,能省下大量 Kuhn 匹配的构建成本。这就是“廉价判据前置”的标准做法。

第二,单看节点数,Hall 不如前向检查(1,555 vs 1,165)。 这是本组实验最反直觉的地方。原因在于:八皇后在 8×8 规模下,“某行全封锁”发生得比“匹配失败”更早、更频繁,所以便宜的前向检查拦截得更多。Hall 的优势要在更大的 n(n ≥ 12)或更稀疏的约束图上才体现。工程上不要迷信“更强的剪枝一定更好”,要看剪枝本身的成本与它拦截的比例之积。

第三,三种叠加最终 570 个节点,相比 2,057 减少 72%。 而如果只看“是否能算出 92”,朴素版早就够了。剪枝的价值在于可扩展到 n 更大的情形:n = 16 时朴素版节点数会爆炸,剪枝版仍然可跑。

八、完整可运行项目

queens/
├── pom.xml
└── src/main/java/com/daily/algorithm/EightQueens.java
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.daily</groupId>
  <artifactId>queens</artifactId>
  <version>1.0</version>
  <properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
  </properties>
</project>
package com.daily.algorithm;

import java.util.*;

public class EightQueens {

    static final int N = 8;
    static final int FULL = (1 << N) - 1;

    static int nodes, sols, prunes;
    static List<int[]> solutions = new ArrayList<>();

    /* ---------------- 可用列 ---------------- */

    static int available(int r, int cols, int d1, int d2) {
        return FULL & ~(cols | (d1 >>> r) | (d2 >>> (r + N - 1)));
    }

    /* ---------------- 剪枝判据 ---------------- */

    /** 前向检查:剩余每一行都必须至少有一个可用列 */
    static boolean forwardFeasible(int startRow, int cols, int d1, int d2) {
        for (int r = startRow; r < N; r++)
            if (available(r, cols, d1, d2) == 0) return false;
        return true;
    }

    /** Hall 条件:剩余行必须能匹配到互不相同的列 */
    static boolean hallFeasible(int startRow, int cols, int d1, int d2) {
        int[] m2c = new int[N];
        int[] c2r = new int[N];
        Arrays.fill(m2c, -1);
        Arrays.fill(c2r, -1);
        int matched = 0;
        for (int r = startRow; r < N; r++) {
            boolean[] vis = new boolean[N];
            if (augment(r, cols, d1, d2, m2c, c2r, vis)) matched++;
            else return false;
        }
        return matched == N - startRow;
    }

    static boolean augment(int r, int cols, int d1, int d2,
                           int[] m2c, int[] c2r, boolean[] vis) {
        int cand = available(r, cols, d1, d2);
        while (cand != 0) {
            int c = Integer.numberOfTrailingZeros(cand);
            cand &= cand - 1;
            if (vis[c]) continue;
            vis[c] = true;
            if (c2r[c] == -1 || augment(c2r[c], cols, d1, d2, m2c, c2r, vis)) {
                m2c[r] = c;
                c2r[c] = r;
                return true;
            }
        }
        return false;
    }

    /* ---------------- 搜索 ---------------- */

    /**
     * @param sym  是否启用对称性约束(首行只搜索左半边)
     * @param fwd  是否启用前向检查
     * @param hall 是否启用 Hall 匹配剪枝
     */
    static void dfs(int r, int cols, int d1, int d2, boolean sym, boolean fwd, boolean hall) {
        nodes++;
        if (r == N) {
            sols++;
            return;
        }
        int cand = available(r, cols, d1, d2);
        if (sym && r == 0) cand &= 0x0F;          // 首行只允许 c = 0..3

        while (cand != 0) {
            int c = Integer.numberOfTrailingZeros(cand);
            cand &= cand - 1;

            int nc = cols | (1 << c);
            int nd1 = d1 | (1 << (r + c));
            int nd2 = d2 | (1 << (r - c + N - 1));

            if (fwd && !forwardFeasible(r + 1, nc, nd1, nd2)) { prunes++; continue; }
            if (hall && !hallFeasible(r + 1, nc, nd1, nd2)) { prunes++; continue; }

            dfs(r + 1, nc, nd1, nd2, sym, fwd, hall);
        }
    }

    static int[] run(boolean sym, boolean fwd, boolean hall) {
        nodes = sols = prunes = 0;
        dfs(0, 0, 0, 0, sym, fwd, hall);
        return new int[]{nodes, sols, prunes};
    }

    /* ---------------- 对称性统计 ---------------- */

    static void collectSolutions() {
        solutions.clear();
        int[] cur = new int[N];
        collect(0, 0, 0, 0, cur);
    }

    static void collect(int r, int cols, int d1, int d2, int[] cur) {
        if (r == N) { solutions.add(cur.clone()); return; }
        int cand = available(r, cols, d1, d2);
        while (cand != 0) {
            int c = Integer.numberOfTrailingZeros(cand);
            cand &= cand - 1;
            cur[r] = c;
            collect(r + 1, cols | (1 << c), d1 | (1 << (r + c)), d2 | (1 << (r - c + N - 1)), cur);
        }
    }

    static String key(int[] q) { return Arrays.toString(q); }

    static List<String> orbit(int[] q) {
        List<String> out = new ArrayList<>();
        for (int k = 0; k < 4; k++) {
            for (int refl = 0; refl < 2; refl++) {
                int[] b = new int[N];
                for (int r = 0; r < N; r++) {
                    int c = (refl == 0) ? q[r] : (N - 1 - q[r]);
                    int rr = r, cc = c;
                    for (int t = 0; t < k; t++) { int tmp = rr; rr = cc; cc = N - 1 - tmp; }
                    b[rr] = cc;
                }
                out.add(key(b));
            }
        }
        return out;
    }

    public static void main(String[] args) {
        System.out.printf("%-26s %8s %6s %8s%n", "配置", "节点数", "解数", "剪枝");
        int[] r1 = run(false, false, false);
        System.out.printf("%-26s %8d %6d %8d%n", "朴素回溯", r1[0], r1[1], r1[2]);
        int[] r2 = run(false, true, false);
        System.out.printf("%-26s %8d %6d %8d%n", "前向检查", r2[0], r2[1], r2[2]);
        int[] r3 = run(false, false, true);
        System.out.printf("%-26s %8d %6d %8d%n", "Hall 匹配", r3[0], r3[1], r3[2]);
        int[] r4 = run(true, false, false);
        System.out.printf("%-26s %8d %6d %8d%n", "对称性", r4[0], r4[1], r4[2]);
        int[] r5 = run(true, true, false);
        System.out.printf("%-26s %8d %6d %8d%n", "对称 + 前向", r5[0], r5[1], r5[2]);
        int[] r6 = run(true, false, true);
        System.out.printf("%-26s %8d %6d %8d%n", "对称 + Hall", r6[0], r6[1], r6[2]);
        int[] r7 = run(true, true, true);
        System.out.printf("%-26s %8d %6d %8d%n", "对称 + 前向 + Hall", r7[0], r7[1], r7[2]);

        // 轨道统计
        collectSolutions();
        Set<String> seen = new HashSet<>();
        int[] orbitDist = new int[9];
        int orbitCount = 0;
        for (int[] s : solutions) {
            if (seen.contains(key(s))) continue;
            List<String> o = orbit(s);
            seen.addAll(o);
            orbitCount++;
            orbitDist[o.size()]++;
        }
        System.out.println("\n解的个数 = " + solutions.size() + ",D4 轨道数 = " + orbitCount);
        for (int k = orbitDist.length - 1; k >= 1; k--)
            if (orbitDist[k] > 0)
                System.out.println("  轨道大小 " + k + " 出现 " + orbitDist[k] + " 次");
        System.out.println("首个解 = " + key(solutions.get(0)));
    }
}

实测输出:

配置                          节点数   解数     剪枝
朴素回溯                       2057     92        0
前向检查                       1165     92      560
Hall 匹配                      1555     92      546
对称性                         1029     46        0
对称 + 前向                     583     46      280
对称 + Hall                    778     46      273
对称 + 前向 + Hall              570     46      273

解的个数 = 92,D4 轨道数 = 12
  轨道大小 8 出现 11 次
  轨道大小 4 出现 1 次
首个解 = [0, 4, 7, 5, 2, 6, 1, 3]

九、复杂度与推广

  • 时间复杂度:最坏 O(n!),剪枝后实际远低于此。n=8 时朴素 2,057 节点,剪枝后 570。
  • 空间复杂度:O(n),递归深度即棋盘行数;位掩码只占三个 int。
  • 单节点成本:前向检查 = 最多 8 次位与;Hall 匹配 = 最多 8 次 Kuhn 增广,每次常数级。所以两者都是 O(n) per node,n=8 时可以忽略。

三条可推广的经验:

第一,位掩码把“约束检查”降到 O(1)。 三个 int 表示列、主对角、副对角,一次位与即得可用列。这是所有“逐行/逐列放置”类回溯题(n 皇后、数独、拉丁方)的通用技巧。

第二,剪枝强度不等于收益。 本文实测前向检查(1,165)优于 Hall 匹配(1,555)。判断标准是 剪枝成本 × 剪枝频率 < 节省的子树规模。别默认“更聪明的判据一定更快”。

第三,对称性约化是“离线”的剪枝。 它不改变搜索内部的判定逻辑,只是把搜索空间砍掉一半。代价是解数也减半,需要事后用轨道除法还原。适用于任何“解的对称变换仍是解”的组合计数问题——八皇后、N 皇后、数独生成、拼图排布都适用。

小结

八皇后真正的学习价值不在“能不能跑出 92 个解”,而在于它是一块干净的试验田:同一份搜索框架,挂上不同的剪枝判据,性能差 3.6 倍(2,057 → 570);同一批解,用群论做轨道除法,92 个解收敛成 12 个本质布局。

两件事值得带走:一是把约束写成位掩码,让“合法与否”变成常数级运算;二是把对称性当成一个正交的剪枝维度,它与算法本身无关,只与问题的对称群有关。这两点在数独生成、拼图求解、乃至 SAT 求解器的对称破坏(symmetry breaking)里都是同一套思路。