《图解算法》之狄克斯特拉算法
1、介绍
在前一篇博客中我们学习了广度优先搜索算法,它解决的是段数最少的路径,如果你要找到最快的路径,该怎么办呢?为此,可以使用本篇博客所讲述的算法——狄克斯特拉算法
如果你使用广度优先搜索,将得到下面这条段数最少的路径。
这条路径耗时7分钟。下面来看看能否找到耗时更短的路径!狄克斯特拉算法包含4个步骤。
(1) 找出“最便宜”的节点,即可在最短时间内到达的节点。
(2) 更新该节点的邻居的开销,其含义将稍后介绍。
(3) 重复这个过程,直到对图中的每个节点都这样做了。
(4) 计算最终路径。
第一步:找出最便宜的节点。你站在起点,不知道该前往节点A还是前往节点B。前往这两个节点都要多长时间呢?
前往节点A需要6分钟,而前往节点B需要2分钟。至于前往其他节点,你还不知道需要多长时间。
由于你还不知道前往终点需要多长时间,因此你假设为无穷大(这样做的原因你马上就会明白)。节点B是最近的——2分钟就能达到。
第二步:计算经节点B前往其各个邻居所需的时间。
你刚找到了一条前往节点A的更短路径!直接前往节点A需要6分钟。
对于节点B的邻居,如果找到前往它的更短路径,就更新其开销。在这里,你找到了:
- 前往节点A的更短路径(时间从6分钟缩短到5分钟);
- 前往终点的更短路径(时间从无穷大缩短到7分钟)。
第三步:重复!
重复第一步:找出可在最短时间内前往的节点。你对节点B执行了第二步,除节点B外,可在最短时间内前往的节点是节点A。
重复第二步:更新节点A的所有邻居的开销。
你发现前往终点的时间为6分钟!
你对每个节点都运行了狄克斯特拉算法(无需对终点这样做)。现在,你知道:
- 前往节点B需要2分钟;
- 前往节点A需要5分钟;
- 前往终点需要6分钟。
狄克斯特拉算法包含4个步骤。
(1) 找出最便宜的节点,即可在最短时间内前往的节点。
(2) 对于该节点的邻居,检查是否有前往它们的更短路径,如果有,就更新其开销。
(3) 重复这个过程,直到对图中的每个节点都这样做了。
(4) 计算最终路径。
二、换钢琴
Rama想拿一本乐谱换架钢琴。
Alex说:“这是我最喜欢的乐队Destroyer的海报,我愿意拿它换你的乐谱。如果你再加5美元,还可拿乐谱换我这张稀有的Rick Astley黑胶唱片。”Amy说:“哇,我听说这张黑胶唱片里有首非常好听的歌曲,我愿意拿我的吉他或架子鼓换这张海报或黑胶唱片。”
Beethoven惊呼:“我一直想要吉他,我愿意拿我的钢琴换Amy的吉他或架子鼓。”
太好了!只要再花一点点钱,Rama就能拿乐谱换架钢琴。现在他需要确定的是,如何花最少的钱实现这个目标。我们来绘制一个图,列出大家的交换意愿。
这个图中的节点是大家愿意拿出来交换的东西,边的权重是交换时需要额外加多少钱。拿海报换吉他需要额外加30美元,拿黑胶唱片换吉他需要额外加15美元。Rama需要确定采用哪种路径将乐谱换成钢琴时需要支付的额外费用最少。为此,可以使用狄克斯特拉算法!别忘了,狄克斯特拉算法包含四个步骤。在这个示例中,你将完成所有这些步骤,因此你也将计算最终路径。动手之前,你需要做些准备工作:创建一个表格,在其中列出每个节点的开销。这里的开销指的是达到节点需要额外支付多少钱。
在执行狄克斯特拉算法的过程中,你将不断更新这个表。为计算最终路径,还需在这个表中添加表示父节点的列。
第一步:找出最便宜的节点。在这里,换海报最便宜,不需要支付额外的费用。还有更便宜的换海报的途径吗?这一点非常重要,你一定要想一想。Rama能够通过一系列交换得到海报,还能额外得到钱吗?答案是不能,因为海报是Rama能够到达的最便宜的节点,没法再便宜了。
第二步:计算前往该节点的各个邻居的开销。
现在的表中包含低音吉他和架子鼓的开销。这些开销是用海报交换它们时需要支付的额外费用,因此父节点为海报。这意味着,要到达低音吉他,需要沿从海报出发的边前行,对架子鼓来说亦如此。
再次执行第一步:下一个最便宜的节点是黑胶唱片——需要额外支付5美元。
再次执行第二步:更新黑胶唱片的各个邻居的开销。
你更新了架子鼓和吉他的开销!这意味着经“黑胶唱片”前往“架子鼓”和“吉他”的开销更低,因此你将这些乐器的父节点改为黑胶唱片。下一个最便宜的是吉他,因此更新其邻居的开销。
你终于计算出了用吉他换钢琴的开销,于是你将其父节点设置为吉他。最后,对最后一个节点——架子鼓,做同样的处理。
如果用架子鼓换钢琴,Rama需要额外支付的费用更少。因此,采用最便宜的交换路径时,Rama需要额外支付35美元。
现在来兑现前面的承诺,确定最终的路径。当前,我们知道最短路径的开销为35美元,但如何确定这条路径呢?为此,先找出钢琴的父节点。
钢琴的父节点为架子鼓,这意味着Rama需要用架子鼓来换钢琴。因此你就沿着这一边。
我们来看看需要沿哪些边前行。钢琴的父节点为架子鼓。
架子鼓的父节点为黑胶唱片。
因此Rama需要用黑胶唱片了换架子鼓。显然,他需要用乐谱来换黑胶唱片。通过沿父节点回溯,便得到了完整的交换路径。
三、注意
有向无环图
图还可能有环,这意味着你可从一个节点出发,走一圈后又回到这个节点。假设在下面这个带环的图中,你要找出从起点到终点的最短路径。
绕环前行是否合理呢?你可以选择避开环的路径。
也可选择包含环的路径:
这两条路径都可到达终点,但环增加了权重。如果你愿意,甚至可绕环两次。
但每绕环一次,总权重都增加8。因此,绕环的路径不可能是最短的路径。
无向图意味着两个节点彼此指向对方,其实就是环!
在无向图中,每条边都是一个环。狄克斯特拉算法只适用于有向无环图。
负权边
回到上面换钢琴的例子,假设黑胶唱片不是Alex的,而是Sarah的,且Sarah愿意用黑胶唱片和7美元换海报。换句话说,换得Alex的海报后,Rama用它来换Sarah的黑胶唱片时,不但不用支付额外的费用,还可得7美元。对于这种情况,如何在图中表示出来呢?
从黑胶唱片到海报的边的权重为负!即这种交换让Rama能够得到7美元。现在,Rama有两种获得海报的方式。
第二种方式更划算——Rama可赚2美元!你可能还记得,Rama可以用海报换架子鼓,但现在有两种换得架子鼓的方式。
第二种方式的开销少2美元,他应采取这种方式。然而,如果你对这个图运行狄克斯特拉算法,Rama将选择错误的路径——更长的那条路径。如果有负权边,就不能使用狄克斯特拉算法。因为负权边会导致这种算法不管用。下面来看看对这个图执行狄克斯特拉算法的情况。首先,创建开销表。
接下来,找出开销最低的节点,并更新其邻居的开销。在这里,开销最低的节点是海报。根据狄克斯特拉算法,没有比不支付任何费用获得海报更便宜的方式。(你知道这并不对!)无论如何,我们来更新其邻居的开销。
现在,架子鼓的开销变成了35美元。
我们来找出最便宜的未处理节点。
更新其邻居的开销。
海报节点已处理过,这里却更新了它的开销。这是一个危险信号。节点一旦被处理,就意味着没有前往该节点的更便宜途径,但你刚才却找到了前往海报节点的更便宜途径!架子鼓没有任何邻居,因此算法到此结束,最终开销如下。
换得架子鼓的开销为35美元。你知道有一种交换方式只需33美元,但狄克斯特拉算法没有找到。这是因为狄克斯特拉算法这样假设:对于处理过的海报节点,没有前往该节点的更短路径。这种假设仅在没有负权边时才成立。因此,不能将狄克斯特拉算法用于包含负权边的图。在包含负权边的图中,要找出最短路径,可使用另一种算法——贝尔曼-福德算法,你可以在网上找到其详尽的说明。
四、实现
有这样的一条道路,你现在在A点,想要前往H点,请找出最短路径:
想要使用JAVA构造有向图的数据结构,你可以使用HashMap嵌套,
HashMap<String, HashMap<String, Integer>>
外层String代表节点名称,内层hashmap代表改节点可前往的点,内部hashmap的String代表外层点可到达的点的名称,Integer代表路程
按照上述步骤书写代码,如下:
1 import java.util.ArrayList; 2 import java.util.HashMap; 3 import java.util.List; 4 5 public class Dijkstra { 6 public static void main(String[] args) { 7 HashMap<String, Integer> A = new HashMap<String, Integer>() { 8 { 9 put("B", 5); 10 put("C", 1); 11 } 12 }; 13 14 HashMap<String, Integer> B = new HashMap<String, Integer>() { 15 { 16 put("E", 10); 17 } 18 }; 19 HashMap<String, Integer> C = new HashMap<String, Integer>() { 20 { 21 put("D", 5); 22 put("F", 6); 23 } 24 }; 25 HashMap<String, Integer> D = new HashMap<String, Integer>() { 26 { 27 put("E", 3); 28 } 29 }; 30 HashMap<String, Integer> E = new HashMap<String, Integer>() { 31 { 32 put("H", 3); 33 } 34 }; 35 HashMap<String, Integer> F = new HashMap<String, Integer>() { 36 { 37 put("G", 2); 38 } 39 }; 40 HashMap<String, Integer> G = new HashMap<String, Integer>() { 41 { 42 put("H", 10); 43 } 44 }; 45 HashMap<String, HashMap<String, Integer>> allMap = new HashMap<String, HashMap<String, Integer>>() { 46 { 47 put("A", A); 48 put("B", B); 49 put("C", C); 50 put("D", D); 51 put("E", E); 52 put("F", F); 53 put("G", G); 54 } 55 }; 56 57 58 Dijkstra dijkstra = new Dijkstra(); 59 dijkstra.handle("A", "H", allMap); 60 } 61 62 private String getMiniCostKey(HashMap<String, Integer> costs, List<String> hasHandleList) { 63 int mini = Integer.MAX_VALUE; 64 String miniKey = null; 65 for (String key : costs.keySet()) { 66 if (!hasHandleList.contains(key)) { 67 int cost = costs.get(key); 68 if (mini > cost) { 69 mini = cost; 70 miniKey = key; 71 } 72 } 73 } 74 return miniKey; 75 } 76 77 private void handle(String startKey, String target, HashMap<String, HashMap<String, Integer>> all) { 78 //存放到各个节点所需要消耗的时间 79 HashMap<String, Integer> costMap = new HashMap<String, Integer>(); 80 //到各个节点对应的父节点 81 HashMap<String, String> parentMap = new HashMap<String, String>(); 82 //存放已处理过的节点key,已处理过的不重复处理 83 List<String> hasHandleList = new ArrayList<String>(); 84 85 //首先获取开始节点相邻节点信息 86 HashMap<String, Integer> start = all.get(startKey); 87 88 //添加起点到各个相邻节点所需耗费的时间等信息 89 for (String key : start.keySet()) { 90 int cost = start.get(key); 91 costMap.put(key, cost); 92 parentMap.put(key, startKey); 93 } 94 95 96 //选择最"便宜"的节点,这边即耗费时间最低的 97 String minCostKey = getMiniCostKey(costMap, hasHandleList); 98 while (minCostKey != null) { 99 System.out.print("处理节点:" + minCostKey); 100 HashMap<String, Integer> nodeMap = all.get(minCostKey); 101 if (nodeMap != null) { 102 //该节点没有子节点可以处理了,末端节点 103 handleNode(minCostKey, nodeMap, costMap, parentMap); 104 } 105 //添加该节点到已处理结束的列表中 106 hasHandleList.add(minCostKey); 107 //再次获取下一个最便宜的节点 108 minCostKey = getMiniCostKey(costMap, hasHandleList); 109 } 110 if (parentMap.containsKey(target)) { 111 System.out.print("到目标节点" + target + "最低耗费:" + costMap.get(target)); 112 List<String> pathList = new ArrayList<String>(); 113 String parentKey = parentMap.get(target); 114 while (parentKey != null) { 115 pathList.add(0, parentKey); 116 parentKey = parentMap.get(parentKey); 117 } 118 pathList.add(target); 119 String path = ""; 120 for (String key : pathList) { 121 path = path + key + " --> "; 122 } 123 System.out.print("路线为" + path); 124 } else { 125 System.out.print("不存在到达" + target + "的路径"); 126 } 127 } 128 129 private void handleNode(String startKey, HashMap<String, Integer> nodeMap, HashMap<String, Integer> costMap, HashMap<String, String> parentMap) { 130 131 for (String key : nodeMap.keySet()) { 132 //获取原本到父节点所需要花费的时间 133 int hasCost = costMap.get(startKey); 134 //获取父节点到子节点所需要花费的时间 135 int cost = nodeMap.get(key); 136 //计算从最初的起点到该节点所需花费的总时间 137 cost = hasCost + cost; 138 139 if (!costMap.containsKey(key)) { 140 //如果原本并没有计算过其它节点到该节点的花费 141 costMap.put(key, cost); 142 parentMap.put(key, startKey); 143 } else { 144 //获取原本耗费的时间 145 int oldCost = costMap.get(key); 146 if (cost < oldCost) { 147 //新方案到该节点耗费的时间更少 148 //更新到达该节点的父节点和消费时间对应的散列表 149 costMap.put(key, cost); 150 parentMap.put(key, startKey); 151 System.out.print("更新节点:" + key + ",cost:" + oldCost + " --> " + cost); 152 } 153 } 154 } 155 } 156 } 157
六:参考致谢
部分图片和内容摘自如下博客,主要内容来自《图解算法》。
1. https://blog.csdn.net/qq_37482202/article/details/89546951
Over......