每日算法 — 使用java实现接金币:时间扩展网络与最小费用流

「接金币」是个看起来不需要算法的游戏——左手右手一起动,见金币就扑上去。但只要加一个约束,它就变成一道标准的网络流问题:如果同时有 k 个接盘(或者 k 个玩家共享同一个关卡),怎样安排每个接盘的轨迹,才能收到最多金币?

答案不在贪心里,也不在 DP 里,而在时间扩展网络(Time-Expanded Network)上的最小费用流。本文用 Java 从零实现这套建模,并解释一个几乎所有人第一次写都会踩的坑:金币节点为什么必须拆点。

一、把「时间」变成一层网络

游戏里最麻烦的东西是时间:同一时刻你只能站在一个格子上,不同时刻的金币对你是「不同的机会」。处理这类问题的标准套路是把时间也展开成图的一维:

  • 节点 = 「第 t 时刻位于格子 (r, c)」,共 T × rows × cols 个;
  • 相邻时刻之间连边,代表「一步移动」:原地停留、左右平移、下移一行(含斜下)。

这样,一个「接盘的完整轨迹」就变成网络上从源点走到汇点的一条路径。这一步的关键转变是:

时间不再是循环里的变量,而是图的一层。轨迹规划 = 图上寻路。

一旦轨迹变成路径,「多个接盘分配金币」就自然变成「多流问题」——每条路径占一份流量,每个金币只能被一条路径收走。

二、金币怎么建模:拆点是唯一的正确解

初学者最自然的想法是:金币格就是一个节点,让路径经过它就算收下。但网络流不认「经过」,只认「容量」。所以金币必须变成一条有容量的边。

问题来了:节点本身没有容量,只有边才有。于是在节点上直接加「容量 1、费用 -1」的平行边,会形成这样的结构:

base ──→ in(1, -1)──→ out ──→ base   ← 负环!

只要存在这条 base → in → out → base 的回路,且总费用为 -1,SPFA 就会不断沿这个环「绕圈」来降低费用,永远不收敛——程序直接卡死。这不是理论风险,本文的第一版实现就真的卡在这里,跑了 180 秒超时。

正确的做法是彻底拆点:把一个金币格拆成 in 和 out 两个节点,

  • 所有入边接到 in;
  • 所有出边从 out 引出;
  • in → out 之间放两条平行边:容量 1 费用 -1(第一个经过者得金币),容量 k-1 费用 0(后续经过者只是路过)。
for (int t = 1; t < TIME; t++)
    for (int[] coin : COINS[t]) {
        int r = coin[0], c = coin[1];
        int in = addNode(), out = addNode();
        inNode[t][r][c] = in;  outNode[t][r][c] = out;
        addEdge(in, out, 1, -1);                 // 第一个经过者得 1 金
        if (k > 1) addEdge(in, out, k - 1, 0);   // 其余经过者只能路过
    }

拆点之后,移动边必须改成「从上一时刻的 out 连到下一时刻的 in」:

addEdge(outNode[t][r][c], inNode[t + 1][nxt[0]][nxt[1]], k, 0);

这一步改动只涉及两个数组索引,但它是整个实现从「卡死」到「正确」的分水岭。负环的根源永远是「同一个节点既是入口又是出口」。

三、求解:SPFA + 连续最短路

边权里出现了 -1,Dijkstra 失效(它要求非负权),所以用 SPFA 找每条增广路的最短路:

