目录
简单介绍
单源最短路
给定一个带权有向图G =(V,E),其中每条边的权是一个实数。另外,还给定V中的一个顶点,称为源。要计算从源到其他所有各顶点的最短路径长度。这里的长度就是指路上各边权之和。这个问题通常称为单源最短路径问题。
问题引入
给定一个 n 个点 m 条边的有向图,图中可能存在重边和自环,所有边权均为正值。
请你求出 1 号点到 n 号点的最短距离,如果无法从 1 号点走到 n 号点,则输出 −1。
输入格式
第一行包含整数 n 和 m。接下来 m 行每行包含三个整数 x,y,z,表示存在一条从点 x 到点 y 的有向边,边长为 z。
输出格式
输出一个整数,表示 1 号点到 n 号点的最短距离。如果路径不存在,则输出 −1。
数据范围
1≤n≤500,1≤m≤105,图中涉及边长均不超过10000。
Dijkstra算法O(n^2)
该算法是一种基于贪心的做法:从起点集合出发,每次寻找离起点所属集合最近的点,并标记该点已经属于起点集合,然后用该点的出边更新所有还不属于起点集合的点离起点集合的距离,直到所有的点都属于起点集合。
代码
//dist数组是点离起点集合的距离,g[i][j]是两个点之间的距离
Dijkstra(int beg)
{
memest(dist, 0x3f, sizeof );
dist[beg] = 0;//起点到自己的距离为0
for (int i = 1; i <= n; i++) {
int t = -1;
for (int j = 1; j <= n; j++) {
//如果j不属于起点集合,并且t点离起点集合比j更远,就将t更新为j
if (!st[j] && (t == -1 || dist[t] > dist[j])) {
t = j;
}
}
st[t] = true;//标记t属于起点集合
for (int j = 1; j <= n; j++) {
//更新离起点的距离
dist[j] = min(dist[j], dist[t] + g[t][j]);
}
}
}
Dijkstra算法堆优化(nlogn)
很显然,上述算法固然正确,但是时间复杂度太高,一旦数据范围过大(例题中数据范围扩大1000倍),很容易超时。因此,我们要对其进行优化。
优化方法:利用STL里面的priority_queue对找离起点集合最近的点这一步进行优化,这一步由O(n)优化为O(logn)。
代码
typedef pair<int, int> PII;
void heapDijkstra(int beg)
{
memset(dist, 0x3f, sizeof dist);
dist[beg] = 0;
priority_queue<PII, vector<PII>, greater<PII>> heap;
heap.push({ dist[beg], beg});
while (q.size()) {
auto t = q.top();
q.pop();
int x = t.first, y = t.second;
if (st[y]) continue;
st[y] = true;
//更新
for (int i = h[y]; ~i; i = edge[i].nxt) {
//简化代码
int j = edge[i].to;
//如果更小就更新,同时将更新过的点入队列
if (dist[j] > dist[j] + edge[i].val) {
dist[j] = dist[j] + edge[i].val;
q.push({ dist[j], j});
}
}
}
}
中场总结
Dijkstra算法的限制:不能出现负权边。
原因:该算法是基于贪心的做法,如果出现负权边则无法保证当前更新到的点是距离起点最小的点,如下图所示:
上述推导是很直观的推导,但是不严谨,更严谨的数学推导请自行google。
接下来我们将会介绍解决带负权边的最短路问题的算法:bellman-ford算法和SPFA算法,SPFA算法是bellman-ford算法的优化。因此,绝大部分能用bellman-ford算法解决的问题都可以用SPFA算法解决,且效率更佳,只有小部分问题,例如最多经过k条边的最短路问题无法用SPFA算法解决。
bellman-ford算法O(nm)
该算法的主要思路是对所有边进行n - 1轮松弛操作,为在一个含有n个顶点的图中,任意两点之间的最短路径最多包含n-1边,除非该图有负权回路。换句话说,第1轮在对所有的边进行松弛后,得到的是起点最多经过一条边到达其他顶点的最短距离;第2轮在对所有的边进行松弛后,得到的是起点最多经过两条边到达其他顶点的最短距离;第3轮在对所有的边进行松弛后,得到的是起点最多经过三条边到达其他顶点的最短距离…那如果进行n - 1轮松弛操作后仍然可以进行松弛怎么办?答案是:此时我们可以判断出在这个图中有负权回路,部分点到起点的最短路无解。
也因为这个思路,这个算法可以解决最多经过k条边的最短路问题。
特别注意,在记录最短路时,要用两个数组记录,last[]
用来记录上一轮的最短路,next[]
用来记录当前轮的最短路。更新的时候要用last[]
来更新next[]
,否则会出现一次更新两条边的情况,导致该算法无法求最多经过k条边的最短路问题(但是依旧可以求无边数限制的最短路问题)。下图举例一种简单的情况证明上述结论。
代码
//dist数组代表当前轮点离起点的距离,last数组代表上一轮点离起点的距离
struct Edge {
int from, to, val;
}edge[M];
void bellman-ford(int beg)
{
//初始化操作
memset(dist, 0x3f, sizeof dist);
dist[beg] = 0;
for (int i = 0; i < n; i++) {
for (int j = 1; j <= m; j++) {
int a = edge[i].from, b = edge[i].to, c = edge[i].val;
//用上一轮的最短路更新这一轮的最短路,防止一次更新两条边。
dist[a] = min(dist[a], last[b] + c);
}
//将上一轮的最短路赋值给last数组
memcpy(last, dist, sizeof dist);
}
}
SPFA算法一般O(m),最坏O(nm)
SPFA算法是对bellman-ford算法优化得到的,但是有得必有失,优化后不能用于求最多经过k条边的最短路问题。从bellman-ford算法代码中可以看出,很多点的dist不会更新但却被遍历了。直觉告诉我们,这里有很大的优化空间。而SPFA算法正是这样做的:创建一个队列,将dist被更新的点入队,依次从队头取出元素,遍历该元素的所有出边(连向的点),看能否更新这些点的dist,若可以,则将其入队,否则跳过。
下面简要说明为什么可以跳过dist不会被更新的点。
- 情况一:该点的dist已被更新过,即
dist[i] = last[j] + edge_val
。此时dist已是最小值,除非该点所在路径上出现负权回路。 - 情况二:last[j] = INF。此时无论dist[i]更新与否,都不影响判定:i、j都不可达。
代码
bool st[N]; //用于判断元素是否处于队列中
int h[N], idx;
struct Edge {
int to, val, nxt;
}edge[M];
void spfa(beg)
{
//初始化
memset(dist, 0x3f, sizeof dist);
dist[beg] = 0;
queue<int> q;
q.push(beg);
while (q.size()) {
auto t = q.front();
q.pop();
st[t] = false;
//遍历出边
for (int i = h[t]; ~i; i = edge[i].nxt) {
int j = edge[i].to;
//判断是否更新t元素出边所连的点的dist
if (dist[j] > dist[t] + edge[i].val) {
dist[j] = dist[t] + edge[i].val;
//重复入队只会增加时间损耗
if (!st[j]) {
q.push(j);
}
}
}
}
}
总结
Dijkstra算法一般用于正权图中,稠密图用朴素版,稀疏图用堆优化版;有负权边的图中一般用spfa算法,除非题目有边数限制才会用bellman-ford算法。
多源最短路
给定一个带权有向图G =(V,E),其中每条边的权是一个实数。另外,还给定V中的多个顶点,称为源。要计算从源到其他所有各顶点的最短路径长度。这里的长度就是指路上各边权之和。这个问题通常称为多源最短路径问题。
问题引入
给定一个 n 个点 m 条边的有向图,图中可能存在重边和自环,边权可能为负数。再给定 k 个询问,每个询问包含两个整数 x 和 y,表示查询从点 x 到点 y 的最短距离,如果路径不存在,则输出 impossible。数据保证图中不存在负权回路。
输入格式
第一行包含三个整数 n,m,k。接下来 m 行,每行包含三个整数 x,y,z,表示存在一条从点 x 到点 y 的有向边,边长为 z。接下来 k 行,每行包含两个整数 x,y,表示询问点 x 到点 y 的最短距离。
输出格式
共 k 行,每行输出一个整数,表示询问的结果,若询问两点间不存在路径,则输出 impossible。
数据范围
1≤n≤200,1≤k≤n21≤m≤20000,图中涉及边长绝对值均不超过 10000。
Floyd算法(时间复杂度为O(n^3))
- 虽然这个算法放在图论,但本质上这是一个DP问题
分析
- 状态定义:我们定义g[k][i][j]表示的集合为所有只经过前k个点,从i到j的距离。
- 状态转移方程:我们假设前k-1个点的所有状态都已经确定:
g[k][i][j]
可以由g[k-1][i][j]
和g[k-1][i][k]+g[k-1][k][j]
转移而来。- 优化:显然三维数组的空间复杂度太高了,我们需要将其滚动成二维数组。根据以往dp的经验,我们第一想法是将[k]这一维滚动掉。那么,状态转移方程需要改变什么或者循环方向需要改变吗?答案是不需要。因为我们状态定义是只经过前k个点,因此
g[i][k]+g[k][j]
中的[k]肯定是没有被遍历过的。
- 初始条件:g[i][j]初始化为题目给出的从i到j的距离,否则初始化为INF即无穷大。
代码
#include <iostream>
using namespace std;
const int N=210;
int g[N][N];
int n,m,k;
void Floyd()
{
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
for(int p=1;p<=n;p++){
g[j][p]=min(g[j][p],g[j][i]+g[i][p]);
}
}
}
}
int main()
{
scanf("%d%d%d",&n,&m,&k);
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
if(i==j) g[i][j]=0;
else g[i][j]=0x3f3f3f3f;
}
}
for(int i=1;i<=m;i++){
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
g[x][y]=min(g[x][y],z);
}
Floyd();
for(int i=1;i<=k;i++){
int x,y;
scanf("%d%d",&x,&y);
if(g[x][y]>0x3f3f3f3f/2) printf("impossible\n");
else printf("%d\n",g[x][y]);
}
return 0;
}