每日算法 — 使用java实现数字华容道:逆序数与树状数组可解性判定

数字华容道(15-Puzzle)有个让人沮丧的经典场景:你手里那个 4×4 拼图,只差最后两个数字放反了,怎么挪都挪不回来。很多人以为是技巧不够,实际上是数学上不可能——这个局面从一开始就注定无解。

判定一个滑块拼图能否复原,只需要一个量:逆序数。本文用 Java 实现这套判定逻辑,用树状数组把逆序数统计压到 O(k log k),并用 BFS 穷举 2×2 与 3×3 的全部排列逐局验证判定规则零误差。

一、为什么「就差两个」永远挪不好

先看核心不变式。把棋盘按行优先展平成一维数组,忽略空格,统计其中逆序对的个数(前面比后面大的数对):

  • 空格左右移动:只交换空格和相邻数字在一维序列里的位置,但数字之间的相对顺序完全不变 → 逆序数奇偶性不变。
  • 空格上下移动:空格跨越 n-1 个数字,相当于一个数字「跳过」n-1 个元素 → 逆序数奇偶性翻转 (n-1) 次。

于是:

  • n 为奇数(如 3×3 八数码):n-1 为偶数,上下移动不改变奇偶性 → 只用逆序数判:逆序数为偶数 ⇔ 可解。
  • n 为偶数(如 4×4 十五数码):n-1 为奇数,上下移动会翻转奇偶性,但同时空格的行号也变了 1,两者恰好抵消 → 需要合起来判:逆序数 + 空格自底向上的行号,为奇数 ⇔ 可解。

那个「14 和 15 放反」的局面逆序数是 1(奇数),空格在最后一行(自底向上行号 1),1 + 1 = 2 是偶数 → 不可解。这不是你的手笨,是群论不允许。

二、逆序数统计:O(k²) 到 O(k log k)

最朴素的写法是双重循环,k = n²-1 个数字,复杂度 O(k²)。n=64 时就是 4095² ≈ 1670 万次比较,单次调用要 16 毫秒。对「批量生成可解局面」这种场景(要跑成千上万次)完全不可接受。

树状数组(Binary Indexed Tree, BIT)把这个问题变成「动态前缀和查询」。核心思路:从右往左扫描序列,每遇到一个值 v,查询「已扫描过的元素中有多少个比 v 小」,累加就是逆序数。

static final class BIT {
    final int[] tree;
    BIT(int n) { tree = new int[n + 1]; }
    void add(int i, int v) { for (; i < tree.length; i += i & -i) tree[i] += v; }
    int sum(int i) { int s = 0; for (; i > 0; i -= i & -i) s += tree[i]; return s; }
}

static long inversions(int[] tiles) {
    int max = 0;
    for (int t : tiles) max = Math.max(max, t);
    BIT bit = new BIT(max);
    long inv = 0;
    for (int i = tiles.length - 1; i >= 0; i--) {     // 从右往左
        if (tiles[i] == 0) continue;                  // 忽略空格
        inv += bit.sum(tiles[i] - 1);                 // 右侧比它小的个数
        bit.add(tiles[i], 1);
    }
    return inv;
}

i & -i 那个位运算是整个数据结构的心脏:它取出 i 的最低位 1,让 add 沿「父节点链」向上爬、sum 沿「兄弟链」向下拆,两者都是 O(log k)。

复杂度对比实测(20 次取平均):

规模 树状数组 朴素 O(k²) 加速比
4×4 0.000 ms 0.000 ms —
8×8 0.001 ms 0.057 ms 40×
16×16 0.006 ms 0.159 ms 25×
32×32 0.066 ms 0.863 ms 13×
64×64 0.620 ms 16.473 ms 27×

小规模(4×4)两者都太快,无法测量差异;规模上去之后稳定在 25–40 倍。这符合理论 O(k log k) vs O(k²) 的预期——比值应该约等于 k / log₂k,64×64 时 4096/12 ≈ 340,实测 27 倍偏低是因为数组分配与缓存开销占了主导。真实工程的加速比永远小于理论值,这不是实现不好,是常数项在说话。

三、BFS 穷举验证:判定规则零误差

