每日算法 — 使用java实现海盗分金:逆向归纳法与博弈论推理

引言

“海盗分金”是博弈论中最经典的逻辑推理谜题之一:五名海盗抢到100枚金币,他们按等级从高到低依次提出分配方案。如果方案被半数或以上海盗接受,就按方案分配;否则提出者被扔下海,由下一位海盗继续提案。假设所有海盗都绝顶聪明、理性且优先追求保命,其次多捞金币,再次乐于看到别人被扔下海。

这个看似简单的规则设定,隐藏着深刻的博弈论原理:逆向归纳法(Backward Induction)。本文将用Java实现这一经典推理过程,展示如何通过从终局向起点逆向推导,得出反直觉的最优分配方案。

问题建模

玩家属性

每名海盗有三个决策优先级(按重要性降序):
1. 保命:活下去是最高优先级
2. 多金:在保命前提下,尽可能多获得金币
3. 嗜血:在前两者相同的情况下,倾向于把人扔下海

投票规则

对于 N 名海盗的方案,需要至少 ⌈N/2⌉ 票(包括提案者自己)才能通过。

核心思想:逆向归纳

不从第一名海盗开始思考,而是从只剩最后一名海盗的”终局”开始,逐步向前推导。因为在每个子博弈中,理性玩家的选择是确定的。

算法设计

状态表示

用 (pirates, coins, startIndex) 表示状态:
– pirates:当前存活的海盗列表(按等级从高到低排序)
– coins:剩余金币数
– startIndex:当前轮到提案的海盗在列表中的索引

逆向归纳递推

对于每个状态,当前提案者需要决定如何分配金币,使得足够数量的海盗投赞成票。

关键观察:每个海盗投赞成票的条件是——接受该方案后的收益 > 拒绝该方案后的收益。如果收益相等,由于嗜血属性,他们会选择拒绝。

因此,提案者需要精确计算每个海盗在”自己死后”的子博弈中能获得的收益,然后用更优的报价收买他们。

Java实现

import java.util.*;

/**
 * 海盗分金博弈求解器
 * 使用逆向归纳法计算N名海盗分M枚金币的最优分配方案
 */
public class PirateGoldSolver {

    /**
     * 海盗类,包含等级、姓名等属性
     */
    static class Pirate {
        final int rank;      // 等级,数字越小等级越高(0为最高)
        final String name;   // 海盗名称

        public Pirate(int rank, String name) {
            this.rank = rank;
            this.name = name;
        }

        @Override
        public String toString() {
            return name + "(等级" + rank + ")";
        }
    }

    /**
     * 分配方案结果
     */
    static class Allocation {
        final int[] distribution;  // 每个海盗分得的金币
        final boolean passes;      // 方案是否通过
        final String reasoning;    // 推理过程描述

        public Allocation(int[] distribution, boolean passes, String reasoning) {
            this.distribution = distribution;
            this.passes = passes;
            this.reasoning = reasoning;
        }
    }

    private final Map<String, Allocation> memo = new HashMap<>();

    /**
     * 求解海盗分金问题
     * @param numPirates 海盗数量
     * @param totalCoins 金币总数
     * @return 最优分配方案
     */
    public Allocation solve(int numPirates, int totalCoins) {
        List<Pirate> pirates = new ArrayList<>();
        for (int i = 0; i < numPirates; i++) {
            pirates.add(new Pirate(i, "海盗" + (i + 1)));
        }
        return solveRecursive(pirates, totalCoins, 0);
    }

