图论算法,拿得起放得下

  • 可在作者的 github仓库 中获取本文和其他文章的 markdown 源文件及相关代码。

  • 欢迎评论或仓库 PR 指出文章错漏或与我讨论相关问题,我将长期维护所有文章。

  • 所有文章均用 Typora 完成写作,可使用 Typora 打开文章 md 文件,以获得最佳阅读体验。

⚠️ ⚠️ ⚠️ 本文巨巨巨长,正文近六万字 (不含题解),尽最大努力全面细致地讲解 bfs/dfs、拓扑排序、最短路、最小生成树、最大流,包括不仅限于给出每个专题内容的不同的常见算法 (如在最短路会讲解 DAG SSSP, Dijkstra, Bellman-Ford, SPFA, Floyd-Warshall 等算法),每一个算法的每一步操作的细节、算法正确性证明、复杂度证明以及完整的可应用的实现代码。全面、细致、系统、准确是本文的追求。

❗️ 【NEW】 ❗️


keywordskeywords:

图的基本概念 / 邻接矩阵 / 邻接表 / 链式向前星 / bfsbfs / dfsdfs / 无向图连通性 / 并查集 / 无向图判圈 / 有向图判圈 / KahnKahn 拓扑排序 / TarjanTarjan 拓扑排序 / 拓扑排序判圈 / 无权单源最短路径 / 带权单源最短路径 / DijkstraDijkstra / DAG 单源最短路径 / 负边图 / 有圈图 / BellmanFordBellman-Ford / SPFASPFA / 带权全源最短路径 / FloydWarshallFloyd-Warshall / 最小生成树 / PrimPrim / KruskalKruskal / 网络流 / 增广路径 / 饱和边 / 最大流最小割定理 / FordFulkersonFord-Fulkerson / EdmondsKarpEdmonds-Karp / DinicDinic

这是 yuki 最近一段时间学习图论的总结,发出来跟大家分享 亿点点 🤏心得。写这篇文章的契机是前段时间看 Weiss 那本 「数算Java描述」,看着看着发现到图论那章作者就只提供伪代码了 (之前的章节会提供完整的可运行代码),而且感觉 Weiss 在那一章里什么都提一点,但匆匆抛出几个结论就跑路,整章看下来比较难受。找了好多资料,包括力扣上几本相关的 leetbook,都不太满意。于是乎花了点时间探索整理了一下,写成本文,(我认为的) 优点有四。

  1. 面向小白,入门友好 。主要针对像我一样的初学者,或者粗浅地看过一些图论算法,但感觉不是特别踏实的朋友。这里的「不踏实」指的是在题解区看到一个问题有眼花缭乱的多种图论方法,但自己只知道常用的那种,有的压根没听过;也可以指的是虽然知道怎么做,但相关算法的正确性证明或复杂度证明,未曾确切地把握过。
  2. 循序渐进,反馈及时 。特别突出了各章节之间的联系与过渡。为了使读者能够在每一章学习中都有及时的正反馈,每介绍一个算法,我都会配套一道力扣上的原题 (力扣没有典型题目则采用其他平台的题目)。
  3. 编排得当,重点全面。 内容编排上作者也颇费苦心,例如「初探图搜索 (遍历)」一节,推敲几日,最终决定用「无向图连通性」问题引入 bfs/dfsbfs/dfs 。然后再以「无向图判圈」和「有向图判圈」问题,一面加强对 bfs/dfsbfs/dfs 写法变化的把握,一面也突出有向图和无向图中 bfs/dfsbfs/dfs 写法的差异。再如「最短路径」中,从无权单源到带权单源,包括带权单源中的 DAG 情形,再到带权全源,网罗各种情形下的最短路算法,朴素版和优化版都详细给出算法过程,复杂度分析,一些特殊情形的讨论,以及针对具体题目的实现代码等。
  4. 证明翔实,有根有据 。在「最短路」和「最大流」这两个章节中,对所有不易看出的结论均给出了详细的,较为严格的证明。包括且不限于对 Dijkstra、Prim、Bellman-Ford、SPFA、Floyd-Warshall 算法的正确性证明,对「最大流最小割定理」的证明,对 Edmonds-Karp、Dinic 算法复杂度的证明等。 部分证明着实耗尽了作者的最后一点智商,导致目前 智商欠费,大脑停机

本文所涉具已呈于「主要内容一览」中,如果朋友们觉得合适,不妨一看。希望朋友们能感受到 yuki 的 亿点用心


本文标题 意在表达作者的一种「希望」,即通过本文的学习,看似沉重的图论算法,也能 重重拿起,轻轻放下

然而不幸的是,作者能力水平十分有限,文章虽已审视几轮,所列代码也悉经验证,但根据之前几篇文章的经验,这一篇也会毫无例外地出现一些错误。因此本文既是心得分享,也是小白 yuki 再次向大家请教的一次机会,文中若有疏漏之处,请各位不吝赐教 🤝。

※ Dinic 的代码还需一些时间验证,暂不列出。「实战应用」中目前有十几题,会持续增加,整篇文章也会持续维护。

※ 部分算法正确性证明及复杂度证明以单篇文章发布过,但为了保持本文完整度,也一并呈现。

※ 内容可能较多,可根据目录选择性阅读。


yuki的其他文章如下,欢迎阅读指正!

如下所有文章同时也在我的 github 仓库 中维护。

文章 [发布时间] 字数/览/藏/赞 (~2022-10-20)
十大排序从入门到入赘 🔥🔥🔥 [20220516] 2.5万字/64.8k览/3.7k藏/937赞
二分查找从入门到入睡 🔥🔥🔥 [20220509] 2.3万字/38.4k览/2.1k藏/503赞
并查集从入门到出门 🔥🔥 [20220514] 1.2万字/17.9k览/1.0k藏/321赞
图论算法从入门到放下 🔥🔥 [20220617] 5.6万字/19.9k览/1.3k藏/365赞
树ADT系列 (预计13篇) 系列文章,连载中
3. 二叉查找树 [20220801] 5千字
4. AVL树 [20220817] 5千字
5. splay树 [20220817] 5千字
6. 红黑树从入门到看开 🔥🤯🤯🤯 [20220915] 3万字/5.3k览/269藏/72赞
10. 树状数组从入门到下车 🔥🤯 [20220722] 1.4万字/5.8k览/196藏/72赞
11. 线段树从入门到急停 🔥🤯 [20220726] 2.5万字/8.7k览/481藏/138赞
图论相关证明系列 系列文章
1. Dijkstra正确性证明 🤯 [20220531]
2. Prim正确性证明 🤯 [20220919]
3. Bellman-Ford及SPFA正确性证明 [20220602]
4. Floyd正确性证明 [20220602]
5. 最大流最小割定理证明 🤯🤯 [20220719]
6. Edmonds-Karp复杂度证明 🤯🤯 [20220515]
7. Dinic复杂度证明 🤯🤯 [20220531]

[2022-12-01]

  • 修改了若干代码实现,使其更易于阅读和理解。

[2022-09-28]

  • 进一步完善了 Prim 算法正确性证明过程。

概览

内容一览

graph-mind.png


图论算法复杂度一览

以下列出本文讲解的全部图论算法的时空复杂度。

分类 算法 时间复杂度
图遍历 bfs O(V+E)O(V+E)
dfs O(V+E)O(V+E)
拓扑排序 Kahn O(V+E)O(V+E)
Tarjan (TopoSort) O(V+E)O(V+E)
最短路 无权SSSP 朴素版 O(V2)O(V^2)
无权SSSP 队列版 O(V+E)O(V+E)
Dijkstra 朴素版 O(V2)O(V^2)
Dijkstra 优先队列版 O(ElogV)O(ElogV)
DAG SSSP O(V+E)O(V+E)
Bellman-Ford O(VE)O(VE)
SPFA (BFM) O(VE)O(VE)
Floyd-Warshall O(V3)O(V^3)
最小生成树 Prim 朴素版 O(V2)O(V^2)
Prim 优先队列版 O(ElogV)O(ElogV)
Kruskal O(ElogV)O(ElogV)
最大流 Ford-Fulkerson O(Ef)O(E*f)
Edmonds-Karp O(VE2)O(VE^2)
Dinic (Dinitz) O(V2E)O(V^2E)

VVEE 表示点集和边集,表格中涉及到「数量」的 VVEE,应为 V|V|E|E| (半角 “|” 会影响 markdown 表格的显示)。

※ 空间复杂度主要取决于建图方式,以「邻接矩阵」建图时为 O(V2)O(|V|^2),以「邻接表」建图时为 O(V+E)O(|V|+|E|) ,表中不再列出。


图的基本概念

概念 描述
顶点 & 边
vertex & edge
一对顶点 (u,v),u,vV(u, v), u, v ∈ VVV 为顶点集。所有边构成边集 EE
边的条数表为 EE ,顶点的个数表为 VV

graph
顶点集 VV 和边集 EE 构成图。
权 / 值
weight / cost
边的权重。
邻接
adjacent
顶点 uuvv 邻接当且仅当 (u,v)E(u, v) ∈ E
邻接矩阵
adjacent matrix
以矩阵表示图。对每条边 (u,v)(u, v) 置矩阵的 A[u,v]A[u, v]truetrue,无 (u,v)(u, v) 边则为 falsefalse
若边有权,则 A[u,v]A[u, v] 等于该值,以一个很大或很小的数表示该边不存在。
邻接表
adjacency list
对每一个顶点,以一个表存放其邻接顶点,共以 VV 个表表示图。
无向图
undirected graph
点无序的图,(u,v)(u, v)(v,u)(v, u) 为同一条边。
有向图
directed graph / digraph
点对有序的图,(u,v)(u, v)(v,u)(v, u) 为两条不同的边。

degree
对无向图顶点 vv 而言,其边的数量,也即其邻接顶点的数量。
入度
indegree
对有向图顶点 vv 而言,(u,v)(u, v) 边的数量
出度
outdegree
对有向图顶点 vv 而言,(v,u)(v, u) 边的数量
路径
path
为一顶点序列 v1,v2,v3,...,vNv_1, v_2, v_3,...,v_N 使得 (vi,vi+1)E,1<=i<=N1(v_i, v_{i+1}) ∈ E, 1<=i<=N-1
一个顶点到他自身也可以看成是一条路径,如果路径不包含边,则路径长为 0 。
路径长度
path length
无向图中指路径上的边数,有向图中指路径边权和。
简单路径
simple path
所有顶点都不同的路径。

cycle
满足 v1=vNv_1 = v_N 的长至少为 1 的路径。
环 / 自环
loop
一个顶点到它自身的边 (v,v)(v, v) ,此概念不常用。
通常环也被视作圈。中文语境下「环」与「圈」通常不做区分,具体需联系上下文理解。
有向无环(圈)图
directed acyclic graph, DAG
无(环)圈的有向图。
连通图
connected graph
从一顶点 ww 到另一顶点 vv 有路径相连称 wwvv 连通,
任意两顶点之间连通的图称为连通图。有向连通图两点之间的路径上的边同向。
连通分量
connected component
对于无向图而言,一个极大连通子图为一个连通分量。
所有连通分量构成互相没有相同顶点的子图集合。
基础图
underlying graph
有向图去掉边的方向后的图称为该有向图的基础图 (无向图)。
强连通
strongly connected
称有向连通图是强连通的。
强连通分量
strongly connected component (SCC)
对于有向图基础图的一个连通分量,
若其中的顶点两两连通,则称此连通分量为强连通分量。
弱连通
weakly connected
有向图不是强连通的,但其基础图是连通的,则称该有向图是弱连通的。
完全图
complete graph
每一对顶点间都有边相连的图。

在后续叙述中,我会假定你已熟悉该表所罗列概念。


图的表示

求解图上问题,首先要以合适的方式存储图。图的基本信息是顶点、边、边的方向以及边权,通过「邻接矩阵」或「邻接表」来存储图,可以很好地组织上述信息。本节简单介绍这两种存图方式,但暂不呈现相关代码,在后续章节解决实际问题时,我们会看到程序中是如何应用这两种方式存图以及提取图中的信息的。(本文大部分实现均采用「邻接表」法存图,若读者想立即参考「邻接矩阵」的具体写法,可参考「最短路径」-「带权全源最短路」-「Floyd-Warshall」-「代码」一节的代码。)


邻接矩阵

用二维数组 edges[][]edges[][] (或者二维容器,例如哈希表) 表示图的方法称为「邻接矩阵」存图法。当顶点可以用整数,尤其是从 0 到 V1|V| - 1 的整数表示时,以二维数组存图,此时 edges[u][v]edges[u][v] 表示顶点 uu 指向顶点 vv 的边,若为带权图,其值表示边权;若为无权图,可令值为 1 或 0 等;若边不存在,可令值为 -1 或 InfinityInfinity 等。邻接矩阵表示法所需空间为 O(V2)O(|V|^2),显然,当图较稀疏时,大量空间将被浪费,利用「邻接表」存图效率更高。


邻接表

对每一个顶点,以一个列表来存储其邻接顶点和相应的边权信息,于是图信息被存储在 V|V| 个列表中,所有这些列表存储了 E|E| 条边的信息,因此邻接表所需空间为 O(V+E)O(|V|+|E|) 。当顶点可以用整数,尤其是从 0 到 V1|V| - 1 的整数表示时,普遍以线性表来存图。对于无权图,以 List<List<Integer>> graph 存图,通过 graph.get(u).get(v) 来获取边 (u,v)(u, v) 信息。对于带权图,由于需要存储边权,因此邻接表中的泛型为 int[]int[] ,即以 List<List<int[]>> graph 存图,对于 int[] v_weight = graph.get(u)v_weightv\_weight 的大小为 2 , v_weight[0]v\_weight[0]uu 的邻接顶点 vvv_weight[1]v\_weight[1](u,v)|(u,v)|。 也可以用 List<List<Pair>> graph 存图,但本文不采用此种写法。

当顶点不适合用非负整数表示时,例如为字符串或其他引用类型,则可用哈希表存图,keyke y 为顶点 (字符串或其他引用类型),valuevalue 仍是列表,内部存储顶点 keykey 邻接顶点。


链式向前星

相比前两种存图法,「链式向前星」存图法是一种极具技巧性的存图方式,虽不太直观,但有不少优点,我们先来介绍其具体实现,再分析其优点。

首先,链式向前星的 本质也是「邻接表」 ,与邻接表法的显式邻接表 (即 graph.get(u)graph.get(u) 获取 uuListList 类型邻接表) 不同,链式向前星并不显式地存储与顶点对应的邻接表,而是将边编号,通过 「边下标指针」 来获取一个顶点的邻边信息。我们直接给出链式向前星具体的存图结构,然后再描述如何通过这样的安排来存图,使得我们能够获取图的相关信息,例如遍历一个顶点的所有边 (邻接顶点) 。

  • 边从 11E|E| 编号。

  • ends[]ends[] 数组大小为 E+1|E|+1ends[edgeNum]ends[edgeNum] 表示下标 (编号) 为 edgeNumedgeNum 的边的终点。

  • weights[]weights[] 数组大小为 E+1|E|+1 ,用于带权图,weights[edgeNum]weights[edgeNum] 表示下标为 edgeNumedgeNum 的边的边权。

  • nexts[]nexts[] 数组大小为 E+1|E|+1 ,对于顶点 uunexts[edgeNum]nexts[edgeNum] 表示下标为 edgeNumedgeNum 的边 「在其所在的顶点邻接表」 中的下一条边。

  • heads[]heads[] 数组大小为 V|V| ,对于顶点 uuheads[u]heads[u]表示顶点 uu 的第一条边。

链式向前星通过「插头法」加边建图,伪代码如下,建议结合后图分析此伪代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 带权有向图
int n, m, edgeNum // n 为顶点数,m 为边数,edgeNum 为边下标(编号)
int[] heads = new int[n]
int[] weights = new int[m + 1], ends = new int[m + 1], nexts = new int[m + 1]

int u, v, weight // 添加边(u,v)
++edgeNum // 作为第 edgeNum 条边加入
weights[edgeNum] = weight // 边权
ends[edgeNum] = v // 该边的终点为 v
// heads[u] 表示建图至当前状态时 u 的邻接表中的第一条边(u,w)的下标
// (u,v)是 u 的邻接表中新加入的边,此句表示边(u,v)的下一条边是(u,w)
// 这也就是链式向前星「插头法」的体现,也是较难理解的一句
nexts[edgeNum] = heads[u]
// 因为(u,v)插入了 u 的邻接表中原首边(u,w)之前,则此时的首边为(u,v)
heads[u] = edgeNum
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 无权无向图
int n, m, edgeNum // n 为顶点数,m 为边数,edgeNum 为边下标(编号)
int[] heads = new int[n]
int[] ends = new int[2 * m + 1], nexts = new int[2 * m + 1]

int u, v, val // 添加边(u,v)和(v,u)
// 以下添加 (u,v)
++edgeNum // 作为第 edgeNum 条边加入
ends[edgeNum] = v // 该边的终点为 v
nexts[edgeNum] = heads[u]
heads[u] = edgeNum
// 以下添加 (v,u)
++edgeNum // 作为第 edgeNum 条边加入
ends[edgeNum] = u // 该边的终点为 v
nexts[edgeNum] = heads[v]
heads[v] = edgeNum

通过下图展示顶点 uu 的边是如何被存放和表达的 (只展现关键的 edgeNum,heads,nextsedgeNum, heads, nexts 是如何关连的)。黑色实心圆点表示 uu 的第一条边,即 heads[u]heads[u]

  • 在遍历边建图之前,heads[u] = 0 ,表示此时 uu 无边,图中用蓝色虚线箭头表达。

  • 在遍历到第 4 条边时 (前三条边与 uu 无关,它们的存图过程与 uu 类似),遇到 uu 的第一条边 (u,x)(u,x) ,该边的编号为 edgeNum=4edgeNum = 4,于是立即让这条新边作为首边 heads[u]=4heads[u] = 4,然后让这条边的后继为上一条边 nexts[4]=0nexts[4] = 0 ,但是这需要先保存 0,例如下面这样操作。

    1
    2
    3
    int tmp = heads[u]; // tmp = 0
    heads[u] = edgeNum; // heads[u] = 4
    nexts[edgeNum] = tmp; // nexts[4] = 0

    实际上我们可以先设置 nexts[edgeNum]nexts[edgeNum] 再更新 heads[u]heads[u] ,就无需 tmptmp 了。

    1
    2
    nexts[edgeNum] = heads[u]; // nexts[4] = 0
    heads[u] = edgeNum; // heads[u] = 4
  • 继续遍历边建图,我们在遍历到第 7 条边时遇到第二条 uu 的边 (u,y)(u,y) ,遍历到第 10 条边时遇到 uu 的第三条边 (u,z)(u,z) 。同样地,每次我们都令新边指向当前首边,然后将新边作为当前首边。

image.png

从上述伪代码和图示中我们感到 nexts[edgeNum] = heads[u] 较难理解,此行是体现链式向前星「插头法」的核心,建图过程中我们总是以 heads[u]heads[u] 「实时地」更新顶点 uu 的首边。完成建图后,对于任意顶点 uuedgeNum=heads[u]edgeNum = heads[u]uu 的首边下标,通过 edgeNum=nexts[edgeNum]edgeNum = nexts[edgeNum] 可以「链式」地遍历 uu 的所有邻边,直到最后 edgeNum=nexts[edgeNum]=0edgeNum = nexts[edgeNum] = 0,遍历完成。此时我们能够很容易地写出「链式向前星」遍历顶点 uu 的邻边的写法。

1
2
3
4
for(int edgeNum = heads[u]; edgeNum != 0; edgeNum = nexts[edgeNum]){
int v = ends[edgeNum];
int weight = weights[edgeNum]; // 若为带权图
}

如果还是不太好理解,我们可以把 edgeedge 看作是边类 EdgeEdge 的实例,nexts[edgeNum]nexts[edgeNum] 是其属性 edge.nextedge.next ,是此边的下一条边 (的引用)。顶点也看作一个类,每个顶点持有一个 headEdgeheadEdge 属性,表示 uu 的首边, head[u]head[u] 就是 u.headEdgeu.headEdge 。 那么基于数组写法就可以写成我们更熟悉的类的写法 (伪代码,仅作示意):

1
2
3
4
5
6
遍历到 u 的新边 edge 后的「插头」操作
edge.next = u.headEdge;
u.headEdge = edge;

遍历 u 的边
for(Edge edge = u.headEdge; edge != null; edge = edge.next)

现在我们能够看出「链式向前星」的如下优点:

  1. 空间复杂度为 O(V+E)O(|V|+|E|) 。且相比利用泛型线性表的邻接表法,空间开销会更小,因为 ListList 的内部数组通常比实际大小更大。
  2. 由于存粹以数组存图,因此处理速度也会比 ListList 更快。
  3. 由于对边编号,在某些需要处理一条边的反向边的场景下 (例如最大流算法中),可以很方便地操作反向边 (具体看「小结」)。

读者若想尽快把握该存图操作,可直接查看如下位置的相应代码。

无权无向图: 「初探图搜索 (遍历)」-「无向图连通性」-「BFS」-「代码」

带权有向图: 「最短路径」-「带权单源最短路」-「Dijkstra」-「优先队列版」-「代码」

※ 从前面的图示可以看出「链式向前星」这个命名是很贴切的。根据知乎问题 链式前向星的发明者是谁? ,知乎用户「Malash」似乎是中文「链式向前星」一词的命名者。根据该问题下 「Yixiao Huang」 用户的回答,该存图方式似乎出自此篇发表于 1987 年的论文 A versatile data structure for edge-oriented graph algorithms


小结

本节学习了三种图的表示方法,各有特点,也有各自适合的应用场景。

  • 对已知「稠密图」建图,或需要频繁地根据两个顶点来读写边权的场景时,考虑「邻接矩阵」。
  • 对已知「稀疏图」建图,或只需根据顶点 uu 遍历其邻边 (邻接顶点) 的场景时,考虑「邻接表」或「链式向前星」。这种场景较为常见,因「邻接表」更易实现,因此本文若为该场景,皆采用「邻接表」建图。
  • 在希望空间复杂度更优,又需要操作「反向边」的场景,考虑「链式向前星」。
操作 邻接矩阵 邻接表 链式向前星
编写 容易 容易 相对复杂
空间 O(V2)O(V^2) O(V+E)O(V+E) O(V+E)O(V+E)
系数比邻接表更优
遍历 uu 的邻边 (邻接顶点) 遍历 dists[u]dists[u]
O(V)O(V)
遍历 graph.get(u)graph.get(u)
O(V)O(V)
edgeNum=heads[u]edgeNum = heads[u]
edgeNum=nexts[edgeNum]edgeNum = nexts[edgeNum] 配合
O(V)O(V)
根据 u,vu, v 取边 (u,v)(u,v) dists[u][v]dists[u][v]
O(1)O(1)
遍历 graph.get(u)graph.get(u)
O(V)O(V)
edgeNum=heads[u]edgeNum = heads[u]
edgeNum=nexts[edgeNum]edgeNum = nexts[edgeNum] 配合
O(V)O(V)
根据 (u,v)(u,v) 取反向边 (v,u)(v,u) dists[v][u]dists[v][u]
O(1)O(1)
遍历 graph.get(v)graph.get(v)
O(V)O(V)
若已知 (u,v)(u,v) 编号 k
则可将边编号为 [2,E+1][2,E+1] ,
(v,u)(v, u) 的编号为 k^1
O(1)O(1)

※ 链式向前星取反向边操作的说明:

将边编号为 [2,E+1][2,E+1] ,初始边编号为 1 (或者 [0,E1][0, E-1] ,初始边编号为 -1),加边建图时加入偶数编号为 kk 的边 (u,v)(u,v) ,总是立即加入编号为 k+1k+1 的反向边 (v,u)(v, u) 。由于边 从 2 开始编号,因此:

  • 所有编号为偶数的边,若记作 kk ,则其对应的反向边为 k+1k+1
  • 所有编号为奇数的边,若记作 kk ,则其对应的反向边为 k1k-1

上述两种情况可统一为 k^1^ 为异或运算。

另外,并非所有图论问题都需要「建图」,因为问题的输入本身就包含了图的信息,只不过通常需要组织成适合算法操作的结构,若该输入可直接被算法操作,也就不必再另外「建图」了。例如在「最小生成树」-「Kruskal」一节中,利用输入 connectionsconnections 即可完成求解,无需另外建图。


初探图搜索 (遍历)

图的搜索 (searchsearch) 或者说遍历 (traversaltraversal) 算法是其他更高级的图论算法的基础,因此熟练掌握图的搜索算法非常重要。「搜索」 一词的重点在于关注图中的一个 targettarget ,可以是顶点,也可以是边或其他,找到即完成任务;「遍历」 一词的重点在于对整张图无遗漏地探索,多数时候这两个词是通用的。如同「树」的 深度优先搜索 (dfsdfs) 和 广度优先搜索 (bfsbfs),图的基本搜索方法也是这两种。实际上我们知道树是一种特殊的图,在学习本节代码的过程中你会发现二者有很多相似之处。

bfsbfsdfsdfs 的应用非常丰富,可用于解决许多图上的问题,但只需掌握其 基本写法 ,就足以支撑我们学习后续更高级的图论算法。为了能够在学习中及时得到反馈,本节以如下三道基本题目引入并详细介绍最基本的 bfsbfsdfsdfs 写法,读者在开始相应子章节前应熟读对应题目。学习解法后应尝试独立写出并提交以得到反馈,最后再参照本节给出的代码来自查。

问题 题目 难度 题解
无向图的连通性 323. 无向图中连通分量的数目 中等 题解
无向图判圈 684. 冗余连接 中等 题解
有向图判圈 207. 课程表 中等 题解

※ 当讨论「连通性」时,通常是对「无向图」而言的,即不关注边方向,只关注那些通过边互相连通的连通分量。在「有向图」中,另有「强连通分量」概念,我们已在「基本概念」中给出定义。

※ 这三道题均有适用性更强的「并查集」解法。如果你尚未学习过「并查集」或仍觉得不太熟练,可以参考我写的 并查集从入门到出门 ,全文 1w+ 字,尝试透彻分析并查集的基本内容,2022年5月中旬在力扣讨论区发布后半个月内收获 5k 阅读量,500+ 收藏,100+ 点赞。


无向图连通性

首先通过考察「无向图连通性」问题 323. 无向图中连通分量的数目 来学习最基本的图的 bfs/dfsbfs / dfs 算法。

虽然此时我们还不知道要如何具体写出这两种算法的细节,但我们知道通过 bfs/dfsbfs / dfs,可以从一个顶点 uu 出发,「搜索」到与之连通的所有顶点。于是我们不难构思出通过「搜索」来寻找所有不相交的「连通分量」的过程。

  1. 依次访问所有顶点,对于当前顶点 uu,先检查它是否已经被访问过,若未被访问,以它为起始点进行「搜索」,在开始搜索后立即将 uu 标记为已访问。
  2. 搜索过程中,跳过那些「已访问」的顶点,对于「未访问」的顶点,显然它们与 uu 同属一个连通分量。
  3. 因为从 uu 开始的「搜索」一定能够找到所有与 uu 在同一连通分量的顶点,因此能够以多少个顶点为「起点」开始搜索,就有多少个连通分量。

上述过程的实现需要设置一个 booleanboolean visitedvisited 数组,大小为顶点数,表示在此后的搜索中是否访问过。「搜索」可以采用 bfsbfsdfsdfs

※ 通过 bfs/dfsbfs / dfs逐渐标记整张图 (的所有顶点) 的做法,也被形象地称之为 flood fill (泛洪) 或 seed fill (播种),本质上是 「记忆化搜索」 的应用。

※ 若采用 bfsbfs 时,不只是对单个顶点按层搜索,而是对整张图按层搜索,也即初始时找到最外层的所有顶点 (对无向图来说度为 1,对有向图来说入度为 0 的顶点),然后将这些顶点的邻接顶点作为下一层顶点,以此类推,按层操作。那么这种处理方式也被形象地称为「涟漪法」、「波纹法」、「涨潮法」等。 后续在「拓扑排序」、「无权单源最短路」中都能看到此方法的应用。


BFS

采用 bfsbfs ,如同树的 bfsbfs, 需要借助队列。每次对「未访问」的顶点 uu 执行「搜索」时,将其放入队列中,出队时置出队顶点为已访问。借助队列完成同一连通分量顶点的搜索。只要队不空,则队首 uu 出队,访问 uu 的所有邻接顶点 vv ,并将它们都放入队中。通过这个方式,一定可以 按层 完成所有与 uu 相连通的顶点的标记。代码如下。需要注意的是,无向图要 双向建边

一般可用哈希表 Map<k,v>Map<k, v> 来存储图信息,kk 为顶点,vv 为该顶点的邻接顶点列表。当顶点为一组连续整数时 (通常为 {0,1,2,...,n1}\{0,1,2,...,n-1\}nn 为顶点总数),用 List<List<Integer>> 存图效率更高,下标表示顶点,其对应的 List<Integer> 即为该顶点的邻接顶点表。通过下标可快速获取顶点的邻接表。

