上期回顾:https://www.cnblogs.com/ofnoname/p/18678895

之前我们已经介绍了最大流问题的基本定义、最大流最小割定理、增广路径与残量网络的构建方法,以及如何利用这些概念实现 EK 算法。EK 算法通过每次使用 BFS 寻找从源点到汇点的最短增广路径,保证了算法在有限步内终止,但是频繁的路径搜索会导致效率不高。

在 EK 的基础上,我们将在接下来的文章中重点探讨如何利用分层思想、多路增广以及当前弧优化等策略来克服 EK 算法的不足,使用 Dinic 和 ISAP 算法进一步提高最大流问题求解的效率。

将 EK 改进为 Dinic

EK 的最显而易见问题就是反复 BFS 的浪费,BFS 总是按照层次来选择路径,而多次时间上相邻的 BFS 很可能遍历过程相同,造成了浪费。既然如此,我们可以按照节点到源点距离预先进行分层,然后在选择路径时,总是按照层号递增的方向选择。

Dinic 算法是在 EK 算法的基础上进行改进的一种最大流算法。它通过预先构造分层图,并在同一分层图上多次寻找增广路径,从而大幅提升了算法的效率。下面我们从分层图的构造、阻塞流的寻找以及多次增广这几个方面来详细介绍 Dinic 算法。

首先,Dinic 算法的每一轮使用 $ \text{BFS} $ 从源点 $ s $ 开始,对整个网络进行分层。每个节点都被赋予一个层数,表示它距离 $ s $ 的最短距离。分层的过程中,仅保留那些从一个层次直接通向下一个层次的边,构成所谓的分层图。这样一来,每条边都满足如下条件:

\[\text{如果边 } (u,v) \text{ 存在,则 } \text{level}(v) = \text{level}(u) + 1 \,.
\]

在构造完分层图后,算法进入增广阶段。这一阶段的目标和 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 算法的最坏情况时间复杂度可以认为是

\[O(n^2 \, m) \,,
\]

