上期回顾: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. Codeforces Round 901 (Div

    C. Jellyfish and Green Apple 题解 显然\(n \% m =0\),答案一定为\(0\) 如果\(n > m\),我们显然可以将\(n / m\)的苹果分给每个人,然 ...

  2. OSG开发笔记(三十五): OsgUtil::Optimizer:优化场景策略,提升显示性能和渲染效率

    前言   在OSG中,osgUtil::Optimizer是一个非常重要的工具类,它提供了一系列优化场景图的方法,以提高渲染性能和效率.   Demo      在笔者的pc上,优化前优化后渲染交互没 ...

  3. windows下python批量更新软件包

    前言 相信很多小伙伴都遇到过python有些软件包版本过低导致无法安装一些模块的问题,刚好我前两天也遇到了,这里写个文章记录一下 一.更新pip版本 打开命令控制面板,输入: python -m pi ...

  4. postgres 在centos 安装

    执行如下命令安装POSTGRES sudo yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86 ...

  5. 使用 spring stream 发送消息

    为什么使用spring stream ? spring stream 是用来做消息队列发送消息使用的.他隔离了各种消息队列的区别,使用统一的编程模型来发送消息. 目前支持: rabbitmq kafk ...

  6. 编译器-FIRST集合

      语法分析器的两个重要函数 FIRST和FOLLOW FIRST的定义 FIRST(α),可从α推导得到的串的首符号的集合 1.如果X是一个终结符,那么FIRST(X) = X 2.如果X是一个非终 ...

  7. JAVA8的computeIfAbsent使用方法

    基础说明 computeIfAbsent 是 Java 8 引入的 Map 接口中的一个默认方法.它允许你以原子操作的方式在给定键不存在时计算其值,并将其添加到映射中.如果该键已经存在,则返回已存在的 ...

  8. 各种各样的 Host Builder

    各种各样的 Host Buider If you're building for the web or writing a distributed application, you might nee ...

  9. 中电金信:GienTech动态|一波好消息→中标!多领域“开花”

  10. 强化学习算法中的log_det_jacobian

    相关: https://colab.research.google.com/github/google/brax/blob/main/notebooks/training_torch.ipynb 之前 ...