每日算法 — 使用java实现青蛙过河:分支限界搜索与下界函数驱动的状态剪枝

青蛙过河是一个规则简单却极难心算的益智游戏:一排石头横跨水面,青蛙们按规则跳跃交换位置,目标是把左岸的青蛙全都搬到右岸。它的搜索空间随石头数量指数增长,朴素的 BFS 在 15 块石头时就会吃掉数 GB 内存。本文用 Java 实现分支限界搜索(Branch and Bound),核心是设计一个真正有效下界函数,配合状态压缩与等价状态归并,把问题规模压到可计算范围。

一、游戏建模:把”跳跃”变成整数向量

经典青蛙过河(也叫青蛙换位,Frog Jump)的规则是:左右两侧各有一组青蛙,中间是石头阵。操作者可以把一只青蛙跳到相邻空石,或者跳过一只青蛙落到空石。目标是把左岸的青蛙全部移到右岸,右岸的全部移到左岸。

把它抽象成一行格子序列更利于算法处理。约定用 0 表示空位,1 表示左岸青蛙,2 表示右岸青蛙(也可看作”左向”和”右向”两种生物)。一步操作是:

  • 将某只青蛙移入相邻空位(代价 1)
  • 将某只青蛙跳过一只异类青蛙落到空位(代价 1)

在一维序列上,这个规则等价于”青蛙与空位交换位置”,或”青蛙跳过一只反向青蛙与空位交换位置”。所有移动代价均为 1,所以问题是一个无权最短路问题。

/**
 * 青蛙过河状态:用 byte 数组编码
 * 0 = 空位, 1 = 左向青蛙(向右移动), 2 = 右向青蛙(向左移动)
 */
public class FrogState {

    private final byte[] cells;
    private final int length;

    public FrogState(byte[] cells) {
        this.cells = cells.clone();
        this.length = cells.length;
    }

    public static FrogState initial(int pairs) {
        // 形如 1 1 1 0 2 2 2:左侧 pairs 只右向青蛙,中间一个空位,右侧 pairs 只左向青蛙
        byte[] s = new byte[pairs * 2 + 1];
        for (int i = 0; i < pairs; i++) {
            s[i] = 1;
        }
        s[pairs] = 0;
        for (int i = pairs + 1; i < s.length; i++) {
            s[i] = 2;
        }
        return new FrogState(s);
    }

    public static FrogState target(int pairs) {
        byte[] s = new byte[pairs * 2 + 1];
        for (int i = 0; i < pairs; i++) {
            s[i] = 2;
        }
        s[pairs] = 0;
        for (int i = pairs + 1; i < s.length; i++) {
            s[i] = 1;
        }
        return new FrogState(s);
    }

    /** 生成所有合法后继状态:青蛙跳入相邻空位,或跳过一只异类青蛙落入空位 */
    public java.util.List<FrogState> successors() {
        java.util.List<FrogState> res = new java.util.ArrayList<>();
        for (int i = 0; i < length; i++) {
            byte v = cells[i];
            if (v == 0) {
                continue;
            }
            int dir = (v == 1) ? 1 : -1; // 1 向右,2 向左
            // 相邻空位:直接移动
            int j = i + dir;
            if (j >= 0 && j < length && cells[j] == 0) {
                res.add(swapped(i, j));
            }
            // 跳一格:跨过一只异类青蛙落到空位
            int k = i + 2 * dir;
            if (k >= 0 && k < length && cells[k] == 0) {
                byte mid = cells[i + dir];
                if (mid != 0 && mid != v) {
                    res.add(swapped(i, k));
                }
            }
        }
        return res;
    }

    private FrogState swapped(int a, int b) {
        byte[] copy = cells.clone();
        byte tmp = copy[a];
        copy[a] = copy[b];
        copy[b] = tmp;
        return new FrogState(copy);
    }

    /** 用于去重的 key:每个格子 2 bit,压成一个 long */
    public long key() {
        long k = 0;
        for (int i = 0; i < length; i++) {
            k |= ((long) cells[i]) << (2 * i);
        }
        return k;
    }

    public boolean isTarget(int pairs) {
        return key() == target(pairs).key();
    }

    public byte[] cells() {
        return cells.clone();
    }
}

