每日算法 — 使用java实现传金币:BFS状态空间搜索与博弈论最优传递策略

引言

“传金币”是一类经典的组合博弈问题:n个人围坐一圈,每人持有若干金币,每次只能将一枚金币传给相邻的人,目标是使所有人金币数相等。本文用Java实现该问题的最优求解算法,涵盖BFS状态空间搜索、博弈论中的纳什均衡思想,以及剪枝优化策略。

一、问题建模

1.1 状态定义

将n个人的金币数表示为一个状态向量,目标状态为每人平均金币数:

public class CoinState {
    public final int[] coins;      // 每人持有的金币数
    public final int moves;        // 已执行的传递次数
    public final CoinState parent; // 父状态,用于回溯路径

    public CoinState(int[] coins, int moves, CoinState parent) {
        this.coins = coins.clone();
        this.moves = moves;
        this.parent = parent;
    }

    /**
     * 计算状态的唯一哈希值,用于去重
     */
    @Override
    public int hashCode() {
        return Arrays.hashCode(coins);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (!(obj instanceof CoinState)) return false;
        return Arrays.equals(coins, ((CoinState) obj).coins);
    }

    /**
     * 检查是否达到目标状态
     */
    public boolean isGoal(int target) {
        for (int c : coins) {
            if (c != target) return false;
        }
        return true;
    }
}

1.2 合法移动生成

每次移动将一枚金币从位置i传给相邻的(i+1)%n或(i-1+n)%n:

public class CoinTransferGame {
    private final int n;           // 人数
    private final int target;      // 目标金币数
    private final int[] initial;   // 初始状态

    public CoinTransferGame(int[] initial) {
        this.initial = initial;
        this.n = initial.length;
        int sum = Arrays.stream(initial).sum();
        if (sum % n != 0) {
            throw new IllegalArgumentException("金币总数无法均分");
        }
        this.target = sum / n;
    }

    /**
     * 生成当前状态的所有后继状态
     * 每个位置可以向左右邻居传递一枚金币(如果有的话)
     */
    public List<CoinState> getNeighbors(CoinState state) {
        List<CoinState> neighbors = new ArrayList<>();
        int[] c = state.coins;

        for (int i = 0; i < n; i++) {
            if (c[i] <= 0) continue; // 没有金币可传

            // 传给左边邻居 (i-1)
            int left = (i - 1 + n) % n;
            int[] newLeft = c.clone();
            newLeft[i]--;
            newLeft[left]++;
            neighbors.add(new CoinState(newLeft, state.moves + 1, state));

            // 传给右边邻居 (i+1)
            int right = (i + 1) % n;
            if (right != left) { // n=2时左右是同一个
                int[] newRight = c.clone();
                newRight[i]--;
                newRight[right]++;
                neighbors.add(new CoinState(newRight, state.moves + 1, state));
            }
        }
        return neighbors;
    }
}

二、BFS最优解搜索

2.1 标准BFS实现

由于每次传递的代价相同(均为1次操作),BFS保证找到最少传递次数:

public class BFSSolver {
    private final CoinTransferGame game;

    public BFSSolver(CoinTransferGame game) {
        this.game = game;
    }

    /**
     * BFS搜索最少传递次数
     * @return 最优解的状态链,null表示无解
     */
    public CoinState solve() {
        Queue<CoinState> queue = new LinkedList<>();
        Set<CoinState> visited = new HashSet<>();

        CoinState start = new CoinState(game.getInitial(), 0, null);
        queue.offer(start);
        visited.add(start);

        int explored = 0;
        while (!queue.isEmpty()) {
            CoinState current = queue.poll();
            explored++;

            if (current.isGoal(game.getTarget())) {
                System.out.println("BFS探索状态数: " + explored);
                return current;
            }

            for (CoinState next : game.getNeighbors(current)) {
                if (!visited.contains(next)) {
                    visited.add(next);
                    queue.offer(next);
                }
            }
        }
        return null; // 无解
    }

    /**
     * 回溯打印解路径
     */
    public void printSolution(CoinState goal) {
        List<CoinState> path = new ArrayList<>();
        CoinState cur = goal;
        while (cur != null) {
            path.add(cur);
            cur = cur.parent;
        }
        Collections.reverse(path);

        System.out.println("最少传递次数: " + goal.moves);
        for (int i = 0; i < path.size(); i++) {
            System.out.printf("Step %d: %s%n", i, Arrays.toString(path.get(i).coins));
        }
    }
}

2.2 双向BFS优化

