每日算法 — 使用java实现过桥问题:贪心策略与动态规划的最优时间求解

过桥问题是一个经典的逻辑谜题,也是算法面试中的常客。场景如下:夜晚,有四个人需要过一座桥,他们只有一只手电筒。桥每次最多容纳两人,且必须携带手电筒才能过桥。四个人的过桥时间分别为1分钟、2分钟、5分钟和10分钟。当两人一起过桥时,速度由较慢者决定。手电筒不能扔回,必须由某人带回来。问:四人全部过桥的最短总时间是多少?

直观上你可能认为答案显而易见,但最优解往往出人意料。本文将用Java实现过桥问题的通用求解器,讲解贪心策略的局限性,以及如何通过动态规划获得严格最优解。

问题分析:贪心为何可能失效

许多人首先想到的贪心策略是:让过桥最快的人(1分钟者)作为”摆渡人”,每次带一个人过桥并返回送手电筒。对于(1, 2, 5, 10)这个经典案例:

  • 1和2过桥 → 2分钟
  • 1返回 → 1分钟
  • 1和5过桥 → 5分钟
  • 1返回 → 1分钟
  • 1和10过桥 → 10分钟

总时间 = 2 + 1 + 5 + 1 + 10 = 19分钟

但这并不是最优解!最优策略是:

  • 1和2过桥 → 2分钟
  • 1返回 → 1分钟
  • 5和10过桥 → 10分钟
  • 2返回 → 2分钟
  • 1和2过桥 → 2分钟

总时间 = 2 + 1 + 10 + 2 + 2 = 17分钟

关键洞察是:让两个最慢的人一起过桥,虽然单次花费10分钟,但避免了分别由最快者陪同(5+10=15分钟)的更大开销。

状态表示:位掩码压缩

对于N个人的过桥问题,一个关键观察是:任意时刻,每个人要么在起始岸,要么在目标岸。因此可以用一个N位二进制数表示状态,第i位为1表示第i个人已过桥(在目标岸)。

/**
 * 过桥问题求解器
 * 使用位掩码表示状态,动态规划求解最优解
 */
public class BridgeCrossingSolver {
    private final int[] times;      // 每个人的过桥时间
    private final int n;            // 人数
    private final int fullMask;     // 全1掩码,表示所有人都已过桥

    public BridgeCrossingSolver(int[] times) {
        this.times = times.clone();
        Arrays.sort(this.times);    // 按过桥时间排序,方便处理
        this.n = times.length;
        this.fullMask = (1 << n) - 1;
    }

动态规划状态转移

设 dp[mask] 表示从初始状态(所有人在起始岸)到状态 mask(mask中1表示已过桥的人)所需的最短时间。

由于手电筒可能在任意一岸,我们需要区分状态:设 dp[mask] 为手电筒在目标岸(即刚有人过桥到达)时,对应mask的最短时间。初始状态 dp[0] = 0。

状态转移有两种模式(假设手电筒在目标岸,即mask中1的人所在的一侧):

  1. 模式A(最快者摆渡):让目标岸最快的人带手电筒返回,然后起始岸选两人过桥。等价于从mask状态出发,选mask中的一个人返回,再选不在mask中的两人过桥。

  2. 模式B(次快者摆渡配合):这是更巧妙的策略——起始岸最快的两人先过桥,最快者返回,目标岸最慢的两人过桥,次快者返回。这种策略在某些情况下优于模式A。

实际上,更简洁的DP思路是:设 dp[mask] 为手电筒在目标岸时的最短时间。要扩展状态,需要有人从目标岸带手电筒返回起始岸,然后起始岸选两人过桥。但由于状态数达到 2^N,对于N ≤ 15的情况完全可行。