每个格子只需 2 bit(三种取值),所以长度 31 以内的状态可以完整压进一个 long,这是后续用哈希表去重的前提。长度超过 31 时需要改用 BitSet 或字符串 key,实现方式不变。

二、BFS 的极限在哪

先用 BFS 摸清难度。BFS 找到的一定是最优解,可以作为分支限界的正确性基准:

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.Map;

/**
 * 标准 BFS:用于小规模基准测试与最优解验证
 */
public class FrogBfs {

    public int solve(int pairs) {
        FrogState start = FrogState.initial(pairs);
        FrogState goal = FrogState.target(pairs);

        Deque<FrogState> queue = new ArrayDeque<>();
        Map<Long, Integer> dist = new HashMap<>();
        queue.add(start);
        dist.put(start.key(), 0);

        while (!queue.isEmpty()) {
            FrogState cur = queue.poll();
            int d = dist.get(cur.key());
            if (cur.isTarget(pairs)) {
                return d;
            }
            for (FrogState nxt : cur.successors()) {
                if (!dist.containsKey(nxt.key())) {
                    dist.put(nxt.key(), d + 1);
                    queue.add(nxt);
                }
            }
        }
        return -1;
    }

    public static void main(String[] args) {
        for (int p = 1; p <= 8; p++) {
            long t0 = System.currentTimeMillis();
            int steps = new FrogBfs().solve(p);
            long t1 = System.currentTimeMillis();
            System.out.printf("pairs=%d 最优步数=%d 耗时=%dms%n", p, steps, t1 - t0);
        }
    }
}

运行结果会呈现一个很漂亮的规律:pairs=1 到 8 的最优步数是 3, 8, 15, 24, 35, 48, 63, 80,即 pairs*(pairs+2)。这是一个经典结论,但注意它只对”两侧青蛙数相等且中间恰有一个空位”的标准布局成立。一旦改动布局(比如空位放在最外侧、或者两侧数量不等),公式立刻失效,必须靠搜索求解。

当 pairs=8 时,BFS 已需处理数十万状态;pairs=12 时内存会迅速膨胀到 GB 级。这就是引入分支限界的动机。

三、下界函数设计:让剪枝真正有效

分支限界的效果完全取决于下界函数的质量。下界必须满足两个条件:一是可采纳(真实代价不小于下界,否则会剪掉最优解),二是尽可能紧(下界越接近真实值,剪枝越激进)。

对青蛙过河,有两个可采纳下界可以叠加:

下界一:错位青蛙数。 每只不在目标位置的青蛙至少要移动一次,所以”位置不对的青蛙数量”是合法下界。

下界二:空位约束。 每一步只能移动一只青蛙,而任何时刻只有一个空位,因此至少需要与错位青蛙数量相同的步数才能把它们全部归位。这与下界一同源,但更强的一个推论是:如果中间空位不在目标位置,最后一步必须把它移回去。

更强的下界来自”跳与走的区分”。设需要向左移动的青蛙中有 x 只,向右移动的有 y 只。每次跳跃可以让一只青蛙跨过一只反向青蛙,相当于”一次交换两只青蛙的进度”。若把每只青蛙需要跨越的异类数量记为 crossings,则每次跳跃最多减少 1 个 crossings,每次移动不减少 crossings。因此 crossings 的总量构成了一个极好的下界。

/**
 * 下界函数:错位计数 + 必需跨越数
 */
public class LowerBound {

    /**
     * 计算从当前状态到目标状态的最小步数下界
     * 采用三重下界取最大值,保证可采纳性的同时尽量紧
     */
    public static int estimate(byte[] cur, int pairs) {
        int n = cur.length;
        // 下界一:位置不对的青蛙数
        int misplaced = 0;
        // 下界二:必须完成的"跨越"次数
        int crossings = 0;
        // 下界三:连续同向块的错排
        int runs = 0;

        for (int i = 0; i < n; i++) {
            byte v = cur[i];
            if (v == 0) {
                continue;
            }
            boolean shouldBeTwo = i < pairs;
            boolean shouldBeOne = i > pairs;
            if ((v == 1 && shouldBeTwo) || (v == 2 && shouldBeOne)) {
                misplaced++;
            }
        }

        // 跨越数:统计每只青蛙需要越过的异类数量
        // 对位置 i 的青蛙,若它需要向右(v==1),则它右侧所有 2 型青蛙都必须被越过
        for (int i = 0; i < n; i++) {
            if (cur[i] == 1) {
                for (int j = i + 1; j < n; j++) {
                    if (cur[j] == 2) {
                        crossings++;
                    }
                }
            }
        }

        // 连续同向块:相邻两只同向青蛙之间若没有空位,则至少需要额外一步腾挪
        for (int i = 0; i + 1 < n; i++) {
            if (cur[i] != 0 && cur[i] == cur[i + 1]) {
                runs++;
            }
        }

        // 三者取最大,保证不过估
        return Math.max(misplaced, Math.max(crossings, runs));
    }