当状态空间较大时,双向BFS可显著降低搜索范围:

public class BidirectionalBFSSolver {

    public CoinState solve(CoinTransferGame game) {
        // 构造目标状态
        int[] goalArr = new int[game.getN()];
        Arrays.fill(goalArr, game.getTarget());
        CoinState goalState = new CoinState(goalArr, 0, null);

        Queue<CoinState> qStart = new LinkedList<>();
        Queue<CoinState> qGoal = new LinkedList<>();
        Map<CoinState, CoinState> visitedStart = new HashMap<>();
        Map<CoinState, CoinState> visitedGoal = new HashMap<>();

        CoinState start = new CoinState(game.getInitial(), 0, null);
        qStart.offer(start);
        qGoal.offer(goalState);
        visitedStart.put(start, null);
        visitedGoal.put(goalState, null);

        while (!qStart.isEmpty() && !qGoal.isEmpty()) {
            // 优先扩展规模较小的一侧
            if (qStart.size() <= qGoal.size()) {
                CoinState meet = expandLayer(qStart, visitedStart, visitedGoal, game, true);
                if (meet != null) return reconstruct(meet, visitedStart, visitedGoal);
            } else {
                CoinState meet = expandLayer(qGoal, visitedGoal, visitedStart, game, false);
                if (meet != null) return reconstruct(meet, visitedStart, visitedGoal);
            }
        }
        return null;
    }

    private CoinState expandLayer(Queue<CoinState> q, Map<CoinState, CoinState> myVisited,
                                   Map<CoinState, CoinState> otherVisited,
                                   CoinTransferGame game, boolean fromStart) {
        int size = q.size();
        for (int i = 0; i < size; i++) {
            CoinState cur = q.poll();
            for (CoinState next : game.getNeighbors(cur)) {
                if (myVisited.containsKey(next)) continue;
                myVisited.put(next, cur);
                if (otherVisited.containsKey(next)) {
                    return next; // 相遇
                }
                q.offer(next);
            }
        }
        return null;
    }

    private CoinState reconstruct(CoinState meet,
                                   Map<CoinState, CoinState> fromStart,
                                   Map<CoinState, CoinState> fromGoal) {
        // 拼接双向路径
        List<CoinState> path = new ArrayList<>();
        CoinState cur = meet;
        while (cur != null) {
            path.add(cur);
            cur = fromStart.get(cur);
        }
        Collections.reverse(path);

        cur = fromGoal.get(meet);
        while (cur != null) {
            path.add(cur);
            cur = fromGoal.get(cur);
        }

        // 创建带正确步数的目标状态
        CoinState result = path.get(path.size() - 1);
        return new CoinState(result.coins, path.size() - 1, null);
    }
}

三、博弈论视角:纳什均衡与最优策略

3.1 双人对抗变体

考虑对抗版本:两人轮流传递金币,每次必须传递一枚,无法操作者输。这等价于Nim变体:

public class GameTheoryAnalyzer {

    /**
     * 计算双人对抗版传金币的Grundy数(SG函数)
     * 状态: 每个人与目标值的偏差向量
     * 简化模型: 将问题转化为偏差的一维流动
     */
    public static int computeGrundy(int[] diffs) {
        // 对简化的一维链模型,使用记忆化搜索
        String key = Arrays.toString(diffs);
        if (memo.containsKey(key)) return memo.get(key);

        Set<Integer> reachable = new HashSet<>();
        int n = diffs.length;

        for (int i = 0; i < n; i++) {
            if (diffs[i] > 0) {
                // 可以向左边传递(减少自己的 surplus)
                int[] next = diffs.clone();
                next[i]--; next[(i-1+n)%n]++;
                reachable.add(computeGrundy(next));
            }
            if (diffs[i] < 0) {
                // 可以向右边传递(减少自己的 deficit)
                int[] next = diffs.clone();
                next[i]++; next[(i+1)%n]--;
                reachable.add(computeGrundy(next));
            }
        }

        int g = 0;
        while (reachable.contains(g)) g++;
        memo.put(key, g);
        return g;
    }

    private static final Map<String, Integer> memo = new HashMap<>();
}

3.2 线性时间贪心解法

对于原始的最小步数问题(非对抗),存在O(n)贪心解法:

public class GreedySolver {

