「Drainage Ditches」 排水沟

GitHub跳转原题关系图返回列表

把有向沟渠网络看作流网络(容量=速率,源=1,汇=M),用 Dinic 的 BFS 分层 + DFS 阻塞流 + 当前弧求最大流。

OJ: roj

题目 ID: 3224

难度:提高+/省选-

标签:图论网络流最大流Dinic

创建: 2026-10-02 03:11

更新: 2026-10-10 11:30

形式化题目

给定一张有向图 G=(V,E)G=(V,E),∣V∣=M|V| = M、∣E∣=N|E| = N,每条弧 (Si,Ei)(S_i,E_i) 带非负容量 CiC_i (CiC_i 允许为 00)。指定源点 s=1s = 1、汇点 t=Mt = M。

求一个满足下面两个约束的流 f:E→Z⩾0f : E \to \mathbb{Z}_{\geqslant 0} 的最大值:

0⩽f(u,v)⩽c(u,v),∑uf(u,v)=∑wf(v,w)(v≠s, t). 0 \leqslant f(u,v) \leqslant c(u,v), \qquad \sum_{u} f(u,v) = \sum_{w} f(v,w) \quad (v \neq s,\ t).

也就是求从 11 号点到 MM 号点的最大流。图中可能存在重边、自环与环, 更不保证 Si<EiS_i < E_i,所以它不是 DAG,不能靠拓扑序上的 DP 解决。

以样例为例,5 条沟渠构成的流网络如下(箭头上的数字是容量)。

graph LR
    v1((1 池塘))
    v2((2))
    v3((3))
    v4((4 河))
    v1 -->|40| v2
    v1 -->|20| v4
    v2 -->|20| v4
    v2 -->|30| v3
    v3 -->|10| v4

读者可以观察到三件事:11 的两条出边容量之和是 6060,但进入 44 的三条边容量之和只有 20+20+10=5020+20+10 = 50,答案正是 5050 —— 说明答案受"每一条弧各自的上限"同时约束,是多条路共享容量的结果, 不是某一条最宽路的瓶颈。另外 1→2→41\to2\to4 与 1→2→3→41\to2\to3\to4 都要穿过 1→21\to2 这条容量 4040 的沟渠, 它们必须分这 4040 的额度。

正解

思路

第一步:为什么"找一条最宽的路"不对

最朴素的想法是:找一条从 11 到 MM 的路,让它的瓶颈尽量大,把这条路的流量当作答案。 但样例直接否定了它:只用 1→41 \to 4 只能得到 2020; 1→2→41\to2\to4、1→2→3→41\to2\to3\to4 与它并不互斥,三条路可以同时送水, 各自的瓶颈 20,20,1020,20,10 加起来才是 5050。

所以答案不是一条路,而是一组互相共享容量、又都不超限的"流量分配"。这正是流的定义: 每条弧上有流量 f(u,v)f(u,v),除源汇外每个点流入等于流出,且 0⩽f(u,v)⩽c(u,v)0 \leqslant f(u,v) \leqslant c(u,v)。

第二步:贪心推路径为什么必须能"撤销"

第二个想法是:反复找一条"还有剩余容量的路",沿路把瓶颈流量推满并累加,直到找不到为止。

这个方向是对的,但只推正向会卡住。看一个最小反例:容量为 s→a=1s\to a=1、s→b=1s\to b=1、a→b=1a\to b=1、a→t=1a\to t=1、b→t=1b\to t=1。 如果第一步推了 s→a→b→ts \to a \to b \to t(瓶颈 11),则 a→ta \to t、s→bs \to b 都空着, 可流只有 11;而最优是 s→a→ts\to a\to t 与 s→b→ts\to b\to t 两条,共 22。 要救回来,必须能把 a→ba \to b 上已经推的那单位水退回去。

做法是维护残量网络 GfG_f:每条弧 u→vu \to v 除了正向剩余容量 c(u,v)−f(u,v)c(u,v) - f(u,v), 再追加一条反向弧 v→uv \to u,容量等于已推送的 f(u,v)f(u,v)。 反向弧上推流量不代表水真的倒流,它的含义是减少原来那条正向弧的流量。 把"撤销"变成"走一条反向弧"之后,算法就统一成了"在 GfG_f 里找路"。

第三步:用增广路定理给出终止条件

