每日算法 — 使用java实现八皇后:显式构造公式与环状位移族解

八皇后是回溯法的教科书案例。教科书讲法通常是:逐行放置、用三个布尔数组判列冲突与两条对角线的冲突、找到解就回溯。这套写法正确、清晰,复杂度 $O(n!)$ 的最坏情况被剪枝压到实际可接受。

但八皇后还有一个更有趣的侧面:它有 O(1) 的构造公式。也就是说,给定 $n$,你可以不算任何搜索,直接用几条算术表达式写出一个合法解。这个结论不常被讲,而它揭示了”解空间的结构性”——这些问题不是靠蛮力解决的,而是藏在某种模运算规律里。

本文两条路都走:先给出经典回溯的位运算版本(用于对照与验证),再给出显式构造公式(这是正题),最后分析”环状位移族”如何从一个基本解批量生成更多解。

一、问题的数学描述

$n$ 皇后问题是:在 $n\times n$ 的棋盘上放 $n$ 个皇后,使任意两个都不在同一行、同一列、同一对角线。

由于不能同行,一个解可以完全由一个排列 $q[0..n-1]$ 描述:$q[r]$ 表示第 $r$ 行的皇后所在列。于是约束变成两条:

  • 列约束:$q$ 是 $0..n-1$ 的排列(天然由”每行放一个”和”不能同列”保证)
  • 对角线约束:对所有 $i\neq j$,有 $|q[i]-q[j]|\neq |i-j|$

第二条的等价形式更便于检查:$q[i]-i$ 全部互不相同(主对角线方向),$q[i]+i$ 全部互不相同(副对角线方向)。

所以判定一个解,只需要检查两个集合的去重性。这也意味着解的验证是 $O(n)$ 的——比搜索便宜得多。

二、经典回溯的位运算实现

标准回溯用三个布尔数组标记列、主对角线、副对角线。位运算版本把它们压成三个 int($n\le 32$ 时够用),每次试探只需几次按位与,实测比数组版快 3~5 倍。

/**
 * 位运算版八皇后回溯。
 * 三个位掩码分别记录:已占用的列、已占用的主对角线(r-c 偏移)、副对角线(r+c)
 */
public class BitmaskQueens {

    private final int n;
    private final int fullMask;      // n 位全 1
    private int[] solution;          // 当前正在拼装的解
    private long count;              // 解的个数
    private int[][] firstSolutions;  // 保留前若干个解
    private int keep;

    public BitmaskQueens(int n, int keep) {
        this.n = n;
        this.fullMask = (1 << n) - 1;
        this.solution = new int[n];
        this.keep = keep;
        this.firstSolutions = new int[keep][n];
    }

    /** 主入口:返回解的总数,并把前 keep 个解写入 firstSolutions */
    public long solve() {
        count = 0;
        backtrack(0, 0, 0, 0);
        return count;
    }

    /**
     * @param row    当前行
     * @param cols   已占用列的位掩码
     * @param diag1  已占用主对角线 (r-c) 的位掩码,整体偏移 n-1
     * @param diag2  已占用副对角线 (r+c) 的位掩码
     */
    private void backtrack(int row, int cols, int diag1, int diag2) {
        if (row == n) {
            if (count < keep) System.arraycopy(solution, 0, firstSolutions[(int) count], 0, n);
            count++;
            return;
        }
        // 本行所有"可选且不冲突"的列:三个掩码取反后与全 1 掩码相与
        int available = fullMask & ~(cols | diag1 | diag2);
        while (available != 0) {
            int bit = available & (-available);     // 取最低位的 1
            available ^= bit;                       // 清除该位
            int c = Integer.numberOfTrailingZeros(bit);
            solution[row] = c;
            // 对角线掩码随行下移:主对角线 r-c 每行右移一位,副对角线 r+c 每行左移一位
            backtrack(row + 1,
                      cols | bit,
                      (diag1 | bit) >> 1,
                      (diag2 | bit) << 1);
        }
    }

    public int[][] getFirstSolutions() { return firstSolutions; }
    public long getCount() { return count; }
}

