图的定义和相关术语
图由顶点,边组成
有向图
无向图,所有边是双向的
顶点的度是指和该顶点相连的边的条数
对有向图,顶点的出边条数为该顶点的出度,顶点的入边条数称为该顶点的入度
图的存储
邻接矩阵和邻接表
邻接矩阵
设图G(V,E)的顶点标号为0,1,...,N-1
则可令二维数组G[N][N]的量两维分别表示图的顶点标号,
即如G[i][j]为1,表示顶点i和顶点j间有边,如为0,则说明顶点i和顶点j间不存在边
此二维数组G[][]称为邻接矩阵
邻接矩阵在顶点个数较多时,会需要较大内存
邻接表
设图G(V, E)的顶点编号为0,1,...,N-1
每个顶点可能有若干条出边,
如把同一个顶点的所有出边放在一个列表中
则N个顶点有N个列表
这N个列表称为图G的邻接表,即为Adj[N]
Adj[i]存放顶点i的所有出边组成的列表
图的遍历
指对图的所有顶点按一定顺序进行访问
访问方法一般有:深度有限搜索[DFS],广度优先搜索[BFS]
用深度有限搜索遍历图
- 用DFS遍历图
- DFS具体实现
1.连通分量
无向图中,如两个顶点间可相互到达,称为两个顶点连通
如G(V,E)的任意两个顶点都连通,称为图G为连通图
否则,称图G为非连通图,且其中极大连通子图的连通分量
2.强连通分量
有向图中,如两个顶点可各自通过一条有向路径到达另一个顶点
称者两个顶点强连通
如G(V,E)的任意两个顶点都强连通,称图G为强连通图
否则,称图G为非强连通图,且其中的极大强连通子图为强连通分量
把连通分量和强连通分量均称为连通块
采用广度优先搜索法遍历图
- 用BFS遍历图
广度优先搜索以"广度"为关键词
- BFS的具体实现
最短路径
给定G(V,E)
求一条从起点到终点的路径
使路径上经过的所有边的边权之和最小
对任意给出的图G(V,E)
和起点S,终点T
求从S到T的最短路径
解决最短路径问题的常用算法有Dijkstra算法,Bellman-Ford,SPFA,Floyd
Dijkstra
来解决单源最短路径
即给定图G和起点s
通过算法得到S到达其他每个顶点的最短距离
基本思想
对图G(V,E)
设置集合S,存放已被访问的顶点
每次从集合V-S中选择与起点S的最短距离最小的一个顶点[记为u]
访问并加入集合S
令u为中介点
优化起点s与所有从u能到达的顶点v间的最短距离
执行n次[n为顶点个数]
直到集合S已包含所有顶点
Dijkstra算法解决的是单源最短路问题,
即给定图G(V,E)和起点s
求起点s到达其它顶点的最短距离
策略:
设集合S存放已被访问的顶点
执行n次下面步骤
- 每次从集合V-S中选择与起点s的最短距离最小的一个顶点u,访问并加入集合S
- 令u为中介点,检查u直接可达节点是否可优化,若可优化,优化
对有两条及以上可达最短距离的路径,
考察时,常给出第二标尺
第二标尺常见的是以下三种出题方法或其组合
- 给每条边增加一个边权[如发费]
要求在最短路径多条时,要求路径上发费之和最小
- 给每个点一个点权,在最短路径有多条时,要求路径上的点权之和最大
- 直接问有多少条最短路径
Bellman-Ford算法和SPFA算法
Dijkstra适合权重非负图
Bellman-Ford适合权重非负和负两种情况
根据环中边权之和的正负,环分为零环,正环,负环
从源点可达节点集合存在负环时,导致最短路径不存在
执行V-1轮循环
每轮遍历图中所有边
对每条边u-->v
如以u为中介点可使d[v]更小
则更新到v的路径
算法时间复杂度O(VE)
此后,
再对所有边进行一轮操作
判断是否存在可优化路径,如有,说明存在负环,返回false
否则,结束
注意到,只有当某个顶点u的d[u]值改变时,
从它出发的边的邻接点v的d[v]值才可能被改变
进行一个优化
建立一个队列
每次将队首顶点u取出
对从u出发的所有边u-->v进行松弛操作
如可松弛,更新路径,如被更新的顶点v不在队列中,
把v加入队列
直到队列为空[说明图中没有从源点可达的负环]
或某顶点入队次数超过了V-1[说明图中存在从源点可达的负环]
queue<int> Q;
源点s入队
while(队列非空)
{
取出队首元素u
for(u的所有邻接边u->v)
{
if(d[u]+dis<d[v])
{
d[v]=d[u]+dis;
if(v当前不在队列)
{
v入队
if(v入队次数大于n-1)
{
说明有可达负环,return
}
}
}
}
}
优化后的算法称为SPFA
期望时间复杂度为O(kE)
k是一个常数
Floyd算法
解决全源最短路径
对给定的图G(V,E)
求任意两点u,v之间的最短路径长度
时间复杂度是O(n^3)
算法基于这样一个事实:
如存在顶点k
使得以k作为中介时顶点i和顶点j的当前最短距离缩短
则使用顶点k作为顶点i和顶点j的中介点
即当dis[i][k]+dis[k][j]<dis[i][j]时,
令dis[i][j]=dis[i][k]+dis[k][j]
算法流程
枚举顶点k属于[1,n]
以顶点k作为中介点,枚举所有顶点对i和j(i属于[1,n], j属于[1,n])
如果dis[i][k]+dis[k][j]<dis[i][j]成立
赋值dis[i][j]=dis[i][k]+dis[k][j]
最小生成树
最小生成树及其性质
最小生成树是在一个给定的无向图G(V,E)中求一棵树T
使得这棵树拥有图G中的所有顶点
且所有边都是来自图G中的边
且满足整棵树的边权之和最小
最小生成树3个性质:
- 最小生成树是树,其边数等于顶点数减1,且树内一定不会有环
- 对给定的图G(V,E),其最小生成树可不唯一,但边权之和一定是唯一的
- 由于最小生成树是在无向图上生成的,其根结点可是这棵树上的任意一个节点
求解最小生成树有prim算法与kruskal算法
prim算法
基本思想
对图G(V,E)设置集合S
存放已被访问的顶点
每次从集合V-S中选择与集合S的最短距离最小的一个顶点[即为u]访问并加入集合S
令顶点u为中介点
优化所有从u能到达的顶点v与集合S之间的最短距离
执行n次
直到S已包含所有顶点
prim算法解决的是最小生成树问题
即在一个给定的无向图G(V,E)中求一棵生成树
使得这棵树拥有图G中的所有顶点
且所有边都是来自图G中的边
且满足整棵树的边权之和最小
prim算法的具体实现
集合S的实现
顶点V_{i}[0<=i<=n-1]与集合S的最短距离
- 集合S的实现方法和Dijkstra中相同
// G为图,一般设为全局变量
// 数组d为顶点与集合S的最短距离
prim(G, d[])
{
初始化
for(循环n次)
{
u = 使d[u]最小的还未被访问的顶点的标号
记u已被访问
for(从u出发能到达的所有顶点v)
{
if(v未被访问&&以u为中介点使得v与集合S的最短距离d[v]更优)
{
将G[u][v]赋值给v与集合S的最短距离d[v]
}
}
}
}
算法时间复杂度O(V^2)
kruskal算法
kruskal算法采用了边贪心的策略
基本思想
在初始状态时隐去图中的所有边
图中每个顶点都自成一个连通块
之后执行下面步骤
- 对所有边按边权从小到大进行排序
- 按边权从小到大测试所有边
如当前测试边所连接的两个顶点不在同一个连通块中,
则把这条测试边加入当前最小生成树中,否则,将边舍弃
- 执行步骤2,直到最小生成树中的边数等于总顶点数减1或是测试完所有边时结束
结束时,如最小生成树的边数小于总顶点数减1
说明该图不连通
需要判断边的两个端点是否在不同的连通块中
struct edge
{
int u, v;
int cost;
} E[MAXE];
bool cmp(edge a, edge b)
{
return a.cost < b.cost;
}
int kruskal()
{
令最小生成树的边权之和为ans,最小生成树的当前边数Num_Edge
将所有边按边权从小到大排序
for(从小到大枚举所有边)
{
if(当前测试边的两个端点在不同的连通块中)
{
将该测试边加入最小生成树中
ans += 测试边的边权
最小生成树的当前边数Num_Edge加1
当边数Num_Edge等于顶点数减1时结束循环
}
}
return ans;
}
如何判断测试边的两个端点是否在不同的连通块中
如何将测试边加入最小生成树中
如把每个连通块当作一个集合
就可把问题转换为判断两个端点是否在同一个集合中,可用并查集
并查集可通过查询两个节点所在集合根节点是否相同来判断它们是否在同一个结合
只要把测试边的两个端点所在集合合并,就能达到将边加入最小生成树的效果
int father[N];
int findFather(int x)
{
...
}
int kruskal(int n, int m)
{
int ans = 0, Num_Edge = 0;
for(int i = 1; i <= n; i++)
{
father[i] = i;
}
sort(E, E+m, cmp);
for(int i = 0; i < m; i++)
{
int faU = findFather(E[i].u);
int faV = findFather(E[i].v);
if(faU != faV)
{
father[faU] = faV;
ans += E[i].cost;
Num_Edge++;
if(Num_Edge == n - 1)
{
break;
}
}
}
if(Num_Edge != n - 1)
{
return -1;
}
else
{
return ans;
}
}
时间复杂度为O(Elog(E))
适合顶点多,边少情况
边多时,一般用Prim
边少时,一般用kruskal
拓扑排序
有向无环图
如一个有向图的任意顶点无法通过一些有向边回到自身
则称这个有向图为有向无环图
拓扑排序
拓扑排序是将有向无环图G的所有顶点排成一个线性序列
使得对G的任意两个顶点u, v
如存在边u->v
则在序列中u一定在v前面
这个序列又称为拓扑序列
- 1.定义一个队列Q,把所有入度为0的结点加入队列
- 2.取队首结点,输出
删去所有从它出发的边
令这些边到达的顶点的入度减1
如顶点的入度减为0,则将其加入队列
- 反复进行2操作,直到队列为空
如队列为空时,入过队的节点数目恰好为N
说明拓扑排序成功
否则,拓扑排序失败
vector<int> G[MAXV];
int n, m, inDegree[MAXV];
bool topologicalSort()
{
int num = 0;
queue<int> q;
for(int i = 0; i < n; i++)
{
if(inDegree[i] == 0)
{
q.push(i);
}
}
while(!q.empty())
{
int u = q.front();
q.pop();
for(int i = 0; i < G[u].size(); i++)
{
int v = G[u][i];
inDegree[v]--;
if(inDegree[v] == 0)
{
q.push(v);
}
}
G[u].clear();
num++;
}
if(num == n)
{
return true;
}
else
{
return false;
}
}
可用来判断是否有环
关键路径
AOV网和AOE网
AOV网
指用顶点表示活动
用边集表示活动间优先关系的有向图
AOE网
边活动网是指用带权的边集表示活动
用顶点表示事件的有向图
AOE网重点解决两个问题:
- 工程起始到终止需要多少时间
- 哪条路径上的活动是影响整个工程进度的关键
AOE网中的最常路径被称为关键路径
关键路径上的活动称为关键活动
最长路径
对一个没有正环的图[指从源点可达的正环]
如需求最长路径长度
可把所有边边权乘以-1
令其变为相反数
然后用Bellman-Ford算法或SPFA求最短路径长度,将所得结果取反即可
如图中有正环,则最长路径不存在
关键路径
AOE网实际是有向无环图
关键路径是图中的最长路径
求解有向无环图中最长路径的方法
设置数组e和l,其中e[r]和l[r]
分别表示活动a_{r}的最早开始时间和最迟开始时间
求出上述两个数组后,
可通过判断e[r]==l[r]是否成立来确立活动r是否是关键活动
怎样求解数组e和l
事件V_{i}在经过活动a_{r}之后到达事件V_{j}
注意,顶点作为事件,也有拖延可能
ve[i] 事件i的最早发生时间
vl[i] 事件i的最迟发生时间
- 对活动a_{r}来说,只要在事件V_{i}最早发生时马上开始,就可使得活动a_{r}的开始时间最早,故e[r]=ve[i]
- 如l[r]是活动a_{r}最迟发生时间,则l[r]+length[r]就是事件V_{j}的最迟发生时间,故l[r]=vl[j]-length[r]
只要求出ve和vl两个数组,就可通过上面公式得到e和l这两个数组
如有k个事件V_{i1}~V_{ik}通过相应的活动a_{r1}~a_{rk}到达事件V_{j}
活动的边权为length[rl]~length[rk]
设已算好了事件V_{i1}~V_{ik}的最早发生时间ve[i1]~ve[ik]
则事件V_{j}的最早发生时间就是ve[i1]+length[r1]~ve[ik]+length[rk]中的最大值
此处取最大值是因为只有所有能到达V_{j}的活动都完成后,V_{j}才能被"激活"
需要保证访问某节点时,它的前驱结点都已经访问完毕
使用拓扑排序可完成
按拓扑序列计算ve数组时,可保证计算到某个节点时,该节点所有前驱都已经计算完成
在拓扑排序访问到某个节点时,使用ve[i]去更新其所有后继结点的ve值
// 拓扑序列
stack<int> topOrder;
bool topologicalSort()
{
queue<int> q;
for(int i = 0; i < n; i++)
{
if(inDegree[i] == 0)
{
q.push(i);
}
}
while(!q.empty())
{
int u = q.front();
q.pop();
topOrder.push(u);
for(int i = 0; i < G[u].size(); i++)
{
int v = G[u][i].v;
inDegree[v]--;
if(inDegree[v] == 0)
{
q.push(v);
}
// 用ve[u]来更新u的所有后续结点v
if(ve[u] + G[u][i].w > ve[v])
{
ve[v] = ve[u] + G[u][i].w;
}
}
}
if(topOrder.size() == n)
{
return true;
}
else
{
return false;
}
}
同理,从事件V_{i}出发通过相应的活动a_{r1}~a_{rk}可到达的k个事件V_{j1}~V_{jk}
活动的边权为length[rl]~length[rk]
假设已经算好了事件V_{j1}~V_{jk}的最迟发生时间vl[j1]~vl[jk]
则事件V_{i}的最迟发生时间就是vl[j1]-length[rl]~vl[jk]-length[rk]中的最小值
和ve数组类似
如需要计算出vl[i]的正确值
vl[j1]~vl[jk]必须已经得到
这要求与ve数组刚好相反
也即需要在访问某个节点时保证它的后继节点均已经访问完毕
可通过逆拓扑序列来实现
在实现拓扑排序过程中使用栈来存储拓扑序列,按顺序出栈
访问逆拓扑序列中的每个事件V_{i}时
可遍历V_{i}的所有后继节点V_{j1}~V_{jk}
使用vl[j1]~vl[jk]来求出vl[i]
fill(v1, v1+n, ve[n-1])
while(!topOrder.empty())
{
int u = topOrder.top();
topOrder.pop();
for(int i = 0; i < G[u].size(); i++)
{
int v = G[u][i].v;
if(vl[v] - G[u][i].w < vl[u])
{
vl[u] = vl[v] - G[u][i].w;
}
}
}
- 按拓扑序和逆拓扑序分别计算各顶点[事件]的最早发生时间和最迟发生时间
ve[j]=max{ve[i]+length[i-->j]}
vl[j]=min{vl[j]-length[i-->j]}
- 用上面的结果计算各边[活动]的最早开始时间和最迟开始时间
最早:e[i-->j]=ve[i]
最迟:l[i-->j]=vl[j]-length[i-->j]
- e[i-->j]=l[i-->j]的活动即为关键活动
主体部分代码[适用汇点确定且唯一的情况,以n-1号顶点为汇点为例]
// 不是有向无环图返回-1,否则,返回关键路径长度
int criticalpath()
{
memset(ve, 0, sizeof(ve));
if(topologicalSort() == false)
{
return -1;
}
fill(v1, v1+n, ve[n-1]);
while(!topOrder.empty())
{
int u = topOrder.top();
topOrder.pop();
for(int i = 0; i < G[u].size(); i++)
{
int v = G[u][i].v;
if(vl[v] - G[u][i].w < vl[u])
{
vl[u] = vl[v] - G[u][i].w;
}
}
}
for(int u = 0; u < n; u++)
{
for(int i = 0; i < G[u].size(); i++)
{
int v = G[u][i].v, w = G[u][i].w;
int e = ve[u], l = vl[v] - w;
if(e == 1)
{
printf("%d->%d
", u, v);
}
}
}
return ve[n-1];
}
// 如果事先不知道汇点编号
// 如何求关键路径长度
// 即为取ve数组的最大值
// 因为所有事件中ve最大的一定是最后一个[或多个]事件,也即汇点
int maxLength = 0;
for(int i = 0; i < n; i++)
{
if(ve[i] > maxLength)
{
maxLength = ve[i];
}
}
fill(v1, v1+n, maxLength);
如果要完整输出所有关键路径
就需要把关键活动存下来,方法是新建一个邻接表,
当确定边u->v是关键活动时,将边u->v加入邻接表
这样最后生成的邻接表就是所有关键路径合成的图了
可用DFS遍历来获取所有关键路径