费用流入门学习初步小结
0 前言
5月份开始任务宛如洪水,写了两篇周小结感觉毫无干货,于是直接熄火备战期中考,然后是期末考、project等。现在可以慢慢腾出一定的时间,稍微总结一下学期的内容。
《算法分析与设计》课程让我重温了以前的一些知识点,学的一塌糊涂( ̄▽ ̄)"。在期末学习费用流时,因为用到了SPFA,所以想一起把最短路的几个点也梳理一下。大二的时候想梳理一遍最短路,那时用word码字,效率低,写了一点懒得写了( ̄▽ ̄)"。趁这个机会,打算按照【最短路总结】、【网络流和费用流】的思维顺序,进行丢丢个人理解的梳理。
1 最短路总结
这部分内容,在草稿箱里从17年8月躺倒现在,着实离谱。实在是太懒了…( ̄▽ ̄)"
学习最短路算法,一个很基础的概念是”松弛“。然后呢,去理解 Dijkstra、Floyd、Bellman-ford算法的关键思想,然后再了解两个算法优化版本”优先队列优化的Dijkstra“以及”SPFA“即可。在下文内容中,我个人习惯是,起点是 s s s,终点是 g g g.
1.1 松弛
先抛出一个概念,什么是”松弛“?—— 两个点 s s s和 g g g 可以通过第三个点 k k k 作为中转点,来优化、缩短它们之间的距离!如下图所示:
上图就很明显:
s
s
s 到
g
g
g 直走的距离是999,但是通过
k
k
k 进行中转的话,距离就会变成2,明显更短了。所以说:
s
s
s 和
g
g
g 可以通过
k
k
k 进行松弛操作。而我们“寻找最短路”的过程,实际上就是起点到终点的path,不断被松弛的过程。最短路算法就是变着花样进行松弛。
而松弛过程的代码形式就是:
if( mp[s][g] > mp[s][k]+mp[k][g] ) mp[s][g]= mp[s][k]+mp[k][g];
很直观,很好理解。
1.2 普普通通的Dijkstra
Dijkstra算法很常用了,它是 “单源” 最短路算法,采用贪心的思想。即只计算出一个起点到其他端点的最短路信息。只要是单源最短路算法,我们就只需要一维矩阵来保存当前的最短路计算的结果,例: d i s [ i ] dis[i] dis[i] 表示起点 s s s 到节点 i i i 的最短距离值是多少。 也因为是单源最短路算法,我们只需要去关心”源点“到别的点之间的距离是否可以被松弛,任意两个非源点的距离是否被松弛并不care~
-
Dijkstra的思想是什么呢?
- 贪心的依据: 对于当前的起点 s s s,它如果有好多个直接相邻的邻接点,那么 离他最近的那个邻接点 k k k,肯定是目前、也是未来不需要被松弛、未来也没办法松弛的最近的节点! 明白这个事实以后,我们只需要贪心的选这个节点作为中转点来尝试优化其他所有节点就能一定程度上优化其他的节点了!
- 注意:Dijkstra用于松弛的中转点的选择,和后续其他算法是不同的!其最大的特点是,每个节点只会被选中一次,也必须被选中一次;被选中一次后,松弛其他所有点;而它们被选中当中转点的时候,它们必然能保证“从那时刻开始自己已经是最优了”而只需要去帮助、松弛别人而已了。
- 因此,总体Dijkstra的流程就是: 每一次都贪心地枚举 当前状况下离起点 s s s 最近的点 k k k ⇒ \Rightarrow ⇒ 然后尝试其他的点能否通过此点 k k k 进行“松弛”操作 ⇒ \Rightarrow ⇒ 继续选取下一个 k k k.
-
时间复杂度分析:
- 根据上面的思路,在有
N
N
N 个节点的图中,要计算出起点
s
s
s 到其他各点的最短路,需要
N
−
1
N-1
N−1 个节点都完成松弛的中转点的角色(除去起点,每个点都要当一次中转点来松弛其他人。我个人喜欢把起点的初始化也算进去,那就是
N
N
N轮)。——算法执行
N
−
1
N-1
N−1 轮。
- 每次在寻找中转点的时候,基于上面讲述的“贪心的依据”,是寻找距离起点最近的点(肯定是没选过的才行),所以遍历一遍找最近。——贪心遍历找一个中转点的时间是 O ( N ) O(N) O(N).
- 用中转点,对其他 N − 1 N-1 N−1个节点进行松弛。——松弛一波是 O ( N ) O(N) O(N).
- 所以复杂度是 O ( N 2 ) O(N^2) O(N2).
- 根据上面的思路,在有
N
N
N 个节点的图中,要计算出起点
s
s
s 到其他各点的最短路,需要
N
−
1
N-1
N−1 个节点都完成松弛的中转点的角色(除去起点,每个点都要当一次中转点来松弛其他人。我个人喜欢把起点的初始化也算进去,那就是
N
N
N轮)。——算法执行
N
−
1
N-1
N−1 轮。
-
Code: 普通Dijkstra的代码
1.3 优先队列(堆)优化的Dijkstra
上述讨论了Dijkstra算法的时间复杂度,如果我们想进一步提升Dijkstra算法的性能我们可以引入“优先队列(堆)” 来实现。其优化、以及实现的思路是:
-
优先队列优化的Dijkstra的思想是什么呢?
- 把所有节点都放入到堆里,每次从堆顶取距离最小的节点,完成贪心的过程——从而贪心取一个中转点的时间是 O ( l o g N ) O(logN) O(logN) 虽然取堆顶是 O ( 1 ) O(1) O(1)但是还得更新堆顶…)
- 我们可以观察到一件事:不用对所有其他 N − 1 N-1 N−1个节点进行松弛操作,而是 仅需要对中转点邻接点进行松弛即可。 不邻接的点怎么可能松弛的了嘛——由于每个中转点对自己的邻接点进行松弛,那么 N − 1 N-1 N−1个中转点以后,就是遍历了两遍边的数量,即 O ( 2 M ) = O ( M ) O(2M)=O(M) O(2M)=O(M)的时间.
- 总共呢,中转点其实还是只有 N − 1 N-1 N−1个(算上初始化的起点那就是 N N N个)。每一次中转点都会将自己周围的邻接点及其距离源点的信息捆绑在一起然后一同加入到堆里面,因此 同一个节点会同时在堆中有好几个! 所以刚开始堆会很小,到了后来堆会变的很大,最多里面装了 2 M 2M 2M个值(边的数量)。
-
时间复杂度分析:
- 很多人对于“优先队列优化的Dijkstra”的时间复杂度写的不一样,这是为什么呢?我刚开始也认为优化以后的Dijkstra是 O ( N l o g N ) O(NlogN) O(NlogN),琢磨了一下发现不对,我的思考如下。
- 首先回顾一下,堆的特性是每次取堆顶是 O ( 1 ) O(1) O(1),但是pop掉堆顶以后更新下一个堆顶的过程是 O ( l o g N ) O(logN) O(logN),而每次往堆里增加新值堆都需要动态调整也是 O ( l o g N ) O(logN) O(logN)。注:此时在讨论堆的性质时,这里的 N N N表示的是堆内部的值的个数。
- 为什么网上有说优化以后的Dijkstra是 O ( N l o g N ) O(NlogN) O(NlogN)呢?是这么来的:Dijkstra要取 N − 1 N-1 N−1个中转点,每次从堆里拿一个中转点的时间是 O ( l o g N ) O(logN) O(logN),松弛邻接点的个数虽然不好说,但是所有中转点松弛的次数是 2 M 2M 2M,所以总时间是 O ( N l o g N + 2 M ) = O ( N l o g N ) O(NlogN+2M)=O(NlogN) O(NlogN+2M)=O(NlogN). 但是这个思路是没有考虑上述提到的堆的其他操作的时间复杂度的:堆会越来越大,到后来里面的节点个数有 2 M 2M 2M个,当然也不考虑这个问题,因为假设图是超级稠密图而 M = N × N M=N \times N M=N×N,此时堆取最大值然后更新的时间复杂度依然是 O ( l o g M ) = O ( l o g N 2 ) = O ( 2 l o g N ) = O ( l o g N ) O(logM) = O(log N^2) = O( 2logN ) = O(logN) O(logM)=O(logN2)=O(2logN)=O(logN),一般甚至都不是稠密图,所以写 l o g M logM logM和 l o g N logN logN 都没啥区别;但是要考虑的是,每一次松弛时将周围的邻接点加入到堆中,加一次就要时间 O ( l o g M ) O(logM) O(logM)的时间!整个优先队列优化的Dijkstra过程中,需要最多加入 2 M 2M 2M次邻接点, 所以真正的堆优化的Dijkstra的时间复杂度应该是: O ( N l o g M + M l o g M ) = O ( ( N + M ) l o g M ) = O ( ( N + M ) l o g N ) O(NlogM + MlogM) = O( (N+M)logM ) = O((N+M)logN ) O(NlogM+MlogM)=O((N+M)logM)=O((N+M)logN),)至于是 l o g N logN logN 还是 l o g M logM logM 我上面提到了,我觉得差不多,就可以写一样的~
-
Code: 优先队列优化的Dijkstra的代码
1.4 SPFA算法(含平凡的队列一个)
因为SPFA (Shortest Path Faster Algorithm)算法和堆优化的Dijkstra算法有一丢丢相像,以前我还一度混淆,但是搞清楚它们之间的区别之后,就能更快上手。所以看完上面的堆优化的Dijkstra以后,趁热打铁,就能理解SPFA了。刷简单题什么的,能用SPFA用SPFA,难题就用队列优化Dijkstra了。
-
SPFA算法依然是单源最短路算法。SPFA算法除了快,还有一个优点就是 能处理含有负边与负环的图! 但是Dijkstra算法就不能。
-
为什么Dijkstra不能跑“含负权的图”:因为Dijkstra从不走“回头路”,每次贪心完的节点不再后续优化松弛了,所以有些时候涉及到负边,跑出来的结果不是最优的。例如:三角形图,给出格式是(起点,终点,边权),那么(1,2,1),(1,3,2),(2,3,-3),的1到2的最优解是-1,但是Dijkstra跑出来是1~
-
SPFA的思想是什么呢?
-
在谈论SPFA的思想之前, 先要明确Dijkstra的几个关键的特征:①贪心选取最近的点作为中转点;②中转点被选中的时候,其必然是最优状态! ③由②可知,每个节点有且只有一次成为中转点。如果说中转点”松弛“别的点过程像是在帮助别人的话,那么Dijkstra每次选的都是最优秀的人去帮助别人。
-
SPFA的思想是任何人都可以帮助”别人“,即使你不是最优秀的。 SPFA准备了一个 队列 ,里面装的是有可能起到松弛作用的节点。每一次从队列里取出一个节点,尝试对其邻接点进行松弛(为什么是邻接点而不是所有 N − 1 N-1 N−1个点,道理是和队列优化的Dijkstra一样的,因为非邻接的松弛不到呀( ̄▽ ̄)").关键的地方来了:若某邻接点 k k k被松弛了,说明起点到它的距离被优化了!那说明这个 k k k的某些邻接点也可能通过它被松弛呀!就把这个 k k k加入队列中,待会松弛它的邻居!!!正是因为每个人都可以松弛自己周围的人,所以我们不需要去找最优秀的人来专门做中转点进行松弛。这个SPFA会一直找一直找,直到没有任何人能够继续松弛周围的人了,所有人都被榨干了,那么队列肯定就空了,算法就停了!
-
因为每个人都可以去松弛别人,所以 SPFA中可以出现多条边松弛了一个节点,但是同一时间队列中同一个点只能被压入一次! 毕竟同一个节点在一个队列中出现了好几次也只能表示同一个意思:它想帮助别人。所以SPFA中的book是判断一个点是否在队列里,不需要重复进队,而出队了就把它的标记复位,以后上级被人松弛了依然可以再次进队的。
-
再次和堆优化的DIjkstra算法进行对比:
- ①优先队列优化的Dijkstra用的是”优先队列“,实际上是 ”堆“ ;而SPFA用的是真 ∙ \bullet ∙平凡的队列一枚。
- ②堆优化的Dijkstra压入的是节点ID以及当前与源点的距离,每个节点可以被压入多次,但是其与源点的距离值会变(同一个节点肯定是减少);而SPFA压入的仅仅是节点ID,每个节点也可以被压入多次。
- ③堆优化的Dijkstra的
book[i]
的作用是贪心的时候,判断哪些节点当过中转点了,为了防止重复选择它当中转点;而SPFA的book[i]
的作用是判断谁当前在队列里。 - ④堆优化的Dijkstra再怎么样,依然是贪心思想;而SPFA这种思想,是动规DP。
-
-
为什么SPFA算法能检测负环? 一张图里有 N N N个节点,我是结点 k k k,那如果要松弛我,肯定是别的 N − 1 N-1 N−1个节点来松弛我。如果我被松弛了,那我就要进队。我不可能被同一个人重复松弛的! 所以,任意节点(我)被松弛的次数和我进队的次数都不可能超过其他节点的个数也就是 N − 1 N-1 N−1,因此如果出现任何一个节点入队的次数是 ≥ N \geq N ≥N,那说明图中肯定是有负环的。 因为负环会让最短路越走越小,算法会爱上走负环,同时SPFA算法也永远不会结束,是个死循环~
-
时间复杂度分析:
- 虽然SPFA的最坏时间复杂度很高,是 O ( N × M ) O(N \times M) O(N×M),但是平均运行的性能很好,类似快排——最坏时间复杂度很高但是平均运行性能不错hh。如果出现卡时间的情况,就改用堆优化的Dijkstra即可。
-
Code: SPFA的代码
1.5 Floyd算法
上面都是难的了,Floyd算法从内容上就显得很简单。这是一个多源最短路算法,运行完以后能知道任意两个节点之间的最短路是多少,当然时间复杂度也是最高的。但是Floyd算法的理解上,我还没有完全理解,只是记住了~同时记录一下我个人常常陷入的错误Floyd理解,引以为戒。
-
Floyd的思想是什么呢?
-
我个人常常陷入的错误思想: 因为,每个节点作为一次中转点去松弛其他人一次,即,每个节点仅出现一次作用之后,最短路结果就能得到了,所以Floyd的第一层循环 k k k就是枚举每个人去当中转点,后两层循环就是让所有人松弛一波即可。但是,这是完全错误的!而仅仅是代码上看起来是这么回事而已!
-
首先说为什么是错误的。第一个观点就是错的,并不是“每个节点作为一次中转点去松弛其他人一次,最短路结果就能得到了”,如果这句话是对的,那么Dijkstra里去掉贪心的过程,而是任意选取一个节点作为中转点去松弛别人不就是这句话的思想了吗,难道Dijkstra的贪心过程是完全没有意义的吗?明白了概念上这个观点是错的以后,接下里可以举一个具体的例子来说明这个观点是错的:
在这张图中,源点是 s s s,如果我们选了 a a a 作为第一个中转点,那么源点到 c c c 的距离肯定不是最优解,因为最优解还需要通过节点 b b b 进行松弛才行。但是因为我们上面的观点是“每个节点作为一次中转点去松弛其他人一次,最短路结果就能得到了”,所以后续的松弛过程中, b b b 节点也仅仅会松弛 a a a 而不会松弛 c c c, 这样显然会导致结果不正确。
-
所以在求解最短路的过程中,“松弛”与求解最短路的联系是怎么样的呢?上面这句话要怎么修改才能正确呢?我认为,应该是:每个节点作为中转点去松弛其他人一次,并对其他人相关的邻接点、其邻接点的邻接点…(递归下去)都更新因此收到的松弛收益后,那么最短路结果就能得到了!从这个角度上来说,确实是每个节点仅出现一次作用,但是要对其他人完完全全的负责才算“一次作用”!基于这个正确的思想以后,看上图,当我们松弛节点 b b b 的时候, b b b虽然直接松弛了节点 a a a,但是节点 b b b 还需要对 a a a 的后代也就是 c c c 负责,所以要等 a a a 再次松弛一遍节点 c c c, 来将 b b b 的松弛收益贯彻下去才行!而这,就是SPFA的算法思想!
-
-
时间复杂度: 看代码,一看就懂, O ( N 3 ) O(N^3) O(N3).
-
Code:
for(k=1;k<=n;k++) for(i=1;i<=n;i++) for(j=1;j<=n;j++) if(e[i][j]>e[i][k]+e[k][j]) e[i][j]=e[i][k]+e[k][j];
2 网络流复习
-
基础知识:
-
普通FF算法:(是一个思想,不讲究到底使用什么算法来寻找增广路的path)
- C是m条边里面权值最大的值。
- 每次BFS要m,一共要nC次(如果是整数增广路至少为1,为什么是nC?因为最多情况n个节点都和汇点连接,且权值都为C),所以复杂度是 O ( m n C ) O(mnC) O(mnC).
-
加速FF算法,此处以EK算法为例子:(基于FF算法的思想,把寻找增广路的过程用BFS来实现,遍历图是O(m))
- ①n个点之间的中间节点不能包括起点和终点,故中间点数为n-2。
- ②(u,v))之间是关键边,下一次也是关键边时的起点到u的距离起码增加了2(我也不知道为什么)
- 这两点能推出(u,v)成为关键边的次数最多为(n-2)/2,而一共有m条边。
- 所以要BFS的次数是共mn次,EK复杂度为 O ( n m 2 ) O(nm^2) O(nm2).
-
-
网络流怎么建图?
-
我以前普通建图都是用邻接表:
vector<Edge> edge[inf];
,实现edge[i]
表示所有和节点 i i i出发的邻边。但这个形式在这里不行了,因为网络流要保存每次的path,需要记录”边的编号“,而如果采取我习惯的这个存储形式,就没法记录和后续“根据边ID返回修改”。 -
那应该怎么存储边信息呢?网上的存边的方法是——①每条边存下来
Edge edge[M];
然后 ②每条边的下标位置建立一个index表,是每个节点的第一条边ID:int pos_edge[M]
③每个边多一个next边的ID变量,形成链式结构,SPFA用的时候就用next去追踪下一条边,直至-1。④边的插入的编号是头插法,所以是倒着来的:edges[cnt].next = head[from]; head[from] = cnt;
-
是否要采纳这种存边的方式?如果按我自己来:用一个vector存每个点的邻接边ID——node[N];又用一个数组存每个边的参数——Edge edge[M];然后建立双向的索引,要俩对应的index数组,晕,算了太麻烦了,按网上的来吧。。。
-
Edge类型不用定义起点,因为SPFA中都是从起点直接遍历邻接点,所以存终点即可。
-
注意,每次建边的时候,要同时建立反向边。所以下标从0开始的话,因为都是成对建边,故 ^ 1 \verb!^! 1 ^1 的异或过程,就可以表示反向边的下标 。
-
3 费用流入门
-
不错的网络流模板:网络流模板参考
-
费用流的代码和普通最大流区别:
- 费用流的边每次采纳以后,spfa求出来的dis[g]代表的是整条path上的 单位流的费用合 ; 所以spfa结束后我们要根据path上最小的residule的量,来增加流、更新新费用: m i n R e s ∗ d i s [ g ] minRes*dis[g] minRes∗dis[g] ,而不是dis[g];而根据当前的的path,按照最大流的方法,我们把路径上所有path的边的剩余流量 r e s res res参数update掉 ,而 边的“单位费用流”参数完全不会修改的。 也就是说,费用流每次计算完的 d i s [ i ] dis[i] dis[i] 的意义是——从源点到达终点 i i i 的单位流的总费用!
- SPFA的path怎么存的?不是存每个节点的path父节点的id,因为这样在最后 很不方便去索引边的位置,去update对应的flow值。应该是,每个节点存对应的path上的边的id! 注意,边比点少一个,所以path上的一个点会没有值,写的时候要分清楚自己的顺序,可以存在终点上,源点就空出来不管它。在索引边的时候, i = p a t h [ g ] i=path[g] i=path[g], i i i 是边的id, g g g 是边的终点,那如何知道起点呢?(因为边的参数里只存了终点没存起点,当然也可以自己存进去)起点就是此条边反向边的终点,所以起点就是 s = e d g e [ i ^ 1 ] . t o s=edge[i \verb!^! 1].to s=edge[i^1].to
-
为什么最小费用最大流( Minimum Cost Maximum Flow, MCMF),使用的SPFA而不是Dijkstra? 因为费用流中的反向边是负权边,所以Dijkstra跑不了,只能用SPFA!当然,SPFA复杂度不稳定,所以那个时候只能用“修正版的Dijkstra”了。
-
费用流的实现在普通最大流的实现上,有一丝不同:
- 最大流找到一条path,路径上所有path的边都要进行修改边权。
- 费用流找到一条path,路径上所有path的边也要进行修改边权,但是在spfa的内部实现过程中是不需要修改边参数的,是只读的(要么完整的用这条边要么就不用)。只在SPFA结束以后才修改一次。
-
Code:
- 可以参考这篇博客的第二题写法,也是一道简单的费用流的例题,加深理解:【研一课程】算法设计与分析 解题报告 ——CSDN 仰天倀笑
- 另一个费用流的板子,写的很好: 网络流模板参考——知乎 Pecco