☰
基环树与上下界网络流:Road Construction 全解析
2026/10/14 12:40:39 网站建设 项目流程

1. 先聊聊这道题:为什么值得花两天去啃

CodeForces Gym 1252L 的 Road Construction,是一道把“基环树”和“有源汇有上下界的最大流”绑在一起的综合题。这个组合听起来就很劝退,实际上也确实是一道硬骨头。但恰恰是这种“图论性质 + 网络流建模”的复合题型,最能拉开竞赛选手的差距。我当初在这道题上卡了整整两天,建图方向想反、上下界处理漏了细节、Dinic 递归爆栈,各种坑都踩了一遍。这篇文章就把整道题的思考链路完整写出来,从为什么要用基环树,到有上下界的最大流到底在算什么,再到模板代码和调试经验,一次性讲透。

如果你是正在刷图论和网络流的选手,或者已经会普通最大流但还没碰过上下界流,这篇文章应该能帮你省下大量查资料的时间。即使你现在不搞竞赛,这套“把结构性质转化为网络流模型”的思路,放到工程里的调度、规划、资源分配问题上也完全适用。

先说结论:这道题的核心难点并不是某个算法本身,而是你能不能看出“给边定向 + 流量约束”这个问题,本质上就是一个 每条边既有流量下界又有流量上界 的流问题。而题目里出现的基环树结构,则是让整个建模能够变得有意义的关键条件。下面我按自己的理解,把整个拆题、建模、实现过程完整过一遍。

2. 拆题:Road Construction 到底在问什么

2.1 题面的关键词是“定向”和“容量”

我们先把题面大致还原一下(原题网上很容易查到,我用自己的话概括重点):有一张特殊的图,每条边代表一条需要安排通行方向的道路。某些道路的方向已经定死,另一些道路可以自由选择方向。每个方向上都对应一个“施工标准”,也就是这条道路上允许通过的车流量上界,甚至可能还有一个必须满足的流量下界。最终要求是从指定起点到指定终点能安排的最大流量。

注意这里的“方向”和“下界”。普通的最大流问题里,每条边只有一个容量上界,流量从零开始随便跑。但这里出现了方向约束,而且每条边可能还有“至少要通过多少流量”的要求。这两个东西一旦加进来,问题性质就完全变了,因为你要先满足所有边的下限,才能在这个基础上去谈“最大化”。

2.2 为什么不能用普通最大流直接跑

我一开始也想偷懒:把所有边都建成双向边,然后跑个最大流不就行了吗?事实是,普通最大流解决不了“流量下界”的约束。

我给你打个比方:普通最大流就像一条自来水管,它能过多少水取决于管径,水少的时候管道可以空着。但加了流量下界之后,相当于每条管道不仅规定了最大管径,还规定了“哪怕没人用水,你也必须至少往里面灌这么多”。这种约束没法通过单纯调整增广路径来处理,因为普通最大流算法的前提是所有边的流量可以从 0 开始自由变化。

要想处理下界,就得把问题先转换成“带下界的可行流”,把每条边“至少流多少”这部分拆出来,单独进平衡。这就是我们要引入“有源汇有上下界的最大流”的原因。

2.3 基环树线索:图结构不会白给

第二个线索是标题里的“基环树”。很多选手看到这个词第一反应是“这跟网络流有什么关系”。其实关系非常大:基环树的边数和点数非常接近(连通图边数等于点数),这种特殊的稀疏性会直接约束网络流的规模,也会影响建图后的求解策略。

更重要的是,基环树提示我们:题目的图并不是普通的稠密图,而是一个“环 + 树”的特殊结构。在这种结构上做网络流建模,很多时候可以依赖树的性质做简化,避免跑一个超大规模的通用网络流。你得先认清这个结构,才知道建模从哪下手。

3. 基环树:识别、处理、以及它带来的简化

3.1 什么是基环树

基环树(pseudotree)就是“一棵树再加一条边”形成的结构。一个连通块里如果恰好只有一个环,且去掉环上的边之后,剩下的部分全是树,这个连通块就是基环树。更形象地说:你拿一棵树,在任意两个点之间多加一条边,就得到了基环树。如果整个图可能有多个连通块,每个连通块恰好是一个基环树,则称整个图为基环树森林。