设 ff 是当前可行流,若 GfG_f 里还有 s→ts \to t 的路(称为增广路), 沿着它每条弧都增加 δ=min⁡cf\delta = \min c_f,就得到一个流量多 δ\delta 的可行流 (每条约中间点进、出各变 δ\delta,净流入不变;每条原弧要么加不超容量、要么减不为负)。

反过来,若 GfG_f 里没有增广路,取 SS = 从 ss 在 GfG_f 中可达的点集: 任何从 SS 指向 Sˉ\bar S 的原图弧若还没满流,它的剩余容量就会让 Sˉ\bar S 里的端点也可达,矛盾, 所以这些弧全部满流;同理 Sˉ→S\bar S \to S 的原图弧流量必须为 00,否则反向剩余容量同样会让 Sˉ\bar S 可达到达。 两者合起来说明跨割的净流量 ∣f∣=∑u∈S,v∉Sc(u,v)|f| = \sum_{u\in S, v \notin S} c(u,v) 恰等于这个割的容量; 而任何流的值都不超过任何割的容量,所以 ff 已经是最大流。

增广路定理:ff 是最大流   ⟺  \iff GfG_f 中不存在 s→ts \to t 的路。

这条定理就是"不断增广、直到无路可走"这一族算法(Ford–Fulkerson 思路)的正确性依据。

第四步:Dinic —— 把"每轮一条路"变成"每轮一层阻塞流"

朴素的每次只增广一条路,轮数可能和答案大小成正比(这里 CiC_i 可达 10710^7,完全不可行)。 Dinic 把找路这件事批量化:

  1. BFS 分层:在 GfG_f 上只沿 cap > 0 的弧走,记 level[v] 为 ss 到 vv 的最少弧数; 若 level[t] < 0,由增广路定理当前流即最大流,返回。
  2. DFS 推阻塞流:只保留满足 level[v] == level[u] + 1 的弧, 在这张分层图上反复找 s→ts \to t 路并推满,直到图中再也没有这样的路 (此时推到的流量叫阻塞流)。
  3. 回到第 1 步。

分层带来两个好处:

  • 路径不会绕圈:每条弧层号严格 +1,路长被限制在 M−1M-1 以内;
  • 轮数有界:一轮结束意味着分层图上所有短的路都被堵死, 新的最短增广路必须变长,因此 level[t] 每轮严格增大,外层最多 O(M)O(M) 轮,与 CiC_i 的大小无关。

第五步:当前弧与回溯优化

  • DFS 中如果某条出弧被判定走不通(容量为 00 或层号不接),它在本轮永远不会再走通, 用一个指针 it[u] 记住扫到哪,下次从它继续,不再从头扫出边。
  • 递归写法在 Python 中既有栈深风险又慢,代码里用显式栈 path(元素是 (点, 出边号))模拟:
    • 走到汇点后沿 path 扣正弧、加反弧,然后退回到第一条被榨干的弧继续, 而不是退回源点重来;
    • 走到死路时把该点的 level 置 -1(本轮永久废弃)并弹栈回溯。

下面这张表是用代码里的算法在样例上逐轮打印的真实过程,用来把上面的抽象步骤落到具体数字。

轮次 BFS 得到的 level 本轮增广的路径 瓶颈 δ\delta 累计流量
1 1:0, 2:1, 3:2, 4:1 1→41 \to 4 20 20
2 1:0, 2:1, 3:2, 4:2 1→2→41 \to 2 \to 4 20 40
3 1:0, 2:1, 3:2, 4:3 1→2→3→41 \to 2 \to 3 \to 4 10 50

表格每行是一轮:第一列是轮次,第二列是该轮 BFS 得到的分层结果(只列可达点,level 就是它离源点的弧数), 第三列是该轮被推的增广路,第四、五列分别是这条路的瓶颈与累加后的总流量。观察重点:

  • 第 1 轮选中的是最短的 1→41\to4(层差 11),而不是路长更大的 1→2→3→41\to2\to3\to4; Dinic 每轮只在意"最短"这一件事。
  • 第 2 轮汇点的层号从 11 变成 22,直观展示了"最短增广路长度单调递增", 这正是外层最多 O(M)O(M) 轮的来源。
  • 第 3 轮之后 11 的两条出弧全部满流、汇点不可达,算法返回 5050; 此时进入 44 的三条弧容量和 20+20+10=5020+20+10=50 被恰好用满,答案不可能更大。

代码

cpp
/**
 * 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 11:30
 * update_at: 2026-10-10 11:30
 */
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int NO_EDGE = -1;
const int UNREACHED = -1;