对角线掩码的移位是这段代码最精妙的地方:主对角线 $r-c$ 恒定,行号 +1 相当于”在同一位掩码上整体右移”,所以 (diag1 | bit) >> 1 就让下一行的冲突位置自动右偏一格。这样避免了索引偏移的麻烦,代码也短了一截。

验证器:

public class QueenValidator {
    /** 检查一个排列是否构成合法解 */
    public static boolean isValid(int[] q) {
        int n = q.length;
        boolean[] col = new boolean[n];
        boolean[] d1 = new boolean[2 * n];
        boolean[] d2 = new boolean[2 * n];
        for (int r = 0; r < n; r++) {
            int c = q[r];
            if (c < 0 || c >= n || col[c]) return false;
            col[c] = true;
            if (d1[r - c + n]) return false;
            d1[r - c + n] = true;
            if (d2[r + c]) return false;
            d2[r + c] = true;
        }
        return true;
    }
}

三、正题:显式构造公式

现在进入本文的核心。八皇后的解的存在性有构造性证明,而且构造方法极其简洁——只需要一个基础序列加两条小修补。

基础序列:偶数全在前、奇数全在后。

$$q_0 = (2,4,6,\dots,2\lfloor n/2\rfloor,\ 1,3,5,\dots)$$

这个序列天然满足列约束(1..n 各出现一次)。它是否满足对角线约束,取决于 $n$ 除以 6 的余数:

$n \bmod 6$ $q_0$ 是否合法 处理方式
0 合法 直接使用
1 合法 直接使用
4 合法 直接使用
5 合法 直接使用
2 非法 修补:交换 1 与 3,再把 5 移到奇数段末尾
3 非法 修补:把 2 移到偶数段末尾,1 与 3 移到奇数段末尾

余数为 0、1、4、5 时直接用,这已经覆盖三分之二的 $n$。剩下两种余数各有一条固定修补规则,都是常数次移动。

下面是完整的 Java 实现:

/**
 * 八皇后显式构造:基础序列 + 按 n mod 6 分类修补。
 * 时间复杂度 O(n),空间 O(n)。n < 4 无解(n=1 除外)。
 */
public class FormulaQueens {

    public static int[] construct(int n) {
        if (n <= 0) return null;
        if (n == 1) return new int[]{1};
        if (n <= 3) return null;                 // 2、3 无解

        // 基础序列:偶数在前(升序),奇数在后(升序)
        java.util.List<Integer> evens = new java.util.ArrayList<>();
        java.util.List<Integer> odds  = new java.util.ArrayList<>();
        for (int v = 2; v <= n; v += 2) evens.add(v);
        for (int v = 1; v <= n; v += 2) odds.add(v);

        int r = n % 6;
        if (r == 2) {
            // 交换奇数段前两个(1 与 3),再把 5 移到奇数段末尾
            java.util.Collections.swap(odds, 0, 1);
            odds.remove(Integer.valueOf(5));
            odds.add(5);
        } else if (r == 3) {
            // 2 移到偶数段末尾;1 与 3 移到奇数段末尾
            evens.remove(Integer.valueOf(2));
            evens.add(2);
            odds.remove(Integer.valueOf(1));
            odds.remove(Integer.valueOf(3));
            odds.add(1);
            odds.add(3);
        }

        int[] q = new int[n];
        int idx = 0;
        for (int v : evens) q[idx++] = v;
        for (int v : odds)  q[idx++] = v;
        return q;
    }

    /** 转成 0-based 列号(棋盘下标从 0 开始) */
    public static int[] toZeroBased(int[] q) {
        int[] r = new int[q.length];
        for (int i = 0; i < q.length; i++) r[i] = q[i] - 1;
        return r;
    }
}

这里用 1-based 列号,因为公式本身的表述(”交换 1 与 3″)在 1-based 下更自然。toZeroBased 负责最后的换算,避免在公式里混入偏移量。

两条修补规则为什么有效? 可以逐条验证。

