【数据结构与算法基础】最短路径问题

前言

数据结构,一门数据处理的艺术,精巧的结构在一个又一个算法下发挥着他们无与伦比的高效和精密之美,在为信息技术打下坚实地基的同时,也令无数开发者和探索者为之着迷。

也因如此,它作为博主大二上学期最重要的必修课出现了。由于大家对于上学期C++系列博文的支持,我打算将这门课的笔记也写作系列博文,既用于整理、消化,也用于同各位交流、展示数据结构的美。

此系列文章,将会分成两条主线,一条“数据结构基础”,一条“数据结构拓展”。“数据结构基础”主要以记录课上内容为主,“拓展”则是以课上内容为基础的更加高深的数据结构或相关应用知识。

欢迎关注博主,一起交流、学习、进步,往期的文章将会放在文末。


说起图论算法,最经典的莫过于最短路径了。他永远是图论入门难以绕开的经典问题,也常常是学习图论遇到的容易卡住的难点。

说起最短路径,那首先研究的对象就是两点间的路径,它要有起点和终点。图中两个连通结点之间至少存在一条路径,最短路径就是其中权值和最小的路径。

算法中对权值定义可以很广泛,他可以是点权,也可以是边权,也可能多关键字的权重…总之不论权值如何定义,重要的只是权值和能够被表示出来且能够被比较。但在本文中,我们讨论的只有边权,且权值为正数的正权图。

这篇文章,我们探讨的最短路径分为两种:单源最短路径任意两点间最短路径
以及实现他们的两种经典算法:dijkstra算法和floyd算法

单源最短路径

单源最短路径,是指从一点出发到图中其他所有点的最短路径。

通常,只要求出到各点的最短路径长度。
有时,也会要求打印出最短路径。

例如:对于如下图
在这里插入图片描述

从源点1出发到其他节点的最短路径及距离为:

2 : 1 , 3 , 2 3 2:1,3,2\quad3 2:1,3,23
3 : 1 , 3 1 3:1,3\quad1 3:1,31
4 : 1 , 3 , 4 5 4:1,3,4\quad5 4:1,3,45
5 : 1 , 3 , 5 6 5:1,3,5\quad6 5:1,3,56
6 : 1 , 3 , 5 , 6 7 6:1,3,5,6\quad7 6:1,3,5,67

求解单源最短路径,可行的算法有很多,下面来介绍一种非常经典的算法:dijkstra算法

迪杰斯特拉(dijkstra)算法

dijkstra算法的核心思想是将全部结点分成两个集合,一个是已知最短路的集合 A A A,剩下的是未知最短路的集合 B B B,同时记录每个节点到源点的最短距离 d i s dis dis

约定:源点到自身的最短距离为0,即 d i s s = 0 dis_s = 0 diss=0。无路径的两点间的距离为无穷大。

算法步骤如下:

  • 一开始,已知最短路的集合中仅有源点一个元素。且仅记录与源点直接相连的节点的距离 d i s dis dis,其余结点默认为无穷大
  • 每次从 B B B中找出一个距离最短的结点 k k k,加入集合 A A A
  • 使用结点 k k k更新源点到 B B B中结点的最短距离,即是否经过 k k k到该结点比先前到该结点的方案更优
  • 重复上述过程,直至所有结点都加入结合 A A A
  • 最短路径长度就是结点的 d i s dis dis

对于上图,将每一次挑选节点的过程中 d i s dis dis用表格画出来就是:
在这里插入图片描述
在编写算法之前,有必要先来说明一下图的存储。有关图的存储方式,可以参考这篇博文

下面的算法中,存储方式都采用数组模拟的邻接表的方式。最短路径使用数组dis进行存储;
代码定义如下:

struct Edge{//邻接表边结构体
	int vertex;//终点编号
	int len;//边权
	int next;//后继边在数组中下标
};
Edge nxt[M];

int head[N];//链首数组
int dis[N];//最短路长度
int tot = 0;//边计数
bool vis[N];//标记点是否已经计算出最短路径
朴素实现

朴素的算法就是每次使用遍历的方式查询 B B B集合中距离最小的结点

