了解了优先队列,本来想写一道题目练练手,结果就看到了8441,看着像是bfs求最短路,然而T了,并不知道怎么优化,然后又去找老师要了标程,结果神仙代码看不懂(主要是因为太菜..),看到里面用了dijstra,就干脆先从最短路问题入手。
最短路问题,一般有三种方法,dijstra,bellman-forward,floyed,三者个有特色,适合于不同的场合。
一。dijstra(迪杰斯特拉)
Dijkstra算法
1.定义概览
Dijkstra(迪杰斯特拉)算法是典型的单源最短路径算法,用于计算一个节点到其他所有节点的最短路径。主要特点是以起始点为中心向外层层扩展,直到扩展到终点为止。该算法无法处理负权边。
问题描述:在无向图 G=(V,E) 中,假设每条边 E[i] 的长度为 w[i],找到由顶点 V0 到其余各点的最短路径。(单源最短路径)
2.算法描述
1)算法思想:设G=(V,E)是一个带权有向图,把图中顶点集合V分成两组,第一组为已求出最短路径的顶点集合(用S表示,初始时S中只有一个源点,以后每求得一条最短路径 , 就将加入到集合S中,直到全部顶点都加入到S中,算法就结束了),第二组为其余未确定最短路径的顶点集合(用U表示),按最短路径长度的递增次序依次把第二组的顶点加入S中。在加入的过程中,总保持从源点v到S中各顶点的最短路径长度不大于从源点v到U中任何顶点的最短路径长度。此外,每个顶点对应一个距离,S中的顶点的距离就是从v到此顶点的最短路径长度,U中的顶点的距离,是从v到此顶点只包括S中的顶点为中间顶点的当前最短路径长度。
2)算法步骤:
a.初始时,S只包含源点,即S={v},v的距离为0。U包含除v外的其他顶点,即:U={其余顶点},若v与U中顶点u有边,则<u,v>正常有权值,若u不是v的出边邻接点,则<u,v>权值为∞。
b.从U中选取一个距离v最小的顶点k,把k,加入S中(该选定的距离就是v到k的最短路径长度)。
c.以k为新考虑的中间点,修改U中各顶点的距离;若从源点v到顶点u的距离(经过顶点k)比原来距离(不经过顶点k)短,则修改顶点u的距离值,修改后的距离值的顶点k的距离加上边上的权。
d.重复步骤b和c直到所有顶点都包含在S中。
3.执行动画过程如下图
4.例题:http://icpc.upc.edu.cn/problem.php?id=2716
算法实现:
#include <iostream> #include <bits/stdc++.h> // dijstra n^2 TLE using namespace std; const int maxn=5e6+10; const int inf=1e9+7; struct E { int v,w; }; vector <E> edge[maxn]; int in[maxn],dis[maxn];//in 数组表示在集合S内,dis表示到个点的最短距离 int dijstra(int s,int e,int n)//s 出发点 e 终止点 n 点数 { for (int i=0; i<=n; i++) dis[i]=inf; dis[s]=0,in[s]=1; for (int i=0; i<edge[s].size();i++) { dis[edge[s][i].v]=edge[s][i].w; //printf("to%d=%d\n",edge[s][i].v,dis[edge[s][i].v]); } //初始化 for (int i=0; i<=n; i++) { int mi=inf,k=s;//找到s点最短距离的点 for (int j=1; j<=n; j++) { if (!in[j] && dis[j]<mi) { mi=dis[j]; k=j; } } in[k]=1;//将最短的新点加入集合S int num=edge[k].size();//用新点k去扩展新点 for (int j=0; j<num; j++) { int v=edge[k][j].v,w=edge[k][j].w; if (!in[v]) { if (dis[k]+w<dis[v]) //relax { dis[v]=dis[k]+w; } } } } return dis[e]; } int main() { int n,m,t; scanf("%d%d%d",&n,&m,&t); for (int i=1; i<=m; i++) { int u,v,w; scanf("%d%d%d",&u,&v,&w); edge[u].push_back({v,w}); edge[v].push_back({u,w}); } int ans=dijstra(1,t,n); printf("%d\n",ans); return 0; }
其实dj算法就是BFS+贪心,它每次选一个点,然后扩散(bfs)到它的邻点之后,再从所有点中,选出离起点最近的点,继续扩散出去。这样总共n-1次之后,图上所有点离起点的距离必然是最小的。时间复杂度为n^2,n>1000一般稳稳地TLE。
6.优化:
考虑到每次都是用最近的那一个结点更新,暴力跑需要n的时间,太慢了。
怎么样能gkd呢?我们自然可以想到优先队列,因为每次要找的点有鲜明的特征,是距离s最近的点。这样优化后,n变成了logn,所以总的复杂度变为nlogn,瞬间快乐。
代码实现:
#include <iostream> #include <bits/stdc++.h> using namespace std; const int maxn=5e6+10; const int inf=INT_MAX/2; /*struct E { int v,w; bool operator< (const E& b) const { return w > b.w; } };*/ struct E { int v,w; friend bool operator< (E x,E y) { return x.w>y.w; } //重载<运算符,使得距离小的优先级大 }; /*struct cmp { bool operator() (const E &x,const E &y) const { return x.w>y.w; } };*/ int vis[maxn],dis[maxn]; vector <E> edge[maxn]; int dijheap(int s,int e,int n) { priority_queue <E> Q; for (int i=0; i<=n; i++) dis[i]=inf; Q.push({s,0}); dis[s]=0; while(!Q.empty()) { E cur=Q.top();//保证取出的队首元素就是距离s最近的 Q.pop(); int cv=cur.v; if (vis[cv]) continue; vis[cv]=1; int num=edge[cv].size(); for (int i=0;i<num;i++)//用这个点去扩展relax { int v=edge[cv][i].v,w=edge[cv][i].w; if (!vis[v]) { if (dis[v]>dis[cv]+w) { dis[v]=dis[cv]+w; Q.push({v,dis[v]}); } } } } return dis[e]; } int main() { int n,m,t; scanf("%d%d%d",&n,&m,&t); for (int i=1; i<=m; i++) { int u,v,w; scanf("%d%d%d",&u,&v,&w); edge[u].push_back({v,w}); edge[v].push_back({u,w}); } int ans=dijheap(1,t,n); printf("%d\n",ans); return 0; }
7.小结
Dijstra算法十分优秀,在使用堆(优先队列)优化的情况下,时间复杂度为nlogn,如果题目中不是单源的最短路,那么可以每个点都作为起点跑一下dj算法,n^2logn。
缺点:
dj算法是无法处理负权边的!为什么呢,因为dj算法是贪心BFS,而BFS有一个特点,就是短视! 它只能看到与自己相邻的点的情况,但是对于远方,它就一脸蒙蔽了。如果有两种走法,一种是直接走边长5到达,一种是先走10,再走-20到达,显然,我们的dijstra算法会直接走第一种。
8.扩展
其实,dijstra算法,还可以输出最短路的路径,只需要用一个pre数组记录一下每个节点的前驱结点,递归输出就可以了。
代码实现:
#include <bits/stdc++.h> using namespace std; typedef long long ll; const int maxn=1e3+20; const int inf=1e9+7; int dis[maxn],vis[maxn],pre[maxn]; struct E { int v,w; bool friend operator< (E x,E y) { return x.w>y.w; } }; vector <E> edge[maxn]; void dij(int s,int n) { priority_queue <E> Q; while(Q.size()) Q.pop(); for (int i=1; i<=n; i++) dis[i]=inf; dis[s]=0;pre[s]=s; Q.push({s,0}); while(!Q.empty()) { E cur=Q.top(); Q.pop(); int cv=cur.v; int num=edge[cv].size(); if (vis[cv]) continue; vis[cv]=1; for (int i=0; i<num; i++) { int v=edge[cv][i].v,w=edge[cv][i].w; if (dis[v]>dis[cv]+w) { dis[v]=dis[cv]+w; Q.push({v,dis[v]}); pre[v]=cv; } } } } void outway(int i) { if (pre[i]!=i) { printf("%d-->",i); outway(pre[i]); } else printf("1\n"); return ; } int main() { int n,m; freopen("out2.txt","w",stdout); while(~scanf("%d%d",&n,&m)) { if (n==0&&m==0) break; memset(vis,0,sizeof(vis)); memset(edge,0,sizeof(edge)); memset(pre,0,sizeof(pre)); for (int i=1; i<=m; i++) { int u,v,w,flag=0; scanf("%d%d%d",&u,&v,&w); edge[u].push_back({v,w}); } dij(1,n); for (int i=2; i<=n; i++) i==n ? printf("%d\n",dis[i]) :printf("%d ",dis[i]); for (int i=2; i<=n; i++) outway(i); } return 0; }
需要正序输出的话,其实也可以,用stack记录一下路径即可
代码如下:
void callway(int i,int x) { while(pre[i]!=i) { way[x].push(i); i=pre[i]; } way[x].push(1); } void outway(int x) { while(way[x].size()) { int a=way[x].top(); way[x].pop(); if (way[x].size()==0) printf("%d\n",a); else printf("%d-->",a); } } for (int i=2; i<=n; i++) callway(i,i),outway(i);
二。Bellman-Ford 算法
1.定义概览
Bellman - ford算法是求含负权图的单源最短路径的一种算法,效率较低(nm),代码难度较小。其原理为连续进行松弛,在每次松弛时把每条边都更新一下,若在n-1次松弛后还能更新,则说明图中有负环,因此无法得出结果,否则就完成。
问题描述:在无向图 G=(V,E) 中,假设每条边 E[i] 的长度为 w[i],找到由顶点 V0 到其余各点的最短路径,边长可能为负值。(单源最短路径)
2.算法描述
每一条边松弛n次,对于任意一条最短路,最多松弛n-1次,如果还能松弛,说明存在负环
3.算法步骤
a.初始化
b.对每个点所连的边松弛
3.松弛检查负环
4.例题 http://icpc.upc.edu.cn/problem.php?id=1634
代码如下:
#include <iostream> #include <bits/stdc++.h> using namespace std; typedef long long ll; const int maxn=2e4+10; const ll inf=2147483647; struct E { int v,w; }; vector<E> edge[maxn]; ll dis[maxn]; bool bellman(int s,int n) { for (int i=1; i<=n; i++) dis[i]=inf; dis[s]=0; for (int k=1; k<=n;k++) for (int i=1; i<=n; i++) { for (int j=0; j<edge[i].size();j++) { ll v=edge[i][j].v,w=edge[i][j].w; if (dis[v]>dis[i]+w) dis[v]=dis[i]+w; } } for (int i=1; i<=n; i++) { for (int j=0; j<edge[i].size();j++) { int v=edge[i][j].v,w=edge[i][j].w; if (dis[v]>dis[i]+w) return 1; } } return 0; } int main() { int n,m,s; scanf("%d%d%d",&n,&m,&s); for (int i=1; i<=m; i++) { int u,v,w; scanf("%d%d%d",&u,&v,&w); edge[u].push_back({v,w}); } int flag=bellman(s,n); for (int i=1; i<=n-1; i++) { printf("%lld ",dis[i]); } printf("%lld\n",dis[n]);
flag ? cout<<"Yes\n" : cout<<"No\n"; return 0; }
5.优化(SPFA)
朴素的bellman-ford算法时间复杂度是n*m,很容易超时
很多时候我们并不需要那么多次松弛,只有上一次被松弛的结点,所连接的边,才有可能引起下一次的松弛。
那么我们就用队列维护<那些结点可能会引起松弛>,就可以至访问必要的边了。
优化以后时间复杂度会是k*m,k为常数且很小。
代码如下:
#include <iostream> #include <bits/stdc++.h> using namespace std; struct E { int v,w; }; const int maxn=2e4+10,inf=1e9+7; vector <E> edge[maxn]; int in[maxn],dis[maxn]; void SPFA(int s,int n) { for (int i=1;i<=n;i++) dis[i]=inf; dis[s]=0; queue <int> Q; Q.push(s); in[s]=1; while(!Q.empty()) { int cur=Q.front(); Q.pop(); in[cur]=0; int num=edge[cur].size(); for (int i=0; i<num; i++) { int v=edge[cur][i].v,w=edge[cur][i].w; if (dis[v]>dis[cur]+w) { dis[v]=dis[cur]+w; if (!in[v]) Q.push(v),in[v]=1; } } } } int main() { int n,m; scanf("%d%d",&n,&m); for (int i=1; i<=m; i++) { int u,v,w; scanf("%d%d%d",&u,&v,&w); edge[u].push_back({v,w}); } SPFA(1,n); for (int i=2;i<=n;i++) { printf("%d\n",dis[i]); } return 0; }
6.小结
bellman-ford算法比较好写,但是时间复杂度不够好,即使是优化过的SPFA算法,也很容易被精心设计的稠密图给卡掉,毕竟理论上界还是n*m。
但是对于负权边或者判断负环,我们就必须使用bellman*ford算法了。
7.扩展
关于玄学复杂度的玄学优化
SLF优化
SLF叫做Small Label First 策略。
比较当前点和队首元素,如果小于队首,则插入队首,否则加入队尾。
具体为啥可以优化,其实也是玄学,甚至对于有的数据,优化还会变慢。。。其实SPFA被卡的话,我觉得优化也没有意义的,所以只能算是锦上添花吧,不是很必要掌握(万一哪天水过了呢)
代码如下:
#include <iostream> #include <bits/stdc++.h> using namespace std; const int maxn=5000010; const int inf=1e9+7; struct E { int v,w; }; vector <E> edge[maxn]; int in[maxn],dis[maxn]; int SPFA(int s,int e,int n) { for (int i=1; i<=n; i++) dis[i]=inf; dis[s]=0; deque <int> Q; Q.push_back(s); in[s]=1; while(!Q.empty()) { int cur=Q.front(); Q.pop_front(); in[cur]=0; int num=edge[cur].size(); for (int i=0; i<num;i++) { int v=edge[cur][i].v,w=edge[cur][i].w; if (dis[v]>dis[cur]+w) { dis[v]=dis[cur]+w; if (!in[v]) { if (dis[v]<=dis[cur]) Q.push_front(v); else Q.push_back(v); in[v]=1; } } } } return dis[e]; } int main() { int n,m,t; scanf("%d%d%d",&n,&m,&t); for (int i=1; i<=m; i++) { int u,v,w; scanf("%d%d%d",&u,&v,&w); edge[u].push_back({v,w}); edge[v].push_back({u,w}); } int ans=SPFA(1,t,n); printf("%d\n",ans); return 0; }
三。Floyed算法(弗洛伊德算法)
1.定义概览
一种可以求出任意两点之间最短路的算法,支持正负权,可以实现传递闭包,时间复杂度N^3
2.算法描述
1)算法思想原理:
Floyd算法是一个经典的动态规划算法。用通俗的语言来描述的话,首先我们的目标是寻找从点i到点j的最短路径。从动态规划的角度看问题,我们需要为这个目标重新做一个诠释(这个诠释正是动态规划最富创造力的精华所在)
从任意节点i到任意节点j的最短路径不外乎2种可能,1是直接从i到j,2是从i经过若干个节点k到j。所以,我们假设Dis(i,j)为节点u到节点v的最短路径的距离,对于每一个节点k,我们检查Dis(i,k) + Dis(k,j) < Dis(i,j)是否成立,如果成立,证明从i到k再到j的路径比i直接到j的路径短,我们便设置Dis(i,j) = Dis(i,k) + Dis(k,j),这样一来,当我们遍历完所有节点k,Dis(i,j)中记录的便是i到j的最短路径的距离。
2).算法描述:
a.从任意一条单边路径开始。所有两点之间的距离是边的权,如果两点之间没有边相连,则权为无穷大。
b.对于每一对顶点 u 和 v,看看是否存在一个顶点 w 使得从 u 到 w 再到 v 比己知的路径更短。如果是更新它。
3.代码实现
memset(dis,0x3f,sizeof(dis)); for (int i=1; i<=n; i++) dis[i][i]=0; for (int k=1; k<=n; k++) for (int i=1; i<=n; i++) for (int j=1; j<=n; j++) dis[i][j]=min(dis[i][j],dis[i][k]+dis[k][j]);
4.扩展
对于有向图,有时我们只关心两点之间是否有通路,可以用0/1表示。然后吧循环语句改成 a[i][j]|=(a[i][k]&&a[k][j]);就可以啦
例题和代码:http://acm.zju.edu.cn/onlinejudge/showProblem.do?problemCode=4124
#include <iostream> #include <bits/stdc++.h> using namespace std; const int maxn=105; int a[maxn][maxn],ma[maxn],mi[maxn]; int main() { int T; cin>>T; while(T--) { memset(a,0,sizeof(a)); memset(ma,0,sizeof(ma)); memset(mi,0,sizeof(mi)); int n,m,flag=0; scanf("%d %d",&n,&m); for (int i=1; i<=m; i++) { int u,v; scanf("%d %d",&u,&v); if (u==v) flag=1; a[u][v]=1; } for (int k=1; k<=n; k++) { for (int i=1; i<=n; i++) { for (int j=1; j<=n; j++) { a[i][j]=a[i][j]||(a[i][k]&&a[k][j]); } } } for (int i=1; i<=n; i++) { for (int j=1; j<=n; j++) { if (a[i][j]&&a[j][i]) { flag=1; break; } } if (flag) break; } if (flag) { for (int i=1; i<=n; i++) putchar(48); putchar(10); continue ; } for (int i=1; i<=n;i++) { for (int j=1; j<=n; j++) { if (a[i][j]) ma[i]++,mi[j]++; } } for (int i=1; i<=n; i++) { if (ma[i]<=n/2 && mi[i]<=n/2) putchar(49); else putchar(48); } putchar(10); } return 0; }
四。总结
最短路算法是图论里最基础的算法,根据不同的情况,我们要有合适的选择。
对于负权边和判断负环,一般用SPFA;
对于需要输出路径的,一般用dijstra;
对于判断联通的,一般用floyd;
当题目没有特别强调有负权边时,一般应该选择dijstra,因为spfa很容易被人卡时间。除非数据很水。