浅谈 最短路

最短路问题(short-path problem):最短路问题是图论研究中的一个经典算法问题,指在寻找图(由结点和路径组成的)中两结点之间的最短路径。算法具体的形式包括:

1.确定起点的最短路径问题 - 即已知起始结点,求最短路径的问题。

2.确定终点的最短路径问题 - 与确定起点的问题相反,该问题是已知终结结点,求最短路径的问题。在无向图中该问题与确定起点的问题完全等同,在有向图中该问题
等同于把所有路径方向反转的确定起点的问题。

3.确定起点终点的最短路径问题 - 即已知起点和终点,求两结点之间的最短路径。

4.全局最短路径问题 - 求图中所有的最短路径。


Floyd

part 1 算法本身

是一种基于动态规划的多源点最短路算法

dis[k][i][j]表示,从i到j一定经过<=k的点的最短路径

显然dis[0][i][j] = cost[i][j]

那么状态转移方程是?

其实也很容易,从k-1转移到k无非就是两种状态

1.不经过k点:dis[k - 1][i][j]

2.经过k点: dis[k - 1][i][k] + dis[k - 1][k][j]

好像区间dp哦

这个实现很简单吧(记得枚举k的循环放最外面

for(int k = 1; k <= n; k++)
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= n; j++) 
            dis[k][i][j] = min(dis[k - 1][i][j], dis[k - 1][i][k] + dis[k - 1][k][j]);

然后因为它是按阶段进行的,一定是上一个的值更新这一个,所以……第一维可以省略!然后我们就得到了最经典的Floyd算法

for(int k = 1; k <= n; k++)
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= n; j++) 
            dis[i][j] = min(dis[i][j], dis[i][k] + dis[k][j]);

Floyd算法输出路径和大多数算法一样采用记录前驱点的方式。我们定义:

pre[i][j]记录从i到j的最短路径中,j的前驱点。

递归还原路径即可。

模板代码
#include <cstdio> 
#include <cmath>
#include <cstring>
#include <algorithm>
using namespace std;

const int MAXN = 505;
int dis[MAXN][MAXN];
// 表示i到j的最短路
int pre[MAXN][MAXN];
// 表示i到j的最短路的j的前驱点
int s, t;
// 起点s,终点t

void floyd(int n) { // floyd算法
	for(int k = 1; k <= n; k++)
		for(int i = 1; i <= n; i++)
			for(int j = 1; j <= n; j++) {
				if(dis[i][j] > dis[i][k] + dis[k][j]) {
					dis[i][j] = dis[i][k] + dis[k][j];
					pre[i][j] = pre[k][j]; 
                    // 因为我们枚举的k是i到j的最短路的中转点集
                    // 所以i到j的最短路的j的前驱点其实就是k到j的最短路的j的前驱点
				}
			}
}

void print(int x) {
	if(pre[s][x] == 0) return ;
    // 边界条件:代表这已经是第一个点了(即它没有前驱点
	print(pre[s][x]);
    // 继续下一层
	printf(" %d", x);
}

int main() {
	memset(dis, 0x3f, sizeof dis); // 初始化,注意是0x3f,而不是0x3f3f3f3f
	int n, m;
	scanf ("%d %d", &n, &m);
    // 有n个店,m条边
	for(int i = 1; i <= m; i++) {
		int u, v, cost;
		scanf ("%d %d %d", &u, &v, &cost);
		dis[u][v] = cost;
        // 这里是一个有向图,所以单向存边
		pre[u][v] = u;
        // u到v的最短路目前一定是dis[u][v]
        // 所以可以直接初始化其前驱点为u,如果出现dis[u][k] + dis[k][v] < dis[u][v]的情况,将会在Floyd里处理
	}
	for(int i = 1; i <= n; i++) {
		dis[i][i] = 0;
        // 每个点到自己的距离为0
	}
	floyd(n);
	scanf ("%d %d", &s, &t);
	if(dis[s][t] == 0x3f3f3f3f) printf("no solution");
    // 如果s,t之间没有路径,输出“no solution”
	else {
    // 否则输出路径
		printf("%d\n", dis[s][t]);
		printf("%d ", s); 
        // 单独输出起点,因为起点是没有在输出函数里面输出的
		print(t);
	}
	return 0; 
} 

