1.前言

最近在看《算法图解》,其中第七章狄克斯特拉算法个人感觉并没有讲的清楚,比如看完7.1节给人的感觉是狄克斯特拉算法会遍历图中的每一条边,后续狄克斯特拉不适用负权边的说法就站不住脚了。后续在查阅诸多资料之后,总结文章一篇,尽可能以通俗易懂且思路清晰的方式来讲解狄克斯特拉算法。

2.简介

狄克斯特拉算法用于寻找在加权图中前往目标节点的最短路径,加权图是对边进行加权的图。

2.1.定理

设想这样一个场景——在一个没有负权边的有向图中,如果从起点直接到节点A的开销小于从起点直接到节点B的开销,那么即使从起点出发经过节点B还有其他路径可以到达节点A,其总开销也会大于从起点到节点A的开销。
在这里插入图片描述
比如在上图中,起点到A的开销为3,这个开销一定小于从起点开始经其他节点到A的总开销,因为从起点到B的开销就已经大于从起点到A的开销。
上面推论的结果就是——最短路径的子路径仍然是最短路径这句话是指在最短路径中,从起点到该路径上任意一点的子路径是从起点到该点的所有子路径中最短的。重点在“从起点开始”。比如在上图中,如果最短路径经过了A,那么在最短路径中从起点到A的子路径一定是所有从起点到A的路径中最短的那条但并不意味着在最短路径中从A到终点的路径是其他所有到终点的路径中最短的那条。
但我们可能会认为虽然前面的子路径不是最短的,但只要后面的子路径选的好,加起来仍然可以得到最短路径。我们可以这样理解,在当前的最短路径中,点A到终点的路径保持不变,将起点到点A的路径设为L1。如果L1不是起点到点A最短的路径,则存在起点到点A的另外一条路径L2使得L2 < L1,将L1替换成L2,即可得到一条比当前最短路径还要短的路径,那么当前路径就不是最短路径了。路径上的每个点都能以此类推。由此可以证明在没有负权边的情况下,最短路径的子路径仍然是最短路径。
这也解释了为什么狄克斯特拉算法每次都从当前节点开销最少的邻居节点开始下一轮处理。

2.2.术语

  • 权重:狄克斯特拉算法用于每条边都有关联数字的图,这些数字称为权重
  • 加权图/非加权图:带权重的图称为加权图,不带权重的图称为非加权图
  • 广度优先搜索:计算非加权图中的最短路径
  • 狄克斯特拉算法:计算加权图中的最短路径
  • 环:在图中,如果可以从一个节点出发,走一圈后又回到这个点,则说明图中存在环,绕环的路径不可能是最短路径。在无向图中,每条边就是一个环,狄克特拉斯算法只适用于有向无环图,且没有负权边
  • 开销:从一个点到另一个点所经历的边的权重之和,一般加权图的最短路径指的是权重之和最小的那条路径

3.算法步骤

狄克斯特拉算法可分为5步:

  1. 找出从起点出发,可以前往的、开销最小的未处理点
  2. 对于该节点的邻居,检查是否有前往他们的更短路径,如果有,则更新其开销
  3. 将该节点加入已处理队列中,后续不再处理该节点
  4. 重复1-4步,直到对图中除了终点的所有节点都进行了检查
  5. 得到最终路径

我们通过两个例子来体验上述步骤。

3.1.示例1

找出下图中,从起点到终点的最短路径
在这里插入图片描述
准备一张表,存储从起点到各个节点的开销及其父节点,作用是方便最后找出最短路径和最短路径的长度。
初始化时,设起点开销为0,其他节点开销为 inf (无限大),算法结束的条件就是除终点外的所有节点都被处理了。

节点父节点开销是否已处理
起点-0
A-inf
B-inf
终点-inf-

找出当前开销最小的未处理节点,得到起点。起点的邻居是A和B,将其他不能直接到达的节点开销保持为 inf ,更新表:

节点父节点开销是否已处理
起点-0
A起点6
B起点2
终点-inf-

继续找出当前开销最小的未处理节点,得到节点B,计算经节点B前往其各个邻居的开销:

  • B到A距离为3,加上起点到B的距离,得从起点经B到达A开销为5,小于之前从起点直接到A的开销
  • B可到达终点,加上起点到B的距离,得从起点经B到达终点开销为7

更新表:

节点父节点开销是否已处理
起点-0
AB5
B起点2
终点B7-

至此B节点的所有邻居处理完毕,将B标识为已处理:

节点父节点开销是否已处理
起点-0
AB5
B起点2
终点B7-

重复上面的过程,找出当前开销最小的未处理节点,得到A,计算经节点A前往其各个邻居的开销:

  • A到终点距离为1,加上起点到A的最短距离5,得6,小于之前从起点经B到达终点的距离

更新表:

节点父节点开销是否已处理
起点-0
AB5
B起点2
终点A6-