    /**
     * 递归求解子博弈
     * @param pirates 当前存活的海盗列表(按等级排序)
     * @param coins 剩余金币
     * @param proposerIndex 当前提案者的索引
     * @return 最优分配方案
     */
    private Allocation solveRecursive(List<Pirate> pirates, int coins, int proposerIndex) {
        // 状态编码用于记忆化
        String stateKey = encodeState(pirates, coins, proposerIndex);
        if (memo.containsKey(stateKey)) {
            return memo.get(stateKey);
        }

        int n = pirates.size();

        // 终局情况:只剩一名海盗
        if (n == 1) {
            int[] dist = new int[]{coins};
            String reason = pirates.get(0).name + "独揽全部" + coins + "枚金币";
            Allocation result = new Allocation(dist, true, reason);
            memo.put(stateKey, result);
            return result;
        }

        // 计算需要的赞成票数(至少半数,向上取整)
        int requiredVotes = (n + 1) / 2;

        // 当前提案者
        Pirate proposer = pirates.get(proposerIndex);

        // 计算如果当前提案者被扔下海,每个海盗在子博弈中的收益
        Map<Pirate, Integer> futurePayoff = new HashMap<>();
        for (int i = 0; i < n; i++) {
            if (i != proposerIndex) {
                // 模拟移除当前提案者后的子博弈
                List<Pirate> remaining = new ArrayList<>(pirates);
                remaining.remove(proposerIndex);
                // 下一个提案者是当前列表中索引为 proposerIndex 的海盗
                // 如果proposerIndex是最后一个,则下一个是0;否则保持proposerIndex
                int nextProposer = proposerIndex >= remaining.size() ? 0 : proposerIndex;
                Allocation future = solveRecursive(remaining, coins, nextProposer);

                // 找到该海盗在子博弈中的收益
                // 注意:remaining列表中索引可能变化,需要按rank匹配
                Pirate p = pirates.get(i);
                int futureIdx = -1;
                for (int j = 0; j < remaining.size(); j++) {
                    if (remaining.get(j).rank == p.rank) {
                        futureIdx = j;
                        break;
                    }
                }
                int payoff = (futureIdx >= 0) ? future.distribution[futureIdx] : 0;
                futurePayoff.put(p, payoff);
            }
        }

        // 提案者需要收买 (requiredVotes - 1) 名海盗
        // 策略:收买那些"未来收益最低"的海盗,因为收买他们的成本最低
        // 每个被收买者需要获得:futurePayoff + 1(严格大于未来收益)

        // 按未来收益升序排序(优先收买未来收益低的)
        List<Map.Entry<Pirate, Integer>> sorted = new ArrayList<>(futurePayoff.entrySet());
        sorted.sort(Comparator.comparingInt(Map.Entry::getValue));

        // 尝试构造方案:收买刚好足够的海盗
        int[] distribution = new int[n];
        Arrays.fill(distribution, 0);

        // 提案者自己 always 投赞成票
        int votes = 1;
        int remainingCoins = coins;

        // 需要收买的海盗列表
        List<Pirate> toBribe = new ArrayList<>();
        int bribeCost = 0;

        for (int i = 0; i < sorted.size() && votes < requiredVotes; i++) {
            Pirate p = sorted.get(i).getKey();
            int future = sorted.get(i).getValue();
            int bribe = future + 1;  // 必须严格大于未来收益

            if (bribeCost + bribe <= coins) {
                toBribe.add(p);
                bribeCost += bribe;
                votes++;
            } else {
                // 金币不足以收买这名海盗,尝试下一个
                continue;
            }
        }

        // 如果无法凑够票数,方案不通过
        if (votes < requiredVotes) {
            String reason = proposer.name + "无法凑够" + requiredVotes + "票,被扔下海";
            Allocation result = new Allocation(new int[n], false, reason);
            memo.put(stateKey, result);
            return result;
        }

        // 构造分配方案
        for (Pirate p : toBribe) {
            int idx = -1;
            for (int i = 0; i < n; i++) {
                if (pirates.get(i).rank == p.rank) {
                    idx = i;
                    break;
                }
            }
            if (idx >= 0) {
                int future = futurePayoff.get(p);
                distribution[idx] = future + 1;
                remainingCoins -= distribution[idx];
            }
        }

        // 提案者拿走剩余金币
        distribution[proposerIndex] = remainingCoins;

        // 构建推理说明
        StringBuilder reason = new StringBuilder();
        reason.append(proposer.name).append("提出方案:");
        for (int i = 0; i < n; i++) {
            reason.append(pirates.get(i).name).append("=").append(distribution[i]).append(" ");
        }
        reason.append("\n收买逻辑:");
        for (Pirate p : toBribe) {
            reason.append(p.name).append("未来只能得").append(futurePayoff.get(p))
                  .append(",现给").append(futurePayoff.get(p) + 1).append(";");
        }

        Allocation result = new Allocation(distribution, true, reason.toString());
        memo.put(stateKey, result);
        return result;
    }

    /**
     * 编码状态用于记忆化
     */
    private String encodeState(List<Pirate> pirates, int coins, int proposerIndex) {
        StringBuilder sb = new StringBuilder();
        sb.append(coins).append("_").append(proposerIndex).append("_");
        for (Pirate p : pirates) {
            sb.append(p.rank).append(",");
        }
        return sb.toString();
    }

