包括了图论的一些常用算法的定义、说明、代码实现。
图的存储
设有向图 G = ( V , E ) G=(V,E) G = ( V , E ) ,V V V 为点集,E E E 为边集,( x , y ) (x, y) ( x , y ) 表示从 x x x 到 y y y 的有向边,其边权为 w ( x , y ) w(x, y) w ( x , y ) 。记 n = ∣ V ∣ n = |V| n = ∣ V ∣ , m = ∣ E ∣ m = |E| m = ∣ E ∣ 。
对于图,一般使用邻接矩阵或邻接表来进行存储。前者存储稠密图,后者存储稀疏图。所谓稠密图,指的是 m m m 近似于 n 2 n^2 n 2 。
记邻接矩阵 A A A ,其定义可以表示如下:
A [ i , j ] = { 0 i = j w ( i , j ) ( i , j ) ∈ E + ∞ ( i , j ) ∉ E A[i, j] =
\begin{cases}
0 & & i=j \\
w(i, j) & & (i, j) \in E \\
+\infty & & (i, j) \notin E
\end{cases}
A [ i , j ] = ⎩ ⎪ ⎪ ⎨ ⎪ ⎪ ⎧ 0 w ( i , j ) + ∞ i = j ( i , j ) ∈ E ( i , j ) ∈ / E
其空间复杂度为 O ( n 2 ) O(n^2) O ( n 2 ) 。
稀疏图表示 m m m 近似于 k n kn k n ,其中 k k k 为常数。稀疏图使用邻接表存储。
邻接表可以看成带有索引数组的多个数据链表的结构集合。数据被分为若干类,每一类的数据构成一个链表,每一类有一个代表数据,作为表头。所有表头构成一个表头数组,作为一个可以随机访问的索引。
在一个具有 n n n 个点, m m m 条边的有向图结构中,我们可以记类别为该边的起点编号。这样 m m m 条边就分成 n n n 类,记第 x x x 类为“从 x x x 出发的所有边”,通过表头head[x]
,可以定位从 x x x 出发的所有边。
使用如下数据构建邻接表:
head[x]
表示由节点x
连接的边的链表的头节点
edge[i]
表示边i
的目标节点
weight[i]
表示边i
的权重
ne[i]
表示对应链表中的下一条边的编号
在这种表示方法下,向图中加入一条边可以写为:
1 2 3 4 5 6 7 int tot; void add (int u, int v, int w) { edge[++tot] = v, weight[tot] = w; ne[tot] = head[x], head[x] = tot; }
遍历从一个点出发的所有边可以写为:
1 2 3 4 5 6 7 for (int i = head[u]; ~i; i = ne[i]) { int v = edge[i], w = weight[i]; }
无向图的存图方式与有向图完全相同。在邻接数组中,有 A [ i , j ] = A [ j , i ] A[i, j] = A[j, i] A [ i , j ] = A [ j , i ] 。在邻接表中,可通过添加正反两条边的方式存储无向图。此时,通过置tot
初值为1,我们可以获得编号为2和3、4和5……等的正反边。注意到此时,edge[x]
与edge[x ^ 1]
分别表示正反边,且正向边的编号一定为偶数。
单源最短路径(Single Source Shortest Path)
给定一张有向图 G = ( V , E ) G=(V, E) G = ( V , E ) ,V V V 为点集,E E E 为边集,n = ∣ V ∣ n = |V| n = ∣ V ∣ , m = ∣ E ∣ m = |E| m = ∣ E ∣ ,节点以 [ 1 , n ] [1, \ n] [ 1 , n ] 编号,( x , y , z ) (x, y, z) ( x , y , z ) 描述一条从 x x x 出发,到达 y y y ,权重为 z z z 的有向边。求 d i s t [ n ] dist[n] d i s t [ n ] 数组,使得 d i s t [ i ] dist[i] d i s t [ i ] 表示从起点 1 1 1 到节点 i i i 的最短路径的长度。
Dijkstra算法
算法流程如下:
初始化 d i s t [ 1 ] dist[1] d i s t [ 1 ] 为 0 0 0 ,其余为无穷大
找出未被标记的, d i s t [ x ] dist[x] d i s t [ x ] 最小的节点 x x x ,标记节点 x x x
扫描 x x x 的所有出边 ( x , y , z ) (x, y, z) ( x , y , z ) ,若 d i s t [ y ] > d i s t [ x ] + z dist[y] > dist[x] + z d i s t [ y ] > d i s t [ x ] + z ,则更新 d i s t [ y ] dist[y] d i s t [ y ] 为 d i s t [ x ] + z dist[x] + z d i s t [ x ] + z 。
重复 2 ~ 3步骤,直到所有节点都被标记
Dijkstra算法基于贪心思想,适用于无负权边的图。不存在负权边时,全局最小值 d i s t [ x ] dist[x] d i s t [ x ] 不可能再被更新,故其即为 1 1 1 到 x x x 的最短路。不断选择全局最小值并更新,即可计算所有点的最短路长度。
朴素
在稠密图下,使用邻接矩阵存图,寻找 d i s t [ x ] dist[x] d i s t [ x ] 的方式是:直接遍历 d i s t dist d i s t 数组。同时,每一次遍历领边需要检查所有点,易得时间复杂度为 O ( n 2 ) O(n^2) O ( n 2 ) 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 int a[N][N];int dist[N];bool st[N];void dijkstra () { memset (dist, 0x3f , sizeof (dist)); memset (st, 0 , sizeof (0 )); dist[1 ] = 0 ; for (int i = 1 ; i < n; ++i) { int x = 1 ; for (int j = 1 ; j <= n; ++j) if (!st[j] && dist[j] < dist[x]) x = j; st[x] = true ; for (int y = 1 ; y <= n; ++y) dist[y] = min (dist[y], dist[x] + a[x][y]); } }
堆优化
容易想到,使用堆维护全局最小值。
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 using PII = pair<int , int >;void dijkstra () { priority_queue<PII, vector<PII>, greater<PII>> pq; memset (dist, 0x3f , sizeof (dist)); memset (st, 0x3f , sizeof (dist)); dist[1 ] = 0 ; pq.push ({0 , 1 }); while (pq.size ()) { int d, x; tie (d, x) = pq.top (); pq.pop (); if (st[x] || d > dist[x]) continue ; st[x] = true ; for (int i = head[x]; ~i; i = ne[i]) { int y = edge[i], w = weight[i]; if (dist[y] > dist[x] + w) { dist[y] = dist[x] + w; pq.push ({dist[y], y}); } } } }
考虑到更新每个边时都有可能入堆,故实际复杂度为 O ( m l o g m ) O(mlogm) O ( m l o g m ) ,考虑到 m < n 2 m < n^2 m < n 2 ,即复杂度为 O ( m l o g n ) O(mlogn) O ( m l o g n )
Bellman-Ford算法和SPFA算法
Bellman-Ford基于迭代思想。若所有边都满足 d i s t [ y ] ≤ d i s t [ x ] + z dist[y] \le dist[x] + z d i s t [ y ] ≤ d i s t [ x ] + z ,则 d i s t dist d i s t 即为最短路数组。
扫描所有边 ( x , y , z ) (x, y, z) ( x , y , z ) ,若 d i s t [ y ] > d i s t [ x ] + z dist[y] > dist[x] + z d i s t [ y ] > d i s t [ x ] + z ,则更新 d i s t [ y ] dist[y] d i s t [ y ] 。
重复上述步骤,直到没有更新发生。
Bellman-Ford算法的时间复杂度为 O ( n m ) O(nm) O ( n m ) 。
SPFA又称为“队列优化的Bellman-Ford算法”,SPFA算法流程如下:
建立队列,初始队列中只含有节点1。
取出队头节点 x x x ,扫描所有出边 ( x , y , z ) (x, y, z) ( x , y , z ) ,若 d i s t [ y ] > d i s t [ x ] + z dist[y] > dist[x] + z d i s t [ y ] > d i s t [ x ] + z ,则更新 d i s t [ y ] dist[y] d i s t [ y ] ,若 z z z 不在队中,将 z z z 入队。
重复2,直到队列为空
SPFA算法基于一种朴素的思想:只有更新了 x x x 之后,其对应的出边 y y y 才有可能被更新。SPFA避免了对不需要扩展的节点的冗余扫描,在随机图上时间复杂度为 O ( k m ) O(km) O ( k m ) ,在特殊构造的图上可能退化为 O ( n m ) O(nm) O ( n m ) 。
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 void spfa () { memset (dist, 0x3f , sizeof (dist)); queue<int > q; dist[1 ] = 0 , st[1 ] = true ; q.push (1 ); while (q.size ()) { int x = q.front (); p.pop (); st[x] = false ; for (int i = head[x]; ~i; i = ne[i]) { int y = edge[i], w = edge[i]; if (dist[y] > dist[x] + w) { dist[y] = dist[x] + w; if (!st[y]) { q.push (y); st[y] = true ; } } } } }
Bellman-Ford算法与SPFA算法适用于无负环图。
任意两点间最短路
一般使用Floyd算法在 O ( n 3 ) O(n^3) O ( n 3 ) 的时间内求解任意两点间最短路。
Floyd算法基于DP。记 D [ k , i , j ] D[k, i, j] D [ k , i , j ] 表示经过若干个编号不超过 k k k 的节点,从 i i i 到 j j j 的最短路。该问题可划分为两个子问题:经过编号不超过 k − 1 k-1 k − 1 的点从 i i i 到 j j j ,或从 i i i 先到 k k k 再到 j j j 。故有:
D [ k , i , j ] = m i n ( D [ k − 1 , i , j ] , D [ k − 1 , i , k ] + D [ k − 1 , k , j ] ) D[k, i, j] = min(D[k - 1, i, j], D[k - 1, i, k] + D[k - 1, k, j])
D [ k , i , j ] = m i n ( D [ k − 1 , i , j ] , D [ k − 1 , i , k ] + D [ k − 1 , k , j ] )
k k k 表示阶段,所以必须置于最外层循环中。k k k 这一维在这一情况下可以省略。
D [ i , j ] = m i n ( D [ i , j ] , D [ i , k ] + D [ k , j ] ) D[i, j] = min(D[i, j], D[i, k] + D[k, j])
D [ i , j ] = m i n ( D [ i , j ] , D [ i , k ] + D [ k , j ] )
D D D 矩阵的初始值与邻接矩阵相同。
1 2 3 4 5 6 7 void floyd () { for (int k = 1 ; k <= n; ++i) for (int i = 1 ; i <= n; ++i) for (int j = 1 ; j <= n; ++j) d[i][j] = min (d[i][j], d[i][k] + d[k][j]); }
类似地,可以计算传递闭包
1 2 3 4 5 6 7 void floyd () { for (int k = 1 ; k <= n; ++i) for (int i = 1 ; i <= n; ++i) for (int j = 1 ; j <= n; ++j) d[i][j] |= d[i][k] & d[k][j]; }
最小生成树(Minimum Spanning Tree)
给定一张边带权的无向图 G = ( V , E ) G = (V, E) G = ( V , E ) ,n = ∣ V ∣ n=|V| n = ∣ V ∣ , m = ∣ E ∣ m=|E| m = ∣ E ∣ 。由 V V V 中全部顶点和 E E E 中 n − 1 n-1 n − 1 条边构成的无向联通子图被称为 G G G 的一棵生成树。边权值之和最小的生成树被称为无向图 G G G 的最小生成树。
定理
任意一棵最小生成树一定包含无向图中权值最小的边。证明略。
推论
给定一张无向图 G = ( V , E ) G = (V, E) G = ( V , E ) ,n = ∣ V ∣ n=|V| n = ∣ V ∣ , m = ∣ E ∣ m=|E| m = ∣ E ∣ 。从 E E E 中选出 k < n − 1 k<n-1 k < n − 1 条边构成 G G G 的一个生成森林,若在从剩余的 m − k m-k m − k 条边中选 n − k − 1 n-k-1 n − k − 1 条添加到生成森林中,使之成为生成树,并且选出的边的权值之和最小。则该生成树一定包含这 m − k m-k m − k 条边中连接森岭的两个不连通节点的权值最小的边。
Kruskal算法
基于上述推论可以得出Kruskal算法:维护最小生成森林,从剩余边中挑出一条权值最小的连接两个不连通树的边,并加入到最小生成森林中,直到它变成树。
建立并查集,每个点各自构成一个集合
把所有边按照权值大小从小到大排序
依次扫描每一条边,如果 ( x , y ) (x,y) ( x , y ) 属于同意集合,则忽略
否则,合并 x x x ,y y y 所在的集合,并把 z z z 累加到答案中
所有边扫描完成后,第4步中的边构成最小生成树
时间复杂度为 O ( m l o g n ) O(mlogn) O ( m l o g n ) 。
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 struct Edge { int x, y, z; } edges[M];int p[N];int find (int x) { if (x == p[x]) return x; return p[x] = find (x); } int kruskal () { int sum = 0 ; sort (edge + 1 , edge + 1 + m, [](Edge a, Edge b) -> bool {return a.z < b.z}); for (int i = 1 ; i <= n; ++i) p[i] = i; for (int i = 1 ; i <= m; ++i) { int a = find (edge[i].x); int b = find (edge[i].y); if (a == b) continue ; p[a] = b; res += edge[i].z; } return res; }
Prim算法
Prim算法总是维护最小生成树的一部分。
在任意时刻,设已经确定属于最小生成树的节点集合为 T T T ,剩余节点集合为 S S S ,Prim算法找到 m i n x ∈ S , y ∈ T { z } min_{x\in S, y\in T}\{z\} m i n x ∈ S , y ∈ T { z } ,然后把点 x x x 从 S S S 移到 T T T ,并累加边权 z z z 。
Prim算法的时间复杂度为 O ( n 2 ) O(n^2) O ( n 2 ) ,因此常用于邻接矩阵存储的稠密图。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 int grid[N][N], dist[N];bool st[N]; int prim () { int res = 0 ; memset (dist, 0x3f , sizeof (dist)); memset (st, 0 , sizeof (st)); dist[1 ] = 0 , st[1 ] = 1 ; for (int i = 2 ; i <= n; ++i) { int x = 1 ; for (int j = 2 ; j <= n; ++j) if (!st[j] && dist[j] < dist[x]) x = j; st[x] = true ; for (int y = 1 ; y <= n; ++y) if (!st[y]) dist[y] = min (dist[y], grid[x][y]); } }
树的直径
树中最远的两个节点之间的距离被称为树的直径,连接这两点的路径被称为树的最长链。
树的直径一般有两种求法
树形DP
不妨设 1 号节点为根,图就可以看作有根树。设 D [ x ] D[x] D [ x ] 表示从节点 x x x 出发走向以 x x x 为根的子树,能够到达的节点的最远的距离。设 x x x 的子节点为 y 1 , y 2 , ⋅ ⋅ ⋅ , y t y_1,y_2,···,y_t y 1 , y 2 , ⋅ ⋅ ⋅ , y t , e d g e ( x , y ) edge(x, y) e d g e ( x , y ) 表示边权,显然有
D [ x ] = m a x 1 ≤ i ≤ t { D [ y i ] + e d g e ( x , y i ) } D[x] = max_{1\le i\le t}\{D[y_i] + edge(x, y_i)\}
D [ x ] = m a x 1 ≤ i ≤ t { D [ y i ] + e d g e ( x , y i ) }
设经过节点 x x x 的直径长度为 F [ x ] F[x] F [ x ] ,整棵树的直径就是 m a x 1 ≤ x ≤ n { F [ x ] } max_{1\le x\le n}\{F[x]\} m a x 1 ≤ x ≤ n { F [ x ] } 。
显然,根据此定义,x x x 的最长链有可能经过父节点。但是此时父节点的最长链的枚举一定包括了此链,所以求 F [ x ] F[x] F [ x ] 时只需在子树上求解即可。
F [ x ] F[x] F [ x ] 可以由四部分构成:从 y i y_i y i 到 y i y_i y i 子树中最远距离,边 ( x , y i ) (x,y_i) ( x , y i ) ,边 ( x , y j ) (x,y_j) ( x , y j ) ,从 y j y_j y j 到 y j y_j y j 子树中最远距离。
在求解过程中,D [ x ] D[x] D [ x ] 恰好保存枚举过的 D [ y i ] + e d g e ( x , y i ) D[y_i] + edge(x, y_i) D [ y i ] + e d g e ( x , y i ) 的最大值,利用此即可避免使用二重循环。也就是说,我们首先利用 D [ x ] + D [ y j ] + e d g e ( x , y j ) D[x] + D[y_j] + edge(x, y_j) D [ x ] + D [ y j ] + e d g e ( x , y j ) 更新 F [ x ] F[x] F [ x ] ,再用 D [ y j ] + e d g e ( x , y j ) D[y_j] + edge(x, y_j) D [ y j ] + e d g e ( x , y j ) 更新 D [ x ] D[x] D [ x ] 。
1 2 3 4 5 6 7 8 9 10 11 12 13 void dp (int x) { st[x] = 1 ; for (int i = head[x]; ~i; i = ne[i]) { int y = edge[i]; if (st[y]) continue ; dp (y); res = max (res, d[x] + d[y] + weight[i]); d[x] = max (d[x], d[y] + weight[i]); } }
两次bfs
这种方法可以算出树上的具体节点,但是无法适用于带负权边的树
从任意一个节点出发,遍历树,求出与出发距离最远的节点,记为 p p p
从 p p p 出发,再进行一次遍历,求出与 p p p 距离最远的节点,记为 q q q
从 p p p 到 q q q 的路径就是树的直径。
在第二步中,可以记录下来每个点第一次被访问时的前驱节点,最后从 q q q 递归到 p p p ,即可得到直径的具体方案。
最近公共祖先(LCA)
给定一棵有根树,若节点 z z z 既是节点 x x x 的祖先,也是节点 y y y 的祖先,则称 z z z 是 x , y x, y x , y 的公共祖先。在 x , y x,y x , y 的所有公共祖先中,深度最大的一个称为 x , y x,y x , y 的最近公共祖先。
求解 L C A LCA L C A 的算法有三种:
向上标记法
从 x x x 向上走到根节点,并标记所有经过的节点
从 y y y 向上走到根节点,当第一次遇到已标记的节点时,就找到了 L C A ( x , y ) LCA(x, y) L C A ( x , y )
树上倍增法
设 F [ x , k ] F[x, k] F [ x , k ] 表示 x x x 的 2 k 2^k 2 k 辈祖先,若不存在则为 0 0 0 。F [ x , 0 ] F[x, 0] F [ x , 0 ] 就是 x x x 的父节点。除此之外,显然有 ∀ k ∈ [ 1 , l o g n ] , F [ x , k ] = F [ F [ x , k − 1 ] , k − 1 ] \forall k \in [1, logn],\ F[x, k] = F[F[x, k-1], k-1] ∀ k ∈ [ 1 , l o g n ] , F [ x , k ] = F [ F [ x , k − 1 ] , k − 1 ] 。
我们可以对树进行广度优先遍历,按照层次顺序,在节点入队之前,计算在 F F F 数组中对应的值。这是预处理部分,时间复杂度为 O ( n l o g n ) O(nlogn) O ( n l o g n ) 。
基于 F F F 数组计算 L C A ( x , y ) LCA(x, y) L C A ( x , y ) 如下:
设 d [ x ] d[x] d [ x ] 表示 x x x 的深度。不妨设 d [ x ] ≥ d [ y ] d[x] \ge d[y] d [ x ] ≥ d [ y ] (否则可交换 x , y x,y x , y )。
用二进制拆分,把 x x x 上调到与 y y y 同一深度。
若此时 x = y x=y x = y ,说明 L C A ( x , y ) = y LCA(x,y)=y L C A ( x , y ) = y
用二进制拆分思想,把 x , y x,y x , y 同时向上调整,并且保持深度一致且二者不相会。
此时 F [ x , 0 ] F[x,0] F [ x , 0 ] 就是 L C A LCA L C A 。
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 void build () { queue<int > q; q.push (root); depth[root] = 1 ; while (q.size ()) { int u = q.front (); q.pop (); for (int i = head[u]; ~i; i = ne[i]) { int v = edge[i]; if (depth[v]) continue ; depth[v] = depth[u] + 1 ; f[v][0 ] = u; for (int j = 1 ; j < K; ++j) f[v][j] = f[f[v][j - 1 ]][j - 1 ]; q.push (v); } } } int lca (int x, int y) { if (depth[x] > depth[y]) swap (x, y); for (int i = K - 1 ; i >= 0 ; --i) if (depth[f[y][i]] >= depth[x]) y = f[y][i]; if (x == y) return x; for (int i = K - 1 ; i >= 0 ; --i) if (f[x][i] != f[y][i] && f[x][i] != 0 ) x = f[x][i], y = f[y][i]; return f[x][0 ]; }
LCA的Tarjan算法
在深度优先遍历的任意时刻,树中节点分为三类:
已经访问完毕并且回溯的节点。在这些节点上标记整数2。
已经开始递归,但是尚未回溯的节点,这些节点就是正在访问的节点 x x x 和 x x x 的祖先。在这些节点上标记1。
尚未访问的节点,这些节点没有标记。
对于正在访问的节点 x x x ,若 y y y 是已经访问完毕并且回溯的节点,则 L C A ( x , y ) LCA(x, y) L C A ( x , y ) 等于从 y y y 向上走到根,第一个遇到的标记为 1 的节点。
可以利用并查集进行优化,当一个节点获得整数 2 时,把它所在的集合合并到它的父节点所在的集合中(合并时它的父节点一定为 1,且单独构成一个集合)。
在这种优化下,只需查询 y y y 所在集合的代表元素,就等价于从 y y y 一直向上走到标记1的节点,即 L C A ( x , y ) LCA(x,y) L C A ( x , y ) 。
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 int p[N], dist[N], st[N], ans[M];vector<int > queries[N], queries_id[N]; void add (int a, int b, int w) { edge[++tot] = b, weight[tot] = w; ne[tot] = head[a], head[a] = tot; } void add_query (int a, int b, int id) { queries[a].push_back (b), queries_id[a].push_back (id); queries[b].push_back (a), queries_id[b].push_back (id); } int find (int x) { if (p[x] == x) return x; return p[x] = find (p[x]); } void tarjan (int u) { st[u] = 1 ; for (int i = head[u]; ~i; i = ne[i]) { int v = edge[i], w = weight[i]; if (st[v]) continue ; dist[v] = dist[u] + w; tarjan (v); p[v] = u; } for (int i = 0 ; i < queries[u].size (); ++i) { int v = queries[u][i], id = queries_id[u][i]; if (st[v] == 2 ) { int lca = find (v); ans[id] = min (ans[id], dist[u] + dist[v] - 2 * dist[lca]); } } st[u] = 2 ; }
基环树
在树上任意添加一条边,会形成一个环,我们称之为“基环树”。求解基环树问题的一般方法是,找出图中的唯一一个环,将其作为广义的根节点,把环之外的部分按照树来处理。
负环与差分约束
负环
判断负环有两种方式:
Bellman-Ford判定负环
若经过 n 轮迭代,算法仍未结束(仍有能产生更新的边),则图中存在负环
SPFA判定负环
设 cnt[x] 表示从 1 到 x 的最短路径包含的边数,cnt[1] = 0,当更新dist数组时同样更新cnt,若存在cnt[y] ≥ n,则有负环。
差分约束
差分约束是一组形如 X i − X j ≤ c k X_i - X_j \le c_k X i − X j ≤ c k 的不等式,我们要解决的问题是:求一组 X i = a i X_i=a_i X i = a i 使得所有约束条件得到满足。
考虑到差分约束类似于三角不等式,对每个约束条件在图中作一条边,增加零号节点并使 X i − X 0 ≤ 0 X_i-X_{0} \le 0 X i − X 0 ≤ 0 ,从零号节点开始求最短路,所得dist数组即为一个解。若存在负环则无解。
Tarjan算法与有向图连通性
给定有向图 G = ( V , E ) G=(V,E) G = ( V , E ) ,若存在 r ∈ V r \in V r ∈ V ,满足从 r r r 出发能够到达 V V V 中所有的点,则称 G G G 是一个“流图”(Flow Graph),记为 ( G , r ) (G,r) ( G , r ) ,其中 r r r 称为流图的流点。
在一个流图 ( G , r ) (G,r) ( G , r ) 上从 r r r 出发进行深度优先遍历,每个点只访问一次。所有发生递归的边 ( x , y ) (x,y) ( x , y ) (换而言之,从 x x x 到 y y y 是对 y y y 的第一次访问)构成一棵以 r r r 为根的树,我们把它称为流图 ( G , r ) (G,r) ( G , r ) 的搜索树 。
同时,在深度优先遍历的过程中,按照每一个节点第一次被访问的时间顺序,一次给予流图中 N N N 个节点 1 ∼ N 1\sim N 1 ∼ N 的整数标记,该标记被称为时间戳 ,记为d f n [ x ] dfn[x] d f n [ x ] 。
流图中的每一条有向边 ( x , y ) (x,y) ( x , y ) 必定是以下四种之一:
树枝边,指搜索树中的边,即 x x x 是 y y y 的父节点。
前向边,指搜索树中 x x x 是 y y y 的祖先节点。
后向边,指搜索树中 y y y 是 x x x 的祖先节点。
横叉边,指除了以上三种情况的边,它一定满足 d f n [ y ] < d f n [ x ] dfn[y] < dfn[x] d f n [ y ] < d f n [ x ] 。
有向图的强连通分量(Strongly Connected Component, SCC)
给定一张有向图。若对于图中任意两个节点 x , y x,y x , y ,既存在从 x x x 到 y y y 的路径,也存在从 y y y 到 x x x 的路径,则称该有向图是“强连通图”。有向图的极大强连通子图被称为“强连通分量”,简记为SCC。
Tarjan算法的思路是尽量找到能与某个节点构成环的所有节点。
Tarjan算法在深度优先遍历的同时维护了一个栈。当访问到节点 x x x 时,栈中需要保存以下两类节点:
搜索树上 x x x 的祖先节点,记为集合 a n c ( x ) anc(x) a n c ( x ) 。
设 y ∈ a n c ( x ) y \in anc(x) y ∈ a n c ( x ) 。若存在后向边 ( x , y ) (x,y) ( x , y ) ,则 ( x , y ) (x,y) ( x , y ) 与 y y y 到 x x x 的路径一起形成环。
已经访问过,并且存在一条路径到达 a n c ( x ) anc(x) a n c ( x ) 的节点。
设 z z z 是一个这样的节点,从 z z z 出发存在一条路径到达 y ∈ a n c ( x ) y\in anc(x) y ∈ a n c ( x ) 。若存在横叉边 ( x , z ) (x,z) ( x , z ) ,则 ( x , z ) (x,z) ( x , z ) 、z z z 到 y y y 的路径、y y y 到 x x x 的路径形成一个环。
综上所述,栈中的节点就是能够与从 x x x 出发的后向边或横叉边形成环的节点。
追溯值
设 s u b t r e e ( x ) subtree(x) s u b t r e e ( x ) 表示流图的搜索树中以 x x x 为根的子树。x x x 的追溯值 l o w [ x ] low[x] l o w [ x ] 定义为满足以下条件的节点的最小时间戳。
存在栈中。
存在一条从 s u b t r e e ( x ) subtree(x) s u b t r e e ( x ) 出发的有向边,以该点为终点。
根据定义,Tarjan算法按照以下步骤计算“追溯值”。
当节点第一次被访问时,x x x 入栈,初始化 l o w [ x ] = d f n [ x ] low[x]=dfn[x] l o w [ x ] = d f n [ x ] 。
扫描从 x x x 出发的每条边 ( x , y ) (x,y) ( x , y ) 。
若 y y y 没被访问过,则说明 ( x , y ) (x,y) ( x , y ) 是树枝边,递归访问 y y y ,从 y y y 回溯之后,令 l o w [ x ] = m i n ( l o w [ x ] , l o w [ y ] ) low[x]=min(low[x],low[y]) l o w [ x ] = m i n ( l o w [ x ] , l o w [ y ] ) 。
若 y y y 被访问过且在栈中,则令 l o w [ x ] = m i n ( l o w [ x ] , d f n [ y ] ) low[x]=min(low[x],dfn[y]) l o w [ x ] = m i n ( l o w [ x ] , d f n [ y ] ) 。
从 x x x 回溯之前,判断是否有 l o w [ x ] = d f n [ x ] low[x]=dfn[x] l o w [ x ] = d f n [ x ] 。若成立,则不断从栈中弹出节点,直至 x x x 出栈。
强连通分量判定法则
在追溯值的计算中,若从 x x x 回溯前,有 l o w [ x ] = d f n [ x ] low[x]=dfn[x] l o w [ x ] = d f n [ x ] ,则栈中从 x x x 到栈顶的所有节点构成一个强连通分量。
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 stack<int > stk; bool ins[N]; int c[N]; int dfn[N], low[N], num, cnt; vector<int > scc[N]; void tarjan (int u) { dfn[u] = low[u] = ++num; stk.push (u), ins[u] = true ; for (int i = head[u]; ~i; i = ne[i]) { int v = edge[i]; if (!dfn[v]) { tarjan (v); low[u] = min (low[u], low[v]); } else if (ins[v]) low[u] = min (low[u], dfn[v]); } if (dfn[u] == low[u]) { cnt++; int x; do { x = stk.top (), ins[x] = false ; stk.pop (); c[x] = cnt, scc[cnt].push_back (x); } while (x != u); } }
可以使用缩点的方式建立新的有向无环图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 void add_c (int x, int y) { vc[++tc] = y, nc[tc] = hc[x], hc[x] = tc; } for (int x = 1 ; x <= n; ++x) for (int i = head[x]; ~i; i = ne[i]) { int y = edge[i]; if (c[x] == c[y]) continue ; add_c (c[x], c[y]); }
2-SAT
有 N N N 个变量,每个变量只有两种可能的取值。再给定 M M M 个条件,每个条件都是对两个变量的取值限制。求是否存在对 N N N 个变量的合法取值,使得 M M M 个条件均满足,这就是 2-SAT 问题。
该问题判定方法如下:
建立 2 N 2N 2 N 个节点的有向图,每个变量 A i A_i A i 对应 2 2 2 个节点,一般设为 i i i 和 i + N i+N i + N 。
考虑每个条件,形如“若变量 A i A_i A i 赋值为 A i , p A_{i,p} A i , p ,则变量 A j A_j A j 赋值为 A j , q A_{j,q} A j , q ”,其中 p , q ∈ { 0 , 1 } p,q\in \{0, 1\} p , q ∈ { 0 , 1 } 。
用 Tarjan 算法求出有向图中所有的强连通分量。
若存在 i ∈ [ 1 , N ] i\in [1,N] i ∈ [ 1 , N ] ,满足 i i i 和 i + N i+N i + N 属于同一个强连通分量,则存在矛盾。否则可以构造出一个可行解。
注意第二点需要考虑等价的逆否命题。
欧拉路问题
给定一张无向图,若存在一条从节点 S S S 到节点 T T T 的路径,恰好不重不漏地经过每条边一次(可以重复经过顶点),则称该路径为 S S S 到 T T T 的欧拉路。
特别的,若回到起点 S S S ,则称该路径为欧拉回路。存在欧拉回路的无向图被称为欧拉图。
欧拉图的判定:一张图为无向图,当且仅当无向图连通,并且每个点的度数都是偶数。
欧拉回路的判定:一张图中存在欧拉回路,当且仅当无向图连通,并且图中恰好有两个点的度数为奇数,其他节点的度数都是偶数。这样个点就是欧拉路的起点和终点。
求欧拉回路的一种流程为:
1 2 3 4 5 6 7 8 9 10 void dfs(int x) 对于从x出发的每条边(x,y) 如果该边没有访问过 把这条边标记为已访问 dfs(y) 把y入栈 主函数中: dfs(1) 倒序输出栈中所有的节点
一种优化方法是,对于一个访问过的边,在邻接表中令表头直接指向下一条边,这就避免了重复访问边。
因为欧拉回路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 int ans[M], t = 0 ;bool st[M]; void euler () { stack<int > stk; stk.push (1 ); while (stk.size ()) { int u = stk.top (); int i = head[u]; while (~i && st[i]) i = ne[i]; if (~i) { stk.push (edge[i]); st[i] = st[i ^ 1 ] = true ; head[x] = ne[i]; } else { stk.pop (); ans[++t] = u; } } }
二分图
如果一张无向图的 N N N 个节点(N ≥ 2 N \ge 2 N ≥ 2 )可以分成 A , B A,B A , B 两个非空集合,其中 A ∩ B = ∅ A \cap B = \empty A ∩ B = ∅ ,并且在同一集合内的点之间都没有边相连,那么称这张图为一张二分图,A A A ,B B B 分别称为二分图的左部和右部。
判定
定理:一张图是二分图,当且仅当图中不存在长度为奇数的环。
用染色法进行二分图的判定,大致思想:尝试用两种颜色标记图中的节点,当一个节点被标记后,它的所有相邻节点被标记与它相反的颜色,若标记过程产生冲突,则图中存在奇环。
最大匹配
“任意两条边都没有公共端点”的边的集合被称为图的一组匹配。在二分图中,包含边数最多的一组匹配被称为二分图的最大匹配。
如果二分图中存在一条连接两个非匹配点的路径 path,使得非匹配边与匹配边在path上交替出现,那么称path是匹配 S S S 的增广路。
二分图的一组匹配是最大匹配,当且仅当图中不存在 S S S 的增广路。
匈牙利算法用来计算二分图最大匹配,它的主要过程为:
设 S = ∅ S=\empty S = ∅ ,即所有的边都是非匹配边
寻找增广路path,把路径上所有边的匹配状态取反,得到一个更大的匹配 S ′ S' S ′
重复第二步,直到图中不存在增广路
该算法的关键在于如何找到增广路。匈牙利算法尝试给每一个左部节点 x x x 寻找一个匹配的右部节点 y y y 。右部点 y y y 能与左部点 x x x 匹配,需要满足以下两个条件之一。
y y y 本身是非匹配点
y y y 已经与左部点 x ′ x' x ′ 匹配,但从 x ′ x' x ′ 出发能找到另一个右部点 y ′ y' y ′ 与之匹配,此时路径 x ∼ y ∼ y ′ ∼ x ′ x\sim y\sim y'\sim x' x ∼ y ∼ y ′ ∼ x ′ 为一条增广路
时间复杂度为 O ( n m ) O(nm) O ( n m )
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 bool dfs (int x) { for (int i = head[x]; ~i; i = ne[i]) { int y = edge[i]; if (!visit[y]) { visit[y] = true ; if (!match[y] || dfs (match[y])) { match[y] = x; return true ; } } } return false ; } for (int i = 1 ; i <= n; ++i){ memset (visit, 0 , sizeof (visit)); if (dfs (i)) ans++; }
带权匹配
给定一张二分图,二分图的每一条边都带有一个权值。求出该二分图的一组最大匹配,使得匹配边的权值总和最大。这个问题被称为二分图的带权最大匹配,也称二分图的最优匹配。
交错树
在匈牙利算法中,如果从某个左部节点出发寻找匹配失败,那么在 DFS 的过程中,所有访问过的节点,以及为了访问这些节点而经过的边,共同构成一棵树。这棵树的根是一个左部节点,所有叶子节点也都是左部节点,奇数层边是非匹配边,偶数层边是匹配边,因此,这棵树被称为交错树。
顶标
全称“顶点标记值”。在二分图中,给第 i i i 个左部节点一个整数值 A i A_i A i ,给第 j j j 个右部节点一个整数值 B j B_j B j 。同时,必须满足 ∀ i , j , A i + B j ≥ w ( i , j ) \forall i,j,A_i+B_j\ge w(i,j) ∀ i , j , A i + B j ≥ w ( i , j ) ,其中 w ( i , j ) w(i,j) w ( i , j ) 表示第 i i i 个左部点与第 j j j 个右部点之间的边权(没有边时设为负无穷)。这些整数值 A i , B j A_i,B_j A i , B j 称为节点的顶标。
相等子图
二分图中所有节点和满足 A i + B j = w ( i , j ) A_i+B_j=w(i,j) A i + B j = w ( i , j ) 的边构成的子图,称为二分图的相等子图。
若相等子图中存在完备匹配,则这个完备匹配就是二分图的带权最大匹配。
求解最大带权匹配
KM算法的基本思想是,在满足 ∀ i , j , A i + B j ≥ w ( i , j ) \forall i,j, A_i+B_j \ge w(i,j) ∀ i , j , A i + B j ≥ w ( i , j ) 的前提下,先给每个节点赋值 A i = m a x 1 ≤ j ≤ n { w ( i , j ) } , B j = 0 A_i=max_{1\le j \le n}\{w(i,j)\},B_j=0 A i = m a x 1 ≤ j ≤ n { w ( i , j ) } , B j = 0 ,然后不断扩大相等子图的规模。
对于一个相等子图,如果最大匹配不完备,说明一定有一个左部节点匹配失败。记匹配这个节点时访问到的路径为一棵交错树 T T T 。此时降低 T T T 中的 A i A_i A i 的值∇,同时增加所有 T T T 中的 B j B_j B j 值 ∇,则原匹配边仍在相等子图中,而左部点沿着非匹配边访问不在子图中的 B j B_j B j 时,由于 A i + B j A_i+B_j A i + B j 减少,所以可能新增加点到子图中。
实际上,我们在所有 i ∈ T , j ∉ T i\in T,j \notin T i ∈ T , j ∈ / T 的边中,找到最小 A i + B j − w ( i , j ) A_i+B_j-w(i,j) A i + B j − w ( i , j ) 作为 ∇ 值,这样对应的左部点就能增加一个匹配的右部点,使得相等子图得到扩大。
不断重复以上过程,直到每一个左部点都匹配成功,就得到了相等子图的完备匹配,即原图的最大带权匹配。具体实现时,可以在DFS中维护一个数组记录可能更新 ∇ 的值。时间复杂度为 O ( N 2 M ) O(N^2M) O ( N 2 M ) 。
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 int w[N][N];int la[N], lb[N]; bool va[N], vb[N]; int match[N];int n, nabla, upd[N];bool dfs (int x) { va[x] = 1 ; for (int y = 1 ; y <= n; y++) if (!vb[y]) { if (la[x] + lb[y] - w[x][y] == 0 ) { vb[y] = 1 ; if (!match[y] || dfs (match[y])) { match[y] = x; return true ; } } else upd[y] = min (upd[y], la[x] + lb[y] - w[x][y]); } return false ; } int KM () { for (int i = 1 ; i <= n; i++) { la[i] = -0x3f3f3f3f ; lb[i] = 0 ; for (int j = 1 ; j <= n; j++) la[i] = max (la[i], w[i][j]); } for (int i = 1 ; i <= n; i++) { while (true ) { memset (va, 0 , sizeof (va)); memset (vb, 0 , sizeof (vb)); memset (upd, 0x3f , sizeof (upd)); if (dfs (i)) break ; nabla = 0x3f3f3f3f ; for (int j = 1 ; i <= n; j++) if (!vb[j]) nabla = min (nabla, upd[j]); for (int j = 1 ; j <= n; j++) { if (va[j]) la[j] -= nabla; if (vb[j]) lb[j] += nabla; } } } int ans = 0 ; for (int i = 1 ; i <= n; i++) ans += w[match[i]][i]; return ans; }
优化算法
可以进一步把算法优化到 O ( N 3 ) O(N^3) O ( N 3 ) 。考虑到已经遍历过的交错树不需要再次遍历,只需要从新加入的最小的边开始搜索即可。我们还需要 l a s t last l a s t 数组来记录每个右部节点 j j j 在交错树中的上一个右部节点,沿 l a s t last l a s t 倒推更新增广路。
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 bool dfs (int x, int p) { va[x] = 1 ; for (int y = 1 ; y <= n; y++) if (!vb[y]) { if (la[x] + lb[y] - w[x][y] == 0 ) { vb[y] = 1 ; last[y] = p; if (!match[y] || dfs (match[y], y)) { match[y] = x; return true ; } } else if (upd[y] > la[x] + lb[y] - w[x][y]) { upd[y] = la[x] + lb[y] - w[x][y]; last[y] = p; } } return false ; } int KM () { for (int i = 1 ; i <= n; i++) { la[i] = -0x3f3f3f3f ; lb[i] = 0 ; for (int j = 1 ; j <= n; j++) la[i] = max (la[i], w[i][j]); } for (int i = 1 ; i <= n; i++) { memset (va, 0 , sizeof (va)); memset (vb, 0 , sizeof (vb)); memset (last, 0 , sizeof (last)); memset (upd, 0x3f , sizeof (upd)); int st = 0 , match[0 ] = i; while (match[st]) { int nabla = 0x3f3f3f3f ; if (dfs (match[st]), st) break ; for (int j = 1 ; j <= n; j++) { if (!vb[j] && upd[j] < nabla) { nabla = upd[j]; st = j; } } for (int j = 1 ; j <= n; j++) { if (va[j]) la[j] -= nabla; if (vb[j]) lb[j] += nabla; else upd[j] -= nabla; } vb[st] = 1 ; } while (st) { match[st] = match[last[st]]; st = last[st]; } int ans = 0 ; for (int i = 1 ; i <= n; i++) ans += w[match[i]][i]; return ans; } }
最小点覆盖
给定一张二分图,求出一个最小的点集 S S S ,使得图中任意一条边都有至少一个端点属于 S S S ,这个问题被称为二分图的 最小点覆盖(vertex cover) ,简称最小覆盖。
二分图最小点覆盖包含的点数等于二分图最大匹配包含的边数。
最大独立集
给定一张无向图 G = ( V , E ) G=(V,E) G = ( V , E ) ,满足下列条件的点集 S S S 被称为图的独立集。
S ∈ V S\in V S ∈ V
∀ x , y ∈ S , ( x , y ) ∉ E \forall x,y \in S,(x,y)\notin E ∀ x , y ∈ S , ( x , y ) ∈ / E
图的独立集就是任意两点之间都没有边相连的点集。
“任意两点之间都有一条边相连”的子图被称为无向图的“团”,点数最多的团被称为最大团。
定理
无向图 G G G 的最大团等于其补图 G ′ G' G ′ 的最大独立集。G ′ = ( V , E ′ ) G'=(V,E') G ′ = ( V , E ′ ) 被称为 G = ( V , E ) G=(V,E) G = ( V , E ) 的补图,其中 E ′ = { ( x , y ) ∉ E } E'=\{(x,y)\notin E\} E ′ = { ( x , y ) ∈ / E } 。
设 G G G 是一个有 n n n 个节点的二分图,G G G 的最大独立集的大小等于 n n n 减去最大匹配数。这是因为去掉二分图的最小点覆盖,剩余部分自然组成最大独立集。
有向无环图的最小路径点覆盖
给定一张有向无环图,要求用尽量少的不相交的简单路径,覆盖有向无环图的所有顶点。这个问题被称为有向无环图的最小路径点覆盖。
设原来的有向无环图为 G = ( V , E ) , n = ∣ V ∣ G=(V,E), n = |V| G = ( V , E ) , n = ∣ V ∣ 。把 G G G 中的每个点 x x x 拆成编号为 x x x 和 x + n x+n x + n 的两个点,建立一张新的二分图,1 ∼ n 1\sim n 1 ∼ n 作为二分图左部点,n + 1 ∼ 2 n n+1\sim 2n n + 1 ∼ 2 n 作为二分图右部点,对于原图的每条有向边 ( x , y ) (x,y) ( x , y ) ,在二分图的左部点 x x x 与右部点 y + n y+n y + n 之间连边,最终得到的二分图称为 G G G 的拆点二分图,记为 G 2 G_2 G 2 。
有向无环图 G G G 的最小路径点覆盖包含的路径条数,等于 n n n 减去拆点二分图 G 2 G_2 G 2 的最大匹配数。
网络流
流网络
一个流网络 G = ( V , E ) G=(V,E) G = ( V , E ) 是一张有向图,图中每条有向边 ( x , y ) ∈ E (x,y) \in E ( x , y ) ∈ E 都有一个给定的权值 c ( x , y ) c(x,y) c ( x , y ) ,称为边的容量 。图中还有两个指定的特殊节点 S ∈ V S\in V S ∈ V 和 T ∈ V T\in V T ∈ V ,分别称为源点和汇点。
设 f ( x , y ) f(x,y) f ( x , y ) 是定义在节点二元组(x ∈ V x\in V x ∈ V ,y ∈ V y\in V y ∈ V )上的实数函数,且满足:
容量限制:f ( x , y ) ≤ c ( x , y ) f(x,y)\le c(x,y) f ( x , y ) ≤ c ( x , y )
f ( x , y ) = − f ( y , x ) f(x,y)=-f(y,x) f ( x , y ) = − f ( y , x )
流量限制:∀ x ≠ S , x ≠ T \forall x \neq S,x\neq T ∀ x = S , x = T ,∑ ( u , x ) ∈ E f ( u , x ) = ∑ ( x , v ) ∈ E f ( x , v ) \sum_{(u,x)\in E}f(u,x)=\sum_{(x,v)\in E}f(x,v) ∑ ( u , x ) ∈ E f ( u , x ) = ∑ ( x , v ) ∈ E f ( x , v )
f f f 称为网络的流函数 ,对于 ( x , y ) ∈ E (x,y)\in E ( x , y ) ∈ E ,f ( x , y ) f(x,y) f ( x , y ) 称为边的流量,c ( x , y ) − f ( x , y ) c(x,y)-f(x,y) c ( x , y ) − f ( x , y ) 称为边的剩余容量。
从源点 S S S 流向其他店的流量,大小为 ∣ f ∣ = ∑ ( S , v ) ∈ E f ( S , v ) |f|=\sum_{(S,v)\in E}f(S,v) ∣ f ∣ = ∑ ( S , v ) ∈ E f ( S , v ) ,称为整个网络的流量。
最大流
使得整个网络流量最大的流函数被称为网络的最大流。
残量网络
对于一个流量图的网络流 f f f ,记其残差网络为 G f G_f G f ,两者一一对应。
G f = ( V f , E f ) G_f=(V_f, E_f) G f = ( V f , E f ) ,其中 V f = V , E f = E ∪ E ′ V_f=V,E_f = E \cup E' V f = V , E f = E ∪ E ′ 。残差网络中各个边对应容量定义如下:
c ′ ( u , v ) = { c ( u , v ) − f ( u , v ) ( u , v ) ∈ E f ( u , v ) ( u , v ) ∉ E c'(u,v)=
\begin{cases}
c(u,v)-f(u,v) & (u,v)\in E \\
f(u,v) & (u,v) \notin E
\end{cases}
c ′ ( u , v ) = { c ( u , v ) − f ( u , v ) f ( u , v ) ( u , v ) ∈ E ( u , v ) ∈ / E
反向边容量等于原图正向边流量,正向边容量等于原图边容量减去差值。可以证明 ∣ f + f ′ ∣ = ∣ f ∣ + ∣ f ′ ∣ |f+f'|=|f|+|f'| ∣ f + f ′ ∣ = ∣ f ∣ + ∣ f ′ ∣ 。
Edmonds-Karp 增广路算法
若残量网络上一条从源点 S S S 到汇点 T T T 的路径中各条边的容量都大于 0,则称这条路径为一条增广路。显然,可以让一股流沿着增广路从 S S S 流到 T T T ,使网络的流量增大。EK算法不断用BFS寻找增广路,直到网络上不存在增广路为止。
在每轮寻找增广路的过程中,EK算法只考虑图中所有 f ( x , y ) < c ( x , y ) f(x,y) < c(x,y) f ( x , y ) < c ( x , y ) 的边,用 BFS 找到一条从 S 到 T 包含边数最少(长度最短)的路径,同时计算出路径上各边的剩余容量的最小值 m i n ( f ) min(f) m i n ( f ) ,则网络的流量就可以增加 m i n ( f ) min(f) m i n ( f ) 。
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 int n, m, s, t;int head[N], ne[M], edge[M], weight[M], tot = 1 ;int d[N], pre[N];bool vis[N];void add (int a, int b, int c) { edge[++tot] = b, weight[tot] = c; ne[tot] = head[a], head[a] = tot; } bool bfs () { memset (vis, 0 , sizeof (vis)); memset (d, 0 , sizeof (d)); memset (pre, 0 , sizeof (pre)); queue<int > q; vis[s] = true , d[s] = 0x3f3f3f3f , q.push (s); while (q.size ()) { int x = q.front (); q.pop (); for (int i = head[x]; ~i; i = ne[i]) { int y = edge[i]; if (!vis[y] && weight[i] > 0 ) { vis[y] = true ; d[y] = min (d[x], weight[i]); pre[y] = i; if (y == t) return true ; q.push (y); } } } return false ; } int EK () { int res = 0 ; while (bfs ()) { res += d[t]; for (int i = t; i != s; i = edge[pre[i] ^ 1 ]) { int id = pre[i]; weight[id] -= d[t]; weight[id ^ 1 ] += d[t]; } } return res; }
Dinic 算法