void dijkstra(int start,int n){
	const int inf = 1 <<29;

	//初始化结点信息
	for(int i = 0;i < n;i++){
		dis[i] = inf;
		vis[i] = false;
	}
	//设置起点距离为0
	dis[start] = 0;
	int k;//选中的距离最小结点
	while(true){
	
		k = -1;
		for(int i = 0;i < n;i++){//遍历寻找距离最小结点
			if(vis[i] || dis[i] == inf){//已经在集合中,或者不可到达的元素不考虑
				continue;
			}
			if(k == -1 || dis[i] < dis[i]){
				k = i;
			}
		}

		if(k == -1){//不存在集合外的元素,即所有结点都寻找到了最短路
			break;
		}

		vis[k] = true;//将该节点加入集合
		
		for(int i = head[k];i;i = nxt[i].next){//遍历以该节点为起点的所有边,更新与改点相连结点最短路径
			if(vis[nxt[i].vertex]){//如果该节点已经在集合中,则跳过
				continue;
			}
			if(dis[nxt[i].vertex] > nxt[i].len + dis[k]){//如果从当前结点出发更优则更新答案
				dis[nxt[i].vertex] = nxt[i].len + dis[k];
			}
		}
	}
}

朴素算法的确就是按照算法步骤逐步翻译即可得到。但是缺点也很明显,就是复杂度太高了。

总的来看,外层循环每次会加入一个节点,内层中每次需要遍历所有结点获取最近结点。另外,他还遍历了所有边。

所以如果点的数量为 n n n,边的数量为 m m m,则算法复杂度为: O ( n 2 + m ) O(n^2 +m) O(n2+m)

堆优化实现

使用堆可以优化算法的寻找最短路径最小点的过程。特别的,我们需要定义堆中的结点,它现在不仅是单个的数据,而是一系列数据的复合体,同时这个复合体应该可以进行比较从而构建堆(对于堆得定义和实现还不太理解的同学可以先回顾这一篇):

堆结点定义及堆实现如下:

struct Node{
	int vertex;//到某个结点
	int dis;//到该节点的最短距离
	
	bool operator < (const Node & node){
		return dis < node.dis;
	}
}

Node heap[M];//堆空间
int size = 0;//堆规模

void down(int k){
	int son = k << 1;
	while(son <= size){
		if(son + 1 <= size && heap[son + 1] < heap[son]){
			son++;
		}
		if(heap[k] < heap[son]){
			break;
		}

		Node temp = heap[k];
		heap[k] = heap[son];
		heap[son] = temp;

		k = son;
		son <<= 1;
	}
}

void up(int k){
	int fa = k >> 1;
	while(fa != 0){
		if(heap[fa] < heap[k]){
			break;
		}

		Node temp = heap[fa];
		heap[fa] = heap[k];
		heap[k] = temp;

		k = fa;
		fa >>= 1;
	}
}

void add(Node node){
	heap[++size] = node;
	up(size);
}

void pop(){
	heap[1] = heap[size--];
	down(1);
}

Node peak(){
	return heap[1];
}

有了堆的定义,下面就是如何使用堆来优化算法了:

  • 算法开始时将源点信息初始化并入堆
  1. 每次从堆顶中拿去最短路径最小元素并将其弹出
  2. 如果该节点已经在集合中,则重复第一步
  3. 将节点加入集合
  4. 遍历所有可到达的结点,如果可以使用当前结点更新最短路径,则将更新后的结果加入堆中

从上面的过程中可以看到使用堆优化的算法存在一个小问题,就是一个节点在堆中可能出现多次,每次被结点更新路径都会导致其加入堆中。但这并不会影响算法的正确性,因为最终一定会先拿到结点的最短路径,后续再拿到该节点则令其直接弹出。

void dijkstra(int start,int n){
	const int inf = 1 <<29;

	//初始化结点信息
	for(int i = 0;i < n;i++){
		dis[i] = inf;
		vis[i] = false;
	}
	//设置起点距离为0
	dis[start] = 0;
	add({0,0});//加入源点
	
	Node node;//选中的距离最小结点
	while(size){//循环,堆不为空
		node = peak();//获取当前堆顶元素
		pop();
		if(vis[node.vertex]){//如果该对顶元素已经在集合中
			continue;
		}
		
		vis[k] = true;//将该节点加入集合
		
		for(int i = head[k];i;i = nxt[i].next){//遍历以该节点为起点的所有边,更新与改点相连结点最短路径
			if(vis[nxt[i].vertex]){//如果该节点已经在集合中,则跳过
				continue;
			}
			if(dis[nxt[i].vertex] > nxt[i].len + dis[k]){//如果从当前结点出发更优则更新答案
				dis[nxt[i].vertex] = nxt[i].len + dis[k];
				add({nxt[i].vertex,dis[nxt[i].vertex]});//将被更新结点新状态加入堆中
			}
		}
	}
}

