水往低处流:最大流的最高标号预留推进算法(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\) 满足:

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