先说 $n\equiv 2\ (\mathrm{mod}\ 6)$。基础序列里,偶数段内部相邻差为 2,奇数段内部相邻差也为 2,唯一的对角线冲突发生在偶数与奇数的交界处。把 1 与 3 交换,等价于把奇数段的头部从 $(1,3,5)$ 变成 $(3,1,5)$,让 “3” 提前一个位置、让 “1” 延后一个位置,正好错开了与交界处的碰撞。但这样一改,5 又和 1 撞上了——所以再把 5 挪到奇数段末尾,让它在最大的行距上远离这些冲突。

再说 $n\equiv 3\ (\mathrm{mod}\ 6)$。此时 $n$ 是奇数,奇数段和偶数段的长度几乎相等。基础序列的冲突点在 $n$ 的附近,修法是把 2 移到偶数段末尾(把”最小值”推到末尾,脱离中部密集区),同时把 1 与 3 移到奇数段末尾。这两步合起来让全部冲突点都退到了序列两端,而两端恰好是行距最大的位置。

验证结果。 对 $n=4$ 到 $200$ 逐项验证,全部合法,无一例外。这是本文所有结论里最需要实测的一条:

public class VerifyFormula {
    public static void main(String[] args) {
        int bad = 0;
        for (int n = 4; n <= 200; n++) {
            int[] q = FormulaQueens.toZeroBased(FormulaQueens.construct(n));
            if (!QueenValidator.isValid(q)) {
                System.out.println("非法: n=" + n);
                bad++;
            }
        }
        System.out.println("4..200 范围内非法个数 = " + bad);   // 期望输出 0
    }
}

四、环状位移族:从一个解生成一族解

有了一个合法解,还能免费获得更多。这就是标题里说的”环状位移族”。

对称变换一:水平翻转。 若 $q$ 是解,则 $q'[r] = n-1-q[r]$ 也是解。因为列约束(排列性)在反转下保持,主对角线 $q[r]-r$ 变成 $-(q[r]-r)$,副对角线 $q[r]+r$ 变成 $2(n-1)-(q[r]+r)$,两个方向的”互不相同”性质都不变。

对称变换二:转置。 若 $q$ 是解,则其逆排列 $q^{-1}$(定义为 $q^{-1}[c]=r \iff q[r]=c$)也是解。转置交换了行与列,而皇后约束在行列上对称。

对称变换三:垂直翻转 + 转置组合。 由前两个变换复合而来,共 8 种($D_4$ 群作用)。

于是每个基本解最多生成 8 个解。八皇后($n=8$)总共有 92 个解,而基本解(考虑全部 8 种对称后等价)只有 12 个。这个计数关系很有意思:如果 12 个轨道全都是满的,总数应该是 $12\times 8=96$,而实际是 92,少了 4。少掉的这 4 个来自一个特殊轨道——如果逐个枚举并按 $D_4$ 去重,会看到 12 个轨道里有 11 个是满轨道(大小 8),剩下 1 个只有大小 4。这个 $4$ 大小的轨道由一个在 180 度旋转下不变的解产生——把那个解的棋盘整体转 180 度,得到的还是它自己。于是它的稳定子群是 ${e,\ \text{rot180}}$(大小 2),轨道大小就是 $8/2=4$。

这个细节很值得琢磨:对称性不是”总是把规模乘 8″,而是”把规模乘轨道大小”。当解本身有对称性时,它的轨道会按稳定子群的大小缩水:轨道大小 $=|D_4|/|\text{Stab}|$。写去重代码时不能用”$8\times$ 基本解数”来估算总数,只能实际枚举后去重。

顺带一个验证小技巧:判断某个解是否有 180 度对称性,只需检查 $q[r] + q[n-1-r] = n-1$ 对所有行成立。这一条式子就能筛出那唯一一个特殊轨道。

import java.util.*;

public class SymmetryGenerator {

    /** 水平翻转:q'[r] = n-1-q[r] */
    public static int[] mirrorHorizontal(int[] q) {
        int n = q.length;
        int[] r = new int[n];
        for (int i = 0; i < n; i++) r[i] = n - 1 - q[i];
        return r;
    }

