Floyd 算法精講
題目描述
小明希望在公園散步時找到從一個景點到另一個景點的最短路徑。給定公園的景點圖,包含 N 個景點和 M 條雙向道路,每條道路有已知的長度。小明有 Q 個觀景計劃,每個計劃包含一個起點和終點,求每個計劃的最短路徑長度。
輸入包含景點數量 N、道路數量 M,接著 M 行每行三個整數 u、v、w 表示景點 u 和 v 之間的雙向道路長度為 w。然后輸入觀景計劃數量 Q,接著 Q 行每行兩個整數 start 和 end。輸出每個計劃的最短路徑長度,若無路徑則輸出 -1。
輸入輸出示例
輸入:
7 3 1 2 4 2 5 6 3 6 8 2 1 2 2 3
輸出:
4 -1
解題思路
算法原理
Floyd 算法是一種多源最短路徑算法,基于動態規劃思想。通過引入中間節點逐步更新所有節點對之間的最短距離,最終得到全局最優解。該算法可以處理帶負權的圖。
動態規劃思想
- 狀態定義:
grid[i][j][k]
表示從節點 i 到節點 j,只能經過節點 1 到 k 的最短距離。 - 狀態轉移:
grid[i][j][k] = min(grid[i][j][k-1], grid[i][k][k-1] + grid[k][j][k-1])
,即不經過節點 k 或經過節點 k 的最小值。 - 初始化:初始時,
grid[i][j][0]
為直接連接的邊的權值,其他為無窮大。 - 遍歷順序:按 k、i、j 的順序遍歷,確保每一步都基于之前的結果。
空間優化
由于每次計算只依賴前一次的結果,可以將三維數組優化為二維數組,減少空間復雜度。
代碼實現
import java.util.Scanner;public class FloydAlgorithm {public static final int MAX_VAL = 10005; // 邊的最大距離public static void main(String[] args) {Scanner sc = new Scanner(System.in);int n = sc.nextInt(); // 景點數量int m = sc.nextInt(); // 道路數量// 初始化距離矩陣int[][] grid = new int[n + 1][n + 1];for (int i = 1; i <= n; i++) {for (int j = 1; j <= n; j++) {grid[i][j] = MAX_VAL; // 初始化為最大值}}// 輸入道路信息for (int i = 0; i < m; i++) {int u = sc.nextInt();int v = sc.nextInt();int w = sc.nextInt();grid[u][v] = w; // 雙向道路grid[v][u] = w;}// Floyd 算法核心部分for (int k = 1; k <= n; k++) {for (int i = 1; i <= n; i++) {for (int j = 1; j <= n; j++) {// 更新 i 到 j 的最短路徑if (grid[i][k] + grid[k][j] < grid[i][j]) {grid[i][j] = grid[i][k] + grid[k][j];}}}}// 處理觀景計劃int q = sc.nextInt(); // 計劃數量for (int i = 0; i < q; i++) {int start = sc.nextInt();int end = sc.nextInt();if (grid[start][end] == MAX_VAL) {System.out.println(-1); // 無路徑} else {System.out.println(grid[start][end]); // 最短路徑長度}}}
}
代碼注釋
- 初始化距離矩陣:
grid[i][j]
初始化為最大值,表示初始時無路徑。 - 輸入道路信息:讀取每條道路并更新距離矩陣,雙向道路需兩邊更新。
- Floyd 算法核心:三重循環,逐步引入中間節點 k,更新所有節點對的最短路徑。
- 處理觀景計劃:對每個計劃查詢最短路徑,若仍為最大值則輸出 -1,否則輸出路徑長度。
總結
Floyd 算法通過動態規劃思想,利用三維數組逐步更新節點間的最短路徑。空間優化后使用二維數組,時間復雜度為 O(n^3),適合稠密圖或多源最短路徑問題。理解動態規劃的狀態轉移和遍歷順序是掌握該算法的關鍵。
A* 算法精講
題目描述
在象棋中,騎士(馬)按照“日”字形移動。給定騎士的起始坐標和目標坐標,計算從起點到達目標點所需的最短步數。棋盤大小為 1000x1000。
輸入包含多個測試用例,每行給出起點和目標點的坐標。輸出每個測試用例的最短步數。
輸入輸出示例
輸入:
6
5 2 5 4
1 1 2 2
1 1 8 8
1 1 8 7
2 1 3 3
4 6 4 6
輸出:
2
4
6
5
1
0
解題思路
算法原理
A* 算法是一種啟發式搜索算法,結合了廣度優先搜索(BFS)和迪杰斯特拉算法(Dijkstra)的優點,通過引入啟發式函數來指導搜索方向,從而提高搜索效率。
關鍵點
- 啟發式函數:用于估計當前節點到目標節點的距離,指導搜索方向。常見的啟發式函數包括曼哈頓距離、歐氏距離和切比雪夫距離。本題采用歐氏距離。
- 優先級隊列:根據啟發式函數計算的值(F = G + H)對節點進行排序,優先擴展最有可能接近目標的節點。
- 節點狀態記錄:記錄已訪問節點的步數,避免重復訪問。
代碼實現
import java.util.*;
import java.awt.Point;
import java.util.PriorityQueue;public class KnightAttack {// 定義騎士的8個可能移動方向private static final int[][] MOVES = {{-2, -1}, {-2, 1}, {-1, -2}, {-1, 2},{1, -2}, {1, 2}, {2, -1}, {2, 1}};public static void main(String[] args) {Scanner scanner = new Scanner(System.in);int n = scanner.nextInt();for (int i = 0; i < n; i++) {int a1 = scanner.nextInt();int a2 = scanner.nextInt();int b1 = scanner.nextInt();int b2 = scanner.nextInt();System.out.println(astar(new Point(a1, a2), new Point(b1, b2)));}}private static int astar(Point start, Point end) {// 優先級隊列,存儲 (總代價, 當前位置, 已走步數)PriorityQueue<Node> heap = new PriorityQueue<>((a, b) -> Double.compare(a.f, b.f));heap.add(new Node(start, 0, heuristic(start, end)));// 記錄訪問過的節點及其步數Map<Point, Integer> visited = new HashMap<>();visited.put(start, 0);while (!heap.isEmpty()) {Node current = heap.poll();// 檢查是否到達目標if (current.point.equals(end)) {return current.steps;}// 生成下一個可能的移動位置for (int[] move : MOVES) {Point nextPoint = new Point(current.point.x + move[0], current.point.y + move[1]);// 檢查是否在棋盤范圍內if (nextPoint.x >= 1 && nextPoint.x <= 1000 && nextPoint.y >= 1 && nextPoint.y <= 1000) {int newSteps = current.steps + 1;// 如果該位置未被訪問或有更短路徑if (!visited.containsKey(nextPoint) || newSteps < visited.get(nextPoint)) {visited.put(nextPoint, newSteps);double f = newSteps + heuristic(nextPoint, end);heap.add(new Node(nextPoint, newSteps, f));}}}}// 如果無法到達目標return -1;}private static double heuristic(Point start, Point end) {// 歐氏距離啟發式函數double dx = start.x - end.x;double dy = start.y - end.y;return Math.sqrt(dx * dx + dy * dy);}private static class Node {Point point;int steps;double f; // 總代價:f = steps + heuristicNode(Point point, int steps, double f) {this.point = point;this.steps = steps;this.f = f;}}
}
代碼注釋
- 移動方向定義:騎士有8個可能的移動方向,用
moves
數組表示。 - 啟發式函數:
heuristic
函數計算兩個點之間的歐氏距離,作為估計距離。 - A 搜索函數*:
astar
函數使用優先級隊列進行搜索,每次擴展最有可能接近目標的節點。 - 優先級隊列:隊列中存儲元組
(總代價, 當前位置x, 當前位置y, 已走步數)
,按總代價排序。 - 訪問記錄:
visited
字典記錄已訪問節點及其最小步數,避免重復訪問。 - 輸入處理:讀取多個測試用例,對每個用例調用
astar
函數計算最短步數。
總結
A* 算法通過引入啟發式函數,能夠在大規模地圖上高效地找到最短路徑。相比傳統的廣度優先搜索(BFS),A* 算法通過優先擴展接近目標的節點,減少了不必要的搜索,提高了效率。選擇合適的啟發式函數對算法性能至關重要。
最短路算法總結
至此已經講解了四大最短路算法,分別是 Dijkstra、Bellman-Ford、SPFA 和 Floyd。
針對這四大最短路算法,我用了七篇長文才徹底講清楚,分別是:
- Dijkstra 樸素版
- Dijkstra 堆優化版
- Bellman-Ford
- Bellman-Ford 隊列優化算法(又名 SPFA)
- Bellman-Ford 算法判斷負權回路
- Bellman-Ford 之單源有限最短路
- Floyd 算法精講
- 啟發式搜索:A * 算法
最短路算法比較復雜,而且各自有各自的應用場景。以下是講過的最短路算法的使用場景對比表:
算法 | 適用場景 | 特點 |
---|---|---|
Dijkstra | 單源且邊為正數 | 效率高,適合稀疏圖和稠密圖的不同版本 |
SPFA | 單源邊可為負數 | 一般情況下比 Bellman-Ford 效率高,適合大部分場景 |
Bellman-Ford | 單源邊可為負數,需判斷負權回路 | 可以檢測負權回路,代碼實現相對簡單 |
Floyd | 多源點求最短路 | 適合節點數量較少的情況,實現簡單 |
(因為 A * 屬于啟發式搜索,和上面最短路算法并不是一類,不適合一起對比,所以沒有放在一起)
可能有同學感覺:這個表太復雜了,我記也記不住。
其實記不住的原因還是對這幾個最短路算法沒有深刻的理解。這里給大家一個大體使用場景的分析:
- 如果遇到單源且邊為正數,直接 Dijkstra。至于使用樸素版還是堆優化版,還是取決于圖的稠密度。多少節點多少邊算是稠密圖,多少算是稀疏圖,這個沒有量化。如果想量化,只能寫出兩個版本然后做實驗去測試,不同的判題機得出的結果還不太一樣。一般情況下,可以直接用堆優化版本。
- 如果遇到單源邊可為負數,直接 Bellman-Ford。同樣 SPFA 還是 Bellman-Ford 取決于圖的稠密度。一般情況下,直接用 SPFA。
- 如果有負權回路,優先 Bellman-Ford。如果是有限節點最短路,也優先 Bellman-Ford,理由是寫代碼比較方便。
- 如果是遇到多源點求最短路,直接 Floyd。除非源點特別少,且邊都是正數,那可以多次 Dijkstra 求出最短路徑,但這種情況很少。一般出現多個源點了,就是想讓你用 Floyd 了。
- 對于 A *,由于其高效性,在實際工程應用中使用最為廣泛。由于其結果的不唯一性,也就是可能是次短路的特性,一般不適合作為算法題。游戲開發、地圖導航、數據包路由等都廣泛使用 A * 算法。
圖論總結
從深搜廣搜到并查集,從最小生成樹到拓撲排序,最后是最短路算法系列。
至此算上本篇,一共30篇文章,圖論之旅就在此收官了。
在0098.所有可達路徑,我們接觸了兩種圖的存儲方式,鄰接表和鄰接矩陣,掌握兩種圖的存儲方式很重要。
圖的存儲方式也是大家習慣在核心代碼模式下刷題經常忽略的知識點。因為在力扣上刷題不需要掌握圖的存儲方式。
深搜與廣搜
在二叉樹章節中,其實我們講過了深搜和廣搜在二叉樹上的搜索過程。
在圖論章節中,深搜與廣搜就是在圖這個數據結構上的搜索過程。
深搜與廣搜是圖論里基本的搜索方法,大家需要掌握三點:
- 搜索方式:深搜是一個方向搜,不到黃河不回頭。廣搜是圍繞起點一圈一圈的去搜。
- 代碼模板:需要熟練掌握深搜和廣搜的基本寫法。
- 應用場景:圖論題目基本上可以用深搜也可用廣搜,看哪個方便而已。
注意事項
需要注意的是,同樣是深搜模板題,會有兩種寫法。
在0099.島嶼的數量深搜.md和0105.有向圖的完全可達性,涉及到dfs的兩種寫法。
我們對dfs函數的定義是處理當前節點還是處理下一個節點很重要,決定了兩種dfs的寫法。
這也是為什么很多錄友看到不同的dfs寫法,結果發現提交都能過的原因。
而深搜還有細節,有的深搜題目需要用到回溯的過程,有的就不用回溯的過程,
一般是需要計算路徑的問題需要回溯,如果只是染色問題(島嶼問題系列)就不需要回溯。
例如:0105.有向圖的完全可達性深搜就不需要回溯,而0098.所有可達路徑中的遞歸就需要回溯,文章中都有詳細講解
注意:以上說的是不需要回溯,不是沒有回溯,只要有遞歸就會有回溯,只是我們是否需要用到回溯這個過程,這是需要考慮的。
很多錄友寫出來的廣搜可能超時了,例如題目:0099.島嶼的數量廣搜
根本原因是只要加入隊列就代表走過,就需要標記,而不是從隊列拿出來的時候再去標記走過。
具體原因,我在0099.島嶼的數量廣搜中詳細講了。
在深搜與廣搜的講解中,為了防止慣性思維,我特別加入了題目0106.島嶼的周長,提醒大家,看到類似的題目,也不要上來就想著深搜和廣搜。
還有一些圖的問題,在題目描述中,是沒有圖的,需要我們自己構建一個圖,例如0110.字符串接龍,題目中連線都沒有,需要我們自己去思考什么樣的兩個字符串可以連成線。
并查集
并查集相對來說是比較復雜的數據結構,其實他的代碼不長,但想徹底學透并查集,需要從多個維度入手,
我在理論基礎篇的時候講解如下重點:
- 為什么要用并查集,怎么不用個二維數據,或者set、map之類的。
- 并查集能解決那些問題,哪些場景會用到并查集
- 并查集原理以及代碼實現
- 并查集寫法的常見誤區
- 帶大家去模擬一遍并查集的過程
- 路徑壓縮的過程
- 時間復雜度分析
上面這幾個維度大家都去思考了,并查集基本就學明白了。
其實理論基礎篇就算是給大家出了一道裸的并查集題目了,所以在后面的題目安排中,會稍稍的拔高一些,重點在于并查集的應用上。
例如并查集可以判斷這個圖是否是樹,因為樹的話,只有一個根,符合并查集判斷集合的邏輯,題目:0108.冗余連接。
在0109.冗余連接II中對有向樹的判斷難度更大一些,需要考慮的情況比較多。
最小生成樹
最小生成樹是所有節點的最小連通子圖,即:以最小的成本(邊的權值)將圖中所有節點鏈接到一起。
最小生成樹算法,有prim和kruskal。
prim算法是維護節點的集合,而Kruskal是維護邊的集合。
在稀疏圖中,用Kruskal更優。在稠密圖中,用prim算法更優。
邊數量較少為稀疏圖,接近或等于完全圖(所有節點皆相連)為稠密圖
Prim算法時間復雜度為O(n^2),其中n為節點數量,它的運行效率和圖中邊樹無關,適用稠密圖。
Kruskal算法時間復雜度為O(nlogn),其中n為邊的數量,適用稀疏圖。
關于prim算法,我自創了三部曲,來幫助大家理解:
第一步,選距離生成樹最近節點
第二步,最近節點加入生成樹
第三步,更新非生成樹節點到生成樹的距離(即更新minDist數組)
大家只要理解這三部曲,prim算法至少是可以寫出一個框架出來,然后在慢慢補充細節,這樣不至于自己在寫prim的時候兩眼一抹黑完全憑感覺去寫。
minDist數組是prim算法的靈魂,它幫助prim算法完成最重要的一步,就是如何找到距離最小生成樹最近的點。
kruscal的主要思路:
- 邊的權值排序,因為要優先選最小的邊加入到生成樹里
- 遍歷排序后的邊
- 如果邊首尾的兩個節點在同一個集合,說明如果連上這條邊圖中會出現環
- 如果邊首尾的兩個節點不在同一個集合,加入到最小生成樹,并把兩個節點加入同一個集合
而判斷節點是否在一個集合以及將兩個節點放入同一個集合,正是并查集的擅長所在。
所以Kruskal是需要用到并查集的。
這也是我在代碼隨想錄圖論編排上為什么要先講解并查集在講解最小生成樹。
拓撲排序
拓撲排序是在圖上的一種排序。
概括來說,給出一個有向圖,把這個有向圖轉成線性的排序就叫拓撲排序。
同樣,拓撲排序也可以檢測這個有向圖是否有環,即存在循環依賴的情況。
拓撲排序的一些應用場景,例如:大學排課,文件下載依賴等等。
只要記住如下兩步拓撲排序的過程,代碼就容易寫了:
- 找到入度為0的節點,加入結果集
- 將該節點從圖中移除
最短路算法
最短路算法是圖論中,比較復雜的算法,而且不同的最短路算法都有不同的應用場景。
我在最短路算法總結篇里已經做了一個高度的概括。
大家要時常溫故而知新,才能透徹理解各個最短路算法。