數據結構與算法--最短路徑之Dijkstra算法
加權圖中,我們很可能關心這樣一個問題:從一個頂點到另一個頂點成本最小的路徑。比如從成都到北京,途中還有好多城市,如何規劃路線,能使總路程最小;或者我們看重的是路費,那么如何選擇經過的城市可以使得總路費降到最低?
- 首先路徑是有向的,最短路徑需要考慮到各條邊的方向。
- 權值不一定就是指距離,還可以是費用等等...
最短路徑的定義:在一幅有向加權圖中,從頂點s到頂點t的最短路徑是所有從s到t的路徑中權值最小者。
為此,我們先要定義有向邊以及有向圖。
加權有向圖的實現
首先是有向邊。
package Chap7;
public class DiEdge {
private int from;
private int to;
private double weight;
public DiEdge(int from, int to, double weight) {
this.from = from;
this.to = to;
this.weight = weight;
}
public int from() {
return from;
}
public int to() {
return to;
}
public double weight() {
return weight;
}
@Override
public String toString() {
return "(" +
from +
"->" + to +
" " + weight +
')';
}
}
比起無向邊Edge類,更簡單些,因為兩個頂點有明顯的先后順序。
然后是加權有向圖。
package Chap7;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
public class EdgeWeightedDiGraph<Item> {
private int vertexNum;
private int edgeNum;
// 鄰接表
private List<List<DiEdge>> adj;
// 頂點信息
private List<Item> vertexInfo;
public EdgeWeightedDiGraph(List<Item> vertexInfo) {
this.vertexInfo = vertexInfo;
this.vertexNum = vertexInfo.size();
adj = new ArrayList<>();
for (int i = 0; i < vertexNum; i++) {
adj.add(new LinkedList<>());
}
}
public EdgeWeightedDiGraph(List<Item> vertexInfo, int[][] edges, double[] weight) {
this(vertexInfo);
for (int i = 0; i < edges.length; i++) {
DiEdge edge = new DiEdge(edges[i][0], edges[i][1], weight[i]);
addDiEdge(edge);
}
}
public EdgeWeightedDiGraph(int vertexNum) {
this.vertexNum = vertexNum;
adj = new ArrayList<>();
for (int i = 0; i < vertexNum; i++) {
adj.add(new LinkedList<>());
}
}
public EdgeWeightedDiGraph(int vertexNum, int[][] edges, double[] weight) {
this(vertexNum);
for (int i = 0; i < edges.length; i++) {
DiEdge edge = new DiEdge(edges[i][0], edges[i][1], weight[i]);
addDiEdge(edge);
}
}
public void addDiEdge(DiEdge edge) {
adj.get(edge.from()).add(edge);
edgeNum++;
}
// 返回與某個頂點依附的所有邊
public Iterable<DiEdge> adj(int v) {
return adj.get(v);
}
public List<DiEdge> edges() {
List<DiEdge> edges = new LinkedList<>();
for (int i = 0; i < vertexNum; i++) {
for (DiEdge e : adj(i)) {
edges.add(e);
}
}
return edges;
}
public int vertexNum() {
return vertexNum;
}
public int edgeNum() {
return edgeNum;
}
public Item getVertexInfo(int i) {
return vertexInfo.get(i);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(vertexNum).append("個頂點, ").append(edgeNum).append("條邊。\n");
for (int i = 0; i < vertexNum; i++) {
sb.append(i).append(": ").append(adj.get(i)).append("\n");
}
return sb.toString();
}
public static void main(String[] args) {
List<String> vertexInfo = List.of("v0", "v1", "v2", "v3", "v4", "v5", "v6", "v7");
int[][] edges = {{4, 5}, {5, 4}, {4, 7}, {5, 7}, {7, 5}, {5, 1}, {0, 4}, {0, 2},
{7, 3}, {1, 3}, {2, 7}, {6, 2}, {3, 6}, {6, 0}, {6, 4}};
double[] weight = {0.35, 0.35, 0.37, 0.28, 0.28, 0.32, 0.38, 0.26, 0.39, 0.29,
0.34, 0.40, 0.52, 0.58, 0.93};
EdgeWeightedDiGraph<String> graph = new EdgeWeightedDiGraph<>(vertexInfo, edges, weight);
System.out.println("該圖的鄰接表為\n"+graph);
System.out.println("該圖的所有邊:"+ graph.edges());
}
}
實現和加權無向圖差不多,就改了addEdge
和adj
方法。addEdge由于邊有向,不會對稱地存儲邊;adj方法不像無向圖那樣鄰接表中有重復的邊,有向圖中鄰接表中的邊都是唯一的,所以全部加入即可。
最短路徑的數據結構
1、最短路徑樹中的邊
和深度優先、廣度優先搜索一樣,我們將用到一個edgeTo[]
表示一個樹形結構,edgeTo[v]
表示樹中連接頂點v和其父結點的邊(也就是起點s到v的路徑上最后一條邊)。
2、起點到各個頂點的最短距離
和Prim算法類似,需要一個distTo[]
。Prim算法中它存放的是:到某個頂點權值最小的那條邊。而最短路徑中,distTo[v]
存放的是:從起點s開始到某頂點v的最短路徑長度。我們約定到起點s的最短路徑長度為0,即distTo[s] = 0
;同時約定從起點s到不可達的頂點的距離均為正無窮。
最短路徑算法的基礎基于一個被稱為松弛的簡單操作。放松一條邊v -> w意味著檢查s到w的最短路徑是否是 先從s到v,再從v到w。如果是就更新相關數據結構的內容;如果不是,不作更改。用代碼可以表示為
// v -> w, v和w是邊edge的兩個頂點
// distTo[v] :s到v的最短距離;distTo[w]:s到w的最短距離
if (distTo[v] + edge.weight() < distTo[w]) {
distTo[w] = distTo[v] + e.weight();
edgeTo[w] = edge;
}
再用一幅圖加深理解。
先看左邊兩個圖:s到v的最短距離是3.1,s到w的最短距離是3.3。當在頂點v時,檢查它的鄰接點w,邊v -> w的權值是1.3,從s到w的當然不能先從s到v,再從v到w,因為這倆加起來都4.4,比原來s到w的方案還要費勁,所以不會更改distTo[w]
和edgeTo[w]
。此時我們說v -> w這條邊失效并忽略它。
再看右邊兩個圖:原先s到w的方案距離為7.2,現在我們換條路走,從s先到v,再從v到w,只有4.4!這是條到w更近的路。所以更新,distTo[w]
改成4.4,到s到w的最后一條邊edgeTo[w]
也改成了v- > w這條邊。此時就稱邊v -> w放松成功(可以想象成一根緊繃的橡皮筋,它的長度比較長;橡皮筋放松后,長度變短。)
對頂點的放松就是:放松由該頂點引出的所有邊。
在實現之前,對于最短路徑算法我們需要了解得更多,來看幾個命題。
- 當且僅當對于從v -> w的任意一條邊,都有
dist[w] <= distTo[v] + edge.weight()
,那么s到w的路徑都是最短路徑。 - Dijkstra算法能解決邊權值非負的加權有向圖的單點最短路徑問題,換句話說,當遇到有負權值的邊,或者想通過一次運算就找到任意頂點到任意頂點的最短路徑,Dijkstra就不適用了。
- 如果v是從起點s可達的,那么邊v -> w只會被放松一次,放松v時,必有
dist[w] <= distTo[v] + edge.weight()
,該等式在算法整個流程都成立,所以distTo[w]
只能減小。而distTo[v]不會改變,因為每次都選擇distTo[]最小的頂點,之后的放松操作不可能使得任何distTo[]的值小于dist[v]。也就是說,每次選擇distTo[]最小的頂點,它的值不會小于那些已經放松過的頂點的最短路徑值distTo[v],也不會大于任意未被放松過的頂點。所有從s可達的頂點都會按照distTo[]里最短路徑的權值來依次放松。 - 最短路徑算法也可以處理無向圖,用有向圖的數據類型,只是對應于無向圖,每條邊都會創建兩條方向不同的有向邊。例如,無向圖中的邊3-0,使用有向圖創建3 -> 0和0 -> 3兩條邊,然后調用最短路徑算法即可。
Dijkstra算法的實現
package Chap7;
import java.util.*;
public class Dijkstra {
private DiEdge[] edgeTo;
private double[] distTo;
private Map<Integer, Double> minDist;
public Dijkstra(EdgeWeightedDiGraph<?> graph, int s) {
edgeTo = new DiEdge[graph.vertexNum()];
distTo = new double[graph.vertexNum()];
minDist = new HashMap<>();
for (int i = 0; i < graph.vertexNum(); i++) {
distTo[i] = Double.POSITIVE_INFINITY; // 1.0 / 0.0為INFINITY
}
// 到起點距離為0
distTo[s] = 0.0;
relax(graph, s);
while (!minDist.isEmpty()) {
relax(graph, delMin());
}
}
private int delMin() {
Set<Map.Entry<Integer, Double>> entries = minDist.entrySet();
Map.Entry<Integer, Double> min = entries.stream().min(Comparator.comparing(Map.Entry::getValue)).get();
int key = min.getKey();
minDist.remove(key);
return key;
}
private void relax(EdgeWeightedDiGraph<?> graph, int v) {
for (DiEdge edge : graph.adj(v)) {
int w = edge.to();
if (distTo[v] + edge.weight() < distTo[w]) {
distTo[w] = distTo[v] + edge.weight();
edgeTo[w] = edge;
if (minDist.containsKey(w)) {
minDist.replace(w, distTo[w]);
System.out.println(w);
} else {
minDist.put(w, distTo[w]);
}
}
}
}
public double distTo(int v) {
return distTo[v];
}
public boolean hasPathTo(int v) {
return distTo[v] != Double.POSITIVE_INFINITY;
}
public Iterable<DiEdge> pathTo(int v) {
if (hasPathTo(v)) {
LinkedList<DiEdge> path = new LinkedList<>();
for (DiEdge edge = edgeTo[v]; edge != null; edge = edgeTo[edge.from()]) {
path.push(edge);
}
return path;
}
return null;
}
public static void main(String[] args) {
List<String> vertexInfo = List.of("v0", "v1", "v2", "v3", "v4", "v5", "v6", "v7");
int[][] edges = {{4, 5}, {5, 4}, {4, 7}, {5, 7}, {7, 5}, {5, 1}, {0, 4}, {0, 2},
{7, 3}, {1, 3}, {2, 7}, {6, 2}, {3, 6}, {6, 0}, {6, 4}};
double[] weight = {0.35, 0.35, 0.37, 0.28, 0.28, 0.32, 0.38, 0.26, 0.39, 0.29,
0.34, 0.40, 0.52, 0.58, 0.93};
EdgeWeightedDiGraph<String> graph = new EdgeWeightedDiGraph<String>(vertexInfo, edges, weight);
Dijkstra dijkstra = new Dijkstra(graph, 0);
for (int i = 0; i < graph.vertexNum(); i++) {
System.out.print("0 to " + i + ": ");
System.out.print("(" + dijkstra.distTo(i) + ") ");
System.out.println(dijkstra.pathTo(i));
}
}
}
/* Outputs
0 to 0: (0.0) []
0 to 1: (1.05) [(0->4 0.38), (4->5 0.35), (5->1 0.32)]
0 to 2: (0.26) [(0->2 0.26)]
0 to 3: (0.9900000000000001) [(0->2 0.26), (2->7 0.34), (7->3 0.39)]
0 to 4: (0.38) [(0->4 0.38)]
0 to 5: (0.73) [(0->4 0.38), (4->5 0.35)]
0 to 6: (1.5100000000000002) [(0->2 0.26), (2->7 0.34), (7->3 0.39), (3->6 0.52)]
0 to 7: (0.6000000000000001) [(0->2 0.26), (2->7 0.34)]
*/
和Prim算法的即時版本的幾乎一樣!兩種算法都是添加邊的方式來構造一棵樹:Prim算法每次添加的是離整棵樹(各個頂點)最近的樹外的頂點;Dijkstra算法每次添加的是離起點最近的樹外頂點。
Dijkstra不需要marked[]
來記錄被訪問過的頂點了,因為每條邊v -> w只會被放松一次,每個頂點也只會放松一次。放松后的頂點的最短路徑長度一定滿足dist[w] <= distTo[v] + edge.weight()
,當想重復放松某個頂點時,會因為無法通過以下條件而被跳過。
if (distTo[v] + edge.weight() < distTo[w]) { }
我們還是來跟著圖走一遍。
- 放松頂點0,2、4被加入Map,distTo[2]為0 -> 2的權值,distTo[4]為0 -> 4的權值。
- 按權值放松頂點2,0 -> 2添加到樹中。7被加入Map。distTo[7]為0 -> 2 -> 7的權值和。
- 放松頂點4,0 -> 4被加入到樹中。5加到Map。dsitTo[5]為0 -> 4 -> 5的權值和。0 -> 4 -> 7沒有0 ->2 -> 7路徑短所以不更新distTo[7]。
- 放松頂點7,2- > 7加入到樹中。3加入到Map。distTo[3]為0 -> 2 -> 3 -> 7的權值和,0 -> 2 -> 7 -> 5的權值和沒有0 -> 4 -> 5的權值和小,所有不更新distTo[5]
- 放松頂點5, 4 ->5加入到樹中,1加入到Map,distTo[1]為0 -> 4 -> 5 -> 1的權值和。0 -> 4 -> 5 -> 7的權值和沒有0 -> 2 -> 7的權值和小,所以不更新distTo[7]
- 放松頂點3,7 -> 3加入到樹中。6加入到Map。distTo[6]為0 -> 2 -> 7 -> 3 -> 6的權值和。
- 放松頂點1,5 -> 1加入到樹。0 -> 4 -> 5 ->1 -> 3的權值和由于沒有0 -> 2 -> 7 -> 3 的權值和小,所以不更新distTo[3]。
- 放松頂點6, 3 -> 6加入到樹中。至此所有頂點都已放松一次,算法結束。
by @sunhaiyu
2017.9.23