int[] minCostFlow(int s, int t, int maxFlow) {
    while (totalFlow < maxFlow) {
        Arrays.fill(dist, Integer.MAX_VALUE);
        dist[s] = 0;
        ArrayDeque<Integer> q = new ArrayDeque<>();  q.add(s);
        while (!q.isEmpty()) {                       // SPFA:允许负权边
            int v = q.poll();  inQ[v] = false;
            for (Edge e : g.get(v)) {
                if (e.residual() > 0 && dist[v] + e.cost < dist[e.to]) {
                    dist[e.to] = dist[v] + e.cost;
                    prevV[e.to] = v;  prevE[e.to] = i;
                    if (!inQ[e.to]) { inQ[e.to] = true; q.add(e.to); }
                }
            }
        }
        if (dist[t] == Integer.MAX_VALUE) break;      // 无增广路
        // ... 沿 prevV/prevE 回退,更新流量 ...
    }
    return new int[]{totalFlow, totalCost};
}

这就是连续最短路算法(Successive Shortest Path):每次增广「当前残量网络里最便宜的路径」。它的正确性依赖于一个重要性质——如果初始网络无负环,则每次增广后的残量网络仍无负环。这也是为什么第二节的拆点如此关键:有负环时这个前提直接崩塌。

复杂度 O(F · V · E),本文的图只有百级节点、k ≤ 3,毫秒级完成。

四、用联合 DP 交叉验证

最小费用流的结果必须被独立验证,否则你无法区分「算法跑对了」和「文件跑完了」。

验证方法:k 条路径的联合状态 DP。状态是 k 条路径当前所在的格子(有序元组),逐层推演,每层统计落在金币格上的不同格子数:

static int jointDP(int k) {
    // 状态 = k 条路径的有序位置组合
    // 每层:对每个状态枚举所有后继组合,累加本层金币命中数
}

因为同一时刻不会有两个金币出现在同一格,这种「按不同格子计数」不会重复计算,所以联合 DP 的结果是精确基准。

实测三个规模的对比:

k(接盘数) 最小费用流收入 联合 DP 基准 一致
1 3 3 是
2 5 5 是
3 7 7 是

10 枚金币、3 个接盘只能收到 7 枚——剩下 3 枚是「时间-空间上真的赶不到」的。这些不可达的金币构成了关卡的真实难度上限。

五、单路径最优轨迹长什么样

对 k=1 再做一次 DP 回溯,能得到完整的轨迹(这也是游戏里「自动演示」功能需要的输出):

t=0 (0,0)  t=1 (1,1)  t=2 (2,2)  t=3 (2,2)  t=4 (2,3)
t=5 (2,2)  t=6 (2,1)  t=7 (2,0)

收入 3 枚。注意 t=3 时它停在原地不动——这不是浪费一步,而是为了在 t=4 到 (2,3) 收金币、并在 t=5 及时折返。在时间扩展网络里,「原地停留」是一条正经的边(自环),不是「什么都不做」。 这一点在建模时极易漏掉:如果只连「移动」边不连「停留」边,路径被迫每步都必须移动,最优解就会算错。

六、完整可运行代码

项目结构:

coin-catcher-mincostflow/
├── src/
│   └── CoinCatcherMinCostFlow.java
└── out/
javac -d out src/CoinCatcherMinCostFlow.java
java  -cp out CoinCatcherMinCostFlow
import java.util.*;

/**
 * 「接金币」游戏的最优收集问题 —— 时间扩展网络上的最小费用流。
 *
 * 游戏设定:ROWS×COLS 网格,玩家控制 k 个接盘(k 个并行的分身),
 * 每单位时间每个接盘可:原地停留、左右平移一格、或下移一行(可带左右偏移)。
 * 每个时刻 t 会在若干个格子掉落金币,接盘恰好在 (t,r,c) 即可收下,每枚金币只能收一次。
 *
 * 建模(时间扩展网络 / Time-Expanded Network):
 *   节点 = 「第 t 时刻位于格子 (r,c)」,共 TIME×ROWS×COLS 个。
 *   移动边:相邻时刻之间连容量 k、费用 0 的边。
 *   金币节点必须「拆点」:把所有入边导入 in、所有出边从 out 引出,
 *         in → out 一条容量 1、费用 -1 的边,代表第一个经过者收走金币;
 *         in → out 一条容量 k-1、费用 0 的边,代表后续路径只是路过。
 *   若不做拆点、只是在原节点上加平行边,会形成 base→in→out→base 的负环,
 *   SPFA 会无限松弛(这是实现这类模型最容易踩的坑)。
 *
 * 求解:连续最短路算法 + SPFA(边权含 -1,不能用 Dijkstra)。
 * 复杂度 O(F·V·E),图只有百级节点、k ≤ 3,毫秒级完成。
 *
 * 正确性用「k 条路径的联合状态 DP」交叉验证:
 *   状态 = (t, 路径1位置, 路径2位置, ...),逐层推演,每层统计落在金币格的不同位置数。
 *   因为同一时刻不会有两个金币出现在同一格,联合 DP 的计数是无重复的,可作为精确基准。
 */
