关于最短路的随笔
今天做了一个最短路的练习,前面几道都还比较水,最后一道不久以前做过,而且还纠结过很长一段时间,方法记下了,所以做出来了。可是回头看看自己的代码,发现似乎全部都是照搬的白书的代码。想要重新看看白书加深一下了解,却发现,有关最短路的好多东西都还没有了解过,比如说图的邻接表的使用以及优先队列的优化都还不曾了解。再往前一看,却发现最小生成树的方法居然也不记得了。所以又重新看了看书,加深一下了解,下面把有关最短路的问题先简单整理一下,待以后慢慢添加。
首先是最小生成树,他指的是权值最小的没有环的图。而解最小生成树就有一个最经典的方法,那就是Kruskal。下面是伪代码
先将所有的边按照权值的从小到大排序 首先树为空 初始化连通分量,让每个节点自成一个独立的连通分量 for(对于每一条边e) { 如果e的左右端点不在同一个连通分量 { 边e加入到树中 合并边e的左右顶点 } }
上面的方法求出来的树就是要求的最小生成树。由于for循环里面是按照顺序拿出的每条边,而边又是按照从小到大的顺序排序了的,所以加起来一定是权值最小的。但是个人感觉上面代码写的相当抽象,什么叫一个连通分量,怎么找两个点在不在同一个连通分量,又怎么合并???
这里就用到了并查集的知识。正好并查集就是对集合的操作,而这个集合正好就可以表示上面的连通分量的概念,至于查找和连通正好又是并查集的最基本的操作。(所以说感觉好像只要是用Kruskal就一定得用并查集一样)。不多说,
并查集的查找是否在同一集合
int find(int x) {return x == p[x] ? x : p[x] = find(p[x]);}
合并(x,y):
int a = find(x);
int b = find(y);
p[a] = b;
到这里,最小生成树问题就顺利解决了。
然后是一般的最短路问题,首先回顾一下最简单的flyod,它的思想就是暴力枚举a到b的最短距离肯定可以划分为a到{......}再到b的最短距离的和(当然集合也可以为空),这个随便就可以想明白的。当然他的缺点和优点也是显而易见的、
代码:
void flyod() { for(int k=0;k<N;k++) { for(int i=0;i<N;i++) { for(int j=0;j<N;j++) { if(d[i][j] > d[i][k] + d[k][j]) { d[i][j] = d[i][k] + d[k][j]; } } } } }
然后看一看Dijkstra。它是求单源最短路最唱使用的方法之一。它的思想就是然每一步都是走的当前最短的距离。先看一图:点击此处
从图中可以看到每一步都是找到的路径最短的路所走的。先看代码:
1 void dijkstra(int s) 2 { 3 for(int i=0;i<=N;i++) d[i] = INF; 4 d[s] = 0; 5 for(int i=0;i<N;i++) 6 { 7 int m = INF; 8 for(int j = 0;j<N;j++)if(!vis[j] && d[j]<m)m=d[s=j]; 9 vis[s] = 1; 10 for(int j=0;j<N;j++)if(d[j] > d[s]+Map[s][j])d[j]=d[s]+Map[s][j]; 11 } 12 }
可以看到代码中首先将所有的d值赋值为无穷大,只有起点(源点)是0,这也就保证了下面的for循环中第一个找的的满足d[j]<m的点一定是起点,然后就从它求出它到其他所有点的最小权值。即上面的Map[a][b]表示边a到b的权值(Map[a][b]=INF相当于a到b的边不存在)。
在下一次for(i=1)时,又首先找到一个d值最小的点(也就是到起点s最近的点),再求一次,又更新最短距离,这样的话每次都是从选择的从起点出发的新的最小权值的点,因此也就求出来了从起点到其他所有点的最短路径。又因为每一个外部循环,都会有一个顶点被标记,所以外部循环就至少N次,也只需要N次就够了(继续循环没有意义了)。
然后就看了一下邻接表的优化。
首先明确的就是邻接表只对稀疏图(也就是边的数目远远小于顶点的数目)作用比较明显,因为这时就可以不用管那些不存在的边,我觉的我还是的好好学学邻接表的使用,除了下了一两道hash题目外,好像再也没有用过邻接表了。它的复杂度就由O(n^2),可以减少到O(mlogn),m是边的数目。
1 int n,m; 2 int first[MAXN],next[MAXM],u[MAXM],v[MAXM],w[MAXM]; 3 void read_graph() 4 { 5 scanf("%d%d", &n, &m); 6 for(int i=0;i<n;i++) first[i] = -1; 7 for(int e=0;e<m;e++) 8 { 9 scanf("%d%d%d", &u[e],&v[e],&w[e]); 10 next[e] = first[u[e]]; 11 first[u[e]] = e; 12 } 13 }
既然上面使用了邻接表来存边,那么要如何实现mlogn的算法呢,这里就再讲讲优先队列的实现。
简而言之,优先队列就是存放在队列里的元素不是按照他们的存进顺序排列的,而是按照我们自定义的元素优先级的大小排列的,优先级大的元素会被首先取出来。
这样的话,那我们就可以在存放每一条边的时候,按照他们每一个点的d[]值作为优先级比较放进队列中,这样的话每次取出d值最小的点,也就相当于上面dijkstra代码里面的
for(int j = 0;j<N;j++)if(!vis[j] && d[j]<m)m=d[s=j];
这一行语句,所以也就不用每次都循环n次来查找了。但是有个问题就是如果仅仅是将d值放进优先队列的话,在取出来,我们也不会知道它是属于哪一个顶点的值。
所以这里就又新添加了一个STL的东西,叫做pair,用它便可以将两个值捆绑在一起,在取出一个元素的时候,也就把它的d值和顶点编号一并取了出来(当然用结构体也是相当方便的)。
看代码:
1 struct cmp//定义优先队列的优先级比较 2 { 3 bool operator() (Pair a, Pair b) 4 { 5 return a.first < b.first; 6 } 7 }; 8 9 bool done[MAXN]; 10 typedef pair<int, int> Pair;//用于捆绑d值和序号顶点序号 11 void dijkstra(int s) 12 { 13 mem(done); 14 for(int i=0;i<=N;i++) d[i] = INF;//初始时将suoyoud值设置为+∞ 15 priority_queue<Pair, vector<Pair>, cmp>q;//定义一个优先队列 16 q.push(make_pair(d[s]=0, s));//将起点放入队列中,且只有起点的d值为0 17 while(!q.empty())//依次从优先队列中取出优先级最大的元素(也就是d值最小的点)直到为空 18 { 19 Pair top = q.top(); q.pop(); 20 int x = top.second; 21 if(done[x]) continue;//如果此顶点已经算过了,不在讨论 22 done[x] = true; 23 for(int e = first[x]; e != -1; e = next[e])//枚举此个顶点的所有边 24 { 25 if(d[v[e]] > d[x] + w[e]) 26 { 27 d[v[e]] = d[x] + w[e]; 28 q.push(make_pair(d[v[e]], v[e]));//将新的d值变小的点放进优先队列 29 } 30 } 31 } 32 }
Bellman-Ford算法:
由于之前的算法都是针对于只含有正权的边的最短路,如果存在负权,那就该使用Bellman-Ford了。首先需要明确的是,如果存在负权的话,有可能最短路都会不存在(如果n个点形成了一个负权回路的话,那么每一个点再绕一个环回来后那么“最短路”又会缩小),所以Bellman-Ford就给我们提供了一个判断是否存在负权回路的方法。时间复杂度O(nm)
见代码:
1 bool Bellman_Ford(int s)//判断是否存在最短路,如果存在,则d值保留起点s的单源最短路 2 { 3 for(int i = 1; i <= N; i ++) d[i] = INF; 4 d[s] = 0; 5 for(int i=1;i<N;i++)//由于由起点出发只需要N-1次就可以确定起点到其他所有点的最短路 6 { 7 for(int e = 0;e < M; e ++) //枚举每条边 8 { 9 int x = u[e], y = v[e]; 10 if(d[x] < INF) d[y] = MIN(d[y], d[x] + w[e]);//松弛 11 } 12 } 13 for(int e = 0; e < M; e ++) if(d[u[e]] < INF)//再一次枚举所有边 14 { 15 int x = u[e], y = v[e]; 16 if(d[y] > d[x] + w[e]) return false;//如果还有顶点可以松弛,存在负权回路,不存在最短路 17 } 18 return true; 19 }
有了上面dijkstra的思路,我们不难理解他的正确性,这里边不给出解释。
同样,Bellman-Ford也可以用队列来优化,由于不再需要像dijkstra一样每次取出d值最小的顶点,所以我们也就不需要使用优先队列,而使用一般的队列便可以实现,下面是白书上的一段代码:
1 queue<int>q; 2 bool inq[MAXN]; 3 for(int i = 0; i < n; i ++) d[i] = (i == s ? 0 : INF); 4 memset(inq, 0, sizeof(inq)); // “在队列中”的标志 5 q.push(s); 6 while(!q.empty()) 7 { 8 int x = q.front(); q.pop(); 9 inq[x] = false; // 清除“在队列中”的标志 10 for(int e = first[x]; e != -1; e = next[e]) if(d[v[e]] > d[x] + w[e]) 11 { 12 d[v[e]] = d[x] +w[e]; 13 if(!inq[d[e]]) // 如果已经在队列中,就不要重复添加了 14 { 15 inq[v[e]] = true; 16 q.push(v[e]); 17 } 18 } 19 }
我想如果明白了邻接表的使用和Bellman-Ford的思想,理解上面的代码应该问题就不大了。
copy的题目链接,慢慢刷