图(四):最短路径 (Dijkastra, Bellman-Ford, SPFA, Floyd)

作为数据结构的课程笔记,以便查阅。如有出错的地方,还请多多指正!

Dijkastra

  • Dijkastra 用来解决单源点的最短路径问题:给定带权有向图 G G G 和源点 v v v,求从 v v v G G G 中其余各顶点的最短路径

算法描述

贪心算法:按路径长度非递减次序产生最短路径

  • 把顶点的集合 V V V 分成两组:
    • S S S:{已求出最短路径的顶点}
    • T = V − S T=V-S T=VS:{尚未确定最短路径的顶点}
  • 每次从 T T T 中找出距离值最小的顶点加入到 S S S 中,直到 S = V S=V S=V 为止
  • 保证:
    • 每一个顶点对应一个距离值
      • S S S 中顶点:从 V 0 V_0 V0 到此顶点的最短路径长度
      • T T T 中顶点:从 V 0 V_0 V0 到此顶点的只包括 S S S 中顶点作中间顶点的最短路径长度
    • S S S 中各顶点的距离值 ≤ ≤ T T T 中各顶点的距离值

步骤

  • 初始时,令 S = V 0 S={V_0} S=V0, T = 其余顶点 T={其余顶点} T=其余顶点 T T T 中距离值为 V 0 V_0 V0 V i V_i Vi 对应的距离值,即,若存在 < V 0 , V i > <V_0,V_i> <V0,Vi>,为 < V 0 , V i > <V_0,V_i> <V0,Vi> 弧上的权值;若不存在 < V 0 , V i > <V_0,V_i> <V0,Vi>,为 ∞ \infty
  • T T T 中选取一个其距离值为最小的顶点 W W W,加入 S S S。然后对 T T T 中其余顶点的距离值进行修改:若加进 W W W 作中间顶点,从 V 0 V_0 V0 V i V_i Vi 的距离值比不加 W W W 的路径要短,则修改此距离值;这样就保证了 T T T 中顶点保存的距离一定是从源点只经过 S S S 中各顶点的最短路径的距离
  • 重复上述步骤,直到 S = V S=V S=V 为止
    在这里插入图片描述

  • Dijkstra 算法可以很好地解决无负权图的最短路径问题, 但如果出现了负权边, Dijkstra 算法就会失效
    • 例如图 10-39 中设置 A A A 为源点时, 首先会将点 B B B 和点 C C C d i s t dist dist 值变为 − 1 -1 1 1 1 1, 接着由于点 B B B d i s t dist dist 值最小, 因此用点 B B B 去更新其未访问的邻接点。在这之后点 B B B 标记为己访问, 于是将无法被从点 C C C 出发的边 C B CB CB 更新, 因此最后 d i s t [ B ] dist[B] dist[B] − 1 -1 1 而非最短路径长度 − 4 -4 4
      在这里插入图片描述

算法有效性证明

贪心算法的有效性证明:最优子结构性质 + 贪心选择性质

  • 最优子结构性质:由 Dijkastra 算法步骤易证,每次更新得到的 T T T 中各顶点对应的距离一定为从源点出发,只经过 S S S 中各顶点得到的最短路径的距离
  • 贪心选择性质:设 d i s t [ i ] dist[i] dist[i] 为从源点到 i i i 只经过 S S S 中顶点的最短路径长度。假设我们在这一步中选择的最短路径点为 u u u。如下图所示,如果存在一条从源到 u u u 的路且该路径中存在不属于 S S S 的顶点,则设这条路初次走出 S S S 之外到达的顶点为 x ∈ V − S x\in V-S xVS,然后徘徊于 S S S 内外若干次,最后离开 S S S 到达 u u u
    ∵ d ( v , x ) + d ( x , u ) = d ( v , u ) ≤ d i s t [ u ] , d ( x , u ) ≥ 0 ∴ d i s t [ x ] ≤ d ( v , x ) ≤ d i s t [ u ] \because d(v,x)+d(x,u)=d(v,u)\leq dist[u],d(x,u)\geq0\\ \therefore dist[x]\leq d(v,x)\leq dist[u] d(v,x)+d(x,u)=d(v,u)dist[u],d(x,u)0dist[x]d(v,x)dist[u]与贪心选择矛盾,因此 d i s t [ u ] dist[u] dist[u] 必为 v v v u u u 的最短路径
    在这里插入图片描述

算法实现

  • d[ ]:一维数组,用于描述路径长度
    • d[v]存放当前找到的从源点 V 0 V_0 V0 v v v 的最短路径长度,其初始值为 V 0 V_0 V0 到顶点 v v v 的弧的权值
  • pre[ ]:描述最短路径
    • pre[v] 中存 V 0 → v V_0 \rightarrow v V0v 的最短路径中与 v v v 相邻的点的索引,不存在路径则用 − 1 -1 1 表示。最后输出路径时需要用到堆栈 / 递归
  • vis[ ]:描述顶点是否已求得最短路径
    • vis[v] 为 1,表示 v ∈ S v\in S vS;为 0,表示 v ∈ T v\in T vT