public final class CoinCatcherMinCostFlow {

    static final class Edge {
        int to, rev, cap, cost, flow;
        Edge(int t, int r, int c, int w) { to = t; rev = r; cap = c; cost = w; }
        int residual() { return cap - flow; }
    }

    final List<List<Edge>> g = new ArrayList<>();

    int addNode() { g.add(new ArrayList<>()); return g.size() - 1; }

    void addEdge(int u, int v, int cap, int cost) {
        g.get(u).add(new Edge(v, g.get(v).size(), cap, cost));
        g.get(v).add(new Edge(u, g.get(u).size() - 1, 0, -cost));
    }

    /** 连续最短路:SPFA 找最小费用增广路,直到流量达到 maxFlow */
    int[] minCostFlow(int s, int t, int maxFlow) {
        int n = g.size(), totalFlow = 0, totalCost = 0;
        int[] dist = new int[n], prevV = new int[n], prevE = new int[n];
        while (totalFlow < maxFlow) {
            Arrays.fill(dist, Integer.MAX_VALUE);
            dist[s] = 0;
            boolean[] inQ = new boolean[n];
            ArrayDeque<Integer> q = new ArrayDeque<>();
            q.add(s); inQ[s] = true;
            while (!q.isEmpty()) {
                int v = q.poll(); inQ[v] = false;
                for (int i = 0; i < g.get(v).size(); i++) {
                    Edge e = g.get(v).get(i);
                    if (e.residual() > 0 && dist[v] != Integer.MAX_VALUE
                            && dist[v] + e.cost < dist[e.to]) {
                        dist[e.to] = dist[v] + e.cost;
                        prevV[e.to] = v; prevE[e.to] = i;
                        if (!inQ[e.to]) { inQ[e.to] = true; q.add(e.to); }
                    }
                }
            }
            if (dist[t] == Integer.MAX_VALUE) break;
            int add = maxFlow - totalFlow;
            for (int v = t; v != s; v = prevV[v]) add = Math.min(add, g.get(prevV[v]).get(prevE[v]).residual());
            for (int v = t; v != s; v = prevV[v]) {
                Edge e = g.get(prevV[v]).get(prevE[v]);
                e.flow += add;
                g.get(v).get(e.rev).flow -= add;
            }
            totalFlow += add;
            totalCost += add * dist[t];
        }
        return new int[]{totalFlow, totalCost};
    }

    // ---------------------- 关卡定义 ----------------------
    static final int ROWS = 3, COLS = 4, TIME = 8;

    /** coins[t] = 第 t 时刻的金币坐标 {row, col} */
    static final int[][][] COINS = {
            {},                        // t=0 无金币
            {{2, 1}},                  // t=1
            {{2, 0}, {2, 2}},          // t=2
            {{1, 1}},                  // t=3
            {{1, 3}, {2, 3}},          // t=4
            {{0, 2}},                  // t=5
            {{2, 1}},                  // t=6
            {{1, 0}, {1, 2}}           // t=7
    };

    static int cell(int t, int r, int c) { return (t * ROWS + r) * COLS + c; }

