图论基础
图论
相关概念
图 (Graph) 是一个二元组\(G=(V(G),E(G))\) 。其中\(V(G)\) 是非空集,称为 点集 (Vertex set) ,对于 \(V\)中的每个元素,我们称其为 顶点 (Vertex) 或 节点 (Node) ,简称 点 ;\(E(G)\) 为\(V(G)\) 各结点之间边的集合,称为 边集 (Edge set) 。
常用\(G=(V,E)\) 表示图。
当\(V,E\) 都是有限集合时,称\(G\) 为 有限图 。当 \(V\)或\(E\) 是无限集合时,称 \(G\)为 无限图 。
图有多种,包括 无向图 (Undirected graph) , 有向图 (Directed graph) , 混合图 (Mixed graph) 等。
若 \(G\)为无向图,则\(E\) 中的每个元素为一个无序二元组\((u,v)\) ,称作 无向边 (Undirected edge) ,简称 边 (Edge) ,其中\(u,v\in V\) 。设 \(e=(u,v)\),则\(u\) 和\(v\) 称为 的 端点 (Endpoint) 。
若\(G\) 为有向图,则\(E\) 中的每一个元素为一个有序二元组\((u,v)\),有时也写作\(u\rightarrow v\) ,称作 有向边 (Directed edge) 或 弧 (Arc) ,在不引起混淆的情况下也可以称作 边 (Edge) 。设\(e=u\rightarrow v\) ,则此时 \(u\)称为 \(e\)的 起点 (Tail) , 称\(v\)为\(e\) 的 终点 (Head) ,起点和终点也称为 \(e\)的 端点 (Endpoint) 。
若\(G\) 为混合图,则 \(E\)中既有向边,又有无向边。
若 \(G\)的每条边\(e_k=(u_k,v_k)\) 都被赋予一个数作为该边的 权 ,则\(G\)称为 赋权图 。如果这些权都是正实数,就称 \(G\)为正权图 。

若一张图的边数远小于其点数的平方,那么它是一张 稀疏图 (Sparse graph) 。
若一张图的边数接近其点数的平方,那么它是一张 稠密图 (Dense graph) 。
连通:在一个图中,如果从顶点U到顶点V有路径,则称U和V是连通的;
连通图:如果一个无向图中,任意两个顶点之间都是连通的,则称该无向图为连通图。否则称为非连通图;
连通分量:一个无向图的连通分支定义为该图的最大连通子图,左图的连通分量是它本身。

