第十章 图算法专题
10.1 图的定义和相关术语
有向图-无向图
出度-入度
10.2 图的存储
10.2.1邻接矩阵
g[][]
【注】邻接矩阵只适用于顶点数目不超过1000的题目。
10.2.2 邻接表
vector<int> adj[n]
或者
struct edge{
int v;
int w;
};
vector<edge> adj[n];
10.3 图的遍历
10.3.1 深度优先搜索DFS
DFS以深度为第一关键词,每次沿着路径访问没有被访问过的结点直到不能再前进才退回到最近的分岔口。
【注】DFS不一定会遍历整个图的所有边!
邻接矩阵版本
const int maxn=1000;
const int inf=1e9+10;
int n,g[maxn][maxn];
bool vis[maxn]={false};
voud dfs(int u,int depth) //访问深度
{
vis[u]=true;
//对u的操作
for(int v=0;v<n;v++)
{ //v未被访问过且是u的邻接点
if(vis[v]==false&&g[u][v]!=inf)
dfs(v,depth+1);
}
}
//遍历整个图g
void dfsTrave()
{
for(int u=0;u<n;u++)
{
if(vis[u]==false)
dfs(u,1); //访问u所在的连通块
}
}
邻接表版本
const int maxn=1000;
const int inf=1e9+10;
int n;
vector<int> adj[maxn];
bool vis[maxn]={false};
voud dfs(int u,int depth) //访问深度
{
vis[u]=true;
//对u的操作
for(int i=0;i<adj[u].size();i++)
{ //adj[u][i]未被访问
if(vis[adj[u][i]]==false)
dfs(adj[u][i],depth+1);
}
}
//遍历整个图g
void dfsTrave()
{
for(int u=0;u<n;u++)
{
if(vis[u]==false)
dfs(u,1);
}
}
10.3.2 广度优先搜索BFS
BFS以广度为第一关键词,每次以扩散的方式向外访问顶点。
建立一个队列,把初始顶点加入队列,此后每次取出队首顶点进行访问,并把从该顶点出发可以到达的 未曾加入过队列(而非未曾访问过的) 的顶点全部加入队列,直到队列为空。
邻接矩阵版
int n,g[maxn][maxn];
bool inq[maxn]={flase};
void bfs(int u)
{
queue<int> q;
q.push(u);
inq[u]=true;
while(!q.empty())
{
int u=q.front();
q.pop();
for(int v=0;v<n;v++)
{
if(inq[v]==false&&g[u][v]!=inf)
{
q.push(v);
inq[v]=true;
}
}
}
}
void bfsTrave()
{
for(int u=0;u<n;u++)
{
if(inq[u]==false)
bfs(u);
}
}
邻接表版
int n;
vector<int> adj[maxn];
bool inq[maxn]={flase};
void bfs(int u)
{
queue<int> q;
q.push(u);
inq[u]=true;
while(!q.empty())
{
int u=q.front();
q.pop();
for(int i=0;i<adj[u].size();i++)
{
int v=adj[u][i];
if(inq[v]==false&&g[u][v]!=inf)
{
q.push(v);
inq[v]=true;
}
}
}
}
void bfsTrave()
{
for(int u=0;u<n;u++)
{
if(inq[u]==false)
bfs(u);
}
}
如果要知道连接块中每个顶点的层号,声明一个带变量layer
的结构体。
10.4 最短路径
对于给定的图G
、起点S
和终点T
,求出S
到T
的最短路径
10.4.1 Dijkstra算法
单源最短路问题:给定图G
和起点s
,得到s
到其他每个顶点的最短距离。非负权。
注意顶点是0 ~ n-1还是1 ~ n。
思路
集合S
存放已被访问过的顶点,执行n
次以下步骤:
- 从集合
V-S
中选择与起点s
距离最短的顶点u
,访问u
并加入集合S
中。 - 令
u
为中介点,优化起点s
到所有u
能到达的顶点v
(未被访问过的)之间的最短距离。
1. 具体实现
- 常量:
const int maxn=最大顶点数量;
const int INF=1e9;
- 图
G
:int G[maxn][maxn]
。初始化为全INF
,在输入边权后得到完整的图。 - 集合
S
:bool vis[maxn]
。初始化为false
,表示未被访问过。 - 起点
s
到其他顶点的最短距离:int d[maxn]
。初始化时,除d[s]=0
外,其余初始化为INF
。
1.1 邻接矩阵实现
顶点数较少(<=500)时,可以直接使用邻接矩阵。
#include <cstdio>
#include <string.h> //memset头文件
#include <algorithm> //fill头文件
using namespace std; //使用algorithm头文件的前提
const int maxn=1000; //最大顶点数
const int INF=1e9; //表示两顶点不可达
int n,g[maxn][maxn];
int d[maxn];
bool vis[maxn]={false}; //初值为false,表示全都未被访问过
void Dijkstra(int s)
{
//ini
fill(d,d+maxn,INF); //非-1或0不要用memset
d[s]=0;
//update n times
for(int i=0;i<n;i++) //循环n次
{
//获得集合V-S中距离集合S最近的点
int u=-1,MIN=INF; //MIN表示起点到V-S中顶点u的最小距离
for(int j=0;j<n;j++)
{
if(vis[j]==false&&d[j]<MIN)
{
u=j;
MIN=d[j];
}
}
//如果没有这样的点,说明此时集合V-S里的点与集合S不可达!
if(u==-1)
return ;
//根据中介点u更新最短路
vis[u]=true; //访问顶点u
for(int v=0;v<n;v++)
{ //v未被访问过,u能到达v,d[v]能被优化
if(vis[v]==false&&g[u][v]!=INF&&d[u]+g[u][v]<d[v])
{
d[v]=d[u]+g[u][v];
}
}
}
}
复杂度:O(V2)。n次循环,每次循环寻找最近的点以及更新邻接点。
1.2 邻接链表实现
#include <cstdio>
#include <string.h> //memset头文件
#include <algorithm> //fill头文件
#include <vector>
using namespace std; //使用algorithm头文件的前提 , stl
const int maxn=1000; //最大顶点数
const int INF=1e9; //表示两顶点不可达
struct node{
int v,dis; //目标顶点,边权
};
vector<node> adj[maxn]; //图,adj[u]存放顶点u的所有邻接点
int n; //顶点数
int d[maxn]; //最短路长
bool vis[maxn]={false};
void Dijsktra(int s)
{
fill(d,d+maxn,INF);
d[s]=0;
for(int i=0;i<n;i++)
{
int u=-1,min=INF;
for(int j=0;j<n;j++)
{
if(vis[j]==false&&d[j]<MIN)
{
u=j;
MIN=d[j];
}
}
//当前集合V-S与集合S不可达!
if(u==-1)
return ;
vis[u]=true;
for(int j=0;j<adj[u].size();j++)
{
int v=adj[u][j].v;
if(vis[v]==false&&d[v]>d[u]+adj[u][j].dis)
{
d[v]=d[u]+adj[u][j].dis;
}
}
}
}
复杂度:O(V2+E) 。n次循环,每次循环寻找最近的点以及更新邻接点。
PS.寻找最小d[u]
的过程可以通过堆优化,从而使复杂度降为O(VlogV+E)。
2. 变种
2.1 无向边
将每条无向边看做两条方向相反的有向边。
2.2 打印最短路径
前驱结点数组pre[]
,pre[v]
表示从起点s
到顶点v
的最短路径上的v
的前一个顶点。初始化时,pre[i]=i
。
for(int v=0;i<n;v++)
{ //v未被访问过,u能到达v,d[v]能被优化
if(vis[v]==false&&G[u][v]!=INF&&d[u]+G[u][v]<d[v])
{
d[v]=d[u]+G[u][v];
pre[v]=u; //记录前驱结点
}
}
//输出最短路径
void printPath(int s,int v)
{
if(v==s) //当前结点是起点时,直接输出,并返回。
{
printf("%d",s);
return ;
}
printPath(s,pre[v]);
printf("%d",v);
}
2.3 最短路径不止一条
第二标尺。增加一个数组来存放新增的边权或点权或最短路径条数。
2.3.1 每条边再加一个权
增加一个数组cost[maxn][maxn]
(边权)和一个数组c[maxn]
。初始化c[s]=0
,其余为INF
或-1
(如果求的是最小边权和,则初始化为INF,反之初始化为0)。
if(vis[v]==false&&g[u][v]!=INF)
{
if(d[v]>d[u]+g[u][v])
{
d[v]=d[u]+g[u][v];
c[v]=c[u]+cost[u][v];
}
else if(d[v]==d[u]+g[u][v]&&c[v]>c[u]+cost[u][v]) //最短路径长度相同
{
c[v]=c[u]+cost[u][v]; //看第二标尺是否更小(或更大)
}
}
2.3.1 每个点增加一个点权
增加一个数组weight[maxn]
(点权)和一个数组w[maxn]
。初始化w[s]=weight[s]
,其余为0
或INF
(如果求的是最小点权和,则初始化为INF,反之初始化为0)。
if(vis[v]==false&&g[u][v]!=INF)
{
if(d[v]<d[u]+g[u][v])
{
d[v]=d[u]+g[u][v];
w[v]=w[u]+weight[v];
}
else if(d[v]==d[u]+g[u][v]&&w[v]<w[u]+weight[v]) //最短路径相同
{
w[v]=w[u]+weight[v]; //看第二标尺是否更优
}
}
2.3.3 问有多少条最短路径
增加一个数组num[maxn]
(最短路径条数)。初始化num[s]=0
(假设一定存在最短路径),其余为0
。
if(vis[v]==false&&g[u][v]!=INF)
{
if(d[v]<d[u]+g[u][v])
{
d[v]=d[u]+g[u][v];
num[v]=num[u]; //继承
}
else if(d[v]==d[u]+g[u][v]) //最短路径相同
{
num[v]+=num[u]; //累积
}
}
3. Dijsktra+DFS
【注】若只有一个第二标尺,则直接使用Dijsktra算法。否则,使用DIJ+DFS。
先在Dijsktra
算法中记录下所有的最短路径(只考虑距离),再从所有的最短路径中选出第二标尺最优的路径。
具体思路
- 使用Dijsktra算法记录所有的最短路径。
将pre
数组设置为vector<int> pre[MAXN]
。对于每个结点,pre[v]
就是一个变长数组vector
,且最多存在maxn
个前驱结点。pre[v]
中存放结点v
的所有能产生最短路径的前驱结点。pre[maxn]
数组不需要赋初值。
if(d[v]>d[u]+g[u][v]) //有更短的最短路径,清空pre[v],再push_back
{
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]中
{
pre[v].push_back(u); //压入
}
- 遍历所有最短路径。找出第二标尺最优的最短路径。
所有的前驱结点形成一棵递归树。对这棵树进行深度遍历,每当到达叶子结点(即起点),就得到了一条最短路径。对于每条最短路径计算第二标尺,得到最优第二标尺的那条最短路径。 - 具体实现
第二标尺最优值optValue
,全局变量。
记录最优路径的数组path
,vector
。
临时记录DFS遍历到叶子结点时的路径tempPath
,vector
。
递归边界:叶子结点,即起点。
计算最短路径的数量:全局变量,递归边界处++。
ATTENTION
tempPath
中的路径是逆序的。- 对于每次递归,先将当前结点
push
到tempPath
中,再在退出前pop
当前结点,实现回溯。统一将递归边界单独push
和pop
。
int optValue=0,cnt=0;
vector<int> pre[MAXN];
vector<int> path,tempPath;
void dfs(int v) //v为当前访问结点
{
if(v==st) //st为起点
{
cnt++; //计算最短路径数量
tempPath.push_back(v); //tempPath中保存了当前正在递归的最短路径
//计算该条最短路径的第二标尺,注意tempPath是倒序的!!!
int value;
if(value 优于 optValue)
{
optValue=value;
path=tempPath;
}
tempPath.pop_back(); //删除刚加入的结点
return ; //递归一定要return啊!
}
tempPath.push_back(v);
for(int i=0;i<pre[v].size();i++)
{
dfs(pre[v][i]);
}
tempPath.pop_back(); //回溯一个结点
}
10.4.2 Bellman-Ford算法和SPFA算法
1. Bellman-Ford算法
可处理带负权边的单源最短路问题。
根据环的边权之和,可以将环分为零环、正环和负环。零环和正环不会影响最短路,但如果是从源点可以到达的负环,就会影响最短路径的求解。
Bellman-Ford算法
- 设置一个数组
d
,存放源点到各个顶点的最短距离。 - 如果存在从源点可达的负环,算法返回
false
;否则返回true
。 - 对图中的边进行
V-1
轮操作,每轮都遍历图中所有的边。对于每条边 (u,v) ,如果以u作为中介点可以使d[v]
更小,就更新d[v]
。 - 算法复杂度为O(VE)。
V-1
轮操作后,如果没有源点可达的负环,那么此时数组d
中的所有值都应当已经达到最优。所以再对所有边进行一轮操作,如果有还能被优化的值,说明图中存在源点可达的负环,返回false
。
【注】由于Bellman-Ford算法要遍历所有的边,所以使用邻接表更好,邻接矩阵会使复杂度增加到O(n3)。
struct Node{
int v,dis;
};
vector<Node> adj[maxn];
int n;
int d[maxn];
bool bellman(int s)
{
fill(d,d+maxn,inf);
d[s]=0;
for(int i=0;i<n-1;i++) //v-1轮操作
{
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[u]+dis<d[v])
{
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[u]+dis<d[v])
return false;
}
}
return true;
}
【注】如果在某一轮操作中,没有任何边被松弛,说明已达到最优,可以直接退出。
统计最短路径条数
由于Bellman-Ford会多次访问曾经访问过的顶点,所以需要设置记录前驱的数组set<int> pre[maxn]
,当遇到一条和已有最短路径长度相同的路径时,必须重新计算最短路径条数。
set<int> pre[maxn];
if(d[u]+dis<d[v])
{
d[v]=d[u]+vis;
num[v]=num[u]; //继承
pre[v].clear();
pre[v].insert(u);
}
else if(d[u]+dis==d[v])
{
pre[v].insert(u);
num[v]=0;
set<int>::iterator it;
for(it=pre.begin();it!=pre.end();it++)
{
num[v]+=num[*it];
}
}
2. SPFA算法
只有当某个顶点u
的d[u]
值改变时,从他出发的边的邻接点v
的d[v]
值才可能被改变。所以
- 建立一个队列
- 每次将队首顶点
u
取出,对u
的所有邻接边进行松弛操作 - 如果可能松弛并且顶点
v
不在队列中,将v
加入到队列 - 直到队列为空,或者某个顶点的入队次数超过V-1(有可达的负环)。
这就是SPFA算法,期望时间复杂度为O(kE),甚至由于Dij。但如果有源点可达的负环,复杂度会退化到O(VE)。
#include <cstdio>
#include <vector>
using namespace std;
vector<Node> adj[maxn];
int n,d[maxn],num[maxn]; //若已知没有负环,可以不要num[]数组
bool inq[maxn];
bool SPFA(int s)
{
//ini
memset(inq,false,sizeof(inq));
memset(num,0,sizeof(num));
fill(d,d+maxn,inf);
//源点入队
queue<int> q;
q.push(s);
inq[s]=true;
num[s]++; //判断有没有负环
d[s]=0;
while(!q.empty())
{
int u=q.front();
q.pop();
inq[u]=false; //出队
//遍历u的所有邻接边
for(int j=0;j<adj[u].size();j++)
{
int v=adj[u][j].v;
int dis=adj[u][j].dis;
//松弛
if(d[u]+dis<d[v])
{
d[v]=d[u]+dis;
if(!inq[v])
{
q.push(v);
inq[v]=true;
num[v]++;
if(num[v]>=n) return false;
}
}
}
}
return true;
}
10.4.3 Floyd算法
全源最短路问题:给定图g(V,E)
,求任意两点u
,v
之间的最短路径长度,时间复杂度为O(n3)。
【注】顶点数<=200时可用。
int n,m;
int dis[maxn][maxn];
void floyd()
{
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];
}
}
}
}
}
10.5 最小生成树
10.5.1 最小生成树及其性质
10.5.2 prim算法
10.6 拓扑排序
10.6.1 有向无环图
如果一个有向图的任一个点都无法通过一些有向边回到自身,则称这个图为有向无环图DAG。
10.6.2 拓扑排序
如果存在边(u,v)
,则拓扑排序中u
一定在v
的前面。
具体实现
- 定义一个队列,把所有入度为0的结点加入到队列。
- 取队首结点,输出。删去所有从他出发的边,并令这些边的另一端的结点入度-1。如果某个顶点的入度减为0,则将其入队。
- 反复执行,直到队列为空。
- 如果输出的结点数恰好为n,则拓扑排序成功,图G为有向无环图DAG,反之,图G中有环。
【注】邻接表实现比较好。
vector<int> g[maxn];
int n,m,inD[maxn];
bool topologicalSort()
{
int num=0;
queue<int> q;
for(int i=0;i<n;i++)
if(inD[i]==0) //入度为0
q.push(i);
while(!q.empty())
{
int u=q.front();
printf("%d",u);
for(int i=0;i<g[u].size();i++)
{
int v=g[u][v];
inD[v]--; //入度--
if(inD[v]==0)
q.push(v);
}
g[u].clear(); //清空顶点u出发的所有边
num++;
}
if(num==n) return true;
return false;
}
【注】如果要求有多个入度为0的顶点时选择序号最小的,把queue
改成priority_queue
,或者set
也可以。