不是有个叫迪杰斯特拉(Dijkstra)的算法吗?名字难记不说,加权图的最短路径问题在实际开发中也很少需要自己实现,所以总是很快就忘了。。。

迪杰斯特拉算法是什么

迪杰斯特拉算法是一种用于求解图的最短路径的算法。很多人可能只是听过这个名字。虽然它的原理简单易懂,但初次接触可能有些难以理解,不过只要逐步分析就会发现它其实是一个相当直观的算法。
对于没有权重的迷宫搜索等问题,可以用广度优先搜索(BFS)解决,但如果每条边都有权重,就需要计算所有可能的路径。假设每个顶点只经过一次,且有E条边,那么时间复杂度会是O(E!),计算量会爆炸式增长。
这样计算起来就很不现实。

而迪杰斯特拉算法正是高效解决这类问题的算法。

需要注意的是,各边的成本必须是非负值(大于等于0)。如果包含负数,则需要使用贝尔曼-福特(Bellman-Ford)等算法。

步骤

迪杰斯特拉算法的步骤非常简单:

  1. 将起点的最短距离设为0
  2. 从未访问的点中选择已知最短距离且距离最小的顶点移动
  3. 设置该顶点连接的其他顶点的最短距离。如果可以更新更短的距离,则更新。
  4. 重复以上步骤,直到所有顶点的最短距离都确定

光这么说可能有点抽象,下面我们通过一个具体例子来说明。

示例

虽然方法很原始,但这是最能表达清楚的方式…
我们来看下面这张图的最短路径:
image.png

绿色表示最短路径已确定并已访问的顶点,红色是起点顶点。每个顶点上的数字表示当前的最短路径:
dijkstra algo.gif

可以看到,算法从起点开始,每次移动到当前最短路径的顶点,并计算相邻顶点的最短路径。

实现

接下来我们来实现这个算法。
按照上述步骤直接实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function main(nodes) {
const start = nodes[0]
// 记录已访问的顶点
const visited = new Set()
const routesFromStart = new Map()
// 记录从起点出发的距离

routesFromStart.set(start, {distance: 0})
for(const n of nodes) {
if(n != start) {
// 除起点外所有顶点初始化为无穷大
routesFromStart.set(n, {distance: Number.MAX_VALUE})
}
}
let current = start
let routes = new Map()
while(current != null) {
visited.add(current)
for(const edge of current.edges) {
// 计算相邻顶点的最短距离,如果更小则更新
if(edge.cost + routesFromStart.get(current).distance < routesFromStart.get(edge.to).distance) {
routesFromStart.set(edge.to, {distance: edge.cost + routesFromStart.get(current).distance})
routes.set(current, edge.to)
}
}
let cheapestNodeDistance = Number.MAX_VALUE
current = null
// 从已计算最短距离的未访问顶点中选择最小的顶点

for(const city of routesFromStart.keys()) {
if(!visited.has(city) && cheapestNodeDistance > routesFromStart.get(city).distance){
cheapestNodeDistance = routesFromStart.get(city).distance
current = city
}
}
}
return routesFromStart.get(nodes[nodes.length - 1]).distance
}

假设顶点数为V,这段代码在最坏情况下需要遍历所有边,并在内部循环中选择最小顶点,因此时间复杂度是O(V² + E)。空间复杂度需要记录每个顶点,所以是O(V)。

关于使用优先队列的实现

细心的读者可能已经发现,这段代码中选取最小顶点的逻辑可以优化,这就是优先队列(Priority Queue)。
优先队列的插入和取出操作需要O(logN)的时间复杂度,但比线性搜索最小顶点更快。

JavaScript没有内置优先队列,以下是Python实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def dijkstra(nodes):
start_node = nodes[0]
routes_from_start = {n: math.inf for n in nodes}

# 起点距离设为0
routes_from_start[start_node] = 0

minHeap = []

# 添加起点
heappush(minHeap, (0, start_node))

# 直到堆为空
while minHeap:
(cost, current_node) = heappop(minHeap)

# 检查priority key是否重复
if cost > routes_from_start[current_node]:
continue

for node in current_node.routes:
price_info = current_node.routes[node]
if routes_from_start[node] > price_info + routes_from_start[current_node]:
routes_from_start[node] = price_info + routes_from_start[current_node]
# 记录更新最短距离的节点
heappush(minHeap, (price_info + routes_from_start[current_node], node))

return routes_from_start[nodes[-1]]

优先队列的说明我们以后再讨论。
优化后的算法时间复杂度为O(V + ElogE),比最初的实现更高效。

记录路径

现在我们已经能求出最短成本,但这是"最短路径"问题,我们自然还想知道具体路径。
改进上面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def dijkstra(nodes):
start_node = nodes[0]
routes_from_start = {n: math.inf for n in nodes}

# 起点距离设为0
routes_from_start[start_node] = 0

minHeap = []

# 添加起点
heappush(minHeap, (0, start_node))
path = collections.defaultdict(Node)

# 直到堆为空
while minHeap:
(cost, current_node) = heappop(minHeap)

# 检查priority key是否重复
if cost > routes_from_start[current_node]:
continue

for node in current_node.routes:
price_info = current_node.routes[node]
if routes_from_start[node] > price_info + routes_from_start[current_node]:
routes_from_start[node] = price_info + routes_from_start[current_node]
# 记录更新最短距离的节点
path[node.id] = current_node.id
heappush(minHeap, (price_info + routes_from_start[current_node], node))

current_node = nodes[-1].id
path_array = []

# 从终点回溯记录的最短路径节点
while current_node:
path_array.append(current_node)
if current_node not in path:
break
current_node = path[current_node]

return routes_from_start[nodes[-1]], path_array[::-1]

迪杰斯特拉算法会记录更新最短距离的节点,最后回溯即可。时间复杂度会增加最短路径节点数的计算量。

为什么这样能求出最短路径

看到这里,很多人可能会想:算法本身很简单,实现也不难,但为什么能保证求出最短距离呢?我们简单验证一下:

image.png

假设集合L中的顶点到起点S的最短距离已确定,那么从L连接到的最小顶点也应该是S的最短距离。

image.png

image.png

在集合T中选取最小顶点i,则d[i] = min(T)。对于任意顶点k,最短距离d[k] ≥ d[i]是确定的,因为d[i]是最小值且各边权重非负。
通过归纳法可以证明这一点。

其实这就是一个递推公式:

d[i] = min(k ⊂ T) + i到L中相邻顶点的最短距离

说到递推公式就想到动态规划(DP)。关于DP可以参考这篇文章:
https://qiita.com/drken/items/a5e6fe22863b7992efdb

用DP来看值的更新过程:
image.png

纵轴表示迭代次数,横轴表示顶点。原来迪杰斯特拉算法是DP的一种啊。

总结

通过以上分析,迪杰斯特拉算法一旦理解后其实相当简单。以后在算法问题中遇到类似问题时,希望能快速联想到这个算法。
*顺便说一句,我也想写一篇关于DP的文章

讲解视频可以在这里观看:
https://youtu.be/jz8b0q5R1Ss

参考

http://www.lab2.kuis.kyoto-u.ac.jp/~shuichi/algintro/alg-6s.pdf
https://www.youtube.com/watch?v=X1AsMlJdiok