    /**
     * 打印完整的逆向推理过程
     */
    public void printReasoning(int numPirates, int totalCoins) {
        System.out.println("=== 海盗分金逆向归纳推理 ===\n");
        System.out.println("海盗数量:" + numPirates);
        System.out.println("金币总数:" + totalCoins);
        System.out.println("投票规则:至少半数赞成(含提案者)\n");

        // 从终局开始逐步展示
        for (int n = 1; n <= numPirates; n++) {
            System.out.println("--- 当剩余 " + n + " 名海盗时 ---");
            Allocation alloc = solve(n, totalCoins);
            System.out.println(alloc.reasoning);
            System.out.println("方案通过:" + alloc.passes);
            if (alloc.passes) {
                System.out.print("分配结果:");
                for (int i = 0; i < alloc.distribution.length; i++) {
                    System.out.print("海盗" + (i + 1) + "=" + alloc.distribution[i] + " ");
                }
            }
            System.out.println("\n");
        }
    }

    /**
     * 主程序:运行经典5海盗100金币问题
     */
    public static void main(String[] args) {
        PirateGoldSolver solver = new PirateGoldSolver();

        // 经典问题:5名海盗,100枚金币
        int numPirates = 5;
        int totalCoins = 100;

        System.out.println("海盗分金问题求解器\n");
        System.out.println("规则:");
        System.out.println("1. 按等级从高到低依次提案");
        System.out.println("2. 需要至少半数(含提案者)赞成");
        System.out.println("3. 优先级:保命 > 多金 > 嗜血\n");

        solver.printReasoning(numPirates, totalCoins);

        // 最终方案
        Allocation finalAlloc = solver.solve(numPirates, totalCoins);
        System.out.println("=== 最终最优方案 ===");
        System.out.println(finalAlloc.reasoning);
        System.out.print("金币分配:");
        for (int i = 0; i < finalAlloc.distribution.length; i++) {
            System.out.print("海盗" + (i + 1) + "=" + finalAlloc.distribution[i] + " ");
        }
    }
}

经典结论分析

对于5名海盗分100枚金币的经典问题,逆向归纳的推理链如下:

终局分析

  • 1人局:海盗5独得100枚
  • 2人局:海盗4提案,需要2票(全票)。海盗5知道若海盗4死则自己得100,因此会反对任何不给100的方案。海盗4无法收买海盗5,方案不通过,海盗4被扔下海。海盗5得100。
  • 3人局:海盗3提案,需要2票。海盗4知道若海盗3死则自己也会死(2人局分析),因此只要给海盗4 1枚(大于0),海盗4就会支持。海盗3得99,海盗4得1,海盗5得0。
  • 4人局:海盗2提案,需要2票。海盗3在3人局中得99,海盗4得1,海盗5得0。海盗2只需收买海盗5(给1枚)即可凑够2票。分配:海盗2得99,海盗3得0,海盗4得0,海盗5得1。
  • 5人局:海盗1提案,需要3票。海盗2在4人局中得99,海盗3得0,海盗4得0,海盗5得1。海盗1只需收买海盗3和海盜4(各给1枚)即可凑够3票。分配:海盗1得98,海盗2得0,海盗3得1,海盗4得1,海盗5得0。

反直觉的结论

最高等级的海盗1不仅活了下来,还拿走了98枚金币!这个结果完全违背直觉,却是理性博弈的必然结果。关键在于: proposal者利用了自己”先下手为强”的优势,以及对下游海盗命运的掌控力。

复杂度分析

  • 状态数:O(N!) 种海盗存活组合,但由于本题中海盗按固定顺序被淘汰,实际状态数为 O(N^2)(子问题规模从1到N)。
  • 单次决策:对于N名海盗,需要分析N-1名海盗的子博弈,并排序 O(N log N)。
  • 总时间复杂度:O(N^3),对于小规模问题(N < 100)瞬间完成。
  • 空间复杂度:O(N^2) 用于记忆化存储。

扩展思考

  1. 非线性效用:如果海盗的金币效用不是线性的(例如边际效用递减),收买策略会如何变化?
  2. 联盟与背叛:允许多轮谈判和临时联盟时,核心(Core)和沙普利值(Shapley Value)如何应用?
  3. 信息不完全:如果海盗不知道彼此的优先级排序,问题变为贝叶斯博弈。
  4. 动态海盗:如果新海盗可以不断加入,形成无限重复博弈,稳定的分配规则是什么?

总结

海盗分金问题以极简的规则展示了博弈论的精髓:理性参与者通过预测他人的理性行为,逆向推导出当前的最优策略。Java实现中的递归结构和记忆化技术,完美映射了逆向归纳法的数学本质。

这个结果也提醒我们:在层级结构中,高位者拥有不成比例的议价能力——不是因为他们更强壮,而是因为他们掌握了”提出方案”的结构性优势。这在公司治理、政治博弈乃至日常谈判中都有深刻的现实映照。