邻接矩阵

const int MAXV = 1000; 		// 最大顶点数
const int INF = 0x3fffffff;	// 这里为了防止溢出, 不使用 0x7fffffff
int n, G[MAXV][MAXV]; 		// n 为顶点数
int d[MAXV]; 				// 起点到达各点的最短路径长度
int pre[MAXV]; 				// pre[v] 表示从起点到顶点 v 的最短路径上 v 的前一个顶点
bool vis[MAXV] = {false}; 	// 标记数组, vis[i]==true 表示已访问

void Dijkstra(int s) { // s 为起点
	fill(d, d + MAXV, INF); 	// fill 函数将整个 d 数组赋为 INF
	for(int i = 0; i < n; i++)
		pre[i] = i; 			// 初始状态设每个点的前驱为自身
	d[s] = 0; 					// 起点 s 到达自身的距离为 0
	
	for(int i = 0; i < n; i++) {
		int u = -1, MIN = INF; 	// u 使 d[u] 最小, MIN 存放该最小的 d[u]
		for(int j = 0; j < n; j++){ // 找到未访问的顶点中 d[j] 最小的
			if (vis[j] == false && d[j] < MIN) {
				u = j;
				MIN = d[j];
			}
		}
		// 找不到小于 INF 的 d[u], 说明剩下的顶点和起点 s 不连通
		if (u == -1) 
			return;
		vis[u] = true; // 标记 u 为己访问
		for(int v = 0; v < n; 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; 		// 记录 v 的前驱顶点是 u
			}
		}
	}
}

T ( n ) = O ( n 2 ) T(n)=O(n^2) T(n)=O(n2)

居然达到了邻接矩阵的最优时间复杂度!

邻接表

struct Node {
	int v, dis; // v 为边的目标顶点, dis 为边权
};
vector<Node> Adj[MAXV];
int n; 			// n 为顶点数
int d[MAXV];
int pre[MAXV];
bool vis[MAXV] = {false};

void Dijkstra(int s) {
	fill(d, d + MAXV, INF);
	for(int i = 0; i < n; i++)
		pre[i] = i;
	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];
			}
		}
		if(u == -1) 
			return;
		vis[u] = true;
		// 只有下面这个 for 与邻接矩阵的写法不同
		for (int j = 0; j < Adj[u].size(); j++) {
			int v = Adj[u][j].v;
			if(vis[v] == false && d[u] + Adj[u][j].dis < d[v]){
				d[v] = d[u] + Adj[u][j].dis;
				pre[v] = u;
			}
		}
	}
}

  • 上面代码中每次找最小距离的操作还是用了遍历整个数组的方法,但是如果采用最小堆,最终的时间复杂度为:
    T ( n ) = O ( e l o g n ) T(n)=O(elogn) T(n)=O(elogn)

Bellman-Ford (BF)

参考《算法笔记》

  • BF 算法可解决单源最短路径问题,也能处理有负权边的情况 (不能处理负值圈)

算法描述及有效性证明

for (i = 0; i < n - 1; i++) { 	// 执行 n - 1 轮操作,其中 n 为顶点数
	for (each edge u->v) { 		// 每轮操作都遍历所有边
		if (d[u] + length[u->v] < d[v]) { // 以 u 为中介点可以使 d[v] 更小
			d[v] = d[u] + length[u->v]; 	// 松弛操作
		}
	}
}

证明

  • 如果最短路径存在,那么最短路径上的顶点个数肯定不会超过 V V V 个。于是,如果把源点 s s s 作为一棵树的根结点,把其他结点按照最短路径的结点顺序连接,就会生成一棵最短路径树
    在这里插入图片描述
  • 由于初始状态下 d [ s ] d[s] d[s] 为 0, 因此在接下来的步骤中 d [ s ] d[s] d[s] 不会被改变(也就是说,最短路径树中第一层结点的 d d d 值被确定)。接着,通过 Bellman-Ford 算法的第一轮操作之后,最短路径树中的第二层顶点的 d d d 值也会被确定下来;然后进行第二轮操作, 于是第三层顶点的 d d d 值也被确定下来。这样计算直到最后一层顶点的 d d d 值确定。由于最短路径树的层数不超过 V V V 层,因此 Bellman-Ford 算法的松弛操作不会超过 V − 1 V-1 V1 轮。证毕

  • 此时, 如果图中没有从源点可达的负环 (负值圈), 那么数组 d d d 中的所有值都应当已经达到最优。因此,如下面的伪代码所示, 只需要再对所有边进行一轮操作, 判断是否有某条边 u → v u\rightarrow v uv 仍然满足 d [ u ] + l e n g t h [ u → v ] < d [ v ] d[u]+ length[u\rightarrow v] < d[v] d[u]+length[uv]<d[v], 如果有, 则说明图中有从源点可达的负环, 返回 false; 否则,说明数组 d d d 中的所有值都已经达到最优, 返回 true