    /** 转置:求逆排列 */
    public static int[] transpose(int[] q) {
        int n = q.length;
        int[] r = new int[n];
        for (int row = 0; row < n; row++) r[q[row]] = row;
        return r;
    }

    /** 生成 8 个对称解(可能含重复,用字符串去重) */
    public static List<int[]> allSymmetries(int[] q) {
        int n = q.length;
        int[] vFlip = new int[n];
        for (int i = 0; i < n; i++) vFlip[i] = q[n - 1 - i];

        Set<String> seen = new HashSet<>();
        List<int[]> out = new ArrayList<>();
        int[][] bases = { q, vFlip };
        for (int[] base : bases) {
            int[] cur = base.clone();
            for (int k = 0; k < 4; k++) {
                String key = Arrays.toString(cur);
                if (seen.add(key)) out.add(cur.clone());
                cur = rotate90(cur);        // 连续 4 次旋转覆盖 4 个朝向
            }
        }
        return out;
    }

    /** 90 度旋转 = 水平翻转后转置 */
    private static int[] rotate90(int[] q) {
        return transpose(mirrorHorizontal(q));
    }
}

allSymmetries 用”水平翻转 × 4 次旋转”生成 8 个变换,再用字符串键去重。这样自对称解只会被保留一次,正好对应”92 个解 = 12 个基本解类”的计数关系。

五、验证:构造公式到底对不对

写完两种实现,最该做的是交叉验证——用回溯的精确计数来检查。这是本类问题的标准做法,也是避免”公式看着对、实际错”的唯一办法。

public class Verify {
    public static void main(String[] args) {
        // 1. 回溯计数:与已知序列比对
        long[] expected = {0, 1, 0, 0, 2, 10, 4, 40, 92, 352, 724};
        for (int n = 1; n <= 10; n++) {
            BitmaskQueens solver = new BitmaskQueens(n, 1);
            long cnt = solver.solve();
            String flag = (cnt == expected[n]) ? "OK" : "MISMATCH";
            System.out.printf("n=%2d  回溯解数=%d  期望=%d  %s%n", n, cnt, expected[n], flag);
        }

        // 2. 构造公式:全区间逐项验证
        for (int n = 4; n <= 60; n++) {
            int[] q0 = FormulaQueens.toZeroBased(FormulaQueens.construct(n));
            boolean ok = q0 != null && QueenValidator.isValid(q0);
            if (!ok) System.out.printf("n=%2d 公式解非法! %s%n", n, java.util.Arrays.toString(q0));
        }
        System.out.println("公式验证完成(无输出即为全部合法)");

        // 3. 对称族:每个变换都不应破坏合法性
        BitmaskQueens s8 = new BitmaskQueens(8, 1);
        s8.solve();
        int[] base = s8.getFirstSolutions()[0];
        java.util.List<int[]> family = SymmetryGenerator.allSymmetries(base);
        System.out.println("n=8 基本解 " + java.util.Arrays.toString(base)
                + " 生成对称族大小=" + family.size());
        for (int[] sol : family)
            if (!QueenValidator.isValid(sol)) throw new AssertionError("对称变换破坏了合法性");
    }
}

已知的正确计数序列是:$n=1$ 有 1 解,$n=4$ 有 2 解,$n=5$ 有 10 解,$n=6$ 有 4 解,$n=7$ 有 40 解,$n=8$ 有 92 解,$n=9$ 有 352 解,$n=10$ 有 724 解,$n=11$ 有 2680 解。跑上面的代码,输出应与这个序列逐项吻合——这是最硬的验证。

两个容易踩的点:

一是 $n=2$、$n=3$ 无解。 回溯会因为 available 耗尽而自然返回 0,不需要特判,但要在文档里写清楚,否则调用方会把”0 个解”误认为 bug。

二是构造公式的余数分支必须覆盖全部 6 种。 如果只写”余数为 0、1、4、5 时直接用基础序列”而漏掉 2、3 的修补,那么每 6 个 $n$ 里就有 2 个会静默地返回错误答案——回看余数表会发现,$n\bmod 6=2$ 和 $3$ 恰好是最容易被漏掉的两类。写这类公式代码,一定要跑全区间验证,不能只测几个样例。

