上期回顾:https://www.cnblogs.com/ofnoname/p/18678895
之前我们已经介绍了最大流问题的基本定义,让从源点流出的总流量达到最大,同时不违反任何管道的运输能力限制。学习了最大流最小割定理、增广路径与残量网络的构建方法,以及如何利用这些概念实现 EK 算法。EK 算法通过每次使用 BFS 寻找从源点到汇点的最短增广路径,保证了算法在有限步内终止,但是频繁的路径搜索会导致效率不高。
经典 Ford-Fulkerson 方法通过不断寻找增广路径 (残量网络中 \(s\) 到 \(t\) 的路径)来增加流量,通过改进为 Dinic 和 ISAP 后,在随机图上很快,但在某些情况下,但其效率存在明显瓶颈:
例如,在 Zig-zag 图等特殊构造中,DFS/BFS 可能总是选择低效的增广路径。
每次增广至少增加 1 单位流量,最坏时间复杂度为 \(O(|E| \cdot |f_{max}|)\),其中 \(|f_{max}|\) 是最大流量值。对于较稠密图和特殊构造的图,极容易达到最坏情况。
这些缺陷催生了更高效的算法 ------ Push-Relabel 框架,它不再依赖增广路径搜索,而是通过局部操作直接模拟流体的重力运动。接下来我们将深入解析这一革命性的思路。
Push-Relabel ------ 像洪水一样思考
从"修水管"到"造瀑布"
想象你是一名城市规划师,传统增广路算法就像在复杂的下水道系统中一节一节地拼接管道 ,必须找到一条完整的从水源到水库的路径才能放水。而 Push-Relabel 则是一场人为制造的洪水------你不再关心全局路径,而是直接让水从高处向低处倾泻,甚至临时造山抬高地势让水流改道!
这种思维的颠覆性在于:
- 局部性:每次只需关注单个节点及其邻居的状态
- 异步性:不同区域的水流可以独立推进
- 容错性:源点和汇点通常特殊处理,而其他节点允许暂时违反流量守恒,入大于出(后续再修正)
核心概念
在执行 Push-Relabel 过程时,中间状态下每个节点有以下两个状态:
(1) 高度函数(Height Function)------地形的海拔
给每个节点 \(u\) 分配一个高度值 \(h(u)\),想象这是该点的海拔高度。算法运行时,水流只能从高处流向低处 (严格来说是流向高度恰好低1 的邻居)。惯例上,初始时,源点 \(s\) 被固定到"云端"(高度为 \(|V|\)),而汇点 \(t\) 和其他点"海平面"(高度为0)。高度会在运行时被修改。
(2) 超额流(Excess Flow)------节点的蓄水池
每个非源点汇点的节点 \(u\) 维护一个超额流 \(e(u)\),表示该点当前存储的未分配水量(入减出)。只有源点 \(s\) 和汇点 \(t\) 可以无限产生/吸收水(\(e(s)=+\infty, e(t)=-\infty\)),其他节点必须通过Push 操作将超额水最终全部流向低处。
(想象节点是蓄水池,高度差形成瀑布,池子满了就会溢出)
两大基本操作
操作一:Push(推送)------ 瀑布效应
源点需要特殊处理,它可以无限往外流水。所以初始化时让源点无视高度差向所有出边灌水。
而当普通节点 \(u\) 有超额流(\(e(u)>0\)),且存在邻居 \(v\) 满足:
\[h(u) = h(v) + 1 \quad \text{且} \quad (u,v) \text{ 在残量网络中有剩余容量} \]
则可以将 \(\delta = \min(e(u), c_f(u,v))\) 单位流量推送到 \(v\),效果相当于:
- \(e(u) \leftarrow e(u) - \delta\)
- \(e(v) \leftarrow e(v) + \delta\)
- 更新残量网络(正向边减 \(\delta\),反向边加 \(\delta\))
即每个节点都可以把自己的多余流量向周围高度刚好少 1 的节点推送完。
操作二:Relabel(重贴高度)------ 人造山峰
当节点 \(u\) 有超额流,但所有邻居的海拔都不低于它(无法形成瀑布),那么他多出来的水就放不出去了,此时必须"抬高地形":
\[h(u) \leftarrow 1 + \min\{ h(v) \mid (u,v) \in E_f \} \]
将高度修改为周围节点中的最小高度加一这相当于在 \(u\) 下方突然造出一座更高的山,迫使水流找到新的出口。显然高度只会不断升高。
戏剧性场景:
- \(u\) 当前高度3米,蓄水5吨
- 所有邻居高度 ≥3米,形成"死水"
- Relabel 后,\(u\) 高度变为(邻居最小高度+1)=4米
- 下一轮可能发现某个邻居现在高度3米,形成新的瀑布!
在英语语境里常称呼节点高度为 distance label,不过算法导论里称其为高度,这是更生动的叫法,表示同一个意思。
设图中节点总数为 \(n\),随着高度不断增加,算法运行中可能出现 \(h(u) > n\) 的情况(即高过源点)。此时节点 \(u\) 的高度实际上进入了"幽灵层"------它不再对应真实的地形,而是一种数学上的占位符:
- 语义转换 :\(h(u) > n\) 意味着 \(u\) 到汇点 \(t\) 的路径已被完全阻塞,他的超额流量无论如何也送不到汇点了。根据高度差约束,水流只能从 \(h(u) = h(v)+1\) 的边推送。若 \(h(u) > n\) 且汇点 \(t\) 的高度始终为 0,则 \(u\) 到 \(t\) 的路径上必然存在高度断层(至少需要 \(n+1\) 层高度差),从而阻断正向流动。
- 行为逻辑 :这些节点的超额流将由高度差被迫反向流动 ,最终退回源点 \(s\)
可以证明高度最高为 \(2|V|-1\)。
如在下面的图里,首先由源点放出 \(10\) 流量到 \(c\),然后 \(c\) 将被反过来抬升并最终逐次送回超额流量,最终答案是\(1\)。
s → a 10
a → b 7
b → c 4
c → t 1
理论上,每次遍历所有节点,检查可以 push 或 relabel 的节点并操作,就可以得到答案。
HLPP 算法
基础 Push-Relabel 的 \(O(n^2m)\) 时间复杂度是盲目的。例如在下图结构中,普通算法可能会反复将节点 \(u\) 的水推给 \(v\),又因 \(v\) 无法排水而推回 \(u\),形成"打乒乓球"现象:
s → u (容量100)
u → v (容量1)
v → t (容量100)
HLPP 是 push-relabel 算法的一种实现。优先处理海拔最高 的溢出节点,如同治水时先疏通最上游的堰塞湖,防止洪水回溯。实践上,可以通用的使用优先队列,但考虑到高度的值域和算法性质,更常使用桶(Bucket) 结构按高度分层管理节点,维护一个"当前最高高度"始终指向当前最高非空桶,每一次我们都从这个大桶里取出节点,尝试 push (若能推出去)或 relabel(若推不出去)。优化后时间复杂度降至 \(O(n^2\sqrt{m})\) 。
GAP 优化
Gap 现象 :在算法运行过程中,如果存在某个高度值 \(k\),使得没有任何节点高度为 \(k\),但存在高度 \(>k\) 的节点,则称出现一个 Gap。这相当于地形出现断层,高处的水永远无法流到断层以下的区域。
若在高度 \(k\) 处出现 Gap,则所有高度 \(>k\) 的节点到汇点 \(t\) 在残量网络中不可达 。这些节点的超额流实际上被困在"孤岛"中,必须通过主动排水将其送回源点。
我们维护一个高度计数数组,当某个高度层计数降为0时,触发 Gap 检测,将所有高度 >k 的节点标记为"死亡"(高度设为 \(n+1\))
4。
假设节点高度分布为 \([5,5,4,3,3,1]\),当高度2的节点全部消失时:
- 检测到 Gap 出现在 \(k=2\)
- 高度为 5、5、4 的节点被判定为"孤岛"
- 立即将这些节点高度设为 \(n+1\),其超额流将快速回流到源点
所有高度大于 \(n\) 的节点都意味着他们多出来的超额流量只能送回源点,假如我们只求最大流数值,不求解达成最大流时每条边的流量,那么我们可以直接不处理他们而是直接丢弃出队,他们的入大于出不会影响最终答案。
设定初始高度
上文提到初始时,源点汇点以外的其他点初始高度为 0。实际上可以把他们的初始高度设置为到汇点距离,这不会影响算法正确性
HLPP 不是简单的启发式优化,而是通过高度拓扑排序 和断层检测,严格降低了复杂度上限。这种将物理直觉与离散数学结合的思想,正是算法设计的精髓所在。
cpp
class Graph {
struct Edge {
int v, res, next;
Edge(int v, int res, int next) : v(v), res(res), next(next) {}
};
vector<int> head;
vector<Edge> edges;
int n, m, s, t;
public:
void addEdge(int u, int v, int cap) {
// 同时添加两侧边,便于残量网络的构建
edges.emplace_back(v, cap, head[u]);
head[u] = edges.size() - 1;
edges.emplace_back(u, 0, head[v]);
head[v] = edges.size() - 1;
}
Graph(int n, int m, int s, int t) : n(n), m(m), s(s), t(t), head(n+1, -1) {
edges.reserve(m * 2);
}
long long hlpp() {
vector<long long> excess(n+1, 0);
vector<int> dep(n+1, n), gap(2*n+1, 0), curHead(head);
vector<vector<int>> buckets(2*n+1);
int max_h = 0;
queue<int> q;
q.push(t);
dep[t] = 0;
while (!q.empty()) {
int u = q.front();
q.pop();
gap[dep[u]]++;
for (int i = head[u]; i != -1; i = edges[i].next) {
int v = edges[i].v;
if (dep[v] == n && edges[i^1].res > 0) {
dep[v] = dep[u] + 1;
q.push(v);
}
}
}
if (dep[s] == n) return 0; // s is not reachable from t
dep[s] = n; // in hlpp, s & t are specially handled
// push from source
for (int i = head[s]; i != -1; i = edges[i].next) {
int v = edges[i].v;
if (edges[i].res > 0) {
auto flow(edges[i].res);
edges[i].res -= flow;
edges[i^1].res += flow;
excess[s] -= flow;
excess[v] += flow;
}
if (v != s && v != t && excess[v] > 0) {
buckets[dep[v]].push_back(v);
max_h = max(max_h, dep[v]);
}
}
// get highest, push & relabel
while (max_h >= 0) {
if (buckets[max_h].empty()) { max_h--; continue; }
int u = buckets[max_h].back(); buckets[max_h].pop_back();
if (excess[u] == 0 || dep[u] != max_h) continue;
while (excess[u] > 0) {
if (dep[u] >= n) {
excess[u] = 0;
break;
}
if (curHead[u] == -1) { // relabel
int min_h = 2 * n;
for (int i = head[u]; i != -1; i = edges[i].next) {
if (edges[i].res > 0) min_h = min(min_h, dep[edges[i].v]);
}
int old_h = dep[u];
int new_h = min_h + 1;
gap[old_h]--;
dep[u] = new_h;
gap[new_h]++;
if (gap[old_h] == 0 && old_h < n) {
for (int v = 0; v <= n; v++) {
if (v == s || v == t) continue;
if (dep[v] > old_h && dep[v] < n) {
gap[dep[v]]--;
dep[v] = n + 1;
gap[n+1]++;
if (excess[v] > 0) buckets[dep[v]].push_back(v);
}
}
}
max_h = max(max_h, dep[u]);
curHead[u] = head[u];
} else {
int i = curHead[u];
if (edges[i].res > 0 && dep[u] == dep[edges[i].v] + 1) {
auto flow(min(excess[u], (long long)edges[i].res));
edges[i].res -= flow;
edges[i^1].res += flow;
excess[u] -= flow;
excess[edges[i].v] += flow;
if (edges[i].v != s && edges[i].v != t && excess[edges[i].v] > 0) {
buckets[dep[edges[i].v]].push_back(edges[i].v);
max_h = max(max_h, dep[edges[i].v]);
}
} else {
curHead[u] = edges[i].next;
}
}
}
} return excess[t];
}
};
拓展知识
运算量和复杂度
- 重贴标签次数 :每个节点最多被重贴标签 \(2n\) 次(高度从 0 增长到 \(2n-1\))
- 饱和推送次数 :每条边最多触发 \(O(n)\) 次饱和推送(每次推送至少抬高起点高度)
- 复杂度上界 :\(O(n^2\sqrt{m})\)(通过最高标号优先策略压缩,其证明较困难)这个上界相对较紧。
与 Dinic 算法的对比
Dinic 算法的特点:
- 分层网络:通过 BFS 构建分层图,强制流量按层递进
- 优势场景:稀疏图、边容量较小的情况
- 弱点:稠密图中频繁重建分层网络代价高昂
HLPP 的优势:
- 免维护分层结构:高度函数动态调整,避免重复 BFS
- 异步并行潜力:节点操作相互独立,适合 GPU 加速
- 稠密图霸主:在完全图、网格图等场景下速度可提升 10 倍以上
思考题 :若将 HLPP 的高度差约束从 1 改为 \(k\),会对算法行为产生什么影响?(提示:考虑 \(k=0\) 和 \(k=2\) 的极端情况)