最短路徑
BFS(Breadth-First Search,廣度優先搜尋)、Dijkstra、A* 在同一張地圖上找路:BFS 不管路有多難走,Dijkstra 管,A* 還知道終點大概在哪個方向。
演算法
地圖
畫筆
永遠先展開「從起點過來累積成本最低」的格子;泥巴一格算 5。用堆積當優先佇列。
293/293
已展開邊界(發現了、還沒展開)路徑泥巴:一步成本 5
Dijkstra 到達終點:35 步、成本 35,共展開 293 格。
三種做法在這張地圖上
| 展開格數 | 路徑步數 | 路徑成本 | |
|---|---|---|---|
| BFS | 254 | 21 | 49 |
| Dijkstra | 293 | 35 | 35 |
| A* | 171 | 35 | 35 |
亮起來的是這一步執行的程式碼
type Cell = [number, number];const WALL = 1, MUD = 2;const STEPS: Cell[] = [[0, 1], [1, 0], [0, -1], [-1, 0]]; // right, down, left, up function neighbours(grid: number[][], [r, c]: Cell): Cell[] { return STEPS.map(([dr, dc]): Cell => [r + dr, c + dc]).filter( ([nr, nc]) => grid[nr]?.[nc] !== undefined && grid[nr][nc] !== WALL, );} function walkBack(parent: Map<string, Cell | null>, goal: Cell): Cell[] { const path: Cell[] = []; for (let cell: Cell | null = goal; cell; cell = parent.get(String(cell))!) path.push(cell); return path.reverse();} function bfs(grid: number[][], start: Cell, goal: Cell) { const parent = new Map<string, Cell | null>([[String(start), null]]); const queue: Cell[] = [start]; for (let head = 0; head < queue.length; head++) { const cell = queue[head]; if (String(cell) === String(goal)) { return { path: walkBack(parent, goal), expanded: head + 1 }; } for (const next of neighbours(grid, cell)) { if (parent.has(String(next))) continue; parent.set(String(next), cell); queue.push(next); } } return { path: [], expanded: queue.length };} // Dijkstra when useEstimate is false, A* when it is true.function shortestPath(grid: number[][], start: Cell, goal: Cell, useEstimate: boolean) { const estimate = ([r, c]: Cell) => useEstimate ? Math.abs(r - goal[0]) + Math.abs(c - goal[1]) : 0; const best = new Map<string, number>([[String(start), 0]]); const parent = new Map<string, Cell | null>([[String(start), null]]); const done = new Set<string>(); const queue = new MinQueue(); let seq = 0; queue.push([estimate(start), 0, seq++, ...start]); while (queue.size > 0) { const [, negG, , r, c] = queue.pop(); const cell: Cell = [r, c], g = -negG; if (done.has(String(cell)) || g > best.get(String(cell))!) continue; done.add(String(cell)); if (String(cell) === String(goal)) { return { path: walkBack(parent, goal), cost: g, expanded: done.size }; } for (const next of neighbours(grid, cell)) { if (done.has(String(next))) continue; const nextG = g + (grid[next[0]][next[1]] === MUD ? 5 : 1); if (nextG >= (best.get(String(next)) ?? Infinity)) continue; best.set(String(next), nextG); parent.set(String(next), cell); queue.push([nextG + estimate(next), -nextG, seq++, ...next]); } } return { path: [], cost: Infinity, expanded: done.size };} class MinQueue { private a: number[][] = []; get size() { return this.a.length; } private less(x: number[], y: number[]) { return (x[0] - y[0] || x[1] - y[1] || x[2] - y[2]) < 0; } push(entry: number[]) { const a = this.a; a.push(entry); for (let i = a.length - 1, p = (i - 1) >> 1; i > 0 && this.less(a[i], a[p]); i = p, p = (i - 1) >> 1) { [a[i], a[p]] = [a[p], a[i]]; } } pop(): number[] { const a = this.a, top = a[0], last = a.pop()!; if (a.length === 0) return top; a[0] = last; for (let i = 0; ; ) { const l = 2 * i + 1, r = l + 1; let m = i; if (l < a.length && this.less(a[l], a[m])) m = l; if (r < a.length && this.less(a[r], a[m])) m = r; if (m === i) return top; [a[i], a[m]] = [a[m], a[i]]; i = m; } }}模型假設與範圍
- Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。
什麼時候用
- 每一步成本都一樣(迷宮、社群網路的「幾度分隔」):BFS 找到的就是最短路徑,也最簡單。
- 步驟成本不同、但都不是負的(道路長度、網路延遲):Dijkstra。
- 知道終點在哪、能估計剩下的距離(地圖、遊戲):A*。估計只要不高估,就保證找到最短的。
和其他主題的關係
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| BFS 每一步成本相同時才找得到最便宜的路 | O(V + E) | O(V + E) |
| Dijkstra 多出來的 log V 是堆積的進出 | O((V + E) log V) | O((V + E) log V) |
| A* 最差和 Dijkstra 一樣;估計越準,實際展開越少 | O((V + E) log V) | O((V + E) log V) |
空間:O(V),V 是格子數,E 是相鄰關係(格子地圖上 E ≤ 4V)
Big O 實測:n 變大時步數怎麼長
數的是:空曠方格地圖、起點左上終點右下;n 是格子數
| Big O | n = 256 | n = 1,024 | n = 4,096 | n = 16,384 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|---|
| BFS 展開格數 | O(n) | 256 | 1,024 | 4,096 | 16,384 | ×64 (×64) |
| Dijkstra 展開格數 | O(n) | 256 | 1,024 | 4,096 | 16,384 | ×64 (×64) |
| Dijkstra 堆積比較次數 | O(n log n) | 1,475 | 7,916 | 39,771 | 191,540 | ×130 (×112) |
A* 在同樣的地圖上只展開 31、63、127、255 格:沒有障礙時,估計值正好等於真實距離,它直直走到終點,展開數只跟邊長成正比。
和其他做法比
| 泥巴或繞路 | 迷宮 | 空地 | |
|---|---|---|---|
| BFS | 展開 254 格,成本 49 | 展開 230 格,成本 53 | 展開 260 格,成本 21 |
| Dijkstra | 展開 293 格,成本 35 | 展開 224 格,成本 37 | 展開 260 格,成本 21 |
| A* | 展開 171 格,成本 35 | 展開 148 格,成本 37 | 展開 22 格,成本 21 |
在三張預設地圖上各跑一次量出來的(迷宮用固定種子)。「展開」是從佇列取出來處理的格子數,到達終點就停。
真實世界裡的它
- 導航軟體在道路圖上跑 A* 的變形,再加上預先算好的捷徑層級。
- 路由協定 OSPF(Open Shortest Path First)在每台路由器上跑 Dijkstra,算出轉送表。
- 遊戲角色尋路幾乎都是格子或導航網格上的 A*。
取捨與陷阱
- BFS 不看成本:在「泥巴或繞路」地圖上,它選的是步數最少、直接穿過泥巴的路,總成本比 Dijkstra 找到的高。
- A* 的估計如果高估,就不保證最短;估得越接近真實值(但不超過),展開的格子越少。
- Dijkstra 遇到負成本的邊會算錯;那種圖要用 Bellman-Ford(這裡還沒收錄)。
- 優先佇列是關鍵:如果每次都掃一遍陣列找最小值,Dijkstra 會從 E log V 變成 V²。