强连通图:在一个有向图中,对于任意两个顶点U和V,都存在着一条从U到V的有向路径,同时也存在着一条从V到U的有向路径,则称该有向图为强连通图;右图不是一个强连通图。
强连通分量:一个有向图的强连通分量定义为该图的最大的强连通 ,右图含有两个强连通分量,一个是1和2构成的一个子图,一个是3独立构成的一个子图。
形象地说,图是由若干点以及连接点与点的边构成的。
图的存储
直接存边
方法
使用一个数组来存边,数组中的每个元素都包含一条边的起点与终点(带边权的图还包含边权)。(或者使用多个数组分别存起点,终点和边权。)
#include <iostream>
#include <vector>
using namespace std;
struct Edge {//存储一条边所连的两个节点
int u, v;
};
int n, m;
vector<Edge> e;
vector<bool> vis;
bool find_edge(int u, int v) //判断u,v是否相连,即是否存在一边端点分别为u,v
{
for(int i = 1; i <= m; ++i)
if (e[i].u == u && e[i].v == v) return true;
return false;//枚举所有边都没有一边分别连u,v,则返回false
}
void dfs(int u)
{
if (vis[u]) return;//访问过就不再访问,去重
vis[u] = true;//标记为访问过
for(int i = 1; i <= m; ++i)
if (e[i].u == u) dfs(e[i].v);//有以u为起点的边,则遍历这条边的终点
}
int main()
{
cin >> n >> m;
vis.resize(n + 1, false);//resize() 重新指定容器有效的元素个数为n+1,令为false
e.resize(m + 1);
for(int i = 1; i <= m; i++) cin >> e[i].u >> e[i].v;
return 0;
}
复杂度
查询是否存在某条边:\(O(m)\)
遍历一个点的所有出边:\(O(m)\)
遍历整张图: \(O(nm)\)
空间复杂度:\(O(m)\)
应用
由于直接存边的遍历效率低下,一般不用于遍历图。
在 Kruskal 算法中,由于需要将边按边权排序,需要直接存边。
在有的题目中,需要多次建图(如建一遍原图,建一遍反图),此时既可以使用多个其它数据结构来同时存储多张图,也可以将边直接存下来,需要重新建图时利用直接存下的边来建图。
邻接矩阵
方法
使用一个二维数组 adj 来存边,其中 adj[u][v] 为 1 表示存在\(u\) 到\(v\) 的边,为 0 表示不存在。如果是带边权的图,可以在 adj[u][v] 中存储 \(u\)到\(v\) 的边的边权。
STL
#include <iostream>
#include <vector>
using namespace std;
int n, m;
vector<bool> vis;
vector<vector<bool> > adj;//二维布尔数组
bool find_edge(int u, int v) { return adj[u][v]; }//判断u,v是否相连
void dfs(int u)
{
if (vis[u]) return;//访问过就不再访问,去重
vis[u] = true;//标记为访问过
for (int v = 1; v <= n; ++v)
if (adj[u][v]) dfs(v);//如果有以u为起点的边,则遍历这条边的终点
}
int main()
{
cin >> n >> m;
vis.resize(n + 1, false);//resize() 重新指定容器有效的元素个数为n+1,令为false
adj.resize(n + 1, vector<bool>(n + 1, false));//重新指定容器有效的元素个数为n+1,令为布尔数组为0
for (int i = 1; i <= m; i++)
{
int u, v;
cin >> u >> v;
adj[u][v] = true;
adj[v][u] = true;//当为无向图时
}
return 0;
}
#include <iostream>
#include <vector>
using namespace std;
int a[1001],n,m;
void vis[1001];
void dfs(int u)
{
if (vis[u]) return;
vis[u] = true;
for(int v = 1; v <= n; ++v)
if (a[u][v]) dfs(v);//如果有以u为起点的边,则遍历这条边的终点
}
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
a[i][j]=0x7fffffff;
for(int k=1;k<=m;k++)
{
cin>>i>>j>>w;//i连到j的边,权值为W
a[i][j]=w;
a[j][i]=w;//如果是无向图,加上
}
}
复杂度
查询是否存在某条边:\(O(1)\)
遍历一个点的所有出边: $ O(n) $
遍历整张图: \(O(n^2)\)
空间复杂度: \(O(n^2)\)
应用
邻接矩阵只适用于没有重边(或重边可以忽略)的情况。
其最显著的优点是可以\(O(1)\) 查询一条边是否存在。
由于邻接矩阵在稀疏图上效率很低(尤其是在点数较多的图上,空间无法承受),所以一般只会在稠密图上使用邻接矩阵。
邻接表
方法
使用一个支持动态增加元素的数据结构构成的数组,如 vector adj[n + 1] 来存边,其中 adj[u] 存储的是点\(u\) 的所有出边的相关信息(终点、边权等)。
#include <iostream>
#include <vector>
using namespace std;
int n, m;
vector<bool> vis;
vector<vector<int> > adj;
bool find_edge(int u, int v)
{
for(int i = 0; i < adj[u].size();i++) //size()目前容器正拥有的元素个数
if (adj[u][i] == v) return true;
return false;
}
void dfs(int u)
{
if (vis[u]) return;
vis[u] = true;
for (int i = 0; i < adj[u].size();i++) dfs(adj[u][i]);
}
int main()
{
cin >> n >> m;
vis.resize(n + 1, false);
adj.resize(n + 1);
for (int i = 1; i <= m; ++i)
{
int u, v;
cin >> u >> v;
adj[u].push_back(v);//在最后添加元素
}
return 0;
}
复杂度
查询是否存在\(u\)到\(v\)的边:\(O(d^+(u))\) (如果事先进行了排序就可以使用二分查找做到 )。
遍历点\(u\)的所有出边:\(O(d^+(u))\)
遍历整张图: \(O(n+m)\)
空间复杂度: \(O(m)\)
应用
存各种图都很适合,除非有特殊需求(如需要快速查询一条边是否存在,且点数较少,可以使用邻接矩阵)。
尤其适用于需要对一个点的所有出边进行排序的场合。
数组模拟邻接表(推荐)
#include <iostream>
using namespace std;
const int maxn=1001,maxm=100001;
struct Edge
{
int next; //下一条边的编号
int to; //这条边到达的点
int dis; //这条边的长度
}edge[maxm];
int head[maxn],num_edge,n,m,u,v,dis
void add_edge(int from,int to,int dis) //加入一条从from到to的单向边
{
edge[++num_edge].next=head[from];//head[i]代表点i边集中的第一条边编号,对应链表的head指针
edge[num_edge].to=to; //next[i]代表边i的下一条边编号,对应链表中的next指针
edge[num_edge].dis=dis; //to[i]代表边i到达的结点
head[from]=num_edge; //dis[i]代表边i的权值
}
int main()
{
num_edge=0;
scanf("%d %d",&n,&m); //读入点数和边数
for(int i=1;i<=m;i++)
{
scanf("%d %d",&u,&v,&dis)//u、v之间有一条边
add_edge(u,v,dis); //u到v连一条边
}
}
链式前向星
方法
本质上是用链表实现的邻接表,代码如下:
#include <iostream>
#include <vector>
using namespace std;
int n, m;
vector<bool> vis;
vector<int> head, nxt, to;
void add(int u, int v)
{
nxt.push_back(head[u]);
head[u] = to.size();
to.push_back(v);
}
bool find_edge(int u, int v)
{
for (int i = head[u]; ~i; i = nxt[i])
{ // ~i 表示 i != -1
if (to[i] == v) return true;
}
return false;
}
void dfs(int u)
{
if (vis[u]) return;
vis[u] = true;
for (int i = head[u]; ~i; i = nxt[i]) dfs(to[i]);
}
int main()
{
cin >> n >> m;
vis.resize(n + 1, false);
head.resize(n + 1, -1);
for (int i = 1; i <= m; ++i)
{
int u, v;
cin >> u >> v;
add(u, v);
}
return 0;
}
复杂度
查询是否存在\(u\)到\(v\) 的边:\(O(d^+(u))\)
遍历点 的所有出边: \(O(d^+(u))\)
遍历整张图: \(O(n+m)\)
空间复杂度: $ O (m)$
应用
存各种图都很适合,但不能快速查询一条边是否存在,也不能方便地对一个点的出边进行排序。
优点是边是带编号的,有时会非常有用,而且如果 cnt 的初始值为奇数,存双向边时 i ^ 1 即是 i 的反边
图的遍历
DFS
首先访问一个邻接结点,然后以这个被访问的邻接结点作为初始结点,访问它的第一个邻接结点。
先纵向深入,再横向访问。
void dfs(int u)
{
vis[u] = 1;
for (int i = head[u]; i; i = edge[i].next)
if (!vis[edge[i].to]) dfs(v);
}
BFS
图论中常用的遍历方法,先将一个邻接点放入队列,弹出后将其邻接结点放入队列。
void bfs(int u)
{
while (!Q.empty()) Q.pop();//清空队列
Q.push(u);//放入点u
vis[u] = 1;//u点被访问过
d[u] = 0;//u点路径为0
p[u] = -1;//u点的上一个点没有
while (!Q.empty())
{
u = Q.front();//取出队头
Q.pop();//弹出队头
for (int i = head[u]; i; i = edge[i].next) //遍历
{
if (!vis[edge[i].to]) //邻接结点没有访问过
{
Q.push(edge[i].to);//入队
vis[edge[i].to] = 1;//标记为访问过
d[edge[i].to] = d[u] + 1;//路径加1
p[edge[i].to] = u;//上一个点是u
}
}
}
}
最短路问题
定义:在图中,每一条边或点有一定的权值(可能是正,也可能为负),找出一条从A到B的路径,使权值和最小
性质:对于边权为正的图,任意两个结点之间的最短路,不会经过重复的结点。
对于边权为正的图,任意两个结点之间的最短路,不会经过重复的边。
对于边权为正的图,任意两个结点之间的最短路,任意一条的结点数不会超过 \(n\),边数不会超过 \(n-1\)。
Floyd算法 \(O(N^3)\)
用来求任意两个结点之间的最短路的。
复杂度比较高,但是常数小,容易实现。(我会说只有三个 for 吗?)
适用于任何图,不管有向无向,边权正负,但是最短路必须存在。(不能有个负环)
我们定义一个数组 f[k][x][y] ,表示只允许经过结点\(1\) 到\(k\) ,结点\(x\) 到结点\(y\) 的最短路长度。
很显然, f[n][x][y] 就是结点\(x\)到结点\(y\)的最短路长度。
上面两行都显然是对的,然而这个做法空间是 \(O(N^3)\)。
但我们发现数组的第一维k是没有用的,于是可以直接改成\(f[x][y] = min(f[x][y], f[x][k]+f[k][y])\)
时间复杂度是\(O(N^3)\) ,空间复杂度是 \(O(N^2)\)。
for(k = 1; k <= n; k++) //循环中间点
for(i = 1; i <= n; i++) //起点
for(j = 1; j <= n; j++)//终点
f[i][j] = min(f[i][j], f[i][k] + f[k][j]);//如果有中间点k,使i——>j更优,则更换
Bellman-Ford算法 \(O(NM)\)
给定一个有向图,若对图中每一条边(x,y,z),有dis[y]<=dis[x]+z成立
枚举每条边(x,y,z),若dis[y]>dis[x]+z,则用dis[x]+z更新dis[y]
有向图
#include<iostream>
#include<cstdio>
using namespace std;
#define MAX 0x3f3f3f3f
#define N 1010
int n, m, from; //点,边,起点
typedef struct Edge //边
{
int u, v;
int cost;
}Edge;
Edge edge[N];
int dis[N], pre[N];
bool Bellman_Ford()
{
for(int i = 1; i <= n; ++i) //初始化
dis[i] = (i == from ? 0 : MAX);
for(int i = 1; i <= n - 1; ++i)
for(int j = 1; j <= m; ++j)
if(dis[edge[j].v] > dis[edge[j].u] + edge[j].cost) //松弛(顺序一定不能反~)
{
dis[edge[j].v] = dis[edge[j].u] + edge[j].cost;
pre[edge[j].v] = edge[j].u;
}
bool flag = 1; //判断是否含有负权回路
for(int i = 1; i <= m; ++i)
if(dis[edge[i].v] > dis[edge[i].u] + edge[i].cost)
{
flag = 0;
break;
}
return flag;
}
void print_path(int root) //打印最短路的路径(反向)
{
while(root != pre[root]) //前驱
{
printf("%d-->", root);
root = pre[root];
}
if(root == pre[root])
printf("%d\n", root);
}
int main()
{
scanf("%d%d%d", &n, &m, &from);
pre[from] = from;
for(int i = 1; i <= m; ++i)
{
scanf("%d%d%d", &edge[i].u, &edge[i].v, &edge[i].cost);
}
if(Bellman_Ford())
for(int i = 1; i <= n; ++i) //每个点最短路
{
printf("%d\n", dis[i]);
printf("Path:");
print_path(i);
}
else
printf("have negative circle\n");
return 0;
}
SPFA算法 \(O(kE)\)
使用一个队列,将源点s入队,对队列中每个元素,BFS其出边进行松弛操作,如果可以松弛,加入该出边所到点
这个算法,简单的说就是队列优化的bellman-ford,利用了每个点不会更新次数太多的特点发明的此算法。
这种算法常用于处理稀疏图和负权图,时间复杂度不稳定,容易被卡
void SPFA()
{
int inq[maxn],d[maxn];
queue<int> q;
memset(inq,0,sizeof(inq));//初始化
for(int i=1;i<=n;i++) d[i]= 0x3f;//初始化
d[s]=0; inq[s]=1; q.push(s);//源点路径长为0,标记在队列中,插入队列
while(!q.empty)
{
int u=q.front; q.pop() ; inq[u]=0;//将队头弹出,标记不在队列
for(int i=head[u];i;i=edge[i].next)
{
int to=edge[i].to,from=edge[i].from,dis=edge[i].dis;
if(d[to]>d[from]+dis)//松弛操作
{
d[to]=d[from]+dis;
if(!inq[to]) //如果可以松弛,加入该出边所到点(前提此点未被访问过)
{
inq[to]=1;q.push(to);
}
}
}
}
printf("%d\n",d[t]);
}
Dijkstra算法 \(O ( N^2)\) ---》 \(O(m\log n)\)
此算法可以求解不带负权的图,求从源点s到图中其他所有点的最短路。
将所有节点分为两部分,已知最短路径的顶点集合 P 和未知最短路的集合 Q 。
用dis数组记录目前已找到每个点的最短路径,初始化dis[s]=0,进行遍历。
在集合中放入源点 s ,找到一个离 s 最近的点 u ,使得 dis[u]最小,加入集合 P
考察所有以 u 为起点的边,进行松弛操作。假如有一条 u 到 v 的边,且从 s 到 v 的长度 < dis[v],就更新dis[v]
struct HeapNode//建立小根堆
{
int u,d;
bool operator < (const HeapNode &rhs) const//重载运算符
{
return d > rhs.d;
}
};
void Dijkstra()
{
int d[maxn];
priority_queue<HeapNode> q;
for(int i=1;i<=n;i++) d[i]= 0x3f;//初始化
d[s]=0;
q.push((HeapNode){s,d[s]});//加入源点
while(!q.empty)
{
HeapNode x=q.top(); q.pop();//弹出队头
int u=x.u;
if(x.d!=d[u]) continue;
for(int i=head[u];i>=0;i=edge[i].next)
{
int to=edge[i].to,from=edge[i].from,dis=edge[i].dis;
if(d[to]>d[from]+dis)//松弛操作
{
d[to]=d[from]+dis;
q.push((HeapNode){to,d[to]});
}
}
}
printf("%d\n",d[t]);
}
最小生成树
最小生成树:对于带权图,权值和最小的生成树
最小瓶颈生成树:对于带权图,最大权值最小的生成树
最小生成树一定是最小瓶颈生成树
可以使用Prim算法或者Kruskal算法,复杂度均为O(mlogn)
prim算法 \(O (N^2)\)
随意选取一个点作为已访问集合的第一个点,并将所有相连的边加入堆中
从堆中找到最小的连接集合内和集合外点的边,将边加入最小生成树中
将集合外点标记为已访问,并将相连边加入堆
重复以上过程直到所有点都在访问集合中
#include <bits/stdc++.h>
using namespace std;
int n, m;
int head[200001] ,ecnt,d[200001];
struct Edge
{
int to;
int next;
int dis;
}edge[1000001];
void add(int u, int v, int w)
{
edge[++ecnt].next=head[u];
edge[ecnt].to=v;
edge[ecnt].dis=w;
head[u]=ecnt;
}
bool vis[200001];
int cnt;
long long ans;
struct node
{
int x, d;
node(int x, int d) : x(x), d(d) {}
};
bool operator<(node a, node b)//重载运算符
{
return a.d > b.d;
}
priority_queue<node> q;
int main()
{
cin >> n >> m;
for (int i = 1; i <= m; i++)
{
int u, v, w;
cin >> u >> v >> w;
add(u, v, w);
add(v, u, w);//无向图
}
for (int i = 1; i <= n; i++)
d[i] = 1000000005;//初始化
q.push(node(1, 0));
d[1] = 0;//第一个结点到自己为0
cnt = -1;//初始化
while (!q.empty() && cnt < n - 1)
{
node h = q.top();
q.pop();
if (vis[h.x])
continue;
vis[h.x] = 1;
d[h.x] = 0;
cnt++;
ans += h.d;
for (int e = head[h.x]; e; e = edge[e].next)
{
if (d[edge[e].to] > edge[e].dis)
{
d[edge[e].to] = edge[e].dis;
q.push(node(edge[e].to, d[edge[e].to]));
}
}
}
if (cnt == n - 1)
{
cout << ans << endl;
}
else
{
cout << "orz" << endl;
}
}
Kruskal算法流程
将边按照权值排序
依次枚举每一条边,若连接的两点不连通则加入最小生成树中
使用并查集维护连通性
#include<bits/stdc++.h>
using namespace std;
struct edge{
int from,to,dis;
}g[500001];
bool cmp(edge a,edge b)
{
return a.dis<b.dis;
}
int fa[200001];
int getf(int x)
{
if(fa[x]==x)return x;
fa[x]=getf(fa[x]);//状态压缩
return fa[x];
}
int n,m,cnt;
long long ans;
int main()
{
cin>>n>>m;
for(int i=1;i<=m;i++)
{
cin>>g[i].from>>g[i].to>>g[i].dis;
}
for(int i=1;i<=n;i++)
fa[i]=i;
sort(g+1,g+m+1,cmp);
for(int i=1;i<=m&&cnt<n-1;i++)
{
int fu=getf(g[i].from),fv=getf(g[i].to);
if(fu==fv)continue;
fa[fu]=fv;
cnt++;
ans+=g[i].dis;
}
if(cnt==n-1){
cout<<ans<<endl;
}else{
cout<<"orz";
}
}
拓扑排序
与数组排序无关,倾向于一种逻辑关系,类似一种访问顺序,这种顺序往往不唯一
问题一般是随着排序顺序处理节点信息,不需要打印顺序
拓扑排序适用于有向无环图(无向则没有意义,有环得不出结果)
算法流程
首先在建图时记录每个点的入度
建立一个队列,把接下来需要访问的点加入队列
最开始时所有入度为0的点都可以访问,加入队列
依次从队列中取出每个点u,枚举其出边,边的终点设为v 。 此处进行各种u->v的信息更新
因为u信息已经计算过了,相当于从图中删去u,将其v入度-1
此时v若入度为0则说明前置信息处理完成,加入队列
拓扑排序源代码
#include<cstdio>
#include<cstdlib>
#include<cstring>
#include<algorithm>
#include<queue>
using namespace std;
struct edge
{
int x,y,next;
}a[200010];
int first[100010],len;
queue<int> z;
int id[100010];
int p[100010],l;
void ins(int x,int y)
{
len++;
a[len].x=x;a[len].y=y;
a[len].next=first[x];first[x]=len;
}
int main()
{
int n,m,i,j,x,k,y;
scanf("%d%d",&n,&m);
for(i=1;i<=m;i++)
{
scanf("%d%d",&x,&y);
ins(x,y);
id[y]++;
}
for(i=1;i<=n;i++)
if(id[i]==0)z.push(i);
while(!z.empty())
{
x=z.front();
p[++l]=x;
z.pop();
for(k=first[x];k;k=a[k].next)
{
y=a[k].y;
id[y]--;
if(id[y]==0)z.push(y);
}
}
for(i=1;i<=n;i++)printf("%d ",p[i]);
printf("\n");
}
拓扑排序求最大路径
#include <iostream>
using namespace std;
const int maxn=1001,maxm=100001;
int ind[maxn];
int d[maxn];
int q[maxn], qhead = 0, qtail = 0;
struct Edge
{
int next; //下一条边的编号
int to; //这条边到达的点
int dis; //这条边的长度
}edge[maxm];
int head[maxn],num_edge,n,m,u,v,dis;
void add_edge(int from,int to,int dis) //加入一条从from到to的单向边
{
edge[++num_edge].next=head[from];
edge[num_edge].to=to;
edge[num_edge].dis=dis;
head[from]=num_edge;
}
void topo()
{
for (int i = 1; i <= n; i++)
if (!ind[i]) q[qtail++] = i;//入度为0先入队
while (qhead != qtail)
{
int now = q[qhead++];//取队头
for (int i = head[now]; i; i = edge[i].next)//head每一个点连的第一条边,next对应下一条边
{
d[edge[i].to] = max(d[edge[i].to], d[now] + 1);
if (!--ind[edge[i].to]) q[qtail++] = edge[i].to;
}
}
}
int main()
{
num_edge=0;
scanf("%d %d",&n,&m); //读入点数和边数
for(int i=1;i<=m;i++)
{
scanf("%d %d",&u,&v,&dis);//u、v之间有一条边
ind[v]++;
add_edge(u,v,dis); //u到v连一条边
}
topo();
for(int i=1;i<=n;i++)
cout<<d[i]<<endl;
}

浙公网安备 33010602011771号