把停运卡车建模成最小割:容量改 c·K+1 使“先压总损失、再压边数”,Dinic 求最大流后逐边删测流找出割边。
OJ: roj
题目 ID: 2074
难度:提高+/省选-
标签:pythonusaco图论网络流最小割
创建: 2026-10-01 07:03
更新: 2026-10-07 13:50
形式化题目
有
- 停运损失总和最小(主目标);
- 总和相同时,停运的线路数最少(次级目标)。
输出最小总损失、最少线路数,以及这些线路的输入行号(从小到大)。
从数学结构看,这是带权有向图上的 s-t 最小割:把节点分成含
样例输入与输出(milk60 数据):
4 5
1 3 100
3 2 50
2 4 60
1 2 40
2 3 8060 1
3下面用样例网络演示“割”的含义,★ 标出的边就是最终要停运的线路:
flowchart LR
n1((1 工厂)) -->|"线路1 100"| n3((3))
n3 -->|"线路2 50"| n2((2))
n2 -->|"★线路3 60"| n4((4 零售商))
n1 -->|"线路4 40"| n2
n2 -->|"线路5 80"| n3
把节点分成
| 跨过 |
总代价 | |
|---|---|---|
| 线路3(60) | 60 ← 最优 | |
| 线路1(100)、线路4(40) | 140 | |
| 线路2(50)、线路4(40) | 90 | |
| 线路1(100)、线路5(80) | 180 |
正解
思路
1. 先认出来:这就是最小割。 最朴素的思路是枚举停运哪些线路再检查连通性,有
2. 次级目标“线路数最少”用容量变换吸收。 普通最大流只保证主目标。把每条边的容量改成
那么任意割的新容量
3. 找出具体是哪几条线路:贪心逐条删测流。 最大流算法只给“割值”,还要把割边找出来。
在当前图上临时删掉边
- 若恰好减少了
(等于 自己的修改容量),说明任何最大流都无法绕开 , 必然 属于当前图的某个最小割 —— 保留删除,计入答案; - 若减少不足
,说明流量可以绕行, 不在最小割里 —— 恢复容量。
按输入顺序处理完全部 0 0。
代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 07:30
# update_at: 2026-10-01 07:30
import sys
from collections import deque
INF = 10**18 # 增广时的“无穷大”上界
def dinic(adj: list[list[int]], to: list[int], caps: list[int], n: int) -> int:
"""当前容量 caps 下,节点 1 到节点 n 的最大流(Dinic 算法)。"""
flow = 0
s, t = 1, n
while True:
level = [-1] * (n + 1) # BFS 分层
level[s] = 0
dq = deque([s])
while dq:
u = dq.popleft()
for eid in adj[u]:
v = to[eid]
if caps[eid] > 0 and level[v] < 0:
level[v] = level[u] + 1
dq.append(v)
if level[t] < 0: # 汇点不可达,当前流已是最大流
return flow
it = [0] * (n + 1) # 每个节点的弧指针:只扫一遍被拒绝的弧
def push(u: int, f: int) -> int:
"""沿 level 严格递增的路径把流量 f 尽量送到汇点,返回实际送出量。"""
if u == t:
return f
while it[u] < len(adj[u]):
eid = adj[u][it[u]]
v = to[eid]
if caps[eid] > 0 and level[v] == level[u] + 1:
d = push(v, min(f, caps[eid]))
if d:
caps[eid] -= d
caps[eid ^ 1] += d
return d
it[u] += 1
return 0
while True:
d = push(s, INF)
if not d:
break
flow += d
def make_caps(removed: list[bool], W: list[int], m: int) -> list[int]:
"""按 removed 状态重建容量数组:已移除的边容量为 0,其余为修改容量。"""
caps = [0] * (2 * m)
for i in range(m):
if not removed[i]:
caps[2 * i] = W[i]
return caps
def extract_cut_edges(adj: list[list[int]], to: list[int], n: int, m: int, W: list[int]) -> tuple[int, list[int]]:
"""贪心提取最小割:返回 (修改图最大流, 按输入顺序排列的割边行号)。
逐条删除边并重跑最大流,若最大流恰好减少该边的修改容量 w_e,说明任何最大流
都无法绕开它,它属于某个最小割 → 保留删除;否则恢复。修改容量已“先压损失、
再压边数”,故删掉的边集就是“总代价最小、边数最少”的最优割。
"""
removed = [False] * m # 已确认属于最小割的边
flow0 = dinic(adj, to, make_caps(removed, W, m), n)
flow = flow0
ans: list[int] = []
for i in range(m):
removed[i] = True
nxt = dinic(adj, to, make_caps(removed, W, m), n)
drop_full = flow - nxt == W[i] # 恰好损失自己的容量:e 属于最小割
if drop_full:
ans.append(i + 1) # 卡车行号从 1 开始
flow = nxt
else:
removed[i] = False
return flow0, ans
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, m = next(data), next(data) # 仓库数 n、卡车数 m(真实数据为 n 在前)
edges = [(next(data), next(data), next(data)) for _ in range(m)] # (起点, 终点, 停运损失)
K = m + 1 # 修改容量的放大系数:任何割的边数都小于 K
W = [c * K + 1 for _, _, c in edges] # 修改容量:损失 c 的边变成 c*K+1
adj: list[list[int]] = [[] for _ in range(n + 1)]
to = [0] * (2 * m)
for i, (u, v, _) in enumerate(edges):
fwd, rev = 2 * i, 2 * i + 1
to[fwd], to[rev] = v, u
adj[u].append(fwd)
adj[v].append(rev)
flow0, ans = extract_cut_edges(adj, to, n, m, W)
cost, count = flow0 // K, len(ans) # 商是最小损失,余数是所选边数
out = [f"{cost} {count}"] + [str(i) for i in ans]
print("\n".join(out))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-06 11:33
* update_at: 2026-10-06 11:33
*
* main.cpp:追查坏牛奶。
* 把停运若干卡车使 1 号仓库到不了 n 号仓库建模成带权有向图的最小割,
* 用容量变换 w_e = c_e * K + 1(K = m + 1)把“损失最小、其次卡车数最少”
* 两个目标合并成一次最小割,再用 Dinic 求最大流;最后按输入顺序逐条删边
* 重测流,恰好让最大流下降 w_e 的边就属于该最小割。
*/
#include <cstdio>
#include <algorithm>
#include <queue>
using namespace std;
typedef long long ll;
const int MAXN = 35; // 仓库数 n <= 32
const int MAXM = 1005; // 卡车数 m <= 1000
const int MAXE = 2 * MAXM; // 每条边带一条反向边
const ll INF = 1000000000000000000LL;
int n; // 仓库数(题面第一行的第一个数)
int m; // 卡车数(题面第一行的第二个数)
int head[MAXN]; // head[u]:节点 u 的第一条出边编号
int nxt_edge[MAXE]; // 链式前向星的下一条边
int to[MAXE]; // 边的终点
ll cap[MAXE]; // 当前残量网络的容量(成对出现:2i 正向,2i+1 反向)
int edge_cnt; // 已加入的边数
ll base_w[MAXM]; // base_w[i]:第 i 条卡车线路的修改容量 c*K+1,下标从 0 开始
bool removed[MAXM]; // removed[i]:第 i 条线路是否已被确认属于最小割
int level[MAXN]; // Dinic:BFS 得到的层次
int cur[MAXN]; // Dinic:当前弧指针
// 加入一条有向边以及它的反向边,两者编号为 2i 与 2i+1。
void add_edge(int u, int v, ll c) {
to[edge_cnt] = v;
cap[edge_cnt] = c;
nxt_edge[edge_cnt] = head[u];
head[u] = edge_cnt;
edge_cnt++;
}
// 按 removed 标记重建残量网络:被删掉的线路容量为 0,其余取修改容量。
void reset_cap() {
for (int i = 0; i < 2 * m; i++) {
cap[i] = 0;
}
for (int i = 0; i < m; i++) {
if (!removed[i]) {
cap[2 * i] = base_w[i];
}
}
}
// Dinic 的 BFS 分层,返回汇点 t 是否可达。
bool bfs(int s, int t) {
for (int i = 1; i <= n; i++) {
level[i] = -1;
}
level[s] = 0;
queue<int> q;
q.push(s);
while (!q.empty()) {
int u = q.front();
q.pop();
for (int e = head[u]; e != -1; e = nxt_edge[e]) {
int v = to[e];
if (cap[e] > 0 && level[v] < 0) {
level[v] = level[u] + 1;
q.push(v);
}
}
}
return level[t] >= 0;
}
// Dinic 的一次增广:沿层次递增的路径把 f 的流量尽量送到汇点,返回实际送出量。
ll dfs(int u, int t, ll f) {
if (u == t) {
return f;
}
while (cur[u] != -1) {
int e = cur[u];
int v = to[e];
if (cap[e] > 0 && level[v] == level[u] + 1) {
ll d = dfs(v, t, min(f, cap[e]));
if (d > 0) {
cap[e] -= d;
cap[e ^ 1] += d;
return d;
}
}
cur[u] = nxt_edge[e];
}
return 0;
}
// 在当前残量网络上求 1 号仓库到 n 号仓库的最大流。
ll max_flow() {
ll flow = 0;
int s = 1;
int t = n;
while (bfs(s, t)) {
for (int i = 1; i <= n; i++) {
cur[i] = head[i];
}
while (true) {
ll d = dfs(s, t, INF);
if (d == 0) {
break;
}
flow += d;
}
}
return flow;
}
int main() {
scanf("%d %d", &n, &m);
for (int i = 1; i <= n; i++) {
head[i] = -1;
}
edge_cnt = 0;
ll K = m + 1; // 放大系数:任何割的边数都不超过 m,必小于 K
for (int i = 0; i < m; i++) {
int u, v;
ll c;
scanf("%d %d %lld", &u, &v, &c);
base_w[i] = c * K + 1; // 损失 c 的线路容量变成 c*K+1
add_edge(u, v, 0); // 容量稍后由 reset_cap 填入,这里先建好边结构
add_edge(v, u, 0);
removed[i] = false;
}
reset_cap();
ll total_flow = max_flow(); // 变换后图的最大流 = 最小割值
ll cur_flow = total_flow;
int ans[MAXM]; // 属于最小割的线路行号(按输入顺序)
int ans_cnt = 0;
// 贪心提取割边:逐条删除并重跑最大流,若最大流恰好下降该边的修改容量,
// 说明任何最大流都绕不开它,它属于某个最小割。
for (int i = 0; i < m; i++) {
removed[i] = true;
reset_cap();
ll next_flow = max_flow();
if (cur_flow - next_flow == base_w[i]) {
ans[ans_cnt] = i + 1; // 线路行号从 1 开始
ans_cnt++;
cur_flow = next_flow;
} else {
removed[i] = false;
}
}
ll cost = total_flow / K; // 商是最小总损失
printf("%lld %d\n", cost, ans_cnt);
for (int i = 0; i < ans_cnt; i++) {
printf("%d\n", ans[i]);
}
return 0;
}复杂度
- 一次 Dinic 最坏
,本题 、 ,实际分层少、单次很快。 - 贪心阶段对每条边重跑一次最大流,共
次,总复杂度 。 实测 12 组官方数据单测最长约 0.27 s。 - 空间
。
总结
把“停运卡车防送达”还原成带权有向图的最小割:最大流给出最小割值;容量变换
图示解析
带权有向图 1→n 删边使 1 不可达 n 枚举 2^m / 2^(n-2) 都爆炸
│ │ ▲
▼ ▼ │
建模:问题 = 最小割 ──▶ 最大流 = 最小割值 ──► 容量变换 w_e = c_e·K+1 (K=m+1)
│ │ 新割值 = K·代价 + 边数
▼ ▼
Dinic 求出 F ──────────────────────────────► 贪心:逐条删边重测流,恰好损失 w_e 的边入答案
│
▼
输出 F//K(最小损失)与按输入顺序的行号(最少边数)从左到右读:建模把删边方案等价成顶点二分(最小割);变换让一次最大流同时满足 “总代价最小”与“边数最少”两个排序目标;贪心把最小割的每一条边按输入顺序精确找出来, 直接输出。