上期回顾: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 不是简单的启发式优化,而是通过高度拓扑排序断层检测,严格降低了复杂度上限。这种将物理直觉与离散数学结合的思想,正是算法设计的精髓所在。

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\) 的极端情况)

水往低处流:最大流的最高标号预留推进算法(HLPP)的更多相关文章

  1. 《老子》是帝王术,提倡复古,崇拜圣人,主张愚民,甘居下流,不争上游:4星|李零《人往低处走:<老子>天下第一》

    ​​“ 俗话说,“人往高处走,水往低处流”.<老子>正好相反,它强调的是作“天下谷”.“天下溪”.“天下之牝”,甘居下流,不争上游(第28和第61章).司马谈说,道家的特点是“去健羡,绌聪 ...

  2. IO流(字节流,字符流,缓冲流)

    一:IO流的分类(组织架构) 根据处理数据类型的不同分为:字节流和字符流 根据数据流向不同分为:输入流和输出流   这么庞大的体系里面,常用的就那么几个,我们把它们抽取出来,如下图:   二:字符字节 ...

  3. [源码]ObjectIOStream 对象流 ByteArrayIOStream 数组流 内存流 ZipOutputStream 压缩流

    1.对象流 import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File ...

  4. IO流03_流的分类和概述

    [概述] Java的IO流是实现输入/输出的基础,它可以方便的实现数据的输入/输出操作. Java中把不同的输入/输出源(键盘.文件.网络连接)抽象表述为"流"(Stream). ...

  5. Java基础知识强化之IO流笔记41:字符流缓冲流之复制文本文件案例02(使用 [ newLine() / readLine() ] )(重要)

    1. 使用字符流缓冲流的特殊功能 [ newLine() / readLine() ] 需求:把当前项目目录下的a.txt内容复制到当前项目目录下的b.txt中  数据源: a.txt -- 读取数据 ...

  6. Java基础知识强化之IO流笔记39:字符流缓冲流之复制文本文件案例01

    1. 字符流缓冲流之复制文本文件案例 需求:把当前项目目录下的a.txt内容复制到当前项目目录下的b.txt中 数据源: a.txt -- 读取数据 -- 字符转换流 -- InputStreamRe ...

  7. Java基础知识强化之IO流笔记38:字符流缓冲流之BufferedWriter / BufferedReader使用

    1. 字符流缓冲流: 字符流为了高效读写,也提供了对应的字符缓冲流. BufferedWriter:字符缓冲输出流 BufferedReader:字符缓冲输入流 2. BufferedWriter使用 ...

  8. JAVA之旅(二十七)——字节流的缓冲区,拷贝mp3,自定义字节流缓冲区,读取键盘录入,转换流InputStreamReader,写入转换流,流操作的规律

    JAVA之旅(二十七)--字节流的缓冲区,拷贝mp3,自定义字节流缓冲区,读取键盘录入,转换流InputStreamReader,写入转换流,流操作的规律 我们继续来聊聊I/O 一.字节流的缓冲区 这 ...

  9. 【Java基础】【21IO(字符流)&字符流其他内容&递归】

    21.01_IO流(字符流FileReader) 1.字符流是什么 字符流是可以直接读写字符的IO流 字符流读取字符, 就要先读取到字节数据, 然后转为字符. 如果要写出字符, 需要把字符转为字节再写 ...

  10. -1-4 java io java流 常用流 分类 File类 文件 字节流 字符流 缓冲流 内存操作流 合并序列流

      File类 •文件和目录路径名的抽象表示形式 构造方法 •public File(String pathname) •public File(String parent,Stringchild) ...

随机推荐

  1. C++顺序结构(3)、数据类型_____教学

    一.设置域宽setw() 输出的内容所占的总宽度成为域宽,有些高级语言中称为场宽. 使用setw()前,必须包含头文件iomanip,即#include<iomanip> 头文件ioman ...

  2. 大文件传输与断点续传实现(极简Demo: React+Node.js)

    大文件传输与断点续传实现(极简Demo:React+Node.js) 简述 使用React前端和Node.js后端实现大文件传输和断点续传的功能.通过分片上传技术,可以有效地解决网络不稳定带来的传输中 ...

  3. 【Python】【Pandas】使用concat添加行

    添加行 t = pd.DataFrame(columns=["姓名","平均分"]) t = t.append({"姓名":"小红 ...

  4. sed 删除 替换 文件内容

      sed添加一行内容 使用sed命令添加一行内容有多种实现方法,下面是几种不同的实现方法: 方法一:使用sed命令在指定行前添加一行内容     sed '2i This is a new line ...

  5. 【转载】Apache Doris、DorisDB傻傻分不清。。。

    https://www.sohu.com/a/488816742_827544   相信这两天很多社区小伙伴都看到 StarRocks 所谓"开源"的动态了,开源用户群里有很多小伙 ...

  6. Qt/C++最新地图组件发布/历时半年重构/同时支持各种地图内核/包括百度高德腾讯天地图

    一.前言说明 最近花了半年时间,专门重构了整个地图组件,之前写的比较粗糙,有点为了完成功能而做的,没有考虑太多拓展性和易用性.这套地图自检这几年大量的实际项目和用户使用下来,反馈了不少很好的建议和意见 ...

  7. Qt编写地图综合应用55-海量点位标注

    一.前言 海量点位标注的出现,是为了解决普通设备点超过几百个性能极速降低的问题,普通的marker标注由于采用的是对象的形式存在于地图中,数量越多,占用内存特别大,超过1000个点性能极其糟糕,哪怕是 ...

  8. Qt编写可视化大屏电子看板系统16-标准柱状图

    一.前言 标准柱状图是大屏系统中最常用的一种展示数据效果图,提供不同的柱子显示数据值,在QCustomPlot的基础上拓展了顶部显示对应的值,不同的柱子不同的颜色,同时还可以调用内置的触发报警颜色的机 ...

  9. 阿里IM技术分享(四):闲鱼亿级IM消息系统的可靠投递优化实践

    本文由阿里闲鱼技术团队景松分享,原题"到达率99.9%:闲鱼消息在高速上换引擎(集大成)",有修订和改动,感谢作者的分享. 1.引言 在2020年年初的时候接手了闲鱼的IM即时消息 ...

  10. 企业微信的IM架构设计揭秘:消息模型、万人群、已读回执、消息撤回等

    本文作者潘唐磊,腾讯WXG(微信事业群)开发工程师,毕业于中山大学.内容有修订. 1.内容概述 本文总结了企业微信的IM消息系统架构设计,阐述了企业业务给IM架构设计带来的技术难点和挑战,以及技术方案 ...