    /** 合法的下一位置:停留、左移、右移、下移(含斜下) */
    static List<int[]> successors(int r, int c) {
        List<int[]> out = new ArrayList<>();
        out.add(new int[]{r, c});                       // 停留
        if (c > 0) out.add(new int[]{r, c - 1});        // 左移
        if (c + 1 < COLS) out.add(new int[]{r, c + 1}); // 右移
        if (r + 1 < ROWS) {
            out.add(new int[]{r + 1, c});
            if (c > 0) out.add(new int[]{r + 1, c - 1});
            if (c + 1 < COLS) out.add(new int[]{r + 1, c + 1});
        }
        return out;
    }

    /**
     * 构造时间扩展网络。金币节点做 in/out 拆点,移动边按拆点后的出入口连接,
     * 从而彻底避免 base→in→out→base 的负环。
     */
    int[] buildNetwork(int k) {
        // 每个 (t,r,c) 先分配一个基础节点
        int[][][] base = new int[TIME][ROWS][COLS];
        for (int t = 0; t < TIME; t++)
            for (int r = 0; r < ROWS; r++)
                for (int c = 0; c < COLS; c++) base[t][r][c] = addNode();

        // 金币格:拆成 in / out
        int[][][] inNode = new int[TIME][ROWS][COLS];
        int[][][] outNode = new int[TIME][ROWS][COLS];
        for (int t = 0; t < TIME; t++)
            for (int r = 0; r < ROWS; r++)
                for (int c = 0; c < COLS; c++) { inNode[t][r][c] = base[t][r][c]; outNode[t][r][c] = base[t][r][c]; }
        for (int t = 1; t < TIME; t++)
            for (int[] coin : COINS[t]) {
                int r = coin[0], c = coin[1];
                int in = addNode(), out = addNode();
                inNode[t][r][c] = in; outNode[t][r][c] = out;
                addEdge(in, out, 1, -1);                 // 第一个经过者得 1 金
                if (k > 1) addEdge(in, out, k - 1, 0);   // 其余经过者只能路过
            }

        int S = addNode(), T = addNode();

        // 移动边:从上一时刻的「出口」连到下一时刻的「入口」
        for (int t = 0; t < TIME - 1; t++)
            for (int r = 0; r < ROWS; r++)
                for (int c = 0; c < COLS; c++)
                    for (int[] nxt : successors(r, c))
                        addEdge(outNode[t][r][c], inNode[t + 1][nxt[0]][nxt[1]], k, 0);

        // 源点 → 第 0 时刻任意列入场(容量 k)
        for (int c = 0; c < COLS; c++) addEdge(S, inNode[0][0][c], k, 0);

        // 任意时刻结束都要能到汇点:这里统一从最后时刻的出口连汇点
        for (int r = 0; r < ROWS; r++)
            for (int c = 0; c < COLS; c++) addEdge(outNode[TIME - 1][r][c], T, k, 0);

        return new int[]{S, T};
    }

    // ---------------------- 精确基准:k 条路径的联合状态 DP ----------------------
    /**
     * 联合 DP:state 是 k 条路径当前所在的格子(有序)。
     * 逐层推演,每层统计「落在金币格的不同格子数」,累加即为总收入。
     * 由于同一时刻不会出现两个金币同格,这种计数不会重复计算。
     */
    static int jointDP(int k) {
        List<int[]> starts = new ArrayList<>();
        for (int c = 0; c < COLS; c++) starts.add(new int[]{0, c});
        // 生成全部长度为 k 的有序起点组合
        Map<String, Integer> cur = new HashMap<>();
        List<int[]> combo = new ArrayList<>();
        buildCombos(starts, k, 0, combo, cur);
        for (int t = 1; t < TIME; t++) {
            Map<String, Integer> next = new HashMap<>();
            for (Map.Entry<String, Integer> e : cur.entrySet()) {
                List<int[]> pos = parse(e.getKey());
                List<List<int[]>> options = new ArrayList<>();
                for (int[] p : pos) options.add(successors(p[0], p[1]));
                List<int[]> picked = new ArrayList<>();
                expand(options, 0, picked, pos, e.getValue(), t, next);
            }
            cur = next;
        }
        int best = 0;
        for (int v : cur.values()) best = Math.max(best, v);
        return best;
    }

