跳到主要內容

演算法

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

主題 · 回溯

回溯

一步一步做選擇,發現走不通就退回上一步換一個選擇。以 N 皇后為例,看剪枝如何讓搜尋量從天文數字降到幾千步。

N

在 6×6 的棋盤放 6 個皇后,任兩個都不在同一列、同一行或同一條斜線上。一列放一個,從左往右試。

1/368
♛
正在試衝突/退回已放的皇后
試過幾格
1
放下幾次
0
退回幾次
0
找到第一個解要試
171

試著把皇后放在第 1 列第 1 行。

亮起來的是這一步執行的程式碼
function solveNQueens(n: number): number[] | null {
const cols: number[] = []; // cols[r] = column of the queen in row r
function safe(row: number, col: number): boolean {
for (let r = 0; r < row; r++) {
const c = cols[r];
if (c === col || Math.abs(c - col) === row - r) {
return false;
}
}
return true;
}
function place(row: number): boolean {
if (row === n) return true;
for (let col = 0; col < n; col++) {
if (!safe(row, col)) continue;
cols.push(col);
if (place(row + 1)) return true;
cols.pop();
}
return false;
}
return place(0) ? cols : null;
}

模型假設與範圍

  • Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。

什麼時候用

  • 要在一大堆組合裡找出符合條件的(全部或任一個):排列、子集合、數獨、填字、排班。
  • 條件可以在做到一半時就檢查:越早發現走不通,剪掉的分支越大。
  • 沒有已知的多項式演算法、但問題規模不大時:回溯通常是最直接、也最好寫對的做法。

和其他主題的關係

由這些組成
資料結構 · 堆疊
被這些用到
考題:斷詞

時間與空間複雜度(Big O)

操作平均最差
N 皇后:找出所有解
剪枝讓實際搜尋量遠小於 N!,但仍是指數成長
O(N!)O(N!)
每次檢查安不安全
這裡的程式逐列比對;改用三個布林陣列記錄欄和兩種斜線,可降到 O(1)
O(N)O(N)
完全不剪枝的暴力法O(N^N)O(N^N)

空間:O(N),遞迴深度 N,加上記錄每列皇后位置的陣列

和其他做法比

解的個數放下幾次(找全部)試過幾格(找全部)每列每行各一個:N!完全不剪枝:N^N
N = 42166024256
N = 510532201203,125
N = 6415289472046,656
N = 7405513,5845,040823,543
N = 8922,05615,72040,32016,777,216

把全部的解找出來。N = 8 時,每列隨便放一個的盤面有 16,777,216 種;回溯只放下 2,056 次、試了 15,720 格,因為一發現衝突就整棵子樹不看了。成本仍然隨 N 爆炸性成長,只是長得慢得多。

真實世界裡的它

  • 數獨與各種解謎程式。
  • 正規表達式引擎(例如 JavaScript、Python、Java 內建的)遇到分支就是用回溯在試。
  • SAT(Boolean Satisfiability)求解器、Prolog 的查詢、排課與排班系統的核心都是有剪枝的回溯搜尋。

取捨與陷阱

  • 最差情況仍是指數時間:輸入稍微變大就可能跑不完。正規表達式的「災難性回溯」曾經讓整個網站停擺。
  • 退回時一定要把狀態還原(把剛放的拿掉):忘了還原,後面的分支就會在錯的狀態上繼續試。
  • 嘗試的順序很重要:先試最受限的位置(例如數獨先填候選最少的格子),能剪掉的分支多得多。

LeetCode 練習