    /**
     * 线性时间求解最少传递次数
     * 核心思想: 将环形拆成链,枚举断开点,取最小值
     * 
     * @param coins 初始金币数组
     * @return 最少传递次数
     */
    public static int solve(int[] coins) {
        int n = coins.length;
        int sum = Arrays.stream(coins).sum();
        if (sum % n != 0) return -1;
        int target = sum / n;

        // 计算前缀与目标的偏差
        int[] diff = new int[n];
        for (int i = 0; i < n; i++) {
            diff[i] = coins[i] - target;
        }

        // 将环形拆成链,枚举断开点k
        // 断开后,链上每个位置i的净流出量就是diff前缀和
        int minMoves = Integer.MAX_VALUE;

        for (int k = 0; k < n; k++) {
            // 以k为起点,计算链上的前缀和
            int[] prefix = new int[n];
            prefix[0] = diff[k];
            for (int i = 1; i < n; i++) {
                prefix[i] = prefix[i-1] + diff[(k+i)%n];
            }

            // 最少移动次数 = 前缀和绝对值之和
            // 证明: 每个prefix[i]表示位置i必须向右传递的金币数
            int moves = 0;
            for (int p : prefix) {
                moves += Math.abs(p);
            }
            minMoves = Math.min(minMoves, moves);
        }

        return minMoves;
    }

    /**
     * 更优的O(n)算法: 利用中位数性质
     * 将问题转化为找前缀和数组的中位数
     */
    public static int solveOptimal(int[] coins) {
        int n = coins.length;
        int sum = Arrays.stream(coins).sum();
        if (sum % n != 0) return -1;
        int target = sum / n;

        // 任选一个断开点,计算前缀和
        int[] prefix = new int[n];
        prefix[0] = coins[0] - target;
        for (int i = 1; i < n; i++) {
            prefix[i] = prefix[i-1] + coins[i] - target;
        }

        // 排序找中位数
        int[] sorted = prefix.clone();
        Arrays.sort(sorted);
        int median = sorted[n / 2];

        // 最小移动次数 = sum|prefix[i] - median|
        int moves = 0;
        for (int p : prefix) {
            moves += Math.abs(p - median);
        }
        return moves;
    }
}

四、完整测试与对比

public class Main {
    public static void main(String[] args) {
        int[] test = {3, 0, 2, 1}; // 总和6,4人,目标每人1.5...不对
        int[] test2 = {4, 0, 2, 0}; // 总和6,4人,无法均分
        int[] test3 = {3, 2, 1, 0}; // 总和6,4人,目标1.5...也不对
        int[] test4 = {4, 0, 2, 2}; // 总和8,4人,目标2

        // 使用合法测试用例
        int[] coins = {4, 0, 2, 2};

        System.out.println("=== 测试数据: " + Arrays.toString(coins) + " ===");

        // BFS求解
        CoinTransferGame game = new CoinTransferGame(coins);
        BFSSolver bfs = new BFSSolver(game);
        CoinState solution = bfs.solve();
        if (solution != null) {
            System.out.println("BFS最少步数: " + solution.moves);
        }

        // 贪心O(n)求解
        int greedyMoves = GreedySolver.solveOptimal(coins);
        System.out.println("贪心最优步数: " + greedyMoves);

        // 更大规模测试
        int[] large = {10, 0, 0, 0, 0, 0, 0, 0, 0, 0}; // 10人,1人有10枚
        System.out.println("\n=== 大规模测试 ===");
        int largeMoves = GreedySolver.solveOptimal(large);
        System.out.println("10人均分10枚,最少步数: " + largeMoves);
    }
}

五、复杂度分析

算法 时间复杂度 空间复杂度 适用场景
BFS O((2n)^S) O((2n)^S) S为小规模状态空间,求精确最优解
双向BFS O((2n)^(S/2)) O((2n)^(S/2)) 状态空间中等,加速明显
贪心O(n^2) O(n^2) O(n) n较大,枚举断开点
贪心O(n log n) O(n log n) O(n) n很大,利用中位数性质

其中S为总偏差金币数(即sum|coins[i]-target|/2)。

六、扩展方向

  1. 多轮博弈: 引入时间折扣因子,分析长期合作与背叛策略
  2. 图结构扩展: 将环形拓扑替换为任意图,使用网络流建模
  3. 带权传递: 不同方向/距离的传递消耗不同体力值,转化为最短路问题

总结

本文从BFS状态空间搜索出发,逐步优化到O(n log n)的贪心算法求解传金币问题。核心技巧包括双向BFS剪枝、SG函数博弈分析,以及将环形问题拆链后利用中位数性质求解。读者可以在此基础上扩展更复杂的网络拓扑与多智能体博弈场景。