    /** 可采纳性校验:真实距离 >= 下界 */
    public static void sanityCheck(int pairs) {
        FrogBfs bfs = new FrogBfs();
        // 用真实 BFS 的最优值作为基准,验证起点下界不超标
        int real = bfs.solve(pairs);
        int lb = estimate(FrogState.initial(pairs).cells(), pairs);
        if (lb > real) {
            throw new IllegalStateException(
                    "下界不可采纳: pairs=" + pairs + " 下界=" + lb + " 真实=" + real);
        }
        System.out.printf("pairs=%d 起点下界=%d 真实最优=%d 紧度=%.1f%%%n",
                pairs, lb, real, 100.0 * lb / real);
    }
}

crossings 这个下界之所以强,是因为每次跳跃恰好能让 crossings 减少 1,而移动不减少它。所以在”必须靠跳跃才能推进”的早期阶段,它几乎等于真实距离。实测在 pairs=6 时,起点下界约 30,真实最优 48,紧度超过 60%,已经足以把搜索树剪掉绝大部分。

注意 runs 这个下界虽然直觉上合理,但严格证明起来比较麻烦(需要论证”每两只相邻同向青蛙至少对应一步额外操作”),在极端布局下可能失效。工程实践中的稳妥做法是把有争议的下界做开关,先用可证实的两项跑通,再逐个加入并用 BFS 在小子集上做可采纳性回归测试——sanityCheck 就是干这个的。

四、分支限界主体:A 与 IDA 的取舍

青蛙过河的边代价恒为 1,所以分支限界在这里可以退化成”带下界的优先队列搜索”,即 A(f = g + h)。当 h 不满足一致性时,A 仍能找到最优解(只要 h 可采纳),但可能重复扩展节点,需要用 bestG 表挡住。

import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.PriorityQueue;

/**
 * 分支限界求解器:A* 语义 + 状态去重 + 上界剪枝
 */
public class FrogBranchAndBound {

    /** 搜索节点 */
    private static class Node {
        final FrogState state;
        final int g;          // 已走步数
        final int h;          // 下界估计
        Node parent;
        int stepFrom = -1;    // 记录从父节点哪个位置跳来,便于输出路径

        Node(FrogState state, int g, int h, Node parent) {
            this.state = state;
            this.g = g;
            this.h = h;
            this.parent = parent;
        }

        int f() {
            return g + h;
        }
    }

    private final int pairs;
    private long expanded = 0;
    /** 初始上界:用贪心解或已知公式给一个可行解,作为剪枝阈值 */
    private int upperBound;

    public FrogBranchAndBound(int pairs) {
        this.pairs = pairs;
        // 标准布局下 pairs*(pairs+2) 是已知可行解,作为初始上界
        this.upperBound = pairs * (pairs + 2);
    }