    static void buildCombos(List<int[]> pool, int k, int depth, List<int[]> cur, Map<String, Integer> out) {
        if (depth == k) { out.put(key(cur), 0); return; }
        for (int[] p : pool) { cur.add(p); buildCombos(pool, k, depth + 1, cur, out); cur.remove(cur.size() - 1); }
    }

    static void expand(List<List<int[]>> options, int idx, List<int[]> picked,
                       List<int[]> base, int value, int t, Map<String, Integer> out) {
        if (idx == options.size()) {
            int gain = coinHits(picked, t);
            String k2 = key(picked);
            out.merge(k2, value + gain, Math::max);
            return;
        }
        for (int[] p : options.get(idx)) {
            picked.add(p);
            expand(options, idx + 1, picked, base, value, t, out);
            picked.remove(picked.size() - 1);
        }
    }

    static int coinHits(List<int[]> positions, int t) {
        Set<String> hit = new HashSet<>();
        for (int[] p : positions)
            for (int[] coin : COINS[t])
                if (coin[0] == p[0] && coin[1] == p[1]) hit.add(p[0] + "," + p[1]);
        return hit.size();
    }

    static String key(List<int[]> pos) {
        StringBuilder sb = new StringBuilder();
        for (int[] p : pos) sb.append(p[0]).append(',').append(p[1]).append('|');
        return sb.toString();
    }

    static List<int[]> parse(String s) {
        List<int[]> out = new ArrayList<>();
        for (String part : s.split("\\|")) {
            if (part.isEmpty()) continue;
            String[] ab = part.split(",");
            out.add(new int[]{Integer.parseInt(ab[0]), Integer.parseInt(ab[1])});
        }
        return out;
    }

    /** 单路径最优轨迹:DP 求最大收入并回溯路径 */
    static void traceBestSinglePath() {
        int[][][] dp = new int[TIME][ROWS][COLS];
        int[][][] fromR = new int[TIME][ROWS][COLS];
        int[][][] fromC = new int[TIME][ROWS][COLS];
        for (int[] row : dp[0]) Arrays.fill(row, Integer.MIN_VALUE);
        for (int c = 0; c < COLS; c++) dp[0][0][c] = 0;
        for (int t = 0; t < TIME - 1; t++) {
            for (int[] row : dp[t + 1]) Arrays.fill(row, Integer.MIN_VALUE);
            for (int r = 0; r < ROWS; r++)
                for (int c = 0; c < COLS; c++) {
                    if (dp[t][r][c] == Integer.MIN_VALUE) continue;
                    for (int[] nxt : successors(r, c)) {
                        int nr = nxt[0], nc = nxt[1];
                        int gain = 0;
                        for (int[] coin : COINS[t + 1]) if (coin[0] == nr && coin[1] == nc) gain = 1;
                        int v = dp[t][r][c] + gain;
                        if (v > dp[t + 1][nr][nc]) {
                            dp[t + 1][nr][nc] = v;
                            fromR[t + 1][nr][nc] = r; fromC[t + 1][nr][nc] = c;
                        }
                    }
                }
        }
        int best = -1, br = 0, bc = 0;
        for (int r = 0; r < ROWS; r++)
            for (int c = 0; c < COLS; c++)
                if (dp[TIME - 1][r][c] > best) { best = dp[TIME - 1][r][c]; br = r; bc = c; }

        StringBuilder path = new StringBuilder();
        int r = br, c = bc;
        for (int t = TIME - 1; t >= 0; t--) {
            path.insert(0, "t=" + t + " (" + r + "," + c + ")  ");
            if (t > 0) { int pr = fromR[t][r][c], pc = fromC[t][r][c]; r = pr; c = pc; }
        }
        System.out.println("\n单路径最优轨迹(收入 " + best + " 枚):");
        System.out.println("  " + path.toString().trim());
    }