使用堆优化算法虽然在堆得构建上耗费了不少功夫,但是在算法效率的优化上却改进了不少,里层的寻找最短路径点的步骤复杂度变成了 l o g n logn logn所以最终的复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)

使用单调队列优化实现

使用堆很复杂,因为手写堆得实现。那么有没有办法即使用堆得优化,又不用手写堆得代码呢?

答案是有的,使用标准库中封装好的容器priorit_queue即优先队列可以代替上面的堆。

使用方法就是给队列结点定义一个比较函数(重载小于号),引入头文件 q u e u e queue queue,声明一个泛型为Node的优先队列。

#include<queue>

struct Node{
	int vertex;//到某个结点
	int dis;//到该节点的最短距离
	
	bool operator < (const Node & node){
		return dis < node.dis;
	}
}
priority_queue<Node> pq;//声明一个泛型为Node的优先队列

然后直接调用其封装好的方法即可:

void dijkstra(int start,int n){
	const int inf = 1 <<29;

	//初始化结点信息
	for(int i = 0;i < n;i++){
		dis[i] = inf;
		vis[i] = false;
	}
	//设置起点距离为0
	dis[start] = 0;
	pq.push({0,0});
	
	Node node;//选中的距离最小结点
	while(!pq.empty()){//循环,堆不为空
		node = pq.top();//获取当前堆顶元素
		pq.pop();
		if(vis[node.vertex]){//如果该对顶元素已经在集合中
			continue;
		}
		
		vis[k] = true;//将该节点加入集合
		
		for(int i = head[k];i;i = nxt[i].next){//遍历以该节点为起点的所有边,更新与改点相连结点最短路径
			if(vis[nxt[i].vertex]){//如果该节点已经在集合中,则跳过
				continue;
			}
			if(dis[nxt[i].vertex] > nxt[i].len + dis[k]){//如果从当前结点出发更优则更新答案
				dis[nxt[i].vertex] = nxt[i].len + dis[k];
				pq.push({nxt[i].vertex,dis[nxt[i].vertex]});//将被更新结点新状态加入堆中
			}
		}
	}
}

这样一来算法的实现就便捷多了。

但是stl虽好,也不要贪杯哦,尤其是对刚学习数据结构的同学,手写几遍堆绝对是百利而无一害的。


任意两点间最短路径

除了单源的最短路径,有时我们还会关心任意两点间的最短路径。这时源点就从单个确定的点变成了所有的点,要求出所有点到所有点的最短路径。

这使得我们不禁想到刚刚讨论的dijkstra算法可以求出单源最短路径,如果对每个点都使用一遍这个算法,是不是就能够得出任意两点间的最短路径了呢?

答案是肯定的,对每个点都跑一遍dijkstra确实可以得到任意两点间的最短路径。其复杂度为: O ( n 2 l o g n ) O(n^2logn) O(n2logn)

但是问题的关键在于,它挺麻烦的,你看dijkstra算法写起来这么老长。

弗洛伊德(floyd)算法

floyd算法也是求解任意两点间最短路径的算法 ,而他的思想就要简洁优雅的多。

他利用动态规划的思想,每次枚举中间点并使用其更新其他点对之间的距离。

可以证明的是,当遍历所有中间点后,任意两点间的距离就是最短距离。

该算法的复杂度也不难分析:枚举中间节点复杂度为 O ( n ) O(n) O(n),枚举点对复杂度 O ( n 2 ) O(n^2) O(n2),总体算法复杂度为 O ( n 3 ) O(n^3) O(n3)

接着我们来看floyd算法的代码实现,在floyd算法中,常使用邻接矩阵来记录两点间距离:

//邻接矩阵,对角线为0,无连边的点对之间距离为inf
int dis[N][N];

for(int k = 0;k < n;k++){//枚举中间节点
	for(int i = 0;i < n;i++){//枚举点对
		for(int j = 0;j < n;j++){
			dis[i][j] = min(dis[i][j],dis[i][k] + dis[k][j]);//更新点对之间的最短距离
		}
	}
}

跑完这个循环,任意两点间的最短路径就寻找完毕了,比跑n边dijkstra要简洁的多,对吧。不过需要注意的就是该算法只推荐使用在点较少的图中,一般这个规模限制在1000之内,这样邻接矩阵开的下,运行时间也不会超。如果规模进一步变大,那就老老实实用dijkstra吧。


往期博客


参考资料:

  • 《数据结构》(刘大有,杨博等编著)
  • 《算法导论》(托马斯·科尔曼等编著)
  • 《图解数据结构——使用Java》(胡昭民著)
  • OI WiKi
  • 36
    点赞
  • 211
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值