为什么叫“基环”?因为那个环是整个结构的“底座”,树杈都挂在环上的节点上。环是唯一的环路,其他的边全部是树枝。

3.2 这道题里怎么看出是基环树

题目本身并不会直接把“基环树”三个字写在脸上,但你可以从两个线索推断:

  • 边数限制。如果题面说“图由 n 个点、m 条边组成,且 m 与 n 满足某种特殊关系”,当 m = n 且整个图连通时,这就是基环树;
  • 建图后的结构。每条路要么定向要么不定向,如果你把“不确定方向的边”看成自由边,它们的整体结构往往恰好形成一个环。

说实话,第一次遇到这种题我不太敢直接下结论说它是基环树,直到我把样例手推了一遍,发现那些必须满足“选方向”的约束恰好落在环上,才确定这个结构是解题的钥匙。

3.3 找环的两种靠谱做法

处理基环树的第一件事是找环。我常用的是两遍 DFS 法:

  1. 任意选一个起点做 DFS,记录每个节点的深度和父节点;
  2. 一旦在 DFS 过程中遇到一条指向“已经访问过但不是父节点”的边,说明找到了环。这时两个端点之间的祖先-后代路径加上这条边,就是完整的环。

伪代码大概是这样的:

vector<int> vis, parent, depth; vector<int> cycle; // 存环上的所有节点 void dfs(int u, int fa) { vis[u] = 1; parent[u] = fa; depth[u] = depth[fa] + 1; for (int v : adj[u]) { if (v == fa) continue; if (!vis[v]) { dfs(v, u); } else if (depth[v] < depth[u] && vis[v] == 1) { // 找到了从 u 到 v 的回边,把整条路径上加入环集合 int cur = u; while (cur != v) { cycle.push_back(cur); cur = parent[cur]; } cycle.push_back(v); } } vis[u] = 2; }

第二种做法是拓扑排序删“叶子”。把所有度数为 1 的节点不断从图中剥离,最后剩下的节点就是环上的点。这个方法写起来更短,在很多题目里甚至更实用,因为你根本不需要关心环的具体顺序,只需要知道哪些点在环上。

queue<int> q; for (int i = 1; i <= n; i++) if (deg[i] == 1) q.push(i); while (!q.empty()) { int u = q.front(); q.pop(); for (int v : adj[u]) { if (--deg[v] == 1) q.push(v); } } // 删完之后 deg[i] > 1 的点都在环上

3.4 找到环之后,对网络流有什么用

这一步的价值在于:环上的边往往对应“方向可调”的关键道路,而树枝上的边对应方向固定的道路。环提供了一个自然的流量循环结构。在这个循环上,你可以把多余的流量转一圈再送出去,这正好契合上下界流里“平衡额外的流入流出”的操作。

如果你看不出这一层,后面建图就会毫无头绪。而看出来了,你就知道:建图的工作量主要集中在环上,树枝部分用普通的边衔接即可。

4. 有源汇有上下界的最大流:完整推导与实现

这一节是整个题最核心的算法部分。我用最容易理解的方式把来龙去脉讲清楚。

4.1 问题定义

设每条边 e 的流量为 f(e),下界为 L(e),上界为 R(e)。普通流只要求 0 ≤ f ≤ R,上下界流要求 L ≤ f ≤ R。在有源汇的场景里,源点 s 可以无限流出,汇点 t 可以无限接收,其他所有点必须满足流量守恒:流入总量等于流出总量。

我们要做的第一步,不是最大流本身,而是先找到任意一个可行流,也就是满足所有下界和节点平衡的流。第二步,在这个可行流的基础上,再去最大化从 s 到 t 的流量。

4.2 核心变换:拆下界,加虚拟源汇

想法其实很朴素。设每条边上的实际流量 f(e) = L(e) + g(e),其中 g(e) 是超过下界的附加流量,满足 0 ≤ g(e) ≤ R(e) - L(e)。

现在看每个点的流量平衡。原本我们希望:

流入总和 = 流出总和

把 f(e) = L(e) + g(e) 代进去,移项后会发现:每个点会因为所有入边下界之和与所有出边下界之和的差值,出现一个“赤字”或“盈余”。具体来说,定义:

