每日算法 — 使用java实现过河问题:BFS状态空间搜索与约束满足

引言

过河问题(Bridge and Torch / Missionaries and Cannibals)是经典的约束满足与状态空间搜索问题。在漆黑的夜晚,若干人需要借助唯一的手电筒过桥,桥每次最多承载两人,且必须有人携带手电筒。不同的人过桥速度不同,两人同行时以较慢者速度为准。目标是让所有人安全过桥,且总耗时最短。

本文将用 Java 完整实现过河问题的 BFS 状态空间搜索,讲解状态表示、约束检查、最优解回溯等核心算法。

问题建模

状态表示

用二进制位表示每个人在桥的哪一侧:
– 0 表示在起始岸
– 1 表示在对岸

状态还需要记录手电筒位置(0 或 1),以及已用时间。

状态 = (人员位置位掩码, 手电筒位置, 已用时间)

约束条件

  1. 每次移动必须携带手电筒
  2. 每次最多移动 1~2 人
  3. 总时间最小化

核心算法:BFS + 优先队列

由于需要求最短时间,使用 Dijkstra 风格的优先队列 BFS:
– 每次取出当前时间最短的状态
– 尝试所有合法的人员移动组合
– 用访问集合去重

完整 Java 实现

import java.util.*;

/**
 * 过河问题求解器
 * 经典约束满足问题:多人在限定条件下过桥,求最短时间
 */
public class BridgeCrossingSolver {

    // 每个人的过桥时间(分钟)
    private final int[] crossingTimes;
    private final int n;

    public BridgeCrossingSolver(int[] crossingTimes) {
        this.crossingTimes = crossingTimes.clone();
        this.n = crossingTimes.length;
        Arrays.sort(this.crossingTimes); // 排序便于处理
    }

    /**
     * 状态类:记录人员位置、手电筒位置、已用时间
     */
    static class State implements Comparable<State> {
        // 位掩码:第i位为1表示第i个人在对岸
        int mask;
        // 手电筒位置:0=起始岸,1=对岸
        int torch;
        // 已用总时间
        int time;
        // 前驱状态(用于回溯路径)
        State prev;
        // 本次移动的人员列表
        List<Integer> moved;

        State(int mask, int torch, int time) {
            this.mask = mask;
            this.torch = torch;
            this.time = time;
        }