理论推导再漂亮,也要用穷举验证。思路是:对 2×2 和 3×3,BFS 从目标局面出发枚举所有真正可达的局面,然后拿判定函数对全部排列逐一比对:

  • 2×2:24 个排列,BFS 可达 12 个
  • 3×3:362,880 个排列,BFS 可达 181,440 个

结果:判定不符 0 个。两个规模都完美匹配。

这组数字本身就很有说服力:3×3 八数码的全部状态里只有恰好一半可解(181440 / 362880 = 0.5)。这个「恰好一半」不是巧合——它正是逆序数奇偶性把状态空间平分成两个连通分量的几何表现。你随便乱摆一个 3×3,有 50% 概率永远解不出来。换成 4×4 十五数码,比例仍然是 1/2(约 10^13 个状态里一半可解)。

BFS 那个 181440 也顺带验证了八数码的经典结论:八数码有 181,440 个可达状态。这是 1960 年代就确立的数字,代码跑出来一分不差。

四、生成可解局面:洗牌 + 奇偶修正

实际做游戏时需要「随机但保证可解」的初始局面。直接随机打乱有 50% 概率不可解,标准做法是洗牌后检查,不对就交换两个数字:

static int[] randomSolvable(int n, Random rnd) {
    // ... Fisher-Yates 洗牌 ...
    if (!isSolvable(tiles, n)) {
        // 奇偶不对 → 交换任意两个非空格数字,即可翻转逆序数奇偶性
        int a = -1, b = -1;
        for (int i = 0; i < total && b < 0; i++) {
            if (tiles[i] != 0) { if (a < 0) a = i; else b = i; }
        }
        int tmp = tiles[a]; tiles[a] = tiles[b]; tiles[b] = tmp;
    }
    return tiles;
}

为什么「交换任意两个数字」一定能修复?因为交换两个元素,要么让一个逆序对消失、要么让一个逆序对出现,逆序数必然改变 ±1(或 ±奇数),奇偶性一定翻转。这是排列群里的「对换改变奇偶性」定理,也是最优雅的一行修复。

代码里跑了 500 次随机生成 3×3 局面,判定为可解的比例是 500 / 500,说明修复逻辑正确。

五、完整可运行代码

项目结构:

sliding-puzzle-parity/
├── src/
│   └── SlidingPuzzleParity.java
└── out/
javac -d out src/SlidingPuzzleParity.java
java  -cp out SlidingPuzzleParity
import java.util.*;

/**
 * 数字华容道(15-Puzzle / n×n 滑块拼图)的可解性判定与困难局面生成。
 *
 * 核心结论(不变式):
 *   把棋盘按行优先展平成一维数组,忽略空格,统计「逆序对」个数 inv。
 *   设空格所在行号为 blankRow(从 1 开始,自底向上数,即倒数第几行)。
 *   - 宽度 n 为奇数:inv 为偶数 ⇔ 可解。
 *   - 宽度 n 为偶数:inv 与 blankRow 的奇偶性相反 ⇔ 可解((inv + blankRow) 为奇数)。
 *
 * 为什么?每次合法移动只交换空格与相邻数字,等价于一次「平移」:
 *   空格左右移动不改变一维序列的相对顺序 → inv 奇偶性不变;
 *   空格上下移动跨越 n-1 个数字 → inv 的奇偶性改变 (n-1) 次,即 n 为奇数时不变、
 *   n 为偶数时翻转,恰好等价于 blankRow 变化 1。
 * 所以 (inv + blankRow) 的奇偶性(n 为偶数)或 inv 的奇偶性(n 为奇数)是移动不变式。
 *
 * 逆序对用树状数组(Binary Indexed Tree)在 O(k log k) 内统计,
 * 比 O(k²) 的双重循环快一个数量级,用于批量生成可解局面。
 */
public final class SlidingPuzzleParity {

    /** 树状数组:单点加 + 前缀和 */
    static final class BIT {
        final int[] tree;
        BIT(int n) { tree = new int[n + 1]; }
        void add(int i, int v) { for (; i < tree.length; i += i & -i) tree[i] += v; }
        int sum(int i) { int s = 0; for (; i > 0; i -= i & -i) s += tree[i]; return s; }
    }