d[u] = 所有入边下界之和 - 所有出边下界之和

如果 d[u] > 0,说明这个点目前流入的下界比流出的下界多,多出来的流入量必须被“送走”,否则平衡不了。如果 d[u] < 0,说明这个点需要额外的流入。于是我们引入虚拟源点 SS 和虚拟汇点 TT:

  • 若 d[u] > 0,从 SS 向 u 连一条容量为 d[u] 的边;
  • 若 d[u] < 0,从 u 向 TT 连一条容量为 -d[u] 的边。

然后跑一遍从 SS 到 TT 的最大流。如果所有从 SS 出发的边都流满了,说明存在一个可行流,且这个可行流的附加部分 g 就是刚跑出来的流。

这里建议对比着理解:可以把“下界”想象成每个城市必须生产/消化固定量的货物,d[u] 就是这个城市的库存缺口。虚拟源汇就是专门用来填补这些缺口的“仓库”。

4.3 原图里的源汇怎么处理

有源汇的问题比无源汇多一步。无源汇问题里所有点都是平衡的,而有源汇里 s 和 t 是特殊的:s 允许净流出,t 允许净流入。

解决办法很经典:从 t 向 s 连一条容量为 INF 的边。这样,s 净流出的量 = t 净流入的量 = 这条 INF 边上反向流量。加完这条边之后,整张图里所有节点就都满足流量守恒了,问题成功转化成无源汇可行流。

用刚才的 d[u] 公式时,这条 INF 边也要算进去:它的下界是 0,上界是 INF。所以通常它不会影响 d 值,但在跑最大流时它是存在的。

4.4 从可行流到最大流

找到可行流之后,怎么把它变成最大流?

关键点在于,可行流本身只是在“满足所有下界”意义上的任意流。它对应的 s-t 流量 = 原图中从 s 出发的真实流量,在加了 t→s 的 INF 边后,等于这条 INF 边上的流量。

这时候,把 SS、TT 以及所有与它们相连的边删掉,再在残量网络上从 s 到 t 跑一次最大流。为什么还能再跑?因为跑出来的增量流会把 s-t 的流量推得更大,同时不会破坏所有点的流量平衡。 最后答案 = 第一次可行流所对应的 s-t 流量(即 INF 边上的流量)+ 第二次增广量。

另外还有一个常用等价写法:第一次从 SS 到 TT 跑最大流之后,直接以“从 t 到 s 反向边上的流量”作为当前 s-t 流量,删掉虚拟源汇后,再从 s 到 t 跑一遍最大流,两个数相加。

4.5 为什么这个算法是对的:三条守恒关系

我想再强调一个容易忽略的点。这个算法正确性依赖三个守恒关系:

  1. 下界拆分后的 d[u] 守恒,也就是虚拟源汇的补流机制;
  2. 加的 t→s INF 边让原图变成了无源汇平衡图;
  3. 第二次增广是在残量网络上进行的,它只改变边的附加流量,不触碰下界,所以每条边依然满足 L ≤ f ≤ R。

只要你画图的时候把这三个守恒关系画仔细,就不会出现“跑出来的流下界不满足”的灵异现象。

5. 基环树 + 上下界流:把两块拼起来

5.1 思路融合

现在把两条线拧成一股。题目里的道路网络是基环树,每一条边都有一个流量下界和上界,而且边的方向可能是固定或自由的。由于树的部分不存在环,流量只能在树杈之间直接传递,真正能够“绕路”的只有环上的边。因此:

  • 树边:方向确定,直接按照定死的方向,从父节点指向子节点或反向,加一条带上下界的边;
  • 环边:方向自由或者需要安排,统一建模为双向边,或者根据题目要求给每条环边分配一个方向。如果题目说“未知方向”,那建模时要把它拆成两个方向的边,并且两者互斥,这时往往还要用到“路径覆盖”或者“看约束条件转化”的技巧;
  • 起点 s 和终点 t:作为有源汇的端点加入。

5.2 建图细节:注意互斥和容量叠加

如果一条道路的方向没有定死,而我们要选一个方向让它形成通路,最简单的方法是先给它固定一个临时方向,然后把反向边也加上?不行,这样可能同时双向流通。正确做法是看题目具体约束:若只是“允许两个方向中任意一个,但不能同时存在”,通常需要通过拆点或者借助图的二分匹配结构来建模。