        @Override
        public int compareTo(State other) {
            return Integer.compare(this.time, other.time);
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof State)) return false;
            State state = (State) o;
            return mask == state.mask && torch == state.torch;
        }

        @Override
        public int hashCode() {
            return Objects.hash(mask, torch);
        }
    }

    /**
     * 使用优先队列BFS求解最短时间
     * 时间复杂度: O(2^n * n^2),空间复杂度: O(2^n)
     */
    public Result solve() {
        // 优先队列:按已用时间排序(Dijkstra)
        PriorityQueue<State> pq = new PriorityQueue<>();
        // 访问集合:记录(mask, torch)的最小时间
        Map<String, Integer> visited = new HashMap<>();

        // 初始状态:所有人都在起始岸,手电筒在起始岸,时间=0
        State start = new State(0, 0, 0);
        pq.offer(start);
        visited.put(key(0, 0), 0);

        while (!pq.isEmpty()) {
            State cur = pq.poll();

            // 终止条件:所有人都在对岸
            if (cur.mask == (1 << n) - 1 && cur.torch == 1) {
                return buildResult(cur);
            }

            // 如果当前状态不是最优,跳过
            String k = key(cur.mask, cur.torch);
            if (visited.get(k) < cur.time) continue;

            // 生成所有合法移动
            List<State> nextStates = generateMoves(cur);
            for (State next : nextStates) {
                String nk = key(next.mask, next.torch);
                Integer existing = visited.get(nk);
                if (existing == null || next.time < existing) {
                    visited.put(nk, next.time);
                    pq.offer(next);
                }
            }
        }

        return null; // 无解(理论上不会发生)
    }

    /**
     * 从当前状态生成所有合法下一步状态
     */
    private List<State> generateMoves(State cur) {
        List<State> moves = new ArrayList<>();

        // 手电筒在哪侧,就从哪侧选人过桥
        int side = cur.torch;

        // 找出在手电筒所在侧的所有人员
        List<Integer> peopleOnSide = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            int personSide = (cur.mask >> i) & 1;
            if (personSide == side) {
                peopleOnSide.add(i);
            }
        }

        // 尝试所有1人或2人组合
        int size = peopleOnSide.size();
        for (int i = 0; i < size; i++) {
            // 1人过桥
            int p1 = peopleOnSide.get(i);
            addMove(moves, cur, List.of(p1), crossingTimes[p1]);

            // 2人过桥
            for (int j = i + 1; j < size; j++) {
                int p2 = peopleOnSide.get(j);
                int cost = Math.max(crossingTimes[p1], crossingTimes[p2]);
                addMove(moves, cur, List.of(p1, p2), cost);
            }
        }

        return moves;
    }

    private void addMove(List<State> moves, State cur, List<Integer> people, int cost) {
        int newMask = cur.mask;
        for (int p : people) {
            // 翻转该人员的位置
            newMask ^= (1 << p);
        }
        int newTorch = 1 - cur.torch; // 手电筒移动到另一侧
        int newTime = cur.time + cost;

        State next = new State(newMask, newTorch, newTime);
        next.prev = cur;
        next.moved = new ArrayList<>(people);
        moves.add(next);
    }

    private String key(int mask, int torch) {
        return mask + "," + torch;
    }

    /**
     * 从终止状态回溯构建结果
     */
    private Result buildResult(State end) {
        List<Step> steps = new ArrayList<>();
        Deque<State> stack = new ArrayDeque<>();

        // 回溯到起点
        State cur = end;
        while (cur != null) {
            stack.push(cur);
            cur = cur.prev;
        }

        // 构建步骤列表
        State prev = null;
        while (!stack.isEmpty()) {
            cur = stack.pop();
            if (prev != null && cur.moved != null) {
                String direction = cur.torch == 1 ? "→" : "←";
                List<String> names = new ArrayList<>();
                for (int p : cur.moved) {
                    names.add("P" + p + "(" + crossingTimes[p] + "min)");
                }
                steps.add(new Step(direction, names, cur.time - prev.time, cur.time));
            }
            prev = cur;
        }

        return new Result(end.time, steps);
    }

    /**
     * 结果类
     */
    static class Result {
        final int totalTime;
        final List<Step> steps;

        Result(int totalTime, List<Step> steps) {
            this.totalTime = totalTime;
            this.steps = steps;
        }

        @Override
        public String toString() {
            StringBuilder sb = new StringBuilder();
            sb.append("最短总时间: ").append(totalTime).append(" 分钟\n");
            sb.append("详细步骤:\n");
            for (int i = 0; i < steps.size(); i++) {
                Step s = steps.get(i);
                sb.append(String.format("%d. %s %s, 耗时=%dmin, 累计=%dmin\n",
                    i + 1, s.direction, String.join(", ", s.people), s.cost, s.cumulative));
            }
            return sb.toString();
        }
    }

    static class Step {
        final String direction;
        final List<String> people;
        final int cost;
        final int cumulative;

        Step(String direction, List<String> people, int cost, int cumulative) {
            this.direction = direction;
            this.people = people;
            this.cost = cost;
            this.cumulative = cumulative;
        }
    }

    public static void main(String[] args) {
        // 经典问题:4人过桥,时间分别为1, 2, 5, 10 分钟
        // 最优解:17分钟
        int[] times1 = {1, 2, 5, 10};
        BridgeCrossingSolver solver1 = new BridgeCrossingSolver(times1);
        Result result1 = solver1.solve();
        System.out.println("=== 经典4人问题 ===");
        System.out.println(result1);

        // 5人过桥问题
        int[] times2 = {1, 2, 5, 10, 12};
        BridgeCrossingSolver solver2 = new BridgeCrossingSolver(times2);
        Result result2 = solver2.solve();
        System.out.println("\n=== 5人问题 ===");
        System.out.println(result2);

        // 6人过桥问题
        int[] times3 = {1, 2, 5, 10, 12, 15};
        BridgeCrossingSolver solver3 = new BridgeCrossingSolver(times3);
        Result result3 = solver3.solve();
        System.out.println("\n=== 6人问题 ===");
        System.out.println(result3);
    }
}

算法解析

状态空间分析

对于 n 个人,每个人有 2 种位置,手电筒有 2 种位置,总状态数为 n * 2^(n+1)。当 n=4 时仅 128 个状态,BFS 可以轻松处理。

为什么用 Dijkstra 而不是普通 BFS

普通 BFS 适用于无权图的最短路径。过河问题中,每条边(每次过桥)的”权重”不同(取决于谁过桥),因此需要用优先队列维护按时间排序的状态,这就是 Dijkstra 算法的核心思想。

最优性证明

优先队列保证每次扩展的都是当前时间最短的状态。当第一次到达目标状态时,其时间必然是最小的——这与 Dijkstra 算法的最优性证明完全一致。

复杂度分析

指标 复杂度 说明
时间复杂度 O(2^n × n²) 状态数 × 每状态转移数
空间复杂度 O(2^n) 访问集合与优先队列
实际性能 n≤10 毫秒级 状态空间随 n 指数增长

扩展思考

  1. 传教士与野人问题:增加约束(任何一侧野人不超过传教士),只需在状态转移时增加约束检查即可。
  2. 多桥问题:如果有多个桥,状态空间扩展为多维。
  3. A* 优化:使用启发函数(如剩余最慢者过桥时间的下界)加速搜索。

总结

过河问题完美展示了状态空间搜索的范式:定义状态 → 确定转移规则 → 添加约束 → 选择搜索策略 → 回溯最优解。掌握这一框架,可解决绝大多数组合优化类问题。