for (each edge u->v) { // 对每条边进行判断
	if(d[u] + length[u->v] < d[v]) { // 如果仍可以被松弛
		return false; 		// 说明图中有从源点可达的负环
	}
	return true; 			// 数组 d 的所有值都已经达到最优
}

时间复杂度

  • 可以看出,如果使用邻接表,则 Bellman-Ford 算法的时间复杂度是 O ( V E ) O(VE) O(VE), 其中 V V V 是顶点个数, E E E 是边数;如果是用邻接矩阵,那时间复杂度就会上升到 O ( V 3 ) O(V^3) O(V3)
    • 因此,一般都使用邻接表

算法实现

struct Node {
	int v, dis; 			// v 为邻接边的目标顶点,dis 为邻接边的边权
};
vector<Node> Adj[MAXV]; 	// 邻接表
int n; 						// n 为顶点数
int d[MAXV]; 				// 起点到达各点的最短路径长度

bool Bellman(int s) { 		// s 为源点
	fill(d, d + MAXV, INF); // fill 函数将整个 d 数组赋为 INF
	d[s] = 0; 				// 起点 s 到达自身的距离为 0
	int slacken_flag = false;
	// 以下为求解数组 d 的部分
	for (int i = 0; i < n - 1; i++) { 	// 执行 n - 1 轮操作,n 为顶点数
		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]} { 	// 以 u 为中介点可以使 d[v] 更小
					d[v] = d[u] + dis; 		// 松弛操作
					slacken_flag = true;
				}
			}
		}
		// 如果在某一轮操作时, 发现所有边都没有被松弛, 
		// 那么说明数组 d 中的所有值都已经达到最优,
		// 不需要再继续, 提前退出即可
		if(!slacken_flag)
		{
			break;
		}
		slacken_flag = false;
	}
	// 以下为判断负环的代码
	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; 	// 数组 d 的所有值都已经达到最优
}

SPFA (Shortest Path Faster Algorithm)

参考《算法笔记》

算法描述及有效性证明

  • 虽然 Bellman-Ford 算法的思路很简洁,但是 O ( V E ) O(VE) O(VE) 的时间复杂度确实很高,在很多情况下并不尽如人意。仔细思考后会发现,Bellman-Ford 算法的每轮操作都需要操作所有边, 显然这其中会有大量无意义的操作,严重影响了算法的性能。于是注意到,只有当某个顶点 u u u d [ u ] d[u] d[u] 值改变时, 从它出发的边的邻接点 v v v d [ v ] d[v] d[v] 值才有可能被改变。由此可以进行一个优化:
    • 建立一个队列,将源点入队
    • 每次将队首顶点 u u u 取出,然后对从 u u u 出发的所有边 u → v u\rightarrow v uv 判断 d [ u ] + l e n g t h [ u → v ] < d [ v ] d[u] + length[u\rightarrow v] < d[v] d[u]+length[uv]<d[v] 是否成立,如果成立,则进行松弛操作,于是 d [ v ] d[v] d[v] 获得更优的值,此时如果 v v v 不在队列中,就把 v v v 加入队列
    • 这样操作直到队列为空(说明图中没有从源点可达的负环),或是某个顶点的入队次数超过 V − 1 V-1 V1 (说明图中存在从源点可达的负环)

伪代码

queue<int> Q;
Q.push(s);			// 源点 s 入队
while (!Q.empty()) {
	取出队首元素 u;
	for(u 的所有邻接边 u->v) {
		if(d[u] + dis < d[v]) {
			d[v] = d[u] + dis;
			if(v 当前不在队列) {
				Q.push(v);
				if(v 入队次数 > n - 1) {
					// 有可达负环
					return;
				}
			}
		}
	}
}

时间复杂度

  • 它的期望时间复杂度 O ( k E ) O(kE) O(kE), 其中 E E E 是图的边数, k k k 是一个常数, 在很多情况下 k k k 不超过 2, 可见这个算法在大部分数据时异常高效, 并且经常性地优于堆优化的 Dijkstra 算法
    • 但如果图中有从源点可达的负环,传统 SPFA 的时间复杂度就会退化成 O ( V E ) O(VE) O(VE)

判断负环

  • 注意:使用 SPFA 可以判断是否存在从源点可达的负环
  • 如果负环从源点不可达, 则需要添加一个辅助顶点 C C C, 并添加一条从源点到达 C C C 的有向边以及 V − 1 V-1 V1 条从 C C C 到达除源点外各顶点的有向边才能判断负环是否存在

