上期回顾:
之前我们已经介绍了最大流问题的基本定义、最大流最小割定理、增广路径与残量网络的构建方法,以及如何利用这些概念实现 EK 算法。EK 算法通过每次使用 BFS 寻找从源点到汇点的最短增广路径,保证了算法在有限步内终止,但是频繁的路径搜索会导致效率不高。
在 EK 的基础上,我们将在接下来的文章中重点探讨如何利用分层思想、多路增广以及当前弧优化等策略来克服 EK 算法的不足,使用 Dinic 和 ISAP 算法进一步提高最大流问题求解的效率。
将 EK 改进为 Dinic
EK 的最显而易见问题就是反复 BFS 的浪费,BFS 总是按照层次来选择路径,而多次时间上相邻的 BFS 很可能遍历过程相同,造成了浪费。既然如此,我们可以按照节点到源点距离预先进行分层,然后在选择路径时,总是按照层号递增的方向选择。
Dinic 算法是在 EK 算法的基础上进行改进的一种最大流算法。它通过预先构造分层图,并在同一分层图上多次寻找增广路径,从而大幅提升了算法的效率。下面我们从分层图的构造、阻塞流的寻找以及多次增广这几个方面来详细介绍 Dinic 算法。
首先,Dinic 算法的每一轮使用 $ \text{BFS} $ 从源点 $ s $ 开始,对整个网络进行分层。每个节点都被赋予一个层数,表示它距离 $ s $ 的最短距离。分层的过程中,仅保留那些从一个层次直接通向下一个层次的边,构成所谓的分层图。这样一来,每条边都满足如下条件:
在构造完分层图后,算法进入增广阶段。这一阶段的目标和 EK 一样,是不断寻找所谓的阻塞流(即增广流),也就是在当前分层图中找出所有可能的增广路径,每次寻找都使用 level 数组,更加高效。直至无法再找到从 $ s $ 到 $ t $ 的增广路径为止,此时重新进行分层,进入下一轮 BFS。
当我们已经分层完毕要寻找阻塞流时,为了优化常数,可以使用通常使用深度优先搜索($ \text{DFS} $)在分层图中寻找多条增广路径,每次递归时尽可能沿着分层图中合法的边进行搜索,直到到达汇点 $ t $ 或遇到无法继续前进的情况为止。由于使用了同一套 level,每一轮找到了多条路径是不会互相抵消的。
在一次分层图上找到阻塞流后,算法就会更新残量网络,并重新构造新的分层图。这样,每一次分层和阻塞流求解过程都能够在较大程度上利用分层结构的信息,减少无效搜索,并且保证每次分层后网络中至少有一部分边被“饱和”,从而加速了整体的收敛过程。
当前弧优化
在 $ \text{DFS} $ 阶段,要使用当前弧优化保证正确的复杂度 。其作用是用于减少在 DFS 过程中重复探索已经无效的边。为每个节点记录当前正在尝试的边的位置,避免在同一次 DFS 搜索中重复遍历那些已经证明无法贡献增广流的边,从而提高整体搜索效率。
在具体实现中,每个节点会维护一个“当前弧”,指向该节点的出边邻接表中的某个位置。当 DFS 从某个节点开始时,会从当前弧指针所指向的位置开始尝试后续的边。若某条边经过尝试后证明无法带来有效的增广(例如,该边的剩余容量为 0,或者经过该边之后无法到达汇点 $ t $),则当前弧指针向后移动,以便在后续 DFS 调用中不再重复尝试这条边。这样,在一次 DFS 搜索过程中,每个节点只会考察其邻接边一次,显著降低了不必要的递归和搜索开销。
当前弧优化只是记录了在同一次 DFS 搜索中已经尝试过且无效的边。在 DFS 过程中,如果一条边被证明无法贡献增广流,那么在相同的分层图中,由于网络结构不变,该边未来也不可能转变为一条有效边。换句话说,若某个节点的边在当前 DFS 中尝试后失败,那么在后续的递归调用中再次尝试该边也不会带来不同的结果。因此,将当前弧指针后移不会遗漏任何可能的增广路径。
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;
int dfs_dinic(int u, int flow, const vector<int> &dep, vector<int> &curHead) {
if (u == t) return flow;
int rest = flow;
// 应用当前弧优化:当再次来到此节点时,不需要遍历已经过的边
for (int &i = curHead[u]; i != -1 && rest; i = edges[i].next) {
int v = edges[i].v;
if (dep[v] == dep[u] + 1 && edges[i].res > 0) {
int d = dfs_dinic(v, min(rest, edges[i].res), dep, curHead);
edges[i].res -= d;
edges[i^1].res += d;
rest -= d;
}
}
return flow - rest;
}
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 dinic() {
long long res = 0;
vector<int> dep(n+1), head_copy(n+1);
for (;;) {
fill(dep.begin(), dep.end(), 0);
queue<int> q;
q.push(s);
dep[s] = 1;
while (!q.empty()) {
int u = q.front();
q.pop();
for (int i = head[u]; i != -1; i = edges[i].next) {
int v = edges[i].v;
if (!dep[v] && edges[i].res > 0) {
dep[v] = dep[u] + 1;
q.push(v);
}
}
}
if (dep[t] == 0) break; // 已经无法到达 t
copy(head.begin(), head.end(), head_copy.begin());
res += dfs_dinic(s, INT_MAX, dep, head_copy);
} return res;
}
};
Dinic 的效率
对于 Dinic 算法的时间复杂度,不同情况下的分析也有所不同。大致而言,在一般的有向网络中,Dinic 算法的最坏情况时间复杂度可以认为是
其中 \(n\) 是网络中的节点数,\(m\) 是边数。严格的上界证明较为复杂,大致来说,这个上界主要来源于如下两个方面:
- 每一次构造分层图的过程通常需要 \(O(m)\) 的时间;
- 在最坏情况下,可能需要执行 \(O(n)\) 次分层及对应的 DFS 寻找阻塞流,每次 DFS 在整个网络中可能会遍历多条边,至多花费 \(O(nm)\) 的时间,从而导致整体复杂度达到 \(O(n^2 \, m)\)。
需要注意的是,上述分析给出的只是最坏情况的理论上界,是非常宽松的。在实际应用中,Dinic 算法通常表现得非常优秀,尤其是在随机图或一般实际问题中,由于网络结构的稀疏性和增广路径分布较为均匀,使得分层和增广过程远低于最坏情况所描述的复杂度。事实上,仅有一些特意构造的数据或特殊的网络结构才能使得 Dinic 算法达到理论上的最差性能。
ISAP:只进行一次 BFS
Dinic 算法虽然简化了路径搜索,通过分层图和阻塞流策略提高了增广效率,但其每一轮也还需要重新构造分层图,ISAP 算法在设计上进一步优化了这一过程。
ISAP 算法采用了一种与 Dinic 不同的策略,它只在初始化时构造一次分层信息。惯例上,ISAP 是从汇点 \(t\) 而不是源点出发,反向进行层次划分(当然 Dinic 也可以从汇点划分,但惯例是源点),确定每个节点到汇点的距离或层次编号。这样一来,每个节点拥有一个初始层次值。
局部更新层次与当前弧
在实际增广过程中,当搜索过程中遇到断流(即当前路径无法继续前进)时,ISAP 不需要重新构造整个分层图,而是仅在断流处局部修改该节点的层次编号以及更新对应的当前弧指针。具体来说:
- 当某个节点无法通过其当前弧获得有效的增广流时,意味着分层需要变化。将节点层次更新为其所有可达点中层次最低值再加一,然后重新从源点开始运行。
- 同时,只更新与该节点相关的当前弧信息(重置到开头),确保后续的搜索不会再次尝试已被判定为无效的边。
这种局部修改策略大大减少了全局分层所需的时间开销,从而显著提高了算法整体的效率。
与 Dinic 算法在同一分层图上寻找阻塞流(即多路增广)不同,ISAP 算法采用与 EK 类似的一次寻找一条增广路径的策略,而不是在同一分层图上多路增广。这种设计保证了在每次断流后,算法能够迅速恢复有效的搜索状态,同时确保层次信息的正确性和路径选择的合理性。最终,ISAP 在很多实际场景中表现出了比 Dinic 更高的效率,尤其是在处理那些非最坏情况的大规模网络时,其局部更新机制能够充分利用初始分层信息,减少不必要的重复计算。其实现反而更加简洁。
GAP 优化
在 ISAP 算法中,GAP 优化是另一项关键的改进技术,用以进一步减少搜索空间,从而提高算法效率。该优化利用了已有层次编号的一个重要性质:如果在层次编号中出现“空档”(即某个层次上没有任何节点),那么所有层次高于该空档的节点都无法通过任何增广路径到达汇点 \(t\)。
具体来说,假设在执行过程中发现层次编号为 \(d\) 的节点数量为 0,即不存在任何节点满足距离汇点为 \(d\)。这时我们可以得出结论: 对于所有距离标签大于 \(d\) 的节点,它们必定无法到达汇点,因此这些节点上不存在任何有效的增广路径。这就意味着全图断流,答案已经得到了。利用这一信息,ISAP 算法便可以立即对这些节点进行剪枝处理。
class Graph {
//......
long long isap() {
long long res = 0;
vector<int> dep(n+1, n), gap(n+1, 0), curHead(head), path(n+1, -1);
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);
}
}
}
// 当源点深度标号小于 n 时,说明存在增广路
int u = s;
while (dep[s] < n) {
if (u == t) {
int aug = INT_MAX;
for (int v = t; v != s; v = edges[path[v]^1].v) {
aug = min(aug, edges[path[v]].res);
}
for (int v = t; v != s; v = edges[path[v]^1].v) {
edges[path[v]].res -= aug;
edges[path[v]^1].res += aug;
}
res += aug;
u = s;
continue;
}
bool advanced = false;
for (int &i = curHead[u]; i != -1; i = edges[i].next) {
int v = edges[i].v;
if (edges[i].res > 0 && dep[u] == dep[v] + 1) {
advanced = true;
path[v] = i;
u = v;
break;
}
}
if (!advanced) {
int minDep = n - 1;
for (int i = head[u]; i != -1; i = edges[i].next) {
int v = edges[i].v;
if (edges[i].res > 0) {
minDep = min(minDep, dep[v]);
}
}
if (--gap[dep[u]] == 0) break; // GAP 优化
dep[u] = minDep + 1; // 修改这一点的 level 和当前弧
gap[dep[u]]++;
curHead[u] = head[u];
if (u != s) u = edges[path[u]^1].v;
}
} return res;
}
};
ISAP 的效率
ISAP 算法在最大流问题中的效率非常优秀,尤其是在大多数实际应用中。尽管按照上面的理论,它的时间复杂度上界与 Dinic 算法都是 \(O(n^2 m)\),但 ISAP 算法通过巧妙的局部更新和 GAP 优化以及较小运行常数,在实际应用中,它往往远比 Dinic 更为高效。