最短路一
知识结构图
基本概念
常见的最短路问题一般可以分为两大类,单源最短路和多源汇最短路。
第一大类,单源最短路 就是求从一个点到其他点的最短距离,是最常见的。
第二大类,多源汇最短路 的问题, 源就是起点的意思, 汇就是终点的意思,其实就是可能不止一个起点,可能有很多询问,每个询问可能是任选几个点到其他点的距离, 也就是说起点和终点都不确定的问题。
单源最短路 又能分成两种情况,一种情况是所有的边权重都是正数,图论里面有两种解决办法,都属于是
我们规定
如果
另一种情况是存在负权边,也就是某些边的权重是负数,也有两种实现方式,一种是
总的说
多源汇最短路 的话就是
最短路的问题我们一定要结合实际的情况来选择对应的解决算法。主要考察的是我们能不能将题目给出的信息抽象成图的问题,建图,把它变成最短路的问题, 然后就可以套用我们的思路和模版来做,所以说重点在分析问题, 解决问题直接套用模版就可以了。至于具体算法为什么正确的证明大家可以课后去查一查资料, 课上就不展开细讲了,因为我们图论算法侧重于实现,那么下面我们以题目的形式来看一下每一种算法的实现思路和代码模版。
例题-Dijkstra求最短路 I
题目描述
给定一个
请你求出
输入格式
第一行包含整数
接下来
数据范围:
输出格式
输出一个整数,表示
如果路径不存在,则输出
输入样例
3 3
1 2 2
2 3 1
1 3 4
输出样例
3
题目分析
读完题面最简单的想法应该是,使用搜索来寻找从起点出发所有可能到达
我们以上图为例,首先我们用一个
- 初始化
,其余节点的 值为正无穷大,所有节点未标记,如下图:
- 找出一个未被标记,并且
值最小的节点 ,显然第一次找到的应该是编号为 的节点,然后标记节点 。如下图:
- 扫描节点
的所有出边 ,若 ,则使用 更新 。显然当前节点 有三条出边,肯定会比正无穷大要小,都满足更新条件,更新 数组,这一步叫做松弛,如下图:
- 重复上述的2~3两个步骤,直到所有的节点都被标记。具体流程如下:
首先找到未被标记,且值最小的点为节点 ,标记节点 。
然后扫描节点
再次找到未被标记,且
然后扫描节点
再次找到未被标记,且
然后扫描节点
示例代码
#include <bits/stdc++.h>
using namespace std;
int arr[1005][1005];//邻接矩阵存储每条边
int dist[1005];//i的最短路长度
bool v[1005];//标记数组
int n, m, ans;
int dijkstra() {
memset(dist, 0x3f, sizeof(dist));//dist数组初始化最大
memset(v, 0, sizeof(v));//节点标记
dist[1] = 0; //节点1
for (int i = 1; i < n; i++) { //重复进行n-1次 ,还有n-1个点需要标记
int x = -1; //寻找未标记中 dist值最小的
for (int j = 1 ; j <= n ; j++) {
if (!v[j] && (x == -1 || dist[j] < dist[x])) x = j; //未标记且最小
}
v[x] = 1; //找到后标记该节点
for (int y = 1; y <= n; y++) { //用当前最小值x更新其他节点
dist[y] = min(dist[y], dist[x] + arr[x][y]); //更新最短路
}
}
if (dist[n] == 0x3f3f3f3f) return -1; //未更新输出-1
return dist[n];
}
int main() {
scanf("%d%d", &n, &m);
//构建邻接矩阵
memset(arr, 0x3f, sizeof(arr)); //极大
for (int i = 1; i <= n; i++) arr[i][i] = 0;
for (int i = 1; i <= m; i++) { //存边
int x, y, z;
scanf("%d%d%d", &x, &y, &z);
arr[x][y] = min(arr[x][y], z); //有重边只保留最小边
}
ans = dijkstra();
printf("%d\n", ans);
return 0;
}
不难看出上述代码的时间复杂度为
例题-Dijkstra求最短路 II
题目描述
给定一个
请你求出
输入格式
第一行包含整数
接下来
数据范围:
输出格式
输出一个整数,表示
如果路径不存在,则输出
输入样例
3 3
1 2 2
2 3 1
1 3 4
输出样例
3
题目分析
观察这道题的
简单复习一下邻接表,我们通常使用数组模拟链表的形式实现,长度为
//加入有向边(x,y),权值为z
void add(int x,int y,int j){
ver[++tot] = y,edge[tot] = z,next[tot] = head[x],head[x]=tot;
}
//访问从 x 出发的所有边
for(itn i=head[x];i;i=next[i]){
int y = ver[i],z = edge[i];
//找到了一条有向边(x,y), 权值为 z
}
我们再来看时间复杂度,
示例代码
#include <bits/stdc++.h>
using namespace std;
const int N = 150010,M = 150010;//注意该位置的变量范围定义,N点,M边
int head[N],ver[M],edge[M],ne[M],d[N];
bool v[N];
int n,m,tot,ans;
//大跟堆,pair的第二维为节点编号
//pair的第一维为dist的相反数(利用相反数变成小根堆)
priority_queue < pair<int,int> > q;
void add(int x,int y,int z){
ver[++tot] = y,edge[tot] = z,ne[tot] = head[x],head[x] = tot;
}
int dijkstra(){
memset(d,0x3f,sizeof(d));//dist数组
memset(v,0,sizeof(v));//节点标记
d[1] = 0;
q.push(make_pair(0,1));
while(q.size()){
int x = q.top().second;//取出堆顶
q.pop();
if(v[x]) continue; //确定的跳过
v[x] = 1;
//扫描所有出边
for(int i = head[x]; i ; i = ne[i]){
int y = ver[i],z = edge[i];
if(d[y] > d[x] + z){ //更新 把新的二元组插入堆
d[y] = d[x] + z;
q.push(make_pair(-d[y],y));
}
}
}
if(d[n] == 0x3f3f3f3f) return -1;//未更新输出-1
return d[n];
}
int main(){
scanf("%d%d",&n,&m);
//构建邻接表
for(int i = 1; i <= m; i++){
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
add(x,y,z);
}
ans = dijkstra();
printf("%d",ans);
return 0;
}
例题-有边数限制的最短路
题目描述
给定一个
请你求出从 impossible
。
注意:图中可能 存在负权回路 。
输入格式
第一行包含三个整数
接下来
输出格式
输出一个整数,表示从
如果不存在满足条件的路径,则输出 impossible
。
输入数据
3 3 1
1 2 1
2 3 1
1 3 3
输出数据
3
题目分析
首先发现这道题目中的边权可能为负数,
//首先是n次迭代
for(n){ //循环点数n
for(所有边 x,y,z) { //从x走向y,权重是z 循环边数m
//这里注意边的存储方式不一定是邻接表,可以开结构体去存储,只要能遍历到所有边就可以了
//和dijkstra类似,更新操作 ,叫做松弛操作
dist[y] = min(dist[y],dist[x]+z);
}
}
//循环完以后 所有边xyz 一定满足 dist[y]<=dist[x]+z 这个式子三角不等式
//不难看出时间复杂度为O(nm)
但是这里要注意一个问题,如果图里面存在负权回路的话, 最短路就不一定存在了有可能会在负权回路里一直转圈,结果会是负无穷,如下图:
也就是说能求出最短路的话,一般来说图里面是没有负权回路的。贝尔曼-福特算法是可以求出来这个图是不是存在负权回路的,假如我们在外层循环迭代
所以一般找负环是使用
针对这道题来说限制了只能经过
下面我们来看一下
int n, m; // n表示点数,m表示边数
int dist[N]; // dist[x]存储1到x的最短路距离
struct Edge{ // 边,a表示出点,b表示入点,w表示边的权重
int x, y, z;
} edges[M];
// 求1到n的最短路距离,如果无法从1走到n,则返回-1。
int bellman_ford(){
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
// 如果第n次迭代仍然会松弛三角不等式,就说明存在一条长度是n+1的最短路径,由抽屉原理,路径中至少存在两个相同的点,说明图中存在负权回路。
for (int i = 0; i < n; i ++ ){
for (int j = 0; j < m; j ++ ){
int x = edges[j].x, y = edges[j].y, z = edges[j].z;
if (dist[y] > dist[x] + z)
dist[y] = dist[x] + z;
}
}
if (dist[n] > 0x3f3f3f3f / 2) return -1;
/*
5号节点距离起点的距离是无穷大,5->n如果是-2,利用5号节点更新n号节点距离起点的距离,将得到10^9−2, 虽然小于10^9, 但并不存在最短路,(在边数限制在k条的条件下)。
*/
return dist[n];
}
而且针对这道题目来说,这里面要注意边数限制为一条的情况下会出现串联的情况,举个例子如下:
节点
还有一个注意点就是源点到终点的距离正好是-1,那么 -1
,输出impossible
。但其实正确答案是 -1
。所以要在主函数去处理不存在的问题。
#include<bits/stdc++.h>
using namespace std;
const int N = 510, M = 10010;
struct Edge { //使用结构体存储边,不用定义一大堆数组去加边
int x,y,z;
} e[M];//把每个边保存下来即可
int dist[N];
int back[N];//备份数组防止串联
int n, m, k;//k代表最短路径最多包涵k条边
void bellman_ford() {
memset(dist, 0x3f, sizeof dist); //dist初始化位无穷,八字节3f3f3f3f大于1e9
dist[1] = 0;
for (int i = 0; i < k; i++) {//k次循环
memcpy(back, dist, sizeof dist);//留存
//遍历所有边,而dijkstra是遍历所有顶点n*n
for (int j = 0; j < m; j++) {
int x = e[j].x, y = e[j].y, z = e[j].z;
dist[y] = min(dist[y], back[x] + z);
//使用backup:避免给x更新后立马更新y
}
}
}
int main() {
scanf("%d%d%d", &n, &m, &k);
for (int i = 0; i < m; i++) {
int x, y, z;
scanf("%d%d%d", &x, &y, &z);
e[i].x = x,e[i].y = y,e[i].z = z;
}
bellman_ford();
if(dist[n]>0x3f3f3f3f/2) puts("impossible");
else printf("%d",dist[n]);
return 0;
}
其实同学们在做题的时候最重点的应该是如何把一个问题抽象成图的问题, 然后抓住每一种算法的重点来解决问题,负环在绝大多数的题目中都是不存在的,所以接下来我们要学习的
先来看我们的例题。
例题-SPFA求最短路
题目描述
给定一个
请你求出从 impossible
。
注意:数据保证不存在负权回路。
输入格式
第一行包含整数
接下来
数据范围:
输出格式
输出一个整数,表示从
如果不存在满足条件的路径,则输出 impossible
。
输入数据
3 3
1 2 5
2 3 -3
1 3 4
输出数据
2
题目分析
我们学完贝尔曼-福特应该可以感觉到这个算法还是有提升的空间的,因为我们会发现它就是在傻傻的遍历点和边,全部都去看一遍,通过这个表达式
只用遍历那些到源点距离变小的点所连接的边即可,只有当一个点的前驱结点更新了,该节点才会得到更新;因此考虑到这一点,我们将创建一个队列每一次加入距离被更新的结点,类似于广搜的思路去解决。
具体优化思路:
- 建立一个队列,初始时队列里只有起始点1。
- 再建立一个数组记录起始点到所有点的最短路径(该表格的初始值要赋为极大值,该点到他本身的路径赋为0)。
- 再建立一个数组,标记点是否在队列中。
- 取出队头节点
,扫描它的所有出边 ,若 ,则使用 更新 。同时,若 不在队列中,则把 入队。 - 重复执行直到队列为空。
- 在保存最短路径的数组中,就得到了最短路径。
在任意时刻,该算法的队列都保存了待扩展的节点。每次入队相当于完成一次
示例代码
#include<bits/stdc++.h>
using namespace std;
const int N = 100010,M = 100010;
int h[N],v[N],e[M],ne[M],d[N];
int n,m,tot;
queue <int> q;
int st[N];//标记
void add(int x,int y,int z){
v[++tot]=y,e[tot]=z,ne[tot]=h[x],h[x]=tot;
}
void spfa(){
memset(d,0x3f,sizeof d);
memset(st,0,sizeof st); //是否在队列中
d[1]=0,st[1]=1;
q.push(1);
while(q.size()){
int x = q.front();//取出队头
q.pop();st[x]=0;//出队并取消标记
//扫描所有的出边
for(int i = h[x] ; i ; i = ne[i] ){
int y = v[i] , z = e[i];
if(d[y] > d[x]+z){
//更新
d[y] = d[x] + z;
if(!st[y]) q.push(y),st[y] = 1; //入队并标记
}
}
}
}
int main(){
scanf("%d%d",&n,&m);
for(int i = 1 ;i <= m ; i++){
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
add(x,y,z);
}
spfa();
if(d[n] == 0x3f3f3f3f) cout <<"impossible";
else cout << d[n];
return 0;
}
算法中的 数组保存的是当前确定了到源点距离最小的点,且一旦确定了最小那么就不可逆了(不可标记为 后改变为 ); 算法中的 数组仅仅只是表示的当前发生过更新的点,且 中的 数组可逆(可以在标记为 之后又标记为 )。顺带一提的是 中的 数组记录的是当前已经被遍历过的点。 算法里使用的是优先队列保存的是当前未确定最小距离的点,目的是快速的取出当前到源点距离最小的点; 算法中使用的是队列(你也可以使用别的数据结构),目的只是记录一下当前发生过更新的点。 算法里最后的判断条件写的是 ;而 算法写的是 ; 其原因在于 算法会遍历所有的边,因此不管是不是和源点连通的边它都会得到更新;但是 算法不一样,它相当于采用了 ,因此遍历到的结点都是与源点连通的,因此如果你要求的 和源点不连通,它不会得到更新,还是保持的 。
了解了
例题- SPFA判断负环
题目描述
给定一个
请你判断图中是否存在负权回路。
输入格式
第一行包含整数
接下来
数据范围:
输出格式
如果图中存在负权回路,则输出 Yes
,否则输出 No
。
输入数据
3 3
1 2 5
2 3 -3
1 3 4
输出数据
2
题目分析
读完题目后发现其实也就是求一下图中是否存在负环的问题,基于
- 统计每个点入队的次数,如果某个点入队
次,则说明存在负环。 - 统计当前每个点的最短路中所包含的边数,如果某点的最短路所包含的边数大于等于n,则也说明存在环。
一般都用方法 2,这里我们也主要讲解方法 2。
整理流程如下:
dist[x]
记录虚拟源点到n的最短距离cnt[x]
记录当前点到虚拟源点最短路的边数,初始每个点到虚拟源点的距离为,只要他能再走 步,即 cnt[x] >= n
,则表示该图中一定存在负环,由于从虚拟源点到至少经过 条边时,则说明图中至少有 个点,表示一定有点是重复使用。 - 若
dist[y] > dist[x] + z
,则表示从点走到 点能够让权值变少,因此进行对该点 进行更新,并且对应 cnt[y] = cnt[x] + 1
,往前走一步。
注意:该题是判断是否存在负环,并非判断是否存在从
代码可以在
示例代码
#include<bits/stdc++.h>
using namespace std;
const int N = 100010,M = 100010;
int h[N],v[N],e[M],ne[M],d[N];
int n,m,tot;
queue <int> q;
int st[N];//标记
int cnt[N];
void add(int x,int y,int z){
v[++tot]=y,e[tot]=z,ne[tot]=h[x],h[x]=tot;
}
bool spfa(){
for(int i = 1;i<=n;i++){
q.push(i);
st[i] = 1;
}
while(q.size()){
int x = q.front();//取出队头
q.pop();st[x]=0;//出队并取消标记
//扫描所有的出边
for(int i = h[x] ; i ; i = ne[i] ){
int y = v[i] , z = e[i];
if(d[y] > d[x]+z){
//更新
d[y] = d[x] + z;
cnt[y] = cnt[x] + 1;//如果你是较短的边那么路过的边数就是到x的边数+1
if (cnt[y] >= n) return true; // 如果从1号点到x的最短路中包含至少n个点(不包括自己),则说明存在环
if(!st[y]) q.push(y),st[y] = 1; //入队并标记
}
}
}
return false;
}
int main(){
scanf("%d%d",&n,&m);
for(int i = 1 ;i <= m ; i++){
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
add(x,y,z);
}
bool f = spfa();
if(f) cout <<"Yes";
else cout << "No";
return 0;
}
单源最短路问题的算法就讲到这里,接下来学习最短路问题中的多源汇最短路问题,先来看例题。
例题- Floyd求最短路
题目描述
给定一个
再给定 impossible
。
数据保证图中不存在负权回路。
输入格式
第一行包含三个整数
接下来
接下来
数据范围:
输出格式
共 impossible
。
输入数据
3 3 2
1 2 1
2 3 2
1 3 1
2 1
1 3
输出数据
impossible
1
题目分析
这道题目我们就要学习一个新的算法,
实现方式是这样的,首先我们把所有的边都存在
根据上图我们可以建立如下的邻接矩阵:
假设我们只允许经过
for(int i=1;i<=n;++i) //遍历起点
for(int j=1;j<=n;++j) //遍历被缩小距离的
if(d[i][j] > d[i][1]+d[1][j]) //如果进行中转后的距离比你现在直接到要近
d[i][j]=d[i][1]+d[1][j];//则直接赋值给给d[i][j]即可
假设我们允许经过
我们需要在只允许经过
可以写出代码如下:
//经过一号顶点
for(int i=1;i<=n;++i)//遍历起点
for(int j=1;j<=n;++j)//遍历被缩小距离的点
//通过1号点进行中转后的距离比你现在直接到要近
if(d[i][j] > d[i][1]+d[1][j])
d[i][j]=d[i][1]+d[1][j];//则直接赋值给给d[i][j]即可
//经过二号顶点
for(int i=1;i<=n;++i)//遍历起点城市
for(int j=1;j<=n;++j)//遍历被缩小距离的点
//通过2号点进行中转后的距离比你现在直接到要近
if(d[i][j] > d[i][2]+d[2][j])
d[i][j]=d[i][2]+d[2][j];//则直接赋值给给d[i][j]即可
以此类推:当我们允许通过所有顶点中转,我们可以轻松的写出下面的算法模板。
for(int k=1;k<=n;k++)
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
if(d[i][j]>d[i][k]+d[k][j])
d[i][j]=d[i][k]+d[k][j];
重点逻辑明白了以后,这道题就不难解决了,只要注意一下实现的小细节就可以了,
示例代码
#include <bits/stdc++.h>
using namespace std;
const int N = 210, M = 2e+10, INF = 1e9;
int n, m, k, x, y, z;
int d[N][N];
void floyd() {
for(int k = 1; k <= n; k++)
for(int i = 1; i <= n; i++)
for(int j = 1; j <= n; j++)
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
int main() {
cin >> n >> m >> k;
for(int i = 1; i <= n; i++)
for(int j = 1; j <= n; j++)
if(i == j) d[i][j] = 0;
else d[i][j] = INF;
while(m--) {
cin >> x >> y >> z;
d[x][y] = min(d[x][y], z);
//注意保存最小的边
}
floyd();
while(k--) {
cin >> x >> y;
if(d[x][y] > INF/2) puts("impossible");
//由于有负权边存在所以约大过INF/2也很合理
else cout << d[x][y] << endl;
}
return 0;
}
最短路的问题同学们初学可能会觉得有一些难度, 是因为最短路问题我们需要记忆的东西比较多多, 这几个模版是必须要记住的,重点先把所有模版记住,弄清楚每个算法的优缺点和适用情况, 灵活选择,然后难点就在我们怎么把题目抽象成图,转换成求最短路的问题。这个需要我们多做题目慢慢的去培养题目的敏感性。