算法实现

vector<Node> Adj[MAXV] ; 		// 邻接表
int n, d[MAXV], num[MAXV]; 		// num 数组记录顶点的入队次数
bool inq[MAXV]; 				// 顶点是否在队列中

bool SPFA(int s) {
	// 初始化部分
	memset(inq, false, sizeof(inq));
	memset(num, 0, sizeof(num));
	fill(d, d + MAXV, INF);
	
	// 源点入队部分
	queue<int> Q;
	Q.push(s); 			// 源点入队
	inq[s] = true; 
	num[s]++;
	d[s] = 0; 			// 源点的 d 值为 0
	
	// 主体部分
	while (!Q.empty()) {
		int u = Q.front();
		Q.pop();
		inq[u] = false; 	// 设置 u 为不在队列中
		// 遍历 u 的所有邻接边 v
		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]) { // 如果 v 不在队列中
					Q.push(v);
					inq[v] = true;
					num[v]++;
					if (num[v] >= n) 
						return false; // 有可达负环
				}
			}
		}
	}
	return true; //无可达负环
}
  • SPFA 十分灵活,其内部的写法可以根据具体场景的不同进行调整。例如上面代码中的 FIFO 队列可以替换成优先队列, 以加快速度;或者替换成双端队列 (deque), 使用 SLF 优化和 LLL 优化 ,以使效率提高至少50%。除此之外,上面给出的代码是 SPFA 的 BFS 版本 ,如果将队列替换成栈,则可以实现 DFS 版本的 SPFA, 对判环有奇效

Floyd

  • Floyd 可以用来解决全源最短路问题,即求每一对顶点之间的最短距离 (尽管可以重复执行 Dijkastra 算法 n n n 次,但是 Floyd 算法要更加简洁)
    • 采用邻接矩阵, T ( n ) = O ( n 3 ) T(n)=O(n^3) T(n)=O(n3)
  • Floyd 能处理负值边,但不能处理负值圈

算法描述

  • 算法思想:逐个顶点试探法

步骤

  • 初始时设置一个 n n n 阶方阵,令其对角线元素为 0,若存在弧 < V i , V j > <V_i,V_j> <Vi,Vj>,则对应元素为权值;否则为 ∞ \infty (邻接矩阵)
  • 逐步试着在原直接路径中增加一个中间顶点,若加入中间点后路径变短,则修改之;否则,维持原值
  • 所有顶点试探完毕,算法结束
    在这里插入图片描述在这里插入图片描述

p a t h [ i ] [ j ] path[i][j] path[i][j]:最短路径 i → j i\rightarrow j ij 上顶点 j j j 的前一个顶点的索引

算法有效性证明

  • D [ k ] [ i ] [ j ] D^{[k]}[i][j] D[k][i][j]:从 i i i j j j,不经过索引比 k k k 大的顶点的最短路径; 则 D [ k + 1 ] [ i ] [ j ] D^{[k+1]}[i][j] D[k+1][i][j] 取下面两个值的最小值即可
    • 经过点 k + 1 k+1 k+1 D [ k ] [ i ] [ k + 1 ] + D [ k ] [ k + 1 ] [ j ] D^{[k]}[i][k+1] +D^{[k]}[k+1][j] D[k][i][k+1]+D[k][k+1][j]
    • 不经过点 k + 1 k+1 k+1 D [ k ] [ i ] [ j ] D^{[k]}[i][j] D[k][i][j]

算法实现

Status_e Floyd(pAdjacentMatrix_t pgraph, AdjacentMatrixWeightType_t distance[MAX_VERTEX_NUM][MAX_VERTEX_NUM], int path[MAX_VERTEX_NUM][MAX_VERTEX_NUM])
{
	// init
	for (int i = 0; i < pgraph->vexNum; ++i)
	{
		for (int j = 0; j < pgraph->vexNum; ++j)
		{
			distance[i][j] = pgraph->arc[i][j];
			path[i][j] = (distance[i][j] < INFINITY && i != j) ? i : -1;
		}
	}

	// 加入 k 作为中间顶点进行试探;路径: i->j
	for (int k = 0; k < pgraph->vexNum; ++k)
	{
		for (int i = 0; i < pgraph->vexNum; ++i)
		{
			for (int j = 0; j < pgraph->vexNum; ++j)
			{
				if (distance[i][k] + distance[k][j] < distance[i][j])
				{
					distance[i][j] = distance[i][k] + distance[k][j];
					if (i == j && distance[i][j] < 0)
					{
						printf("Err! \r\n");//负值圈
						return err;
					}
					path[i][j] = path[k][j]; // 注意这里更新 path 的方式
				}
			}
		}
	}

	return ok;
}
  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值