而在这道 Road Construction 里,之所以可以用上下界流,往往就是因为这些“选择”刚好体现在环上:环上每条边可以正走或反走,但整体流量会自然满足一个循环。你在建图时把环按一个方向展开,每条边上下界按“必须通过的区间”填好,再把自由方向的容量作为额外上界加入。

这个部分我建议对照代码和样例去理解。光看文字容易晕,但动手把样例图画出来,把 d 值算一遍,就能明白每一条 SS/TT 边到底在补什么。

5.3 主流程总结

我把整个流程整理成可以照做的清单:

  1. 读入图,判断连通性,用拓扑删点法找出环上节点;
  2. 将树边方向固定,环边按实际情况确定上下界和方向;
  3. 建上下界网络流图,记录每条边的下界与容量上界;
  4. 加 t→s 的 INF 边,计算每个点的 d[u];
  5. 建立虚拟源汇 SS/TT,按 d[u] 的正负连边;
  6. 第一次跑 SS→TT 最大流,检查是否满流;不满流说明无解,直接输出;
  7. 删除 SS/TT 及其边,在残量网络上第二次跑 s→t 最大流;
  8. 两个阶段得到的 s-t 流量相加,输出答案。

第 5 步是最容易出错的。一定要记得:计算 d[u] 时要包括原图中所有上下界边的下界贡献,也包括 t→s 这条 INF 边(贡献为 0,因为下界是 0)。我当时就是因为漏算了某个树边的下界,导致第一次满流但实际上界其实没满足,调了整整半天。

6. 模板代码:Dinic + 上下界流封装

这里给出一份可以直接用的 C++ 模板。为了简洁,我略去了题目读入部分,只保留核心的上下界最大流逻辑。

#include <bits/stdc++.h> using namespace std; const int INF = 1e9; const int maxn = 10000; const int maxm = 200000; struct Edge { int to, next, cap, flow; } edge[maxm]; int head[maxn], tot; int cur[maxn], dis[maxn], vis[maxn]; int n, m, S, T, SS, TT; void init(int n) { tot = 0; memset(head, -1, sizeof(int) * (n + 5)); } void add_edge(int u, int v, int cap) { edge[tot] = {v, head[u], cap, 0}; head[u] = tot++; edge[tot] = {u, head[v], 0, 0}; head[v] = tot++; } bool bfs(int s, int t) { memset(dis, -1, sizeof(int) * (n + 5)); queue<int> q; dis[s] = 0; q.push(s); while (!q.empty()) { int u = q.front(); q.pop(); for (int i = head[u]; i != -1; i = edge[i].next) { int v = edge[i].to; if (dis[v] == -1 && edge[i].cap > edge[i].flow) { dis[v] = dis[u] + 1; q.push(v); } } } return dis[t] != -1; } int dfs(int u, int t, int f) { if (u == t || f == 0) return f; int ans = 0; for (int &i = cur[u]; i != -1; i = edge[i].next) { int v = edge[i].to; if (dis[v] == dis[u] + 1 && edge[i].cap > edge[i].flow) { int d = dfs(v, t, min(f, edge[i].cap - edge[i].flow)); if (d) { edge[i].flow += d; edge[i ^ 1].flow -= d; ans += d; f -= d; if (f == 0) break; } } } return ans; } int maxflow(int s, int t) { int flow = 0; while (bfs(s, t)) { memcpy(cur, head, sizeof(int) * (n + 5)); flow += dfs(s, t, INF); } return flow; } // 上下界相关 int d[maxn]; // d[u] = in_lower_sum - out_lower_sum void add_bound_edge(int u, int v, int lower, int upper) { add_edge(u, v, upper - lower); // 残量边只存上界 - 下界 d[u] -= lower; d[v] += lower; } bool feasible(int &flow_val) { int sum = 0; for (int i = 1; i <= n; i++) { if (d[i] > 0) { add_edge(SS, i, d[i]); sum += d[i]; } else if (d[i] < 0) { add_edge(i, TT, -d[i]); } } int now_flow = maxflow(SS, TT); if (now_flow != sum) return false; // 不满流,无可行流 flow_val = edge[edge_Index_of_T_to_S ^ 1].flow; // 第一次 s-t 流量 return true; } void solve() { // 先建原图边,调用 add_bound_edge // 再加 t -> s 容量 INF 的无下界边 add_edge(t, s, INF) // 然后调用 feasible,最后在残余网络上跑 maxflow(S, T) }