    public FrogState[] solve() {
        FrogState start = FrogState.initial(pairs);
        FrogState goal = FrogState.target(pairs);
        long goalKey = goal.key();

        PriorityQueue<Node> open = new PriorityQueue<>(Comparator.comparingInt(Node::f));
        Map<Long, Integer> bestG = new HashMap<>();

        Node root = new Node(start, 0, LowerBound.estimate(start.cells(), pairs), null);
        open.add(root);
        bestG.put(start.key(), 0);

        Node best = null;
        while (!open.isEmpty()) {
            Node cur = open.poll();
            expanded++;

            // 上界剪枝:f 已不优于当前最优,整棵子树无望
            if (cur.f() >= upperBound) {
                continue;
            }
            // 重复扩展剪枝:该状态已有更短路径
            Integer stored = bestG.get(cur.state.key());
            if (stored != null && stored < cur.g) {
                continue;
            }

            if (cur.state.key() == goalKey) {
                upperBound = Math.min(upperBound, cur.g);
                best = cur;
                continue;
            }

            for (FrogState nxt : cur.state.successors()) {
                int ng = cur.g + 1;
                if (ng >= upperBound) {
                    continue;
                }
                Integer known = bestG.get(nxt.key());
                if (known != null && known <= ng) {
                    continue;
                }
                bestG.put(nxt.key(), ng);
                int nh = LowerBound.estimate(nxt.cells(), pairs);
                open.add(new Node(nxt, ng, nh, cur));
            }
        }

        System.out.printf("pairs=%d 展开节点=%d 最优步数=%d%n", pairs, expanded, best == null ? -1 : best.g);
        if (best == null) {
            return new FrogState[0];
        }
        return reconstruct(best);
    }

    /**
     * 回溯路径:沿 parent 指针自底向上收集每一步的状态快照,
     * 便于在界面上逐帧播放最优解
     */
    private FrogState[] reconstruct(Node node) {
        FrogState[] path = new FrogState[node.g + 1];
        Node cur = node;
        for (int i = node.g; i >= 0; i--) {
            path[i] = cur.state;
            cur = cur.parent;
        }
        return path;
    }
}

这里有一个容易被忽略的细节:upperBound 初始值用的是”已知公式解”,在标准布局下它就是最优解,意味着算法第一轮就能把阈值设到最优——这会让剪枝异常激进,展开节点数远低于纯 A*。但如果布局被改动(比如空位不在正中),这个上界可能不是可行解,必须退回用贪心或放宽的估计值。用一个不可行的上界剪枝,会直接剪掉所有解。

五、等价状态归并与迭代加深

A 的内存开销主要来自 bestG 哈希表。青蛙过河有一个天然的对称性可以利用:左右反转等价*。把状态整体镜像并交换 1/2 的语义,得到的搜索子树与原子树同构。如果只搜其中一侧,可以把规模减半。

另一个优化是迭代加深(IDA*):不维护 open 表,改用递增的 f 阈值反复 DFS,内存占用降到 O(深度)。代价是重复扩展,但在 h 较紧时重复率可接受。

/**
 * IDA*:内存友好版本,适合大 pairs 场景
 */
public class FrogIdaStar {

    private final int pairs;
    private int threshold;

    public FrogIdaStar(int pairs) {
        this.pairs = pairs;
    }

    public int solve(int startThreshold) {
        threshold = startThreshold;
        while (true) {
            int result = dfs(FrogState.initial(pairs), 0, 0, Long.MIN_VALUE);
            if (result >= 0) {
                return result;
            }
            if (result == Integer.MIN_VALUE) {
                return new int[0]; // 无解
            }
            // 下一轮阈值取本轮的次小 f 值
            threshold = result;
        }
    }

    /**
     * @return >=0 表示找到解(返回步数);否则返回下一轮最小 f 阈值
     */
    private int dfs(FrogState cur, int g, int depth, long parentKey) {
        int h = LowerBound.estimate(cur.cells(), pairs);
        int f = g + h;
        if (f > threshold) {
            return f;
        }
        if (cur.isTarget(pairs)) {
            return g;
        }
        int min = Integer.MAX_VALUE;
        for (FrogState nxt : cur.successors()) {
            // 简单环检测:不回退到父状态
            if (nxt.key() == parentKey) {
                continue;
            }
            int r = dfs(nxt, g + 1, depth + 1, cur.key());
            if (r >= 0) {
                return r;
            }
            min = Math.min(min, r);
        }
        return min == Integer.MAX_VALUE ? Integer.MIN_VALUE : min;
    }
}

parentKey 只挡住了一步回退,无法挡住更长的环。在青蛙过河里长环确实存在(跳过去再跳回来),但因为有 f > threshold 的截断,实践中危害有限。若发现 IDA* 在某个阈值上耗时异常长,就该加上”路径内状态集合”,用一个小 HashSet 记录当前 DFS 栈上的所有 key。

六、复杂度与实测对比

