图论数据结构和基础算法
1、数据结构(以下文章中V代表顶点数,E代表边数)
图常用的两种数据结构
-
邻接矩阵
使用矩阵表示图的连接情况,对于无权图,我们可以用0代表两点之间不连接,1代表两点之间连接。对于有权图,我们可以定义一个极大值INF,来代表两个点不连接,使用权值代表两点连接,这样既存储了权值又存储了两点是否连接。
//定义一个二维数组 用fill函数赋值 int adj[MAX_V][MAX_V]; fill(adj,adj+MAX_V*MAX_V,INF);
优点: 可以在常数时间O(1)内判断两点时间是否有边存在
缺点: 花费O(V^2)的存储空间,对于边很少的稀疏图空间有极大的浪费
注意:如果图中某两点之间有重边或者某个顶点有环需要特殊处理;在无权图中,只需要设
//adj[i][j]为顶点i到顶点j的边数即可
在有权图中,我们只能保存权值最大或权值最小的边即可(根据实际情况选择保存最大还是最小)。必须保存所有边时,可以采取邻接表;
-
邻接表
在邻接表中,是通过使用链表数组结构,把每个顶点相邻的边存储起来。所使用空间为O(V+E);
//c++中可以用vector数组来表示邻接表 vector<int> Graph[MAX_V]; //注意!!!这个相当于二维数组 //当边上有属性情况下 struct edge { int to, cost; }; //to代表与当前顶点相邻的顶点 cost代表权值 vector<edge> Graph[MAX_V]; //第三种结构 struct vertex { vector<vertx*> edge; //指向与之相邻顶点的指针 /* 顶点属性 */ }; vertex Graph[MAX_V];
2、基础算法
-
图的深度优先搜索(DFS)
int adj[MAX_N][MAX_N]; int vexs[n]; //存顶点向量信息 void GraphDFS() { bool flag[n]; //标记这个顶点有没有访问过 for(int i=0;i<V;i++) { flag[i] = false; } for(int i=0;i<V;i++) { if(!flag[i]) { //未访问过 DFS(i); } } } void DFS(int i) { cout<<vexs[i]<<" "; //输出搜索顺序 for(int j=0;j<V;j++) { if(adj[i][j] && !flag[i]) DFS(j); //找到一个与之相邻并且未使用的顶点继续DFS } }
-
图的广度优先搜索(BFS)
int que[V+2]; //用数组充当队列 节省空间 int head,tail=; //head充当头结点,tail充当尾节点 void GraphBFS() { bool flag[V]; //标志数组作用同上 fill(flag,flag+V,false); for(int i=0;i<V;i++) { if(!flag[i]) { //元素未访问过 cout<<vexs[i]<<" "; flag[i] = true; que[++tail] = i; //入队 while(head<=tail) { //队列不空 int j=que[head++]; //出队 for(int k=0;k<V;k++) { if(adj[j][k] && !flag[k]) { //与j相邻的元素且未访问过的顶点进行访问 cout<<vexs[i]<<" "; flag[k] = 1; que[++tail] = k; //k入队 } } } } } }
-
单源最短路径(Bellman-Ford算法)存在负圈无法处理
优点: 可以处理存在边的权值为负的图
从一个点出发求到其他所有顶点的最短路径
Bellman-Ford算法流程分为三个阶段:(这三个阶段摘自某位大佬的文章 由于过去时间久了忘了链接 实在抱歉)
(1) 初始化:将除源点外的所有顶点的最短距离估计值 d[v] ←+∞, d[s] ←0;(2) 迭代求解:反复对边集E中的每条边进行松弛操作,使得顶点集V中的每个顶点v的最短距离估计值逐步逼近其最短距离;(运行|v|-1次)
(3) 检验负权回路:判断边集E中的每一条边的两个端点是否收敛。如果存在未收敛的顶点,则算法返回false,表明问题无解;否则算法返回true,并且从源点可达的顶点v的最短距离保存在 d[v]中。
详细说明:
首先指出,图的任意一条最短路径既不能包含负权回路,也不会包含正权回路,因此它最多包含|v|-1条边。
其次,从源点s可达的所有顶点如果 存在最短路径,则这些最短路径构成一个以s为根的最短路径树。Bellman-Ford算法的迭代松弛操作,实际上就是按顶点距离s的层次,逐层生成这棵最短路径树的过程。
在对每条边进行1遍松弛的时候,生成了从s出发,层次至多为1的那些树枝。也就是说,找到了与s至多有1条边相联的那些顶点的最短路径;对每条边进行第2遍松弛的时候,生成了第2层次的树枝,就是说找到了经过2条边相连的那些顶点的最短路径……。因为最短路径最多只包含|v|-1 条边,所以,只需要循环|v|-1 次。
每实施一次松弛操作,最短路径树上就会有一层顶点达到其最短距离,此后这层顶点的最短距离值就会一直保持不变,不再受后续松弛操作的影响。(但是,每次还要判断松弛,这里浪费了大量的时间,怎么优化?单纯的优化是否可行?)
如果没有负权回路,由于最短路径树的高度最多只能是|v|-1,所以最多经过|v|-1遍松弛操作后,所有从s可达的顶点必将求出最短距离。如果 d[v]仍保持 +∞,则表明从s到v不可达。
如果有负权回路,那么第 |v|-1 遍松弛操作仍然会成功,这时,负权回路上的顶点不会收敛。
/* 算法核心思想:先存入图中所有边的信息包括{起点,终点,权值},在定义一个存最短距离的数组d[MAX_V],初始化为INF代表从固定点到第i个顶点无路。 之后进入一个死循环中,在死循环中(该死循环最多执行n-1次),对所有边进行遍历使最短距离数组d[MAX_N]不断更新最短距离(不断加入新的路径,不断更新最短距离),直到不再更新退出死循环,程序结束。 时间复杂度为O(V*E) */ struct edge { int from, to, cost; }; edge es[MAX_V]; int d[MAX_V]; int V,E; //V是顶点数,E是边数 void find_shortest_path(int s) { //s是源点 fill(d,d+MAX_V,INF); d[s] = 0; //一定要让从源点到源点最短距离为0 不然下面将进入循环无法跳出 while(1) { bool updata = false; //作为d[MAX_N]是否更新的标记 for(int i=0;i<E;i++) { edge e = es[i]; //d[e.from]!=INF 表示的是 从源点s到e.from这条边的起点有路 //d[e.to] > d[e.from] + e.cost 代表 走e这条边比之前到达顶点e.to //更近 即需要更新d[MAX_N] if(d[e.from]!=INF && d[e.to] > d[e.from] + e.cost){ d[e.to] = d[e.from] + e.cost; updata = true; } } if(!updata) break; //如果不再更新代表已经找好了最短路径或者无路可以到达 跳出死循环 } } //还可以修改这个算法判断图中是否存在负圈(即权值为负的圈) //如果图中不存在从s可达的负圈,那么最短路径不会经过同一个顶点两次(也就是说,最多通过V-1条边,while最多执行V-1次) 如果执行了超过V-1次代表存在负圈 bool find_negative_loop() { //存在负圈返回true fill(d,d+MAX_V,0); //最短距离数组初始化为0 for(int i=0;i<V;i++) { for(int j=0;j<E;j++) { edge e = es[j]; if(d[e.to] > d[e.from] + e.cost) { d[e.to] = d[e.from] + e.cost; if(i == V-1) return true; //第V次更新了 存在负圈 } } } return false; }
-
SPFA(堆优化的Bellman-Ford算法)(国际和国内并不怎么认可这个算法,仅供了解不详细介绍)
#include<bits/stdc++.h> using namespace std; const int MAX_N = 9999; const int INF = 9999; int adj[MAX_N][MAX_N]; //SPFA用邻接表存储图求最短路径 bool flag[MAX_N]; //记录顶点是在队列中 int d[MAX_N]; //最短路径 int num[MAX_N]; //记录每个点的入队次数 如果有一个点入队次数超过n次(就是顶点数)那么证明图中存在负圈,SPFA就无法求解 bool SPFA(int s,int n) { //先进行基本的初始化工作 使用从大到小排序的优先队列 priority_queue<int,vector<int>,greater<int>> q; fill(flag,flag+n,false); fill(num,num+n,0); fill(d,d+n,INF); d[s] = 0; q.push(s); flag[s] = true; //s在队列中 num[s]++; while(!q.empty()) { int p = q.top(); q.pop(); //出队 for(int i=0;i<n;i++) { if(d[i] > d[p] + adj[p][i]) { //存在更短的路径 d[i] = d[p] + adj[p][i]; if(!flag[i]) { //如果i顶点不在队列中 q.push(i); //入队 num[i]++; //入队次数增加 if(num[i]>n) { //存在负环 无法求解 return false; } flag[i] = true; } } } flag[p] = false; //p顶点出队了 不在队列中 } return true; }
-
Dijkstra算法(求单源最短路径问题)无法对存在负权值边的图求解 时间复杂度为O(V^2) 时间复杂度只与顶点数量有关 适合稠密图(边多的图 V~E^2)
/* 算法核心思想: (1) 找到最短距离已经确定的顶点,更新与这个节点相邻的顶点的最短路径 (2) 此后就不再关心(1)中已经确定最短距离的顶点 */ int adj[MAX_N][MAX_N]; //使用邻接矩阵 bool used[MAX_N]; int d[MAX_N]; void Dijkstra(int s) { fill(d,d+n,INF); //初始化 fill(used,used+n,false); d[s] = 0; while(true) { //可以改写为for(int k=0;k<V-1;k++) 不存在负圈至多执行V-1次 int v = -1; //用于寻找used为false且最短距离最小的点 for(int i=0;i<n;i++) { if(!used[i] && (v==-1 || d[i] < d[v])) v=i; } if(v==-1) break; //所有顶点都找完了 used[v] = true; for(int j=0;j<n;j++) { //用新确定的最短路径的点更新其余最短路径 d[j] = min(d[j],d[v]+adj[v][j]); } } } //if(d[n] == INF) 说明两点不连通 做题时要考虑进去
-
Dijkstra算法(堆优化优先队列)时间复杂度为O(E log|V|) 适合稀疏图 V ~E同级 使用优先队列实现由于其本身优先队列采取的是冗余 会使时间复杂度变为O(E log|E|) 手写堆 则时间复杂度不变 但由于E和V同级 所以优先使用STL的优先队列
struct edge {int to, cost;}; //边的结构 typedef pair<int,int> P; int V; vector<edge> Graph[MAX_N]; int d[MAX_N]; void Dijkstraqueue(int s) { fill(d,d+V,INF); //初始化 d[s] = 0; priority_queue< P,vector<P>,greater<P> > que; que.push(P(0,s)); //让起点入队 while(!que.empty()) { //只要队列不空就循环 P p=que.top(); que.pop(); int v = p.second; //记录路径最短的顶点 if(d[v]<p.first) continue; //如果这个点已经更新过最短路径 那么继续下一次循环就好 for(int i=0;i<Graph[v].size();i++) { //更新v点对邻接点的影响 edge e = Graph[v][i]; if(d[e.to] > d[v] + e.cost) { d[e.to] = d[v] + e.cost; que.push(P(d[e.to],e.to)); } } } }
-
Floyd-Warshall算法(多源最短路径求解 时间复杂度为O(V^3))
//简单的动态规划 int d[MAX_V][MAX_V]; //存图的邻接矩阵 int V; //顶点数 void Floyd_Warshall() { for(int k=0;k<V;k++) //k是dp的中间状态所以需要先循环 for(int i=0;i<V;i++) for(int j=0;j<V;j++) { d[i][j] = min(d[i][j], d[i][k]+d[k][j]); } //循环结束后 d[i][j] 存放的就是i-->j最短路径 }