只是个菜鸡,请各位大佬,嘴下留情,不要喷我,没什么气度,会拉黑人
这篇文章是关于图计算中单源最短路径问题,认识我的人朋友可能应该都知道我不是特别爱关注算法相关的问题,尤其是关于了解具体某一个算法的实现过程。作为一个数学不是特别好的菜鸡,我总觉得对于这种类似的问题就应该是不求甚解,点到即止,我又不是理论科学家或是研究人员,结果直接拿来用就完了。对于之前的我来说,一个成熟的算法就像是一个黑匣子。我给数据,它出结果,实现过程什么的完全不在乎。
但是近段时间,在跟一些同学在微信群里聊天(我其实是在潜水),会出现知识断层。(后来也去查过这些我不知道的东西,发现跟我完全没有什么关系,实用主义又开始作祟)即便是这样,我也想本着提升自身的目的,还是要再下点功夫,从理论层面看看常用常见算法实现过程,不求能融会贯通,至少能一步步复现。这次援引的材料较多,主要的出版书籍有三本,分别是:《算法图解》、《算法》(特别厚的那本橙书)以及《算法导论》。另外还有很多的网络资料,在文末贴出,我就一个Lego小人,站在Transformers的肩膀上前行。关于《算法导论》的看法。真的是一本讲的比较通透的书,推荐可以买来看看。至于《算法》,我表示没有什么想法,实现语言是Java,虽说算法都是通用的,但是近段时间用pyhton用的太久,不是特别想换Java,而且以后说不定没机会用Java。
0 1Dijkstra Algorithm关于使用Dijkstra的条件背景,一般来说在一个图的数据类型中我们经常想去寻求的是一个最短路径。在没有权值(weight)的情况下,从一个节点(node)到另一个节点的开销就是1。从start节点到end节点,中间经历的线段数目就是从start节点到end节点的开销。但是,一般情况下图不可能没有权值的概念,从点A到点B是会有花费的,比如抽象成一个地图,从一个城市到另一个城市,所花费可能就是时间,或者路费。又比如抽象成一个交易图谱,先用物品A换取物品B,可能是等价交换,这个时候的花费是0,但更有可能是不等价交换,还需要加上一点钱的开销花费。如何用最少的时间到达目的地,如何用最少的钱换取到想要的物品,就是Dijkstra算法的作用。Dijkstra算法解决的是带权重的有向图上的单源最短路径问题,并且要求所有的权重都为非负值。
(图片来源:《算法导论》)
这里先大致叙述下算法实现流程:
››››首先用散列表graph(在不同的语言中,散列表的实现形式不同,例如在python中散列表以字典的形式呈现)来记录这张图的数据。
››››用第二个散列表costs,来表示当前的节点到表中的可触及节点(当前节点的邻居节点)及花费,以及不可触及节点及花费(不可触及的节点花费即为∞)。这张表会随着算法的运行不断更新,更新的这个过程,会称之为“松弛”(后面会解释什么叫做“松弛”)。
››››用第三个散列表parents,来记录到达每个节点之前的前驱节点或者叫父节点,叫什么都好,主要就是指前一个节点。这张表是随着costs一起更新的。
››››每次计算当前花费最少的那个节点,算完之后,做上标记(例如放进一个列表中,表示已经计算过,Dijkstra算法对每个节点只算一次),避免算上环路(有环路一定不是最短路径)。
››››循环往复,直到所有节点全部算完。这样通过第三个parents散列表,就能够反推出整个图的从start节点到end节点的最短路径。
松弛操作
对于每个节点v来说,我们维持一个属性v.d,用来记录从源节点s到节点v的最短路径权重的上界。我们称v.d为s到v的最短路径估计。对一条边的(u,v)的松弛过程为:首先测试一下是否可以从对s到v的最短路径进行改善。测试的方法是,将从节点s到节点u之间的最短路径加上节点u与v之间的边权重,并与当前的s到v的最短路径估计进行比较,如果前者更小,则对v.d和v.π进行更新。
《Introduction to Algorithms》
其实“松弛”的操作,我觉得还挺好理解的。就像想象成从任意节点开始,我们要到另一个节点,但是我们还不知道抵达另一个节点的花费是多少,所以我们会预估一个花费出来,这里默认预估的是∞。那么,一旦我们发现有个有比∞更小的花费,那么我们就会更新costs和parents。就好像一根弦,我原来以为目标点很远,所以拉的很长,结果发现不需要那么远,于是就把这根弦给缩短了,这不就是“松弛”了吗?这种感性的理解方式,可能显得不那么学术,但是有用。
我来做第一个字典,即graph的字典,它应该是这个样子的。
起始节点 | 目标节点 | 花费 |
s | t | 10 |
y | 5 | |
t | y | 2 |
x | 1 | |
y | t | 3 |
x | 9 | |
z | 2 | |
x | z | 4 |
z | s | 7 |
x | 6 |
costs和parents的字典比较简单。costs左边是每个节点(除了start节点),右边一栏是到该节点的总共花费,意味着需要将之前经过的节点的花费都算上。parents实际上,就是最后生成路径的一个依据,字典左栏是每个节点,右栏是抵达该节点的前一个节点(父节点,前驱节点)。
上代码
graph = {}
graph["s"] = {}
graph["s"]["t"] = 10
graph["s"]["y"] = 5
graph["t"] = {}
graph["t"]["y"] = 2
graph["t"]["x"] = 1
graph["y"] = {}
graph["y"]["t"] = 3
graph["y"]["x"] = 9
graph["y"]["z"] = 2
graph["x"] = {}
graph["x"]["z"] = 4
graph["z"] = {}
graph["z"]["x"] = 6
graph["t"]["s"] = 7
infinity = float("infinity")
costs = {}
costs["s"] = 0
costs["t"] = 10
costs["y"] = 5
costs["x"] = infinity
costs["z"] = infinity
parents = {}
parents["s"] = "s"
parents["t"] = "s"
parents["y"] = "s"
parents["x"] = None
parents["z"] = None
processed = []
def find_lowest_cost_node(costs):
lowest_cost = float("inf")
lowest_cost_node = None
# 遍历所有的节点
for node in costs:
cost = costs[node]
# 如果当前的节点的开销更低,且未处理过
if cost < lowest_cost and node not in processed:
# 就将其视为开销最低的节点
lowest_cost = cost
lowest_cost_node = node
return lowest_cost_node
# 未处理的节点中找出开销最小的节点
node = find_lowest_cost_node(costs)
# 这个while循环在所有节点都被处理过后结束
while node is not None:
cost = costs[node]
neighbors = graph[node]
# 遍历当前节点的所有邻居
for n in neighbors.keys():
new_cost = cost + neighbors[n]
# 如果经当单前节点前往该邻居更近
if costs[n] > new_cost:
# 就更新该邻居的开销
costs[n] = new_cost
# 同时将该邻居的父节点设置为当前节点
parents[n] = node
# 将当前节点标记为处理过
processed.append(node)
# 找出接下来要处理的节点并循环
node = find_lowest_cost_node(costs)
print(parents)print(costs)
上面脚本运行的结果就是输出parents和costs的字典内容,可以发现结果和上图完全一样。
{'s': 's', 't': 'y', 'y': 's', 'x': 't', 'z': 'y'}{'s': 0, 't': 8, 'y': 5, 'x': 9, 'z': 7}
那么根据这个字典,就可以逆推出最短路径。比如,end节点如果是z,那么就要先到y节点,要到y节点,就要先到s节点,s就是起始节点,逆推完成。
0 2Bellman-Ford AlgorithmBellman-Ford的使用背景是一般情况下的单源路径问题,并且这里的图可以包含负权重。
在任意含有V个顶点的加权有向图中给定起点s,从s无法到达任何负权重环,以下算法能够解决其中的单点最短路径问题:将distTo[s]初始化为0,其他distTo[]元素初始化为无穷大。以任意顺序放松有向图的所有边,重复V轮。Bellman-Ford算法所需的时间和EV成正比,空间和V成正比
《Algorithm(4 Edition)》
简单阐述一下Bellman-Ford算法的过程:
››››首先生成一个散列表costs,键为每个顶点,值为权值,因为一开始不知道究竟需要多少权值,所以默认为∞
››››再生成一个散列表parents,键为每个顶点,值为该节点前驱节点,默认值均设置为None
››››做循环,循环的次数依图的顶点数而定,N个节点便循环N-1次,每次遍历所有的节点,并且做松弛操作,将结果更新到两个散列表中
››››N-1次循环完毕后,再做一次,如果发现依然能够进行松弛操作,说明图中有权值为负的环路
python实现代码如下,这里有个小问题,Alan还是把问题想复杂了,本来打算按照自己的想法来实现Bellman-Ford,最后把自己绕晕了。在CSDN上看到了作者popoffpopoff的文章,读完之后醍醐灌顶,稍作修改,略加注释,贴在下面。
graph = {
"a":{"b":-1, "c":4},
"b":{"c":3, "d":2, "e":2},
"c":{},
"d":{"b":1, "c":5},
"e":{"d":-3}
}
def Bellman_Ford(graph, sourceNode):
# 两个散列表,分别为costs和parents
costs = {}
parents = {}
infinity = float("inf")
# 初始化每个顶点的权值初始值设为∞,前驱节点设置为None
for v in graph:
costs[v] = infinity
parents[v] = None
# 起始节点的权值或者说花费置0
costs[sourceNode] = 0
# 一共有N个节点便循环N-1次,每次循环所有的节点,并做松弛操作
for i in range(1, len(graph)):
for u in graph:
for v in graph[u]:
if costs[v] > graph[u][v] + costs[u]:
costs[v] = graph[u][v] + costs[u]
parents[v] = u
# 全部做完之后,再做一遍松弛
for u in graph:
for v in graph[u]:
# 如果还是能够实现松弛,说明图中有负权值环路
if costs[v] > costs[u] + graph[u][v]:
return None, None
return costs, parents
costs, parents = Bellman_Ford(graph, 'a')
print(costs)
print(parents)
这里的输出结果如下所示:
{'a': 0, 'b': -1, 'c': 2, 'd': -2, 'e': 1}{'a': None, 'b': 'a', 'c': 'b', 'd': 'e', 'e': 'b'}
依旧是根据parents字典能够反推出最小路径。到这里可以开始做总结了:
Dijkstra算法和Bellman-Ford算法的差别主要在于前者对于图中的权值有一定的要求,即不能包含负的权值边线。而Bellman-Ford虽然能够允许图中包含负的权值,但是运行的时间显然要比前者要慢上不少。可以预见的到,实际应用中,应该较少的情况会用Bellman-Ford算法。
Dijkstra对每个节点都只算一次,而Bellman-Ford会重复计算,这是两者在运行时间上产生差距的重要原因。
[援引资料]
1. 《算法图解》Aditya Bhargava 2017年3月第1版
2. 《算法(第4版)》Robert Sedgewick、Kevin Wayne 2012年10月第1版
3. 《算法导论(第3版)》Thomas H. Cormen、Charles E. Leiserson、Ronald L. Rivest、Clifford Stein 2013年1月第1版
4. https://blog.csdn.net/popoffpopoff/article/details/81940372
5. https://juejin.im/post/5b77fec1e51d4538cf53be68
6. https://www.cnblogs.com/gaochundong/p/bellman_ford_algorithm.html