数据结构 图
图
概念
图由顶点集合和边集合构成,其中边可以是有向的,也可以是无向的,但是不存在顶点有直接和自身相连的边(自环),也不存在两个顶点之间的多个同向边。
有向边用(u,v)表示,无向边用<u,v>表示。
完全图(complete graph):顶点之间两两相连,有向图中边数为n(n-1),无向图中为n(n-1)/2。
权(weight):边上的数值,带权图也叫做网络(network)。
子图(subgraph):图的顶点的子集由其边的子集构成的图
度(degree):与顶点v关联的边数,记作deg(v),有向图中为入度出度之和。所有顶点的度加起来等于边数的2倍(一条边贡献2个度)。
路径(path):一系列连续的顶点。
简单路径:互不重复的一系列顶点。
连通图(connected graph):无向图中任意两个顶点之间都有路径相连(不是边,这是和完全图的区别)。
连通分量(connected component):非连通图的极大连通子图。
强连通图(strongly connected digraph):有向图中任意两点之间都有路径互相连接。
强连通分量(strongly connected component):非强连通图的极大强连通子图。
生成树(spanning tree):无向图的极小连通子图。n个顶点n-1条边。
关节点(articulation point):删去该节点及其与之相连的所有边,则原图被划分为至少两个连通分量。
重连通图(biconnected graph):不存在关节点的图。
顶点表示活动的网络(activities on vertices):用有向边<vi, vj>表示的活动之间的先后关系。
边表示活动的网络(activities on edges):用有向边表示活动,有向边的权值表示活动的持续时间,顶点表示事件。
源点(source):AOE网络的开始点。
汇点:(sink):AOE网络的结束点。
关键路径(critical path):从源点到汇点的最长路径长度。
存储
图有三种存储方式,邻接矩阵,邻接表和邻接多重表(十字链表)。此外在某些算法中,也可以单独存储图的边。
const int n = 10;// 顶点个数
// 邻接矩阵
int m[n][n];
邻接表储存所有顶点,与顶点直接相连的边与顶点链式相连。无向图每个边被储存两次,适合稀疏图,方便统计每个顶点的度。
const int n = 10;// 顶点个数
// 邻接表
// 与某个顶点相连的各个边
struct Edge{
int w;// 边的权值
int dest;// 对应的另一个顶点在邻接表中的下标
Edge* next = NULL;// 指向下一条边
};
// 各个顶点
struct Vertex{
int id;// 顶点编号
Edge * first = NULL;// 指向第一条边
};
// 邻接表表内存储各个顶点,与之相连的各个边以链表形式与其相连
Vertex adjTable[n];
可以看到,无论是邻接矩阵还是邻接表,其都是以顶点为中心的,每个边被储存了两次。很多时候我们需要以边为主体处理问题,因此可以使用邻接多重表。
const int n = 10;// 顶点个数
// 邻接多重表,无向图
// 边
struct Edge{
int w;// 边的权值
int v1;
int v2;// 该边的两个顶点下标
Edge * next1 = NULL;// 指向与顶点v1相连的下一条边,即v1或者v2中出现这个v1的所有边
Edge * next2 = NULL;// 指向与顶点v2相连的下一条边,即v1或者v2中出现这个v1的所有边
};
// 顶点
struct Vertex{
int id// 顶点编号
Edge * first NULL;// 指向与该顶点相连的第一条边
};
// 邻接表表内存储各个顶点,与之相连的各个边以链表形式与其相连
Vertex adjTable[n];
可以看到,在无向图的邻接多重表中,边只出现一次,方便集中处理边。由于邻接表中边结点只储存边的终点,而邻接多重表中既储存了起点又储存了终点,因此二者的总空间是一样的。
而有向图中,理论上不仅要统计入边,还要统计出边。
const int n = 10;// 顶点个数
// 邻接多重表,有向图
// 边
struct Edge{
int w;// 边的权值
int v1;
int v2;// 该边的两个顶点下标
Edge * next1 = NULL;// 指向与顶点v1相连的下一条出边,即v1中出现这个v1的边
Edge * next2 = NULL;// 指向与顶点v2相连的下一条入边,即v2中出现这个v2的边
};
// 顶点
struct Vertex{
int id// 顶点编号
Edge * firstIn NULL;// 指向与该顶点相连的第一条入边
Edge * firstOut NULL;// 指向与该顶点相连的第一条出边
};
// 邻接表表内存储各个顶点,与之相连的各个边以链表形式与其相连
Vertex adjTable[n];
遍历
与树不同的是,图中可能存在回路,因此有可能导致重复访问,需要有一个vis数组记录每个点是否被访问。
#include <iostream>
#include <map>
#include <vector>
#include <queue>
using namespace std;
const int maxn = 100;
int n,k;
int vis[maxn];
// 邻接矩阵
int m[maxn][maxn];
// 邻接表
map<int,vector<int> > g;
// 从s点开始深度优先遍历整个图,邻接矩阵,递归
void dfs1(int s){
vis[s] = 1;
cout<<s<<" ";
for(int i = 1;i <= n;i++){
if(m[s][i] && !vis[i]){
dfs1(i);
}
}
}
// 从s点开始深度优先遍历整个图,邻接表,递归
void dfs2(int s){
vis[s] = 1;
cout<<s<<" ";
for(int i : g[s]){
if(!vis[i]){
dfs2(i);
}
}
}
// 从s点开始广度优先遍历整个图,邻接矩阵,非递归
void bfs1(int s){
queue<int> q;
q.push(s);
vis[s] = 1;
while(!q.empty()){
s = q.front();
cout<<s<<"-"<<vis[s]<<" ";
for(int i = 1;i <= n;i++){
if(m[s][i] && !vis[i]){
q.push(i);
vis[i] = vis[s] + 1;
}
}
q.pop();
}
}
// 从s点开始广度优先遍历整个图,邻接表,非递归
void bfs2(int s){
queue<int> q;
q.push(s);
vis[s] = 1;
while(!q.empty()){
s = q.front();
cout<<s<<"-"<<vis[s]<<" ";
for(int i : g[s]){
if(!vis[i]){
q.push(i);
vis[i] = vis[s] + 1;
}
}
q.pop();
}
}
int main(){
memset(vis, 0, sizeof(vis));
memset(m, 0, sizeof(m));
cin>>n>>k;
for(int i = 0;i < k;i++){
int a,b;
cin>>a>>b;
m[b][a] = m[a][b] = 1;
g[a].push_back(b);
g[b].push_back(a);
}
bfs2(1);
}
/*
9
10
1 2
1 5
1 7
2 3
2 5
3 4
5 6
6 7
6 8
8 9
*/
/*
9
10
1 2
1 3
1 4
2 3
2 5
3 6
4 6
5 7
6 8
8 9
*/
深度优先用递归,广度用队列,非递归。对于邻接矩阵而言,深搜和广搜的时间复杂度都是O(n2),对于邻接表而言,深搜和广搜的时间复杂度都是O(n*e)。
遍历的路径构成了一个极小连通子图,即一棵生成树。利用遍历可以求出非连通图的各个连通分量。
最小生成树
最小生成树具有以下两个性质:
- 最短的边一定在某一棵最小生成树中
- 某个顶点相连的最短边一定在某一棵最小生成树中
根据这两个性质,分别产生了kruskal算法和prim算法,二者都是一种贪心的策略。
kruskal算法
初始时,图中只有各个顶点而没有边,从所有不在同一连通分量的边中选取最短的那一条,将两个顶点连接成一个连通分量,那么这个连通分量就可以视为一个点,继续进行此操作,进行n-1次即可确定最小生成树。
const int maxn = 100;
struct Edge{
int u,v,w;
bool operator < (const Edge &e) const{
return w > e.w;
}
};
priority_queue<Edge> pq;
int F[maxn];
int find(int x){
return F[x] == -1 ? x : find(F[x]);
}
// kruskal算法,寻找图的最小生成树大小,-1代表不连通
int kruskal(int n){
memset(F, -1, sizeof(F));
int ans = 0, cnt = 0;
while (!pq.empty()) {
// 假如该边不在同一个连通分量内
if(find(pq.top().u) != find(pq.top().v)){
// 选取该边,并将该边的两端加入连通分量中
F[find(pq.top().u)] = find(pq.top().v);
ans += pq.top().w;
cnt++;
}
pq.pop();
if(cnt == n-1) break;
}
// 将队列清空
while (!pq.empty()) {
pq.pop();
}
return cnt == n-1 ? ans : -1;
}
复杂度方面,假设有e条边,n个顶点,那么建图复杂度为O(e),建堆复杂度为O(eloge),算法过程中最坏要遍历e条边,每条边假设都被选取,则每次都要执行并查集的查和并操作,并操作的复杂度为O(1),查操作的复杂度为O(logn)(这里取一个简单情况,实际上要更加复杂),遍历的总复杂度为O(elogn)。假如图是连通图,即图可以生成最小生成树,那么图的边数e>=n-1,即总复杂度为O(eloge)。
prim算法
初始时,图中只有各个顶点而没有边,从所有一端在最小生成树集合而另一端不在的边中选取最短的那一条,将另一端加入最小生成树集合,那么这个最小生成树就可以视为一个点,继续进行此操作,进行n-1次即可确定最小生成树。
具体实现方面,其思想与dijkstra类似,都有一个本质上是dp的“松弛”操作,在某个状态下,每个点距离最小生成树集合的距离要不是上一个状态(即加入某个点前)的距离,要不是到新加入的点的距离。这样在每次求最近点时直接查表就可以了。
int vis[maxn];// 记录某个点是否在最小生成树集合中
int lowc[maxn];// 各个点到最小生成树集合的最短距离
int m[maxn][maxn];
const int INF = 0x3f3f3f3f;
// prim算法求图的最小生成树,-1代表不连通
int prim(int n){
int ans = 0;
memset(vis, 0, sizeof(vis));
vis[0] = 1;
// 初始化lowc
for(int i = 0;i < n;i++){
lowc[i] = m[0][i];
}
// 重复n-1次
for(int i = 1;i < n;i++){
// 找到当前距离最近的最小生成树集合以外的点
int minc = INF;
int p = -1;
for(int j = 0;j < n;j++){
if(!vis[j] && lowc[j] < minc){
minc = lowc[j];
p = j;
}
}
// 假如找不到,返回-1
if(p == -1){
return -1;
}
// 更新答案
ans += lowc[p];
vis[p] = 1;
// 松弛
for(int j = 0;j < n;j++){
if(!vis[j] && lowc[j] > m[p][j]){
lowc[j] = m[p][j];
}
}
}
return ans;
}
prim算法的复杂度是O(n2)。理论上用储存边的方式+堆可以达到O(eloge),不过一般不需要这样优化,实现也有些麻烦
总而言之,稀疏图,储存边,用kruskal,稠密图,储存邻接矩阵,用prim。
最短路
最短路就是从源点到终点的权值最小的路径。以下算法既适用有向图,也适用无向图。
dijkstra算法
dijkstra算法可以求有向图单源非负权值的最短路径,可以有环。dijkstra和prim在实现上极为相似,都是贪心+dp,但是其思想上有所不同。prim算法将最小生成树的顶点集合视为一个点,每次在各个孤立的顶点或者顶点集合之间选出与顶点集合相连的最短边,这是一个规模逐渐减小的子问题。dijkstra算法每次选取最短路径树的顶点集合以外的点中距离源点的路径最短的点加入顶点集合,由于不存在负权边,所以该路径就是最短路径。
具体实现中,求解过程和prim类似,可以设置一个pre数组,记录最短路径,在松弛时更新。
const int maxn = 100;
int vis[maxn];// 记录某个点是否在最小生成树集合中
int lowc[maxn];// 各个点到最小生成树集合的最短距离
int pre[maxn];// 每个点的前驱结点
int m[maxn][maxn];
const int INF = 0x3f3f3f3f;
// dijkstra算法求最短路径,0代表不连通,源点为0
int prim(int n){
memset(vis, 0, sizeof(vis));
vis[0] = 1;
// 初始化lowc和pre
for(int i = 0;i < n;i++){
lowc[i] = m[0][i];
pre[i] = 0;
}
// 重复n-1次
for(int i = 1;i < n;i++){
// 找到当前距离最近的最短路集合以外的点
int minc = INF;
int p = -1;
for(int j = 0;j < n;j++){
if(!vis[j] && lowc[j] < minc){
minc = lowc[j];
p = j;
}
}
// 假如找不到,返回0
if(p == -1){
return 0;
}
// 更新
vis[p] = 1;
// 松弛
for(int j = 0;j < n;j++){
if(!vis[j] && lowc[j] > m[p][j] + lowc[p]){
lowc[j] = m[p][j] + lowc[p];
pre[j] = p;
}
}
}
return 1;
}
复杂度方面,和prim一样都是O(n2),可以用堆进行优化。
dijkstra变形
多条最短路,统计条数:设置一个num数组用以记录到每个顶点的最短路条数,在松弛时更新,若成功松弛,则num[v] = num[p],若松弛时二者相等,则num[v] = num[v] + num[p]。
经过顶点权值之和最大的最短路:设置一个vmaxc数组用以记录到每个顶点的顶点权值之和的最大值,假如在松弛时遇到了两个相等的路径,那么比较这两个路径的vmaxc,取较大的那个,也是同样的松弛操作。
dijkstra的分析和递归类似,考虑某一一般状态及其状态的转移。
Bellman-Ford算法
Bellman-ford算法可以求有向图单源任意权值的最短路径,不能有负权环。
Bellman-ford的核心思路是:经过n个顶点的最短路径,最多有n-1个边(否则将产生回路,无论是正是负,都不会是最短路径)。
Bellman-ford本质上是一个dp。设dist[k][v]为从源点u到顶点v的经过边数不超过k的最短路径长度,那么要么是用了k-1条边就可以到v点,要么是用了k-1条边到了与v点直接相连的一个点,即递推关系如下:
dist[k][v] = min{ dist[k-1][v] , min{dist[k-1][j] + m[j][v]} },其中j为所有与v直接相连的顶点
dist[1][v] = m[u][v],初始状态
在dpn-1次后,若dist[n][v] < dist[n-1][v],说明存在负权环。
在具体实现中,采用存储边的方式。当一次递推没有出现dist数组的修改时,那么后序的递推也不会对dist产生修改,因此此时直接返回即可,没有负权环。
const int maxn = 100;
const int INF = 0x3f3f3f3f;
struct Edge{
int u,v;
int w;
Edge(int _u=0,int _v=0,int _w=0):u(_u),v(_v),w(_w){}
};
vector<Edge> E;// 储存各个边
int dist[maxn];
// bellman-ford算法求解以s为源点的n个顶点的单源最短路径,若有负权环返回false
bool bellman_ford(int s, int n){
// 初始化
memset(dist, 0x3f, sizeof(dist));
dist[s] = 0;
// 递推n-1次
for(int i = 1;i < n;i++){
bool flg = false;// 判断有无负权回路
// 遍历所有的边uv,对于每个v,计算其dist是否需要更新
for(int j = 0;j < E.size();j++){
int u = E[j].u;
int v = E[j].v;
int w = E[j].w;
if(dist[v] > dist[u] + w){
dist[v] = dist[u] + w;
flg = true;
}
}
// 假如dist数组没有被修改,那么直接结束,不存在负权环
if(!flg){
return true;
}
}
// 第n次递推,判断有无负权回路
for(int j = 0;j < E.size();j++){
int u = E[j].u;
int v = E[j].v;
int w = E[j].w;
if(dist[v] > dist[u] + w){
return false;
}
}
return true;
}
Bellman-Ford算法的时间复杂度在邻接矩阵的情况下应该是O(n3),若存储边则为O(n*e)。
Floyd算法
Floyd算法用来求解任意两点之间的最短路径,允许权值为负,但是不能含有负权回路。
利用path数组记录两端点之间的中间分隔点。
#include <iostream>
#include <vector>
#include <string.h>
using namespace std;
const int maxn = 100;
const int INF = 0x3f3f3f3f;
int m[maxn][maxn];// 邻接矩阵
int dp[maxn][maxn];// 最短路长度
int path[maxn][maxn];
void floyd(int n){
memcpy(dp, m, sizeof(m));
memset(path, -1, sizeof(path));
for(int k = 0;k < n;k++){/// k要放在最外层
for(int i = 0;i < n;i++){
for(int j = 0;j < n;j++){
if(dp[i][j] > dp[i][k] + dp[k][j]){
dp[i][j] = dp[i][k] + dp[k][j];
path[i][j] = k;
}
}
}
}
}
void showPath(int l,int r){
if(path[l][r] == -1){
cout<<" "<<r;
}else{
showPath(l, path[l][r]);
showPath(path[l][r], r);
}
}
int main(){
memset(m, 0x3f, sizeof(m));
int n = 0,k = 0,a,b;
cin>>n>>k;
for(int i = 0;i < n;i++) m[i][i] = 0;
for(int i = 0;i < k;i++){
cin>>a>>b>>m[a][b];
}
floyd(n);
cout<<dp[1][0]<<endl<<1;
showPath(1, 0);
}
/*
4 8
0 1 1
0 3 4
1 2 9
1 3 2
2 0 3
2 1 5
2 3 8
3 2 6
*/
Floyd算法的复杂度为O(n3)。
AOV网络与AOE网络
AOV与拓扑排序
AOV网络是用顶点表示活动的有向图。为了确定图中是否有环,可以进行拓扑排序,其算法如下:
每次从AOV网络中删去一个没有直接前驱(入度为0)的点,删除其所有出边并输出之。重复该操作,直到无法删除,若输出的顶点数目不足所有顶点,则说明图中存在回路。
#include <iostream>
#include <map>
#include <set>
using namespace std;
map<int,set<int>> inDegree;
// 拓扑排序,false说明有环
bool topoSort(){
while (inDegree.size()) {
bool flg = false;
// 找到入度为0的顶点
for(auto it = inDegree.begin();it != inDegree.end();it++){
int v = it->first;
// 该顶点入度为0
if(it->second.empty()){
flg = true;
// 输出之
cout<<v<<" ";
// 删去之
inDegree.erase(v);
// 删除其所有出边
for(auto ite = inDegree.begin();ite != inDegree.end();ite++){
if(ite->second.find(v)!=ite->second.end()){
ite->second.erase(v);
}
}
break;
}
}
if(!flg) return false;
}
return true;
}
int main(){
int n,m;
cin>>n>>m;
for(int i = 1;i <= n;i++){
set<int> tmp;
inDegree[i] = tmp;
}
for(int i = 0;i < m;i++){
int a,b;
cin>>a>>b;
inDegree[b].insert(a);
}
if(!topoSort())cout<<"no"<<endl;
}
/*
9 11
1 8
1 3
2 3
2 4
2 5
3 4
4 6
4 7
5 6
8 9
9 7
*/
/*
4 4
1 2
2 3
3 4
4 2
*/
其时间复杂度为O(n2loge)(?),若利用栈,则可以达到O(n+e)。
AOE与关键路径
为了确定关键路径和关键活动,首先按照拓扑序计算每个事件的最早开始时间,再按照逆拓扑序计算每个事件的最晚开始时间,最后那些最早开始时间和最晚开始时间相同的顶点即为关键事件,即松弛时间为0的事件。关键路径就是每个事件都是关键事件的路径。
判断图中是否有环
无向图
- 拓扑排序
首先,无向图和有向图一样都可以使用拓扑排序,无向图中,每次拿出度<=1的点,直到无法拿出,若还有没拿出的点,那就有环。
- 直接计算
首先,假如图是连通图,那么假如边数E >= 顶点数N,一定有环;假如图是非连通图,那么可以先遍历一次求出所有的连通分量个数I,则若E + I > N。
- DFS
在遍历时,对各个顶点设置一个数组,记录该顶点的访问情况,若之前从来没有访问过,值为0;若只访问过一次,说明正在访问它的孩子,还没有退回,值为1;若已经退回了,访问了两次,那么值为2。在访问中遇到了值为1的顶点,则有环。
- BFS
在遍历时,对每个访问的顶点,检查其是否和之前已访问的顶点相连(上一层到该点的源点除外),若相连,则有环。
- Bellman-Ford判断负权环
Bellman-Ford在n-1次松弛后还可以继续松弛,则存在负权环。
有向图
- 拓扑排序
有向图d额排序,计算入度出度。
- DFS
同无向图
- BFS
同无向图
- Bellman-Ford判断负权环
同无向图