    /**
     * 用树状数组统计 1..n²-1 序列的逆序对个数。
     * 从右往左扫描,每遇到一个值 v,询问「已扫描过的元素中有多少个比 v 小」,
     * 累加即为逆序对数。
     */
    static long inversions(int[] tiles) {
        int max = 0;
        for (int t : tiles) max = Math.max(max, t);
        BIT bit = new BIT(max);
        long inv = 0;
        int seen = 0;
        for (int i = tiles.length - 1; i >= 0; i--) {          // 从右往左
            if (tiles[i] == 0) continue;                        // 忽略空格
            inv += bit.sum(tiles[i] - 1);                       // 右侧比它小的个数
            bit.add(tiles[i], 1);
            seen++;
        }
        return inv;
    }

    /** 最朴素的 O(k²) 逆序计数,仅用于交叉验证 */
    static long inversionsNaive(int[] tiles) {
        long inv = 0;
        for (int i = 0; i < tiles.length; i++) {
            if (tiles[i] == 0) continue;
            for (int j = i + 1; j < tiles.length; j++) {
                if (tiles[j] != 0 && tiles[i] > tiles[j]) inv++;
            }
        }
        return inv;
    }

    /**
     * 可解性判定。
     * @param tiles 长度为 n*n 的一维数组,0 表示空格,其余为 1..n²-1
     */
    static boolean isSolvable(int[] tiles, int n) {
        long inv = inversions(tiles);
        int blankIndex = -1;
        for (int i = 0; i < tiles.length; i++) if (tiles[i] == 0) { blankIndex = i; break; }
        if (blankIndex < 0) return false;                       // 没有空格不是合法拼图
        int blankRowFromBottom = n - blankIndex / n;            // 自底向上:第几行(1 起)
        if ((n & 1) == 1) return (inv & 1) == 0;                // 奇数宽:逆序数为偶数才可解
        return ((inv + blankRowFromBottom) & 1) == 1;           // 偶数宽:两者之和为奇数才可解
    }

    /** 生成一个随机但保证可解的初始局面:随机打乱 + 奇偶修正 */
    static int[] randomSolvable(int n, Random rnd) {
        int total = n * n;
        int[] tiles = new int[total];
        for (int i = 0; i < total - 1; i++) tiles[i] = i + 1;
        tiles[total - 1] = 0;                                   // 目标局面:空格在右下角
        // Fisher-Yates 洗牌
        for (int i = total - 1; i > 0; i--) {
            int j = rnd.nextInt(i + 1);
            int tmp = tiles[i]; tiles[i] = tiles[j]; tiles[j] = tmp;
        }
        if (!isSolvable(tiles, n)) {
            // 奇偶不对 → 交换任意两个非空格数字即可翻转逆序奇偶性
            int a = -1, b = -1;
            for (int i = 0; i < total && b < 0; i++) {
                if (tiles[i] != 0) { if (a < 0) a = i; else b = i; }
            }
            int tmp = tiles[a]; tiles[a] = tiles[b]; tiles[b] = tmp;
        }
        return tiles;
    }

    /** 空格所在的一维下标 */
    static int blankIndex(int[] tiles) {
        for (int i = 0; i < tiles.length; i++) if (tiles[i] == 0) return i;
        return -1;
    }

    /** 渲染棋盘 */
    static String render(int[] tiles, int n) {
        StringBuilder sb = new StringBuilder();
        int width = String.valueOf(n * n - 1).length();
        for (int r = 0; r < n; r++) {
            sb.append("  ");
            for (int c = 0; c < n; c++) {
                int v = tiles[r * n + c];
                String s = v == 0 ? "  " : String.format("%" + width + "d", v);
                sb.append(s).append("  ");
            }
            sb.append('\n');
        }
        return sb.toString();
    }

    /** 正确应用一次移动,返回新数组(空格与相邻数字交换) */
    static int[] applyMove(int[] tiles, int n, int tileIndex) {
        int[] t = tiles.clone();
        int blank = -1;
        for (int i = 0; i < t.length; i++) if (t[i] == 0) { blank = i; break; }
        int br = blank / n, bc = blank % n, tr = tileIndex / n, tc = tileIndex % n;
        if (Math.abs(br - tr) + Math.abs(bc - tc) != 1) throw new IllegalArgumentException("非法移动");
        t[blank] = t[tileIndex]; t[tileIndex] = 0;
        return t;
    }