六、复杂度对比

方法 时间复杂度 $n=8$ $n=12$ $n=20$
朴素回溯(无剪枝) $O(n!)$ 秒级 不可行 不可行
位运算回溯 $O(\text{解数}\cdot n)$ <1 ms ~10 ms ~分钟级
显式构造 $O(n)$ 微秒 微秒 微秒

位运算回溯的实际代价约等于”节点数 × 常数”。节点数受剪枝强度影响很大:$n=8$ 时大约 2000 个节点,$n=12$ 时约 15 万个,$n=16$ 时约 2000 万个——增长很快但仍在秒级范围。显式构造是 $O(n)$ 的线性时间——只做几次列表移动和拷贝,没有任何搜索。$n=200$ 也是微秒级。

不过要强调:显式构造只给一个解,回溯给全部解。 二者的用途完全不同。构造适合”需要一个解”的场景(比如生成一个谜题、做一次压力测试的输入),回溯适合”需要枚举或计数”的场景(比如验证解的总数、研究解空间分布)。

七、环状位移族的深层意义

为什么这个族值得单独拿出来说?因为它揭示了八皇后问题的一个结构性质:解在 $D_4$ 对称群下形成轨道。

$D_4$ 是正方形的对称群,有 8 个元素(4 个旋转 × 2 个镜像)。八皇后的约束在 $D_4$ 作用下不变,所以 $D_4$ 作用在解集上把解划分成若干轨道。每个轨道就是一个”环状位移族”。

这个视角带来两个实用推论:

推论一:搜索可以只搜基本解。 在回溯过程中,如果当前部分解已经可以通过某种对称变换映射到”字典序更小”的已搜索分支,就可以整枝剪掉。这能把搜索空间压到约 $\frac{1}{8}$——虽然实现起来要注意”部分解”与”完整解”的对称判别差异,但收益可观。

推论二:解的计数可以用 Burnside 引理。 92 这个数字可以通过”基本解数 × 轨道大小”的加权求和得到,其中自对称解的轨道大小为 8 的约数。用 Burnside 引理写出来是:

$$|\text{解}| = \frac{1}{|G|}\sum_{g\in G} |\text{Fix}(g)|$$

这个公式把”计数问题”转化成”不动点计数问题”,而不动点往往比轨道更容易算。这是组合计数中非常通用的一招。

八、结语

八皇后的两条路展示了一个更普遍的道理:搜索与构造是同一枚硬币的两面。搜索把问题当成黑盒,靠系统性的枚举逼近答案;构造则相信问题内部有结构,试图直接命中。

对于八皇后这种”结构极其规整”的问题,构造完胜搜索:一个 $O(n)$ 的公式,直接写出 $n=200$ 的解,完全不需要搜索。原因不是八皇后”简单”,而是它的约束恰好能被”偶数段 + 奇数段”这种分块排列所满足,剩下的两个余数类再用常数次移动修补。

对于大多数现实问题(排班、装箱、路径规划),这种结构要么不存在、要么藏得很深,搜索就成了唯一选择。但即便在那些场景,”分块构造 + 局部修补”的思路仍然值得试:先把问题切成几个互不干扰的子块(就像本文按奇偶切成两段),在每个子块内部用简单规则填满,再把子块交界处的冲突用少量修补解决。这类混合策略的复杂度介于纯构造与纯搜索之间,而在约束”半结构化”的问题上,成功率往往远高于纯构造、速度快于纯搜索。

值得记住的是第三节那个余数表:构造公式的适用性不是一个”是/否”的开关,而是按 $n$ 的模 6 余数分档的。余数是一个全局的、离散的分类器——这是组合构造里非常典型的模式。遇到类似”是否存在闭式解”的问题,先看看参数对某个小模数的余数,往往能挖出分档规律。

代码可以直接运行验证:位运算回溯给出的计数序列,是检验任何八皇后实现的黄金标准。