5.1 最短路
最短路应该是图论中非常经典的一个知识点了
那么,最短路是什么呢?
考虑下面这个无向图:
如果我们要求第 1 1 1 号结点到第 3 3 3 结点的最短距离,那么,我们就可以使用最短路算法
方法很多,我们依次了解
5.2 Floyd 算法
Flotd 算法应该是唯一一个可以处理多源最短路的的算法了
Floyd 本质上其实是一个 DP,那么,自然就有状态及其状态转移方程
定义状态:
d p [ i ] [ j ] dp[\ i\ ][\ j\ ] dp[ i ][ j ] 表示从第 i i i 号点到第 j j j 号点的最短路,那么,我们实际上是很容易想到状态转移方程式:
d p [ i ] [ j ] = min ( d p [ i ] [ j ] , d p [ i ] [ k ] + d p [ k ] [ j ] ) ( i ≤ k ≤ j ) dp[\ i\ ][\ j\ ]=\min(dp[\ i \ ][\ j\ ],dp[\ i\ ][\ k\ ]+dp[\ k\ ][\ j\ ])(i\le k\le j) dp[ i ][ j ]=min(dp[ i ][ j ],dp[ i ][ k ]+dp[ k ][ j ])(i≤k≤j)
初始化也是非常简单的:
d p [ i ] [ j ] = { 0 i = j ∞ i ≠ j and i 与 j 之间无连线 a [ i ] [ j ] i ≠ j and i 与 j 之间有连线 dp[\ i\ ][\ j\ ]=\begin{cases}0&i=j\\\infty&i\ne j\ \operatorname{and}\ i\ \text{与}\ j\ \text{之间无连线}\\a[\ i\ ][\ j\ ]&i\ne j\ \operatorname{and}\ i\ \text{与}\ j\ \text{之间有连线}\end{cases} dp[ i ][ j ]=⎩ ⎨ ⎧0∞a[ i ][ j ]i=ji=j and i 与 j 之间无连线i=j and i 与 j 之间有连线
那么,我们似乎可以很轻松的完成代码了
我们可以轻松的打出代码
#include<cstdio>
#include<algorithm>
using namespace std;
int a[2505][2505],n,m,Start,End,x,y,z;
void Floyd(){
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
for(int k=1;k<=n;k++){
a[i][j]=min(a[i][j],a[i][k]+a[k][j]);
}
}
} //上文已提,套公式
}
int main(){
scanf("%d%d%d%d",&n,&m,&Start,&End);
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
if(i==j){
a[i][j]=0;
}else{
a[i][j]=0x3f3f3f3f;
}
} //上文已提,初始化
}
for(int i=1;i<=m;i++){
scanf("%d%d%d",&x,&y,&z);
a[x][y]=z; //建图
}
Floyd();
printf("%d",a[Start][End]); //输出
return 0;
}
然后,我们轻松的将代码交给了评测姬
听取WA声一片
实际上,我们的状态定义是少了一维的(与背包类似)
那么,完整的状态长什么样?
定义状态:
d p [ k ] [ i ] [ j ] dp[\ k\ ][\ i\ ][\ j\ ] dp[ k ][ i ][ j ] 表示经过前 k k k 个点(包含第 k k k 个点且不要求全部经过),从第 i i i 个点到达第 j j j 个点的最短路
那么,在讨论 d p [ k ] [ i ] [ j ] dp[\ k\ ][\ i\ ][\ j\ ] dp[ k ][ i ][ j ] 时,我们有两种决策,一是不经过第 k k k 个点,二是经过第 k k k 个点
如果不经过第 k k k 个点,就有 d p [ k ] [ i ] [ j ] = d p [ k − 1 ] [ i ] [ j ] dp[\ k\ ][\ i\ ][\ j\ ]=dp[\ k-1\ ][\ i\ ][\ j\ ] dp[ k ][ i ][ j ]=dp[ k−1 ][ i ][ j ]
如果经过第 k k k 个点,就有 d p [ k ] [ i ] [ j ] = d p [ k − 1 ] [ i ] [ k ] + d p [ k − 1 ] [ k ] [ j ] dp[\ k\ ][\ i\ ][\ j\ ]=dp[\ k-1\ ][\ i\ ][\ k\ ]+dp[\ k-1\ ][\ k\ ][\ j\ ] dp[ k ][ i ][ j ]=dp[ k−1 ][ i ][ k ]+dp[ k−1 ][ k ][ j ]
综合一下:
d p [ k ] [ i ] [ j ] = min ( d p [ k − 1 ] [ i ] [ j ] , d p [ k − 1 ] [ i ] [ k ] + d p [ k − 1 ] [ k ] [ j ] ) ( i ≤ k ≤ j ) dp[\ k\ ][\ i\ ][\ j\ ]=\min(dp[\ k-1\ ][\ i \ ][\ j\ ],dp[\ k-1\ ][\ i\ ][\ k\ ]+dp[\ k-1\ ][\ k\ ][\ j\ ])(i\le k\le j) dp[ k ][ i ][ j ]=min(dp[ k−1 ][ i ][ j ],dp[ k−1 ][ i ][ k ]+dp[ k−1 ][ k ][ j ])(i≤k≤j)
我们发现:第 k k k 维的状态全部都由第 k − 1 k-1 k−1 维的状态转移得到的
自然,我们可以舍去这一维,简化状态转移方程式
但是,因为进行了化简,所以要注意顺序,因为 k k k 原先是第一维,所以应该先遍历 k k k
那么,我们就有了正确的 Floyd 算法
#include<cstdio>
#include<algorithm>
using namespace std;
int a[2505][2505],n,m,Start,End,x,y,z;
void Floyd(){
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
for(int k=1;k<=n;k++){
a[i][j]=min(a[i][j],a[i][k]+a[k][j]);
}
}
} //上文已提,套公式
}
int main(){
scanf("%d%d%d%d",&n,&m,&Start,&End);
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
if(i==j){
a[i][j]=0;
}else{
a[i][j]=0x3f3f3f3f;
}
} //上文已提,初始化
}
for(int i=1;i<=m;i++){
scanf("%d%d%d",&x,&y,&z);
a[x][y]=z; //建图
}
Floyd();
printf("%d",a[Start][End]); //输出
return 0;
}
但是,由于 Floyd 算法时间复杂度极高,达到了 O ( n 3 ) O(n^3) O(n3) ,所以
恭喜蒟蒻喜提 TLE
我们就需要其它的算法
但是!请容我再废话两句:
5.2.1 Floyd 求路径
我们需要定义一个数组 p r e [ i ] [ j ] pre[\ i\ ][\ j\ ] pre[ i ][ j ] ,表示从第 i i i 号点到第 j j j 号点的最短路中,第 j j j 号点的上一个的下标
具体详情可以见代码:
#include<cstdio>
int a[505][505],pre[505][505];
int n,m,Start,End,x,y,z;
void Floyd(){
for(int k=1;k<=n;k++){
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
if(a[i][j]>a[i][k]+a[k][j]){
a[i][j]=a[i][k]+a[k][j]; //模板
pre[i][j]=pre[k][j]; //注意,因为这里 k 是一个不确定的的值,不能将 k 的值赋给 pre[i][j] ,而是应该将 pre[k][j] ,即 j 的前驱赋给 pre[i][j]
}
}
}
}
}
void print(int x,int y){
if(pre[x][y]==0){
return ;
}
print(x,pre[x][y]);
printf(" %d",y);
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
a[i][j]=i==j?0:0x3f3f3f3f;
}
}
for(int i=1;i<=m;i++){
scanf("%d%d%d",&x,&y,&z);
a[x][y]=z;
pre[x][y]=x; //初始化,y 的前驱自然是 x
}
Floyd();
scanf("%d%d",&Start,&End);
printf("%d\n%d",a[Start][End],Start);
print(Start,End);
return 0;
}
5.3 Dijkstra 算法
Dijkstra 算法是求最短路中最常用的算法
Dijkstra 算法本质上是一个贪心,它将图中所有的结点分为了两种,一种是已经确定了最短路的结点,一种是还没有确定最短路 的结点。
每一次操作时,我们在还没有确定最短路 的结点中找到一个路径最短的结点,因为该结点路径最短且为未被标记,所以该结点的路径必然是最短路,那么,我们就将该结点标记为已经确定了最短路的结点,并更新该结点周围结点的最短路径(以下简称松弛)
听不懂?我们可以结合一张图
5.3.1 举个例子
在该图中,我们设 1 1 1 号结点为起点
对于每一个结点,逗号前的数值表示序号,逗号后的元素表示从起点到该结点的最小距离
在初始化时,我们需要对原始的最短路进行处理:
d i s [ i ] = { 0 i = i S t a r t ∞ i ≠ i S t a r t dis[\ i\ ]=\begin{cases}0&i=i_{Start}\\\infty&i\ne i_{Start}\end{cases} dis[ i ]={0∞i=iStarti=iStart
接下来,我们可以开始 Dijkstra 算法了
首先,我们在未确定最短路的点中找到与起点距离最短的一个点,标记为以确定最短路,并进行松弛操作:
这里,边缘标黑的结点称为已标记最短路的结点
这是我们的第一步
剩下的依葫芦画瓢,我们就可以得到剩下的操作后的结果
第二步,我们将 3 3 3 号结点作标记,并进行相应的松弛操作
第三步,由于路径最短的结点有两个, 4 4 4 号结点和 5 5 5 号结点,
这里随便选一个 5 5 5 号结点
第四步,我们将 4 4 4 号结点作标记,并进行相应的松弛操作
但是,由于
3
+
5
>
6
3+5>6
3+5>6 ,所以实际上它松了个寂寞无行路,若有人知春去处(文艺复兴?)
第四步,我们将
2
2
2 号结点作标记,并进行相应的松弛操作(实际上还是松了个寂寞)
所以我们很容易得到 Dijkstra 算法的原始版本
是的,还是它
相信大家应该都可以熟练的打出 Dijkstra 的原始算法了,下面给出代码:
#include<cstdio>
const int N=20005;
int dis[N],vis[N],a[N][N];
int n,m,Start,End,x,y,z;
void Dijkstra(){
for(int i=1;i<=n;i++){
dis[i]=a[Start][i];
} //初始化与起点相邻的点
dis[Start]=0,vis[Start]=1;
for(int i=1;i<n;i++){
int minn=2147483647,tot; //minn 用于求最小值,tot 记录下标
for(int j=1;j<=n;j++){
if(vis[j]==0&&minn>dis[j]){ //未被标记且可以更新
minn=dis[j],tot=j; //更新
}
}
vis[tot]=1; //标记
for(int j=1;j<=n;j++){
if(dis[tot]+a[tot][j]<dis[j]){ //对所有可以松弛的边进行松弛
//因为所有未相邻的点全部为极大值,不用担心松弛未相邻的点
dis[j]=dis[tot]+a[tot][j];
}
}
}
}
int main(){
scanf("%d%d%d%d",&n,&m,&Start,&End);
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
a[i][j]=i==j?0:0x3f3f3f3f; //初始化邻接矩阵
}
dis[i]=0x3f3f3f3f; //初始化距离
}
for(int i=1;i<=m;i++){
scanf("%d%d%d",&x,&y,&z);
a[x][y]=a[y][x]=z; //输入邻接矩阵
}
Dijkstra();
printf("%d",dis[End]);
return 0;
}
然而,我们可以对 Dijkstra 算法进行优化
5.3.2 邻接表或链式前向星优化
对于这个循环:
for(int j=1;j<=n;j++){
if(dis[tot]+a[tot][j]<dis[j]){
dis[j]=dis[tot]+a[tot][j];
}
}
很显然,在使用邻接表或链式前向星时,可以直接求到第 i i i 号结点相邻的所有结点,我们只需要对这些结点进行松弛即可
下面是链式前向星优化后的结果:
for(int i=head[xx];i;i=Next[i]){
int yy=ver[i],zz=edge[i];
if(dis[yy]>dis[xx]+zz){ //判断是否可以更新
dis[yy]=dis[xx]+zz;
q.push(make_pair(-dis[yy],yy)); //与优先队列优化有关,后文再提
}
}
5.3.3 优先队列优化
对于这个循环:
for(int j=1;j<=n;j++){
if(vis[j]==0&&minn>dis[j]){
minn=dis[j],tot=j;
}
}
他的本质上其实是要找一个最小值
说起找最小值,我们会想到一个东西——小根堆(优先队列)
自然,我们就可以使用优先队列来进行优化了
int xx=q.top().second; //去除堆顶元素
//结合上文 q.push(make_pair(-dis[yy],yy));——因为优先队列默认为大根堆,我们可以通过一个取反操作,将其编号按照小根堆的顺序压入,就避免了priorty_queue<int,vector<int>,greater<int> > q; 的麻烦写法
q.pop(); //记住:一定要pop!!!
if(vis[xx]){ //如果已经标记了,就不管它
continue;
}
这样,我们就得到了优化后的 Dijkstra 算法
#include<queue>
#include<cstdio>
using namespace std;
priority_queue<pair<int,int> > q;
const int N=105;
int head[N],Next[N],ver[N],edge[N],len;
int dis[N],vis[N];
int n,m,x,y,z,Start,End;
void add(int x,int y,int z){
ver[++len]=y,edge[len]=z,Next[len]=head[x],head[x]=len;
}
void Dijkstra(){
for(int i=1;i<=n;i++){
dis[i]=0x3f3f3f3f;
}
dis[Start]=0; //初始化
q.push(make_pair(0,Start)); //初始化优先队列
while(!q.empty()){
int xx=q.top().second;
q.pop();
if(vis[xx]){
continue;
} //优先队列优化,上文已提
vis[xx]=1;
for(int i=head[xx];i;i=Next[i]){
int yy=ver[i],zz=edge[i];
if(dis[yy]>dis[xx]+zz){
dis[yy]=dis[xx]+zz;
q.push(make_pair(-dis[yy],yy));
}
} //链式前向星优化,上文已提
}
}
int main(){
scanf("%d%d%d%d",&n,&m,&Start,&End);
for(int i=1;i<=m;i++){
scanf("%d%d%d",&x,&y,&z);
add(x,y,z);
add(y,x,z);
}
Dijkstra();
printf("%d",dis[End]); //主函数为模板
return 0;
}
时间复杂度大大降低:
5.3.4 Dijkstra 使用注意事项
在使用 Dijkstra 时要注意:它不宜用于带负权的有向图
举个例子:
按照 Dijkstra 的流程,我们会有上图的一个结果:
肉眼发现:此时 3 3 3 号结点是可以松弛 2 2 2 号结点的,但因为 2 2 2 号结点在之前的算法中已经被标记了,所以计算机是不会更新 2 2 2 号结点的最短路的!
那么,我们就需要其它的算法
5.4 Bell_man Ford以及SPFA算法
个人不太喜欢这两种算法…
那么,对于 Bell_man Ford 算法,其实就是一个迭代的过程,以此对所有边进行松弛
SPFA 算法则是 Bell_man Ford 算法的优化,与 Dijkstra 算法优化类似
由于个人喜好,SPFA容易被卡,PPT 出现故障等缘故,下面直接亮代码吧
但是,我还是要罗嗦两句(烦不烦)
5.4.1 判断负环
我们可以用 SPFA 算法判断有无负环出现
所谓负环,是指在有向图中,从一个点出发,绕了一圈回到了该点,且所经过的权值和为负数
举个例子:
如上图就是一个负环
判断负环的方法在代码里
#include<queue>
#include<cstdio>
#include<cstdlib>
using namespace std;
queue<int> q;
const int N=40005;
int head[N],Next[N],ver[N],edge[N],len;
int dis[N],vis[N],flag[N];
int n,m,Start,End,x,y,z;
void add(int x,int y,int z){
ver[++len]=y,edge[len]=z,Next[len]=head[x],head[x]=len;
}
void SPFA(){
for(int i=1;i<=n;i++){
dis[i]=0x3f3f3f3f;
}
dis[Start]=0,vis[Start]=flag[Start]=1; //初始化,其中 flag 数组用来标记某个元素入了几次队列
q.push(Start);
while(!q.empty()){
int xx=q.front();
q.pop();
vis[xx]=0; //取消入队的标记
for(int i=head[xx];i;i=Next[i]){
int yy=ver[i],zz=edge[i];
if(dis[yy]>dis[xx]+zz){
dis[yy]=dis[xx]+zz;
if(!vis[yy]){ //若当前点尚未入队
vis[yy]=1;
flag[yy]++;
q.push(yy);
if(flag[yy]==n){ //因为一共只有 n 个点,假设一次遍历只能确定一个点的最短路,那么,最多只会入 n-1 次队列
//否则,说明有负环出现
printf("-1");
exit(0);
}
}
}
}
}
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++){
scanf("%d%d%d",&x,&y,&z);
add(x,y,z);
}
scanf("%d%d",&Start,&End);
SPFA();
printf("%d",dis[End]);
return 0;
}
注意:Bell_man Ford和SPFA算法可以解决带负权的有向图,但不能解决带负权的无向图
那如何解决带负权的无向图呢?
那我劝你老老实实打 Floyd 吧