Dijkstra

在步入正题之前,我先介绍一种记忆这个算法名字的好方法------“第几块石头人a(dijkstra)”

这种算法,主要思想就是把点划分为两个集合,已确定最短路的(红点集)以及未确定最短路的(蓝点集)。在一开始,我们先将起点加入红点集(因为起点的最短路就为0。

然后。我们枚举所有蓝点集里的点,找出其中当前到起点距离最短的点,这个点到起点的最短距离就是现在的这个距离,所以加入红点集

可是为什么呢?严格证明

我们假设找到的这个点是a,且它到起点的距离为dis[st][a](st为起点
那么dis[st][a]我们也可以看作 dis[st][k] + dis[k][a](k为红点
我们在另找一蓝点b,且b与a联通,则dis[st][b] = dis[st][k] + dis[k][b]
显然,在b出现后,dis[st][a]也可以看作 dis[st][b] + dis[b][a] 即 dis[st][k] + dis[k][b] + dis[b][a]
又更具a的定义,所以dis[st][a] < dis[st][b],故dis[st][k] + dis[k][a] < dis[st][k] + dis[k][b] + dis[b][a]
又因为dis[st][k]就是起点到k的最短路,所以dis[st][k] + dis[k][a],即dis[st][a]一定是起点到a的最短路!

再加入了红点集后,我们还需要一个操作,更改目前蓝点集里的点的当前最短距离(即松弛操作

		for(int j = 1; j <= n; j++) { 
			if(dis[k] + cost[k][j] < dis[j]) 
				dis[j] = dis[k] + cost[k][j];
		}

就是上面这个东西,如果发现经过某个中转点再到终点的权值比当前直接到终点的距离小,那就可以进行更新

而Dijkstra的输出路径,也和Floyd相似,记录前驱即可

模板代码
#include <cstdio>
#include <algorithm>
#include <cstring>
using namespace std;

const int INF = 0x3F3F3F3F;
// 最大值
const int MAXN = 3005;
int dis[MAXN], cost[MAXN][MAXN], pre[MAXN];
// dis表示最短路
// cost表示边的信息(邻接表
bool vis[MAXN];
// 判断当前点属于哪个集合
int n, m, st, ed;
// n为点数,m为边数

void Di(int s) { //图论 Dijkstra 算法 
	memset(dis, 0x3F, sizeof dis); 
	memset(vis, 0, sizeof vis);
	memset(pre, -1, sizeof pre);
	dis[s] = 0;
	pre[s] = 0;
	for(int i = 1; i <= n; i++) {
		int mi = INF, k;
		for(int j = 1; j <= n; j++) { //蓝点集 路程最小值 
			if(!vis[j] && dis[j] < mi) {
				// 找到当前点到起点距离最短的点
				mi = dis[j];
				k = j;
				// 标记
			}
		}
		vis[k] = true; //加入红点集 
		for(int j = 1; j <= n; j++) { // 更新相连的 
			if(dis[k] + cost[k][j] < dis[j]) {
				// 进行松弛
				dis[j] = dis[k] + cost[k][j];
				pre[j] = k;
				// 把j的目前前驱点置为k
			}
		}
	}
}

void Print_Set(int x) { //输出 
	if(x == 0)
		return;
	// 到起点了,即边界
	Print_Set(pre[x]);
	// 递归输出
	printf("%d ", x);	
}

void Work_Set() { //输入及调用 
	scanf ("%d %d %d %d", &n, &m, &st, &ed);
	for(int i = 1; i <= m; i++) { //输入 
		int u, v, x;
		scanf ("%d %d %d", &u, &v, &x);
		cost[u][v] = x;
		cost[v][u] = x;
	} 
	Di(st); //调用 
	printf("%d\n", dis[ed]);
	Print_Set(t);
}

int main() {
	Work_Set();
	return 0;
}

Dijkstra其实还有两个优化,第一个就是……

for(int j = 1; j <= n; j++) { 
	if(dis[k] + cost[k][j] < dis[j]) {
		dis[j] = dis[k] + cost[k][j];
		// 在这里,你会发现j是从1到n
		// 但k不是到每个点都有边相连,所以考虑使用邻接表
	}
}

第二个……

for(int j = 1; j <= n; j++) { 
	// 这个循环就是求一个最小值嘛
	// 考虑使用优先队列
	if(!vis[j] && dis[j] < mi) {
		mi = dis[j];
		k = j;
	}
}
完整代码
#include <cstdio>
#include <cstring>
#include <queue>
#include <vector>
using namespace std;

const int MAXN = 2005;
bool vis[MAXN]; // 记录是否访问过 
int n, m, dis[MAXN];
// 有n个点,m条边,dis[i]表示起点到i最短路 
struct edge { // 边的信息 
	int v, w; // 边的终点与权值 
	edge(){};
	edge(int v_, int w_) { // 生成边函数 
		v = v_;
		w = w_;
	}
};
struct node { // 优先队列里的边信息 
	int u, dis_;
	// 边的起点,u到总起点的最短路 
	node() {}
	node(int U, int D) {
		u = U;
		dis_ = D;
	}
	friend bool operator < (node a, node b) { return a.dis_ > b.dis_;}
	// 重载运算符,顶部最小 
};
priority_queue<node> q; 
vector<edge> cost[MAXN]; // 邻接表 

void Addedge(int su, int sv, int scost) { // 存边函数 
	cost[su].push_back(edge(sv, scost));
	cost[sv].push_back(edge(su, scost));
	// 无向图双向存边 
}

void Dijkstra(int s) {
	memset(vis, 0, sizeof vis);
	memset(dis, 0x3f, sizeof dis);
	dis[s] = 0;
	q.push(node(s, 0));
	// 起点进队,且起点到起点的最短路为0
	while(!q.empty()) {
		int t = q.top().u; 
		// 取出第一个(目前到起点的最短路最小的 
		q.pop();
		if(vis[t]) continue; // (1) 
		vis[t] = true; // 加入红点集 
		for(int i = 0; i < cost[t].size(); i++) {
			// 枚举边 
			int v = cost[t][i].v;
			if(dis[v] > dis[t] + cost[t][i].w) {
				dis[v] = dis[t] + cost[t][i].w;
				q.push(node(v, dis[v]));
				// 因为这里可能会重复进队
				// 所以我们在前面判断了当前点是否被查询过
				// 即(1) 
			}
		} 
	}
}

int main() {
	int n, m;
	scanf ("%d %d", &n, &m);
	for(int i = 1; i <= m; i++) {
		int U, V, Cost; // 边的信息 
		scanf ("%d %d %d", &U, &V, &Cost);
		Addedge(U, V, Cost); // 存边 
	}
	int st = 1, ed = n; 
	// 假设起点为1,终点为n 
	// 有时的起点终点是要输入的 
	Dijkstra(st); 
	if(dis[ed] == 0x3f3f3f3f) printf("-1");
	else printf("%d", dis[ed]);
	return 0;
}

最终优化我改了1个半小时啊啊啊啊啊啊

Bellman-Ford

其实就是把每条边都反复进行松弛操作嘛……(因为时间会产生浪费,所以不常用

但这个算法可以判断是否存在负环!

因为跑完一遍后,按理说每个边都被松弛到了极点了,如果此时还有边可以松弛,那一定存在负环咯。。

具体代码
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

const int MAXN = 10005;
struct edge { // 存边
	int u, v, w;
	// 边的起点u,边的终点v,以及边权w
} s[MAXN];
int dist[MAXN], pre[MAXN];
// 最短路与路径
int n, m, s_, t_;
// n个点,m条边,s_为起点,t_为终点

void print(int x) {
	// 递归输出函数,详见Dijkstra
	if(pre[x] == 0) return ;
	print(pre[x]);
	printf("%d ", pre[x]);
}

void relax(int u, int v, int w) {
	// 松弛操作
	if(dist[v] > dist[u] + w) {
		// 如果可以松弛就松弛
		dist[v] = dist[u] + w;	
		pre[v] = u;	
		// 因为松弛相当于是更新了目前最短路,所以我们也可以在这里记录路径
	}
}

bool bellman_ford() { // 算法
// 因为我们要判断是否有负环,所以可以把它定义为有返回值的bool
	for(int i = 1; i <= n - 1; i++) 
	// 每条边最多被松弛n-1次
		for(int j = 1; j <= m; j++) 
		// 枚举每一条边
			relax(s[j].u, s[j].v, s[j].w);	// 松弛操作		
	bool flag = true;
	// flag保存当前是否有负环
	for(int i = 1; i <= m; i++) 
		if(dist[s[i].v] > dist[s[i].u] + s[i].w) {
			// 如果跑完算法,还可以松弛?
			// 一定有负环呐!
			flag = false; // 更改flag的值
			break;
		}
	return flag; // 返回
}

int main() {
	scanf ("%d %d", &n, &m);
	for(int i = 1; i <= m; i++) 
		scanf ("%d %d %d", &s[i].u, &s[i].v, &s[i].w);
	scanf ("%d %d", &s_, &t_);
	memset(dist, 0x3f, sizeof dist) // 初始化为最大
	dist[s_] = 0; // 到起点的最小值为0
	for(int i = 1; i <= m; i++) 
		if(s[i].u == s_) dist[s[i].v] = s[i].w; // 如果枚举到i与起点相邻?可以直接确定最短路
	bool flag = bellman_ford();
	if(flag == false) printf("No Solution");
	// 判负环
	else {
		// 如果没有负环,输出路径
		printf("%d\n", dist[t_]);
		printf("%d ", s_);
		print(t_);
		printf("%d", t_);
	}
	return 0;
} 

SPFA

(Shortest Path Faster Algorithm)

时间复杂度:稀疏图上O(kN),稠密图上退化到O(N^2)

在Bellmanford算法中,有许多松弛是无效的。这给了我们很大的改进的空间。
SPFA算法正是对Bellmanford算法的改进。它是由西南交通大学段丁凡1994提出的。它采用了队列和松弛技术。先将源点加入队列。

然后从队列中取出一个点(此时该点为源点),对该点的邻接点进行松弛,如果该邻接点松弛成功且不在队列中,则把该点加入队列。如此循环往复,直到队列为空,则求出了最短路径。

判断有无负环:如果某个点进入队列的次数超过N次则存在负环 ( 存在负环则无最短路径,如果有负环则会无限松弛,而一个带n个点的图至多松弛n-1次)

#include <cstdio>
#include <vector>
#include <queue>
#include <cstring>
using namespace std;

const int MAXN = 100005;
struct edge {
	int v, cost;
	// v表示边的终点,cost表示边权
	edge(){}
	edge(int V, int C) { // 构造函数
		v = V;
		cost = C;
	}
};
vector<edge> s[MAXN]; // 邻接表
bool vis[MAXN]; 
// 判断是否在队列里
int dist[MAXN]; // 最短路
int n, m; // n个点,m条边

void Addedge(int u_, int v_, int w) {
	// 单向存边
	s[u_].push_back(edge(v_, w));
}

void spfa(int st) {
	memset(dist, 0x3F, sizeof dist);
	memset(vis, 0, sizeof vis);
	// 初始化
	queue <int> q; 
	dist[st] = 0; // 起点的最短路为0
	vis[st] = true; // 起点入队
	q.push(st);
	while(!q.empty()) {
		// 如果队列里还有点
		int now = q.front();
		q.pop();
		vis[now] = false;
		// 队头出队
		int size = s[now].size(); // 队头总共有多少边相连
		for(int i = 0; i < size; i++) {
			// 枚举与队头相连的边
			int v_ = s[now][i].v, cost_ = s[now][i].cost;
			// 边的信息
			if(dist[now] + cost_ < dist[v_]) {
				// 松弛操作
				dist[v_] = dist[now] + cost_;
				if(!vis[v_]) { // 如果当前点不在队列中,入队即可
					vis[v_] = true;
					q.push(v_);
				}
			}
		}
	}
}

int main() {
	scanf ("%d %d", &n, &m);
	for(int i = 1; i <= m; i++) {
		int u, v, w;
		scanf ("%d %d %d", &u, &v, &w);
		Addedge(u, v, w); // 存边
	}
	spfa(1);
	printf("%d ", dist[n]);
	// 1~n的最短路
	return 0;
} 

集中一点,登峰造极!!!

posted @ 2020-10-24 11:34  STrAduts  阅读(127)  评论(0编辑  收藏  举报