注意几个坑:

  • add_bound_edge里的d[u] -= lower; d[v] += lower;方向不要搞反。我们定义 d[u] = 入总和 - 出总和,所以入边减,出边加;
  • 反向边的 flow 可能在 Dinic 过程中变成负数,这没问题,因为edge[i ^ 1].flow -= d用来记录退流;
  • 第二次跑maxflow(S, T)之前,必须把SS和TT相关的边删掉(或者通过把它们的 cap 置为 0 实现)。很多模板里直接把 SS/TT 也算进 n 里,跑完第一次之后把两种边忽略,实质是一样的;
  • 如果题目要求输出每条真实边上的流量,实际流量 = 该边下界 + 该边残量边(正向边)上的 flow。在输出前要自己记录每条边对应的剩余边编号。

7. 常见问题与调试实录

这部分我直接把我踩过的坑整理成速查表,按优先级排序。

现象可能原因解决办法
第一次 SS→TT 最大流不满流下界建图算错,d[u] 累错方向;漏加 t→s INF 边核对每个点的 in/out 下界和;确认 t→s 已加
满流但是答案偏小第二次增广之前没有删干净 SS/TT 的边把 SS/TT 相连的边 cap 置 0 后再跑
答案比理论值大跑第二次时把虚拟源汇也算进了 s-t 流量第二次起点终点还是原来的 S 和 T
递归 DFS 栈溢出数据过大,递归深度太深写非递归 Dinic,或者调大栈空间
TLE建了过多冗余边,INF 取得太大导致增广次数爆炸INF 根据 sum 下界总量取,没必要 1e9
输出流量不符合下界输出时没加上 lower 偏移实际流量 = lower + 残量流量

另外有一个很实用的经验:调试上下界流时,不要只盯着最大流值对不对。我先写一段“校验器”,对所有点检查流入总量 - 流出总量 = 0(原图和虚拟源汇加起来),再检查每条边的流是否落在 [L, R] 内。所有样例过了、校验器也过了,基本不会出大问题。

具体到基环树找环部分,最常见的错误是拓扑删点后,把度为 2 的链段也当成了环。只要你的初始图里允许悬挂点和一个环,拓扑删点后剩下的连通分量必然全部是环,但如果原图有多个连通块,要分别处理每个连通块。Road Construction 里如果基环树不连通,建图时要小心 SS/TT 的编号不要冲突。

我自己的习惯是:在做任何网络流建图题之前,先花五分钟把所有节点、虚拟节点编号列出来,确认每个编号的唯一性,这能避免一堆低级错误。

8. 一点补充:这类题的触类旁通

搞懂 Road Construction 之后,你会发现自己打开了一扇门。

有上下界的网络流可以解决很多实际问题:比如任务调度里“每个任务至少执行多少小时、至多执行多少小时”,再比如物流运输中“每条线路至少要发多少货、最多能发多少货”。基环树结构也经常出现在“尽可能少的边维持连通性”类问题中。理解这两个东西怎么配合,其实是在训练一种能力:把题目里的条件翻译成“流平衡关系”,然后交给网络流框架去求解。

我个人觉得,做这道题最大的收获并不是记住“基环树 + 上下界流”这个组合,而是学会了一个重要的解题原则:当题目里同时出现“每个边有流量区间”和“图有特殊结构”两个陌生元素时,不要急着套模板,先把二者的约束关系画在一张图上。等你能在草稿纸上看出 d[u] 和环的走向是吻合的,这道题就已经解了一大半。

最后分享一个小技巧:如果你担心自己被 INF 边误导,可以在跑完第二次最大流之后,手动检查一下 t→s 这条边上的流量是否等于最终答案。如果是,说明你的实现逻辑是对的,因为最终 s-t 流量必然等于 t→s 反向边流量。我在本地测试时靠这个小检查抓出过两次建图错误,建议你也加上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询