    public static void main(String[] args) {
        int total = 0;
        System.out.println("关卡:" + ROWS + " 行 × " + COLS + " 列," + TIME + " 个时刻");
        for (int t = 1; t < TIME; t++)
            for (int[] coin : COINS[t]) {
                total++;
                System.out.println("  t=" + t + " 金币 (" + coin[0] + "," + coin[1] + ")");
            }
        System.out.println("金币总数 = " + total);

        System.out.println("\nk | 最小费用流收入 | 联合DP基准 | 一致");
        for (int k = 1; k <= 3; k++) {
            CoinCatcherMinCostFlow mf = new CoinCatcherMinCostFlow();
            int[] st = mf.buildNetwork(k);
            int[] res = mf.minCostFlow(st[0], st[1], k);
            int gain = -res[1];
            int dp = jointDP(k);
            System.out.printf("%d | %d | %d | %s%n", k, gain, dp, gain == dp ? "是" : "否");
        }
        traceBestSinglePath();
    }
}

运行输出(节选):

关卡:3 行 × 4 列,8 个时刻
  t=1 金币 (2,1)
  t=2 金币 (2,0)
  ...
金币总数 = 10

k | 最小费用流收入 | 联合DP基准 | 一致
1 | 3 | 3 | 是
2 | 5 | 5 | 是
3 | 7 | 7 | 是

单路径最优轨迹(收入 3 枚):
  t=0 (0,0)  t=1 (1,1)  t=2 (2,2)  t=3 (2,2)  t=4 (2,3)  t=5 (2,2)  t=6 (2,1)  t=7 (2,0)

七、复杂度与关卡设计上的用途

项目 数值 说明
节点数 T × rows × cols + 金币拆点 × 2 本文 96 + 20 ≈ 百级
边数 每层约 rows×cols×7 条移动边 含停留与三条下落
求解复杂度 O(F · V · E) SPFA 版常数为 V 倍
本文实测 毫秒级 三档 k 各一次

最小费用流的真正价值不只是「算出答案」,而是它同时给出了关卡的设计诊断。同一套网络只要改一行——把费用从 -1 改成 0——就变成纯最大流,可以在不求解的情况下算出「这个关卡理论上最多能收多少」,用 0.1 秒给上百个候选关卡打分。这就是关卡自动生成器的工作方式:先用最大流做可行性剪枝,再用最小费用流做最优性评分。

延伸方向:

  1. 用势函数 + Dijkstra 替换 SPFA。Johnson 势能可以把负权边转成非负,之后用 Dijkstra,复杂度降到 O(F · E log V),适合节点数上千的大关卡。
  2. 把金币容量从 1 提升为「连续收益」。如果金币有不同面值,把费用从 -1 改成 -value,模型几乎不用改——这正是最小费用流比最大流更适合游戏数值设计的原因。

八、小结

接金币这道小游戏,在加了「k 个接盘」这个约束之后,就变成了时间扩展网络上的最小费用流。整套建模只有三个要点:

  1. 时间展开成图层,轨迹变成路径;
  2. 金币拆点,用「容量 1 费用 -1 + 容量 k-1 费用 0」表达「只能被收一次」;
  3. 停留也是一条边,否则最优解会被系统性低估。

而那个让第一版程序卡死 180 秒的负环,恰恰提醒了我们一件事:网络流建模的错误从来不会给出「差一点的答案」,它要么精确正确,要么彻底死循环。所以每一版都必须用联合 DP 这样的独立基准去校验——本文三档 k 全部一致,才敢说这套模型是对的。