算法专题——最短路
最短路问题的概念
最短路问题是图论中经典的问题,主要用于求解图(有向,无向皆可)中任意两点之间的最短路问题,常见的求法有四种,接下来将介绍最短路的四种求法,以及一种常见的应用。
最短路的求法
Dijkstra算法
摘要:常用,不能处理负权,时间复杂度可以接受。
伪代码:
给定 起始点start;
给定 数组dis[] = {start点到i点的最短路|初始化为i点到i点为0,到其他点为INF}
给定 数组vis设置所有点标记为 false
While(还有没有被标记且距离不是INF的点) {
标记一个没有被标记的最近的点tmp //得到dis[tmp]的最小值
从这个点出发,更新周围点的距离
}
//所有已被标记的点都已得到最小dis数组,后面加入的点不会对dis数组进行干扰
时间复杂度:O(n * m),堆优化之后可以达到O(n * logm)。
朴素代码(邻接矩阵存图):
const int INF = 0x3f3f3f3f;
const int MAXN = 1e5 + 10;
int dist[MAXN], g[MAXN][MAXN];
bool vis[MAXN];
void Dijkstra(int start) {
memset(vis, false, sizeof(vis)); //初始化vis,dist
memset(dist, 0x3f, sizeof(dist));
dist[start] = 0;
for (int i = 0; i < n; i++) {
int u = -1;
for (int j = 1; j <= n; j++)
if (!vis[j] && (u == -1 || dist[u] > dist[j]))
u = j;
vis[u] = true;
for (int j = 1; j <= n; j++)
if (g[u][j] != INF)
dist[j] = min(dist[j], dist[u] + g[u][j]);
}
}
堆优化代码(邻接表实现):
const int INF = 0x3f3f3f3f;
const int MAXN = 1e5 + 10;
struct Node {
int v, c; //C仅用于优先队列排序
Node(int v_ = 0, int c_ = 0): v(v_), c(c_) {}
bool operator< (const Node& a) const {return c > a.c; } //小顶堆修改大顶堆
};
vector<Node> E[MAXN];
bool vis[MAXN];
int dist[MAXN];
void Dijkstra(int start, int n) {
memset(vis, false, sizeof(vis)); //初始化vis,dist
memset(dist, 0x3f, sizeof(dist));
priority_queue<Node> que; //初始化que
while (!que.empty()) que.pop();
dist[start] = 0; //假设一个以被vis标记的虚点, 该点到初始点start的距离为0
que.push(Node(start, 0));
Node tmp;
while (!que.empty()) {
tmp = que.top(); que.pop(); //得到一个距离最近且没有访问过的点,以该点为中心,对到周围的点的距离进行更新
int u = tmp.v;
if (vis[u]) continue;
vis[u] = true;
for (int i = 0; i < E[u].size(); i++) {
int v = E[u][i].v, c = E[u][i].c;
if (!vis[v] && dist[v] > dist[u] + c) {
dist[v] = dist[u] + c;
que.push(Node(v, dist[v]));
}
}
} //出来时,最后一个点被标记,与该点连接的点都已被访问,故不会更新dist
} //所有已被vis标记的点,都以得到最优的dist值
void AddEdge(int u, int v, int w) {
E[u].push_back(Node(v, w));
}
Bellman-Ford算法
摘要:
松弛操作:见下面代码。
if(e[i][j]>e[i][k]+e[k][j])
e[i][j]=e[i][k]+e[k][j];
Dijkstra算法从点入手,在BFS过程中,总让点得到最优的值,Bellman-Ford算法从边入手,对全图进行n - 1次松弛操作,从而得到最优解。初次之外Bellman-Ford还可以处理权值为负的情况。代码如下:
/*
* 单源最短路 bellman_ford 算法,复杂度 O(VE)
* 可以处理负边权图。
* 可以判断是否存在负环回路。返回 true, 当且仅当图中不包含从源点可达的负权回路
* vector<Edge>E; 先 E.clear() 初始化,然后加入所有边
* 点的编号从 1 开始 (从 0 开始简单修改就可以了)
*/
const int INF = 0x3f3f3f3f;
const int MAXN = 550;
int dist[MAXN];
struct Edge {
int u, v;
int cost;
Edge(int _u = 0, int _v = 0, int _cost = 0) : u(_u), v(_v), cost(_cost) {}
};
vector<Edge> E;
//点的编号从 1 开始
bool bellman_ford(int start, int n) {
for (int i = 1; i <= n; i++)
dist[i] = INF;
dist[start] = 0;
//最多做 n-1 次
for (int i = 1; i < n; i++) {
bool flag = false;
for (int j = 0; j < E.size(); j++) {
int u = E[j].u;
int v = E[j].v;
int cost = E[j].cost;
if (dist[v] > dist[u] + cost) {
dist[v] = dist[u] + cost;
flag = true;
}
}
if (!flag) return true; //没有负环回路 //没有点更新了,直接返回
}
for (int j = 0; j < E.size(); j++) //如果还可以更新,说明有负环回路
if (dist[E[j].v] > dist[E[j].u] + E[j].cost)
return false; //有负环回路
return true; //没有负环回路
}
注意:Bellman-Ford算法中,最外层的for循环会迭代n - 1次,为什么是n - 1次?
迭代的次数表示的是路径长度,当迭代到
i
次时,所有最短路边数为i
的路径都会在此次循环中得到。不难想到,当跌倒
k
次时能得到边数不超过k
的最短路径为多少。
不难发现函数的返回值是一个bool类型,其作用在于检测回路中是否存在,不存在返回true,存在返回false(存在负环回路
Floyd算法
摘要:
我们知道松弛操作是引入一个中转点,判断是否可以让两点之间的距离变小。而Folyd算法就是基于此得到的,下面是Floyd的基本思想。
最开始只允许经过1号顶点进行中转,接下来只允许经过1和2号顶点进行中转……允许经过1~n号所有顶点进行中转,求任意两点之间的最短路程。用一句话概括就是:从i号顶点到j号顶点只经过前k号点的最短路程。
代码:
for(k=1;k<=n;k++) //中转点遍历
for(i=1;i<=n;i++) //起始点
for(j=1;j<=n;j++) //终止点
if(e[i][j]>e[i][k]+e[k][j])
e[i][j]=e[i][k]+e[k][j];
SPFA算法
摘要:常用,代码短,可以处理负权,可以判断负权回路
基于BFS的思想。大致思路见下面引用:
算法大致流程是用一个队列来进行维护。 初始时将源加入队列。 每次从队列中取出一个元素,并对所有与他相邻的点进行松弛,若某个相邻的点松弛成功,则将其入队。 直到队列为空时算法结束。
对已弹入队列中的元素vis标记为true,对弹出的元素vis标记为false。这样设计可以防止对将已在队列中的元素再次弹入队列中,避免无效操作。
由于一个由n个点组成的图中,一个点至多只会与n - 1个点相连,所以只要不出现负权边,一个点至多会被更新n - 1次(这里使用被弹入队列中的次数进行)。时间复杂度为O(k * m),k为一个点被平均访问的次数。不难发现,SPFA算法处理稠密图时效果可能会达到最坏的时间复杂度,不过大多时候,没有被特意卡的话,复杂度和Dijkstra是差不多的。
同样可以解决负环回路问题。
代码:
/*
* 单源最短路 SPFA
* 时间复杂度 0(kE)
* 这个是队列实现,有时候改成栈实现会更加快,很容易修改
* 这个复杂度是不定的
*/
const int MAXN = 1010;
const int INF = 0x3f3f3f3f;
struct Edge {
int v;
int cost;
Edge(int _v = 0, int _cost = 0) : v(_v), cost(_cost) {}
};
vector<Edge> E[MAXN];
void addedge(int u, int v, int w) {
E[u].push_back(Edge(v, w));
}
bool vis[MAXN]; //在队列标志
int cnt[MAXN]; //每个点的入队列次数
int dist[MAXN];
bool SPFA(int start, int n) {
memset(vis, false, sizeof(vis));
memset(dist, 0x3f, sizeof(dist));
memset(cnt, 0, sizeof(cnt));
while (!que.empty()) que.pop();
dist[start] = 0;
que.push(start); vis[start] = true;
cnt[start] = 1;
while (!que.empty()) {
int u = que.front(); que.pop();
vis[u] = false;
for (int i = 0; i < E[u].size(); i++) {
int v = E[u][i].v, cost = E[u][i].c;
if (dist[v] > dist[u] + cost) {
dist[v] = dist[u] + cost;
if (++cnt[v] > n) return false; //cnt[i] 为入队列次数,用来判定是否存在负环回路
if (!vis[v]) {
que.push(v);
vis[v] = true;
}
}
}
}
return true;
}
最短路问题分析
最短路算是一种计算模型,要用最短路解决问题重点在于建图,将实际问题抽象为图的模型,而后才通过最短路模型进行求解。
建图方式
需要做到将各种问题,各种特殊情况转换为可以用最短路解决的问题。
最优乘车
建图方式上是一个重点,将航线上所经过的点都进行处理。同一航线上的不同站点,使用权值为1的边进行连接。
昂贵的聘礼
这道题需要解决两个问题:首先是物品的交换如何进行表达,其次是不同的等级以及等级限制如何实现。
物品的交换上,我们可以将一个个的商品看做一个个的点,那么不同的商品之间的交换就可以抽象为在点与点之间连边,边的权值为附加价格。至此便完成了商品交换的表达。至于等级的限制,由于一件商品必然原本属于某人,因此可以给点附加属性——等级,这样之后,我们使用迭代的方法,求出等级限制允许内,不同范围可以得到的最短路,即for(int i = level[酋长] - m; i <= level[酋长]; i++) dijkstra(i, i + m)
。
综合应用
最短路与动态规划
最短路与动态规划关系密切,最短路中一个个的节点可以类比为一个个的状态,而在不同点之间连的边就是转移方程,通过转移方程可以实现不同状态之间的转移。而与一般的转移方程不同,动态规划的转移关系一般是线性的有规律的,而在图论中不同状态的转移依赖于点与点之间的边的关系,图论中的转移更加抽象,囊括更多东西。
综上,在实际解决dp,图论问题中,可以有意识的往这方面思考,将两者进行一个结合。
Telephone Lines
二分+最短路,既然是k次免费,那么显而易见的,需要要求第k + 1大的花费,将前面k个最贵的路程的花费免掉。我们可以发现问题变成了求最大值的最小值的问题了,可以想到用二分解决,接下来便要思考所求的ans是否具有单调性,以及Check函数怎么写的问题。
我们可以假设最终答案是预算而不是具体的某一条最优路线的第k+1大的路程花费,那么显然,预算越多,答案就更加成立,当预算小到一定程度,到达临界值时,就不能完成任务,到达目的地了。所以所求的ans具有单调性,可以使用二分的方法求解。
而Check函数,我们发现,如果有一条路径满足预算是路径上前k + 1大的最大花费,那么当前预算就是满足条件足够到达终点的,反之不行。
于是我们就用到了最短路的方法,在Check(int cost)函数内,求图中花费大于cost的道路的条数最少的路径是多少。
Code:
#include <bits/stdc++.h>
using namespace std;
//STL库常用(vector, map, set, pair)
#define PB push_back
#define MP make_pair
#define pii pair<int,int>
#define pdd pair<double,double>
#define F first
#define S second
//常量
#define PI (acos(-1.0))
#define INF 0x3f3f3f3f
//必加
#define GO(i,a,b) for(int i = (a); i < (b); ++i)
#define GOE(i,a,b) for(int i = (a); i <= (b); ++i)
#define OG(i,a,b) for(int i = (a); i > (b); --i)
#define OGE(i,a,b) for(int i = (a); i >= (b); --i)
#define debug cout
typedef unsigned long long ull;
typedef long long ll;
typedef double db;
/* ----------------------------------------------------------------------------------------------------------------------------------------------------------------- */
const int MAXN = 20100, MAXNN = 1010;
struct Edge{
int v, c;
Edge(int _v = 0, int _c = 0) : c(_c), v(_v) {}
};
int n, m, k;
int a, b, l;
vector<Edge> E[MAXN];
bool vis[MAXNN];
int dist[MAXNN];
deque<int> deq;
void AddEdge(int a, int b, int l) {
E[a].push_back(Edge(b, l));
E[b].push_back(Edge(a, l));
}
bool Check(int cost) {
memset(dist, 0x3f, sizeof(dist));
memset(vis, false, sizeof(vis));
dist[1] = 0;
deq.push_back(1);
while (deq.size()) {
int u = deq.front(); deq.pop_front();
if (vis[u]) continue;
vis[u] = true;
for (int i = 0; i < E[u].size(); i++) {
int v = E[u][i].v, c = E[u][i].c > cost;
if (dist[v] > dist[u] + c) {
dist[v] = dist[u] + c;
if (!c) deq.push_front(v);
else deq.push_back(v);
}
}
}
return dist[n] <= k;
}
int main() {
scanf("%d%d%d", &n, &m, &k);
for (int i = 0; i < m; i++) {
scanf("%d%d%d", &a, &b, &l);
AddEdge(a, b, l);
}
int l = 0, r = 1e6 + 1;
while (l < r) {
int mid = l + r >> 1;
if (Check(mid)) r = mid;
else l = mid + 1;
}
if (r == 1e6 + 1) r = -1;
printf("%d\n", r);
return 0;
}
道路与航线
最优贸易
一个节点可以被多次访问,一条路也可以被多次访问,不是单纯的单源最短路问题,这时我们可以往动态规划的思路上靠,我们可以用一个数组保存从节点1开始到当前节点i可以买入的最低价格,用另一个数组保存从节点i到最后一个节点n可以卖出的最高价格,那最终答案就是max(dpmax[i] - dpmin[i])
,而不同状态之间的转移方程也并不难得到,可以使用SPFA的算法,维护dpmin数组与dpmax数组。
下面给出维护的核心代码
bool vis[MAXN];
int dpmin[MAXN];
vector<Edge> E[MAXN];
while (!que.empty()) {
int u = que.front; que,pop();
vis[u] = false;
for (int i = 0; i < E[u].size(); i++) {
int v = E[u][i].v, c = E[u][i].c;
if (dpmin[v] > min(dpmin[u], c)) { //可以更新当前节点的dpmin,进行一个更新
dpmin[v] = min(dpmin[u], c);
if (!vis[v]) {
que.push(v);
vis[v] = true;
}
}
}
}
拓展应用
最短路的求法中有一些小技巧,可以帮助我们求一些较为特殊的模型问题。
- 求反图,一般的最短路是得到一个起点到多个终点的最短路,那么多个起点一个终点的问题该怎么求解呢,可以用到建反图的方法,将边的链接方向全部取反方向,这样就可以求从终点各起点的最短路了。
- 虚拟原点,一些原问题的之中有些数值不是赋值给边,而是可以赋值给点,这时我们可以使用虚拟原点的方法,将点权转换为边权。还可以解决多起点的问题(通过建立虚拟原点,与各起点建立边权为0的边)。
- 动态规划,了解不同求最短路的方法的本质,进而得知如何通过变形的Dijkstra,SPFA算法维护节点不同的信息,得到想要的值,不在拘束于最短路的方法。