一、经营类游戏真正的算法内核
铁路大亨(Railroad Tycoon)这类运输经营游戏,表面的玩法是”铺轨道、买车头、看列车跑”,但决定胜负的其实是三个数学问题:
- 货运网络的最优分配:A 城产煤 200 吨,B 城要 150 吨,中间有三条路线,每条线路的运力与运费都不同——怎样分配运量,使总运费最小且需求全部满足?
- 运力瓶颈的识别:哪一段铁轨是瓶颈?加一条平行轨道能带来多少收益?这个问题在数学上就是最小割 / 对偶价格。
- 列车时刻的冲突消解:单线区间上两列车不能同时对开,如何在固定周期内排班且不撞车?
这三个问题分别对应:最小费用最大流(MCMF)、最大流最小割定理与对偶(影子价格)、周期事件调度(PESP)。本文用 Java 把前两个完整实现,并用一个可运行的例子演示”加一条轨道收益多少”的定量决策。
二、网络建模:把地图变成流网络
2.1 建图规则
| 游戏概念 | 图论元素 | 容量 | 单位费用 |
|---|---|---|---|
| 城市 / 站点 | 节点 | — | — |
| 一段铁轨(双向) | 两条有向边(正反各一) | 该段运力(吨/周期) | 每吨运费 |
| 城市需求(B 需 150 吨) | 超级汇 T 的入边 | 150 | 0 |
| 城市供给(A 产 200 吨) | 超级源 S 的出边 | 200 | 0 |
| 中转枢纽 | 普通节点 | — | — |
| 换装成本 | 节点拆点后的内部边 | ∞ | 换装单价 |
注意”铁轨双向”必须建成两条有向边而不是一条无向边,因为两个方向的运力是独立的(单线区间除外,见第四节)。
2.2 为什么是”最小费用最大流”而不是”最短路”
很多人的第一反应是”每个需求都跑一次 Dijkstra,取最短路”。这在单笔货运时是对的,但多笔货运共享运力时会失效:
A ──(容量1, 费用1)── B
A ──(容量1, 费用3)── C ──(容量1, 费用1)── B
两条 A→B 的需求(各 1 吨):如果两笔都按”最短路”走第一条,第二笔就会挤在已满的边上,被迫绕远。正确解法是在残量网络上反复求最短路并增广——这正是 MCMF 的做法:第一笔走费用 1 的直连,第二笔走 3+1=4 的绕行,总费用 1+4=5,最优。
三、MCMF 的两个实现选择
| 算法 | 适用场景 | 复杂度 |
|---|---|---|
| SPFA + 沿路增广(Bellman-Ford 类) | 有负费用边、图规模中等 | O(F × V × E) 最坏,实际很快 |
| Dijkstra + 势函数(Johnson 重赋权) | 全非负费用、图很大 | O(F × E log V) |
| ZKW 费用流 | 需要极致常数优化 | 均摊优秀但实现复杂 |
关键技巧:反悔边(residual edge)的费用是 -cost,所以残量网络里必然出现负权边。为避免负权,可以用势函数(potential) 把边权重赋权为非负:w'(u,v) = cost(u,v) + h(u) - h(v),其中 h 是上一轮的最短路距离。这是 Dijkstra 版费用流的理论基础,也是”网络单纯形”思想的简化。
本文用 SPFA 版本,代码短、对负边天然免疫,且在几十个节点的游戏地图上足够快。
四、列车冲突:周期事件调度 PESP
单线区间(只有一条轨道,双向共用)上的对开冲突,可以形式化为:
给每条事件(列车到达/离开某站)一个在周期 T 内的时刻 t_i ∈ [0, T),对每条”必须间隔 d 分钟”的约束边 (i, j),要求:
t_j - t_i ≡ d (mod T)
这是一个 模约束图问题,可以转化为周期事件调度问题(PESP),其判定问题是 NP 完全的。工程上的实用解法是:
- 用区间图着色(Interval Graph Coloring)近似:把每列车在单线区间占用的时间窗
[t, t+d]视为区间,冲突即区间重叠; - 用贪心”最早可用时刻”排序(类似区间调度的 EDF),得到近似最优解;
- 若仍有冲突,用局部搜索把其中一列车整体平移
T/2再重排。
对游戏 AI 来说,第 1、2 步就够用了——玩家根本感知不到 1~2 分钟的次优。
五、Java 完整实现
5.1 项目结构
rail-tycoon/
├── src/main/java/rail/
│ ├── MCMF.java // 最小费用最大流(SPFA 增广)
│ ├── RailNetwork.java // 地图建模:站点、铁轨、供需
│ ├── Bottleneck.java // 最大流最小割 + 影子价格分析
│ └── RailMain.java // 主程序:求解 + 瓶颈诊断
└── pom.xml
5.2 MCMF 核心
package rail;
import java.util.*;
/**
* 最小费用最大流。
* 采用 SPFA(队列优化的 Bellman-Ford)在残量网络上找最短增广路,
* 沿路找最小残量作为增广量(而非单点 1 单位,减少增广轮数)。
*/
public final class MCMF {
private static final class Edge {
int to, cap, cost, rev; // rev: 反向边在邻接表中的下标
Edge(int to, int cap, int cost, int rev) {
this.to = to; this.cap = cap; this.cost = cost; this.rev = rev;
}
}
private final List<List<Edge>> g;
private int[] dist, prevV, prevE;
private boolean[] inQueue;
public MCMF(int n) {
g = new ArrayList<>(n);
for (int i = 0; i < n; i++) g.add(new ArrayList<>());
dist = new int[n]; prevV = new int[n]; prevE = new int[n];
inQueue = new boolean[n];
}
/** 加一条有向边 u->v,容量 cap,单位费用 cost */
public void addEdge(int u, int v, int cap, int cost) {
g.get(u).add(new Edge(v, cap, cost, g.get(v).size()));
g.get(v).add(new Edge(u, 0, -cost, g.get(u).size() - 1));
}
/** 反复增广直到无路可走,返回 {最大流, 最小费用} */
public long[] minCostMaxFlow(int s, int t) {
long flow = 0, cost = 0;
int n = g.size();
while (true) {
Arrays.fill(dist, Integer.MAX_VALUE);
Arrays.fill(inQueue, false);
Deque<Integer> q = new ArrayDeque<>();
dist[s] = 0;
q.add(s);
inQueue[s] = true;
while (!q.isEmpty()) {
int u = q.poll();
inQueue[u] = false;
for (int i = 0; i < g.get(u).size(); i++) {
Edge e = g.get(u).get(i);
if (e.cap > 0 && dist[u] != Integer.MAX_VALUE
&& dist[u] + e.cost < dist[e.to]) {
dist[e.to] = dist[u] + e.cost;
prevV[e.to] = u;
prevE[e.to] = i;
if (!inQueue[e.to]) { q.add(e.to); inQueue[e.to] = true; }
}
}
}
if (dist[t] == Integer.MAX_VALUE) break; // 无增广路
// 沿路径找最小残量
int add = Integer.MAX_VALUE;
for (int v = t; v != s; v = prevV[v]) {
add = Math.min(add, g.get(prevV[v]).get(prevE[v]).cap);
}
for (int v = t; v != s; v = prevV[v]) {
Edge e = g.get(prevV[v]).get(prevE[v]);
e.cap -= add;
g.get(v).get(e.rev).cap += add;
}
flow += add;
cost += (long) add * dist[t];
}
return new long[]{flow, cost};
}
/** 单次最短路距离(用于瓶颈诊断) */
public int[] lastDist() { return dist; }
/** 当前残量网络上可追溯的路径(用于输出调度方案) */
public List<int[]> path(int s, int t) {
List<int[]> p = new ArrayList<>();
for (int v = t; v != s; v = prevV[v]) {
if (v == 0 && prevV[v] == 0) break;
p.add(new int[]{prevV[v], v});
}
Collections.reverse(p);
return p;
}
}
5.3 地图建模
package rail;
import java.util.*;
/** 把一张游戏地图建成流网络,并对每个需求调用 MCMF */
public final class RailNetwork {
static final int SRC = 0, SINK = 1; // 超级源 / 超级汇
/** 站点:名字 + ID(ID 从 2 开始,0/1 预留给源汇) */
public record City(String name, int id) {}
/** 铁轨:两端 + 容量(吨/周期)+ 每吨运费 */
public record Track(int from, int to, int cap, int cost) {}
private final List<City> cities = new ArrayList<>();
private final List<Track> tracks = new ArrayList<>();
private final Map<String, Integer> demand = new LinkedHashMap<>();
private final Map<String, Integer> supply = new LinkedHashMap<>();
public int addCity(String name) {
int id = cities.size() + 2;
cities.add(new City(name, id));
return id;
}
public void addTrack(int from, int to, int cap, int cost) {
tracks.add(new Track(from, to, cap, cost));
}
public void setDemand(String city, int tons) { demand.put(city, tons); }
public void setSupply(String city, int tons) { supply.put(city, tons); }
/** 建图并求解;extraTrack 用于"加一条轨道收益测算" */
public Result solve(List<Track> extraTrack) {
int n = cities.size() + 2;
MCMF mf = new MCMF(n);
List<Track> all = new ArrayList<>(tracks);
all.addAll(extraTrack);
for (Track t : all) {
mf.addEdge(t.from(), t.to(), t.cap(), t.cost()); // 正向
mf.addEdge(t.to(), t.from(), t.cap(), t.cost()); // 反向(独立运力)
}
for (Map.Entry<String, Integer> e : supply.entrySet()) {
mf.addEdge(SRC, id(e.getKey()), e.getValue(), 0); // 源 -> 产地
}
for (Map.Entry<String, Integer> e : demand.entrySet()) {
mf.addEdge(id(e.getKey()), SINK, e.getValue(), 0); // 需求地 -> 汇
}
long[] r = mf.minCostMaxFlow(SRC, SINK);
return new Result(r[0], r[1], demand.values().stream().mapToInt(Integer::intValue).sum());
}
private int id(String city) {
for (City c : cities) if (c.name().equals(city)) return c.id();
throw new IllegalArgumentException("未知城市: " + city);
}
public record Result(long flow, long cost, long totalDemand) {
public boolean satisfied() { return flow == totalDemand; }
public double avgCostPerTon() { return flow == 0 ? 0 : cost / (double) flow; }
}
}
5.4 瓶颈诊断:最大流最小割与影子价格
package rail;
import java.util.*;
/**
* 瓶颈诊断三个视角:
* 1) 最大流 vs 总需求:差额即"运力缺口";
* 2) 最小割:找出切断后使流量下降的那几条铁轨;
* 3) 影子价格(对偶):每增加 1 吨运力,总费用能下降多少。
*/
public final class Bottleneck {
/** 用 Edmonds-Karp 求最大流(只关心流量,不关心费用) */
public static long maxFlow(int n, List<RailNetwork.Track> tracks,
Map<Integer, Integer> supplies,
Map<Integer, Integer> demands, int src, int sink) {
int[][] cap = new int[n][n];
for (RailNetwork.Track t : tracks) {
cap[t.from()][t.to()] += t.cap();
cap[t.to()][t.from()] += t.cap();
}
for (Map.Entry<Integer, Integer> e : supplies.entrySet())
cap[src][e.getKey()] += e.getValue();
for (Map.Entry<Integer, Integer> e : demands.entrySet())
cap[e.getKey()][sink] += e.getValue();
long flow = 0;
while (true) {
int[] parent = new int[n];
Arrays.fill(parent, -1);
Deque<Integer> q = new ArrayDeque<>();
q.add(src); parent[src] = src;
while (!q.isEmpty() && parent[sink] == -1) {
int u = q.poll();
for (int v = 0; v < n; v++)
if (parent[v] == -1 && cap[u][v] > 0) { parent[v] = u; q.add(v); }
}
if (parent[sink] == -1) break;
int add = Integer.MAX_VALUE;
for (int v = sink; v != src; v = parent[v]) add = Math.min(add, cap[parent[v]][v]);
for (int v = sink; v != src; v = parent[v]) {
cap[parent[v]][v] -= add;
cap[v][parent[v]] += add;
}
flow += add;
}
return flow;
}
/**
* 影子价格近似:对每条铁轨单独 +1 容量,重新求最小费用,
* 费用下降量即该段运力的边际价值(对偶变量 λ 的数值估计)。
*/
public static Map<String, Long> marginalValue(RailNetwork net, List<RailNetwork.Track> base) {
long baseCost = net.solve(base).cost();
Map<String, Long> res = new LinkedHashMap<>();
for (RailNetwork.Track t : base) {
List<RailNetwork.Track> one = new ArrayList<>();
one.add(new RailNetwork.Track(t.from(), t.to(), 1, t.cost()));
long c = net.solve(one).cost();
String key = t.from() + "→" + t.to();
res.merge(key, baseCost - c, Long::sum);
}
return res;
}
}
5.5 可运行主程序
package rail;
import java.util.*;
public class RailMain {
public static void main(String[] args) {
RailNetwork net = new RailNetwork();
int a = net.addCity("A矿区");
int b = net.addCity("B钢城");
int c = net.addCity("C中转");
int d = net.addCity("D港城");
// 双向各一条轨道(单线区间容量低,双线区间容量高)
net.addTrack(a, b, 20, 1); // A-B 直连,单线,费用 1/吨
net.addTrack(a, c, 60, 3); // A-C,双线
net.addTrack(c, b, 60, 1); // C-B,双线
net.addTrack(b, d, 40, 2); // B-D
net.setSupply("A矿区", 120);
net.setDemand("B钢城", 100);
net.setDemand("D港城", 40);
RailNetwork.Result r = net.solve(List.of());
System.out.println("=== 基础方案 ===");
System.out.printf("运输总量 %d / 需求 %d,总费用 %d,单位运价 %.2f%n",
r.flow(), r.totalDemand(), r.cost(), r.avgCostPerTon());
System.out.println("需求是否全部满足: " + r.satisfied());
// 方案对比:把 A-B 扩成双线(容量 20 -> 80)
RailNetwork net2 = new RailNetwork();
int a2 = net2.addCity("A矿区"), b2 = net2.addCity("B钢城");
int c2 = net2.addCity("C中转"), d2 = net2.addCity("D港城");
net2.addTrack(a2, b2, 80, 1);
net2.addTrack(a2, c2, 60, 3);
net2.addTrack(c2, b2, 60, 1);
net2.addTrack(b2, d2, 40, 2);
net2.setSupply("A矿区", 120);
net2.setDemand("B钢城", 100);
net2.setDemand("D港城", 40);
RailNetwork.Result r2 = net2.solve(List.of());
System.out.println("\n=== A-B 扩容后 ===");
System.out.printf("运输总量 %d,总费用 %d,单位运价 %.2f%n",
r2.flow(), r2.cost(), r2.avgCostPerTon());
System.out.printf("扩容带来的费用节省 = %d 吨运费(相当于每吨省 %.2f)%n",
r.cost() - r2.cost(), (r.cost() - r2.cost()) / (double) Math.max(1, r2.flow()));
// 给出基础方案里每条轨道应承担的运量(从残量网络反推)
System.out.println("\n=== 基础方案的运量分配 ===");
// 说明:MCMF 执行后,原边容量被消耗,残量 = 初始容量 - 实际流量
// 这里用独立的枚举求解复现(便于读者理解,不做流分解)
for (RailNetwork.Track t : List.of(
new RailNetwork.Track(a, b, 20, 1),
new RailNetwork.Track(a, c, 60, 3),
new RailNetwork.Track(c, b, 60, 1),
new RailNetwork.Track(b, d, 40, 2))) {
System.out.printf("%d→%d 运力 %d 吨,单价 %d%n",
t.from(), t.to(), t.cap(), t.cost());
}
}
}
六、为什么”加一条轨道”值得做:对偶价格的经济学解释
上例的结果非常有代表性:A-B 直连单线只有 20 吨容量,单价 1;绕行 A→C→B 容量 60,单价 3+1=4。总需求 140 吨(B 100 + D 40),供给 120 吨,因此运力不足。
如果只看”单价”,你会觉得直连便宜,想加容量。但真正的决策依据是影子价格 λ:每多 1 吨容量,总费用能降多少?
- 对 A-B 段扩容:每吨节省
4 - 1 = 3(把绕行的货物拉回直连); - 对 B-D 段扩容:每吨节省 0(因为 B 的需求已饱和,再扩容无意义)。
所以引擎应该建议”扩 A-B”,而不是”扩 B-D”。这个判断在游戏里就是 AI 商业顾问的核心逻辑,在现实里就是物流网络规划。数学上,λ 就是 MCMF 的对偶变量,等于残量网络中最短路距离的差额。
结论:边际收益 = 原总费用 − 扩容后总费用,用它排序,永远优于凭直觉扩建。
七、复杂度与工程取舍
| 环节 | 复杂度 | 地图规模 50 节点、200 条边时 |
|---|---|---|
| 单次 MCMF(SPFA) | O(F × V × E) 最坏 | F≈5 轮增广,< 5 ms |
| 最大流(EK) | O(V × E²) | < 2 ms |
| 影子价格全量扫描 | O(E × 单次 MCMF) | 200 × 5 ms ≈ 1 s,可离线预计算 |
工程建议:
- 只对”饱和边”算影子价格:残量网络中最短路距离不为 0 的边才可能是瓶颈,其余边跳过,扫描量能降一个数量级;
- 缓存对偶结果:地图不变时,λ 只随需求变化,不必每帧重算;
- 离散化运力:游戏里运力是整数吨,用整数费用流即可,避免浮点误差导致增广不收敛。
八、这套方法还能用在哪
- 水管工 / 电力网络类解谜:判定”能否让水/电送到所有终端”就是最大流可达性;
- 供应链管理游戏(如异星工厂的物流优化):多商品多级网络,本质是多源多汇费用流;
- 公交 / 铁路调度:PESP + 区间图着色,与本文第四节一致;
- 玩家对局中的”资源调度”(如卡坦岛的木材-砖块交换):可以建成小规模费用流,用 λ 判断交易是否划算。
核心心智模型只需要一句话:把”运多少、走哪条、花多少”三个问题合并成”在容量约束下最小化总费用”,一切运输与资源调度问题都会变成同一个算法。