同一格子只算一次 ⇒ 格子拆成入点出点,取分弧容量 1 费用 -a、借道弧容量 K 费用 0;跑 K 次 Dijkstra+势能的最小费用最大流取负。
OJ: roj
题目 ID: 3208
难度:提高+/省选-
标签:图论网络流费用流拆点最小费用最大流python
创建: 2026-10-02 01:57
更新: 2026-10-10 13:00
形式化题目
给定
约束:
题面样例:
| 行\列 | 0 | 1 | 2 |
|---|---|---|---|
| 0 | 1 | 2 | 3 |
| 1 | 0 | 2 | 1 |
| 2 | 1 | 4 | 2 |
答案是
正解
思路
第一步:想一个"看起来很快"的朴素做法。
既然要选
一次
但它错。 反例(
| 行\列 | 0 | 1 | 2 |
|---|---|---|---|
| 0 | 0 | 6 | 1 |
| 1 | 2 | 3 | 1 |
| 2 | 0 | 0 | 7 |
最大单条路径是
第二步:定位瓶颈——
贪心的本质是把
- 把
个位置塞进 DP 状态,让 条路径并排推进。 的经典方格取数就是这么做的 (四维 DP,两人同时走)。但状态数是 , 时是天文数字,直接出局。 - 把"选
条路径"当成一个整体结构来优化。 观察题目的计分规则: “第一次经过拿分、之后经过免费”——这不是路径之间互相干扰的复杂耦合, 而是一条容量规则:每个格子的"计分机会"只有 1 次。
第 2 条指向的模型就是网络流:把每个格子的计分机会做成一条容量 1 的弧,
再补一条容量
第三步:拆点建图。
每个格子
graph LR
IN["入点 u"] -->|"计分弧 cap=1 cost=-a"| OUT["出点 u+1"]
IN -.->|"借道弧 cap=K cost=0"| OUT
OUT -->|"向右 cap=K cost=0"| R["右邻居的入点"]
OUT -->|"向下 cap=K cost=0"| D["下邻居的入点"]
这张图画的是单个格子的内部结构与它向外的两条移动弧:实线是计分弧(容量 1,费用取负),
虚线是借道弧(容量
完整建图规则:
| 弧 | 容量 | 费用 | 含义 |
|---|---|---|---|
| 入点 |
第一次经过该格子,拿走 |
||
| 入点 |
再次经过,只借道不计分 | ||
| 出点 |
一步向右 | ||
| 出点 |
一步向下 |
源点取左上角入点
为什么这样建模是精确的? 每个格子的计分弧容量是 1,所以无论有多少条路径经过它,
只有一条能拿到
第四步:负费用弧与势能。
最小费用流每轮要沿费用最小的增广路推流,
只要所有残量弧的约化费用都非负,Dijkstra 就能用,且沿约化最短路增广等价于沿原费用最短路增广。
初始势不能取
样例的初始势(第一行入点、第二行出点):
| 格子 | |||||||||
|---|---|---|---|---|---|---|---|---|---|
| 0 | -1 | -3 | -1 | -3 | -6 | -1 | -5 | -9 | |
| -1 | -3 | -6 | -1 | -5 | -7 | -2 | -9 | -11 |
最后一格出点的势是
第五步:边界。
:一条路线都不安排,答案就是 ,直接特判输出。 :计分弧拿到也没有收益,建图时直接跳过,省一条边。 - 借道弧容量取
而不是 :多出来的容量永远用不到,却省掉了 时的边界讨论。
思路与代码的对应。 main.py 只分四层,和上面的推导一一对应:
link(u, v, c, w) 负责加一条正向弧与它的反向弧(反向弧编号 e ^ 1);
dag_potential(...) 是上文"初始势 = 源点最短路"那一步,按点编号从小到大扫一遍残量正向弧;
min_cost_flow(...) 里 K 轮 Dijkstra + 势能更新 + 沿 prev 回溯推流;
max_gain(grid, k) 负责拆点建图,solve() 只做读入、
代码
/**
* 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-10 13:00
* update_at: 2026-10-10 13:00
*/
// 方格取数:拆点建网络,最小费用最大流(k 次单位流,费用取负)
#include <cstdio>
#include <vector>
#include <queue>
using namespace std;
typedef long long ll;
const ll INF = 1000000000000000000LL;
int size_v;
vector<vector<int> > graph;
vector<int> to;
vector<ll> cap, cost;
// 连一条 u->v 容量 c 费用 w 的弧,紧跟一条残量 0、费用相反的弧
void link(int u, int v, ll c, ll w) {
graph[u].push_back((int)to.size());
to.push_back(v);
cap.push_back(c);
cost.push_back(w);
graph[v].push_back((int)to.size());
to.push_back(u);
cap.push_back(0);
cost.push_back(-w);
}
// 源点到各点的最短路当初始势:拆点编号本身就是一个拓扑序
void dag_potential(vector<ll>& h, int s) {
h.assign(size_v, INF);
h[s] = 0;
for (int u = 0; u < size_v; u++) {
if (h[u] == INF) {
continue;
}
for (int i = 0; i < (int)graph[u].size(); i++) {
int e = graph[u][i];
if (cap[e] > 0 && h[u] + cost[e] < h[to[e]]) {
h[to[e]] = h[u] + cost[e];
}
}
}
}
// 从 s 往 t 送 need 次单位流,返回最小费用
ll min_cost_flow(int s, int t, int need) {
vector<ll> h;
dag_potential(h, s);
ll total = 0;
for (int it = 0; it < need; it++) {
vector<ll> dist(size_v, INF);
vector<int> prev(size_v, -1);
priority_queue<pair<ll, int>, vector<pair<ll, int> >, greater<pair<ll, int> > > pq;
dist[s] = 0;
pq.push(make_pair(0LL, s));
while (!pq.empty()) {
pair<ll, int> top = pq.top();
pq.pop();
ll d = top.first;
int u = top.second;
if (d > dist[u]) {
continue;
}
for (int i = 0; i < (int)graph[u].size(); i++) {
int e = graph[u][i];
int v = to[e];
if (cap[e] > 0) {
ll nd = d + cost[e] + h[u] - h[v]; // 约化费用
if (nd < dist[v]) {
dist[v] = nd;
prev[v] = e;
pq.push(make_pair(nd, v));
}
}
}
}
if (prev[t] < 0) {
break; // 送不动了
}
for (int v = 0; v < size_v; v++) {
h[v] += (dist[v] < INF ? dist[v] : 0);
}
vector<int> path;
int v = t;
while (v != s) {
path.push_back(prev[v]);
v = to[prev[v] ^ 1];
}
for (int i = 0; i < (int)path.size(); i++) {
int e = path[i];
cap[e] -= 1;
cap[e ^ 1] += 1;
total += cost[e];
}
}
return total;
}
int main() {
int n, k;
if (scanf("%d %d", &n, &k) != 2) {
return 0;
}
vector<vector<ll> > grid(n, vector<ll>(n, 0));
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
scanf("%lld", &grid[i][j]);
}
}
if (k == 0) {
printf("0\n");
return 0;
}
size_v = 2 * n * n; // 格子 i 拆成 入点 2i 与 出点 2i+1
graph.assign(size_v, vector<int>());
const int STEP[2][2] = {{0, 1}, {1, 0}};
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
int u = 2 * (i * n + j);
if (grid[i][j]) { // 第一条穿过它的路径拿走格子里的数
link(u, u + 1, 1, -grid[i][j]);
}
link(u, u + 1, k, 0); // 之后的路径只借道
for (int s = 0; s < 2; s++) {
int ni = i + STEP[s][0], nj = j + STEP[s][1];
if (ni < n && nj < n) {
link(u + 1, 2 * (ni * n + nj), k, 0);
}
}
}
}
printf("%lld\n", -min_cost_flow(0, size_v - 1, k));
return 0;
}#!/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-02 01:57
# update_at: 2026-10-02 02:05
import sys
from heapq import heappop, heappush
INF = 10**18 # 最短路里的"不可达"
STEP = ((0, 1), (1, 0)) # 每一步只能往右或往下
TAKE = 1 # 拿分那条弧的容量:同一个格子只算一次
FREE = 0 # 借道那条弧的费用:格子被重复经过不再加分
def dag_potential(graph: list[list[int]], to: list[int], cap: list[int],
cost: list[int], s: int) -> list[int]:
"""源点到各点的最短路当初始势:拆点编号 0..2N²-1 本身就是 DAG 的拓扑序。"""
h = [INF] * len(graph)
h[s] = 0
for u in range(len(graph)):
if h[u] == INF: # 尚未连通,跳过
continue
for e in graph[u]:
if cap[e] and h[u] + cost[e] < h[to[e]]: # 只要还有残量的正向弧
h[to[e]] = h[u] + cost[e]
return h
def min_cost_flow(graph: list[list[int]], to: list[int], cap: list[int],
cost: list[int], s: int, t: int, need: int) -> int:
"""从 s 往 t 送 need 次单位流,返回最小费用;反向边编号是 e ^ 1。"""
size = len(graph)
h = dag_potential(graph, to, cap, cost, s) # 有负费用弧,必须先用它定势
total = 0
for _ in range(need):
dist = [INF] * size
prev = [-1] * size
dist[s] = 0
pq = [(0, s)]
while pq:
d, u = heappop(pq)
if d > dist[u]:
continue
for e in graph[u]:
v = to[e]
if cap[e]: # 只在残量弧上松弛
nd = d + cost[e] + h[u] - h[v] # 约化费用,势成立时非负
if nd < dist[v]:
dist[v] = nd
prev[v] = e
heappush(pq, (nd, v))
if prev[t] < 0: # 送不动了(本题网格总能送满 need 次)
break
for v in range(size):
h[v] += dist[v] if dist[v] < INF else 0
path: list[int] = [] # 这条最短路经过的弧,反向走回去
v = t
while v != s:
path.append(prev[v])
v = to[prev[v] ^ 1]
for e in path:
cap[e] -= 1
cap[e ^ 1] += 1
total += sum(cost[e] for e in path)
return total
def max_gain(grid: list[list[int]], k: int) -> int:
"""k 条从左上到右下的单调路径最多能取走多少整数(同一格只计一次)。"""
n = len(grid)
size = 2 * n * n # 格子 i 拆成 入点 2i 与 出点 2i+1
graph: list[list[int]] = [[] for _ in range(size)]
to: list[int] = []
cap: list[int] = []
cost: list[int] = []
def link(u: int, v: int, c: int, w: int) -> None:
"""连一条 u->v 容量 c 费用 w 的弧,紧跟一条残量 0、费用相反的弧。"""
graph[u].append(len(to))
to.append(v)
cap.append(c)
cost.append(w)
graph[v].append(len(to))
to.append(u)
cap.append(0)
cost.append(-w)
for i in range(n):
for j in range(n):
u = 2 * (i * n + j)
if grid[i][j]: # 第一条穿过它的路径拿走格子里的数
link(u, u + 1, TAKE, -grid[i][j])
link(u, u + 1, k, FREE) # 之后的路径只借道,不再加分
for di, dj in STEP:
if i + di < n and j + dj < n:
link(u + 1, 2 * ((i + di) * n + j + dj), k, FREE) # 走到右 / 下邻居的入点
return -min_cost_flow(graph, to, cap, cost, 0, size - 1, k)
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n, k = next(data), next(data)
grid = [[next(data) for _ in range(n)] for _ in range(n)]
print(0 if k == 0 else max_gain(grid, k))
if __name__ == "__main__":
solve()复杂度
- 时间复杂度:建图
,初始势 ,每次 Dijkstra ,共 轮, 合计 。 时点数 、弧数约 , 实测单点约 60ms。 - 空间复杂度:弧数组与邻接表
,每轮 Dijkstra 的距离数组与堆 ,合计 。
对比:朴素贪心是
总结
本题的关键是把"每格只计一次分"翻译成"容量 1 的弧"。
- 朴素贪心(逐条取最大权路径)在样例规模上很快,但它是短视的:第一条路径可能独占掉
对后续路径更有价值的格子。上面
的反例里贪心 、最优 。 - 把
条路径并排塞进 DP 状态是 , 时不可行。 - 换个角度看计分规则——"第一次经过拿分、之后经过免费"本来就是一条容量规则,
于是拆点建图:计分弧容量 1 费用
,借道弧容量 费用 ,移动弧容量 费用 , 跑 轮最小费用增广,答案取负。 - 计分弧费用为负,Dijkstra 必须配势能;由于拆点后的点编号天然是 DAG 拓扑序,
初始势可以用一遍
的最短路递推求出,比 SPFA 更稳。
图示解析
下面这张图串起从"路径并集"到"费用流答案"的主线:
输入 N, K, 网格 a
`- 朴素贪心:每条路径独立取最大权
`- 反例:第一条路独占好格子,合计 19 < 最优 20
`- 瓶颈:K 条路径必须联合决策
|- 塞进 DP 状态 -> O(N^(2K)) 不可行
`- 改成流量模型 -> 每格"计分机会"容量为 1
`- 拆点:入点 --cap1,-a--> 出点 --capK,--0--> 右/下邻居
`- 初始势 = 源点最短路(点编号即拓扑序)
`- K 轮 Dijkstra 增广,取费用相反数 = 答案先看"瓶颈"那一层:它解释了为什么必须改用流量模型,而不是把 DP 维数继续加大。
再看"拆点"那一层:容量 1 的计分弧与容量
在样例上,第一轮增广走