    /**
     * 动态规划求解最优过桥时间
     * dp[mask]:手电筒在目标岸,已过桥的人由mask表示时的最短时间
     */
    public int solve() {
        int[] dp = new int[1 << n];
        Arrays.fill(dp, Integer.MAX_VALUE / 2);
        dp[0] = 0; // 初始状态:无人过桥,手电筒在起始岸

        // 使用BFS/DP按mask从小到大更新
        for (int mask = 0; mask <= fullMask; mask++) {
            if (dp[mask] == Integer.MAX_VALUE / 2) continue;

            // 计算当前哪些人在起始岸(未过桥)
            boolean[] onStart = new boolean[n];
            for (int i = 0; i < n; i++) {
                onStart[i] = (mask & (1 << i)) == 0;
            }

            // 手电筒在起始岸:选两人过桥 -> 到目标岸
            // 或者手电筒在目标岸:选一人返回 -> 到起始岸
            // 我们需要跟踪手电筒位置。简化:用dp[mask]表示手电筒在目标岸的最短时间
            // 那么上一步一定是从起始岸两人过桥过来的
        }

上面的思路有些混乱。让我采用更清晰的双状态DP:设 dp[mask][0] 为手电筒在起始岸的最短时间,dp[mask][1] 为手电筒在目标岸的最短时间。但这会浪费空间。

更好的方法:直接枚举每一步的具体操作。由于N很小,我们可以用记忆化搜索。

    private int[][] memo;

    /**
     * 记忆化搜索
     * @param mask 已过桥的人(在目标岸)
     * @param torchAtGoal true表示手电筒在目标岸
     * @return 从当前状态到全部过桥的最短剩余时间
     */
    private int dfs(int mask, boolean torchAtGoal) {
        if (mask == fullMask) return 0; // 所有人都已过桥

        int key = mask | (torchAtGoal ? (1 << n) : 0);
        if (memo[key] != -1) return memo[key];

        int result = Integer.MAX_VALUE / 2;

        if (torchAtGoal) {
            // 手电筒在目标岸:选一人带手电筒返回起始岸
            for (int i = 0; i < n; i++) {
                if ((mask & (1 << i)) != 0) { // i已过桥
                    int newMask = mask ^ (1 << i);
                    result = Math.min(result, times[i] + dfs(newMask, false));
                }
            }
        } else {
            // 手电筒在起始岸:选两人带手电筒过桥到目标岸
            for (int i = 0; i < n; i++) {
                if ((mask & (1 << i)) != 0) continue; // i已过桥,不在起始岸
                for (int j = i + 1; j < n; j++) {
                    if ((mask & (1 << j)) != 0) continue; // j已过桥
                    int newMask = mask | (1 << i) | (1 << j);
                    int crossTime = Math.max(times[i], times[j]);
                    result = Math.min(result, crossTime + dfs(newMask, true));
                }
            }
        }

        memo[key] = result;
        return result;
    }

完整求解器代码与路径重建

import java.util.*;

public class BridgeCrossingSolver {
    private final int[] times;
    private final int n;
    private final int fullMask;
    private int[][] memo;

    public BridgeCrossingSolver(int[] times) {
        this.times = times.clone();
        Arrays.sort(this.times);
        this.n = times.length;
        this.fullMask = (1 << n) - 1;
    }

    public int solve() {
        memo = new int[(1 << (n + 1))];
        Arrays.fill(memo, -1);
        return dfs(0, false);
    }

    private int dfs(int mask, boolean torchAtGoal) {
        if (mask == fullMask) return 0;
        int key = mask | (torchAtGoal ? (1 << n) : 0);
        if (memo[key] != -1) return memo[key];

        int result = Integer.MAX_VALUE / 2;
        if (torchAtGoal) {
            for (int i = 0; i < n; i++) {
                if ((mask & (1 << i)) != 0) {
                    int newMask = mask ^ (1 << i);
                    result = Math.min(result, times[i] + dfs(newMask, false));
                }
            }
        } else {
            for (int i = 0; i < n; i++) {
                if ((mask & (1 << i)) != 0) continue;
                for (int j = i + 1; j < n; j++) {
                    if ((mask & (1 << j)) != 0) continue;
                    int newMask = mask | (1 << i) | (1 << j);
                    int crossTime = Math.max(times[i], times[j]);
                    result = Math.min(result, crossTime + dfs(newMask, true));
                }
            }
        }
        memo[key] = result;
        return result;
    }

    /**
     * 重建最优路径
     */
    public List<String> getOptimalPath() {
        List<String> path = new ArrayList<>();
        int mask = 0;
        boolean torchAtGoal = false;
        int totalTime = 0;

        while (mask != fullMask) {
            int bestNext = -1;
            int bestTime = Integer.MAX_VALUE / 2;
            boolean nextTorch = !torchAtGoal;

            if (torchAtGoal) {
                for (int i = 0; i < n; i++) {
                    if ((mask & (1 << i)) == 0) continue;
                    int newMask = mask ^ (1 << i);
                    int candidate = times[i] + dfs(newMask, false);
                    if (candidate < bestTime) {
                        bestTime = candidate;
                        bestNext = i;
                    }
                }
                int newMask = mask ^ (1 << bestNext);
                totalTime += times[bestNext];
                path.add(String.format("%d号(%d分钟) 返回起始岸 [累计 %d分钟]",
                    bestNext + 1, times[bestNext], totalTime));
                mask = newMask;
            } else {
                for (int i = 0; i < n; i++) {
                    if ((mask & (1 << i)) != 0) continue;
                    for (int j = i + 1; j < n; j++) {
                        if ((mask & (1 << j)) != 0) continue;
                        int newMask = mask | (1 << i) | (1 << j);
                        int crossTime = Math.max(times[i], times[j]);
                        int candidate = crossTime + dfs(newMask, true);
                        if (candidate < bestTime) {
                            bestTime = candidate;
                            bestNext = i | (j << 8);
                        }
                    }
                }
                int i = bestNext & 0xFF;
                int j = (bestNext >> 8) & 0xFF;
                int crossTime = Math.max(times[i], times[j]);
                totalTime += crossTime;
                path.add(String.format("%d号(%d分钟) 和 %d号(%d分钟) 过桥 -> %d分钟 [累计 %d分钟]",
                    i + 1, times[i], j + 1, times[j], crossTime, totalTime));
                mask |= (1 << i) | (1 << j);
            }
            torchAtGoal = nextTorch;
        }
        return path;
    }

    public static void main(String[] args) {
        int[] times = {1, 2, 5, 10};
        BridgeCrossingSolver solver = new BridgeCrossingSolver(times);
        int minTime = solver.solve();
        System.out.println("最短过桥时间: " + minTime + " 分钟");
        System.out.println("\n最优方案:");
        for (String step : solver.getOptimalPath()) {
            System.out.println(step);
        }
    }
}

贪心近似算法

对于人数较多的情况(N > 15),精确DP会因 2^N 的复杂度而失效。此时可以使用贪心策略获得近似解。核心思路是:每次让两个最慢的人过桥时,比较两种策略的开销,选择较小的:

  • 策略1(最快者摆渡):t[0] + 2*t[1] + t[n-1]
  • 策略2(双慢同行):2*t[0] + t[n-2] + t[n-1]
    /**
     * 贪心近似算法,适用于大规模问题
     */
    public int greedySolve() {
        int[] t = times.clone();
        Arrays.sort(t);
        int total = 0;
        int left = n;

        while (left > 3) {
            // 策略1: t[0]带t[n-1]过,t[0]回,t[0]带t[n-2]过,t[0]回
            int strategy1 = t[0] + 2 * t[1] + t[left - 1];
            // 策略2: t[0]带t[1]过,t[0]回,t[n-2]带t[n-1]过,t[1]回
            int strategy2 = 2 * t[0] + t[left - 2] + t[left - 1];
            total += Math.min(strategy1, strategy2);
            left -= 2;
        }

        if (left == 3) {
            total += t[0] + t[1] + t[2];
        } else if (left == 2) {
            total += t[1];
        } else {
            total += t[0];
        }
        return total;
    }

复杂度分析

  • 精确DP:时间复杂度 O(2^N × N^2),空间复杂度 O(2^N)。适用于 N ≤ 20。
  • 贪心近似:时间复杂度 O(N log N),空间复杂度 O(1)。适用于大规模问题,对于经典案例(1,2,5,10)也能给出最优解17分钟。

总结

过桥问题深刻展示了贪心策略的陷阱:局部最优(每次让最快者返回)不一定导致全局最优。通过动态规划,我们可以系统地搜索所有可能的策略组合,找到严格的最优解。这个思想在任务调度、资源分配等领域有广泛应用。