跳到主要內容

演算法

同一個問題,不同的解題思路

主題 · 最短路徑

最短路徑

BFS(Breadth-First Search,廣度優先搜尋)、Dijkstra、A* 在同一張地圖上找路:BFS 不管路有多難走,Dijkstra 管,A* 還知道終點大概在哪個方向。

演算法
地圖
畫筆

永遠先展開「從起點過來累積成本最低」的格子;泥巴一格算 5。用堆積當優先佇列。

293/293
≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈S≈≈≈≈≈≈≈G≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈≈
已展開邊界(發現了、還沒展開)路徑泥巴:一步成本 5

Dijkstra 到達終點:35 步、成本 35,共展開 293 格。

三種做法在這張地圖上
展開格數路徑步數路徑成本
BFS2542149
Dijkstra2933535
A*1713535
亮起來的是這一步執行的程式碼
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 On = 256n = 1,024n = 4,096n = 16,384成長倍數:實測(理論)
BFS 展開格數O(n)2561,0244,09616,384×64 (×64)
Dijkstra 展開格數O(n)2561,0244,09616,384×64 (×64)
Dijkstra 堆積比較次數O(n log n)1,4757,91639,771191,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²。

LeetCode 練習