    /** 字典序下一个排列(枚举全排列用) */
    static boolean nextPermutation(int[] a) {
        int i = a.length - 2;
        while (i >= 0 && a[i] >= a[i + 1]) i--;
        if (i < 0) return false;
        int j = a.length - 1;
        while (a[j] <= a[i]) j--;
        int t = a[i]; a[i] = a[j]; a[j] = t;
        for (int l = i + 1, r = a.length - 1; l < r; l++, r--) { t = a[l]; a[l] = a[r]; a[r] = t; }
        return true;
    }

    public static void main(String[] args) {
        // ---------- 1. 演示可解与不可解的判定 ----------
        int[] ok15 = {
                 1,  2,  3,  4,
                 5,  6,  7,  8,
                 9, 10, 11, 12,
                13, 15, 14,  0        // 经典「14 与 15 互换」局面,不可解
        };
        int n = 4;
        System.out.println("经典不可解局面(14 与 15 互换):\n" + render(ok15, n));
        System.out.printf("逆序数 = %d,空格自底向上行号 = %d,可解 = %s%n",
                inversions(ok15), n - blankIndex(ok15) / n, isSolvable(ok15, n));

        int[] good = {1,2,3,4,5,6,7,8,9,10,11,12,13,14,0,15};
        System.out.printf("%n对照局面逆序数 = %d,可解 = %s%n",
                inversions(good), isSolvable(good, n));

        // ---------- 2. 树状数组 vs 朴素算法:正确性与性能 ----------
        Random rnd = new Random(20261024L);
        boolean same = true;
        for (int trial = 0; trial < 200; trial++) {
            int[] t = randomSolvable(4, rnd);
            if (inversions(t) != inversionsNaive(t)) { same = false; break; }
        }
        System.out.println("\n树状数组与朴素算法结果一致 = " + same);

        System.out.println("\n规模 | 树状数组(ms) | 朴素 O(k²)(ms) | 加速比");
        for (int n2 : new int[]{4, 8, 16, 32, 64}) {
            int[] t = randomSolvable(n2, rnd);
            long s1 = System.nanoTime();
            long v1 = 0;
            for (int r = 0; r < 20; r++) v1 = inversions(t);
            long s2 = System.nanoTime();
            long v2 = 0;
            for (int r = 0; r < 20; r++) v2 = inversionsNaive(t);
            long s3 = System.nanoTime();
            System.out.printf("%d×%d | %.3f | %.3f | %.0f×%n", n2, n2,
                    (s2 - s1) / 1e6 / 20, (s3 - s2) / 1e6 / 20,
                    (s3 - s2) / (double) Math.max(1, s2 - s1));
            if (v1 != v2) System.out.println("  [警告] 逆序数不一致!");
        }

        // ---------- 3. 随机生成的可解局面都能求解(用 IDA* 验证一个小规模) ----------
        System.out.println("\n对 3×3 八数码随机生成 500 个局面并检查可解性一致性:");
        int solvableCount = 0;
        for (int i = 0; i < 500; i++) {
            int[] t = randomSolvable(3, rnd);
            if (isSolvable(t, 3)) solvableCount++;
        }
        System.out.println("判定为可解的局面数 = " + solvableCount + " / 500(应全部可解)");

        // ---------- 3.5 用 BFS 穷举 2×2 / 3×3 全部排列,验证判定规则无差错 ----------
        for (int n3 : new int[]{2, 3}) {
            int total = n3 * n3;
            long[] reachable = new long[1];
            Set<String> seen = new HashSet<>();
            int[] goal = new int[total];
            for (int i = 0; i < total - 1; i++) goal[i] = i + 1;
            Deque<int[]> queue = new ArrayDeque<>();
            queue.add(goal); seen.add(Arrays.toString(goal));
            while (!queue.isEmpty()) {
                int[] cur = queue.poll();
                int b = 0;
                for (int i = 0; i < total; i++) if (cur[i] == 0) b = i;
                int br = b / n3, bc = b % n3;
                int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
                for (int[] d : dirs) {
                    int r = br + d[0], c = bc + d[1];
                    if (r < 0 || r >= n3 || c < 0 || c >= n3) continue;
                    int[] nxt = cur.clone();
                    int i2 = r * n3 + c;
                    nxt[b] = nxt[i2]; nxt[i2] = 0;
                    if (seen.add(Arrays.toString(nxt))) queue.add(nxt);
                }
            }
            int mismatch = 0, checked = 0;
            int[] perm = new int[total];
            for (int i = 0; i < total; i++) perm[i] = i;      // 以升序排列为枚举起点
            // 枚举全部排列(3×3 为 362880 个,可接受)
            do {
                checked++;
                boolean inBfs = seen.contains(Arrays.toString(perm));
                if (isSolvable(perm, n3) != inBfs) mismatch++;
            } while (nextPermutation(perm));
            System.out.printf("%d×%d:枚举 %d 个排列,BFS 可达 %d 个,判定不符 %d 个%n",
                    n3, n3, checked, seen.size(), mismatch);
        }

        // ---------- 4. 展示一个 4×4 可解局面 ----------
        int[] demo = randomSolvable(4, new Random(42));
        System.out.println("\n随机生成的可解 4×4 局面:\n" + render(demo, 4));
        System.out.println("逆序数 = " + inversions(demo) + ",可解 = " + isSolvable(demo, 4));
    }
}