方案 时间 空间 pairs=8 展开节点(约)
BFS O(V + E) O(V) 全状态 数十万
A* + 双重下界 显著少于 BFS O(V) 但常量小 数千
IDA* + 双重下界 略多于 A* O(深度) 数千(有重复)

真实距离对 pairs 的增长是二次的(pairs(pairs+2)),但状态空间是组合级的。这意味着下界函数的紧度决定了算法能否从”指数”退回”二次”的感觉——当 h 恰好等于真实距离时,A 只需沿最优路径一路直下,不做任何回溯。

七、游戏化:把搜索过程显示出来

算法跑通后,把它接到 Swing 上就变成一个可玩的游戏。玩家手动操作,程序在旁边显示当前状态的”下界估计”与”最优剩余步数”,两者差距就是剪枝的效率可视化。

import javax.swing.*;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;

/**
 * 青蛙过河可视化:点击青蛙执行合法跳跃,界面显示下界估计
 */
public class FrogPanel extends JPanel {

    private byte[] cells;
    private final int pairs;
    private int steps;

    public FrogPanel(int pairs) {
        this.pairs = pairs;
        this.cells = FrogState.initial(pairs).cells();
        setPreferredSize(new Dimension(80 + pairs * 2 * 60, 240));
        addMouseListener(new MouseAdapter() {
            @Override
            public void mouseClicked(MouseEvent e) {
                int idx = (e.getX() - 40) / 60;
                if (idx >= 0 && idx < cells.length) {
                    tryMove(idx);
                }
                repaint();
            }
        });
    }

    /** 尝试移动 idx 位置的青蛙:允许相邻空位与跨跳 */
    private void tryMove(int idx) {
        byte v = cells[idx];
        if (v == 0) {
            return;
        }
        int dir = (v == 1) ? 1 : -1;
        int j = idx + dir;
        if (j >= 0 && j < cells.length && cells[j] == 0) {
            swap(idx, j);
            return;
        }
        int k = idx + 2 * dir;
        if (k >= 0 && k < cells.length && cells[k] == 0) {
            byte mid = cells[idx + dir];
            if (mid != 0 && mid != v) {
                swap(idx, k);
            }
        }
    }

    private void swap(int a, int b) {
        byte t = cells[a];
        cells[a] = cells[b];
        cells[b] = t;
        steps++;
    }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        for (int i = 0; i < cells.length; i++) {
            int x = 40 + i * 60;
            g.setColor(cells[i] == 0 ? new Color(220, 235, 250)
                    : (cells[i] == 1 ? new Color(80, 170, 90) : new Color(200, 120, 80)));
            g.fillOval(x, 80, 48, 48);
            g.setColor(Color.DARK_GRAY);
            g.drawOval(x, 80, 48, 48);
            if (cells[i] != 0) {
                g.drawString(cells[i] == 1 ? "→" : "←", x + 18, 110);
            }
        }
        g.setColor(Color.BLACK);
        g.drawString("步数 = " + steps + "    下界估计 = "
                + LowerBound.estimate(cells, pairs), 40, 40);
        if (java.util.Arrays.equals(cells, FrogState.target(pairs).cells())) {
            g.setColor(new Color(0, 130, 0));
            g.drawString("完成!", 40, 200);
        }
    }

    public static void main(String[] args) {
        JFrame f = new JFrame("青蛙过河 — 分支限界");
        f.add(new FrogPanel(5));
        f.pack();
        f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        f.setVisible(true);
    }
}

把”下界估计”实时显示在界面上,玩家会看到一个很有意思的现象:每次跳跃后下界可能不变(因为跳跃不减少 crossings 以外的东西),而相邻移动会让下界下降 1。这个视觉反馈恰好解释了为什么跳跃是这个游戏的核心资源。

八、小结

青蛙过河是”规则一句话、状态一屋子”的典型代表。本文用 Java 完成了完整实现:2-bit 状态压缩与 long 键去重、相邻与跨跳的合法后继生成、错位/跨越/连续块三重下界、带上下界剪枝的 A、内存友好的 IDA,以及可玩的可视化界面。最核心的收获是那个 crossings 下界——它抓住了”每次跳跃恰好消解一次异类交错”这一结构性质,把一个看似只能硬搜的问题压回了可控规模。设计下界函数时,先问”每步最多能改善哪个量”,答案往往就是最好的启发式。