第一份代码以 哈希表 存图,适合于无法用连续的整数来表示顶点的场景。本题中,顶点可以被表示为 {0,1,2,3,...n1}\{0, 1,2,3,...n - 1\} ,因此采用第二份以 线性表 存图的代码效率更高。之后的内容,只要能够以线性表存图,就不再列出哈希表存图的版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 以 HashMap 存图
class Solution {
public int countComponents(int n, int[][] edges) {
// 1. 建图
Map<Integer, List<Integer>> graph = new HashMap<>();
for(int[] edge : edges){
int u = edge[0], v = edge[1];
List<Integer> uAdj = graph.getOrDefault(u, new ArrayList<>());
uAdj.add(v);
graph.put(u, uAdj); // 无向图边 (u,v)
List<Integer> vAdj = graph.getOrDefault(v, new ArrayList<>());
vAdj.add(u);
graph.put(v, vAdj); // 无向图边 (v,u)
}
// 2. 计算连通分量
boolean[] visited = new boolean[n];
int count = 0;
for(int u = 0; u < n; ++u){
if(!visited[u]){ // 以未访问过的 u 为起始顶点
count++; // 只要顶点 u 此时尚未被访问,说明它不在此前的链路(连通分量)中,那么就以它为新的连通分量起点,连通分量数量+1
bfs(u, visited, graph); // 通过 bfs 寻找与 u 处于同一连通分量的顶点,并标为已访问
}
}
return count;
}
public void bfs(int u, boolean[] visited, Map<Integer, List<Integer>> graph){
Queue<Integer> q = new ArrayDeque<>();
q.add(u);
while(!q.isEmpty()){
u = q.remove();
visited[u] = true; // 在出队时置为已访问
List<Integer> uAdj = graph.get(u);
if(uAdj != null){
for(int v : graph.get(u)){
if(!visited[v]) q.add(v); // 已访问的顶点属于此前搜索过的连通分量
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 以 List 建图
class Solution {
public int countComponents(int n, int[][] edges) {
// 1. 建图
List<List<Integer>> graph = new ArrayList<>();
for(int i = 0; i < n; ++i) graph.add(new ArrayList<>());
for(int[] edge : edges){
int u = edge[0], v = edge[1];
graph.get(u).add(v); // 无向图边 (u,v)
graph.get(v).add(u); // 无向图边 (v,u)
}
// 2. 计算连通分量
boolean[] visited = new boolean[n];
int count = 0;
for(int u = 0; u < n; ++u){
if(!visited[u]){ // 以未访问过的 u 为起始顶点
count++; // 只要顶点 u 此时尚未被访问,说明它不在此前的链路(连通分量)中,那么就以它为新的连通分量起点,连通分量数量+1
bfs(u, visited, graph); // 通过 bfs 寻找与 u 处于同一连通分量的顶点,并标为已访问
}
}
return count;
}
public void bfs(int u, boolean[] visited, List<List<Integer>> graph){
Queue<Integer> q = new ArrayDeque<>();
q.add(u);
while(!q.isEmpty()){
u = q.remove();
visited[u] = true; // 在出队时置为已访问
for(int v : graph.get(u)){
if(!visited[v]) q.add(v); // 已访问的顶点属于此前搜索过的连通分量
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 链式向前星存图
class Solution {
public int countComponents(int n, int[][] edges) {
List<List<Integer>> graph = new ArrayList<>();
int m = edges.length, edgeNum = 0;
int[] ends = new int[2 * m + 1], nexts = new int[2 * m + 1];
int[] heads = new int[n];
boolean[] visited = new boolean[n];
for(int[] edge : edges){
int u = edge[0], v = edge[1];
// 添加边 (u, v)
++edgeNum;
ends[edgeNum] = v;
nexts[edgeNum] = heads[u];
heads[u] = edgeNum;
// 添加边 (v, u)
++edgeNum;
ends[edgeNum] = u;
nexts[edgeNum] = heads[v];
heads[v] = edgeNum;
}
int count = 0;
for(int u = 0; u < n; u++){
if(!visited[u]) {
count++; // 只要顶点 u 此时尚未被访问,说明它不再此前的链路(连通分量)中,以它为新的连通分量起点
bfs(u, visited, heads, ends, nexts);
}
}
return count;
}
private void bfs(int u, boolean[] visited, int[] heads, int[] ends, int[] nexts){
Queue<Integer> q = new ArrayDeque<>();
q.add(u);
visited[u] = true;
while(!q.isEmpty()){
int v = q.remove();
for(int edgeNum = heads[v]; edgeNum != 0; edgeNum = nexts[edgeNum]) {
int w = ends[edgeNum];
if(!visited[w]) {
q.add(w);
visited[w] = true;
}
}
}
}
}

这就是图的 最基本bfsbfs 写法,也是后续应用了 bfsbfs 的更高级的图论算法的基础,读者应当熟练掌握。


DFS

若搜索采用 dfsdfs ,每次对「未访问」的顶点 uu 执行「搜索」,进入 dfsdfs 方法后立即置 visited[u]=truevisited[u] = true ,然后以 forfor 循环依次地,对其「未访问」的邻接顶点执行 dfsdfs 即可。dfsdfs 结束时,uu 所在连通分量的所有顶点 必然 都被标记为「已访问」。代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public int countComponents(int n, int[][] edges) {
List<List<Integer>> graph = new ArrayList<>();
for(int i = 0; i < n; i++) graph.add(new ArrayList<>());
for(int[] edge : edges){ // 建图
int u = edge[0], v = edge[1];
graph.get(u).add(v); // 无向图边 (u,v)
graph.get(v).add(u); // 无向图边 (v,u)
}
boolean[] visited = new boolean[n];
int count = 0;
for(int u = 0; u < n; u++){
if(!visited[u]) {
count++; // 只要顶点 u 此时尚未被访问,说明它不在此前的链路(连通分量)中,以它为新的连通分量起点
dfs(u, visited, graph);
}
}
return count;
}
private void dfs(int u, boolean[] visited, List<List<Integer>> graph){
visited[u] = true; // 立即置为已访问
for(int v : graph.get(u)) {
if(!visited[v]) dfs(v, visited, graph);
}
}
}

这就是图的 最基本dfsdfs 写法,也是后续应用了 dfsdfs 的更高级的图论算法的基础,读者应当熟练掌握。


时空复杂度

时间复杂度:

无论是 bfsbfs 实现还是 dfsdfs 实现,遍历顶点 vv 并从 vv 开始泛洪。考虑图所有顶点构成 完全图 (任意两个顶点间都有边连接),那么对第一个顶点 vv 执行搜索后,每个顶点都会询问所有他的 V1|V| - 1 个邻接顶点是否已被访问。最坏和平均时间复杂度为 O(V2)O(|V|^2)

空间复杂度: 存图空间 O(V+E)O(|V|+|E|)visitedvisited 空间 O(V)O(|V|),递归栈或队列空间 O(V)O(|V|)。总的空间复杂度为 O(V+E)O(|V|+|E|) ,该空间体现了图的规模,也称图的 线性空间复杂度


并查集

并查集是一种用来解决「连通分量」问题的专用性很强的数据结构,对于 323 题,最为直观合适且高效的办法是「并查集」。并查集本身也属「图论」范畴,我已经在另一篇文章中做过讲解,因此本文不再重复。作为图论的重要一环,并查集的学习是非常必要的,在后续「最小生成树」一节中,我们会再次看到它的身影。总之,如果你还不熟悉并查集,可以阅读我写的 并查集从入门到出门 (全文1w+字,尝试透彻分析并查集的基本内容,2022年5月中旬在力扣讨论区发布后半个月内收获 5k 阅读量,500+ 收藏,100+ 点赞) 。


判断图是否有圈

下面我们考察如何判断图是否有圈。以 684. 冗余连接 一题学习如何利用 bfs/dfsbfs / dfs 在无向图中判圈 ,以 207. 课程表 一题学习如何利用 bfs/dfsbfs / dfs 在有向图中判圈 。通过这两道题目,我们进一步加深对 bfs/dfsbfs / dfs 的理解,并体会它们此处的应用相比在「无向图连通性」应用中的变化。我们还会看到在「无向图」和「有向图」中,利用「加边法」或「减边法」判圈的代码,只有很细微的差别。


无向图判圈

对于 684. 冗余连接,题目要求我们找到使得图产生圈的 (在 edgesedges 中) 最后出现的边 。有了上一节的经验,我们很容易想到,从某顶点 vv 出发,对其执行 bfsbfsdfsdfs ,若能在此过程中找到 vv ,则我们确定 vv 是该圈上的一个顶点。对于所有的顶点,我们都执行同样的搜索,就可以确定圈上的所有顶点,然后对照 edgesedges ,就可以找出最后出现的边。但这种通过点来找边的做法比较繁琐,我们可以直接从「边」入手解决本题。现给出如下 「加边法」「减边法」 ,均为「泛洪法」。


加边法 & 减边法

  1. 加边法: 建图过程中,每加入一条边 (u,v)(u, v) 前,搜索在当前图上,能否从 uu 搜索到 vv,如果可以,则说明此时 uuvv 已经连通,再加入 (u,v)(u, v) 将导致成圈。由于我们从前到后依次取 edgesedges 中的边加入,因此导致成圈的那条边就是所求。

  2. 减边法:加边法的逆过程。先完整建图,之后从 edgesedges 中逆序取边 (u,v)(u, v) ,从当前图中减去该边并检测从 uu 是否能连通 vv,若仍能连通,说明 (u,v)(u, v) 是圈上的一条边,由于我们逆序取边,则该边就是所求。

这两种方法都很好理解,应用 bfs/dfsbfs / dfs 执行搜索,我们给出如下加边法的两份代码,减边法和其他方法代码此处不列出,读者若想查看具体写法,可参考「实战应用」中本题的 题解

比较此处的代码与「无向图连通性」一节的代码,可以看到基本框架一样,仅有以下不同,这些不同都是为适应本题所做的简单调整。

  • 本题通过 bfs/dfsbfs/dfs 寻找成圈的最后一条边。
  • 本题中,每次搜索结束后,若未找到圈,需要重置 visitedvisited

BFS 加边法代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 加边法 + bfs
class Solution {
public int[] findRedundantConnection(int[][] edges) {
int n = edges.length;
boolean[] visited = new boolean[n];
List<List<Integer>> graph = new ArrayList<>();
for(int i = 0; i < n; ++i) graph.add(new ArrayList<>());
for(int[] edge : edges){
int u = edge[0] - 1, v = edge[1] - 1;
if(bfs(u, v, visited, graph)) { // 通过 bfs 判断 u, v 是否连通
return new int[]{u + 1, v + 1};
}
graph.get(u).add(v); // 加边 (u, v)
graph.get(v).add(u); // 加边 (v, u)
Arrays.fill(visited, false);
}
return null; // 本题保证必有答案
}
public boolean bfs(int u, int v, boolean[] visited, List<List<Integer>> graph){
Queue<Integer> q = new ArrayDeque<>();
q.add(u);
while(!q.isEmpty()){
u = q.remove();
visited[u] = true;
for(int w : graph.get(u)){
if(w == v) return true; // 判明 u, v 连通立即返回 true
if(!visited[w]) q.add(w);
}
}
return false;
}
}

DFS 加边法代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 加边法 + dfs
class Solution {
boolean hasCycle = false;
public int[] findRedundantConnection(int[][] edges) {
int n = edges.length;
boolean[] visited = new boolean[n];
List<List<Integer>> graph = new ArrayList<>();
for(int i = 0; i < n; ++i) graph.add(new ArrayList<>());
for(int[] edge : edges){
int u = edge[0] - 1, v = edge[1] - 1;
if(dfs(u, v, visited, graph)) { // 通过 dfs 判断 u, v 是否连通
return new int[]{u + 1, v + 1};
}
graph.get(u).add(v); // 加边 (u, v)
graph.get(v).add(u); // 加边 (v, u)
Arrays.fill(visited, false); // 每次搜索后要重置 visited
}
return null; // 本题保证必有答案
}
public boolean dfs(int u, int v, boolean[] visited, List<List<Integer>> graph){
visited[u] = true;
for(int w : graph.get(u)){
if(w == v) hasCycle = true;
if(!visited[w]) hasCycle = dfs(w, v, visited, graph);
}
return hasCycle;
}
}

时空复杂度

时间复杂度: 最坏和平均时间复杂度都是 O(V2)O(|V|^2)。分析与「无向图连通性」的泛洪解法一致,不再赘述。

空间复杂度: 存图空间 O(V+E)O(|V|+|E|)visitedvisited 空间 O(V)O(|V|),递归栈或队列空间 O(V)O(|V|) 。总体为线性空间复杂度 O(V+E)O(|V|+|E|)


有向图判圈

684 题的图为无向图,现在我们通过 207. 课程表 一题来学习应用 bfs/dfsbfs / dfs有向图中判圈

读题后不难看出课程之间的依赖关系可抽象成「图」。每一门课程为一个顶点,课程 vv 依赖课程 uu 表示有向边 (u,v)(u, v) 。仔细理解题意后我们有如下结论,当且仅当 这些课程构成的有向图 「无圈」 时,能够完成所有课程的学习。也就是我们只需要根据输入构建有向图,然后判断该图是否有圈即可,无圈返回 truetrue , 有圈返回 falsefalse

类似「无向图判圈」,我们同样可以用「加边法」或「减边法」来求解此题。差别仅在于如下几点。

  • 本题为有向图,建图时只需单向建边。
  • 考察边 (u,v)(u, v) 加入 (加边法) 或减去 (减边法) 后,该边两点是否还连通时,根据圈方向的 单向性 ,应当以 vv 作为起点, uu 作为目标来搜索。
  • 若存在自环,即 (u,u)(u, u) 这样的圈,加边前或减边后都无法再搜索到 targettarget (即 uu ) ,将导致误判。因此对于自环,需要特殊判断。

这些差别体现在代码中只有两三行的区别,有了之前的经验,我们能够轻松实现。应用 bfs/dfsbfs / dfs 执行搜索,给出如下加边法的两份代码,减边法和其他方法代码此处不列出,可参考「实战应用」中本题的 题解


BFS 加边法代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 加边法 + BFS
class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
List<List<Integer>> graph = new ArrayList<>();
boolean[] visited = new boolean[numCourses];
for(int i = 0; i < numCourses; ++i) {
graph.add(new ArrayList<>());
}
for(int[] edge : prerequisites){ // 建图
int v = edge[0], u = edge[1]; // 要学习 v 先学习 u,因此 u -> v
if(u == v) return false; // 自环特判
if(bfs(v, u, visited, graph)) return false; // 根据有向图特点,检测是否可以从 v 到 u
Arrays.fill(visited, false); // 重置 visited
graph.get(u).add(v); // 加边 (u, v)
}
return true;
}
private boolean bfs(int u, int target, boolean[] visited, List<List<Integer>> graph){
Queue<Integer> q = new ArrayDeque<>();
q.add(u);
while(!q.isEmpty()){
u = q.remove();
visited[u] = true; // 出队时置为true
for(int v : graph.get(u)){
if(v == target) return true; // 找到 target 表示有圈
if(!visited[v]) q.add(v);
}
}
return false;
}
}

DFS 加边法代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 加边法 + DFS
class Solution {
boolean hasCycle = false;
public boolean canFinish(int numCourses, int[][] prerequisites) {
List<List<Integer>> graph = new ArrayList<>();
boolean[] visited = new boolean[numCourses];
for(int i = 0; i < numCourses; ++i) {
graph.add(new ArrayList<>());
}
for(int[] edge : prerequisites){ // 建图
int v = edge[0], u = edge[1]; // 要学习 v 先学习 u,因此 u -> v
if(u == v) return false; // 自环特判
if(dfs(v, u, visited, graph)) return false; // 根据有向图特点,检测是否可以从 v 到 u
Arrays.fill(visited, false); // 重置 visited
graph.get(u).add(v); // 加边 (u, v)
}
return true;
}
private boolean dfs(int u, int target, boolean[] visited, List<List<Integer>> graph){
visited[u] = true;
for(int v : graph.get(u)){
if(v == target) hasCycle = true;
if(!visited[v]) hasCycle = dfs(v, target, visited, graph);
}
return hasCycle;
}
}

时空复杂度

时间复杂度: 最坏和平均时间复杂度都是 O(V2)O(|V|^2)。分析与「无向图连通性」的泛洪解法一致,不再赘述。

空间复杂度: 存图空间 O(V+E)O(|V|+|E|)visitedvisited 空间 O(V)O(|V|),递归栈或队列空间 O(V)O(|V|) 。总体为线性空间复杂度 O(V+E)O(|V|+|E|)


小结

在本节中,我们通过「无向图的连通性」展示了图搜索 (遍历) 最基本的 bfs/dfsbfs / dfs 写法。接着,在「判断图是否有圈」中,进一步展示了在 「无向图」和「有向图」 中如何应用节本的 bfs/dfsbfs / dfs 写法,通过些许调整来适应新的场景。

当我们重新审视「图判圈」问题时,我们会感觉到泛洪做法中有很多重复操作。我们每次加边前 (或减边后) 泛洪都是在当前整张图上进行的,我们自然会想,能否通过对原图的一次「遍历」来找到圈呢?答案是可以的,只需要在基本 bfsbfs / dfsdfs 的基础上,配合其他一些量再做调整即可,该方法就是著名的 「拓扑排序」 ,属于图论算法中比 bfsbfs / dfsdfs 稍微进阶一些的算法。利用「拓扑排序」来判圈,可将时间复杂度从 O(V2)O(|V|^2) 降为 O(V+E)O(|V|+|E|) 。在「拓扑排序」一节的最后,我们将给出相应代码。

现在你已经牢固掌握了图的 bfsbfs / dfsdfs 原理和写法,接下来我们就可以满怀信心地从「拓扑排序」开始学习更高级更复杂的图论算法。


拓扑排序

拓扑排序 (Topological Sorting) : 对有向图的顶点的排序,如果存在从顶点 uu 到顶点 vv 的路径,那么拓扑排序要求 uu 一定在 vv 之前,一定不能出现 vvuu 之前的排序结果。由此可以看出,拓扑排序存在的前提是 有向图无圈。且只要图为 DAGDAG (Directed Acyclic Graph, 有向无圈图),则该图 至少存在一种拓扑排序结果 。此外,是否存在拓扑排序与图是否存在不连通的分量无关,这是显然的,因为互不连通的分量互不依赖,在拓扑排序中这些分量的顺序是任意的。

拓扑排序的主要实现为基于 BFSBFS「Kahn算法」 以及基于 DFSDFS「Tarjan拓扑排序算法」 。我们将看到,这两种算法实现的拓扑排序在求解过程上是 「互逆」 的。wiki 中对拓扑排序有如下 更准确的表述 ,后续我们会再次提到该「表述」。

Precisely, a topological sort is a graph traversal in which each node vv is visited only after all its dependencies are visited.

更准确地,拓扑排序是对图的一种遍历,在这种遍历中,对一个顶点 vv 的访问只发生在它所依赖的顶点被访问之后。

※ 大家思考过为什么这种排序要冠以「拓扑」之名吗?根据作者有限的了解,「拓扑学 (Topology)」研究的是平面或立体图形 (多维?) 连续变形过程中的性质。说一个什么事物是「拓扑的 (Topological)」似乎在表达这个事物变形前后的关系,看起来「拓扑排序」跟数学上的「拓扑」并没有什么关联,因为点或边或整张图并未有什么变化。查了一下,这篇讨论 Why is “topological sorting” topological? 的高赞回答表示该命名大概只是想体现 “network topology” 的味道 (sense)。

作者点评: 适当地探索一个技术名词的「语源」,能够加深我们对其的理解 🤔。

在前一节中,我们提到「图判圈」问题,也就是 684. 冗余连接207. 课程表 两题可通过「拓扑排序」解决,且时间复杂度为 O(V+E)O(|V|+|E|)。但解决的过程中并不会真正地「排序」。为了能体现完整的拓扑排序算法,我们先以 210. 课程表 II 一题为分析对象,该题目描述的内容常作为介绍拓扑排序的一个经典的现实例子 (具体看原题描述)。在本节最后再给出 684 和 207 题的「拓扑排序」解法,在学完本节后,相应解法是很容易写出的。更多「拓扑排序」相关题目请参考「实战应用」。


Kahn

算法描述

Kahn算法:基于 BFSBFS 的拓扑排序算法。之前我们提到过「拓扑排序准确表述」,该算法通过 入度 这一概念很好地体现了「表述」,即当访问一个顶点 vv ,若其 「入度」 为 0 ,即说明它所依赖的所有顶点一定已经被访问过了,此时即可将其输出 (将其排序)。具体来说,先计算所有顶点的入度,然后将入度为 0 的顶点放入队列中,从队列输出队首顶点并依次将其所有 邻接顶点入度减 1 ,每一个邻接顶点入度减 1 后,判断其入度是否减至 0 ,若为 0 将其入队。重复上述过程,直到队列为空 (所有顶点均已入队又出队) 。容易看出,一个顶点入度减至 0 ,当前仅当 它所依赖的顶点的入度在此之前已减至 0。算法结束时,顶点出队的顺序即为拓扑排序,这是一个 「顺序」 拓扑排序过程。

判圈: 某个节点出队时对其存在的邻边入度减 1 后,若这些邻边入度均未减至 0,则说明该图 有圈 。可以通过在 whilewhile 结束后考察 出队顶点数与总顶点数是否相等 来判圈。

A. B. Kahn于1962年发表的 Topological Sorting of Large Networks 论文中描述了该算法。


算法过程

算法的详细过程如下,也即该算法求解拓扑排序问题时的编程范式。具体代码见「代码」部分,在参考之前,你应当通过此处给出的算法过程尝试自己写出。

  1. 根据输入建图及计算入度。

  2. 建图。

    一般可用哈希表 Map<k,v>Map<k, v> 来存储图信息,kk 为顶点,vv 为该顶点的邻接顶点列表。当顶点为一组连续整数时 (通常为 {0,1,2,...,n1}\{0,1,2,...,n-1\}nn 为顶点总数),用 List<List<Integer>> 存图效率更高,下标表示顶点,其对应的 List<Integer> 即为该顶点的邻接顶点表。通过下标可快速获取顶点的邻接表。

  3. 计算入度。

    入度信息一般用大小为顶点数的数组 indegrees[]indegrees[] 表示,入度计算通常与建图同时进行。遍历输入信息,遇到边 (u,v)(u, v) 时,执行 indegree[v]++ 。当所有边都被考察后,入度信息即已完备。

  4. 拓扑排序。
    有了入度信息和图信息,开始拓扑排序。

    1. 设置一个队列 qq 、一个用于保存拓扑排序结果的列表 resres 、一个用于后续判断图是否有圈的计数变量 countcount
    2. 遍历 indegreesindegrees 数组将入度为 0 的顶点入队 (若已知图为有向无圈 连通图 ,则 有且只有一个 顶点入度为 0,可在找到后立即跳出遍历)。
    3. 以一个 whilewhile 检查当前队列是否为空,不空则队首顶点 uu 出队,放入输出结果 resres 中。同时 count++ ,表明 uu 已被排序。
    4. 遍历 uu 的邻接顶点 vv ,使 vv 的入度减 1,并检查减 1 后是否为 0 ,为 0 则 vv 入队。
    5. whilewhile 结束后,在返回前判圈。 图有圈则存在入度不可能减至 0 的顶点 ,则已拓扑排序 (已出队) 的顶点个数 countcount 必小于顶点总数。若满足 count==ncount == n 则返回拓扑排序结果,否则无结果。

时空复杂度

时间复杂度:

  1. 建图及计算所有顶点入度需遍历所有边,时间复杂度为 O(E)O(|E|)
  2. 队列中每个顶点均入队一次,出队一次,O(V)O(|V|)
  3. 更新并检查邻接顶点的 forfor 中,更新和检查的总次数等于边数 O(E)O(|E|)

故总的时间复杂度为 O(V+E)O(|V|+|E|) 。此时间复杂度体现了图的规模,因此也称之为图的 线性时间复杂度 。 若图是连通的 ,由于 EV|E| ≥ |V| (仅在图为链状有向图时 E=V1|E| = |V| - 1),因此通常也可以粗略地记做 O(E)O(|E|)

空间复杂度: 存图空间 O(V+E)O(|V|+|E|)res/indegreesres / indegrees 空间 O(V)O(|V|),队列空间 O(V)O(|V|)。总体为线性空间复杂度 O(V+E)O(|V|+|E|)


代码

Kahn 拓扑排序算法实现 210. 课程表 II 。在掌握了 BFSBFS 算法写法及理解 Kahn 算法过程的基础上,我们很容易写出如下代码。

第一份代码以 哈希表 存图,适合于无法用连续的整数来表示顶点的场景。本题中,顶点可以被表示为 {0,1,2,3,...n1}\{0, 1,2,3,...n - 1\} ,因此采用第二份以 线性表 存图的代码效率更高。此写法为普遍的标准的 Kahn 拓扑排序写法,读者应对该写法 熟稔于心

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 以HashMap存图
class Solution {
public int[] findOrder(int numCourses, int[][] prerequisites) {
// 1. 【建图 + 计算入度】
int[] res = new int[numCourses], indegrees = new int[numCourses];
Map<Integer, List<Integer>> graph = new HashMap<>();
for(int[] edge : prerequisites){ // 建图 & 计算入度
int v = edge[0], u = edge[1]; // u是v的先修课程, u -> v
List<Integer> adjs = graph.getOrDefault(u, new ArrayList<>()); // u的邻接表
adjs.add(v); // v为u的邻接顶点
graph.put(u, adjs);
indegrees[v]++; // v的入度加1
}
// 2. 【拓扑排序】
// 2-1. 遍历入度数组,将入度为 0 的顶点入队。
int count = 0;
Queue<Integer> q = new ArrayDeque<>();
for(int u = 0; u < numCourses; u++){ // 找到入度为0的顶点并入队
if(indegrees[u] == 0) q.add(u);
}
// 2-2. 利用队列完成拓扑排序
while(!q.isEmpty()){
int u = q.remove();
res[count++] = u; // 入度为0,输出到res,同时count++
List<Integer> adjs = graph.get(u);
if(adjs != null){ // 有的顶点可能无邻接顶点
for(int v : adjs){
indegrees[v]--; // v的入度减1
if(indegrees[v] == 0) q.add(v);
}
}
}
// 2-3. 返回前判圈。
return count == numCourses ? res : new int[]{};
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 以List存图
class Solution {
public int[] findOrder(int numCourses, int[][] prerequisites) {
int[] res = new int[numCourses];
// 1. 【建图 + 计算入度】
List<List<Integer>> graph = new ArrayList<>();
for(int i = 0; i < numCourses; ++i){
graph.add(new ArrayList<>());
}
int[] indegrees = new int[numCourses];
for(int[] edge : prerequisites){ // 计算入度 & 建图
int u = edge[1], v = edge[0];
indegrees[v]++;
graph.get(u).add(v);
}
// 2. 【拓扑排序】
// 2-1. 遍历入度数组,将入度为 0 的顶点入队。
Queue<Integer> q = new ArrayDeque<>();
for(int u = 0; u < numCourses; ++u){ // 初始化队列 q
if(indegrees[u] == 0) q.add(u); // 初始入度为 0 的顶点入队
}
// 2-2. 利用队列完成拓扑排序
int count = 0;
while(!q.isEmpty()){
int u = q.remove(); // 队首 u 出队
res[count] = u; // 写入拓扑排序结果
count++; // 累计已完成拓扑排序的顶点数量
for(int v : graph.get(u)){
indegrees[v]--; // 调整入度
if(indegrees[v] == 0) q.add(v); // 若 v 的入度减至 0,入队
}
}
// 2-3. 返回前判圈。
return count == numCourses ? res : new int[]{}; // 判圈
}
}

Tarjan (Topological Sorting)

算法描述

Tarjan拓扑排序算法:基于 DFSDFS 的拓扑排序算法。回顾之前提到的「拓扑排序准确表述」中的「对一个顶点 vv 的访问只发生在它所依赖的顶点被访问之后」,我们从中可以嗅到「层层深入」的味道,即对于 vv ,「层层深入」它所依赖的顶点后访问到它,这是一个 dfsdfs 的过程,「表述」中要求,完成 vv 的访问的前提是完成它所依赖的顶点的访问,那么我们可以在 dfsdfsvv 的过程中,「缓存」 路径上 vv 所依赖的顶点的状态 (即暂不处理),当我们要处理 vv 的时候,已经保证了它所依赖的顶点会在此后「回溯」的过程中处理,因为它们此刻都在 递归栈的更靠顶部的空间中 (也就是在返回路径上) 缓存着。换句话说,我们只需要将 vv 放在当前用于存放拓扑排序结果的空间的最后侧 (这是栈的特点,我在这里称其为「排序结果栈」),此后的回溯一定会将它所依赖的顶点放在它的前面。那么我们什么时候处理 vv 呢?自然是无法从它再深入到任何顶点的时候,可以是其无 (依赖关系的) 后继顶点时,也可以是其后继顶点均已被排序 (被放入结果栈中) 时。理解了这一点,我们即可给出如下 Tarjan 拓扑排序的主要过程。建图过程无需多言。

顶点在算法过程中有三个状态, 未搜索,搜索中、已完成 (访问) 。算法从遍历顶点开始,每遇到一个「未搜索」的顶点 uu ,就以其为起点开始 dfsdfs 。进入 dfsdfs 方法时我们首先将 uu 标记为「搜索中」,然后以 forfor 循环依次考察 uu 的邻接顶点 vv

  • 若其状态为「未搜索」,对其递归调用 dfsdfs ,重复前述过程。
  • 若其状态为「搜索中」,表明我们此前进入过 vvdfsdfs 过程,而此时又遇到了 vv ,显然 vv 是通过一个圈回到了 vv。于是将 hasCyclehasCycle 标记为 truetrue 并返回。
  • 注意我们不必处理「已完成」的 vv ,无判断分支会直接跳过。

如前一段所说,当 uu 无法再深入到任何顶点时,我们标记其状态为「已完成」。每以一个「未搜索」的顶点为起点完成 dfsdfs 后,检测一次 hasCyclehasCycle 是否为 truetrue,是则结束,否则当程序终止时,顺序输出结果栈即为正确的拓扑排序。

该算法并不总是被冠以 Tarjan 之名,在 wiki 中有下面这段话,注意「…seems to…」。另外,Cormen et al. (2001) 指的是那本著名的「算法导论」。

This depth-first-search-based algorithm is the one described by Cormen et al. (2001), it seems to have been first described in print by Tarjan in 1976.

※ 之所以称「Tarjan拓扑排序算法」而非「Tarjan算法」,是因为由 Tarjan 发明或合作发明或有重大贡献的算法和数据结构非常之多,如「最近公共祖先(LCA)」、「强连通分量(SCC)」、「伸展树 (Splay Tree)」、「斐波那契堆 (Fibonacci Heaps)」、「并查集 (Union-Find Set)」等等。狭义上的「Tarjan算法」指的是「强连通分量」算法。实际上将基于 BFSBFS 的拓扑排序算法冠以 Kahn 之名比之将基于 DFSDFS 的拓扑排序冠以 Tarjan 之名更为流行,原因除了前者发明时间更早,且更易于理解之外,还在于后者只是 Tarjan 所发明的寻找「强连通分量」算法的副产品,而未作为一个独立的算法发表 (由此也可见 Tarjan 之强大)。详见 Does Tarjan’s SCC algorithm give a topological sort of the SCC?

… And his algorithm (SCC) also does topological sorting as a byproduct. ---- by Knuth


算法过程

算法的详细过程如下,也即该算法求解拓扑排序问题时的编程范式。具体代码见「代码」部分,在参考之前,你应当通过此处给出的算法过程尝试自己写出。

  1. 根据输入建图及准备 visitedvisited 数组、 hasCyclehasCycle 布尔值、拓扑排序结果栈 resres 以及栈底下标 idxidx (初始时为 V1|V| - 1)。

    1. 建图。与 kahn 算法一致。

    2. visitedvisited 数组下标为顶点,有三种取值,表示顶点在拓扑排序过程中的三种状态。0: 未搜索 1: 搜索中 2: 已完成 (搜索)。初始时 hasCycle=falsehasCycle = false

  2. 拓扑排序。遍历所有顶点,对「未搜索」状态的顶点 uu 执行 dfsdfs

    1. 进入 dfsdfs 后,首先置 visited[u]=1visited[u] = 1,表示 uu 处于搜索中状态。
    2. forfor 循环依次考察 uu 的邻接顶点 vv
      1. visited[v]==0visited[v] == 0 ,状态为「未搜索」。对其递归调用 dfsdfs ,也就是看它是否是其他顶点的「依赖」,使其置于「缓存」之中。
      2. visited[v]==1visited[v] == 1 ,状态为「搜索中」。表明我们此前进入过 vvdfsdfs 过程,而此时又遇到了 vv ,显然 vv 是通过一个圈回到了 vv。于是将 hasCyclehasCycle 标记为 truetrue 并返回。
      3. visited[v]==2visited[v] == 2 ,状态为「已完成」。跳过。
    3. 对于 uu,若其完成了 forfor,表明要么其无 (依赖关系中的) 后继顶点,要么其后继顶点均已被排序 (被放入结果栈中)。也就是 uu 是当前所有未完成排序的顶点中位于依赖关系最后面的顶点,于是将其放入当前结果栈中的的底部。
  3. 若能完成所有顶点的遍历而无圈,说明所有顶点已被拓扑排序,返回 resres


时空复杂度

时间复杂度:

  1. 建图及需遍历所有边,时间复杂度为 O(E)O(|E|)

    每个顶点至多被标记两次,O(V)O(|V|)

  2. 检查邻接顶点的 forfor 的总次数等于边数 O(E)O(|E|)

故总的时间复杂度为同 Kahn 算法一样也是线性时间复杂度 O(V+E)O(|V|+|E|) 。若图是连通的 ,由于 EV|E| ≥ |V| (仅在 V=2|V| = 2,且只有一条边时 E<V|E| < |V|),因此通常也可以粗略地记做 O(E)O(|E|)

空间复杂度: 存图空间 O(V+E)O(|V|+|E|)res/visitedres / visited 空间 O(V)O(|V|),递归栈空间 O(V)O(|V|)。总体为线性空间复杂度 O(V+E)O(|V|+|E|)


代码

Tarjan 拓扑排序算法实现 210. 课程表 II 。此写法为普遍的标准的 Tarjan 拓扑排序写法,读者应对该写法 熟稔于心

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class Solution {
List<List<Integer>> graph;
int[] visited, res;
boolean hasCycle = false;
int idx;
public int[] findOrder(int numCourses, int[][] prerequisites) {
// 1. 建图 + 准备 visited / hasCycle / res / idx
this.graph = new ArrayList<>();
this.visited = new int[numCourses]; // 0: 未搜索 1: 搜索中 2: 已完成
this.res = new int[numCourses]; // 存储拓扑排序结果的栈
this.idx = numCourses - 1; // 栈底下标
for(int i = 0; i < numCourses; i++) {
graph.add(new ArrayList<>());
}
for(int[] edge : prerequisites){ // 建图
int v = edge[0], u = edge[1]; // 要学习v先学习u,因此 u -> v
graph.get(u).add(v);
}
// 2. 遍历所有顶点,对「未搜索」状态的顶点 u 执行 dfs
for(int u = 0; u < numCourses; u++){
if(visited[u] == 0) { // 对未搜索的顶点执行 dfs
dfs(u);
if(hasCycle) return new int[]{}; // 每次搜索后,若检测出圈,返回空数组
}
}
return res;
}
private void dfs(int u){
if(hasCycle) return;
visited[u] = 1; // 立即标记为搜索中
for(int v : graph.get(u)) {
if(visited[v] == 0) dfs(v); // 邻接顶点为「未搜索」状态,dfs之
else if(visited[v] == 1) { // 若邻接顶点为「搜索中」状态,说明有圈
hasCycle = true;
return;
}
}
visited[u] = 2; // 完成搜索,表明在对u进行dfs的过程中未遇到正在搜索中的其他顶点
res[idx--] = u; // 此时u所依赖的顶点都在栈中「缓存」等待后续处理,因此可以将其放入此时结果栈中的底部。
}
}

拓扑排序判圈

在学习了本节内容后,我们再回到 684. 冗余连接207. 课程表 这两题的,给出它们的拓扑排序解法。需要注意的是,应用 Kahn 拓扑排序时,对于「无向图」上的顶点,考虑的是无关方向的 「度」 ,开始排序时要将 度为 1 的顶点入队;对于「有向图」上的顶点,考虑的是 「入度」 ,开始排序时要将 入度为 0 的顶点入队。


无向图判圈

对于 684 题,因为题目要求返回构成圈的在 edgesedges 中的最后一条边,因此只「判圈」还不够,若应用 Kahn 拓扑排序算法,则排序结束后可根据度大于 1 的顶点信息,在 edgesedges 中找到符合要求的最后的那条边。 若只要求判圈,那么也可以采用Tarjan拓扑排序算法 。如下我们只给出 Kahn 排序算法解 684 题的代码。

  • 时间复杂度: 由于本题 V=E|V| = |E|,因此拓扑排序时间复杂度为 O(V)O(|V|),排序结束后在 edgesedges 中寻找「构成圈的最后一条边」的时间复杂度为 O(V)O(|V|)
  • 空间复杂度: 存图空间 O(V)O(|V|)degreesdegrees 空间 O(V)O(|V|),队列空间 O(V)O(|V|)。总体为线性空间复杂度 O(V)O(|V|)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Solution {
public int[] findRedundantConnection(int[][] edges) {
int n = edges.length;
int[] degrees = new int[n];
List<List<Integer>> graph = new ArrayList<>();
for(int i = 0; i < n; i++) graph.add(new ArrayList<>());
for(int[] edge : edges){ // 建图 + 计算顶点的度
int u = edge[0] - 1, v = edge[1] - 1;
degrees[u]++; // u 的度加 1
degrees[v]++; // v 的度加 1
graph.get(u).add(v);
graph.get(v).add(u);
}
Queue<Integer> q = new ArrayDeque<>();
for(int i = 0; i < n; i++) { // 度为 1 的顶点入队
if(degrees[i] == 1) q.add(i);
}
while(!q.isEmpty()){ // 拓扑排序过程
int u = q.remove();
for(int v : graph.get(u)){
degrees[v]--;
if(degrees[v] == 1) q.add(v);
}
}
for(int i = n - 1; i >= 0; i--){ // 在 edges 中从后往前寻找圈上的边,该边的两个顶点的度均大于1
int u = edges[i][0] - 1, v = edges[i][1] - 1;
if(degrees[u] > 1 && degrees[v] > 1){
return new int[]{u + 1, v + 1};
}
}
return null; // 本题保证必有解
}
}

有向图判圈

对于 207. 课程表 题,其拓扑排序解法与 210. 课程表 II 题的区别仅在于前者无需真正排序,只需判断是否存在圈即可。以下给出 Kahn 和 Tarjan 两种拓扑排序算法的代码。

Kahn拓扑排序算法

  • 时间复杂度: 拓扑排序时间复杂度为 O(V+E)O(|V|+|E|)
  • 空间复杂度: 存图空间 O(V+E)O(|V|+|E|)indegreesindegrees 空间 O(V)O(|V|),队列空间 O(V)O(|V|)。总体为线性空间复杂度 O(V+E)O(|V|+|E|)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Kahn拓扑排序
class Solution {
public boolean canFinish(int numCourses, int[][] prerequisites) {
List<List<Integer>> graph = new ArrayList<>();
int[] indegrees = new int[numCourses];
for(int i = 0; i < numCourses; i++) {
graph.add(new ArrayList<>());
}
for(int[] edge : prerequisites){ // 建图 & 计算入度
int v = edge[0], u = edge[1]; // 要学习v先学习u,因此 u -> v
graph.get(u).add(v);
indegrees[v]++;
}
Queue<Integer> q = new ArrayDeque<>();
for(int u = 0; u < numCourses; u++){ // 入度为0的顶点入队
if(indegrees[u] == 0) q.add(u);
}
int count = 0; // 已拓扑排序的顶点数
while(!q.isEmpty()){ // 拓扑排序
int u = q.remove();
count++;
for(int v : graph.get(u)){
indegrees[v]--;
if(indegrees[v] == 0) q.add(v);
}
}
return count == numCourses;
}
}

Tarjan拓扑排序算法

  • 时间复杂度: 拓扑排序时间复杂度为 O(V+E)O(|V|+|E|)
  • 空间复杂度: 存图空间 O(V+E)O(|V|+|E|)visitedvisited 空间 O(V)O(|V|),递归栈空间 O(V)O(|V|)。总体为线性空间复杂度 O(V+E)O(|V|+|E|)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Tarjan拓扑排序
class Solution {
List<List<Integer>> graph;
int[] visited;
boolean hasCycle = false;
public boolean canFinish(int numCourses, int[][] prerequisites) {
this.graph = new ArrayList<>();
this.visited = new int[numCourses]; // 0: 未搜索 1: 搜索中 2: 已完成
for(int i = 0; i < numCourses; i++) {
graph.add(new ArrayList<>());
}
for(int[] edge : prerequisites){
int v = edge[0], u = edge[1]; // 要学习v先学习u,因此 u -> v
graph.get(u).add(v);
}
for(int u = 0; u < numCourses; u++){
if(visited[u] == 0) {
dfs(u);
if(hasCycle) return false; // 每次搜索后,若检测出圈,返回false
}
}
return true;
}
private void dfs(int u){
if(hasCycle) return;
visited[u] = 1; // 搜索中
for(int v : graph.get(u)) {
if(visited[v] == 0) dfs(v);
else if(visited[v] == 1) {
hasCycle = true;
return;
}
}
visited[u] = 2; // 完成搜索,表明在对u进行dfs的过程中未遇到正在搜索中的其他顶点
}
}

小结

在本节中,我们分别介绍了基于 bfsbfs 的 Kahn 算法和基于 dfsdfs 的 Tarjan 拓扑排序算法,并给出了这两种方法解决 210. 课程表 II 一题的代码。我们还展示了利用「拓扑排序」,可以在「图判圈」这一问题上,实现线性时间复杂度求解,比「初探图搜索 (遍历)」一节的方法更好,显示了拓扑排序这一方法的优点。在之后的章节中,我们还会看到拓扑排序是如何继续发挥作用的。


最短路径

本节我们将学习图论算法中古老而经典的「最短路径」问题,该算法在地图、交通等许多领域中的重要性无须多言。我们将按照如下三大类进行讲解。

  • 无权单源最短路: (Single Source Shortest Path, SSSP) 给定一张无权图 GG 和一个顶点 ss ,找出从 ssGG 中每一个顶点的最短路径。

  • 带权单源最短路: (Single Source Shortest Path, SSSP) 给定一张带权图 GG 和一个顶点 ss ,找出从 ssGG 中每一个顶点的最短路径。

  • 带权全源最短路: (All Pairs Shortest Path, APSP) 给定一张带权图 GG ,找出 GG 中所有点对的最短路径。

我们指出,除了特定情形的图 (如 DAG), 最短路径算法不区分图的边是否有向,建图时对有向图/无向图正确建图即可 (有向图单向建边,无向图双向建边)。为了使读者在学习过程中及时地得到反馈,我将在介绍每一种算法后,利用该算法实际解决如下对应题目。读者应当在理解算法内容之后尝试独立求解,然后再与文中给出的代码相比照。

最短路分类 配套例题
无权单源最短路 814. 无向图中的最短路径
带权单源最短路 743. 网络延迟时间
带权全源最短路 743. 网络延迟时间
1334. 阈值距离内邻居最少的城市

无权单源最短路

我们首先介绍最简单的「无权单源最短路」,力扣上似乎没有相应的题目,不过力扣友商平台上的 814.无向图中的最短路径 一题比较匹配 (请自行搜索),我们通过这题来学习本节。虽然该题只须求给定两点之间的最短距离,但我们仍然按给定一点 (源点 ss),求其到其他所有顶点的距离来做,最后只须返回指定顶点的距离即可。

需要注意的是,本小节给出的例题的图虽是无向图,但有向图解法是一致的。「有向无权单源最短路」与「无向无权单源最短路」的求解方法的唯一区别只在与建图时是双向建边 (无向图) 还是单向 (有向图) 建边。后续「带权图」的最短路问题也一样,有向无向并不影响算法过程,因此 对于「最短路」问题,通常不强调「无向」还是「有向」


朴素版

虽然我们早已熟悉利用队列来辅助 bfsbfs 过程的写法,但我还是决定先介绍不用队列的 朴素版本 ,以此为契机再次感受使用队列实现 bfsbfs 的优点。如果你确信自己理解了这一点,你也可以跳过「朴素版」直接看「队列优化版」。


算法描述

在熟悉 bfsbfs 泛洪算法后我们很容易想到无权单源最短路的解法。从源点 ss 开始,以 bfsbfs 方式确定 ss 到其他所有顶点的最短路径。首先,程序主体为一个使得距离 (指源点到其他顶点的距离,此后若无特别说明,「距离」一词均为此意) 能够按层推进的 forfor 循环,for(int dist = 0; dist < |V| - 1; dist++)dist=0dist = 0 表示一开始距离为 0 ,此后会逐层增加距离。该 forfor 内接着一个内层 forfor 循环,用于 遍历所有顶点 ,找到那些 距离已确定 且为当前 distdist 顶点 uu。对每一个 uu ,再以一个 forfor 循环遍历它的邻接顶点 vv ,使那些 距离未确定vv 的距离为 dist+1dist + 1。这样就能够按层推进访问所有顶点并确定这些顶点的距离。

具体来说,对于 ss ,首先置其距离为 0,然后开始外层 forfor 。遍历顶点后只有 ss 满足条件,于是其 邻接顶点 的距离 +1,(如果要求返回具体路径,可以在此时将其前驱顶点置为 ss ,以便将来用于返回路径)。接着遍历顶点,对上一层顶点,也就是之前距离被置为 1 的顶点 uu 考察它们的 距离未确定 的邻接顶点 vv ,使 vv 的距离 +1,(如前,若需要,此时将 vv 的前驱顶点置为 uu )。以 forfor 循环重复上述过程,循环开始时 dist=0dist = 0,循环次数上限为顶点个数减 1,每次执行后 dist+1dist+1,表示距离不断按层递增,且距离 V1≤|V|-1 (当图为链状时取到最大距离,为 V1|V|-1 )。


算法过程
  1. 设置一个用于泛洪标记 (访问状态) 的 visitedvisited 哈希表,一个记录所有顶点距离的哈希表 distsdists

  2. 置给定源点 ss (题中的 AA) 的距离为 0,并立即置其为「已访问」。

  3. 外层 (第一层) forfor 循环按距离递增。初始 dist=0dist=0 ,以 V1|V| - 1 为最大遍历次数,每次 distdist 加 1,表示距离以 1 为单位不断递增,最大不超过 V1|V|-1

    1. 第二层 forfor 循环遍历所有顶点,找到距离已确定且为 distdist 的顶点 uu (即刚刚处理完的上一层顶点)。
      1. 第三层 forfor 循环遍历 uu 的邻接顶点 vv ,置其中距离未确定的 vv 的距离为 dist+1dist+1 。(若有需要,可在此时置 vv 的前驱为 uu ,本题不需要)。
  4. 最外层 forfor 循环结束后所有顶点距离被确定,算法结束。

※ 该题的输入中,顶点的 labellabel 虽是唯一的,但并不连续,因此对于 visitedvisiteddistsdists ,不适合用我们之前已经习惯的数组来表示,改用哈希表表示。另外,此题输入已经给出了图的完整邻接表信息,无需建图过程。


时空复杂度

时间复杂度:虽然程序有三层循环,但总体的效果是对所有顶点询问一次它们的邻接顶点,并不总能进入第三层,因此时间复杂度取决于前两层 forfor 的时间复杂度,为 O(V2)O(|V|^2)

空间复杂度: 存图空间 O(V+E)O(|V|+|E|) (本题已给出,实际上无需此空间), visited/distsvisited/dists 空间 O(V)O(|V|)。总体为线性空间复杂度 O(V+E)O(|V|+|E|) (对于本题实际上是 O(V)O(|V|) )。


代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* Definition for Undirected graph.
* class UndirectedGraphNode {
* int label;
* List<UndirectedGraphNode> neighbors;
* UndirectedGraphNode(int x) {
* label = x;
* neighbors = new ArrayList<UndirectedGraphNode>();
* }
* }
*/

public class Solution {
public int shortestPath(List<UndirectedGraphNode> graph, UndirectedGraphNode A, UndirectedGraphNode B) {
if(A == B) return 0; // 特判
int n = graph.size(), a = A.label, b = B.label;
Set<Integer> visited = new HashSet<>();
Map<Integer, Integer> dists = new HashMap<>();
dists.put(a, 0); // 首先置源点距离为0
visited.add(a); // 立即置为已访问
for(int dist = 0; dist < n - 1; dist++){ // 按层推进
for(UndirectedGraphNode uNode : graph){ // 遍历顶点
int u = uNode.label;
if(visited.contains(u) && dists.get(u) == dist){ // 寻找刚确定好距离的上一层顶点
for(UndirectedGraphNode vNode : uNode.neighbors){
int v = vNode.label;
if(!visited.contains(v)) { // 距离未确定的v
dists.put(v, dist + 1); // 确定当前层顶点的距离
visited.add(v); // 立即置为已访问
}
}
}
}
} // 外层for结束后求得源点A到所有顶点的距离
return visited.contains(b) ? dists.get(b) : -1;
}
}

队列优化版

算法描述

朴素版做法的明显缺点是在程序运行到后期,大部分顶点的距离已确定,但每次距离加 1 后,仍要遍历所有顶点,我们已经知道使用队列可以优化这部分时间。从源点 ss 开始执行 bfsbfs ,一开始将 ss 放入队列中。每次处理一层顶点,在一个 forfor 循环内依次将该层顶点出队,对每一个当前层的顶点 uu ,遍历它的邻接顶点 vv ,只要它未被标记为「已访问」,则它是下一层顶点,此时即可赋予其当前距离,此距离每处理一层增加 1,将 vv 入队并置 vv 为「已访问」。当 qq 中顶点全都出队时,我们就得到了 ss 到所有顶点的最短路径距离。


算法过程
  1. 设置一个队列 qq ,一个用于泛洪标记 (访问状态) 的 visitedvisited 哈希表,一个记录所有顶点距离的哈希表 distsdists
  2. 首先将源点 ss 入队,并立即置其访问状态为「已访问」。
  3. 执行 while(!q.isEmpty())while(!q.isEmpty()) ,开始 bfsbfs 泛洪。每次以一个 forfor 循环处理 一层 顶点,dist++dist++ ,表示当前层的距离。
    1. 对每一个当前层的顶点 uu ,以一个 forfor 循环遍历它的邻接顶点 vv ,只要它未被标记为「已访问」,则它是下一层顶点,此时即可赋予其当前距离,将 vv 入队 并置 vv 为「已访问」。
  4. qq 中顶点全都出队时,我们就得到了 ss 到所有顶点的最短路径距离。

时空复杂度

时间复杂度: BFSBFS 一次泛洪的时间复杂度 O(V+E)O(|V|+|E|) 。可如此分析: 每个顶点均入队一次出队一次,时间复杂度为 O(V)O(|V|) ,每个顶点都会遍历其邻接顶点,总的来看就是总边数,时间复杂度为 O(E)O(|E|)。因此总时间复杂度为线性时间复杂度 O(V+E)O(|V|+|E|)

空间复杂度: 存图空间 O(V+E)O(|V|+|E|) (本题已给出,实际上无需此空间), visited/dists/qvisited/dists/q 空间 O(V)O(|V|)。总体为线性空间复杂度 O(V+E)O(|V|+|E|) (对于本题实际上是 O(V)O(|V|) )。


代码

给出如下代码。由于该题的输入中,顶点的 labellabel 虽是唯一的,但并不连续,因此对于 visitedvisiteddistsdists ,用哈希表来代替数组。该题入参 graphgraph 已经包含了完整的邻接表信息,无需再建图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Solution {
public int shortestPath(List<UndirectedGraphNode> graph, UndirectedGraphNode A, UndirectedGraphNode B) {
if(A == B) return 0;
int n = graph.size(), dist = 0, a = A.label, b = B.label;
Set<Integer> visited = new HashSet<>(); // 用set来存放已访问元素
HashMap<Integer, Integer> dists = new HashMap<>(); // 用哈希表记录每个顶点的距离
Queue<UndirectedGraphNode> q = new ArrayDeque<>();
q.add(A);
visited.add(a); // 将源点置为「已访问」
while(!q.isEmpty()){ // 泛洪
dist++; // 当前层顶点的「距离」
int size = q.size();
for(int i = 0; i < size; i++){ // 一次处理一层
UndirectedGraphNode uNode = q.remove();
for(UndirectedGraphNode vNode : uNode.neighbors){
int v = vNode.label;
if(!visited.contains(v)){ // 若v「未访问」
dists.put(v, dist); // v的距离在此时确定
q.add(vNode);
visited.add(v); // 立即置为「已访问」
}
}
}
}
return visited.contains(b) ? dists.get(b) : -1;
}
}

如果只是为了求解该题,无需记录所有顶点的距离,只要遇到顶点 BB,返回当前距离即可,如下。(有意思的是用数组 visitedvisited 也能通过所有样例,不过这不重要。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Solution {
public int shortestPath(List<UndirectedGraphNode> graph, UndirectedGraphNode A, UndirectedGraphNode B) {
if(A == B) return 0; // 特判
int n = graph.size(), dist = 0;
boolean[] visited = new boolean[n + 1];
Queue<UndirectedGraphNode> q = new ArrayDeque<>();
q.add(A);
visited[A.label] = true;
while(!q.isEmpty()){
dist++;
int size = q.size();
for(int i = 0; i < size; i++){
UndirectedGraphNode uNode = q.remove();
for(UndirectedGraphNode vNode : uNode.neighbors){
int v = vNode.label;
if(vNode == B) return dist;
else if(!visited[v]){
q.add(vNode);
visited[v] = true;
}
}
}
}
return -1;
}
}

带权单源最短路

当图为有权图时,我们将无法简单地通过层数增加来确定每一层顶点的距离,因为从源点若有多条路径可到达顶点 vv,更深的路径完全可以比更浅的路径具有更短的路径长 (路径上的边的权之和) 。本节我们将学习几种不同的 「带权单源最短路」 算法来求解经典的带权单源最短路问题 743. 网络延迟时间 。再次强调,图「有向」或「无向」,对于这些算法,区别仅在于建图时为双向边还是单向边,除非特别说明 (例如 DAG ),否则 最短路算法不区分有向图或是无向图


Dijkstra

一种基于贪心思想的求解 无负边图单源最短路径 的算法 (后续会说明为何不适用于负边图)。该算法可能是「最短路」算法家族中最知名的一个。Dijkstra 算法提出年代早,为贪心思想的极佳应用,另外,其在「20分钟」内被发明的故事也为人所乐道,具体可看相关 wiki 词条。

Dijkstra 在1956年构思出此算法,并于1959年发表的 A Note on Two Problems in Connexion with Graphs 论文中描述了该算法。

本节中,我将仍旧先给出「朴素版」,专注学习 Dijkastra 的过程,并给出严谨的正确性证明。随后,我们提出应用 「优先队列」 的改进版,并通过对这两个版本时间复杂度的分析,指出对于「稠密图」,应当使用「朴素版」,对于「稀疏图」,应当使用「优先队列版」。接着,我会以一个小例子直观地展示为何 Dijkstra 无法处理具有负边的图。


朴素版
算法描述

Dijkstra算法 (狄杰斯特拉): Dijkstra 算法的 基本操作 是将所有顶点区分为距离 已确定未确定 的顶点。算法开始前所有顶点的距离均未确定(一般置为InfinityInfinity),初始时置 ss 的距离为 0。以一个 whilewhile 循环查询当前 是否有距离未确定 的顶点,若有则将 其中距离最小者 uu 选为 当前顶点,并使其距离已知。然后以 bfsbfs 的方式松弛 uu 的邻接顶点 vv ,如之前所述,若要求返回完整路径,则可在此时更新 vv 的前驱为 uu。当不再有未确定距离的顶点时算法结束,此时每一个顶点的距离均最小,若需要返回源点到某顶点的完整路径,可通过不断寻找节点的前驱得到 ss 到该顶点的具体的最短路径。

松弛操作 (relax) 是 Dijkstra 算法的关键,也是后续其他最短路径算法的关键。「松弛」指的是在确定当前顶点 uu 的距离 (最新成为已确定距离的顶点) 后,立即尝试更新其邻接顶点 vv 的距离。更新条件为 du+(u,v)<dvdu + |(u,v)| < dv ,表示当前从源点经过 uu 到达 vv 的距离,要小于此时 vv 的距离,也就是发现了一条比当前源点到 vv 的路径距离更短的路径。以下图举例说明。

ss 经过若干个顶点 (用曲线表现经过多个顶点的路径) 到 aabbaabb 邻接 cc。假设此时 dv=Inifinitydv = Inifinityda=5da = 5db=7db = 7

  • 松弛顺序为先 aabb
    • aa 成为当前顶点时,由于 da+(a,v)=9<Infinityda + |(a, v)| = 9 < Infinity,故 aa 松弛 dvdv,(若需要,此时可将 vv 的前驱置为 aa )。
    • bb 成为当前顶点时,由于 db+(b,v)=8<9db + |(b, v)| = 8 < 9,故 bb 松弛 dvdv,(若需要,此时可将 vv 的前驱置为 bb )。
  • 松弛顺序为先 bbaa
    • bb 成为当前顶点时,由于 db+(b,v)=8<Infinitydb + |(b, v)| = 8 < Infinity,故 bb 松弛 dvdv,(若需要,此时可将 vv 的前驱置为 bb )。
    • aa 成为当前顶点时,由于 da+(a,v)=9>=8da + |(a, v)| = 9 >= 8,故 aa 无法松弛 dvdv

image.png

通过这个例子我们能够直观地观察到,来自 vv 的所有入边的松弛,使得 dvdv 总是有机会更新至所有可能的路径距离的最小值 (而无论先通过那一条入边来松弛)。后续讲解 Bellman-Ford 和 Floyd 算法时,我们还会继续强调松弛操作。另外,松弛这一动词的宾语,现实生活中一般是「松弛边」,不过算法里的松弛,实际上是更新一个点的距离,虽说这个距离是源点到该点的路径长,但毕竟是该点的一个属性。总之你也会看「松弛顶点」的表述,不用特意区分。

至此我们已能够理解如下内容。Dijkstra 算法分成 V|V| 个阶段,每个阶段确定当前距离最小者的顶点 vv 的距离 dvdv 为最短距离 (第一个阶段直接给出 ss 的距离为 0),V|V| 个阶段即可确定所有顶点的距离。一个阶段确定一个顶点距离正是该算法 「贪心」 的体现,其正确性在于,之后不可能通过除 vv 外的其他顶点来松弛 vv ,因为其他能够用来松弛它的顶点的距离都大于等于此时的 dvdv ,而那些距离未更新的 (仍为 InfinityInfinity ) 的顶点,在此后的松弛中,距离也一定是大于 dvdv 的,因为松弛它的必定是此前具有有效距离的顶点。总之,我们能够不很严谨地理解 Dijkstra 算法的正确性。如果读者诸君仍觉不放心,也不必担心,我将在「正确性证明」中,用结合「反证法」的「数学归纳法」,严格地证明 Dijkstra 算法的正确性。


算法过程
  1. 建图及初始化。

    1. 构建带权图。
    2. 设置一个大小为 V|V| 的用于标记顶点距离是否「已确定」的 booleanboolean 数组 visited[]visited[] ,下标表示顶点。
    3. 设置一个大小为 V|V| 的距离数组 dists[]dists[] ,下标表示顶点。初始化所有顶点到源点 ss 的距离为 InfinityInfinity ,表示该顶点到 ss 的距离尚未确定。
    4. ss 到其自身距离为 0。
  2. 以一个循环寻找 当前距离未确定顶点中距离最小者 uu ,立即置 uu 的距离为「已确定」。

  3. 松弛操作。尝试松弛 uu 的所有 距离未确定的 邻接顶点 vv 的距离 dvdv。即 dv=min{dv,du+(u,v)}dv = min\{dv, du + |(u,v)|\} 。若需要求完整路径,可在松弛的同时更新 vv 的前驱为 uu

  4. 循环结束时,每个顶点到源点的最短路径距离被求出。

设下图 v1v_1 为源点,应用 Dijkstra 算法求解的过程如下。

阶段 距离已确定 距离未确定 (括号内表示距离) 松弛
初始 v1()v_1(∞), v2()v_2(∞), v3()v_3(∞), v4()v_4(∞), v5()v_5(∞), v6()v_6(∞), v7()v_7(∞) v1(0)v_1(0)
1 v1(0)v_1(0) v2()v_2(∞), v3()v_3(∞), v4()v_4(∞), v5()v_5(∞), v6()v_6(∞), v7()v_7(∞) v2(2),v_2(2), v4(1)v_4(1)
2 v4(1)v_4(1) v2(2)v_2(2), v3()v_3(∞), v5()v_5(∞), v6()v_6(∞), v7()v_7(∞) v3(3)v_3(3), v5(3)v_5(3), v6(9)v_6(9), v7(5)v_7(5)
3 v2(2)v_2(2) v3(3)v_3(3), v5(3)v_5(3), v6(9)v_6(9), v7(5)v_7(5)
4 v3(3)v_3(3) v5(3)v_5(3), v6(9)v_6(9), v7(5)v_7(5) v6(8)v_6(8)
5 v5(3)v_5(3) v6(8)v_6(8), v7(5)v_7(5)
6 v7(5)v_7(5) v6(8)v_6(8) v6(5)v_6(5)
7 v6(5)v_6(5)

image.png


正确性证明

如下,利用数学归纳法 (结合反证法) 严格证明 Dijkstra 算法正确性。

本证明参考了这个帖子

a) 首先回顾数学归纳法的证明过程。

  1. 起始验证。对于命题 P(n)P(n),当 n=1n = 1 时命题 PP 成立。
  2. 假设命题成立。即假设命题 P(n)P(n)n=m(m>1,mN)n = m (m > 1, m ∈ N) 时成立。
  3. 递推证明。根据 2 的假设,若能证明 n=m+1n = m + 1 时命题 PP 成立,则命题得证。

例如,有命题 P1+2+3...+n=n(n+1)/2P:1+2+3...+n = n*(n+1)/2,按照数学归纳法证明如下:

  1. 起始验证。当 nn 等于 11 时,1=1(1+1)/21 = 1*(1+1)/2,命题成立。

  2. 假设命题成立。假设命题等于 mm 时成立,1+2+3+...+m=m(m+1)/21+2+3+...+m = m*(m+1)/2

  3. 递推证明。根据 2 的假设,如果能证明 n=m+1n = m+1 时命题正确,则命题 PP 成立。

    证明:在 2 所示式子左右两边加上 m+1m+1,得到 1+2+3+...+m+(m+1)=m(m+1)/2+(m+1)1+2+3+...+m+(m+1) = m*(m+1)/2 + (m+1)

    等号右边可以写成 (m+1)(m+2)/2(m+1)*(m+2)/2,显然该形式就是将 n=m+1n = m+1代入原命题 PP 的形式,证毕。

b) 利用数学归纳法证明如下命题。

命题 PP:Dijkstra 算法第 nn 次进入 whilewh ile 时,会将第 nn 个顶点加入距离已确定顶点集合 AA 中,此时对于顶点 vA(∀v ∈ A(nn 个),总有 dv=δvdv = δv

dvdv 表示由 Dijkstra 算法得到的最短距离估计 ( tentative shortest distance ),对于源点 ss ,在程序开始时赋予 ds=0ds = 0,对于其他顶点,由松弛操作得到。δvδv 表示实际的顶点 vv 到源点的最短距离。

  1. 起始验证。当 nn 等于 11 时,AA 集合中只有源点 ss 自身,ds=0ds = 0 (程序开始时赋值得到),且知 δv=0δv = 0,故 n=1n=1 时命题正确。

  2. 假设命题成立。假设命题 PPnn 等于 mm 时,P(m)P(m) 成立,即算法经过 mm 次while,得到具有 mm 个顶点的集合 AA,对于顶点 vA∀v ∈ A (共 mm 个),总有 dv=δvdv = δv

  3. P(m+1)P(m+1) 递推证明。根据 2 的假设,如果能证明第 m+1m+1 个顶点 uu 被放入集合 AA 时有 du=δudu = δu,则命题 PP 成立。

    更详细地,A=m|A| = m 时,在点集 B(B=SA)B (B = S - A) 中根据算法规则找到距离最短的顶点 uu,将该顶点将作为第 m+1m+1 个顶点放入 AA 中,放入后 A=m+1|A| = m + 1,如果能证明 du=δudu = δu,使得 P(m+1)P(m+1) 成立,则对于顶点 vA∀v ∈ A (共 m+1m+1 个),有 dv=δvdv = δv

    以反证法证明之。

    3.1 假设 m+1m+1du=δudu = δu 不成立,即有如下式(1), 之后的目标是根据已知条件导出某种矛盾情形,推翻该假设。

    (1) δu<duδu < du

    δuδu 是实际的 uu 到源点的最短距离,du=δudu = δu 不成立时只能是 δu<duδu < du。算法保证了从 ssuu 的过程一定是一条由图中的有向边构成的连续路径,只要是连续路径,无论有多少条这样的路径。一定有一条最短路径,其长度记作 δuδu

    3.2 根据3.1的假设,存在一条从源点 ssuu 的路径 PuPu,该路径是 ssuu 的最短路径,即 Pu=δu<du|Pu| = δu < du。路径 PuPu 一定有不在 AA 集内的顶点 (至少有 uu 不在 AA 集中),同时也有在 AA 集中的点 (至少有 ss 点在 AA 集中),可以假设 PuPu 经过 xxyy,其中 xxAA 中 (可以是 ss 本身),yyBB 中 (可以是 uu 本身),yyuu 的过程中也可以再进入 AA,如下图。PxPxPuPu 在顶点 xx 结束的子路径,因为路径 Px+(x,y)Px + (x, y) 为路径 PuPu 的一部分,所以有:

    (2) Px+(x,y)Pu=δu|Px| + |(x, y)| ≤ |Pu| = δu

    这是显然的,因为 Px+(x,y)Px + (x, y)PuPu 的一部分,当 y=uy=u 时取到等号。

    image.png

    3.3 在 xx 被选中进入 AA 集内时,对其邻接顶点 yy 执行过 松弛操作,该操作会比较 dx+(x,y)dx + |(x, y)| 是否小于 dydy,若小于则以 dx+(x,y)dx + |(x, y)| 更新 dydy 的值,所以如果更新了,更新之后有 dy=dx+(x,y)dy = dx + |(x, y)| ,如果没更新,说明 dy<dx+(x,y)dy < dx + |(x, y)|。假设之后 yy 还会被 yy 的其他前驱顶点更新 dydy 值 (当该前驱顶点进入 AA 集时),那 dydy 只会变得更小,所以一定有:

    (3) dydx+(x,y)dy ≤ dx + |(x, y)|

    比较式 (2) 和式 (3) 中的 Px|Px|dxdx,因为 dx=δxdx = δx (由步骤2的 P(m)P(m) 假设给出,顶点 xxP(m)P(m) 假设的 mm 个顶点之一),而 PxPx 只是若干从 ssxx 的路径之一,因此必有 d(x)Pxd(x) ≤ |Px|,当 PxPx 恰是 ssxx 的最短路径时取到等号。所以根据式 (2) 和式 (3) 有:

    dydx+(x,y)Px+(x,y)Pudy ≤ dx + |(x, y)| ≤ |Px| + |(x, y)| ≤ |Pu|,即

    (4) dyPu=δudy ≤ |Pu| = δu

    3.4 顶点 yyuu 均在 BB 集中,根据算法规则,uu 之所以是第 m+1m+1 个被放入 AA 集中的顶点,是因为第 m+1m+1 次进入while时,uuBB 集中相比于 BB 集中的其他顶点(自然也包括 yy ),到源点 ss 的距离最小,显然有:

    (5) dudydu ≤ dy

    结合式 (1),式 (4),式 (5) 得到:

    (6) δu<dudyPu=δuδu < du ≤ dy ≤ |Pu| = δu ,即 δu<δuδu < δu

至此,由 3.1 的假设 「d(u)=δ(u)d(u) = δ(u) 不成立」导出了矛盾,所以 d(u)=δ(u)d(u) = δ(u) 是成立的,Dijkstra 算法正确性得证


时空复杂度

时间复杂度:O(V2+E)O(|V|^2 + |E|),由于 E<V2|E| < |V|^2 ,所以也可以写为 O(V2)O(|V|^2)

  1. 寻找拥有最小距离的顶点的时间为 O(V2)O(|V|^2) 。 每次遍历寻找时间复杂度为 O(V)O(|V|) ,需寻找 V|V| 次 。

  2. 所有顶点的距离被松弛的次数上限为 O(E)O(|E|) 。由算法可知顶点距离松弛只发生在找到当前距离未确定且距离最小的顶点之后,一次更新的次数是该顶点的邻接顶点数 (即出边数,出度)。每一次循环松弛某一个顶点的所有出边。所有顶点的出边数即总边数 E|E| 。故总松弛次数,也即所有顶点的距离被更新的次数时间复杂度为 O(E)O(|E|)

空间复杂度:存图空间 O(V+E)O(|V|+|E|)visited/distsvisited/dists 空间 O(V)O(|V|)。总体为线性空间复杂度 O(V+E)O(|V|+|E|)


代码

现在,我们利用上述知识来实际编程解决 743. 网络延迟时间 。仔细读题后可知,从某个节点 kk 发出一个信号,要使所有节点都收到信号,只要使距离 kk 最远的那个顶点收到信号即可。所以这题是典型的带权单源最短路问题,应用本节的朴素版 Dijkstra 算法,以顶点 kk 为源点,求出其他所有顶点到 kk 的最短路长度,返回其中最大者即可。需要注意以下几点。

  • 构建带权图时,需要记录边权,因此顶点的邻接表以 List<int[]> 表示,对于顶点 uu ,其邻接表中的 int[] v_weightv_weight[0]uu 的邻接顶点, v_weight[1] 为边权 (u,v)|(u, v)|
  • 为了清晰地看出「在当前距离未确定的顶点中找到距离最小者」这一「贪心」动作,我们将其写为 getMingetMin 方法。
  • 当存在与源点不连通的顶点时,该顶点的距离将得不到松弛,因此存在未松弛顶点时返回 -1。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Solution {
public int networkDelayTime(int[][] times, int n, int k){
List<List<int[]>> graph = new ArrayList<>();
for(int i = 0; i < n; i++) graph.add(new ArrayList<>());
for(int[] edge : times){ // 建带权图
int u = edge[0] - 1, v = edge[1] - 1, weight = edge[2];
graph.get(u).add(new int[]{v, weight});
}
int max = 0, INF = Integer.MAX_VALUE; // max为距离源点最远顶点的距离,INF为所有顶点距离初始值
int[] dists = new int[n];
boolean[] visited = new boolean[n];
Arrays.fill(dists, INF); // 距离初始化
dists[k - 1] = 0; // 源点距离置0
int u = -1; // 当前距离未确定顶点中的距离最小者
while((u = getMin(dists, visited)) != -1) { // 遍历顶点,寻找当前距离未确定顶点中的距离最小者
visited[u] = true; // 立即置为距离已确定
for (int[] v_weight : graph.get(u)) {
int v = v_weight[0], weight = v_weight[1];
if(!visited[v]) { // 对于u的距离未确定的邻接顶点v,松弛v的距离
int dv = dists[u] + weight;
if(dv < dists[v]) { // 松弛条件
dists[v] = dv; // 更新dv
}
}
}
}
for(int dist : dists){ // 找最短路长度最大者
if(dist == INF) return -1; // 存在距离未更新的顶点直接返回 -1
if(dist > max) max = dist;
}
return max;
}
private int getMin(int[] dists, boolean[] visited){ // 在当前距离未确定的顶点中找距离最小者
int n = dists.length, min = Integer.MAX_VALUE, minVertex = -1;
for (int u = 0; u < n; u++) {
if(!visited[u] && dists[u] < min) {
min = dists[u];
minVertex = u;
}
}
return minVertex;
}
}

优先队列版
算法描述

针对朴素版「算法过程」第 2 步「在当前距离未确定顶点中寻找距离最小者」,我们很容易想到利用优先队列 (小顶堆) pq(priority_queue)pq(priority\_queue) 来优化,除此之外其他过程与朴素版一致。需要注意的是一个顶点 vv 的距离在被确定前可能经过多次松弛,每次松弛都会进入 pqpq ,于是同一时间,堆中可能有多个相同的顶点 (松弛过几次就有几个) 。这其中最靠顶的将会先出堆,出堆即表明该顶点距离已确定,所以顶点出堆时要判断是否是第一次出堆,若不是则跳过。


算法过程
  1. 建图及初始化。
    1. 构建带权图。
    2. 设置一个小顶堆 pqpq
    3. 设置一个大小为 V|V| 的用于标记顶点距离是否「已确定」的 booleanboolean 数组 visited[]visited[] ,下标表示顶点。
    4. 设置一个大小为 V|V| 的距离数组 dists[]dists[] ,下标表示顶点。初始化所有顶点到源点 ss 的距离为 InfinityInfinity ,表示该顶点到 ss 的距离尚未确定。
    5. ss 到其自身距离为 0。
    6. ss 入堆。
  2. 一次出堆完成一个顶点最短路径的确定。以 whilewhile 循环对 pqpq 判空,若不空,堆顶顶点 uu 出堆,此时 uu 为距离未确定顶点中距离最小者,置 uu 的距离为已确定。
  3. 松弛操作。尝试松弛 uu 的所有 距离未确定的 邻接顶点 vv 的距离 dvdv。即 dv=min{dv,du+(u,v)}dv = min\{dv, du + |(u,v)|\} 。若需要求完整路径,可在松弛的同时更新 vv 的前驱为 uu
  4. 循环结束时,每个顶点到源点的最短路径距离被求出。

时空复杂度

时间复杂度:O(ElogE+ElogE)O(|E|log|E|+|E|log|E|) ,化简为 O(ElogE)O(|E|log|E|) ,可进一步化简为 O(ElogV)O(|E|log|V|) ,具体如下。

  1. 在当前距离未确定顶点中寻找距离最小者的总时间复杂度O(ElogE)O(|E|log|E|)

    1. whilewhilepqpq 判空次数与堆大小有关,堆的大小为 O(E)O(|E|) ,注意不是 O(V)O(|V|) 而是 O(E)O(|E|),后述。
    2. 获取距离最小者耗时为堆的查找时间复杂度, O(logE)O(log|E|)
    3. 故此部分时间复杂度为 O(ElogE)O(|E|log|E|)

    ※ 优先队列判空操作 isEmpty()isEmpty() 本身是常数时间操作,但总共要执行 O(E)O(|E|) 次。

  2. 考虑所有顶点的距离被更新 (松弛) 导致的 总的顶点入堆时间复杂度O(Elog(E))O(|E|log(|E|))

    1. 由算法可知顶点 uu 松弛其邻接顶点 vv 的距离只发生在找到 uu 为当前距离未确定且距离最小的顶点之后 ( uu 出堆后)。(尝试) 松弛的次数是其邻接顶点 vv 的个数 (即 uu 的出边数,出度)。每一次循环确定一个这样的顶点,也就是每一次循环尝试松弛 当前距离被确定的顶点 的所有出边。所有顶点的出边即总边数为 E|E| 。故总松弛次数,也即所有顶点的距离被更新的次数,也即顶点入堆总次数为 O(E)O(|E|)
    2. dvdv 被松弛时 vv 入堆,插入操作的时间复杂度为 O(logE)O(log|E|)。由上述,入堆次数与更新次数相同,于是 所有顶点的距离更新 以及该更新导致的 顶点入堆的总时间复杂度O(E+ElogE)O(|E| + |E|log|E|)

    ※ 根据上述顶点入堆次数取决于总边数的分析,堆的大小上限不是 O(V)O(|V|) 而是 O(E)O(|E|) ,所以 dvdv 更新时 vv 入堆的时间复杂度为 O(logE)O(log|E|) ,只是借助边数与顶点数的关系,可化简为 O(logV)O(log|V|)E<V2|E| < |V|^2 ,即有 logE<2logVlog|E| < 2log|V|,可化简为 O(logV)O(log|V|)

空间复杂度:存图空间 O(V+E)O(|V|+|E|)visited/distsvisited/dists 空间 O(V)O(|V|)pqpq 空间 O(E)O(|E|) 。总体为线性空间复杂度 O(V+E)O(|V|+|E|)

※ tip1: 连通图顶点数与边数有如下关系。

无向连通图:V1<=E<=V(V1)/2|V| - 1 <= |E| <= |V|*(|V| - 1) / 2

链状时 E|E| 取到最小 V1|V|-1 ,完全连通即两两相连时取到最大 V(V1)/2|V|*(|V| - 1) / 2

有向连通图:V1<=E<=V(V1)|V| - 1 <= |E| <= |V|*(|V| - 1)

链状时 E|E| 取到最小 V1|V|-1 ,完全连通即两两相连且正反向成对时取到最大 V(V1)|V|*(|V| - 1)

※ 利用 Fibonacci堆的 Dijkstra 算法时间复杂度为 O(E+VlogV)O(|E|+|V|log|V|) ,本文不做介绍 (我不会)。

对比「朴素版」的时间复杂度,不难看出,当图为 「稠密图」 时,「优先队列版」复杂度可表为 O(V2logV)O(|V|^2log|V|) ,这是比「朴素版」更差的复杂度。因此对于稠密图,我们可采用更优的「朴素版」。当图为 「稀疏图」 时,「优先队列版」复杂度可表为 O(VlogV)O(|V|log|V|) ,显然此时应采用「优先队列版」。


代码

现在,我们给出如下利用优先队列的 Dijkstra 算法代码来解决 743. 网络延迟时间 。其与朴素版的差别仅在于每个「阶段」寻找当前距离未确定顶点中距离最小者的方式不同。该写法为 Dijkstra 算法写法的模版写法,读者应 牢记于心

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Dijkstra优先队列 (邻接表存图)
class Solution {
public int networkDelayTime(int[][] times, int n, int k){
List<List<int[]>> graph = new ArrayList<>();
for(int i = 0; i < n; i++) graph.add(new ArrayList<>());
for(int[] edge : times){ // 建带权图
int u = edge[0] - 1, v = edge[1] - 1, weight = edge[2];
graph.get(u).add(new int[]{v, weight});
}
int max = 0, INF = Integer.MAX_VALUE; // max为距离源点最远顶点的距离,INF为所有顶点距离初始值
int[] dists = new int[n];
boolean[] visited = new boolean[n];
Arrays.fill(dists, INF); // 距离初始化
dists[k - 1] = 0; // 源点距离置0
PriorityQueue<int[]> pq = new PriorityQueue<>(((u, v) -> u[1] - v[1])); // 小顶堆
pq.add(new int[]{k - 1, 0}); // 源点入堆
while(!pq.isEmpty()) {
int[] u_dist = pq.remove();
int u = u_dist[0];
visited[u] = true; // 立即置u的距离为已确定
for (int[] v_weight : graph.get(u)) {
int v = v_weight[0], weight = v_weight[1];
if(!visited[v]) { // 对于u的距离未确定的邻接顶点v,松弛v的距离
int dv = dists[u] + weight;
if(dv < dists[v]) { // 松弛条件
dists[v] = dv; // 更新dv
pq.add(new int[]{v, dv}); // v入堆
}
}
}
}
for(int dist : dists){ // 找最短路长度最大者
if(dist == INF) return -1; // 存在距离未更新的顶点
if(dist > max) max = dist;
}
return max;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Dijkstra优先队列 (链式向前星存图)
class Solution {
public int networkDelayTime(int[][] times, int n, int k){
int m = times.length, edgeNum = 0;
int[] heads = new int[n];
int[] ends = new int[m + 1], nexts = new int[m + 1], weights = new int[m + 1];
for(int[] edge : times){ // 建带权图
int u = edge[0] - 1, v = edge[1] - 1, weight = edge[2];
edgeNum++;
weights[edgeNum] = weight;
ends[edgeNum] = v;
nexts[edgeNum] = heads[u];
heads[u] = edgeNum;
}
int max = 0, INF = Integer.MAX_VALUE; // max为距离源点最远顶点的距离,INF为所有顶点距离初始值
int[] dists = new int[n];
boolean[] visited = new boolean[n];
Arrays.fill(dists, INF); // 距离初始化
dists[k - 1] = 0; // 源点距离置0
PriorityQueue<int[]> pq = new PriorityQueue<>(((u, v) -> u[1] - v[1])); // 小顶堆
pq.add(new int[]{k - 1, 0}); // 源点入堆
while(!pq.isEmpty()) {
int u = pq.remove()[0];
if(visited[u]) continue;
visited[u] = true; // 立即置u的距离为已确定
for (int edgeNo = heads[u]; edgeNo != 0; edgeNo = nexts[edgeNo]) {
int v = ends[edgeNo], weight = weights[edgeNo];
if(!visited[v]) { // 对于u的距离未确定的邻接顶点v,松弛v的距离
int dv = dists[u] + weight;
if(dv < dists[v]) { // 松弛条件
dists[v] = dv; // 更新dv
pq.add(new int[]{v, dv}); // v入堆
}
}
}
}
for(int dist : dists){ // 找最短路长度最大者
if(dist == INF) return -1; // 存在距离未更新的顶点
if(dist > max) max = dist;
}
return max;
}
}

负边图

以下图为例说明 Dijkstra 算法为何无法处理负边图。

顶点 ss 是源点,从 ss 开始执行算法,dududvdv 被更新为 1, 2,在距离未确定的顶点 uuvvdudu 更小,uu 的距离设为已确定,du=1du = 1 。然而实际上从 ssvv 再到 uu 会得到一条长度为 -1 的更短的路径。

由此我们也可以看到,Dijkstra 「贪心」能够成立的一个隐含前提是路径边数的增长 必须 使得路径长 (边权和) 是 「非递减」 的。存在负边则导致路径边数增长后边权和递减,使得「贪心」成立的前提不成立。

image.png


DAG SSSP

算法描述

若最短路的求解对象是 DAG ,那么根据其无圈的特点,可以采用「拓扑排序」的方式求单源最短路,使得时间复杂度仅为线性的 O(V+E)O(|V|+|E|)

有向无圈图 一定存在拓扑排序,从一个入度为 0 的源点 ss 开始以拓扑排序方式实现 Dijkstra 算法。具体而言,准备一个普通队列 qq ,首先将 ss 入队,然后开始 while(!q.isEmpty())while(!q.isEmpty()) 。每次将入度为 0 的顶点 uu 出队,由于其已无入边,故该顶点距离 dudu 不会再被更新,此时 dudu 即已为确定的最短距离。标准的 Dijkstra 算法总是通过这样的操作来确定一个顶点的距离,即「在当前距离未确定的顶点中寻找距离最小者,并置其距离为已确定」,所以也可以将 DAG 情形的算法看作是省去了上述关键操作的 Dijkstra 算法,也就是顶点 uu 入队 时其距离即确定。


有负边的 DAG

DAG版的 Dijkstra 算法 可应用于有负边的DAG。 因为对任意顶点 uu ,仅在其入度减为 0 时其距离才被确定,而此时来自其入边的松弛一定都已执行过,因此 uu 是否存在负权值的入边,dudu 都可以被正确松弛到最短。以下图为例,从 ss 开始执行算法,ss 出队,dududvdv 被更新为 1 和 2 ,然后 u,vu, v 入度减为 1, 0,vv 无入边,其距离不会再被更新,即 dv=2dv = 2 即为 vv 的最终距离,vv 入队。接着 vv 出队,dudu 被更新为 -1 ,uu 的入度减为 0,此时 uu 无入边,其距离不会再被更新,即其距离被最终确定为 du=1du = -1 。可以看到 uu 的距离 在其入度减至 0 的过程中总会更新至最短

image.png


贪心 & 动态规划

这个小部分是我的一点思考🤔,与读者们探讨一下 Dijkstra 和 DAG 拓扑排序式的 SSSP 算法所分别体现的「贪心」和「动态规划」思想。

我们已经知道,无论是一般的 Dijkstra 还是本节的 DAG 上的 SSSP 算法,在求顶点 vv 的距离时,本质上都是通过这样的「动态规划」式的递推式求解的, (u,v)(u, v) 是所有 vv 的入边。

dv=min{dv,du+(u,v)},(u,v)Edv = min\{dv, du + |(u, v)|\}, (u, v) ∈ E

主要区别在于,在 Dijkstra 中,我们不必 「全部算完」 所有入边带来的松弛,而是在 dvdv 为当前未确定距离顶点中最小时,就「提前」确定了距离。因此它既是「动态规划」,又因为这个「提前」确定的特点,更多地被人强调为「贪心」。而本节 DAG 的 SSSP 算法,一定会计算所有入边的松弛,因此他更具有典型的「打表」式的「动态规划」特点。这大概是 Dijkstra 总与「贪心」相联系而不太被人指出其「动态规划」的特点的原因?


算法过程
  1. 建图及初始化。
    1. 设置一个大小为 V|V| 的入度数组 indegrees[]indegrees[] ,下标表示顶点。
    2. 构建带权图并同时计算入度。
    3. 设置一个队列 qq
    4. 设置一个大小为 V|V| 的距离数组 dists[]dists[] ,下标表示顶点。初始化所有顶点到源点 ss 的距离为 InfinityInfinity ,表示该顶点到 ss 的距离尚未确定。
    5. ss 到其自身距离为 0。
    6. ss 入队。
  2. 通过 while(!q.isEmpty())while(!q.isEmpty()) 不断将当前入度为 0 的顶点出队,出队时置其距离已确定。
  3. 松弛操作。尝试松弛 uu 的所有 距离未确定的 邻接顶点 vv 的距离 dvdv。即 dv=min{dv,du+(u,v)}dv = min\{dv, du + |(u,v)|\} 。若需要求完整路径,可在松弛的同时更新 vv 的前驱为 uu
  4. 将上述 vv 的入度减 1,并考察是否减至 0,若减至 0,则 vv 的距离在此时确定, vv 入队。
  5. 循环结束时,每个顶点到源点的最短路径距离被求出。

时空复杂度

时间复杂度:即拓扑排序的时间复杂度,为线性时间复杂度 O(V+E)O(|V|+|E|)

空间复杂度:存图空间 O(V+E)O(|V|+|E|)visited/distsvisited/dists 空间 O(V)O(|V|)qq 空间 O(V)O(|V|) 。总体为线性空间复杂度 O(V+E)O(|V|+|E|)


代码

如下是假设 743. 网络延迟时间 的输入为无圈图的前提下的代码。但实际部分测试用例是有圈的,测试了一下只能过 15 个用例,然后就碰到有圈图了。等找到合适的输入限制为 DAG 图的题之后再更新。总之,DAG 上「拓扑排序」式的 SSSP 算法,或者干脆称 DAG 版的 Dijkstra 算法的写法如下。如果我们确定输入为 DAG,则应该优先考虑此版本算法, 以获得线性时间复杂度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Solution {
public int networkDelayTime(int[][] times, int n, int k){
List<List<int[]>> graph = new ArrayList<>();
int[] indegrees = new int[n]; // 入度信息
for(int i = 0; i < n; i++) graph.add(new ArrayList<>());
for(int[] edge : times){ // 建带权图 & 计算入度
int u = edge[0] - 1, v = edge[1] - 1, weight = edge[2];
graph.get(u).add(new int[]{v, weight});
indegrees[v]++;
}
int max = 0, INF = Integer.MAX_VALUE; // max为距离源点最远顶点的距离,INF为所有顶点距离初始值
int[] dists = new int[n];
Arrays.fill(dists, INF); // 距离初始化
dists[k - 1] = 0; // 源点距离置0
Queue<Integer> q = new ArrayDeque<>();
q.add(k - 1); // 源点入队
while(!q.isEmpty()) { // 拓扑排序过程
int u = q.remove();
for (int[] v_weight : graph.get(u)) {
int v = v_weight[0], weight = v_weight[1];
int dv = dists[u] + weight;
if(dv < dists[v]) { // 松弛条件
dists[v] = dv; // 更新dv
}
indegrees[v]--; // v入度减1
if(indegrees[v] == 0) q.add(v); // v入队
}
}
for(int dist : dists){ // 找最短路长度最大者
if(dist == INF) return -1; // 存在距离未更新的顶点
if(dist > max) max = dist;
}
return max;
}
}

Bellman-Ford

之前我们指出,Dijkstra 算法因其「贪心」的特点,在还未穷尽顶点所有入边的松弛时就「过早地」确定了该顶点的距离,导致其无法处理具有负边的图。与之相对地, DAG 最短路算法中,通过「入度」信息, 确保了任意顶点一定能够穷尽所有入边的松弛 ,因此可以适用于有负边的图 (DAG) ,但为了确保入度能减至 0 ,要求图不能有圈。我们自然会想,有没有什么办法能够结合二者的优点,使得最短路算法能够同时处理有圈且有负边的图呢?答案是肯定的,只要我们通过某种方式,在不借助入度的情况下保证所有顶点都能够执行其所有入边的松弛,就可以实现上述要求。本节介绍的 Bellman-Ford 算法就是具有这样特点的最短路算法。

本节中我会给出叙述式的 Bellman-Ford 算法正确性证明 (说明),并展示其「动态规划」的本质。为了能够更好地理解算法过程,在「实例说明」中展示算法运行的详细过程。在「负边图 & 负圈图」一节中说明该算法能够处理负边图以及不能够处理负圈图的原因。

在「代码」中,我们仍旧通过 743. 网络延迟时间 一题来展示 Bellman-Ford 的写法。

Bellman-Ford 在1950年代中后期被多个学者独立发明,以至于在算法冠名权上有些争议。Jeff Erickson 在他的 Algorithms 一书的 8.7 节开头,做了如下介绍。

The simplest implementation of Ford’s generic shortest-path algorithm was first sketched by Alfonso Shimbel in 1954, described in more detail by Edward Moore in 1957, and independently rediscovered by Max Woodbury and George Dantzig in 1957, by Richard Bellman in 1958, and by George Minty in 1958. (Neither Woodbury and Dantzig nor Minty published their algorithms.) In full compliance with Stigler’s Law, the algorithm is almost universally known as Bellman-Ford, because Bellman explicitly used Ford’s 1956 formulation of relaxing edges, although some authors refer to “Bellman-Kalaba” and a few early sources refer to “Bellman-Shimbel”.

于是 Jeff Erickson 干脆十分公允地写道:

The Shimbel / Moore / Woodbury-Dantzig / Bellman-Ford / Kalaba / Minty / Brosh algorithm can be summarized in one line:

Bellman-Ford: Relax ALL the tense edges, then recurse.


算法描述

Bellman-Ford Algorithm(贝尔曼-福特算法): 与 Dijkstra 算法的相同点是对边 (或者说对顶点) 不断执行松弛操作 ,以逐渐得到所有顶点到源点的最短距离。Dijkstra 每次循环「贪心地」完成 一个 顶点最短路径的确定,而 BF 算法则对图的 所有边 ( E|E| 条边) ,简单地进行 V1|V|-1全量松弛操作 ,第 ii 次「全量松弛」使得位于第 i+1i+1 层的顶点的距离被确定。从一次全量松弛确定一层顶点距离这个角度上来说, BF 算法也是「贪心」思想的应用。任意顶点最多居于第 V|V| 层 (以源点 ss 为第 1 层),因此算法结束时,保证 (无负圈图) 所有顶点距离最短。

※ 实际上任何最短路算法都无法求出「负圈图」的最短路,因为通过在负圈上不断绕圈,路径长度可以无限小,也就是 负圈上的顶点不存在「最短路」 。后面我们将看到,虽无法给出负圈图的最短路结果 (因为本来就没有),但 BF 算法能够判断图是否存在负圈

※ 作者看过一些资料称全量松弛次数为 V|V| 次,这是不够严谨的,只需 V1|V| - 1 次即可。


算法过程
  1. 建图及初始化。
    1. 构建带权图。
    2. 设置一个大小为 V|V| 的距离数组 dists[]dists[] ,下标表示顶点。初始化所有顶点到源点 ss 的距离为 InfinityInfinity ,表示该顶点到 ss 的距离尚未确定。
    3. 设置一个 finishedfinished 布尔变量用于实现「提前结束优化」(不是必须的)。
    4. ss 到其自身距离为 0。
  2. 外层循环执行 V1|V|-1 次,每一次都 「松弛所有边」
    1. 进入外层循环后按顶点顺序依次对所有顶点 uu 考察其所有邻接顶点 vv 是否有 du+(u,v)<dvdu + |(u, v)| < dv,若有,松弛之,即令 dv=du+(u,v)dv = du + |(u, v)|。同时置 finished=falsefinished = false ,表示松弛未结束。若需要求完整路径,可在松弛的同时更新 vv 的前驱为 uu

    2. 在一次「松弛所有边」的操作中,若没有任何边被松弛,表明所有可能的松弛已完成 (负圈图除外), 此时可 「提前」退出最外层循环

  3. 外层循环结束时,(若无负圈) 每个顶点到源点的最短路径距离被求出。
  4. 检查图是否有负圈。再次对所有边执行松弛操作, 若有边可被松弛,则有负圈 ,结束程序,否则正常结束,所有顶点最短路径被求出。

正确性证明 (说明)
  1. 已知若一个顶点 vv 的所有入边若完成了所有可能的松弛 (一条入边可被多次松弛),则在 最后一次松弛 后,必有 dv=δvdv=δv 。 (dvdv 表示由算法得到的 vv 的距离, δvδv 表示实际的 vv 的最短距离)。

  2. 易知,第 ii 次全量松弛,第 ii 层顶点的出边 必被松弛 。例如第 1 次全量松弛,ss (第 1 层顶点) 的出边必被松弛。第 2 次全量松弛,由于有了第 1 次全量松弛的结果,第 2 层顶点 (ss 的邻接顶点) 的出边必被松弛。

  3. edges(s,v)|edges(s, v)| 表示 ssvv 的路径的边数,最多为 V1|V|-1 ,当 ssvv 为链状时为 V1|V|-1 。当 vv 的入边是第 1 层入边时,将在第 1 次全量松弛时被松弛,若是第 2 层入边,则会在第 2 次全量松弛时被松弛 (这条入边的发出顶点在第 1 次全量松弛时已被松弛),以此类推,第 ii 层入边会在第 ii 次全量松弛时被松弛。所有顶点的入边组成了该图的所有边,任意一边一定是某一层次的入边 (可以同时属于多个层次) 。

  4. 由此,vv 的入边能否被全部松弛只取决于其最深的入边能否被松弛 (当一条入边属于多个层次时,取其最深层次),也即取决于 ssvv 的最长路径 max{edges(s,v),vV}\max\{|edges(s, v)|, v ∈ V\} 的边数。如前述, max{edges(s,v),vV}V1\max\{|edges(s, v)|, v ∈ V\} ≤ |V|-1 ,故至多经过 V1|V| - 1 次全量松弛, 图的所有入边必定都松弛过且完成了所有可能的松弛 (某条入边属于多个层次时,可能经过多次松弛) 。如果把图看成以 ss 为根的树,也可以说为了求得所有顶点的最短路径,所需的全量松弛的次数取决于 树的高度 。对某一顶点,可以说该顶点至多经过其 最大深度减 1 次 全量松弛后取得最短路径。

  5. 上述过程对任意顶点均成立,故 BF 算法正确性得证。

此证明也可以看作如下「动态规划」过程。为单串 O(1)O(1) 依赖动态规划 (「 O(1)O(1) 依赖」指当前元素的更新只依赖常数个元素)。

  1. 定义: dp[j]dp[j] 表示源点 ss 到顶点 jj 的最短路径长度。

  2. 边界: dp[s]=0dp[s] = 0

  3. 递推: dp[j]=dp[j1]+(j1,j)dp[j] = dp[j - 1] + |(j - 1, j)|j1j - 1 表示 sjs-j 最短路径上的前一个顶点。

根据前述,j1j - 1 顶点最大层深必比 jj 小 1,故 dp[j1]dp[j - 1] 一定在求 dp[j]dp[j] 之前就已求出,递推式成立。


实例说明

以下实际考察 BF 算法对下图的求解过程,重点关注顶点 vv 。 (图裂了的话可以看这里)

image.png

p(s,v)p(s, v) 有如下四种可能:

p1:s>vp1=15p1: s > v ,|p1| = 15(s,v)(s, v)vv 的第 1 层入边

p2:s>a>vp2=14p2: s > a > v,|p2| = 14(a,v)(a, v)vv 的第 2 层入边

p3:s>b>a>vp3=12p3: s > b > a > v ,|p3| = 12(a,v)(a, v)vv 的第 3 层入边

p4:s>b>c>vp4=6p4: s > b > c > v ,|p4| = 6(c,v)(c, v)vv 的第 3 层入边

可以看到 (a,v)(a, v) 边同时属于 vv 的第 2 和第 3 层入边。这四条路径最长者长 3 (指边数,p3p3p4p4 ),根据上述分析,只需要执行 3 次全量松弛即可完成对 vv 最短路径的确定。又因为源点到所有顶点的所有路径中最长的长度也是 3,所以执行 3 次全量松弛可以确定所有顶点的最短路径。假设全量松弛时顶点的处理顺序为 s,a,c,b,vs, a, c, b, v ,下表展示算法对该图的运行过程 (第 3 次之后的全量松弛不会再松弛任何边,省略)。

松弛过程 dsds dada dcdc dbdb dvdv
初始 0 (*)
第1次全量松弛 0 ∞ > 9 ∞ > 3 (*) ∞ > 15
第2次全量松弛 0 9 > 7 (*) ∞ > 5 (*) 3 15 > 14
第3次全量松弛 0 7 5 3 14 > 12 > 6 (*)
1
2
3
4
5
6
7
8
备注:
1. 初始时令 ds 为0,(*)表示已取得该顶点最短路径。
2. 第1次。(s, a), (s,b), (s, v)属于第1层入边,被松弛。
且b只有一条入边,即经过这趟松弛操作,使得 db = δb。
3. 第2次。第1层入边不会再被松弛。第2层入边(a, v), (b, a), (b, c)被松弛。
经过这趟松弛操作,a, c的全部入边松弛完毕,使得 da = δa,dc = δc。
4. 第3次。第1,2层入边不会再被松弛,第3层入边(a, v), (c, v)被松弛。
v 的全部入边松弛完毕,使得 dv = δv。

负边图 & 负圈图
  • 负边图。由于该算法在 V1|V|-1 次对所有边的松弛操作中会穷尽所有边被松弛的可能,类似以拓扑排序方式针对 DAG 图的 Dijkstra 算法 (通过入度为 0 保证穷尽所有松弛的可能),所以也适用于有负边的图。

  • 负圈图。当图存在负圈时,ss 到圈上任意顶点的距离都可以通过不断绕圈趋于无限小。因此若不能保证输入的图无负圈,可以在 V1|V| - 1 次全量松弛后再执行一次全量松弛,若仍有边可被松弛,说明存在负圈。


提前结束优化

当某一次全量松弛过程中没有边被松弛,说明所有可能的松弛已被穷尽,可提前结束程序。


最坏情形

当图中存在两点间路径长度为 V1|V|-1 ,且在最后一次「全量松弛」时仍有边被松弛时间达到最坏情形。此情况下,需要对所有边执行 V1|V|-1 次松弛后才能求得所有顶点的最短路径。对于一链状图 s>a>b>c>vs > a > b > c > v ,其中 (s,a)=1|(s, a)| = 1(a,b)=2|(a, b)| = 2(b,c)=3|(b, c)| = 3(c,v)=4|(c, v)| = 4。 (图裂了的话可以看这里)

image.png


时空复杂度

时间复杂度:每次全量松弛要操作 E|E| 条边,共 V1|V|-1 次,复杂度为 O((V1)E)O((|V|-1)|E|) ,即 O(VE)O(|V||E|)

空间复杂度:存图空间 O(V+E)O(|V|+|E|)distsdists 空间 O(V)O(|V|)。总体为线性空间复杂度 O(V+E)O(|V|+|E|)


代码

如下是 743. 网络延迟时间 的 Bellman-Ford 解法。代码中设置了 finishedfinished 布尔变量实现「提前结束优化」,并在通过在外层 forfor 结束后再执行一次全量松弛实现「负圈检测」,由于本题已保证了不存在负值边,也就不存在负圈,因此负圈检测可以省略。

此代码是 Bellman-Ford 算法的较为普遍的写法 (提前结束优化 + 负圈检测),读者应熟练掌握。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 提前退出优化 Bellman-Ford ,带「负圈检测」
class Solution {
public int networkDelayTime(int[][] times, int n, int k){
List<List<int[]>> graph = new ArrayList<>();
for(int i = 0; i < n; i++) graph.add(new ArrayList<>());
for(int[] edge : times){ // 建带权图
int u = edge[0] - 1, v = edge[1] - 1, weight = edge[2];
graph.get(u).add(new int[]{v, weight});
}
int max = 0, INF = Integer.MAX_VALUE; // max为距离源点最远顶点的距离,INF为所有顶点距离初始值
int[] dists = new int[n];
Arrays.fill(dists, INF); // 距离初始化
dists[k - 1] = 0; // 源点距离置0
boolean finished = true;
for(int i = 0; i < n - 1; i++) { // |V| - 1次
for (int u = 0; u < n; u++) { // 全量松弛
for(int[] v_weight : graph.get(u)){
int v = v_weight[0], weight = v_weight[1];
long dv = (long) dists[u] + (long) weight; // 等号右边可能会溢出,临时转为long
if(dv < dists[v]) { // 松弛条件
dists[v] = (int) dv; // 更新dv
finished = false;
}
}
}
if(finished) break; // 某一次全量松弛未松弛任何边时,提前结束
else finished = true; // 否则finished置回true
}
// 负圈检测
for (int u = 0; u < n; u++) { // 执行一次全量松弛
for(int[] v_weight : graph.get(u)){
int v = v_weight[0], weight = v_weight[1];
long dv = (long) dists[u] + (long) weight; // 等号右边可能会溢出,临时转为long
if(dv < dists[v]) { // 发现仍能松弛,表明存在负圈
System.out.println("Negtive Cycle Found!");
}
}
}
for(int dist : dists){ // 找最短路长度最大者
if(dist == INF) return -1; // 存在距离未更新的顶点
if(dist > max) max = dist;
}
return max;
}
}

SPFA (BFM)

学习 Bellman-Ford 时我们隐隐感觉到「全量松弛」做了许多无意义的松弛尝试,自然地,我们想能否优化松弛次数,在保证穷尽顶点所有入边松弛这一前提下,尽量少地松弛呢?或者干脆说,我们希望所有的松弛,都是有效松弛,也就是顶点所有入边的松弛是无遗漏且不重复的。本节中,我们将看到 SPFA (BFM) 算法在 BF 的基础上是如何借助队列轻松地实现这一改进的。

BFM 即 Bellman-Ford-Moore,这一改进由 Edward F. Moore 他于1959 年发表的 The shortest path through a maze 论文中提出。1994年,西南交通大学的段凡丁在该年4月的《西南交通大学学报》里发表了题为关于最短路径的SPFA快速算法 的论文,重新提出了Moore的改进 ,并且给了个比较通俗的名字 Shortest Path Fast Algorithm。


算法描述

SPFA算法(最短路径快速算法):SPFA 算法是对 BF 算法的一种改进。在BF 算法的说明中我们指出,第 ii 次「全量松弛」操作,只有第 i+1i+1 层的顶点距离会被更新至最短,也就是说每次全量松弛中,有效的松弛都是「一层」顶点,这明显地具有 bfsbfs 特点,因此可以考虑不通过「全量松弛」来松弛第 i+1i+1 层顶点,而是以 bfsbfs 的方式,借助队列 qq ,每松弛一层顶点,将它们入队,出队时,尝试松弛到其所有邻接顶点的距离,即可在第 ii 层顶点出队时松弛第 i+1i+1 顶点并使这些第 i+1i+1 顶点距离取得最小。因为顶点「按层」入队出队,层深最大为 V|V| (无负圈图),因此算法可以结束。

我们还可以这样看,一个顶点 vv 的距离能够被更新,隐含着这样一个 前提vv 的前驱 uu 的距离被更新过 。因为 du+(u,v)<dvdu + |(u, v)| < dv 时才会更新 dvdv,而 (u,v)|(u, v)| 是不变的,初始时 dududvdv 都是无穷大,所以只有 dudu 更新 (变小),dvdv 才有机会更新 (变小)。从源点出发指向其邻接顶点,对一个连通的有向图,总能遍历所有顶点,每次考察已松弛的顶点 uu 是否能松弛其邻接顶点 vvvv 成为已松弛的顶点后再考察是否能松弛 vv 的邻接顶点,重复此操作直到「当前已松弛顶点均无法再松弛任何顶点」为止。设置一个队列 qq,程序开始时置源点s的距离为 0,ss 入队。whilewhileqq 判空,不空时队首顶点 uu 出队,松弛其边 (更新 uu 的邻接顶点 vv 的距离),根据上述分析,如果 dvdv 被更新,那它的邻接顶点将 有机会被更新 ,所以将 vv 入队,等待之后出队时尝试松弛 vv 的边。 需要注意的是vv 入队前需要检查当前队列中是否已有 vv ,若有则无需入队。该检查使得时间复杂度为 O(VE)O(|V||E|)否则这一复杂度将无法得到保证 。重复上述过程,当 qq 为空时 (无负圈图) 代表所有被更新过距离的顶点,都 无法再触发其邻接顶点距离的更新 。也可以说对任意一个顶点 uu 来说,ssuu 的所有路径带来的所有可能的对 dudu 的更新已被穷尽。程序结束时得到所有顶点到源点的最短路径。


算法过程
  1. 建图及初始化。

    1. 构建带权图。
    2. 设置一个大小为 V|V| 的距离数组 dists[]dists[] ,下标表示顶点。初始化所有顶点到源点 ss 的距离为 InfinityInfinity ,表示该顶点到 ss 的距离尚未确定。
    3. 设置一个大小为 V|V| 的距离数组 inCounts[]inCounts[] ,下标表示顶点。记录顶点的入队次,用于负圈检测 (不是必须的)。
    4. 设置一个队列 qq
    5. ss 到其自身距离为 0。
    6. ss 入队。
  2. 通过 while(!q.isEmpty())while(!q.isEmpty()) 不断将队首顶点 uu 出队,对 uu 尝试松弛其所有邻边

    1. 对顶点 uu 考察其所有邻接顶点 vv 是否有 du+(u,v)<dvdu + |(u, v)| < dv,若有,松弛之,即令 dv=du+(u,v)dv = du + |(u, v)|。同时考察是否有 inCounts[v] > |V| - 1 ,若满足,说明存在负圈,可直接结束程序,否则 inCounts[v]++ 。若需要求完整路径,可在松弛的同时更新 vv 的前驱为 uu
  3. whilewhile 结束时,(若无负圈) 每个顶点到源点的最短路径距离被求出。


正确性证明 (说明)

SPFA 算法与 BF 算法的核心内容都在于 穷尽所有路径带来的所有可能的松弛 。BF 算法通过 V1|V|-1 次全量松弛来实现这一点,但第 ii 次全量松弛中, 有效松弛 仅作用于第 i+1i+1 层顶点,其他层深顶点不能够被松弛却还是会被尝试,这就产生了冗余操作。SPFA 算法利用前述顶点的距离能够被松弛的隐含前提, 通过队列来减少松弛的次数 。第 ii 层顶点出队时发生的松弛,效果上相当于 BF 算法外层循环第 ii 次对所有边的全量松弛。在连通且无负圈的情况下,按层推进一定能够执行最大层深第 V|V| 层,因此该算法是正确的。


实例说明

仍以前一张网络图为例考察 SPFA 算法的求解过程,进一步看清其正确性及 SPFA 与 BF 的关系。

第 1 层顶点只有 ss ,所以第 2 步 ss 出队相当于 BF 算法中第 1 次全量松弛。a,b,va,b,v 是第 2 层顶点,所以第 3,4,5 步相当于 BF 算法中第 2 次全量松弛。此时 v,a,cv,a,c 是第 3 层顶点 (根据所在路径层深的不同,一个顶点可以属于不同层) ,所以接下来的第 6,7 相当于 BF 算法中的第 3 次全量松弛。最后一步使得队列为空,结束。

松弛过程 dsds dada dcdc dbdb dvdv 队列 qq
1. 初始 0 (*) s;
2. s出 0 ∞ > 9 ∞ > 3 (*) ∞ > 15 a, b, v;
3. a出 0 9 3 15 > 14 b, v;
4. b出 0 9 > 7 (*) ∞ > 5 (*) 3 14 v; a, c
5. v出 0 7 5 3 14 a, c;
6. a出 0 7 5 3 14 > 12 c; v
7. c出 0 7 5 3 12 > 6 (*) v;
8. v出 0 7 5 3 14
1
2
3
4
5
6
7
8
9
10
11
1. 初始时令 ds 为0,(*)表示已取得该顶点最短路径。「;」是层的分隔符。
2. s 出队时松弛 (s, a), (s,b), (s, v)。
且 b 只有一条路径,即经过这趟松弛操作,使得 db = δb。
3. a 出队时松弛 (a, v),这是 v 的距离第 2 次被松弛。
4. b 出队时松弛 (b, a), (b, c),a 有两条路径,c 只有一条,
于是 da = δa,dc = δc。
5. 无可松弛边。
6. a 出队时松弛 (a, v),这是 v 的距离第 3 次被松弛。
7. c 出队时松弛 (c, v),这是 v 的距离第 4 次被松弛。
此时 p(s, v) 所有可能的路径带来的 v 的入边的松弛均已完成,于是 dv = δv 。
8. 无可松弛边,队列空,程序结束。

负边图 & 负圈图
  • 负边图。SPFA 能够处理有负权的非负圈图 ,原因与 BF 算法一样,因为算法会处理所有顶点的 所有入边的松弛

  • 负圈图。若图存在负圈,负圈上的顶点将无限循环入队,算法无法结束。

负圈判定: 记录每个顶点入队的次数,顶点 vv 的距离更新后判断当前更新次数是否超过了 V1|V|-1 次,若超过则说明存在负圈,若不超过则将更新次数加 1。以层为单位追踪顶点入队出队的过程,不难理解无负圈情况下,一个顶点的距离至多被松弛 (顶点入队) V1|V|-1 次,若超过则说明存在经过该顶点的负圈。

当图只有一个节点时,要小心处理负圈检测。如下伪代码,次数检测写在次数加一之前可以 避免单节点图被误判为负圈

1
2
3
4
5
6
7
if(!q.contains(w)) { // 检查w是否已经在q中
q.add(w) // 将w加入队列
if(w.inCount > |V| - 1) { // 若大于|V|-1则检出负圈
System.err.println("存在负圈!")
}
else w.inCount++ // 记录入队次数
}

当然也可以调换次数检测和加一的顺序,并把 >V1> |V| - 1 改成 >V> |V| ,如下,效果相同。

1
2
3
4
5
6
7
if(!q.contains(w)) { // 检查w是否已经在q中
q.add(w) // 将w加入队列
w.inCount++ // 记录入队次数
if(w.inCount > |V|) { // 若大于|V|则检出负圈
System.err.println("存在负圈!")
}
}

Bellman-Ford-Moore 和 SPFA

本节开头我们已经说过,SPFA 实际上应当称作 Bellman-Ford-Moore 算法。根据Wiki词条 Bellman-Ford algorithm 的介绍,「对所有的边,简单地松弛 V1|V| - 1 轮」的朴素 BF 算法在相近的几年里被三个人分别独立发明。只是不知道什么原因算法名称后来定型成了 Bellman-Ford。

1
2
3
1955年 Alfonso Shimbel
1956年 Lester Ford Jr.
1958年 Richard Bellman

又过了一年,1959年的时候 Edward F. Moore 提出了 BF 算法的一个改进,即前文的伪代码 (SPFA / Bellman-Ford-Moore) 。

A variation of the Bellman-Ford algorithm known as Shortest Path Faster Algorithm, first described by Moore (1959), reduces the number of relaxation steps that need to be performed within each iteration of the algorithm.

1994年,西南交通大学的段凡丁在该年4月的《西南交通大学学报》里发表了题为《关于最短路径的SPFA快速算法》的论文,重新提出了Moore的改进 ,并且给了个比较通俗的名字 Shortest Path Fast Algorithm。段老师显然没看过 Moore 当初的论文,否则不会给出一个错误的复杂度估计(给出的复杂度是 O(kE)O(k|E|) )。有意思的是,现在用 Google 搜 SPFA,即便在英文论坛,许多人对这个改进也称之为 SPFA,而非 BFM,可谓是「喧宾夺主」了。


时空复杂度

时间复杂度:O(VE)O(|V||E|) 。为严谨说明,列出 SPFA 伪代码如下。

1
2
3
4
5
6
7
8
9
10
SPFA (Bellman-Ford-Moore) 算法伪代码:
1 Queue q
2 q.add(s) // s的距离初始为0, 其他顶点的距离初始为Infinity
3 while(!q.isEmpty())
4 v = q.remove()
5 for (w : v.adjs) // w是v的邻接顶点
6 if(dv + d(v, w) < dw)
7 dw = dv + d(v, w)
8 if(!q.contains(w)) //检查w是否在当前队列中,不在则入队
9 q.add(w)

按层分析很容易得到 SPFA 的复杂度。顶点一层一层地入队出队,一张图最多有 V|V| 层 (以 ss 为第 1 层),所以 按层计 ,任何顶点最多只能入队 V1|V| - 1 次 (应用第8行入队前检查)。第 1 层顶点个数为 1,其余每层顶点数不会超过 V1|V| - 1 (第8行所保证)。再次强调,虽然一个顶点可能会通过不同的路径重复属于某一层,例如 s>a>cs>b>cs > a > c,s > b > ccc 在第 2 层中出现两次,但有了第 8 行的检查,使得 一个顶点最多只能在一层顶点里出现一次 。考虑每层顶点个数小于 V|V| ,每层顶点的松弛次数少于 E|E| 次,因此复杂度为 O(VE)O(|V||E|)

第 8 行 if(!q.contains(w)) 是SPFA 作为改进 BF 的关键,有必要继续进一步说明为何加了这个检查优化 不影响结果的正确性 。假设从 ss 经过长度相同的不同路径到达若干个不同顶点,这些顶点都指向 vv ,每条路径带来的松弛都能执行到 (BFS 所保证),只是除了第一次之外不把 vv 放入队列。将 vv 放入队列的目的是在之后使其邻接顶点 ww 有被松弛的机会。对于 ww ,来自 vv 的松弛机会只需一次即可,所以无需每次都将 vv 放入队列中。

SPFA 每出队第 ii 层顶点,使得在最短路径上第 i+1i+1 层的顶点得到松弛。不厌其烦地,SPFA第 ii 层顶点出队的效果等同于 BF 第 ii 轮对边的全量松弛的效果,比 BF 的操作更有效率的地方在于,SPFA 仅仅松弛它够得着得邻边。BF 暴力地松弛所有边,但有效的只有第 i+1i+1 层,其他更深或更浅的顶点无法松弛,但一律以 if(dv + |(v, w)| < dw) 询问了一次。于是,SPFA 便实现了对顶点的 「无遗漏且不重复」 的松弛这一改进。

空间复杂度:存图空间 O(V+E)O(|V|+|E|)dists/inCounts/qdists/inCounts/q 空间 O(V)O(|V|)。总体为线性空间复杂度 O(V+E)O(|V|+|E|)

对时间复杂度的分析可以看出,稀疏图中顶点 vvp(s,v)p(s, v) 路径平均条数很少,相比 BF,SPFA 实际运行速度会很快,稠密图下则无明显优势。


代码

如下是 743. 网络延迟时间 的 SPFA(BFM) 解法。代码中应用了「负圈检测」,由于本题已保证了不存在负值边,也就不存在负圈,因此负圈检测可以省略。

此代码是 SPFA 算法的较为普遍的写法 (带负圈检测),读者应熟练掌握。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// SPFA(BFM)+负圈检测
class Solution {
public int networkDelayTime(int[][] times, int n, int k){
List<List<int[]>> graph = new ArrayList<>();
for(int i = 0; i < n; i++) graph.add(new ArrayList<>());
for(int[] edge : times){ // 建带权图
int u = edge[0] - 1, v = edge[1] - 1, weight = edge[2];
graph.get(u).add(new int[]{v, weight});
}
int max = 0, INF = Integer.MAX_VALUE; // max为距离源点最远顶点的距离,INF为所有顶点距离初始值
int[] dists = new int[n], inCounts = new int[n]; // inCounts记录顶点入队次数,用于负圈检测
Arrays.fill(dists, INF); // 距离初始化
dists[k - 1] = 0; // 源点距离置0
Queue<Integer> q = new ArrayDeque<>();
q.add(k - 1); // 源点入队
while(!q.isEmpty()){
int u = q.remove();
for(int[] v_weight : graph.get(u)){ // 松弛u的邻接顶点
int v = v_weight[0], weight = v_weight[1];
int dv = dists[u] + weight;
if(dv < dists[v]){
dists[v] = dv;
if(!q.contains(v)) { // 入队的前提是此时v不在q中,否则程序虽正确,但复杂度将不再是O(|V||E|)
q.add(v);
if(inCounts[v] > n - 1) { // 负圈检测 (本题不需要)
System.out.println("Negtive Cycle Found!");
return -1;
}
else inCounts[v]++;
}
}
}
}
for(int dist : dists){ // 找最短路长度最大者
if(dist == INF) return -1; // 存在距离未更新的顶点
if(dist > max) max = dist;
}
return max;
}
}

带权全源最短路

在「带权单源最短路」中,我们介绍了 Dijkstra / DAG SSSP (归为特殊 Dijkstra) / Bellman-Ford / SPFA (BFM) 最短路算法。若想求图上任意两点的距离,在这些算法中将不得不一个个计算,我们不禁会想,有没有什么算法可以一次求解所有顶点对 (all pairs) 的最短路径呢?本节我们介绍的就是这样一种一次性求解所有点对最短距离的算 Floyd-Warshall 算法。

Jeff Erickson 在他的 Algorithms 一书的 9.8 节开头,介绍了 Floyd-Warshall 算法被多人相继独立发明 (发现) 的历史。

…A difffferent formulation of shortest paths that removes this logarithmic factor was proposed twice in 1962, first by Robert Floyd and later independently by Peter Ingerman, both slightly generalizing an algorithm of Stephen Warshall published earlier in the same year. In fact, Warshall’s algorithm was previously discovered by Bernard Roy in 1959, and the underlying recursion pattern was used by Stephen Kleene in 1951.

除了展示 Floyd-Warshall 算法可用于解决求单源最短路的 743. 网络延迟时间 一题,我们还会展示更适合用它来求解的全源最短路问题 1334. 阈值距离内邻居最少的城市


Floyd-Warshall

Floyd-Warshall算法(弗洛伊德算法): 求解图中任意两点的最短路径的算法。算法以 3 重循环考察任意顶点 ii 到任意顶点 jj 是否有经过任意顶点 kk 的可松弛路径,即对每一个顶点 kk (外层循环),考察是否有 d(i,k)+d(k,j)<d(i,j)d(i, k) + d(k, j) < d(i, j) ,若有则更新 d(i,j)=d(i,k)+d(k,j)d(i, j) = d(i, k) + d(k, j)

可以这样理解其工作过程。已知确定的任意两点 i,ji, j 间有确定的最短路径 p(i,j)p(i, j) (只要无负圈,必有最短路径,不连通时最短路径长度为 InfinityInfinity ),p(i,j)p(i, j) 由多次松弛操作得到。先假设算法是正确的 (详细证明见后述),那么在 p(i,j)p(i, j) 的最后一次松弛后 (通过顶点 yy 松弛),得到 p(i,j)=p(i,y)+p(y,j)p(i, j) = p(i, y) + p(y, j) 。同理,p(i,y)p(i, y)p(y,j)p(y, j)p(i,j)p(i, j) 的两个部分,它们由之前的松弛操作得到。例如松弛顶点 xx 后得到 p(i,y)p(i, y) ,可知 p(i,y)=p(i,x)+p(x,y)p(i, y) = p(i, x) + p(x, y) ,松弛顶点 zz 后得到 p(y,j)p(y, j) ,可知 p(y,j)=p(y,z)+p(z,j)p(y, j) = p(y, z) + p(z, j) 。路径 p(i,j)p(i, j) 的构建过程可以一直拆分溯源到某三个相邻顶点的连接。例如在 p(i,j)p(i, j) 上有三个相邻顶点为 i>a>bi > a > b ,那么在外层循环处理 aa ,中层循环处理 ii ,内层循环处理 bb 时,松弛操作将 i>a>bi > a > b 「连接起来」(因为 最短路径上的任意子路径都是最短的 ,必松弛)。顺着算法执行过程不难看出,算法通过外循环的 kk 来连接边,通过不断连接短路径产生长路径,最终增长为完整的最短路径。

在「正确性证明 (说明)」中我们将进一步透过「动态规划」来把握该算法正确性。


算法过程
  1. 建图及初始化。

    1. 构建带权图。在 Floyd-Warshall 算法中,以「邻接矩阵」构建带权图是更方便也更普遍的做法。
    2. 设置一个大小为 VV|V|*|V| 的距离矩阵 (二维数组) dists[][]dists[][] ,下标表示顶点。初始化所有顶点对距离为 InfinityInfinity ,表示所有点对间距离尚未确定。若要求输出入境本身,还需设置一个 VV|V|*|V| 大小的路径信息矩阵。
  2. 外层循环执行 V|V| 次, 每次固定顶点 kk

    1. 中层循环执行 V|V| 次,每次固定顶点 ii

      1. 内层循环执行 V|V| 次,每次固定顶点 jj

        考察是否有 d(i,k)+d(k,j)<d(i,j)d(i, k) + d(k, j) < d(i, j) ,若有,松弛之,即令 d(i,j)=d(i,k)+d(k,j)d(i, j) = d(i, k) + d(k, j) 。若要求输出入境本身,还要对应更新路径信息矩阵。

  3. 外层循环结束时,(若无负圈) 所有顶点对的最短路径距离被求出。

  4. 检查图是否有负圈。再次对所有边执行松弛操作, 若有边可被松弛,则有负圈 ,结束程序,否则正常结束,所有顶点最短路径被求出。

※ 以下展示如何通过路径信息的矩阵 pp ,递归地输出路径。

p[i][j]p[i] [j] 的值是 kk ,即 ii 经过 kkjj ,在每次松弛时更新 kk 。例如有最短路径 i>a>b>c>ji > a > b > c > j 。程序结束后得到的路径信息矩阵如下。

顶点 ii aa bb cc jj
ii nullnull nullnull aa nullnull bb
aa nullnull nullnull nullnull nullnull nullnull
bb nullnull nullnull nullnull nullnull cc
cc nullnull nullnull nullnull nullnull nullnull
jj nullnull nullnull nullnull nullnull nullnull

利用该矩阵,通过递归即可找到 i>a>b>c>ji > a > b > c > j 。递归过程大致如下,顶点输出顺序即为路径顺序。

1
2
3
4
5
6
7
8
i > j, 找到b
i > b,找到a
i > a为null,输出i
a > b为null,输出a
b > j,找到c
b > c为null,输出b
c > j为null,输出c
最后输出j

正确性证明 (说明)

该算法的 本质是动态规划 ,以状态转移方程的形式描述如下,其中 dp[k][i][j]dp[k][i][j] 表示 经过前 kk 个顶点的松弛,得到的顶点 ii 到顶点 jj 的最短路径长度 。注意第一维的 kk 表示 kk 个顶点,第二维和第三维表示具体的顶点。

1
2
3
1. 定义: dp[k][i][j] 表示经过前 k 个顶点的松弛,得到的顶点 i 到顶点 j 的最短路径长度。
2. 边界: dp[0][i][j] = Infinity
3. 递推: dp[k][i][j] = min{dp[k-1][i][j], dp[k-1][i][k] + dp[k-1][k][j]}

最短路径 不经过kk 个顶点 (顶点 kk ): dp[k][i][j]=dp[k1][i][j]dp[k][i][j] = dp[k-1][i][j]

最短路径 经过kk 个顶点 (顶点 kk ): dp[k][i][j]=dp[k1][i][k]+dp[k1][k][j]dp[k][i][j] = dp[k-1][i][k] + dp[k-1][k][j]

1
2
3
4
5
6
// floyd核心伪代码
for(k : V)
for(i : V)
for(j : V)
if(d(i, k) + d(k, j) < d(i, j))
d(i, j) = d(i, k) + d(k, j)

补充说明:已知点 i,ji, j 之间的最短路径为 p(i,j)p(i, j) ,那么 p(i,j)p(i, j) 上的任意两点 a,ca, c 的最短路径确定在 p(i,j)p(i, j) 上。反证法简单可证。假设 p(i,j)p(i, j) 上两点 a,ca, c 之间的最短路径经过一不在 p(i,j)p(i, j) 上的顶点 bb ,那 i,ji, j 的最短路径也就不是 p(i,j)p(i, j) ,而是 p(i,a)+p(a,b)+p(b,c)+p(c,j)p(i, a) + p(a, b) + p(b, c)+ p(c, j)


实例分析

下面通过一个例子观察 Floyd 算法的动态规划过程,对其正确性可以有更直观的感受。由于算法总是在整张图上进行处理,展示整张图将使过程变得杂乱,因此我们只选取一条 iijj 的最短路径,展现该路径的构建过程。由于这条路径是随意选取的,其他所有在图上的 任意两点 的路径的构成都是类似的。

设图 GGi,ji, j 间最短路径 ppi>a>b>c>d>e>f>g>h>ji > a > b > c > d > e > f > g > h > j 。最外层循环对该路径上顶点的处理顺序可以是任意顺序,例如 b,h,i,g,a,e,j,f,c,db, h, i, g, a, e, j, f, c, d (分别标上序号 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,表示外层循环处理的先后顺序)。现在我们以溯源的方式从处理最后一个顶点 dd 得到 d(i,j)d(i, j) 开始观察,且只观察程序对上述十个顶点的处理(对其他顶点的处理形成其他路径)。该路径的取得只与三个循环对该路径上的顶点的操作有关,循环对其他顶点的操作不影响结果,因为其他顶点不在该最短路径上,由它们导致的松弛不影响路径 pp ,或者说 pp 会从若干条 iijj 的路径中胜出。

外层循环处理 dd 之后由 d(i,d)+d(d,j)d(i, d) + d(d, j) 的结果得到 iijj 的最短路径长 d(i,j)d(i, j) ,因此 d(i,d)d(i, d)d(d,j)d(d, j) 此时必是已知的。继续溯源,看看 d(i,d)d(i, d)d(d,j)d(d, j) 是如何得到的。到 dd 的路径上 cc 是最后被处理的,处理 cc 时计算 d(i,d)=d(i,c)+d(c,d)d(i, d) = d(i, c) + d(c, d) ,其中 d(c,d)d(c, d) 是边长,这是程序开始时已知, d(i,c)d(i, c) 需要继续溯源, d(i,c)=d(i,a)+d(a,c)d(i, c) = d(i, a) + d(a, c) ,其中 d(i,a)d(i, a) 是边长, d(a,c)=d(a,b)+d(b,c)d(a, c) = d(a, b) + d(b, c)d(a,b)d(a, b)d(b,c)d(b, c) 是边长。其余过程如图,标红处表示相邻的顶点的边长,在程序开始时得到。

可以看出,外层循环处理 i 到 j 的最短路径的所有顶点的过程中,先处理的顶点得到 子路径总能够为后处理的顶点构建更长的子路径 ,直到处理 (最短路径上的) 最后一个顶点时,将两个子路径连接起来形成最终的最短路径,这正是动态规划过程的体现。

不同于 BF 动态规划过程的单串 O(1)O(1) 依赖,Floyd 动态规划是单串 O(n)O(n) 依赖 的。也可以描述为某一次的松弛形成的路径 不一定直接作用于下一次松弛 ,而是在之后某一次松弛中发生作用。例如处理 b(1)b(1) 和处理 h(2)h(2) 是外层循环的两次相邻的操作,它们分别产生了两条不相连的子路径 a>b>ca>b>cg>h>jg>h>j 。之后外层循环处理 a(5)a(5)a>b>ca>b>c 增长为 i>a>b>ci>a>b>c ,处理 c(9)c(9) 时增长为 i>a>b>c>di>a>b>c>dg>h>jg>h>j 在外层循环处理 g(4)g(4) 时增长为 f>g>h>jf>g>h>j ,处理 f(8)f(8) 时增长为 d>e>f>g>h>jd>e>f>g>h>j ( d>e>fd>e>f 在外层循环处理 e(6)e(6) 时得到)。最终在处理 d(10)d(10) 时得到 i>a>b>c>d>e>f>g>h>ji > a > b > c > d > e > f > g > h > j

image.png

flyod_demo.gif

总结 Floyd 算法的过程,外层循环执行完第 kk 次,给出由 k+1k+1 条边组成的路径,下一次会利用长度为 1,2,...,k+11, 2, ... , k+1 的路径连接出长度为 k+2k+2 的路径。这就好像将任意两点连成单边线(只要这两点之间存在路径),然后再将两条单边线作为零件连成长度为 2 的线,因为具备所有单边线,所以无论长度为 2 的线是由哪些单边线组成的,都可以找到并连起来。然后再利用长度为 1,2 的线作为零件连成长度为 3 的线,因为无论一条长度为 3 的线是如何构成的,构成它的单边线和 2 边线都已具备。以此类推直到连出所有可能长度的线。


负边图 & 负圈图
  • 负边图。Floyd-Warshall 能够处理有负权的非负圈图 ,原因与 BF 算法一样,因为算法会处理所有顶点的 所有入边的松弛

  • 负圈图。当图存在负圈时,ss 到圈上任意顶点的距离都可以通过不断绕圈趋于无限小。因此若不能保证输入的图无负圈,可以在 3 重循环后再执行一次全量松弛,若仍有边可被松弛,说明存在负圈。


时空复杂度

时间复杂度: 3 重循环,O(V3)O(|V|^3)

空间复杂度: 邻接矩阵、距离矩阵、路径信息矩阵 (若有的话) 均为 O(V2)O(|V|^2) 。总的空间复杂度为 O(V2)O(|V|^2)


代码

如下是 743. 网络延迟时间 的 Floyd-Warshall 解法。代码中应用了「负圈检测」,由于本题已保证了不存在负值边,也就不存在负圈,因此负圈检测可以省略。另外,此实现采用了 「邻接矩阵」 存图。

此代码是 Floyd-Warshall 算法的较为普遍的写法 (带负圈检测),读者应熟练掌握。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Floyd-Warshall (带负圈检测)
class Solution {
public int networkDelayTime(int[][] times, int n, int k){
int[][] dists = new int[n][n];
int max = 0, INF = Integer.MAX_VALUE; // max为距离源点最远顶点的距离,INF为所有顶点距离初始值
for(int[] distRow : dists) Arrays.fill(distRow, INF); // 距离初始化
for(int u = 0; u < n; u++) dists[u][u] = 0; // 顶点到自身距离为0
for(int i = 0; i < times.length; i++){ // 建图 (邻接矩阵)
int u = times[i][0] - 1, v = times[i][1] - 1, weight = times[i][2];
dists[u][v] = weight;
}
for(int k1 = 0; k1 < n; k1++) { // 对所有顶点 k
for (int i = 0; i < n; i++) { // 对所有顶点 i
for(int j = 0; j < n; j++){ // 对所有顶点 j
long ik = dists[i][k1], kj = dists[k1][j], ij = dists[i][j]; // 为防止溢出,临时转为 long
if(ik + kj < ij) { // 松弛条件
dists[i][j] = (int) (ik + kj);
}
}
}
}
for(int dist : dists[k - 1]){ // 注意题目为「单源」
if(dist == INF) return -1; // 存在距离未更新的顶点
if(dist > max) max = dist;
}
// 负圈检测
for (int u = 0; u < n; u++) { // 全量松弛
for(int v = 0; v < n; v++){
long dv = (long) dists[k - 1][u] + (long) dists[u][v]; // 等号右边可能会溢出,临时转为long
if(dv < dists[k - 1][v]) { // 松弛条件
System.out.println("Negtive Cycle Found!");
}
}
}
return max;
}
}

现在,我们展示 Floyd-Warshall 解决 1334. 阈值距离内邻居最少的城市 一题的代码如下。该题是典型的「所有点对」最短距离问题。求解过程十分简单。

  1. 应用 Floyd-Warshall 算法求出所有点对间最短距离,存于矩阵 dists[][]dists[][] 中。
  2. 遍历 distsdists 矩阵,统计每一个顶点在距离阈值内 (小于等于) 的邻接顶点个数,实时地记录其中最小者。
  3. 因为 uu 是从小到大遍历的,若有多个满足要求的顶点,记录的就是编号最大的那一个。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Solution {
public int findTheCity(int n, int[][] edges, int distanceThreshold) {
int INF = Integer.MAX_VALUE;
int[][] dists = new int[n][n];
for(int[] distRow : dists) Arrays.fill(distRow, INF);
for(int u = 0; u < n; u++) dists[u][u] = 0;
for(int[] edge : edges){
int u = edge[0], v = edge[1], weight = edge[2];
dists[u][v] = weight;
dists[v][u] = weight;
}
for(int k = 0; k < n; k++){ // floyd求出dists
for(int i = 0; i < n; i++){
for(int j = 0; j < n; j++){
long ik = dists[i][k], kj = dists[k][j], ij = ik + kj;
if(ij < dists[i][j]){
dists[i][j] = (int) ij;
}
}
}
}
int ans = 0, minCount = n;
for(int u = 0; u < n; u++){ // 遍历dists寻找答案
int count = 0;
for(int v = 0; v < n; v++){
if(dists[u][v] <= distanceThreshold) count++;
}
if(count <= minCount) {
minCount = count;
ans = u;
}
}
return ans;
}
}

小结

总结「最短路径」算法如下。

算法 时间复杂度 负边 正圈 负圈
无权 SSSP
● 朴素版 O(V2)O(V^2) - Yes -
● 队列版 O(V+E)O(V+E) - Yes -
带权 SSSP
● Dijkstra 朴素版 O(V2)O(V^2) No Yes No(不可检测)
● Dijkstra 小顶堆版 O(ElogV)O(ElogV) No Yes No(不可检测)
● Dijkstra DAG O(V+E)O(V+E) Yes No (可检测) No (可检测)
● Bellman-Ford O(VE)O(VE) Yes Yes No (可检测)
● SPFA (BFM) O(VE)O(VE) Yes Yes No (可检测)
带权 APSP
●Floyd-Warshall O(V3)O(V^3) Yes Yes No (可检测)

※ 本文未涉及「无权 APSP」,无权图的所有点对最短路可通过对所有顶点执行无权单源最短路算法求得,时间复杂度为 O((V+E)V)O((|V|+|E|)*|V|) 。对此问题有更多兴趣的读者可参考 All pairs shortest path in undirected and unweighted graphs

※ 空间复杂度主要取决于建图方式,以「邻接矩阵」建图时为 O(V2)O(|V|^2),以「邻接表」建图时为 O(V+E)O(|V|+|E|) ,表中不再列出。


最小生成树

在最短路径问题中,研究的是如何找到图中两点的最短路径,当我们将整张图作为对象考虑时,很自然地会想,在图上所有顶点都互相连通的基础上,如何找到最小的总路径和呢?这有点像是 以图为整体的「最短路径」 。很容易找到这个问题的现实意义,例如给一个科技园区铺装光纤,要求所有办公楼和厂房都互相连通且尽可能节省光纤用量。办公楼和厂房为图中顶点,互相之间的距离为边权,该场景实际上是要求找到能将所有顶点连通且边权和最小的边集。这个边集就是本节要介绍的 「最小生成树」

最小生成树 ( Minimum Spanning Tree, MST ): 通常指的无向图中的一个边集,该边集使得图中所有顶点互相连通,且边权总和最小。由于此边集构成的子图必然无圈,该子图为一棵树,因此称该边集为「最小生成树」。

本节讲解求图上 MST 的两种算法: Prim 和 Kruskal,我们将看到前者几乎和 Dijkstra 一致,而后者只是并查集思想的简单应用。同样,为了在学习这两个算法过程中及时得到反馈,我们将以 1135. 最低成本联通所有城市 一题作为配套,有了 Dijkstra 和并查集的基础,MST 的学习是十分轻松的。


Prim

Prim 算法与 Dijkstra 算法无论是从思想还是实际代码表现上都非常类似,在理解了 Dijkstra 的基础上,学习 Prim 几乎没有思考成本。后续我们以一个表格逐行比较二者的内容,读者将发现 二者最根本的差别只在于顶点距离的更新方法 。由于有了 Dijkstra 的基础,行文中,我们着重对比二者,对 Prim 算法只做必要的简略讲解。我们仍循前例,先介绍「朴素版」再介绍「优先队列版」。读者务必在阅读本节之前还阅读 Dijkstra 章节。

与之前的几个算法类似,Prim算法也曾被多人在不同时间相继独立发明 (发现) 。Jeff Erickson 在他的 Algorithms 一书的 7.4 节开头,做了如下介绍。

The next oldest minimum spanning tree algorithm (Prim’s algorithm) was first described by the Czech mathematician Vojtěch Jarník in a 1929 letter to Borůvka; Jarník published his discovery the following year. The algorithm was independently rediscovered by Joseph Kruskal in 1956, (arguably) by Robert Prim in 1957, by Harry Loberman and Arnold Weinberger in 1957, and finally by Edsger Dijkstra in 1958. Prim, Lobermand and Weinberger, and Dijkstra all (eventually) knew of and even cited Kruskal’s paper, but since Kruskal also described two other minimum spanning-tree algorithms in the same paper, this algorithm is usually called “Prim’s algorithm”, or sometimes “the Prim/Dijkstra algorithm”, even though by 1958 Dijkstra already had another algorithm (inappropriately) named after him.

Jeff Erickson 在他的书中几乎只将此算法称为 Jarník 算法,不过本文还是按照流行的说法称为 Prim 算法。


朴素版

算法描述

Prim (普里姆算法): 一种基于贪心思想的求解无向图上 MST 的算法。我们直接将 Prim 算法和 Dijkstra 二者对比如下。

Dijkstra Prim
贪心算法 贪心算法
顶点分为 距离已确定距离未确定 顶点 顶点分为 距离已确定 (已加入生成树)距离未确定 (未加入生成树) 顶点
所有顶点距离初始化为 InfinityInfinity 所有顶点距离初始化为 InfinityInfinity
源点 ss 的距离初始化为 0 生成树起始点 ss 的距离初始化为 0
以一个循环寻找 当前距离未确定顶点中距离最小者 uu
立即置 uu 的距离为「已确定」。
以一个循环寻找 当前距离未确定顶点中距离最小者 uu
立即置 uu 的距离为「已确定」。
尝试以如下方式更新 (松弛) uu 的邻接顶点的距离
dv=min(dv,du+d(u,v))dv = min(dv, du + d(u, v))
尝试以如下方式更新 uu 的邻接顶点的距离
dv=min(dv,d(u,v))dv = min(dv, d(u,v))
whilewhile 结束时所有顶点最短路径及其距离被求出 whilewhile 结束时 MST 及其所有边权被求出

我们看到这两个算法的每一步都几乎相同,不同点主要有三处,最主要的是第 3 点。

  1. Prim 用于在「无向图」中求 MST,因此建图时要「双向建边」。Dijkstra 应用于有向图时单向建边,应用于无向图时双向建边。
  2. Dijkstra 中的「距离」指的是顶点到源点的距离。Prim 中的「距离」指的是顶点到其已在生成树上的邻接顶点 (父顶点) 的距离。
  3. 顶点距离更新方式不同

算法过程
  1. 建图及初始化。

    1. 构建双向带权图。
    2. 设置一个大小为 V|V| 的用于标记顶点距离是否「已确定」的 booleanboolean 数组 visited[]visited[] ,下标表示顶点。
    3. 设置一个大小为 V|V| 的距离数组 dists[]dists[] ,下标表示顶点。初始化所有顶点的距离为 InfinityInfinity ,表示所有顶点的距离尚未确定。
    4. 任意选取 一个顶点作为起始顶点,置距离为 0,通常我们将第一个顶点的距离置为 0 。
  2. 以一个循环寻找 当前距离未确定顶点中距离最小者 uu ,立即置 uu 的距离为「已确定」。

  3. 距离更新。尝试更新 uu 的所有 距离未确定的 邻接顶点 vv 的距离 dvdv。即 dv=min{dv,(u,v)}dv = min\{dv, |(u,v)|\}

  4. 循环结束时,所有顶点距离被求出,也就是 MST 所有边权被求出。


正确性证明

如下证明参考了 参考1 以及 参考2

虽然 Prim 算法过程与 Dijkstra 类似,但证明过程并不通用。如下是 Prim 算法的证明过程。

证明:在图 GG 上执行 Prim 算法得到的生成树 SS 是 MST。

  1. GG 上的 MST (之一) 是生成树 SminS_{min},即证明 S=Smin|S| = |S_{min}| ,绝对值表示生成树的边权和。

  2. SS 生长过程中,首次 遇到的不属于 SminS_{min} 的边为 e=(u,v)e=(u, v)uu 在此前得到的点集中,vv 在剩余的顶点中。从 SS 中拿走 ee ,则 SS 剩下的不连通的两部分分别为包含 uu 的子树 UU 以及包含 vv 的子树 VV

  3. SminS_{min} 是 MST,其上必存在 uuvv 的路径,且已知 uuUU 中,vvVV 中,由于 (u,v)Smin(u,v)∉S_{min} ,则必然存在边 f=(a,b)Sminf=(a,b)∈S_{min} 穿过 UVU → V,即 aaUU 中, bbVV 中 (若 aauu ,则 bb 不是 vv;若 bbvv,则 aa 不是 uu) 。

  4. ee 即将被加入 SS 时,顶点 aa 已在 UU 中 ( UUSS 当前生长到的部分)。

    1. 此时 aa 是「距离未确定」的顶点。因为如果 aa 是距离已确定的顶点,则在 UU 中存在一条边 gg 连接 aa 和另一顶点,我们已经知道 eeSS 生长过程中 首次 遇到的不属于 SminS_{min} 的边,也就是说 gSming∈S_{min} ,这与 fSminf∈S_{min} 矛盾,因为 aa 一旦「距离确定」,后续就不会考察 aa 的连边,ff 也就不可能加入 SminS_{min}
    2. 由于 aa 是「距离未确定」的顶点,考察顶点 uu 的连边时,顶点 aa 的连边也一定会被考察。由于 ee 在此轮考察中被 Prim 算法选入 SS 中,而 ff 未被选择,可知 ef|e|≤|f|
  5. 对于 SminS_{min} ,断开 f=(a,b)f=(a,b) ,则 SminS_{min} 被分成不连通的两棵子树 AABB,且 uabvu → a → b → v 被断开,说明 uuvv 分别在不连通的两棵子树 A,BA, B 上。此时连上 e=(u,v)e=(u,v) 得到的 SAeBS_{AeB} 显然是一棵生成树。因为 ef|e|≤|f| ,则必然有 e=f|e|=|f| , 否则 SAeBS_{AeB} 边权之和更小,与 SminS_{min} 是 MST 矛盾。因此 SAeB=Smin|S_{AeB}|=|S_{min}|,即 SAeBS_{AeB} 也是 MST。

  6. 重复上述过程,继续考察 SS 中下一条不属于 SminS_{min} 的边,直到得到 SS 。每一次考察,都可以得到上述 5 的结论,最终有 S=Smin|S|=|S_{min}|,即 SS 是 MST。

image.png


时空复杂度

与朴素版 Dijkstra 算法相同,不赘述。

时间复杂度:O(V2)O(|V|^2)

空间复杂度:O(V+E)O(|V|+|E|)


代码

1135. 最低成本联通所有城市 一题是非常典型的求 MST 的题目,应用本节的朴素 Prim 解法轻松可解,而且你会发现,只需对 743. 网络延迟时间 的朴素 Dijkstra 解法做几行修改即可得到此代码。求解时需要注意以下两点。

  • 构建带权图时要双向建边。
  • 当存在距离未更新的顶点时说明图不连通时,返回 -1。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Solution {
public int minimumCost(int n, int[][] connections){
List<List<int[]>> graph = new ArrayList<>();
for(int i = 0; i < n; i++) graph.add(new ArrayList<>());
for(int[] edge : connections){ // 建带权图
int u = edge[0] - 1, v = edge[1] - 1, weight = edge[2];
graph.get(u).add(new int[]{v, weight}); // 建边 (u,v)
graph.get(v).add(new int[]{u, weight}); // 建边 (v,u)
}
int sum = 0, INF = Integer.MAX_VALUE; // max为距离源点最远顶点的距离,INF为所有顶点距离初始值
int[] dists = new int[n];
boolean[] visited = new boolean[n];
Arrays.fill(dists, INF); // 距离初始化
dists[0] = 0; // 生成树起始点距离置0 (可任选一个顶点)
int u = -1; // 当前距离未确定顶点中的距离最小者
while((u = getMin(dists, visited)) != -1) { // 遍历顶点,寻找当前距离未确定顶点中的距离最小者
visited[u] = true; // 立即置为距离已确定
for (int[] v_weight : graph.get(u)) {
int v = v_weight[0], weight = v_weight[1];
if(!visited[v]) { // 对于u的距离未确定的邻接顶点v,更新v的距离
if(weight < dists[v]) { // 更新条件
dists[v] = weight; // 更新dv
}
}
}
}
for(int dist : dists){ // 计算 MST 边权和
if(dist == INF) return -1; // 若有不连通的城市,返回 -1
sum += dist;
}
return sum;
}
private int getMin(int[] dists, boolean[] visited){ // 在当前距离未确定的顶点中找距离最小者
int n = dists.length, min = Integer.MAX_VALUE, minVertex = -1;
for (int u = 0; u < n; u++) {
if(!visited[u] && dists[u] < min) {
min = dists[u];
minVertex = u;
}
}
return minVertex;
}
}

优先队列版

算法描述

与 Dijkstra 一样,Prim 也可用优先队列 (小顶堆) 优化,内容一致,不赘述。


算法过程
  1. 建图及初始化。
    1. 构建双向带权图。
    2. 设置一个小顶堆 pqpq
    3. 设置一个大小为 V|V| 的用于标记顶点距离是否「已确定」的 booleanboolean 数组 visited[]visited[] ,下标表示顶点。
    4. 设置一个大小为 V|V| 的距离数组 dists[]dists[] ,下标表示顶点。初始化所有顶点距离为 InfinityInfinity ,表示所有顶点距离尚未确定。
    5. 置任意一个顶点的距离为 0,通常我们将第一个顶点的距离置为 0 。
    6. 上述顶点入堆。
  2. 一次出堆完成一个顶点距离的确定,即将该顶点加入 MST。以 whilewhile 循环对 pqpq 判空,若不空,堆顶顶点 uu 出堆,此时 uu 为距离未确定顶点中距离最小者,置 uu 的距离为已确定。
  3. 更新操作。尝试更新 uu 的所有 距离未确定的 邻接顶点 vv 的距离 dvdv。即 dv=min{dv,(u,v)}dv = min\{dv, |(u,v)|\}
  4. 循环结束时,所有顶点距离被求出,也就是 MST 所有边权被求出。

时空复杂度

与优先队列版 Dijkstra 算法相同,不赘述。

时间复杂度:O(ElogV)O(|E|log|V|)

空间复杂度:O(V+E)O(|V|+|E|)

同样地,当图为 「稠密图」 时,采用更优的「朴素版」;当图为 「稀疏图」 时,采用更优的「优先队列版」。


代码

现在,我们给出如下利用优先队列的 Prim 算法代码来解决 1135. 最低成本联通所有城市 。同样地,只需对 743. 网络延迟时间 的优先队列版 Dijkstra 解法做几行修改即可得到此代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Solution {
public int minimumCost(int n, int[][] connections){
List<List<int[]>> graph = new ArrayList<>();
for(int i = 0; i < n; i++) graph.add(new ArrayList<>());
for(int[] edge : connections){ // 建带权图
int u = edge[0] - 1, v = edge[1] - 1, weight = edge[2];
graph.get(u).add(new int[]{v, weight}); // 建边 (u,v)
graph.get(v).add(new int[]{u, weight}); // 建边 (v,u)
}
int sum = 0, INF = Integer.MAX_VALUE; // sum为MST边权和,即本题所求。INF为所有顶点距离初始值。
int[] dists = new int[n];
boolean[] visited = new boolean[n];
Arrays.fill(dists, INF); // 距离初始化
dists[0] = 0; // 生成树起始点距离置0 (可任选一个顶点)
PriorityQueue<int[]> pq = new PriorityQueue<>((u, v) -> u[1] - v[1]); // 小顶堆
pq.add(new int[]{0, 0}); // 起始点入堆
while(!pq.isEmpty()) {
int[] u_dist = pq.remove();
int u = u_dist[0];
if(visited[u]) continue;
visited[u] = true; // 立即置u的距离为已确定
for (int[] v_weight : graph.get(u)) {
int v = v_weight[0], weight = v_weight[1];
if(!visited[v]) { // 对于u的距离未确定的邻接顶点v,更新v的距离
if(weight < dists[v]) { // 更新条件
dists[v] = weight; // 更新dv
pq.add(new int[]{v, weight}); // v入堆
}
}
}
}
for(int dist : dists){ // 计算 MST 边权和
if(dist == INF) return -1; // 若有不连通的城市,返回 -1
sum += dist;
}
return sum;
}
}

Kruskal

前述 Prim 算法从一个顶点出发,逐渐「长出」一棵 MST,而本节的 Kruskal 算法在「生成」MST 的过程上与 Prim 颇有相对之感,该方法令 MST 的各部分各自生长,最终合并成一棵完整的 MST。Kruskal 算法是并查集的典型应用,需要读者先了解并查集。在之前的「初探图搜索 (遍历)」 - 「无向图连通性」 - 「并查集」中我已提过,我在另一篇文章中对并查集做过较为全面的介绍,如果读者还不熟悉并查集,那么在开始本节之前,可以先阅读我写的 并查集从入门到出门 (全文1w+ 字,尝试透彻分析并查集的基本内容,2022 年 5 月中旬在力扣讨论区发布后两周内收获 5k 阅读量,500+ 收藏,100+ 点赞及大量好评)。

后续内容在假设你已了解了「并查集」基本内容的基础上,均只做非常简要的说明 (因为详细内容都已在「并查集从入门到出门」中呈现)。

Joseph B. Kruskal 于 1956 年发表的 论文 中描述了该算法。


算法描述

Kruskal (克鲁斯卡尔算法): 一种基于贪心思想的求解无向图上 MST 的算法。该算法首先将所有边按边权排序,接着应用「并查集」的「查询-合并」过程来决定将哪些边加入到 MST 中。算法内容的简要描述为: 对排序后的边权,依序 (按边权从小到大) 取边 (u,v)(u,v) ,若顶点 u,vu, v 还未连通,则将 (u,v)(u,v) 加入 MST ,直到加入的边数等于 V1|V| - 1


算法过程

  1. 构建双向带权图。
  2. 按边权从小到大排序所有边。
  3. 实现以顶点的并查集。
  4. 从排序后的边中依序 (按边权从小到大) 选取边 (u,v)(u, v) ,查询该边的两个顶点是否已在 MST 中 (即是否已连通) ,是则跳过,否则合并,也即将此边加入 MST 中。
  5. 当加入的边数等于 V1|V| - 1 时,表明所有在一个连通分量中的顶点构成了 MST,算法结束。
  6. 若依序取完所有的边后,加入 MST 的边数仍达不到 V1|V| - 1,说明此图不存在 MST,也即图中存在不连通的顶点。

时空复杂度

时间复杂度: 边权排序 O(ElogE)O(|E|log|E|) ,单次查询或合并均为 O(α(n))O(\alpha(n)),近似 O(1)O(1) ,至多 V1|V| - 1 次查询与合并。综合为 O(V+ElogE)O(|V|+|E|log|E|),化简为 O(ElogV)O(|E|log|V|)

空间复杂度: 建图 O(V+E)O(|V|+|E|) (若输入已有图信息,也可以不用建图),parents/rankparents / rank 空间 O(V)O(|V|)。综合为 O(V+E)O(|V|+|E|)


代码

现在,我们给出如下利用 Kruskal 算法代码来解决 1135. 最低成本联通所有城市 。并查集采用「带路径压缩的查找 + 按秩合并」的组合。熟悉「并查集」之后我们能够非常轻松地写出该代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class Solution {
public int minimumCost(int n, int[][] connections) {
Arrays.sort(connections, (a, b) -> a[2] - b[2]); // 先对边权排序
int sum = 0, count = 0;
int[] parents = new int[n];
for(int i = 0; i < n; i++) parents[i] = i;
UnionFind uf = new UnionFind(parents);
for(int[] edge : connections){
int u = edge[0] - 1, v = edge[1] - 1, weight = edge[2];
if(uf.find(u) != uf.find(v)){
uf.union(u, v); // 合并
sum += weight; // 实时地累计 MST 边权和
count++;
if(count == n - 1) return sum; // 若存在 MST,必能由此句返回
}
}
return -1; // 否则无MST
}
}
class UnionFind{
int[] parents, rank;
public UnionFind(int[] parents){
this.parents = parents;
this.rank = new int[parents.length];
Arrays.fill(rank, 1);
}
public int find(int x){ // 带路径压缩的查找
if(parents[x] == x) return x;
return parents[x] = find(parents[x]);
}
public void union(int x, int y){ // 按秩求并
int xRoot = find(x), yRoot = find(y);
if(xRoot != yRoot){
if(rank[yRoot] <= rank[xRoot]){
parents[yRoot] = xRoot;
}
else parents[xRoot] = yRoot;
if(rank[xRoot] == rank[yRoot]) rank[xRoot]++;
}
}
}

小结

本节介绍了两种常见的求解「最小生成树」的算法,在掌握 Dijkstra 算法的基础上,Prim 只需简单对照学习即可完全掌握。对于 Kruskal,仅仅是并查集的典型应用,因此在掌握了并查集的基础上,学习 Kruskal 也是十分轻松的。

「最短路径」算法中,由于最短路是边权的累计,在无法穷尽松弛的算法中,例如 Dijkstra,我们特别强调了其无法处理负边图的特点,且所有的最短路径算法都不能求解负圈图 (有的可以检测出负圈) 。但「最小生成树」比较边权的大小而无「累计」效应,因此 负边和负圈都不影响算法的正确性 ,也就是在讨论「最小生成树」时,我们 无需考虑图是否有圈以及是否有负边


网络流

图论中,除了关心顶点到顶点的「最短距离」和整张图的「最小生成树」之外,人们还关心 顶点间的「流量」。将图想象成一张信息网络,信息在顶点之间流动,每条边的权值限制了同一时间承载流量的上限,我们想知道,从一个顶点到另一个顶点,一次最多能够发送多少流量,这就是同样著名的「最大流」问题。在本节中我们将从著名的 「最大流最小割定理」 的证明开始,深入讨论该问题,并给出几种求最大流的算法。


最大流最小割定理

最大流最小割定理(Max-flow min-cut theorem):对于一张网络图 GG,从源点 ss 到汇点 tt最大流量等于最小割所有割边容量之和

以下将从 路径存在问题 引入 割和割的大小 的概念,再由割的大小与 sts-t 不相交路径数量的关系,证明该定理。证明过程中先假设有向图 (网络) 边无权 (或者说均为单位边权,即边权为1),随后再推广至有权图。

定理证明过程整理自B站南大老师蒋炎岩的授课视频: [算法竞赛入门] 网络流基础: 理解最大流/最小割定理 ,同时也增加了作者的理解。

在这里我们提前指出 Ford-Fulkerson 方法的正确性证明与最大流最小割定理的证明等价 ,这意味着它们会被 同时证明 。如下,也就是 k=mk = mk=lk = l 同时成立,后续我们将理解这一点。

  • Ford-Fulkerson方法的正确性证明:图 GGsts-t 的最大流为 kk ,FF 方法找到的流量为 mm ,证明 k=mk=m
  • 最大流最小割定理证明:图 GGsts-t 的最大流为 kksts-t 最小割大小为 ll ,证明 k=lk=l

L. R. Ford Jr. & D. R. Fulkerson (1962) Flows in Networks, 这一 论文 第23页,作者给出的最大流最小割定理描述如下。

Theorem 5.1 Max flow min cut theorem.

For any network the maximal flow value from ss to tt is equal to the minimal cut capacity over cuts separating ss and tt.


从路径存在问题到割

sts-t 流 (flow) 存在的前提是 sts-t 路径 存在。对于路径存在问题,通常以 DFS/BFSDFS/BFS 算法判定,算法从 ss 出发找到 tt 则说明 sts-t 路径存在,否则不存在。为了引入割的概念,现在尝试以更本质的方式考察该问题。首先以排列组合的方式简单罗列所有可能的 sts-t 路径。例如一张包含顶点 s,a,b,c,d,ts, a, b ,c, d, t 的图,可以罗列出如下路径,暂不考虑边是否存在。

1
2
3
4
5
6
7
s > t
s > a > t
s > b > t
...
s > a > b > t
s > a > c > t
...

对于上述列表中的一条路径,若该路径中的每对相邻顶点构成的边均存在,则该路径为一条 sts-t 路径。若对于列表中的所有路径都能找到不存在的边,则证明该图不存在 sts-t 的路径。

对于路径存在问题,罗列所有可能路径并逐一判断的方法虽最严格,但显然不是证明的好办法。再次考虑 DFS/BFSDFS/BFS,算法从 ss 出发,会找到所有能到达的顶点,将这些顶点作为集合 SS,剩下 ss 无法到达的顶点作为集合 TTsts-t 路径不存在, 等价于 SS 中任意顶点到 TT 中任意顶点的连边都不存在 ,由此引入如下 的概念。

割的定义:割 (cut) 是图 GG顶点集 VV 的划分 。有向图 G(V,E)G(V, E)sts-tC=(S,T)C = (S, T)VV 被划分为顶点集 SSTT ,且 sSs ∈ StTt ∈ T

割的大小:对于上述割,其大小指从 SSTT 的边的数量,即边 (u,v)EuS,vT(u, v) ∈ E | u ∈ S, v ∈ T 的数量,称这些边为 割边

根据割及其大小的定义,只要存在 sts-t 路径,无论如何划分 VV 得到 sts-t 割,总能在图中找到从 SSTT 的边,即该割的大小一定大于0。例如下面的 sts-t 路径,s,a,d,e,hSb,c,f,g,i,tTs, a, d, e, h ∈ S, b, c, f, g, i, t ∈ T,红箭头表示 sts-t 割的边。只要满足 sSs ∈ S (ss 在绿色阴影里),tTt ∈ T (tt 不在绿色阴影里),在有路径的情况下,红箭头一定存在,而与绿色阴影的形状 (具体的 sts-t 割) 无关。反过来说就是 sts-t 路径存在,则所有 sts-t 割的大小都大于0

因此,要想证明 sts-t 路径不存在,只需要找到一个大小为 0 的 sts-t 割即可 。 注意,当 sts-t 路径不存在时,也可能存在大小不为 0 的 sts-t 割,很容易找到相关例子,因此重申,证明 sts-t 路径不存在是要找到一个大小为 0 的 sts-t 割。

image.png


不相交路径数量与割的大小

现在,我们初步建立了「流」与「割」在特定情形下的联系,即上述分析给出的「sts-t 路径数量为 0 时 (流为0),必存在大小为 0 的 sts-t 割」。这是 「sts-t 路径数量」 与 「sts-t 割大小」 的特定关系。再次回到定理内容,定理描述了「最大流」和「最小割边容量」(对无权图来说即割边数量) 的关系,不难理解,最大流就是 不相交的 sts-t 的路径 数量 (再次注意,我们当前讨论的是无权图,「数量」即「流量」)。sts-t不相交 路径指对于多条 sts-t 路径,它们之间 没有公共边 。后续我们通过如下三个量的关系来证明定理,即证明 k=lk = l

  • kk 为图中 实际存在的 sts-t 不相交路径数量。

  • mm算法找到的 sts-t 不相交路径数量。

  • ll 为图中 最小 sts-t 割边数量。


不相交路径数上界

假设图有 kksts-t 的不相交路径,想要知道 kk 的大小,可以逐步假设 k(k=0,k=1,k=2,...)k (k = 0, k = 1, k = 2,...),再验证是否确实有那么多条。例如假设 k=0k=0 时,实际上就是前述 sts-t 路径存在性问题,已经指出,若存在大小为 0 的 sts-t 割,则 k=0k=0。当 k>0k > 0 时,考虑是否还能用 sts-t 割的大小表达不相交路径数量。

下图 k=1k=1,以图中的阴影表示 sts-t 割,可以看出该割的大小为 1。容易看出 不相交路径数量受到割边 (b,c)(b, c) 数量的限制 ,如果所有 sts-t 割大小最小为1,那么任意 sts-t 路径一定都经过该唯一割边,即 sts-t 不相交 路径至多为 1。若最小割大小为 2,则存在 2 条经过 2 条不同割边的 sts-t 路径。总之,sts-t 割的大小 cc 决定了 sts-t 不相交路径数量 kk 的上界,即 kck ≤ c。如果 c=lc = l ,自然就有 klk≤l ,任意 sts-t 割的大小 cckk 的一个 上界llkk最紧上界 。现在我们离目标近了一步,即「割边数」cc 和「最大流」(sts-t 不相交路径数量) kk 的关系满足: kck ≤ c ,最紧地有 klk≤l

image.png


不相交路径数下界

上述以割边数给出了不相交路径数量 kk 的上界,而如果能直接找到 mm 条不相交路径,此 mm 可立即作为 kk 的一个下界,且若 m=lm = l,便可得到 k=lk = l。 直接地,我们用 DFS/BFSDFS/BFSmm,在 GG 中寻找一条 sts-t 路径 pp,找不到时 k=0k=0,若能找到,为了保证找到的路径总是 不相交 的,需要从 GG 中删去 pp,然后 k=k+1k = k + 1。看起来有效,但实际操作后我们会发现一个问题,如下图,当第一次选择的路径为 s>d>a>ts > d > a > t 后,由于该路径被删除,将无法再找到下一条路径,而该图显然有 k=2k=2 (s>a>t,s>d>ts > a > t, s > d > t),因此单纯使用 DFS/BFSDFS/BFS 算法得到的 mm 并不一定是 kk 。而 Ford-Fulkerson 方法可以解决这一问题,即 FF 方法保证 m=km = k

image.png


反向边 (路径)

L. R. Ford, JR & D. R. Fulkerson 在1956年的论文中修正了单纯以 DFS/BFSDFS/BFS 找最大流的缺陷缺陷,并证明了改进后算法的正确性,这就是著名的 Ford-Fulkerson 方法。修正办法十分简单,即在每一次找到一条路径并删除该路径后,在 tst-s 的方向上 (sts-t 的反方向) 添加原路径的反向路径,仅此而已。如前图,找到 s>d>a>ts > d > a > t 的路径后将其删除,立即添加 t>a>d>st > a > d > s 路径。该操作使得后续仍能找到一条 sts-t 路径,即 s>a>d>ts > a > d > t,继续删除并添加相应的反向路径,最终无法再找到 sts-t 路径,m=2m=2 被正确求出。

image.png

sts-t 路径称为 增广路径(Augmenting Path),可以将反向路径称作 反向增广路径。添加这一反向路径的操作是整个算法最为核心的部分,以下证明这一操作的正确性,即以「寻找增广路-删除该路径-添加反向路径」这一FF方法得到 mmsts-t 路径,且 m=km = k。稍等,我们不是要证明「最大流最小割定理」,也就是证明 k=lk = l 吗?为何突然转为证明 m=km=k ?很快你会看到,这两点是等价的,且是同时证明的。


证明k=m

蒋岩炎老师的视频中不以严谨的数学证明,而是以图形化的直观方式证明了最大流最小割定理 (也即证明了 FF 方法的正确性)。


添加反向边的有效性

如下左图 (图中的 sstt 虽画出了多个点,但表示一个点),p1,p2,p3p1, p2, p3DFS/BFSDFS/BFS 找到的头 3 条 sts-t 不相交路径 (每次都会添加反向路径,但这三条恰好都还未经过反向边),p4p4 是第 4 条 (经过反向边)。p4p4 由两种边组成,如下中图,一种是不与 p1,p2,p3p1, p2, p3 交叠的边,即此前尚未出现在不相交路径上的 原边,另一种是与 p2p2p3p3 (反向) 交叠的边,这些边是找到 p2p2p3p3 后添加的 反向边,它们使得 p4p4 可以沿着这样的反向边到达 tt。假设此时 FF 方法已找不到 sts-t 路径,那么 FF 方法找到了 4 条不相交路径,即 m=4m = 4

p4p4 是通过添加反向边来间接得到的 (此路径并不存在于原图中),现在的问题,由于 4 条路径是通过添加反向边找到的,它们并不都是原图中存在的路径。我们首先明确 原图 中确实有 4 条不相交路径。为了看出这一点,找到 p4p4 后,在反向 p4p4 之前,去掉 p4p4 走过的反向边,然后将还原 p1,p2,p3p1, p2, p3 反向过后的边 (再反回去) ,得到下右图。可以看到,p1p1不变,而 p2,p3,p4p'2, p'3, p'4 就是 原图 中的 sts-t 路径,它们显然不相交 。注意下右图本身就是原图的一部分,只不过一开始没找到 p2,p3,p4p'2, p'3, p'4 ,而是找到了 p2,p3p2, p3,然后通过反向边找到了 p4p4

尽管算法找到的路径与下右图显示的原图上的路径不同,但数量是正确的。如果还有 p5p5,仍然可以通过相同的方法证明原图中有 5 条不相交路径。在示意图中那些通过反向边得到的 (在原图中不存在的) 路径看起来就像是切开了起初得到的路径 (p2,p3p2, p3),并通过部分原边连接这些路径的断点,使得 sstt 可达

image.png


k的确定与最小割的大小

上述内容说明了添加反向边能够找到新的 (在不添加反向边时可能找不到的) 不相交路径,并且我们看到算法找到的路径与原图实际的路径 一一对应 (数量相等) 。有了这些准备工作,我们开始证明 k=mk = m 。证明基于以下思路。

  1. 已经知道 m=4m = 4,因此 km=4k ≥ m = 4
  2. 如果找到原图 GGsts-tCC,证明它是「最小割」且 l=4l = 4 ,那么证明就完成了。
  3. 对于 2,指出一个割,直接证明它是 「最小的」 是困难的,因此我们不必考虑它是不是最小,转而考虑在原图中寻找一个大小 c=4c=4 的割 CC 。因为 mkcm≤k≤c ,那么 m=k=c=4m=k=c=4,确定了 k=4k=4 ,就不可能存在大小小于 4 的割,于是 m=k=l=c=4m=k=l=c=4
  4. 上述 3 就意味着 FF 方法 (m=km=k) 和最大流最小割定理 (k=lk=l) 同时得证。

根据这个思路,我们接着前图继续分析。再次强调,现在的目标是 在原图中寻找一个大小为 c=4c=4 的割 CC

以上中图为例,将 p4p4 反向得到如下残留图 GfG_{f'}

image.png

残留图 GfG_{f'} 有两种边 ,一种是 sts-t 不相交路径的反向边(注意,这里说的 sts-t 不相交路径不是通过算法找到的路径,而是前述说明中 p1,p2,p3,p4p1, p'2, p'3, p'4 那样的原图中存在的 sts-t 路径),一种是其他不在不相交路径上的原边。显然,此时 tst-s 不相交路径就是原图上的 4 条 sts-t 不相交路径的反向路径。由于 sts-t 已不连通,可知此时在残留图中存在大小为 0 的 sts-tCC 。我们指出如下具体的割 CC

CC : 将 GfG_{f'}ss 能够到达的顶点划归 SS,将剩下的顶点划归 TT

该割大小为 0 ,反证法简单可证明。若有割边,也就是还能从 SS 找到一个顶点 vv 连到 TT 中的某个顶点 uu,那么 ss 就能通过 vv 连通 uu ,这与我们的划分要求相矛盾 ,因为在 GfG_{f'} 中已经将 ss 能到达的所有顶点都划入 SS 了。uu 也不可能是 tt,因为 GfG_{f'}sts-t 不连通,因此假设不成立,此割无割边。 由此得到如下重要推论。

推论: 割 CC 在原图 GG 中大小为 4。

证明如下:

  1. CC 在残留图 GfG_{f'} 中大小为 0 ,也就是 SS 中无顶点可到 TT
  2. 残留图与原图的区别仅仅是 不相交路径的方向相反
  3. 在原图 GG 中,SS 中的顶点可分为不在 sts-t 路径上和在 sts-t 路径上的顶点。对于前者,根据 1,无顶点可到 TT ,对于后者,它们 贡献且仅贡献了 4 条割边
    1. 也就是说每条 sts-t 路径只贡献一条割边,这要求每条 sts-t 路径都只穿过一次 STS-T
    2. 因为原图 GGsts-t 路径是残留图 GfG_{f'}tst-s 路径的反向路径。因此证明原图中 sts-t 路径只穿过一次 STS-T 等价于证明残留图中 tst-s 路径只穿过一次 TST-S。(记住,割 CC 在原图和残留图中是一样的)
    3. 假设在残留图 GfG_{f'}tst-s 路径穿过了两次 TST-S,因为 路径是连续的 ,穿越过程必然是 TSTST→S→T→S,也就是会有边穿越 STS-T这与残留图 GfG_{f'} 存在大小为 0 的 sts-t 割相矛盾 ( GfG_{f'} 上的割 CC)。
    4. 因此原图 GG 中的 sts-t 路径均只穿过一次 STS-T,每条路径只贡献一条割边,也即割 CC 在原图 GG 中大小为 4 。

推论得证。

至此,通过找到残留图 GfG_{f'} 上大小为 0 的割 CC ,我们证明了割 CC 在原图中大小为 4 ,实现了证明目标。最后总结如下。

  1. 我们在指出原图 GfG_f 上的割 CC 时,并不知道它是不是最小割,但证明了 m=c=4m = c = 4,而我们知道 mkcm≤k≤c ,因此直接得到了 k=m=4k = m = 4 。因为 k=4k=4 ,所以原图上的任何 sts-t 割不可能比割 CC 更小,于是 CC 是一个最小割 ,综上,有 m=k=l=c=4m=k=l=c=4
  2. m=km=k 证明了 Ford-Fulkerson 方法的正确性, k=lk = l 证明了最大流最小割定理,这就是我们在开始证明时提到「FF 方法正确性和最大流最小割定理的证明等价,它们会被同时证明」的原因。

推广至有权图

只需将边权为 nn 的边看作 nn 条单位边,转换成无权图即可适用前述证明。例如 s>a>ts > a > t 路径,(s,a)(s, a) 边权为 2,(a,t)(a, t) 边权为3,将边拆成单位边后,原容量为 2 的一条路径被分成两条单位容量的路径。


Ford-Fulkerson

在「最大流最小割定理及其证明」中我们已经介绍了 Ford-Fulkerson 方法并证明了该方法的正确性。本节我们继续讲解该方法更详细的内容。


算法描述

Ford-Fulkerson方法(福特-富尔克森方法): 1956 年 L. R. Ford Jr 和 D. R. Fulkerson 在其发表的 论文 中描述的基于 贪心思想 的求 有向图最大流 的算法。因为该算法未指定求增广路径的具体算法,所以通常将其称作 Ford-Fulkerson 方法 (Method) 而非 Ford-Fulkerson 算法 (Algorithm) 。若增广路径 bfsbfs 方式求出 ,则为 Edmonds-Karp 算法 。此外也可以以 dfsdfs 方式求最短路径,我们之后会介绍 结合 bfsbfsdfsdfs 求增广路的 Dinic 算法 。FF 首先设置要求解的目标,即源点 ss 到目标点 tt 的最大流,设为 ff,初始值为 0,然后寻找 sts-t 路径 p1p1p1p1 上最小权边的边权即 ss 能沿 p1p1 发往 tt 的最大流量 c(p1)c(p1)。如我们在「最大流最小割定理及其证明」中所述,由于不适当的增广路选择会使得最大流量 ff 在求出前 sstt 就没有了增广路,因此 Ford-Fulkerson 方法要求每次求出一条增广路后,要在相反方向路径上添加 c(p1)c(p1),其作用是 ttss 在必要时能够发回原先 ss 发送到 tt 的流量。我们已经证明了该做法的正确性。当无法再在残留图中找到 sts-t 的增广路时所得到的 ff 即为原图 sts-t 最大流。


算法过程

给定一张图 G(V,E)G(V, E) ,源点为 ss ,汇点为 tt 。求 GG 中从 sstt 的最大流 ff

  1. 设置一张残留网络 (残留图/残差图/残余图,residual graph ) GfGfGfGf 初始为 GG

  2. 考察 GfGf 中是否有从 sstt 的路径 pp ,使得 pp 上的每一条边 (u,v)p(u,v) ∈ p ,都有 c(u,v)>0c(u, v) > 0pp 称作 增广路径 (Augmenting Path) 。若存在 pp ,则:
    2.1 将 pp最小边的权 cc 加入 ff
    2.2 pp 的每条边减去该最小边的权 cc
    2.3 pp 的反向路径上的每条边加上该最小边权 cc

  3. GfGf 中反复寻找增广路径 pp 并执行 2 中的操作直到 GfGf 中找不到 pp,算法停止。此时得到的 ff 即为 sstt 的最大流。

※ 虽然根据 wiki 的说法 Ford-Fulkerson 未指定求增广路的具体方式,但若以 dfsdfs 方式求增广路,通常也称作 Ford-Fulkerson 。


算法正确性证明

请参考「最大流最小割定理」。


两种坏情形

小边权增广路径情形

如下图,寻找从 AABB 的增广路径时,若选取 A>B>C>DA>B>C>D 后,再选取 A>C>B>DA>C>B>D ( C>BC>B 由反向边操作得到),如此反复选取。由于 B,CB,C 之间的小权边限制了流的大小,需经历 2000 次增广才能结束算法,而选取 A>B>DA>B>D,然后选取 A>C>DA>C>D 则只需要两次。针对如何避免选取含有小边权的增广路,有以下两种做法。

  • 做法1: 总是选取流最大的增广路径 。显然能够避免小权边引起的多次增广路选取。

  • 做法2: 总是选取最短的增广路径 。较短的增广路降低了路径上出现小权边的概率。

image.png

bfsbfs 方式应用无权单源最短路径方法实现做法 2 即为 Edmonds-Karp 算法。


算法无法终止的情形

算法能够结束的隐含前提是 每次找到的增广路至少使 sstt 的发送流增加 1 个单位 (例如整数 1) ,只要最大流是固定的,经过有限次操作后总能发送到最大流。如果边权存在 无理数 ,则算法可能无法结束。以下举例说明 (该例来自 Wiki )。

如下图的流量网络,e1=(b,a),e2=(d,c),e3=(b,c)e1 = (b, a), e2 = (d, c), e3 = (b, c)e1=e3=1|e1| = |e3| = 1e2=r=(51)/2|e2| = r = (√5 - 1) / 2 。其他边的容量为 M(M>=2)M (M >= 2) ,且 r2=1rr^2 = 1 - rr3=rr2r^3 = r - r^2 , r4=r2r3r^4 = r^2 - r^3 …。有路径 p0=s>b>c>tp0 = s > b > c > tp1=s>d>c>b>a>tp1 = s > d > c > b > a > tp2=s>b>c>d>tp2 = s > b > c > d > tp3=s>a>b>c>tp3 = s > a > b > c > t 。若按路径 p0,p1,p2,p1,p3,p1,p2,p1,p3,p1,p2,p1,p3...p0, p1, p2, p1, p3, p1, p2, p1, p3, p1, p2, p1, p3... 的顺序增广,则算法无法结束。

观察下表经过上述顺序一次循环后的结果,步骤 1 和步骤 5 时 e1,e2,e3e1, e2, e3 剩余容量为 rn,rn+1,0r^n, r^{n+1}, 0 的形式。发送流为 1+2(r1+r2)1+2(r^1+r^2) 。经过无限次完整的循环,e1,e2,e3e1, e2, e3 剩余容量都会是 rn,rn+1,0r^n, r^{n+1}, 0 的形式,而发送流为 1+2ri1+2∑r^i ( rr 是从 1 到正无穷的整数)。根据等比数列求和公式有:

1+2ri=1+2r/(1r)1+2∑r^i = 1 + 2*r/(1-r)

r=1r2=(1+r)(1r)r = 1 - r^2 = (1+r)*(1-r) 代入上式分子中的 rr ,得到 3+2r3+2r ,即发送流会向 2+52+√5 趋近,但从原图可以看出最大流为 2M+1>5>2+52M + 1 > 5 > 2+√5

步骤 增广路 发送流 e1剩余 e2剩余 e3剩余
0 r0=1r^0 = 1 r1r^1 1
1 p0p0 1 r0r^0 r1r^1 0
2 p1p1 rr r2r^2 0 r1r^1
3 p2p2 rr r2r^2 r1r^1 0
4 p1p1 r2r^2 0 r3r^3 r2r^2
5 p3p3 r2r^2 r2r^2 r3r^3 0

image.png


时空复杂度

时间复杂度:O(Ef)O(|E|*f)

设最大流为 ff ,算法过程为穷尽增广路径的过程,对于任意一条增广路,都可以保证在 O(E)O(|E|) 时间内找到 (例如以 bfs/dfsbfs/dfs 方式寻找,为 O(V+E)O(|V|+|E|),简略为 O(E)O(|E|) ),而每找到一条增广路径,至少增加 1 个单位的流量 (不考虑边权为无理数的情况,前面已说过,该情况可能会导致算法无法终止),故总的时间复杂度为 O(Ef)O(|E|*f) 。此时间复杂度与最大流的大小正相关,当 ff 较大时会导致较高的时间复杂度。若以 bfsbfs 算法寻找增广路,即 Edmonds-Karp 算法,时间复杂度为 O(VE2)O(|V||E|^2) 。若以 bfsbfsdfsdfs 相结合的 Dinic 算法寻找增广路,则时间复杂度进一步降为 O(V2E)O(|V|^2|E|)

空间复杂度:依赖于存图及求最短路径的具体方法,采用邻接表存图,则存图空间为 O(V+E)O(|V|+|E|) ,采用邻接矩阵为 O(V2)O(|V|^2) 。若以 bfsbfs 求最短路径,即 Edmonds-Karp 算法,队列的长度导致的空间复杂度为 O(V)O(|V|) 。若以 dfsdfs 求最短路径,递归栈导致的空间复杂度为 O(V)O(|V|) 。总体来说取决于存图方式。


代码

我们采用 dfsdfs 方式找增广路,并将其看作 Ford-Fulkerson 「算法」。现给出 FF 「算法」解决 P3376 【模板】网络最大流 (请自行搜索) 一题的代码(#9, #10 测试例超时,其他通过)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// Ford-Fulkerson
package com.yukiyama.demo;

import java.util.*;
import java.io.*;

public class FF {
boolean[] visited;
int[] ends, heads, nexts, weights, preEdges, pre, saturatedWeights;
int edgeNum;
public static void main(String[] args) {
Scanner cin = new Scanner(System.in);
int n = cin.nextInt(), m = cin.nextInt(), s = cin.nextInt() - 1, t = cin.nextInt() - 1;
FF demo = new FF();
demo.visited = new boolean[n];
demo.ends = new int[2 * m + 2];
demo.nexts = new int[2 * m + 2];
demo.weights = new int[2 * m + 2];
demo.preEdges = new int[n];
demo.heads = new int[n];
demo.pre = new int[n];
demo.saturatedWeights = new int[n];
demo.edgeNum = 1; // 初始为1,使得第一条边的编号为2,二进制为 10,第二条边(第一条边的反向边)编号为3,二进制为11,方便取异或
demo.init(m, cin); // 建图
cin.close();
System.out.println(demo.ek(s, t)); // 求最大流
}
// 求 s-t 最大流
public long ek(int s, int t) {
if (s == t) return 0; // 特判
long max = 0, incFlow = 0;
while((incFlow = dfs(s, t, Integer.MAX_VALUE)) > 0) { // 不断增广
max += incFlow; // 将每次增广路带来的流量增加到maxFlow上
Arrays.fill(visited, false); // 重置 visited
}
return max;
}
// 链式向前星建图
private void init(int m, Scanner cin) {
for(int i = 0; i < m; i++) {
int u = cin.nextInt() - 1, v = cin.nextInt() - 1, weight = cin.nextInt();
++edgeNum;
ends[edgeNum] = v;
weights[edgeNum] = weight;
nexts[edgeNum] = heads[u];
heads[u] = edgeNum;
++edgeNum;
ends[edgeNum] = u;
weights[edgeNum] = 0;
nexts[edgeNum] = heads[v];
heads[v] = edgeNum;
}
}
// dfs 增广并实时地发送 & 发回流
private long dfs(int u, int t, long incFlow){
if(u == t) return incFlow;
visited[u] = true; // 立即置为已访问
for(int edgeNo = heads[u]; edgeNo != 0; edgeNo = nexts[edgeNo]) {
int v = ends[edgeNo], weight = weights[edgeNo];
if(visited[v] || weight == 0) continue; // 跳过已访问或边权为0的情况
long saturatedWeight = 0;
if((saturatedWeight = dfs(v, t, Math.min(incFlow, weight))) > 0) {
weights[edgeNo] -= saturatedWeight; // 增广路发送流
weights[edgeNo ^ 1] += saturatedWeight; // 反向增广路发回流
return saturatedWeight;
}
}
return 0;
}

}

Edmonds-Karp

算法描述

Edmonds-Karp算法 (埃德蒙兹-卡普算法): 以 bfsbfs 方式 (无权最短路) 寻找增广路径实现 Ford-Fulkerson 方法即为 Edmonds-Karp 算法。每找到一条增广路并确定该路径上的最小边权 (该路径最大可发送流) 后,将此边权计入最大流中,并在此路径上对 sts-t 方向的边减去该权值, tst-s 方向上加上该权值。当 BFS 无法再找到增广路时算法结束,得到 sstt 的最大流。


算法过程

给定一张图 G(V,E)G(V, E) ,源点为 ss ,汇点为 tt 。求 GG 中从 sstt 的最大流 ff

  1. 设置一张残留网络 (残留图/残差图/残余图,residual graph ) GfGfGfGf 初始为 GG

  2. bfsbfs 方式 考察 GfGf 中是否有从 sstt 的路径 pp ,使得 pp 上的每一条边 (u,v)p(u,v) ∈ p ,都有 c(u,v)>0c(u, v) > 0pp 称作 增广路径 (Augmenting Path) 。若存在 pp ,则:
    2.1 将 pp最小边的权 cc 加入 ff
    2.2 pp 的每条边减去该最小边的权 cc
    2.3 pp 的反向路径上的每条边加上该最小边权 cc

  3. GfGf 中反复寻找增广路径 pp 并执行 2 中的操作直到 GfGf 中找不到 pp,算法停止。此时得到的 ff 即为 sstt 的最大流。


时空复杂度

以下证明 EK 算法的时间复杂度为:O(VE2)O(|V||E|^2)

本证明参考了证明1证明2


1. 一次BFS增广

每次 bfsbfs 增广,在增广路上的未饱和边会多出一条反向边,原图的单向边添加反向边后,就具有双向边 (两条) ,但饱和边仍是一条 (反向) ,因此增广操作导致的边数增长不会使总边数超过原来的 2 倍,即算法的任意时刻总边数 <2E< 2|E|。因此一次 bfsbfs 的时间复杂为 O(2E)O(2|E|),即 O(E)O(|E|)


2. BFS增广次数
2.1 增广路长度非递减

即证明算法过程中的 sstt 的 BFS 增广操作,即正向边删除和反向边增加的操作,不会导致源点 ss 到任意一点 vv 的最短路径距离减少。残留图 GfG_f 经过一次 BFS 增广变为 GfG_{f'} 后,对任意顶点 vv ,源点 ssvv 的最短路径长度 非递减,即有 d(s,v)d(s,v)d'(s, v) ≥ d(s, v)。以下利用反证法证明,并在证明中解释为何只能证明非递减而无法证明严格递增,即不是 d(s,v)>d(s,v)d'(s, v) > d(s, v)

  1. 假设某一次 sts - t 增广后,使得某些顶点到源点 ss 的最短路径距离相比增广前变小了,且这其中距离源点 ss 最近者为 vv。则根据该假设有

    (1) d(s,v)<d(s,v)d'(s, v) < d(s, v)

  2. uuGfG_{f'}vv 的靠近源点 ss 的前一个顶点,则有

    (2) d(s,u)+1=d(s,v)d'(s, u) + 1= d'(s, v)

    如 2.1.1 所述,vv 是我们有意选择的在 GfG_{f'} 中最短距离相比在 GfG_f 中变小的且距离 ss 最近的顶点,uuvv 更靠近 ss,但在 GfG_{f'} 中相比在 GfG_f 中距离 ss 的最短路径未变小,即有

    (3) d(s,u)d(s,u)d(s, u) ≤ d'(s, u)

  3. 假设 (u,v)Ef(u, v) ∈ E_f,已知 ssvv 的最短路径距离为 d(s,v)d(s, v),则有

    (4) d(s,u)+1=d(s,v)d(s, u) + 1 = d(s, v)

    结合(1)、(2)、(3)有

    d(s,u)d(s,u)d(s,u)+1d(s,u)+1=d(s,v)<d(s,v)d(s, u) ≤ d'(s, u) → d(s, u) + 1 ≤ d'(s, u)+ 1 = d'(s, v) < d(s, v),即

    (5) d(s,u)+1<d(s,v)d(s, u) + 1 < d(s, v)

    (4) 是由 2.1.3 的假设得到的,与由 (1), (2), (3) 得到的 (5) 矛盾,故在 2.1.1 假设成立的前提下,2.1.3 的假设 (u,v)Ef(u, v) ∈ E_f 不成立,即 (u,v)Ef(u, v) ∉ E_f。同时我们还能看到,如果一开始证明的是 d(s,v)>d(s,v)d'(s, v) > d(s, v),那么就要反证 d(s,v)d(s,v)d'(s, v) ≤ d(s, v) (对应式(1)),经过同样的过程当前的式 (5) 会变为 d(s,u)+1d(s,v)d(s, u) + 1 ≤ d(s, v),将推导不出与 (4) 式的矛盾(因为都有一个等号)。

  4. 由上述知 (u,v)Ef(u, v) ∉ E_f,但如 2.1.2 所述,(u,v)Ef(u, v) ∈ E_{f’} ,故在 GfG_f 上的增广必定经过了 (v,u)(v, u),且此边饱和,导致在 GfG_{f'} 中产生了反向边 (u,v)(u, v)。于是可知在 GfG_f 中有

    (6) d(s,u)=d(s,v)+1d(s, u) = d(s, v) + 1

(6) 与 (5) 矛盾,于是最初 2.1.1 的假设不成立,即不存在这样的顶点 vv,也即增广操作使得源点到任意一点 vv 的长度 非递减故EK算法寻找的增广路长度非递减


2.2 增广次数
  1. 假设某次 GfG_f 的增广中 (u,v)(u, v) 为饱和边,增广后 (u,v)Ef(u, v) ∉ E_{f'}(v,u)Ef(v, u) ∈ E_{f'}。之后 (u,v)(u, v) 要再次出现的前提是 (v,u)(v, u) 在增广路上。在 GfG_f(u,v)(u, v) 第一次成为饱和边时有

    (7) d(s,v)=d(s,u)+1d(s, v) = d(s, u) + 1

  2. (u,v)(u, v) 第二次成为饱和边,可知在此前的 GfG_{f'}(v,u)(v, u) 中在增广路上,在 GfG_{f'} 的这次增广中有

    (8) d(s,u)=d(s,v)+1d'(s, u) = d'(s, v) + 1

  3. 由 2.1 的结论,ss 到任意顶点的最短路径非递减,即必有 d(s,v)d(s,v)d'(s, v) ≥ d(s, v) ,结合 (7) 和 (8),有

    d(s,u)=d(s,v)+1d(s,v)+1=d(s,u)+2d'(s, u) = d'(s, v) + 1 ≥ d(s, v) + 1 = d(s, u)+ 2,即

    (9) d(s,u)d(s,u)+2d'(s, u) ≥ d(s, u) + 2

也就是说,(u,v)(u, v) 第二次成为饱和边时 ssuu 的最短距离至少比前一次成为饱和边时大 2。而 ss 到任意顶点的距离最多不超过V1|V| - 1,故 (u,v)(u, v) 可以成为饱和边的次数最多为 (V1)/2(|V| - 1) / 2。每次增广至少有一条边成为饱和边,根据 2.1 中的说明,EK算法过程中边数 <2E< 2|E|,故考虑 所有边的总的增广次数必小于 2E(V1)/22|E|*(|V| - 1)/2,即 增广次数复杂度为 O(VE)O(|V||E|)


综上,EK 算法复杂度为 O(VE2)O(|V||E|^2)

证明过程中 BFS增广使得增广路长度非递减 的结论是关键,网上有的文章声称 BFS 寻找增广路的操作使得增广路长度递增,但我们已经在 2.1.3 的叙述中指出这是不对的。根据 2.1 的证明,只能得到 非递减 的结果,但这一结论在 2.2 中足以证明任意边第二次成为饱和边时其最短路径长至少增加2,由此得到BFS次数的上界。

空间复杂度:存图空间为 O(V+E)O(|V|+|E|) ,队列空间为 O(V)O(|V|) 。总体时间复杂度为 O(V+E)O(|V|+|E|)


代码

现给出 EK 算法解决 P3376 【模板】网络最大流 一题的代码(通过)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// edmonds-karp
package com.yukiyama.demo;

import java.util.*;
import java.io.*;

public class EK {
boolean[] visited;
int[] ends, heads, nexts, weights, preEdges, pre, saturatedWeights;
int edgeNum;
public static void main(String[] args) {
Scanner cin = new Scanner(System.in);
int n = cin.nextInt(), m = cin.nextInt(), s = cin.nextInt() - 1, t = cin.nextInt() - 1;
EK demo = new EK();
demo.visited = new boolean[n];
demo.ends = new int[2 * m + 2];
demo.nexts = new int[2 * m + 2];
demo.weights = new int[2 * m + 2];
demo.preEdges = new int[n];
demo.heads = new int[n];
demo.pre = new int[n];
demo.saturatedWeights = new int[n];
demo.edgeNum = 1; // 初始为1,使得第一条边的编号为2,二进制为 10,第二条边(第一条边的反向边)编号为3,二进制为11,方便取异或
demo.init(m, cin); // 建图
cin.close();
System.out.println(demo.ek(s, t)); // 求最大流
}
// 求 s-t 最大流
public long ek(int s, int t) {
long max = 0;
while(bfs(s, t)) { // 不断增广
max += update(s, t); // 将每次增广路带来的流量增加到maxFlow上
Arrays.fill(visited, false); // 重置 visited
}
return max;
}
// 链式向前星建图
private void init(int m, Scanner cin) {
for(int i = 0; i < m; i++) {
int u = cin.nextInt() - 1, v = cin.nextInt() - 1, weight = cin.nextInt();
++edgeNum;
ends[edgeNum] = v;
weights[edgeNum] = weight;
nexts[edgeNum] = heads[u];
heads[u] = edgeNum;
++edgeNum;
ends[edgeNum] = u;
weights[edgeNum] = 0;
nexts[edgeNum] = heads[v];
heads[v] = edgeNum;
}
}
// bfs 增广
private boolean bfs(int s, int t){
if (s == t) return false; // 特判
Queue<Integer> q = new ArrayDeque<>();
q.add(s); // 起始顶点入队
visited[s] = true; // 立即置为已访问
saturatedWeights[s] = Integer.MAX_VALUE; // 增广路径到达 s 为止的饱和边权置为INF
while(!q.isEmpty()) {
int u = q.remove();
for(int edgeNo = heads[u]; edgeNo != 0; edgeNo = nexts[edgeNo]) {
int v = ends[edgeNo], weight = weights[edgeNo];
if(visited[v] || weight == 0) continue; // 跳过已访问或边权为0的情况
saturatedWeights[v] = Math.min(saturatedWeights[u], weight); // 记录到 v 为止的饱和边权
q.add(v); // v入队
visited[v] = true; // 立即置为已访问
preEdges[v] = edgeNo; // 记录边下标
pre[v] = u; // 记录前驱
if(v == t) return true; // 找到增广路
}
}
return false;
}
// 发送 & 发回流
private int update(int s, int t) {
int cur = t;
while(cur != s) {
int edge = preEdges[cur];
weights[edge] -= saturatedWeights[t]; // 增广路发送流
weights[edge ^ 1] += saturatedWeights[t]; // 反向增广路发回流
cur = pre[cur];
}
return saturatedWeights[t];
}

}

Dinic (Dinitz)

算法描述

Dinic算法 (迪尼克算法):EK 算法以 bfsbfs 方式实现 FF 方法中的增广操作,在 Dinic 算法中,采用 bfsbfsdfsdfs 结合的方式增广。首先引进 高度标号 (层次标号)分层图 的概念。以 bfsbfs 算法从 sstt ,标记 ss 为第 1 层,ss 的邻接顶点为第 2 层,以此类推 tt 为最后一层。执行一次 bfsbfs,赋予每个顶点高度标号信息,此时的图称作分层图。在此分层图内以 dfsdfs 方式反复寻找增广路并执行增广操作 (找到饱和边,发送和发回流),累计发送流。完成当前分层图的所有增广操作称作 一个阶段 ,一个阶段的发送流总和为此分层图的 阻塞流 。一个阶段结束后,将此阶段阻塞流累计到最大流中,接着重置高度标号信息,再次执行 bfsbfs 得到新的分层图并重复 dfsdfs 的增广操作,直到无法分层时说明 sstt 已无增广路,算法结束,此时得到的阻塞流总和即为 sstt 的最大流。

高度标号的作用:在以 dfsdfs 增广时,每次从顶点 uu 到其邻接顶点 vv 的路径增长,都要考察 vv 的的高度是否比 uu 的高度大 1,以此来保证路径总是能 按步增长到最后一层tt (在有增广路的前提下)。


算法过程

给定一张图 G(V,E)G(V, E) ,源点为 ss ,汇点为 tt 。求 GG 中从 sstt 的最大流 ff

  1. 设置一张残留网络 (残留图/残差图/残余图) GfG_fGfG_f 初始为 GG
  2. 调用 bfsbfs 方法,赋予 GfG_f 中所有顶点以高度标号,建立当前分层图。注意在赋予高度时会先判断是否已赋过值,若有则跳过,因此顶点高度不会因为它处于多个层次而被较高的高度值覆盖,即一个顶点的高度是是它离源点最近的高度。此方法返回布尔值,有分层图 (即有增广路) 时返回 truetrue ,否则返回 falsefalse
  3. 求增广路发送流。以 dfsdfs 方法搜索 sstt 的增广路 pp ,并实时地发送 / 发回流,返回值为本次增广路发送流 (饱和边权 cc)。在方法中比较当前边与上一条边的边权,取较小者,并将此取值作为递归 dfsdfs 方法的入参 (具体看代码)。若存在 pp ,也即递归调用到基本情形 (遇到 tt ),以当前边权减去 cc ,当前边的反向边权加上 cc ,然后返回 cc
  4. 求阻塞流。反复求上述增广路发送流,并累积到当前分层图阻塞流中。在步骤 3 中,当前分层图完成所有增广由于已无增广路,不会递进到基本情形 (不会遇到 tt ),返回 0 且层层返回 0,于是发送流不大于 0,此时即可返回当前分层图的阻塞流。
  5. 累计分层图阻塞流,清空所有顶点的高度标号信息 ,再次调用 bfsbfs 方法建立当前分层图,重复 2、3、 4,直到建立分层图的 bfsbfs 方法返回 falsefalse,表明当前图无法分层,即 sstt 无增广路,算法结束。此时 sstt 的最大流。

实例分析

以下图为例,增广过程如下:

image.png

第一次:s>a>c>t,s>a>c>t, 找到增广路后 returnreturn,得到饱和边权 ,修改当前边正向和反向边权后 returnreturn,再次修改当前边的正向和反向边权后 returnreturn (层层返回并修改边权),t>c>a>st>c>a>s 后得到本次增广路发送流为 2 。整体的顶点访问顺序为 s>a>c>t>c>a>ss>a>c>t>c>a>s

第二次:s>a>cs>a>c ,发现 (c,t)(c, t) 边权为 0,不满足邻边边权要大于 0 的条件,找 aa 的下一个满足层号关系的邻接顶点 dd ,然后 d>td>t ,同上,找到增广路后在returnreturn 过程中修改正反向边权,t>d>a>st>d>a>s 后得到本次增广路发送流为 1 。整体的顶点访问顺序为 s>a>c>d>t>d>a>ss>a>c>d>t>d>a>s

第三次:从 ss 开始,发现 (s,a)(s, a) 边权为 0,不满足邻边边权要大于 0 的条件,找 ss 的下一个满足层号关系的邻接顶点 bb ,然后 b>d>tb>d>t ,同上,找到增广路后在 returnreturn 过程中修改正反向边权,t>d>b>st>d>b>s 后得到本次增广路发送流为 22 。整体的顶点访问顺序为 s>b>d>t>d>b>ss>b>d>t>d>b>s

第四次:从 ss 开始,forfor 循环遍历所有邻接顶点均不满足邻边边权要大于 0 的条件,返回 0 ,得到最终结果当前分层图阻塞流 5。


时空复杂度

以下证明 Dinic 算法时间复杂度为:O(V2E)O(|V|^2|E|)

如下证明参考了COMPSCI 638: Graph Algorithms, Lecture3

1. BFS建立分层图

时间复杂度为 O(E)O(|E|) ,可参考无权最短路径复杂度分析,略。

2. 一次DFS增广

在分层图中,由于高度标号加一的判断条件限制, dfsdfs 只能沿着高度递增的方向从 sstt 推进,因此单次 dfsdfs 推进次数最多不超过 V1|V|-1 次,时间复杂度为 O(V)O(|V|)

3. 一个阶段中DFS增广次数

在分层图中,每次 dfsdfs 增广的结果使得一条高度递增方向上的饱和边 (u,v)(u, v) 消失,此边的反向边 (v,u)(v, u) 增加。如前述,增广路只能沿着高度递增的方向,而同一阶段内(同一张分层图中)反向边是沿着高度递减方向增加的。(u,v)(u, v) 消失后再次出现的条件是 (v,u)(v, u) 为此后某次增广路上的饱和边。再次强调,在 同一阶段内 (同一张分层图中),(v,u)(v, u)方向是高度递减的,不可能出现在增广路上,因此一条高度递增边 (u,v)(u, v) 被删除后不会再次出现。一次增广至少令一条高度递增边饱和并删除,高度递增边数量不大于E|E|,于是 一个阶段内 dfsdfs 的次数最多为 E|E|,即 O(E)O(|E|)。结合 2 可知,一个阶段的总复杂度为单次 dfsdfs 的复杂度与 dfsdfs 次数的乘积,即 O(VE)O(|V||E|)

4. 阶段数 (分层图建立次数)

此证明是难点。 如前述,在层高递增条件的限制下,sts - t 最短路径长度即 tt 的层高,因此最短路径最大不超过 V1|V| - 1。如果能证明每次分层图使得 sts - t 最短路径长 「严格」递增 ,则立即推出阶段数(分层图建立次数)的上限为 V|V|,复杂度为 O(V)O(|V|)。最短路径长严格递增的证明如下。

令相邻的两次分层图为 GfG_fGfG_{f'} ,以 d(u,v)d(u, v)d(u,v)d'(u, v) 分别表示GfG_fGfG_{f'} 中的 uuvv 的最短距离。

  1. Edmonds-Karp算法复杂度证明 已知 (这一点很重要),删正向边加反向边的操作,使得源点 ss 到任意一点 uu 的距离是非递减的,即 必有 d(s,u)d(s,u)d'(s, u) ≥ d(s, u)。对证明过程稍加改造很容易得到一个对汇点 tt 来说类似的结论,即删正向边加反向边的操作,使得任意一点 uu 到汇点 tt 的距离是非递减的,即 必有 d(u,t)d(u,t)d'(u, t) ≥ d(u, t)

    (1) d(s,u)d(s,u)d'(s, u) ≥ d(s, u)

    (2) d(u,t)d(u,t)d'(u, t) ≥ d(u, t)

对于 sts - t 来说有 d(s,t)d(s,t)d'(s, t) ≥ d(s, t)。接下来的证明目标是拿掉该不等式中的等号,证明 GfG_{f'} 相比 GfG_f,严格地有 d(s,t)>d(s,t)d'(s, t) > d(s, t)。可用反证法证明不可能取等号

  1. 假设 d(s,t)d(s,t)d'(s, t) ≥ d(s, t) 可以取到等号
    GfG_{f'} 有一条 sstt 的最短路径 ff',则一定存在边 (x,y)Ef(x, y) ∈ E_{f'},是 GfG_f 某条增广路饱和边的反向边。因为如果 EfE_{f'} 都是 GfG_f 中存在的边,由于 d(s,t)=d(s,t)d'(s, t) = d(s, t),这样的 ff' 路径在 GfG_f 中就一定已经被找到了。因此在 GfG_f 中有 (y,x)Ef(y, x) ∈ E_f ,于是有

    (3) d(s,x)=d(s,y)+1d(s, x) = d(s, y) + 1

image.png

  1. 由 (1) 和 (2) 易知

    (4) d(s,x)d(s,x)d'(s, x) ≥ d(s, x)

    (5) d(y,t)d(y,t)d'(y, t) ≥ d(y, t)

  2. ff' 路径长可写成 d(s,t)=d(s,x)+1+d(y,t)d'(s, t) = d'(s, x) + 1 + d'(y, t) ,应用 (4) 和 (5) 得到

    d(s,t)=d(s,x)+1+d(y,t)d(s,x)+1+d(y,t)d'(s, t) = d'(s, x) + 1 + d'(y, t) ≥ d(s, x) + 1 + d(y, t)

    又由 (3) 得到

    d(s,t)=d(s,x)+1+d(y,t)d(s,x)+1+d(y,t)=d(s,y)+d(y,t)+2d'(s, t) = d'(s, x) + 1 + d'(y, t) ≥ d(s, x) + 1 + d(y, t) = d(s, y) + d(y, t) + 2

    (6) d(s,t)d(s,t)+2d'(s, t) ≥ d(s, t) + 2

由此,d(s,t)=d(s,t)d'(s, t) = d(s, t) 的假设不成立,但我们知道 d(s,t)d(s,t)d'(s, t) ≥ d(s, t) ,因此有 d(s,t)>d(s,t)d'(s, t) > d(s, t) ,也即证明了 Dinic 算法中下一分层图的最短路径长度与前一分层图相比严格递增


综上,总时间复杂度为每个阶段建立分层图的复杂度 O(E)O(|E|) 与在该分层图内执行的总 dfsdfs 复杂度 O(VE)O(|V||E|) 之和乘以阶段数 O(V)O(|V|),为 O((E+VE)V)O((|E|+|V||E|)*|V|),即 O(V2E)O(|V|^2|E|)


空间复杂度:采用邻接表存图,则存图空间为 O(V+E)O(|V|+|E|) ,采用邻接矩阵为 O(V2)O(|V|^2)bfsbfs 中的队列为 O(V)O(|V|)dfsdfs 中的递归栈为 O(V)O(|V|) 。总体来说取决于存图方式。


代码

1
// TODO

实战应用

给出以下图论相关题目及作者写的题解,供读者在阅读本文后自练自查。

※ 不断更新中。

题目 难度 题解
323. 无向图中连通分量的数目 中等 题解
547.省份数量 中等 题解
200.岛屿数量 中等 题解
695. 岛屿的最大面积 中等 题解
785. 判断二分图 中等 题解
684. 冗余连接 中等 题解
1631. 最小体力消耗路径 中等 题解
329. 矩阵中的最长递增路径 困难 题解
207. 课程表 中等 题解
210. 课程表 II 中等 题解
269. 火星词典 困难 题解
444. 序列重建 中等 题解
814.无向图中的最短路径 (其他平台题目) 中等
743. 网络延迟时间 中等
1334. 阈值距离内邻居最少的城市 中等
1135. 最低成本联通所有城市 中等
题目 UF BFS/DFS Topo SP MST MF
323
547
200
695
785
684
1631
329
207
210
269
444
814
743
1334
1135
1
2
3
4
5
6
7
8
UF: Union-Find 并查集
FS: DFS & BFS 深搜和广搜
Topo: Topological Sort 拓扑排序
SP: Shortest Path 最短路
MST: Minimum Spanning Tree 最小生成树
MF: Maximum Flow 最大流

标记 〇 表示该题与该算法相关。


【更新日志】

[2022-09-20]

  • 修改了 Bellman-Ford 方法求解 743. 网络延迟时间 的代码中的一处 bug 。即若本次全量松弛执行了松弛操作,则 finished = false ,随后在开始下一次全量松弛前应将 finished 置回 true。感谢 @nTouKxAnj3 (@河) 、 @rain-roc (@RainRoc) 指出。

[2022-09-08]

  • 新增了 Prim 算法正确性证明小节。(小节路径: 最小生成树 > Prim > 朴素版 > 正确性证明)

[2022-07-17]

  • 大幅修改了「最大流最小割定理」中的「证明 k=mk=m」小节的内容,修正了前一天发现的证明过程中的错误 (结论正确,过程有误)。虽然作者认为修正后的证明是正确的,但如果读者仍觉得有问题,还请评论或私信联系。

[2022-07-16]

  • 在「最大流最小割定理」一节的「最小割的大小」小节中,原文称「现在我们来指出一个 l=4l = 4 的割 … … 就得到了原图的一个大小为 4 的 sts-t 割」。根据原文的描述,似乎无法指出大小为 4 的割。这个部分有待更正,目前还在思考。。。暂时先以删除线划去,并注明证明暂不成立。
  • 在「Edmonds-Karp」-「时间复杂度」-「2.2 增广次数」中,原文称「…之后 (u,v)(u, v) 要再次出现的前提是 (v,u)(v, u) 为饱和边…」,这是错误的。正确表述是「…之后 (u,v)(u, v) 要再次出现的前提是 (v,u)(v, u)增广路上的边 …」,后续论证仍然成立。
  • 上述两点均由 @migeater 指出,非常感谢!🙏

[2022-07-01]

  • 在「链式向前星」中增加图示及若干补充描述。

[2022-06-20]

  • 新增「网络流」一节。

[2022-06-19]

  • 在「图的表示」一节中新增「链式向前星」小节,并在后续代码中给出此存图方式的代码实例。