第10章 图算法专题
《算法笔记》第10章。
图的基本定义
顶点(vertex)、边(edge)、出度、入度等不再赘述。
图的存储
这里从传统算法的角度讨论图的存储,两个基本办法:邻接矩阵和邻接表
1 2 3 4 5 6 7 8 9 10
| int maxv = 1000; int G[maxv][maxv];
vector<int> G[n];
struct Node { int v, dis; } vector<Node> G[n];
|
图的遍历
和前面在tree中讨论了很多次的一样,图的遍历同样是DFS和BFS,最大的区别在于
- 图不一定是连通的,可能存在多个连通分量,因此需要从每个顶点出发尝试遍历,并且不断记录已经访问过的顶点
- 遍历了所有顶点,不代表已经遍历了所有边。这一点需要特别注意
下面写出BFS和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 29 30 31 32 33 34 35 36 37 38 39 40 41
| void DFS(int u, int &stat_res) { vis[u] = ture; for(int i = 0; i<G[u].size(); ++i) { if(vis[G[u][i]]==false) { DFS(G[u][i], stat_res); } } } void DFSGraph() { for(int u = 0; u<n; ++u) { if(vis[u]==false) { int stat_res = 0; DFS(u, stat_res); } } }
void BFS(int u) { queue<int> q; vis[u] = ture; q.push(q); while(!q.empty()) { int u = q.top(); q.pop(); for(int i=0;i<G[u].size();++i) { if(vis[G[u][i]]==false) q.push(G[u][i]); vis[G[u][i]] = ture; } } } void BFSGraph() { for(int u = 0; u<n; ++u) { if(vis[u]==false) { BFS(u); } } }
|
最短路径
Dijkstra算法
求最短路径的经典算法,该算法能够从某个起点出发,寻找到其它所有顶点的最短路径。
基本思想:从还没有到达的剩余顶点中,选择一个最短距离的顶点,访问它;然后检查如果从这个新访问的顶点出发,看能否让剩余未到达的顶点的最短距离变小,如果可以就更新剩余顶点的最短距离;持续执行上一步,知道所有顶点都访问完毕。
实现时候的几个核心思路:
- 一个检查是否已经访问过的数组
bool vis[maxv]
,初始化时false
- 一个存储到不同顶点最短路径的数据
int d[maxv]
,初始化为INF
,一个巨大的数字,可以是e9
;结合vis[maxv]
和d[maxv]
就可以选出所有未到达顶点中具有最短路径的那个顶点
邻接矩阵版本的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
| int INF = 1000000000;
void Disjkstra(int s) { fill(d, d+n, INF); fill(vis, vis+n, false); d[s] = 0; for(int i = 0; i<n; ++i) { int u = -1; int MIN = INF; for(int v=0; v<n; ++v) { if(vis[v]==false && d[v]<MIN) { u = v; MIN = d[v]; } } if(u==-1) return; vis[u] = ture; for(int v=0; v<n; ++v) { if(vis[v]==false && G[u][v]!=-1 && d[v]>d[u] + G[u][v]) { d[v] = d[u] + G[u][v]; } } } }
|
上面的函数执行完毕后,d[maxv]
中将保存所有最短路径距离。
接下来讨论,如何输出最短路径?
解决方法是记录每个顶点最短路径的前驱结点即可,开始结点的最短路径是自身
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
| int pre[maxv]; void Disjkstra(int s) { fill(d, d+n, INF); fill(vis, vis+n, false); d[s] = 0; pre[s] = s; for(int i = 0; i<n; ++i) { int u = -1; int MIN = INF; for(int v=0; v<n; ++v) { if(vis[v]==false && d[v]<MIN) { u = v; MIN = d[v]; } } if(u==-1) return; vis[u] = ture; for(int v=0; v<n; ++v) { if(vis[v]==false && G[u][v]!=-1 && d[v]>d[u] + G[u][v]) { d[v] = d[u] + G[u][v]; pre[v] = u; } } } }
|
然后通过递归就可以输出最短路径
1 2 3 4 5 6 7 8
| void DFSPath(int s, int u) { if(s==u) { printf("%d ", s); return; } DFSPath(s, pre[u]); printf("%d ", u); }
|
当然在做题的时候,不会只有这么简单的要求,通常会有更多的要求,比如要求选择在最短路径中花费最少的一条,要求输出最短路径的数量等等。
下面是三种常见的应对策略:
- 给每条边新增边权,然后要求在多个最短路径中选择新增边权最好的情况
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
for(int v=0; v<n; ++v) { if(vis[v]==false && G[u][v]!=-1) { if (d[v]>d[u] + G[u][v]) { d[v] = d[u] + G[u][v]; pre[v] = u; } else if (d[v] == d[u] + G[u]) { if(c[v] > cost[u][v] + c[u]) { c[v] = cost[u][v] + c[u]; pre[v] = u; } } } }
|
- 每个点新增了点权,要求在最短路径中,寻找点权最优的情况,类似于上面的方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| for(int v=0; v<n; ++v) { if(vis[v]==false && G[u][v]!=-1) { if (d[v] > d[u] + G[u][v]) { d[v] = d[u] + G[u][v]; pre[v] = u; w[v] = w[u] + weight[v]; } else if (d[v] == d[u] + G[u]) { if(w[v] < w[u] + weight[v]) { w[v] = w[u] + weight[v]; pre[v] = u; } } } }
|
- 求最短路径的数量,使用一个数组,记录最短路径数量即可
1 2 3 4 5 6 7 8 9 10 11 12 13
| for(int v=0; v<n; ++v) { if(vis[v]==false && G[u][v]!=-1) { if (d[v] > d[u] + G[u][v]) { d[v] = d[u] + G[u][v]; pre[v] = u; nums[v] = nums[u] } else if (d[v] == d[u] + G[u]) { nums[v] += nums[u]; } } }
|
在上面的方法中,总是只保留最优的最短路径,这种情况不一定适用于所有的情形。下面介绍一种方法,总是先保留所有的最短路径,然后再从所有的最短路径中进行选择。
核心方法是,不再只保留一个前驱结点,而是保留所有的最短路径的前驱结点
新的方法
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
| void Disjkstra(int s) { fill(d, d+n, INF); fill(vis, vis+n, false); d[s] = 0; pre[s].clear(); pre[s].push_back(s); for(int i = 0; i<n; ++i) { int u = -1; int MIN = INF; for(int v=0; v<n; ++v) { if(vis[v]==false && d[v]<MIN) { u = v; MIN = d[v]; } } if(u==-1) return; vis[u] = ture; for(int v=0; v<n; ++v) { if(vis[v]==false && G[u][v]!=-1) { if(d[v]>d[u] + G[u][v]) { d[v] = d[u] + G[u][v]; pre[v].clear(); pre[v].push_back(u); } else if(d[v]>d[u] + G[u][v]) { pre[v].push_back(u); } } } } }
|
遍历所有的最短路径:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| void DFSPath(int s, int u, vector<int> &tmpPath, int &optValue, vector<int> &optPath) { if(u==s) { tmpPath.push_back(u); int value; if(当前最短路径的value优于optvalue) { optValue = value; optPath = tmpPath; } tmpPath.pop_back(); return; } tmpPath.push_back(u); for(int i =0;i<pre[u].size();++i) { DFSPath(s, pre[i], tmpPath, optValue, optPath); } tmpPath.pop_back(); }
|
Bellman-Ford算法和SPFA算法
在dijkstra算法中,如果遇到图有负权边,由于该算法会直接选择该负边,而忽略了其它可能的路径,可能造成某些通过非负权边可以访问到的顶点没有被访问到。它无法较好的处理负权边。
对于以上问题,同样是针对单源最短路径问题,有bellman-ford算法,以及其改进版本SPFA算法可以解决。
Bellman-ford算法的基本思想:
- 对图中的每个边进行
V-1
轮的检查。在每一轮的检查中,如果发现通过边[u][v]
,可以让顶点v
的最短路径缩短,就进行替换,这一点类似于Dijkstra算法,区别在于Bellman算法是遍历每条边,保证所有的边都会参与判定过程。进行V-1
轮检查的原因是,某个结点到开始顶点的最短路径长度不会超过V
(包括开始顶点),如果不考虑都有的开始顶点,只需要最多V-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
| bool bellman(int s) { d[s] = 0; for(int i = 0; i < n-1; ++i) { for(int u = 0; u < n; ++u) { for(int j = 0; j < Adj[u].size(); ++j) { int v = Adj[u][j].v; int dis = Adj[u][j].dis; if(d[v] > d[u] + dis) { d[v] = d[u] + dis; } } } } for(int u = 0; u < n; ++u) { for(int j = 0; j < Adj[u].size(); ++j) { int v = Adj[u][j].v; int dis = Adj[u][j].dis; if(d[v] > d[u] + dis) { return false; } } } return ture; }
|
上述算法每次要遍历所有的边,实际上只有最短路径d
发生变化的顶点出发的边才需要进行判断,因此可以使用一个队列存储所有最短路径发生变化的顶点,出队后,再把发生最短路径变化且不再队列中的顶点入队。如果发现队空了,可以判断没有可达的负环;如果有某个顶点入队次数超过了V
(也就是最短路径发生变化超过了V
次),可以判断存在可达的负环。
经过上述改进后的算法就叫做SPFA算法(Shortest Path Faster Algorithm),该算法在大多数的图中都非常高效。
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
| bool SPFA(int s) { queue<int> q; q.push(s); bool inqueue[n]={false}; int inqueueNum[n]={0}; inqueue[s] = true; d[s] = 0; inqueueNum[s] = 1; while(!q.empty()) { int u = q.top(); q.pop(); for(int j = 0; j < Adj[u].size(); ++j) { int v = Adj[u][j].v; int dis = Adj[u][j].dis; if(d[v] > d[u] + dis) { d[v] = d[u] + dis; if(inqueue[v]==false) { q.push(v); inqueue[v] = true; inqueueNum[v]++; if(inqueueNum[v]>=n) return false; } } } } return ture; }
|
Floyd算法
Floyd可以解决全源最短路径的问题,也就是说询问任意两个点之间的最短距离,该问题就限制了问题可以查询的顶点数量在200以内,所以总是可以使用邻接矩阵的方法解决。核心思想是,如果顶点k
为中介时,可以使得顶点i
到顶点j
的距离缩短,就使用顶点k
为中介。
代码:
1 2 3 4 5 6 7 8 9 10 11 12
| void Floyd(int s) { for(int k =0; k<n; ++k) { for(int i =0; i<n; ++i) { for(int j=0; j<n; ++j) { if(dis[i][k]!=INF && dis[k][j]!=INF && dis[i][k]+dis[k][j]<dis[i][j]) { dis[i][j] = dis[i][k]+dis[k][j]; } } } } }
|
最小生成树
最小生成树是从一个无向图当中,获取一课树,这棵树满足
- 包括了所有的图顶点
- 所有的边都是图中原有的边
- 该树的边权和最小
由于是一棵树,所以最小生成树一定有V-1
条边。最小生成树的根结点可以是任意的结点(试想下一棵树,如果没有特殊的性质,我们当然可以把任意一个数结点当做是根结点,然后重新排列成树的层级形状)。当然在题目中,一般会指定要从哪个结点出发生成最小生成树。
Prime算法
prime算法和dijkstra算法很相似,区别在于prime选择下一步访问的图顶点时不是考虑到起源结点最短距离,而是到整个已访问结点集合的最短距离(具体的说,访问新顶点,检查下新访问顶点到未访问顶点的距离,看能否让距离减小,不需要考虑之前新访问顶点的最短距离)。
下面写一下邻接表版本的prime算法:
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
| int INF = 1000000000; bool vis[maxn]; int dis[maxn]; int prime(int s) { fill(vis, vis+n, false); fill(dis, dis+n, INF); dis[s] = 0; int ans = 0; for(int i = 0; i < n; ++i) { int MINDIS = INF; int u = -1; for(int v = 0; v<n; ++v) { if(vis[v]==false && dis[v]<MINDIS) { MINDIS = dis[v]; u = v; } } if(u==-1) return ans; vis[u] = true; ans += dis[u]; for(int j = 0; j<Adj[u].size(); ++j) { int v = Adj[u][j].v; int dis_uv = Adj[u][j].dis; if(vis[v] == false && dis[v] > dis_uv) { dis[v] = dis_uv; } } } return ans; }
|
kruskal算法
Kruskal算法(克鲁斯卡尔算法)的思想很简单,使用边贪心的思路:
- 按照边权从小到大排序所有边
- 认为所有图顶点一开始是独立不连通的块(并查集的初始状态)
- 遍历所有排好序的边,如果一条边的两个顶点不在同一个连通块(并查集寻找集合根结点),就加入这个边,连通两个连通块(并查集合并);如果两个顶点已经处于同一个连通块,就略过该边
- 重复上一步直至所有边遍历完毕或者已经选择了
V-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 44 45 46 47 48 49
| struct edge { int u, v; int dis; } E [maxe];
bool cmp(edge e1, edge e2) { return e1.dis < e2.dis; }
int father[maxn];
int findFather(int i) { int tmp_i = i; while(father[i]!=i) { i = father[i]; } while(i!=father[tmp_i]) { int tmp_z = tmp_i; tmp_i=father[tmp_i]; father[tmp_z] = i; } return i; }
int kruskal() { int ans = 0; for(int i = 0; i<n; ++i) { father[i] = i; } sort(E, E+edge_num, cmp); int u, v; int tree_count = 0; for(int i=0; i<edge_num; ++i) { u = E[i].u; fatherU = findFather(u); v = E[i].v; fatherV = findFather(v); dis = E[i].dis; if(fatherU!=fatherV) { father[fatherU] = father[fatherV]; ans += dis; tree_count++; if(tree_count==n-1) break; } } if(tree_count!=n-1) return -1; return ans; }
|
拓扑排序
一个有向图的任意顶点都不可能通过一些有向边返回,这样的有向图叫做有向无环图。
检查一个有向无环图的办法可以通过检查图的拓扑排序能否包括所有的图顶点。拓扑排序是指如果在图中存在u->v
,则u
在拓扑排序中一定在v
前,u
是v
的先导元素。
解决思路是,使用一个队列存储所有入度为0的顶点,出队队首元素,访问该顶点,然后删除所有以该顶点为起点的边,如果有顶点入度变为了0,就入队。重复上述过程直到队列为空。检查此时访问的元素,如果存在部分顶点为访问,则说明有向图中存在环。
邻接表版本的代码:
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
| int inDegree[maxn]; bool topologicalSort() { queue<int> q; for(int i=0;i<n;i++) { if(inDegree[i]==0) { q.push(i); } } int zeroDegreeCount = 0; while(!q.empty()) { int u = q.front(); q.pop(); zeroDegreeCount++; printf("%d ", u); for(int j = 0; j < Adj[u].size(); ++j) { int v = Adj[u][j].v; inDegree[v]--; if(inDegree[v]==0) { q.push(v); } } } if(zeroDegreeCount==n) return true; return false; }
|
关键路径
AOV(Activity On Vertex)网络是顶点表示活动,顶点可以有点权,边没有边权,代表优先级。上一节的拓扑排序就是用来寻找AOV网络上的一种活动排序。
AOE(Activity On Edge)网络是边表示活动,顶点表示事件,顶点没有点权,边有边权。AOE通常用在工程场景中,边表示从一个事件到另一事件需要的时间/代价等。
AOV和AOE网络的边都表示某种优先级,因此不会存在环,都是有向无环图。
AOV网络总是可以转换为AOE网络,试想下只需要把AOV中的顶点拆分为两个顶点作为事件开始与结束,这两个顶点中的有向边边权就是原顶点的点权,剩下的AOV原有边边权设置为0。
AOE网络总是可以通过添加额外的起点和汇点,形成只有一个起点,一个汇点的图。
关键路径:对于AOE网络中的最长路径,叫做关键路径;关键路径上的所有活动叫做关键活动;关键路径表示要完成AOE网络中的所有活动所需要的最少时间,关键活动是无法拖延完成的活动。
关键路径的寻找方法,核心在于寻找顶点i
(事件)的最早开始时间和最晚开始时间,最早开始时间是从起点开始就马不停蹄的完成所有事件,只要顶点i
的所有先导顶点完成了,就立刻开始完成顶点i
。顶点i
的最晚开始时间,是从终点开始反向计算,只要不延误后续顶点的最晚开始时间就可以。
实现的时候,对于每个顶点,维护数组ve[maxn]
保存顶点的最早开始时间;数组vl[maxn]
保存顶点的最迟开始时间。计算好这两个数组之后,遍历所有的边u->v
,计算u->v
的最早开始时间ve[u]
和最晚开始时间vl[v]-dis[u->v]
。
按照拓扑排序,可以计算出各个顶点的最早开始时间max
(所有先导顶点的最早时间+先导边时间);然后按照拓扑排序的反向顺序,计算各个顶点的最晚开始时间min
(所有后续顶点的最晚开始时间-后续边时间)。
代码实现:
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
| stack<int> topoloStack; int ve[maxn]; int vl[maxn];
int inDegree[maxn]; bool topologicalSort() { fill(ve, ve+n, 0); queue<int> q; for(int i=0;i<n;i++) { if(inDegree[i]==0) { q.push(i); } } int zeroDegreeCount = 0; while(!q.empty()) { int u = q.front(); q.pop(); zeroDegreeCount++; topoloStack.push(u); for(int j = 0; j < Adj[u].size(); ++j) { int v = Adj[u][j].v; int dis = Adj[u][j].dis; inDegree[v]--; if(inDegree[v]==0) { q.push(v); } if(ve[v] < ve[u] + dis) { ve[v] = ve[u] + dis; } } } if(zeroDegreeCount==n) return true; return false; }
int criticalPath() { if(!topologicalSort()) return -1; fill(vl, vl+n, ve[n-1]); while(!topoloStack.empty()) { int u = topoloStack.top(); topoloStack.pop(); for(int j = 0; j < Adj[u].size(); ++j) { int v = Adj[u][j].v; int dis = Adj[u][j].dis; if(vl[u] > vl[v] - dis) { vl[u] = vl[v] - dis; } } } for(int u = 0; u < n; ++u) { for(int j = 0; j < Adj[u].size(); ++j) { int v = Adj[u][j].v; int dis = Adj[u][j].dis; int e_edge = ve[u]; int l_edge = vl[v] - dis; if(e_dege == l_edge) { printf("%d->%d\n", u, v); } } } return ve[n-1]; }
|