其中 \(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 更为高效。

最大流的 Dinic 算法和 ISAP 算法的更多相关文章

  1. 使用Apriori算法和FP-growth算法进行关联分析

    系列文章:<机器学习实战>学习笔记 最近看了<机器学习实战>中的第11章(使用Apriori算法进行关联分析)和第12章(使用FP-growth算法来高效发现频繁项集).正如章 ...

  2. 最小生成树---Prim算法和Kruskal算法

    Prim算法 1.概览 普里姆算法(Prim算法),图论中的一种算法,可在加权连通图里搜索最小生成树.意即由此算法搜索到的边子集所构成的树中,不但包括了连通图里的所有顶点(英语:Vertex (gra ...

  3. mahout中kmeans算法和Canopy算法实现原理

    本文讲一下mahout中kmeans算法和Canopy算法实现原理. 一. Kmeans是一个很经典的聚类算法,我想大家都非常熟悉.虽然算法较为简单,在实际应用中却可以有不错的效果:其算法原理也决定了 ...

  4. 转载:最小生成树-Prim算法和Kruskal算法

    本文摘自:http://www.cnblogs.com/biyeymyhjob/archive/2012/07/30/2615542.html 最小生成树-Prim算法和Kruskal算法 Prim算 ...

  5. 0-1背包的动态规划算法,部分背包的贪心算法和DP算法------算法导论

    一.问题描述 0-1背包问题,部分背包问题.分别实现0-1背包的DP算法,部分背包的贪心算法和DP算法. 二.算法原理 (1)0-1背包的DP算法 0-1背包问题:有n件物品和一个容量为W的背包.第i ...

  6. 用Spark学习FP Tree算法和PrefixSpan算法

    在FP Tree算法原理总结和PrefixSpan算法原理总结中,我们对FP Tree和PrefixSpan这两种关联算法的原理做了总结,这里就从实践的角度介绍如何使用这两个算法.由于scikit-l ...

  7. 字符串查找算法总结(暴力匹配、KMP 算法、Boyer-Moore 算法和 Sunday 算法)

    字符串匹配是字符串的一种基本操作:给定一个长度为 M 的文本和一个长度为 N 的模式串,在文本中找到一个和该模式相符的子字符串,并返回该字字符串在文本中的位置. KMP 算法,全称是 Knuth-Mo ...

  8. 最小生成树之Prim算法和Kruskal算法

    最小生成树算法 一个连通图可能有多棵生成树,而最小生成树是一副连通加权无向图中一颗权值最小的生成树,它可以根据Prim算法和Kruskal算法得出,这两个算法分别从点和边的角度来解决. Prim算法 ...

  9. java实现最小生成树的prim算法和kruskal算法

    在边赋权图中,权值总和最小的生成树称为最小生成树.构造最小生成树有两种算法,分别是prim算法和kruskal算法.在边赋权图中,如下图所示: 在上述赋权图中,可以看到图的顶点编号和顶点之间邻接边的权 ...

  10. Algorithm --> Kruskal算法和Prim算法

    最小生成树之Kruskal算法和Prim算法 Kruskal多用于稀疏图,prim多用于稠密图. 根据图的深度优先遍历和广度优先遍历,可以用最少的边连接所有的顶点,而且不会形成回路.这种连接所有顶点并 ...

随机推荐

  1. json编码格式化美化

    有时候你想存储一个json到文件中,然后让别人调用或者读取或者作为临时存储,诸如此类. 但是php json_encode后数据是压缩的没有格式化,导致读起来有点费劲. 所以你可以这样(php 5.4 ...

  2. Vulhub Apache Httpd漏洞复现

    目录 前言 多后缀解析漏洞 换行解析漏洞(CVE-2017-15715) 2.4.49 路径穿越漏洞(CVE-2021-41773) 2.4.50 路径穿越漏洞(CVE-2021-42013) SSR ...

  3. 成为Java GC专家(4) — Apache的MaxClients参数详解及其在Tomcat执行FullGC时的影响

    这是"成为Java GC专家系列文章"的第四篇. 在第一篇文章 成为JavaGC专家Part I - 深入浅出Java垃圾回收机制 中我们学习了不同GC算法的执行过程,GC如何工作 ...

  4. element table 合并同类项并输出后台返回数据

    table的样式如下 后台返回的数据格式是按照横着来的,因为表头是经过处理的,而且是作为独立出来的数据返给前端的,所以当我们进行数据填充的时候需要用到后台返回的完整的数据,要想一一对应的话,我们需要进 ...

  5. Blazor 组件库 BootstrapBlazor 中Circle组件介绍

    组件介绍 Circle进度环组件,是一个图表类组件.一般有两种用途: 显示某项任务进度的百分比. 统计某些指标的占比. 它的样子如下: 它的代码如下: <Circle Width="2 ...

  6. ZCMU-1053

    比较简单记录一下主要感觉它这个题目没说清楚,题目要求:先有n,接着给出长度为n的标准组,然后给出猜测组,输出的两个数一个是有多少个是相对应的既相同坐标其数值也相同,后一个是两个都有但是位置不同(不含已 ...

  7. Java根据前端返回的字段名进行查询数据

    在Java后端开发中,我们经常需要根据前端传递的参数(如字段名)来动态查询数据库中的数据.这种需求通常出现在需要实现通用查询功能或者复杂查询接口的场景中.为了实现这个功能,我们需要结合Java的反射机 ...

  8. 高中生入门学习c/c++指导

    一.c与c++关系 参考图示: 可见,c与c++的基本部分是相同的,会有一些小区别,不妨一起学.DEV-C++能支持C++和C语言编程 二.学习资料网站介绍 1.C语言初阶--手把手教零基础/新手入门 ...

  9. [WPF UI] 为 AvalonDock 制作一套 Fluent UI 主题

    AvalonDock 是我这些天在为自己项目做技术选型时发现的一个很好的开源项目,它是一个用于 WPF 的布局控件库,可以帮助我们实现类似 Visual Studio 的布局效果.因为它自带的一些样式 ...

  10. Java中MessageFormat的坑

    目录 Java中MessageFormat的坑 问题现象 问题排查 如何解决 Java中MessageFormat的坑 问题现象 某个业务功能需要通过SSH协议执行命令查询一些数据,而某次查询居然没有 ...