图算法总结(基本算法、最小生成树、所有结点对的最短路径问题、最大流)
一、基本的图算法
-
存图
-
邻接矩阵:
int graph[max_n][max_m];
-
邻接链表:
struct Edge { int v, w; // v是邻接的结点,w是边权 }; vector<Edge> g[N]; void add(int u, int v, int w) // 加边 { Edge edge; edge.v = v, edge.w = w; g[u].push_back(edge); }
-
链式前向星存图:
struct Edge { int v, w; // v是邻接的结点,w是边权 int next; // 记录同起点上一条边的位置 }; Edge edge[m_max]; // 数组存边 int cnt=0; // 记录存入边的下标 int head[n_max]; // 记录每个边的起始点指向的其最后一个终点的那条边的位置 // 需在main函数里使用循环全部初始化为-1 void add_edge(int u,int v,int w) //起点,终点,权值 { cnt++; edge[cnt].v=v; edge[cnt].w=w; edge[cnt].next=head[u]; head[u]=cnt; // 更新head[u]指向的最末终点所在边的位置 }
如果读者仍不明白其原理,可参考这个网址:https://blog.csdn.net/sugarbliss/article/details/86495945
注意:此方法只适用于有向边,但仍可以在两点间存两条方向相反的边,达到无向的效果。
-
-
BFS
广度优先搜索-
使用重要数据结构
queue
(其使用方法可参考我上篇博客:https://www.cnblogs.com/yuyi-21/p/15568742.html):void BFS(int s) { queue<int> q; q.push(s); while (!q. empty()) { top = q.front(); // 访问队首元素top,访问可以是任何事情 q.pop(); // 出队 // 将top的下一层结点中未曾入队的结点全部入队并设置为已入队(链式前向星存图的优势在此显现) } }
-
题目练习可跳转:https://leetcode-cn.com/tag/breadth-first-search/problemset/
-
-
DFS
深度优先搜索-
一般采用递归实现:
void DFS(int num,......) // 参数用来表示状态 { if() // 判断边界 { ... return; } if(/*越界或者是不合法状态*/) return; for() // 有时为if,依照题意 { if() // 若合法 { // 修改操作 // 标记 DFS(); // 往下遍历,自底向上 // 还原(根据题意,有回溯要求时才需还原标记) } } }
-
典型例题(全排列):
void DFS(int x) { if(x==len) { // 打印并return } for(int i=0; i<len; i++) { if(vis[i]==0) // 如果没有被标记 { ans[x] = a[i]; // ans记录最终排列数组,a为读入的原数组 vis[i]=1; // 标记已被使用 DFS(x+1); vis[i]=0; // 取消标记 } } }
-
其它题目可跳转:https://leetcode-cn.com/tag/depth-first-search/problemset/
-
-
拓扑排序
- 拓扑排序的原理(顶点表示活动,有向边表示顶点之间的优先关系):
(1)任意选择一个没有前驱的顶点;
(2)去掉该顶点以及以该顶点为出发点的所有边;(用链式前向星存图!)
(3)重复上述过程,直到所有顶点被去掉(或还有顶点,但不存在入度为0的边——AOV网存在回路)。
为了方便,使用优先队列(类型依据题意):
priority_queue <int,vector<int>,less<int> > big_out;
(大顶堆)、priority_queue <int,vector<int>,greater<int> > small_out;
(小顶堆)(当然,对于大顶堆,你也可以这样定义:
priority_queue<int> a;
)-
代码模型:
for(int i=1;i<=n;i++) { if(in[i]==0) // 输入时用in记录每个点的入度,加入所有入度为0的点进入优先队列 big_out.push(i); } while(!big_out.empty()) // 为空说明已经找完所有点,跳出循环 { int temp=big_out.top(); // 弹出队列中的首元素(按照题意,由大到小输出,可以变通) big_out.pop(); // 出队 // 对temp按照题意进行操作 for(int k=head[temp];k!=-1;k=edge[k].next) // 链式前向星图的遍历,删除所有连接的边 { in[edge[k].to]--; // 所有终点入度-1 if(in[edge[k].to]==0) big_out.push(edge[k].to); // 如果入度变为0,加入队列 } head[temp]=-1; // 将该起点标记为“空巢” }
强连通分量此处暂鸽,以后有时间再来更吧。若有需要可参考《算法导论》p357。
二、最小生成树:
何为最小生成树?再一次引用百度百科的话就是:一个有 n 个结点的连通图的生成树是原图的极小连通子图,且包含原图中的所有 n 个结点,并且有保持图连通的最少的边(也就是n-1条边)。
接下来介绍两个伟大的算法:kruskal
算法 & prim
算法
-
kruskal
算法(稀疏图时推荐使用)基本原理:假设连通网G =(V,E),在E中选择代价最小的边,若该边依附的顶点分别在T中不同的连通分量上,则将此边加入到T中;否则,舍去此边而选择下一条代价最小的边。依此类推,直至T中所有顶点构成一个连通分量为止(可用已经加入了n-1条边来判定)。
-
代码模型:
struct Edge{ int from; int to; int w; }; // 结构体存边 Edge edge[m_max]; int fa[n_max]; // 存每个结点所在连通图的祖先(通过找是否拥有共同祖先来判断两者是否在同一个连通图) // 需要在main函数中全部初始化为自身,即fa[i] = i int find_fa(int t) // 找共同祖先的函数 { if(fa[t]==t) //如果找到头了,就返回祖先 return t; else return find_fa(fa[t]); } int main() { // 使用qsort或其它排序方法从小到大排序 for(int i=0; i<m; i++) //遍历每条边 { a=find_fa(edge[i].from); b=find_fa(edge[i].to); if(a!=b) // 如果两点分属两个不同连接子图,则将该边加入图中 { fa[a]=b; // 更新祖先的值 // 对加入的边,依据题意,做一些操作 cnt_edge++; } if(cnt_edge==(n-1)) // 如果已经加到n-1条边,便说明已找到最小生成树,跳出循环 break; } }
-
-
prim
算法(稠密图时推荐使用)基本原理:假设连通网G =(V,E),①依次在G中选择一条一个顶点仅在V中,另一个顶点在U中(U为已加入最小生成树的点),并且权值最小的边加入集合TE,②同时将该边仅在V中的那个顶点点入集合U。重复上述过程n-1次,使得u=v,此时T为G的最小生成树。
-
代码模型:
void Prim(int n) { int lowcost[MAXINT]; // 未确定生成树的顶点至已确定生成树上顶点的边权重 int closest[MAXINT]; // 前驱结点(最靠近的已确定生成树上的顶点) bool s[MAXINT]; // 是否已加入生成树 s[0]=true; // 初始化第一个结点已找到 for(int i=1;i<=n;i++) { lowcost[i]=c[0][i]; // 存放时记得初始化边权为无穷大 closest[i]=0; s[i]=false; } // 初始化,默认前驱结点都是第一个结点 /*依照题意,对第一个点0的操作*/ for(int i=0;i<n;i++) { int min=INF; // 临时最小值 for(int k=1;k<=n;k++) // 找还未加入的满足算法的最小边 { if((lowcost[k]<min) && (!s[k])) { min = lowcost[k]; int j=k; // 存放当前找到的新结点 } } /*依照题意,对找到的点j的操作*/ s[j]=true; // 将找到的点标记一下 for(k=1;k<=n;k++) // 遍历其它为找过的点,会不会因为新点的加入而有更小的边权 { if((c[j][k]<lowcost[k]) && (!s[k])) { lowcost[k]=c[j][k]; closest[k]=j; } } } }
-
三、所有结点对的最短路径问题:
给一个图,不一定要对所有点进行操作。如果想找到图中任意两个点,得到它们之间的最短路径,那么前面的算法都不适用了。
接下来,就将介绍一种解决此类问题的算法——Floyd-Warshall
算法:
这是一种利用动态规划的思想寻找给定的加权图中多源点之间最短路径的算法,即插入第三个点,看是否可能通过这个点,缩短原来两点间的距离。对于每一个插入点,都需要遍历更新一遍图,所以时间复杂度为O(V^3)
。因为对于每两个点的最短路径,题目都有可能要求输出,故采用邻接矩阵存图。
如果仍不明白的话,可以移步这篇博客:https://blog.csdn.net/yuewenyao/article/details/81021319,讲的很清楚。
代码模板:
long long node_edge[n_max][n_max];
int main()
{
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
node_edge[i][j]=INF; // 将最短路径初始化为无限大
for(int i=1;i<=m;i++)
{
cin>>x>>y>>z;
if(node_edge[x][y]!=INF) // 存边,对于可能有多重边的情况,我们只保留最小边
node_edge[x][y]=(node_edge[x][y]<=z? node_edge[x][y]:z);
else
node_edge[x][y]=z;
}
for(int i=1;i<=n;i++)
node_edge[i][i]=0; // 对角线上均为点,故最短路径初始化为0
long long temp=0; // 存放临时插入点后的路径长度
for(int k=1;k<=n;k++) // 遍历插入点
{
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++) // 遍历图
{
if(node_edge[i][k]==INF || node_edge[k][j]==INF)
continue; // 本没有运算的必要,但可能因为溢出,反而被更新为错误的值,故直接排除
temp=node_edge[i][k]+node_edge[k][j];
if(temp<node_edge[i][j]) //如果更小,则更新对应的最短路径
node_edge[i][j]=temp;
}
}
}
四、最大流:
这类题目是要干什么呢?题目给定指定的一个有向图,其中有两个特殊的点源S(Sources)和汇T(Sinks),每条边有指定的容量(Capacity),求满足条件的从S到T的最大流(MaxFlow)。它的应用面很广,比如:开闸放水。
废话不多说,先来了解几个概念:
-
3个基本性质:
-
容量限制(流量不能大于容量):f(u,v)≤c(u,v);
-
反对称性(从u到v的流量一定是从v到u的流量的相反值):f(u,v) = -f(v,u);
-
流守恒性(流入u的流量和u点流出的流量相等):∑f(u,v)=0 ,v代表相邻结点。
-
-
残留网络:
残留网络 = 容量网络 - 流量网络
-
增广路:
存在一条从s到t的有向通路。
显然只要残量网络中存在增广路,流量就可以增大;反之,如果不存在增广路,流量就已经最大。
朴素的预留推进算法的复杂度较高,且写法较长。这里推荐使用更高效的Dinic
算法或者Isap
、Sap
算法。
-
Dinic
算法关键思路:①存图的时候存入一条正向边、一条反向边(链式前向星法存图),为了方便,不妨让所有的偶数边为正向
i
,奇数边为负向i^1
。因为找到的增广路不一定最优,反边给了我们后悔的机会,最初应初始化为0(不会对流量造成影响),同时,为了保证原点和汇点流量不变,当正向边减去d,反向边应当加上d。②BFS
分层(检查有无增广路的时候顺便分层),DFS
增广,循环往复。实现一次找到多条增广路。代码模板:
// 初始化 cnt=1; head[n_max]=0; int cur[n_max]; // 此数组用于优化,由于DFS中先被遍历到的边已经增广过或确定无法继续增广了,那么下次再到达该节点时,不妨跳过废边,只走有用的边(当前弧优化) void add_edge(int a,int b,int c) // 为了方便,不妨直接将加边封装成一个函数 { cnt++; edge[cnt].to=b; edge[cnt].w=c; edge[cnt].next=head[a]; head[a]=cnt; return; } int main() { // 读入部分省略 while(BFS()) // 如果还有增广路,则继续增广 DFS(s,INF); // 第一个元素为当前点,第二个元素为当前增广路上的最小边权:初始化INF=0x3f3f3f3f cout<<max_flow<<endl; return 0; } bool BFS() // 借助数据结构queue { for(int i=0; i<=n; i++) { cur[i] = head[i]; // 尚无废边,初始化为head的起点 depth[i] = INF; // 初始化深度为INF inque[i] = 0; // 初始化不在队列中 } depth[s]=0; // 源点深度为0 queue<int> q; q.push(s); int temp; // 存放队列首元素 while(!q.empty()) { temp=q.front(); q.pop(); inque[temp]=0; // 标记不在队列中 for(int i=head[temp];i>0;i=edge[i].next) // 链式前向星遍历 { int towards=edge[i].to; // 当前以temp为起点的边的终点 if(edge[i].w>0 && (depth[towards]>(depth[temp]+1))) { depth[towards]=depth[temp]+1; // 每多走一个点,层数+1,为了之后DFS简化操作 // 之后如果depth[towards]==(depth[temp_site]+1),我们就可以判断该路径在一条最短增广路上 if(!inque[towards]) // 如果该点不在队列中,让它入队 { q.push(towards); inque[towards]=1; } } } } if(depth[t]!=INF) // 如果终点值的层数被更新了,说明有一条增广路,反之则没有 return 1; else return 0; } // 于是我们再来一波深搜,更新最大流 int DFS(int temp_site,int min_left) { if(temp_site==t) // 到达汇点,可以使用当前路径,最小边权min_left有效 { max_flow += min_left; return min_left; } int used=0; // 表示这个点的流量用了多少,如果use还没有找到流量上限,则可以继续找别的增广路 int temp_min=0; // 增广路最小残余流量 for(int i=cur[temp_site];i>0;i=edge[i].next) // 从cur[temp_site]开始遍历,略过已经遍历过的废边 { int towards=edge[i].to; // 目标点 cur[temp_site]=i; if(edge[i].w>0 && (depth[towards]==(depth[temp_site]+1))) // 根据BFS的分层,寻找最短的可增广路径 { if (temp_min=DFS(towards,min(min_left-used,edge[i].w))) // 深搜,找该增广路径的最小残余流量,且不为0时进行下面的操作 { used += temp_min; // 该点流量增加 edge[i].w -= temp_min; edge[i^1].w += temp_min; // 正向边加流,反向边减流 if (used==min_left) //已到达流量上限 break; } } } return used; }
-
Isap
算法读者可移步该博客学习:https://www.cnblogs.com/scx2015noip-as-php/p/MFP.html
-
题目练习可跳转(模板题):https://www.luogu.com.cn/problem/P3376