2022-11-25——2022-11-30Acwing每日一题
本系列所有题目均为Acwing课的内容,发表博客既是为了学习总结,加深自己的印象,同时也是为了以后回过头来看时,不会感叹虚度光阴罢了,因此如果出现错误,欢迎大家能够指出错误,我会认真改正的。同时也希望文章能够让你有所收获,与君共勉!
最短路问题的大致框架见上。
口述一遍,加深印象吧。最短路主要分为单源最短路和多源汇最短路,多源汇最短路掌握弗洛伊德(Flod)算法,单源最短路又根据权重的正负分为为两类,一类是由朴素的Dijkstra算法(时间复杂度为()及其堆优化版(时间复杂度为)构成的,他们都适用于正权图,但朴素的Dijkstra算法适用于稠密图,堆优化版本的Dijkstra算法适用于稀疏图。另一类是处理负权图的最短路算法,分别是适用于限制不超过k条边的贝尔曼福特(Bellman-Ford)算法(只有这个算法能处理不超过k条边的图且时间复杂度为)和SPFA算法(时间复杂度一般为最坏的情况为)。
单源与多源:单源指从一个起点到其他所有节点的最短路,多源指图中任意两个结点之间的最短路
稠密图使用邻接矩阵存储图,稀疏图用邻接表存储图
单源最短路
朴素的Dijkstra求最短路
给定一个 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。
输入样例:
3 3
1 2 2
2 3 1
1 3 4
输出样例:
3
算法原理
主要思路:
- 初始化图
g
和所有点到源点的距离数组d
,并初始化状态d[1] = 1
(距离数组的下标从1开始) - 遍历n次,表示确定n个节点距离源点的最短路
- 遍历所有点找到没有确定最短距离的节点中距离S(确定最短距离的集合)最近的一个结点
t
,如果不满足条件!st[j] && d[t] > d[j]
即当前的t
不满足最短距离就更新t=j
。 - 将
t
放入集合S中。 - 遍历每一个点,使用
t
来更新每一个点j
到源点的最短距离d[j] = min(d[t]+g[t][j],d[j])
,这一步往往将t
结点所能到达的那些点的距离dist[j]
变成一个较小的数,这样在下一个循环一开始我们就可以根据距离来找到最接近S的点。
代码实现
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 510;
int g[N][N],d[N]; // 用邻接矩阵g存储稠密图,dist[i]表示第i个节点到初始点的距离
int st[N]; // 每个结点的状态
int n,m;
int dijkstra(){
memset(d,0x3f,sizeof d); // 初始化每个结点到源点的距离
d[1] = 0; // 初始化最开始的状态
for(int i=0; i < n ; ++i ){ //
int t = 0;
for(int j=1; j <= n ; ++j){ // 找到没有确定最短距离的点中距离S(确定最短距离的点的集合)最近的点
if(!st[j] && d[j] < d[t]){ // 如果j点没被确定最短路,并且j到源点的距离小于当前的t点
t = j; // 更新t结点,而不是距离,毕竟是找到最近的结点
}
}
st[t] = true; // 将t结点加入到S中
for(int j=1; j <= n ; ++j){ // 使用t去更新其他所有节点
d[j] = min(d[j],d[t] + g[t][j]);
}
}
if(d[n] == 0x3f3f3f3f) return -1; // 如果不存在到第n个点的路
else return d[n];
}
int main(void)
{
cin >> n >> m;
memset(g,0x3f,sizeof g); // 初始化邻接矩阵为正无穷
while(m--){
int a,b,c;
scanf("%d%d%d",&a,&b,&c);
g[a][b] = min(g[a][b],c); // 存在重边,所以需要找最短的边
}
int t = dijkstra();
if(t == -1){
puts("-1");
}
else{
cout << t << endl;
}
return 0;
}
堆优化版的Dijkstra算法求最短路
给定一个 n 个点 m 条边的有向图,图中可能存在重边和自环,所有边权均为非负值。
请你求出 1 号点到 n 号点的最短距离,如果无法从 1 号点走到 n 号点,则输出 −1。
输入格式
第一行包含整数 n 和 m。
接下来 m 行每行包含三个整数 x,y,z,表示存在一条从点 x 到点 y 的有向边,边长为 z。
输出格式
输出一个整数,表示 1 号点到 n 号点的最短距离。
如果路径不存在,则输出 −1。
数据范围
1≤n,m≤1.5×105,
图中涉及边长均不小于 0,且不超过 10000。
数据保证:如果最短路存在,则最短路的长度不超过 109。
输入样例:
3 3
1 2 2
2 3 1
1 3 4
输出样例:
3
算法原理
先来介绍堆优化版与朴素版有什么区别,我们知道朴素版我们外层循环遍历n次是为了确定所有节点到源点的最短路,这里不能省略(堆优化适用于稀疏图,因此使用邻接表存储,但是他也要遍历所有的边,只不过是使用BFS的方式遍历图的),内层的第一个循环是为了找到距离S最短的结点t
,而堆优化就是优化了这一步,我们使用堆(也就是优先队列)实现以的时间复杂度去得到距离S最近的结点,那么怎么做呢?我们结合朴素般的内层第二个循环来解释,我们第二层循环是更新所有t
能够到达的那些点j
的距离dist[j]
(这里也能发现朴素版遍历n
个结点肯定由很多t
到达不了的,也会浪费一点时间,堆优化版使用邻接表只遍历t
能到达的点就会优化这一点),在更新距离时我们可以判断我们找到的这个t
到j
的距离d[t]+w[j]
与距离d[j]
谁更短,如果我们把每一个能更新距离的t
存储进堆中,那么我们在下一次寻找最近的t
时就可以直接找堆里面更新d[j]
最小的距离的那个t
了嘛,这样的话只要堆不空,就始终能更新t
后面所连接的点他们的最短距离(毕竟t
的距离d[t]
变短了那么以t
更新的后面那些点的距离也都会变短吧)。
需要补充的是STL中的优先队列 priority_queue中存储的元素即使是pair也可以进行排序且是从first开始按照字典序开始排列,所以first存储的是编号为second的点到源点的距离d。
还有就是w[i]代表的是邻接表中结点ver到后续第i个结点的边权
时间复杂度为,外层循环为堆中存储的元素个数,而元素如果想要进堆只能在这个元素能能更新它及其后面的结点距离d的时候才会入堆,也就是说最坏的情况是每个元素都可以更新后继节点,即所有边都会被更新,所以需要更新m回,每一回更新n个点,每回合小根堆中的排序时间复杂度为,所以一共的时间复杂度为。
主要思路:
- 初始化优先队列,邻接表及其距离数组
- 取出距离集合S最近的结点ver,也就是小顶堆中的最小值
heap.top()
,不要忘记删除这个结点,因此每一次删除要对堆内元素重新排序,时间复杂度为。 - 判断
ver
是否被确定过最短距离,如果确定过就跳过(因为第一次确定的边一定是最短的),否则就更新为确定最短距离st[ver] = true
。 - 遍历
ver
能够走的那些边,使用ver
的最短距离来更新每个点j
所对应的一条边,如果可以更新就顺带把当前的j
加进堆里heap.push({d[j],j})
,这是因为我们可以使用更新后的j
来更新j
所连接的点。
代码实现
#include<iostream>
#include<cstring>
#include<algorithm>
#include<queue>
using namespace std;
typedef pair<int ,int > PII;
const int N = 1e6+10;
int h[N],e[N],ne[N],w[N],idx; // 邻接表存储稀疏图的标配,唯一需要注意的是w[i]邻接表遍历时i到后面的j的边权
int st[N]; // 每个结点的状态
int d[N];
int n,m;
void add(int a,int b,int c){
e[idx] = b,w[idx] = c,ne[idx] = h[a],h[a] = idx++; // 使用w存储a->b的边权
}
int dijkstra(){
memset(d,0x3f,sizeof d); // 初始化每个结点到源点的距离
d[1] = 0; // 初始化源点距离
priority_queue<PII,vector<PII>,greater<PII>> heap; // STL中的堆/优先队列
heap.push({0,1}); // 初始化优先队列,编号为1到源点的距离为0,first是距离下标,second是编号
while(heap.size()){ // 将所有更新的t所对应的j距离源点的距离都更新,也就是t到j的每个边都会遍历,时间复杂度最多为m
auto t = heap.top(); // 取最小堆的顶部也就是距离S最近的点
heap.pop(); // 将其取出后就删除
int ver = t.second, dist = t.first; // 取出距离S最近的点ver,及其到源点的距离var的下标
if(st[ver]) continue; // 如果该点已经确定了最短路就跳过
st[ver] = true; // 表示这个点的最短路已经确定
// 后面是为了更新与var相连接的点j的距离
for(int i=h[ver] ; i != -1 ; i = ne[i]){
int j = e[i]; // 对于结点j
if(d[j] > d[ver] + w[i]){ // 如果ver的距离比j的距离要小
d[j] = d[ver] + w[i];
heap.push({d[j],j}); // j为下一次的t,他能更新从j以后所连接的结点的距离
}
}
}
if(d[n] == 0x3f3f3f3f) return -1; // 如果不存在到第n个点的路
else return d[n];
}
int main(void)
{
cin >> n >> m;
memset(h,-1,sizeof h); // 初始化邻接表
while(m--){
int a,b,c;
scanf("%d%d%d",&a,&b,&c);
add(a,b,c); // 即使存在重边,但小根堆也只会寻找权重最小的,大的那些冗余边权就要被continue掉
}
int t = dijkstra();
if(t == -1){
puts("-1");
}
else{
cout << t << endl;
}
return 0;
}
有边数限制的最短路
给定一个 n 个点 m 条边的有向图,图中可能存在重边和自环, 边权可能为负数。
请你求出从 1 号点到 n 号点的最多经过 k 条边的最短距离,如果无法从 1 号点走到 n 号点,输出 impossible
。
注意:图中可能 存在负权回路 。
输入格式
第一行包含三个整数 n,m,k。
接下来 m 行,每行包含三个整数 x,y,z,表示存在一条从点 x 到点 y 的有向边,边长为 z。
点的编号为 1∼n
。
输出格式
输出一个整数,表示从 1 号点到 n 号点的最多经过 k 条边的最短距离。
如果不存在满足条件的路径,则输出 impossible
。
数据范围
1≤n,k≤500,
1≤m≤10000,
1≤x,y≤n,
任意边长的绝对值不超过 10000。
输入样例:
3 3 1
1 2 1
2 3 1
1 3 3
输出样例:
3
算法原理
这里就不多说了,好吧强调一下存储方式是使用结构体存储从a到b的距离w,再解释一下从1到n号点的最多经过k条边的最短距离,假设从1到n号点经过不超过k条边就可达说明存在解,,否则输出imporssible
,如果存在不超过k条边中可达b的边集,那么找其中的边集中的和最小的就是该点的符合要求的最短距离,但这个距离不一定是这个图中最短的,因此如果求得是不超过k条边的最短距离就只能使用贝尔曼福特最短路求解。
主要思路:
- 外循环遍历n次,实际意义确定从源点开始不超过k条边的结点的最短距离。
- 需要注意内循环我们会实时改变最短距离,因此我们需要复制一份最短距离数组
d
,使用复制的数组确能更新最短距离d
,即memcpy(backup,d,sizeof d)
。 - 内循环是遍历当前次数的边所能到达的节点的最短距离,我们遍历所有边,判断从a->b这条边
w
,选择b的最短距离,d[b] = min(d[b],backup[a]+w)
。这一步更新最短距离也被称作松弛操作,目的是为了更新b的最短距离,如果a的距离更短且d[a]+w比d[b]更小,我们可以用a更新b。
还有一点就是这个算法和SPFA算法都不适合含有负环的图中,否则距离会更新为比无穷大小一点。
代码实现
#include<cstring>
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 1e5+100;
int d[N],backup[N];
struct edge{
int a,b,w;
}edges[N];
int n,m,k;
int Bellman_fold(){
memset(d,0x3f,sizeof d);
d[1] = 0; // 初始化源点距离为0,这样从能从1开始更新
for(int i=0; i < k ; ++i){ // k次循环只找到不超过k条边的节点的最短距离
memcpy(backup,d,sizeof d);
for(int j = 1; j <= m ; ++j){ // 遍历所有边
int a = edges[j].a,b = edges[j].b,w = edges[j].w;
d[b] = min(d[b],backup[a] + w); // 更新从0开始所有边的最短距离
}
}
return d[n];
}
int main(void){
cin >> n >> m >> k;
for(int i=1; i <= m ; ++i){
int a,b,w;
cin >> a >> b >> w;
edges[i] = {a,b,w};
}
int t = Bellman_fold();
if(t > 0x3f3f3f3f / 2){
puts("impossible");
}
else{
cout << t;
}
return 0;
}
SPFA求最短路
给定一个 n 个点 m 条边的有向图,图中可能存在重边和自环, 边权可能为负数。
请你求出 1 号点到 n 号点的最短距离,如果无法从 1 号点走到 n 号点,则输出 impossible
。
数据保证不存在负权回路。
输入格式
第一行包含整数 n 和 m。
接下来 m 行每行包含三个整数 x,y,z,表示存在一条从点 x 到点 y 的有向边,边长为 z。
输出格式
输出一个整数,表示 1 号点到 n 号点的最短距离。
如果路径不存在,则输出 impossible
。
数据范围
1≤n,m≤105
,
图中涉及边长绝对值均不超过 10000。
输入样例:
3 3
1 2 5
2 3 -3
1 3 4
输出样例:
2
算法原理
之前提到过,如果所有边都是正数,那么可以使用Dijkstra算法求最短路,如果存在负权重,那么最好使用SPFA求最短路,Bellman-Fold可以在求不大于k条边的最短路中使用。因此我们来看看SPFA怎么求最短路。
SPFA求最短路的代码与堆优化版的Dijkstra算法很相似,他们都使用邻接表形式的BFS遍历图中每一个结点,但最主要的区别在于状态数组st
的定义不一样,堆优化版的st[i]
表示第i个编号的结点是否已经确定了最短距离,而SPFA的st[i]
表示第i个编号的结点是否在队列里,除此之外,堆优化是基于朴素Dijkstra算法而SPFA是基于Bellman-Fold算法优化。
这里先看SPFA与Bellman-Fold算法的区别,先给出Bellman-Fold算法步骤:
- 外层循环n次,实际意义为确定了从源点开始的不超过n条边的结点最短距离,因此最短距离的确定是从源点开始向外扩散的。
- 在这里我们需要额外复制dist数组,因为内循环遍历使用上一轮的dist数组进行更新的,但是我们在内循环就需要更改dist数组,因此我们需要备份。
memcpy(list,dist,sizeof dist)
- 内循环遍历所有m条边,这里我们更新所有边中最小的边
d[e.b] = in(d[e.b],backup[e.a]+e.c)
,意思是从a->b的边,我们比较b到源点的距离和经过a再到b的距离谁更小就选谁。结合内外层循环来看,当外层遍历第一次时,我们更新的也是源点相邻的哪些点的距离,但是我们却更新了许多与源点不相邻的点(没什么用),因此这里就是我们要优化的地方。
再来看看SPFA算法怎么实现
正因为Bellman-Fold算法使用邻接矩阵适合遍历所有的边浪费时间,所以SPFA才会使用邻接表存储,每一次遍历也只会遍历与当前点相邻的哪些点,并且使用了堆优化版的Dijkstra算法里优化的思想,只要能用t更新j那么我们就更新j所连接的哪些点,即将j放入队列中,在下一次循环中更新t,如果队列没有元素说明所有节点的最短路都已确定。
总结一下:
SPFA关于Bellman—Fold算法的优化主要是对于内层循环的遍历上,SPFA使用了邻接表进行遍历,只会遍历邻边,不会遍历多余的点,而贝尔曼福特算法内循环遍历所有的点,会有的时间复杂度。
SPFA关于堆优化版的Dijkstra算法的优化在于SPFA借鉴了堆优化的主要思想,当t能够更新j的最短距离,那么我们也可以用j去更新与j相邻的哪些点的最短距离,只不过堆优化使用优先队列在删除元素会对堆进行排序有的时间复杂度,并且将元素删除并入集合S中不会重复加入一个元素,直到所有的点都加入集合S结束,而SPFA只会存储变小的t,如果一个结点多次进入队列,说明存在负环,因为负环转一圈距离会减少,距离减少就会导致结点再次入队,因此SPFA可以检测负环
综上述两点我们可以得出SPFA的时间复杂度为且堆存储最近节点的数据结构具有单调性,SPFA存储最近节点的数据结构不具有单调性。
代码实现
#include<cstring>
#include<iostream>
#include<algorithm>
#include<queue>
using namespace std;
const int N = 1e5+100; // n和m相同,稀疏图,m远大于n稠密图
int h[N],w[N],e[N],ne[N],idx;
bool st[N];
int d[N];
int n,m;
void add(int a,int b,int c){
e[idx] = b,w[idx] = c,ne[idx] = h[a],h[a] = idx++;
}
int spfa(){
memset(d,0x3f,sizeof d);
d[1] = 0;
queue<int> q;
q.push(1);
st[1] = true; // 1在队列里
while(!q.empty()){
auto t = q.front();
q.pop();
st[t] = false; // 表示t不在队列里面
for(int i=h[t] ; i != - 1; i=ne[i]){
int j=e[i];
if(d[j] > d[t] + w[i]){
d[j] = d[t] + w[i]; // w[i]表示t到其所连接的结点j的边权,看d[j]和经过t加上t到j的距离谁更大哦
if(!st[j]){
q.push(j);
st[j] = true;
}
}
}
}
return d[n];
}
int main(void){
cin >> n >> m;
memset(h,-1,sizeof h);
while(m--){
int a,b,c;
cin >> a >> b >> c;
add(a,b,c);
}
int t = spfa();
if(t == 0x3f3f3f3f) cout << "impossible" << endl; // 不能为-1 表示存在负环是因为值可能会取-1,如果存在负环的话说明一定是到不了最后的因此他的距离依然是无穷大
else cout << t << endl;
return 0;
}
SPFA判断负环
给定一个 n 个点 m 条边的有向图,图中可能存在重边和自环, 边权可能为负数。
请你判断图中是否存在负权回路。
输入格式
第一行包含整数 n 和 m。
接下来 m 行每行包含三个整数 x,y,z,表示存在一条从点 x 到点 y 的有向边,边长为 z。
输出格式
如果图中存在负权回路,则输出 Yes,否则输出 No。
数据范围
1≤n≤2000,
1≤m≤10000,
图中涉及边长绝对值均不超过 10000。
输入样例:
3 3
1 2 -1
2 3 4
3 1 -4
输出样例:
Yes
算法原理
SPFA算法原理上面讲了,这里也就不废话了,主要说一下怎么判断负环。
参考自这篇博客。
SPFA判断负环的方法有两种:
- 更具每个点入队的次数如果大于n次,一定存在负环,这里使用第一种方法求解。
- 根据遍历到边数来判断,如果边数大于m,一定存在负环。
与普通的SPFA算法多了一个将所有结点加入队列中,这是因为题目没说源点是否在负环内,因此我们将所有的点都加进队列里,这样就可以判断所有点是否都存在负环中了。
代码实现
#include<iostream>
#include<cstring>
#include<algorithm>
#include<queue>
using namespace std;
const int N = 2010, M = 10010;
int n, m;
int h[N], w[M], e[M], ne[M], idx; // 只有h存储点n,后面到存储到j的边,所以有M个长度
int d[N], cnt[N];
bool st[N];
void add(int a,int b,int c){
e[idx] = b,w[idx] = c,ne[idx] = h[a], h[a] = idx++;
}
bool spfa(){
// 不用初始化d,是因为初始化d是为了得到起点到终点的距离,但判断负权回路只需要递推式d[j] = min(d[j],d[t]+w[i])
// 这个式子表示如果存在负权回路,那么距离始终会小于0,始终会有结点入队,那么就会一直循环下去,直到某个结点出现的次数超过n从而退出循环
queue<int> q;
for(int i=1 ; i <= n ; ++i){
st[i] =true;
q.push(i);
}
while(q.size()){
auto t = q.front();
q.pop();
st[t] = false;
for(int i = h[t]; i != -1; i = ne[i]){
int j = e[i];
if(d[j] > d[t]+w[i]){
d[j] = d[t] + w[i];
cnt[j] = cnt[t] + 1; // 如果不存在负环,那么cnt[j]从t中继承的永远达不到n,如果存在回路,那么j总是能从t哪里继承值,这里的t在负环中总是能增加
if(cnt[j]>=n) return true; // 说明存在负环
if(!st[j]){
q.push(j);
st[j] = true;
}
}
}
}
return false;
}
int main(void){
cin >> n >> m;
memset(h,-1,sizeof h);
while(m--){
int a,b,c;
cin >> a >> b >> c;
add(a,b,c);
}
if(spfa()) puts("Yes");
else puts("No");
}
多源汇最短路
Floy求最短路
给定一个 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≤n2
1≤m≤20000,
图中涉及边长绝对值均不超过 10000。
输入样例:
3 3 2
1 2 1
2 3 2
1 3 1
2 1
1 3
输出样例:
impossible
1
算法原理
基于动态规划实现的,具体原理等动态规划那一部分再说,这里先看步骤:
使用邻接矩阵存储图d
。
1.三重循环遍历,外层k,内层i,j
2.每次更新d[i][j] = min(d[i][j],d[i][k]+d[k][j])
3.判断结果是否大于比无穷大少点INF/2
,如果存在说明不可达,由于暴力更新最短路因此难免会使得不可达的距离少点,因此需要判断INF/2
需要注意存在重边,邻接矩阵需要取出最小的,邻接表不需要,因为遍历时一般都会判断是否比之前的要小
代码实现
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 210,INF = 1e9;
int d[N][N];
int n,m,Q;
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]); // 看从i->j与i->k->j的距离谁更短
}
}
}
}
int main(void){
cin >> n >> m >> Q;
// 多源汇最短路图的初始化
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--){
int a,b,c;
cin >> a>> b>>c;
d[a][b] = min(d[a][b],c); // 存在重边,选择最小的
}
floyd();
while(Q--){
int a,b;
cin >> a >>b ;
int t = d[a][b];
if(t > INF/2) puts("impossible");
else cout << t << endl;
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!