int head[205];
vector<int> to, nxt;
vector<ll> cap;

void add_edge(int u, int v, ll c) {
    to.push_back(v); cap.push_back(c); nxt.push_back(head[u]); head[u] = (int)to.size() - 1;
    to.push_back(u); cap.push_back(0); nxt.push_back(head[v]); head[v] = (int)to.size() - 1;
}

int level[205];

// BFS 分层
bool build_levels(int s, int t, int m) {
    for (int i = 1; i <= m; i++) level[i] = UNREACHED;
    level[s] = 0;
    deque<int> q;
    q.push_back(s);
    while (!q.empty()) {
        int u = q.front(); q.pop_front();
        int e = head[u];
        while (e != NO_EDGE) {
            int v = to[e];
            if (cap[e] > 0 && level[v] == UNREACHED) {
                level[v] = level[u] + 1;
                q.push_back(v);
            }
            e = nxt[e];
        }
    }
    return level[t] != UNREACHED;
}

// 在当前分层图上推阻塞流
ll blocking_flow(int s, int t) {
    vector<int> it(head, head + 205);
    vector<pair<int, int> > path;   // (点, 走出的弧号)
    ll flow = 0;
    int u = s;
    while (true) {
        if (u == t) {
            ll bottle = 1LL << 60;
            for (size_t i = 0; i < path.size(); i++)
                bottle = min(bottle, cap[path[i].second]);
            for (size_t i = 0; i < path.size(); i++) {
                cap[path[i].second] -= bottle;
                cap[path[i].second ^ 1] += bottle;
            }
            flow += bottle;
            size_t first = 0;
            while (first < path.size() && cap[path[first].second] != 0) first++;
            path.resize(first);
            u = path.empty() ? s : to[path.back().second];
        } else {
            int e = it[u];
            int advance = NO_EDGE;
            while (e != NO_EDGE) {
                int v = to[e];
                if (cap[e] > 0 && level[v] == level[u] + 1) { advance = e; break; }
                e = nxt[e];
            }
            it[u] = e;
            if (advance != NO_EDGE) {
                path.push_back(make_pair(u, advance));
                u = to[advance];
            } else if (path.empty()) {
                break;
            } else {
                level[u] = UNREACHED;
                u = path.back().first;
                path.pop_back();
            }
        }
    }
    return flow;
}

