水往低处流:最大流的最高标号预留推进算法(HLPP)
上期回顾: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\) 满足:
\]
则可以将 \(\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\) 有超额流,但所有邻居的海拔都不低于它(无法形成瀑布),那么他多出来的水就放不出去了,此时必须“抬高地形”:
\]
将高度修改为周围节点中的最小高度加一这相当于在 \(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)的更多相关文章
- 《老子》是帝王术,提倡复古,崇拜圣人,主张愚民,甘居下流,不争上游:4星|李零《人往低处走:<老子>天下第一》
“ 俗话说,“人往高处走,水往低处流”.<老子>正好相反,它强调的是作“天下谷”.“天下溪”.“天下之牝”,甘居下流,不争上游(第28和第61章).司马谈说,道家的特点是“去健羡,绌聪 ...
- IO流(字节流,字符流,缓冲流)
一:IO流的分类(组织架构) 根据处理数据类型的不同分为:字节流和字符流 根据数据流向不同分为:输入流和输出流 这么庞大的体系里面,常用的就那么几个,我们把它们抽取出来,如下图: 二:字符字节 ...
- [源码]ObjectIOStream 对象流 ByteArrayIOStream 数组流 内存流 ZipOutputStream 压缩流
1.对象流 import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File ...
- IO流03_流的分类和概述
[概述] Java的IO流是实现输入/输出的基础,它可以方便的实现数据的输入/输出操作. Java中把不同的输入/输出源(键盘.文件.网络连接)抽象表述为"流"(Stream). ...
- Java基础知识强化之IO流笔记41:字符流缓冲流之复制文本文件案例02(使用 [ newLine() / readLine() ] )(重要)
1. 使用字符流缓冲流的特殊功能 [ newLine() / readLine() ] 需求:把当前项目目录下的a.txt内容复制到当前项目目录下的b.txt中 数据源: a.txt -- 读取数据 ...
- Java基础知识强化之IO流笔记39:字符流缓冲流之复制文本文件案例01
1. 字符流缓冲流之复制文本文件案例 需求:把当前项目目录下的a.txt内容复制到当前项目目录下的b.txt中 数据源: a.txt -- 读取数据 -- 字符转换流 -- InputStreamRe ...
- Java基础知识强化之IO流笔记38:字符流缓冲流之BufferedWriter / BufferedReader使用
1. 字符流缓冲流: 字符流为了高效读写,也提供了对应的字符缓冲流. BufferedWriter:字符缓冲输出流 BufferedReader:字符缓冲输入流 2. BufferedWriter使用 ...
- JAVA之旅(二十七)——字节流的缓冲区,拷贝mp3,自定义字节流缓冲区,读取键盘录入,转换流InputStreamReader,写入转换流,流操作的规律
JAVA之旅(二十七)--字节流的缓冲区,拷贝mp3,自定义字节流缓冲区,读取键盘录入,转换流InputStreamReader,写入转换流,流操作的规律 我们继续来聊聊I/O 一.字节流的缓冲区 这 ...
- 【Java基础】【21IO(字符流)&字符流其他内容&递归】
21.01_IO流(字符流FileReader) 1.字符流是什么 字符流是可以直接读写字符的IO流 字符流读取字符, 就要先读取到字节数据, 然后转为字符. 如果要写出字符, 需要把字符转为字节再写 ...
- -1-4 java io java流 常用流 分类 File类 文件 字节流 字符流 缓冲流 内存操作流 合并序列流
File类 •文件和目录路径名的抽象表示形式 构造方法 •public File(String pathname) •public File(String parent,Stringchild) ...
随机推荐
- 4-CSRF漏洞渗透与防御
1.什么是CSRF漏洞 Cross-Site Request Forgery 跨站请求伪造 从一个第三方的网站,利用其他网站生效的cookie,直接请求服务器的某一个接口,导致攻击发生! 2.CSRF ...
- Tailwind CSS样式优先级控制
前情 Tailwind CSS 是一个原子类 CSS 框架,它将基础的 CSS 全部拆分为原子级别,能达到最小化项目CSS.它的工作原理是扫描所有 HTML 文件.JavaScript 组件以及任何模 ...
- OS之《CPU调度》
CPU调度层次 高级调度:是作业调度.将外村的作业加载到内存里,分配对应的资源,然后加入就绪队列 低级调度:将就绪队列中的进程调度到CPU执行 中级调度:为了提高内存的利用率和系统的吞吐量,将暂时不能 ...
- 鸿蒙UI开发快速入门 —— part11: 鸿蒙计算器开发实践
1.前言 经过我们前面10章的学习,我们基本上可以开发出一个简单的APP了,为了巩固学习的内容,我们先开发一个计算器APP来连个手(文末有源代码),界面如下: 包含基本的计算器运算功能 支持一键清空, ...
- cv2, pil.image, plt.image 读图的差异
人是习惯性动物,当我们第一次用opencv时,肯定会觉得opencv的imread()方式很奇怪,做图像出来天天说图像是RGB图RGB图,可opencv读出来的图,却是BGR的顺序.是不是很奇怪,还不 ...
- 论文解读《The Philosopher’s Stone: Trojaning Plugins of Large Language Models》
发表时间:2025 期刊会议:Network and Distributed System Security (NDSS) Symposium 论文单位:Shanghai Jiao Tong Univ ...
- idea配置gradle国内镜像源
项目文件中找到build.gradle文件,修改其中的buildscript和allprojects地址: buildscript { repositories { maven{ url 'http: ...
- Dart代码混淆
Dart代码混淆 代码混淆是修改应用程序的二进制文件以使其更难被人类理解的过程.混淆会在编译后的 Dart 代码中隐藏函数和类名称,将每个符号替换为另一个符号. Flutter 的代码混淆仅适用于re ...
- 【信号与系统】求使系统稳定的常数K的范围
- Anaconda功能、优点、安装步骤(安装视频)
目录 介绍 功能(包和环境的管理器) 优点(省时省心) 下载地址 安装教程 要点 conda 的常见命令 查询完整帮助文件 管理conda和anaconda 管理环境 包管理 其他 介绍 Anac ...