要从哈尔滨坐火车到厦门需要如何买票,这个问题曾经困扰着我。排除买不买得到票的问题,哈尔滨到厦门之间没有直达车,需要找一个城市来中转,如何更快的回家?图的相关算法就可以解决这个问题。
单源顶点最短路径
单源顶点最短路径,什么意思?就是已知一个起点,求图上任意点的最短路径。这和之前的广度优先算法的目的类似,只不过广度优先算法考虑的是最少的边数,而单源顶点最短路径需要的是路径,二者在无权的前提下是一致的,但是一旦包含权值,二者的结果可能不一致。当然这里我们限制一下前提,这里解决的是有向带权图的单源顶点最短路径。
使用优先队列的Dijkstra算法
解决这个问题最常用的算法就是Dijkstra算法。使用优先队列的Dijkstra算法利用一个优先队列PQ(Priority Queue)的数据结构来实现功能。PQ通过二叉堆来实现将最小元素至于前部的功能,可以很方便给出最小元素。
import heapq
import sys
def buildHeap(A, position):
for i in range(int(len(A) / 2) - 1, -1, -1):
heapify(A, i, position)
def heapify(A, index, position):
max = len(A)
min = index
left = 2 * index + 1
right = 2 * index + 2
if left < max and A[left] < A[index]:
min = left
if right < max and A[right] < A[min]:
min = right
if min != index:
A[index], A[min] = A[min],A[index]
position[A[index][1]] = index
position[A[min][1]] = min
heapify(A, min, position)
def up(A, index, position):
parent = (index - 1) // 2
if parent >= 0:
if A[index] < A[parent]:
A[index], A[parent] = A[parent], A[index]
position[A[index][1]] = index
position[A[parent][1]] = parent
up(A, parent, position)
def pop(A, position):
A[0], A[len(A)-1] = A[len(A)-1], A[0]
position[A[0][1]] = 0
position[A[len(A)-1][1]] = len(A)-1
result = A.pop()
position[result[1]] = -1
if len(A) > 0:
up(A, len(A)-1, position)
return result
def DijkstraPQ(G, s):
pred = [-1] * len(G)
dist = [sys.maxsize] * len(G)
dist[s] = 0
pq = []
position = []
i = 0
for d in dist:
pq.append((d, i))
position.append(i)
i = i + 1
buildHeap(pq, position)
while pq:
u = pop(pq, position)
if G[u[1]]:
for v in G[u[1]]:
w = G[u[1]][v]
newLen = dist[u[1]] + w
if newLen < dist[v]:
decreaseKey(pq, position, v, newLen)
dist[v] = newLen
pred[v] = u[1]
print(dist)
print(pred)
def decreaseKey(A, position, v, newLen):
A[position[v]] = (newLen, v)
up(A, position[v], position)
这里采用DijkstraPQ来实现。首先对前序顶点pred和距离dist进行初始化,在pq中存入距离d和顶点编号构成的元组,利用之前讨论过的堆排序中的二叉堆(这里是顶小堆)来实现优先队列pq的功能。这里只需要能够有优先队列的两个功能,分别是取出最小元素pop和降低优先度decreaseKey。
- pop:pop的目的在于取出pq中的最小元素(取出后pq不包含该元素),这里由于二叉堆的性质,最小元素就在pq[0]。由于python的list.pop()在无参数时是O(1),而无参数是pop出尾部元素,所以这里先将首位元素调换,让后利用up让尾部元素上浮到恰当的位置。这样保证pop后的pq还满足二叉堆的要求。
- decreaseKey:在我们找到比pq和dist中存储的距离更小的距离时需要将pq和dist中的距离更新。dist更新只需要重新赋值即可,而pq中距离的更新就比较麻烦,需要知道待更新距离的顶点在pq中的位置,还需要知道是否对二叉堆进行调整。对于顶点的位置,这里利用position来记录顶点的位置,如果顶点vk被pop取出就在相应的position[k]赋值-1,其余的顶点vi在pq中的位置直接存储在对应的position[i]中。在找到顶点位置以后对相应位置i的pq[position[i]]进行更新。我们知道需要更新的距离小于原距离,所以利用up让更新的元素上浮到恰当的位置。
- up比较好理解,就是将元素和父元素进行比较,小的在上,如果元素需要上浮就迭代调用up。
- buildHeap和heapify参考之前的搜索与排序,在堆排序部分的二叉堆基础上将顶大堆改为顶小堆,并引入position实时记录pq中的位置变化。
DijkstraPQ不断取出堆顶部的元组u,u[1]所代表的顶点由二叉堆的性质可知是与起点距离dist最小的。对u[1]的邻接顶点v分别计算与起点s的距离,采用u[1]的距离dist[u[1]]加u[1]与v的距离G[u[1]][v]作为可能的最小距离newLen与dist[v]比较。如果newLen更小就将pred[v]保存为u[1],保存dist[v],更新pq中的元组。Dijkstra算法就是这样不断用距离最小的顶点作为新的起点来不断更新dist,实现对全部顶点与起点最小距离的计算。
考虑算法的时间复杂度可以发现初始化阶段为O(vlog2v),因为需要建二叉堆。后面我们实际遍历了每条边,不论是pop还是up、decreaseKey,核心都是对二叉堆的调整,所以后面为O(elog2v)。合起来就是O((v+e)log2v)。其中v、e分别为顶点数和边数。
针对稠密图优化的Dijkstra算法
这里还有一种针对稠密图优化的Dijkstra算法。稠密图顾名思义就是边很多的图,具体而言可以说稠密图的边数e与顶点数的平方v2是同阶或以上的。
def DijkstraDense(G, s):
pred = [-1] * len(G)
dist = [sys.maxsize] * len(G)
dist[s] = 0
visited = [False] * len(G)
while True:
u = -1
sd = sys.maxsize
for i in range(len(G)):
if not visited[i] and dist[i] < sd:
sd = dist[i]
u = i
if u == -1:
print(dist)
print(pred)
return
visited[u] = True
if G[u]:
for v in G[u]:
newLen = dist[u] + G[u][v]
if newLen < dist[v]:
dist[v] = newLen
pred[v] = u
DijkstraDense多了一个visted来实现对已访问顶点的排除。通过寻找未被访问且距离最小的顶点u来实现之前优先队列的功能。时间复杂度也很好分析,while循环次数和顶点数v有关,而while内部每次都会对顶点进行遍历,此外实际上将while中所有有关于边的操作合并,恰好将边遍历,故为O(v2+e)。
这里需要交代的是,由于Dijkstra算法是一种贪心算法,所以在存在负权边时可能会出现问题。所以要求使用Dijkstra算法时不存在负权边。但是我们在实际的应用中会存在负权边的情况,这样就可以考虑使用Bellman-Ford算法了。
Bellman-Ford算法
Bellman-Ford算法可以在不存在负值环的情况下使用,事实上存在负值环本身就不存在最短路径。其原理就是不断的扫描整个图,记录与起点的最小距离,直到所有最短距离都获得,最短距离不更新为止。分析一种极端的情况,即某顶点需要由起点出发经由所有顶点才能到达,此时需要扫描len(G)-1次,说明至多len(G)-1次可以获得所有最短路径。此外,如果存在负值环,每次扫描都会有新的“最短”路径产生,所以在扫描len(G)-1次后若还存在新的最小路径,说明存在负值环。
def BellmanFord(G, s):
pred = [-1] * len(G)
dist = [sys.maxsize] * len(G)
dist[s] = 0
for i in range(len(G) + 1):
failUpdate = (i == len(G))
leaveEarly = True
for u in range(len(G)):
if G[u]:
for v in G[u]:
newLen = dist[u] + G[u][v]
if newLen < dist[v]:
if failUpdate:
print("Has negative cycle")
dist[v] = newLen
pred[v] = u
leaveEarly = False
if leaveEarly:
print(dist)
print(pred)
return
print(dist)
print(pred)
BellmanFord中有两个bool量failUpdate、leaveEarly ,分别用来检测是否有负值环和是否可以提前结束。负值环检测的原理前面说了。提前结束的原理是新的一轮遍历没有更新距离,说明目前的距离已经是最短距离了。其他的部分比较简单就不赘述了。
Bellman-Ford算法的时间复杂度也很好分析总共v次扫描,每次扫描都对图进行遍历,即每次扫描e个边,显然是O(v*e)。
小结
我们之前给出了三种不同算法的时间复杂度。前面还介绍了一个概念叫稠密图,这里假设e和v2同阶,代入复杂度分别对于DijkstraPQ、DijkstraDense、BellmanFord是O((v2+v)log2v)、O(2v2)、O(v3)。可以发现针对稠密图设计的DijkstraDense时间复杂度是最小的,BellmanFord已经不堪用了。针对一个足够稀疏的图,假设e和v同阶,代入复杂度分别对于DijkstraPQ、DijkstraDense、BellmanFord是O(2vlog2v)、O(v2+v)、O(v2)。此时使用优先队列的DijkstraPQ成为时间复杂度最小的了。当然,BellmanFord也不是一无是处,BellmanFord在存在负权边情况下是三个算法中唯一的选择。
所有点对最短路径
与寻找单源不同,通常我们要找任意两个顶点之间的最短路径。要解决这个问题有两种思路,一种是直接对所有顶点实施一次单源顶点最短路径;一种是利用动态规划。
这里不介绍第一种方法,因为第一种方法效率不高而且结合前面的例子很容易实现。这里介绍利用动态规划的Floyd-Warshall算法。该算法最终会给出一个n阶方阵dist,dist[i][j]就是图中顶点vi、vj之间的最短距离。当然这里也是以有向带权图为输入的。
def FloydWarshell(G):
dist = []
pred = []
for u in range(len(G)):
dist.append([])
pred.append([])
for v in range(len(G)):
dist[u].append(sys.maxsize)
pred[u].append(-1)
dist[u][u] = 0
if G[u]:
for v in G[u]:
dist[u][v] = G[u][v]
pred[u][v] = u
for k in range(len(G)):
for u in range(len(G)):
if dist[u][k] == sys.maxsize:
continue
for v in range(len(G)):
newLen = dist[u][k] +dist[k][v]
if newLen < dist[u][v]:
dist[u][v] = newLen
pred[u][v] = pred[k][v]
return pred
def ShortestPath(G, pred, s, e):
if s < 0 or e < 0 or e >= len(G) or s >= len(G):
return None
print("Shortest Path from %d to %d is: " %(s, e), end = '')
path = []
sum = 0
path.append(e)
while e != s:
temp = e
e = pred[s][e]
sum = sum + G[e][temp]
if e == -1:
return None
path.append(e)
path.reverse()
print(path)
print("Shortest distence is %d" %(sum))
return path
FloydWarshell输入图G,先将dist和pred初始化,要求方阵dist对角元素为0,其余元素为最大或为已知的邻居顶点距离。pred为-1或存在邻居顶点时为自己。然后开始判断dist[u][k] +dist[k][v]是否小于dist[u][v],如果dist[u][k] +dist[k][v]小于dist[u][v]就将新的距离保存在dist[u][v],pred[u][v]实际就是pred[k][v]。通过不断解决这个小问题,直到dist[u][v]能够找到所有点对的最短距离。ShortestPath根据pred给出前序顶点给出最短路径。
FloydWarshell有三层循环嵌套,这个嵌套是O(v3)。ShortestPath就是将pred中的前序顶点列出,所以是O(e)。其中v是顶点数,e是边数。
最小生成树
之前都是讨论有向带权图的最短路径,现在再来考虑无向带权图的“最短路径”。给定一个图G,假设G中任意两个顶点可以直接连通或通过其他顶点连通,那么我们选出一组边满足连通所有顶点并且权值之和最小,这样的一组边就是最小生成树了。