ll max_flow(int s, int t, int m) {
    ll flow = 0;
    while (build_levels(s, t, m)) {
        flow += blocking_flow(s, t);
    }
    return flow;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    int s = 1, t = m;
    for (int i = 1; i <= m; i++) head[i] = NO_EDGE;
    for (int i = 0; i < n; i++) {
        int u, v;
        ll c;
        cin >> u >> v >> c;
        add_edge(u, v, c);
    }
    cout << max_flow(s, t, m) << "\n";
    return 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-02 03:11
# update_at: 2026-10-02 03:16

import sys
from collections import deque

NO_EDGE = -1    # 链式前向星:没有下一条弧
UNREACHED = -1  # BFS 分层:本轮还没访问到 / 从 s 到不了


def add_edge(head: list[int], to: list[int], nxt: list[int], cap: list[int],
             u: int, v: int, c: int) -> None:
    """加一条 u->v 容量 c 的弧,紧跟一条容量 0 的反向弧:两条弧编号互为 e ^ 1。

    反向弧的容量表示"这条正向弧上已推了多少水",走它就等于把水退回去。
    """
    to.append(v)
    cap.append(c)
    nxt.append(head[u])
    head[u] = len(to) - 1
    to.append(u)
    cap.append(0)
    nxt.append(head[v])
    head[v] = len(to) - 1


def build_levels(head: list[int], to: list[int], nxt: list[int], cap: list[int],
                 s: int) -> list[int]:
    """BFS 分层:只沿还有剩余容量的弧走,返回每点离 s 的弧数,够不到记 UNREACHED。"""
    level = [UNREACHED] * len(head)
    level[s] = 0
    queue = deque([s])
    while queue:
        u = queue.popleft()
        e = head[u]
        while e != NO_EDGE:
            v = to[e]
            if cap[e] > 0 and level[v] == UNREACHED:
                level[v] = level[u] + 1
                queue.append(v)
            e = nxt[e]
    return level


def blocking_flow(head: list[int], to: list[int], nxt: list[int], cap: list[int],
                  level: list[int], s: int, t: int) -> int:
    """在当前分层图上推阻塞流:只走层号严格 +1 的弧,直到再也走不到 t,返回流量。

    用显式栈代替递归:栈里存 (点, 从它走出去的弧号)。层号接不上的弧本轮永远接不上,
    由当前弧 it[u] 跳过;某点的出弧全废就把它的层号抹掉,回退时不再进来。
    """
    it = head[:]                           # 当前弧:每条弧走废后本轮不再重扫
    path: list[tuple[int, int]] = []
    flow = 0
    u = s
    while True:
        if u == t:
            bottle = min(cap[e] for _, e in path)
            for _, e in path:
                cap[e] -= bottle
                cap[e ^ 1] += bottle    # 反向弧加容量 = 允许把这段水退回去
            flow += bottle
            # 只退回到第一条被榨干的弧,从那里接着找下一条增广路
            first = next(i for i, (_, e) in enumerate(path) if cap[e] == 0)
            path = path[:first]
            u = to[path[-1][1]] if path else s
        else:
            e, advance = it[u], NO_EDGE
            while e != NO_EDGE:
                v = to[e]
                if cap[e] > 0 and level[v] == level[u] + 1:
                    advance = e            # 分层图上唯一可走的出弧
                    break
                e = nxt[e]
            it[u] = e
            if advance != NO_EDGE:
                path.append((u, advance))
                u = to[advance]
            elif not path:                 # 源点的弧全废,本轮阻塞流已挖空
                break
            else:                          # 死路:抹掉层号并退回上一个点
                level[u] = UNREACHED
                u = path.pop()[0]
    return flow


def max_flow(head: list[int], to: list[int], nxt: list[int], cap: list[int],
             s: int, t: int) -> int:
    """Dinic:反复"分层 + 推阻塞流",直到残量网络里 t 不可达。"""
    flow = 0
    while True:
        level = build_levels(head, to, nxt, cap, s)
        if level[t] == UNREACHED:          # 无增广路,当前流已是最大流
            return flow
        flow += blocking_flow(head, to, nxt, cap, level, s, t)


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    n, m = next(data), next(data)
    s, t = 1, m                             # 交叉点 1 是池塘(源),交叉点 m 是河(汇)

    head = [NO_EDGE] * (m + 1)
    to: list[int] = []
    nxt: list[int] = []
    cap: list[int] = []
    for _ in range(n):
        u, v, c = next(data), next(data), next(data)
        add_edge(head, to, nxt, cap, u, v, c)  # 水只能沿 Si -> Ei 流

    print(max_flow(head, to, nxt, cap, s, t))


if __name__ == "__main__":
    solve()

复杂度

记 V=MV = M、E=NE = N:

  • BFS 分层一次是 O(V+E)O(V+E),外层最多 O(V)O(V) 轮(最短增广路长度每轮严格增大,上限 V−1V-1);
  • 每轮找阻塞流:当前弧 it[u] 保证每条弧最多被推进或作废一次, 而每次成功增广都榨干至少一条弧(所以一轮里增广次数不超过 EE),每次增广回溯不超过 VV 条弧, 于是单轮是 O(VE)O(VE)。

因此总复杂度为 O(V2E)O(V^2 E),即一般图 Dinic 的经典上界。 本题 V,E⩽200V, E \leqslant 200,V2EV^2E 约 8×1068\times10^6 量级的弧操作上界; 实测 10 组真实数据(含 M=200M=200、N=200N=200 的点)每组不超过 0.020.02 秒、峰值内存约 1515 MB (题面限制 1000 ms / 128 MB),因此没有 TLE / MLE 风险。

空间上只用链式前向星的 2E2E 条弧与长度 O(V)O(V) 的 level、it 数组,即 O(V+E)O(V+E)。

总结

  • 沟渠的方向就是弧的方向、速率就是容量,问题直接等价于单源单汇最大流, 不需要任何额外建模(不同于二分图匹配题要加源汇点)。
  • 关键转弯有两处:一是意识到答案不是一条路,必须允许流量在多条路之间共享; 二是允许"撤销",用反向弧把撤销变成又一次普通的增广。
  • 增广路定理给出了简洁的终止条件;Dinic 用 BFS 分层把每轮从"一条路"升级为"一张分层图上的阻塞流", 使轮数只与点数有关而与容量无关;当前弧 + 显式栈回溯是让它跑得动 Python 的常数优化。
  • 边界情况(N=0N=0、容量全 00、源无出弧、重边、自环、环)都被同一套流程自然覆盖,代码里没有任何特判。