运行输出(节选):

经典不可解局面(14 与 15 互换):
   1   2   3   4
   5   6   7   8
   9  10  11  12
  13  15  14

逆序数 = 1,空格自底向上行号 = 1,可解 = false

树状数组与朴素算法结果一致 = true

2×2:枚举 24 个排列,BFS 可达 12 个,判定不符 0 个
3×3:枚举 362880 个排列,BFS 可达 181440 个,判定不符 0 个

随机生成的可解 4×4 局面:
       5   1   2
   8  11  15  10
   3   6   9  14
  13   7   4  12
逆序数 = 39,可解 = true

代码里保留了两处「自证」机制,这是本文最重要的工程习惯:

  1. inversionsNaive(O(k²) 朴素版)与树状数组版做 200 次随机交叉验证,结果必须完全一致;
  2. nextPermutation 全排列枚举 + BFS 可达集,对判定函数做全空间校验。

没有一个算法应该只被「看起来正确」地实现一遍。 逆序数这种一行就能写错(比如漏掉空格、或者从底向上数行号)的逻辑,交叉验证的成本远低于线上被用户发现「这游戏根本无法通关」的代价。

六、复杂度与延伸

项目 复杂度 说明
逆序数(树状数组) O(k log k),k = n²-1 4×4 约 0.001 ms
逆序数(朴素) O(k²) 64×64 需 16 ms
可解性判定整体 O(k log k) 判定不需求解
求解(IDA* + 曼哈顿) 指数级,15 数码平均 53 步 判定省下的时间可以全给搜索

最后一行是这套判定的真正价值:在启动 IDA* 求解之前,先用 0.001 ms 判掉 50% 不可能的局面。如果不做这个预判,启发式搜索会白跑到内存耗尽才放弃。这就是「一个数学不变式能省掉一半算力」的典型例子。

延伸方向有两个:

  1. 把不变式推广到 N 数码的可解性计数。已知 n 为奇数时可达状态数恰好是全排列的一半;n 为偶数时同样是 1/2。这个结论可以用排列群(交错群 A_k)的指数直接推出:可解状态构成全排列群的指数为 2 的子群,即交错群。
  2. 用 A* 的「线性冲突」启发式求解。曼哈顿距离会低估,线性冲突(同一行/列里顺序颠倒的数字对)能显著提升剪枝,15 数码的节点数可降一个数量级。前作《数字华容道:IDA* 迭代深化与线性冲突启发式》已有完整实现。

七、小结

数字华容道用一个「挪不回来」的直觉挫败,引出了滑块拼图最核心的数学事实:可达状态只占全排列的一半,由逆序数奇偶性划分。这条不变式让「能不能解」变成一个不需要搜索、O(k log k) 就能回答的问题。

工程上,树状数组把统计压到 25–40 倍加速,而 BFS 全排列穷举给出了零误差的验证。三行判定代码、一个位运算数据结构、两组交叉验证——这就是一个「看起来简单但极易写错」的算法应有的完整交付形态。