至此A节点的所有邻居处理完毕,将A标识为已处理:

节点父节点开销是否已处理
起点-0
AB5
B起点2
终点A6-

到此所有除了终点的节点都经过检查,得出从起点到终点最短距离为6,最短路径通过终点的父节点逐级向上追溯为:起点 -> B -> A -> 终点。

3.2.示例2

示例2由示例1改变节点B到节点A路径的方向而来,通过该示例可以更好的理解狄克斯特拉算法的处理过程。
在这里插入图片描述
同样的操作,准备一张表:

节点父节点开销是否已处理
起点-0
A-inf
B-inf
终点-inf-

找出当前开销最小的未处理节点,得到起点,起点的邻居是A和B,更新表:

节点父节点开销是否已处理
起点-0
A起点6
B起点2
终点-inf-

找出当前开销最小的未处理节点,得到节点B,更新表:

节点父节点开销是否已处理
起点-0
A起点6
B起点2
终点B7-

找出下一个开销最小的未处理节点,得到节点A,对节点A的处理就和示例1有所不同了。
节点A有两个邻居:节点B和终点。但节点B已经处理过了,这意味着当前由起点前往节点B的开销最少的路径已经被发现了,结合之前的定理,这意味着两点:

  • 从起点到节点B的开销不可能小于2
  • 如果最短路径经过了节点B,那最短路径中有起点到B的路径一定是 起点 -> B 这条路径

综上,不必要检查 A-> B 这条路径的开销,只需要检查 A -> 终点 这条路径的开销。
由于 起点 -> A -> 终点 这条路径的开销没有小于 7,所以不更新A邻居节点的开销,只将A置为已处理即可:

节点父节点开销是否已处理
起点-0
A起点6
B起点2
终点B7-

到此所有除了终点的节点都经过检查,得出从起点到终点最短距离为7,最短路径通过终点的父节点逐级向上追溯为:起点 -> B -> 终点
需要注意的是,这次我们只找到了其中一条最短路径,因为 起点 -> A -> 终点 这条路径的开销也是 7。

4.实现

下面用python展示从下图中找出用乐谱交换钢琴需要额外支付费用最少的路径
在这里插入图片描述

# 使用狄克斯特拉在加权有向图中寻找最短路径
from cmath import inf

# 构建图
map = {}
map['乐谱'] = {}
map['乐谱']['唱片'] = 5
map['乐谱']['海报'] = 0
map['唱片'] = {}
map['唱片']['吉他'] = 15
map['唱片']['架子鼓'] = 20
map['海报'] = {}
map['海报']['吉他'] = 30
map['海报']['架子鼓'] = 35
map['吉他'] = {}
map['吉他']['钢琴'] = 20
map['架子鼓'] = {}
map['架子鼓']['钢琴'] = 10

# 初始化table中的行
def initRow(costs, key, cost):
  costs[key] = {}
  costs[key]['father'] = None
  costs[key]['cost'] = cost
  costs[key]['isChecked'] = False

# 获取当前开销最少的节点
def findLowerCostNode(costs):
  key = None
  cost = float(inf)

  for name in costs:
    item = costs[name]
    if (item['isChecked'] == False and item['cost'] < cost ):
      cost = item['cost']
      key = name

  return key

def dijkstra(map, start, end):
  # 初始化表
  costs = {}
  initRow(costs, start, 0)

  key = findLowerCostNode(costs)

  while (key != None and key != end): 
    # 获取邻居
    neighbors = map[key].keys()

    # 检查到邻居的开销
    for neighbor in neighbors:
      # 如果邻居不在costs中,则添加
      if (neighbor not in costs):
        initRow(costs, neighbor, float(inf))

      # 获取到key的开销
      newCost = map[key][neighbor] + costs[key]['cost']
      # 如果经key到邻居的开销小于已有开销
      if (newCost < costs[neighbor]['cost']):
        costs[neighbor]['cost'] = newCost
        costs[neighbor]['father'] = key
    
    # key的所有邻居都检查过了
    costs[key]['isChecked'] = True
    key = findLowerCostNode(costs)

  # 打印结果
  key = end
  routes = []

  while (key != None):
    node = costs[key]
    routes.insert(0, key)
    father = node['father']
    key = father
    
  print(f'最短路径为: {"->".join(routes)},开销{costs[end]["cost"]}')
  # print(costs)

dijkstra(map, '乐谱', '钢琴')

5.负权边

负权边即权重为负的边,如果有负权边,则不能使用狄克斯特拉算法,而应该使用贝尔曼-福德算法。
在这里插入图片描述
还是用这张图举例,如果X、Y、Z中有负数,那经过B到的A的开销可能就比3小了,即使从起点到B的开销大于从起点到A开销。

6.参考

图最短路径算法之迪杰斯特拉算法(Dijkstra)

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