遞迴與回溯法 — 從遞迴三要素到回溯模板完整教學 | 資料結構與演算法

2026/06/30
遞迴與回溯法 — 從遞迴三要素到回溯模板完整教學 | 資料結構與演算法

遞迴(Recursion) 是函式呼叫自身來分解問題的程式設計技巧;回溯法(Backtracking) 則是在遞迴搜尋樹上系統性地嘗試所有可能解,當發現當前路徑不可能產生合法解時,立即撤銷(unchoose)並換下一條路徑的演算法策略。掌握這兩者,是解決排列、組合、子集、N-Queens 等經典面試題的關鍵。

前言

你有沒有試過打開俄羅斯套娃?打開最外層,裡面還有一個一模一樣形狀的娃娃;打開第二層,又是一個更小的;直到拆到最小那一個,再也打不開了——這就是遞迴最直觀的比喻。

回溯法(Backtracking)則像是在迷宮中探索:每到一個岔路口,你選一條路走進去;若碰壁,原路退回岔路口,再試另一條。這種「走不通就回頭」的策略,正是回溯的精髓。

這兩個概念放在一起,能解決演算法世界裡一大類「窮舉所有可能」的問題。讀完這篇文章,你將能夠:

  • 理解遞迴三要素,避免無窮遞迴與堆疊溢出
  • 掌握 choose → explore → unchoose 回溯模板,套用到任何搜尋問題
  • 實作排列(Permutation)、組合(Combination)、子集(Subset)、N-Queens 四大經典題
  • 學會剪枝(Pruning)策略,讓回溯從「暴力搜尋」變成「智慧搜尋」
  • 看懂 C++ 位元運算最佳化版本,面試中展示深度
  • 對應 LeetCode 46、78、39、51、17 等高頻考題

核心概念一:遞迴三要素

任何正確的遞迴函式都必須同時滿足以下三個條件。少了任何一個,程式就會出錯。

1. Base Case(終止條件)

Base Case 定義最小子問題的答案,阻止函式無限呼叫自身。若缺少 Base Case,call stack 會不斷累積呼叫框架,最終觸發 Stack Overflow

最典型的例子是計算階乘(Factorial):

// n! = n × (n-1) × (n-2) × ... × 1
function factorial(n: number): number {
  // Base Case:0! = 1,停止遞迴
  if (n === 0) return 1;

  // 遞迴步驟:n! = n × (n-1)!
  return n * factorial(n - 1);
}

console.log(factorial(5)); // 輸出:120
// 呼叫鏈:factorial(5) → factorial(4) → factorial(3) → factorial(2) → factorial(1) → factorial(0) = 1

2. 遞迴關係(Recurrence Relation)

遞迴關係定義如何把規模為 n 的問題,拆解為規模更小的相同問題。數學上可用以下三種典型關係表示:

T(n) = T(n-1) + O(1)    ← 線性遞迴(如走訪鏈結串列)
T(n) = 2·T(n/2) + O(n)  ← 二分遞迴(如 Merge Sort)
T(n) = n·T(n-1)          ← 指數遞迴(如全排列)

以費波那契數列(Fibonacci)為例,它的遞迴關係是 F(n) = F(n-1) + F(n-2)

// 純遞迴版(有重複計算問題,僅作示意)
function fibNaive(n: number): number {
  if (n <= 1) return n;               // Base Case
  return fibNaive(n - 1) + fibNaive(n - 2); // 遞迴關係
}

// 記憶化版本(O(n) 時間,避免重複計算)
function fib(n: number, memo: Map<number, number> = new Map()): number {
  if (n <= 1) return n;
  if (memo.has(n)) return memo.get(n)!;

  const result = fib(n - 1, memo) + fib(n - 2, memo);
  memo.set(n, result);
  return result;
}

console.log(fib(10)); // 輸出:55
console.log(fib(50)); // 輸出:12586269025(純遞迴版會超時,記憶化版瞬間完成)

3. 收斂性(Convergence)

每次遞迴呼叫都必須讓問題規模嚴格縮小,確保最終能觸及 Base Case。若每次呼叫規模不變甚至增大,即使有 Base Case 也永遠到不了。

// 錯誤示範:規模沒有縮小,無窮遞迴
function badRecursion(n: number): number {
  if (n === 0) return 0;
  return badRecursion(n); // 錯誤:傳入相同的 n,永遠不會到達 Base Case
}

// 正確:每次呼叫 n 嚴格減少
function goodRecursion(n: number): number {
  if (n === 0) return 0;
  return goodRecursion(n - 1) + 1; // 正確:n-1 讓問題規模縮小
}

核心概念二:呼叫堆疊視覺化

遞迴執行時,每次函式呼叫都會在呼叫堆疊(Call Stack)上新增一個堆疊框架(Stack Frame),儲存當前函式的局部變數與返回地址。以 factorial(3) 為例:

呼叫階段(向下展開):
┌─────────────────────┐
│  factorial(0) = 1   │  ← 到達 Base Case,開始返回
├─────────────────────┤
│  factorial(1)       │  ← 等待 factorial(0) 的結果
├─────────────────────┤
│  factorial(2)       │  ← 等待 factorial(1) 的結果
├─────────────────────┤
│  factorial(3)       │  ← 最初呼叫
└─────────────────────┘

返回階段(向上回傳):
factorial(0) → 1
factorial(1) → 1 × 1 = 1
factorial(2) → 2 × 1 = 2
factorial(3) → 3 × 2 = 6  ← 最終結果

這個結構說明為什麼遞迴深度過深會導致 Stack Overflow:每個框架都佔用記憶體,N 層深度就需要 N 個框架同時存在。


核心概念三:回溯模板 — choose → explore → unchoose

回溯法的核心是三步循環:

function backtrack(狀態, 選擇列表):
    if 達到終止條件:
        記錄結果
        return

    for 每個選擇 in 選擇列表:
        1. choose   — 將選擇加入當前狀態
        2. explore  — 遞迴進入下一層
        3. unchoose — 撤銷選擇,恢復狀態(回溯)

這個模板的關鍵是 unchoose 步驟必須與 choose 步驟完全對稱。做了什麼,就要撤銷什麼,讓下一次迭代從同樣的乾淨狀態出發。


JS/TS 實作

子集(Subsets)— LeetCode 78

子集問題:給定不重複整數陣列 nums,回傳所有可能的子集(包括空集合)。

決策樹邏輯:每個元素有「選」與「不選」兩種決定,總共 2ⁿ 個子集。

以 nums = [1, 2, 3] 為例,組合搜尋樹(k=2, n=4):
                    []
    ┌───────┬───────┬───────┐
   [1]     [2]     [3]     [4]
  ┌─┼─┐   ┌─┼─┐   └─┐
[1,2][1,3][1,4] [2,3][2,4] [3,4]
  ✓    ✓    ✓    ✓    ✓    ✓
                           ↑
           從 i+1 開始避免重複
// 時間:O(2ⁿ × n)  空間:O(n)(call stack 深度)
function subsets(nums: number[]): number[][] {
  const result: number[][] = [];
  const path: number[] = [];

  function backtrack(start: number): void {
    // 每個節點都是一個合法子集(包含空集合),直接記錄
    result.push([...path]); // 必須淺複製!

    for (let i = start; i < nums.length; i++) {
      path.push(nums[i]);  // choose:加入當前元素
      backtrack(i + 1);    // explore:從 i+1 開始,避免重複
      path.pop();          // unchoose:撤銷,回到上一狀態
    }
  }

  backtrack(0);
  return result;
}

console.log(subsets([1, 2, 3]));
// 輸出:[[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]

全排列(Permutations)— LeetCode 46

排列問題:給定不重複整數陣列 nums,回傳所有全排列。

決策樹邏輯:每次從剩餘未使用元素中選一個,共 n! 個排列。

nums = [1, 2, 3] 的全排列決策樹:
                    []
      ┌─────────────┼─────────────┐
     [1]           [2]           [3]
   ┌──┴──┐       ┌──┴──┐       ┌──┴──┐
 [1,2] [1,3]  [2,1] [2,3]   [3,1] [3,2]
   │     │      │     │       │     │
[1,2,3][1,3,2][2,1,3][2,3,1][3,1,2][3,2,1]
  ✓     ✓      ✓     ✓       ✓     ✓

總共 3! = 6 個葉節點(合法排列)
// 時間:O(n! × n)  空間:O(n)
function permute(nums: number[]): number[][] {
  const result: number[][] = [];
  const path: number[] = [];
  const used: boolean[] = new Array(nums.length).fill(false);

  function backtrack(): void {
    // Base Case:path 已收集所有元素
    if (path.length === nums.length) {
      result.push([...path]);
      return;
    }

    for (let i = 0; i < nums.length; i++) {
      if (used[i]) continue; // 剪枝:已在 path 中的元素跳過

      // choose
      used[i] = true;
      path.push(nums[i]);

      // explore
      backtrack();

      // unchoose(回溯)
      path.pop();
      used[i] = false;
    }
  }

  backtrack();
  return result;
}

console.log(permute([1, 2, 3]));
// 輸出:[[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]

排列 vs 組合的關鍵差異:

特性排列(Permutation)組合/子集(Combination/Subset)
順序重要,[1,2][2,1]不重要,[1,2] = [2,1]
控制重複used[] 陣列,每次從頭掃描start 索引,只向後選取
遞迴參數不傳 start,用 used[] 控制i+1 給下一層

組合總和(Combination Sum)— LeetCode 39

組合問題:給定候選數字陣列 candidates 和目標 target,找出所有和等於 target 的組合(元素可重複使用)。

// 時間:O(2^(t/m)),t = target,m = 最小元素值  空間:O(t/m)
function combinationSum(candidates: number[], target: number): number[][] {
  const result: number[][] = [];
  const path: number[] = [];

  // 排序後可進行剪枝:當前元素超過 remaining,後面更大的都跳過
  candidates.sort((a, b) => a - b);

  function backtrack(start: number, remaining: number): void {
    if (remaining === 0) {
      result.push([...path]);
      return;
    }

    for (let i = start; i < candidates.length; i++) {
      // 可行性剪枝:candidates 已排序,後面的元素只會更大
      if (candidates[i] > remaining) break;

      path.push(candidates[i]);               // choose
      backtrack(i, remaining - candidates[i]); // explore(傳 i 而非 i+1,允許重複使用)
      path.pop();                              // unchoose
    }
  }

  backtrack(0, target);
  return result;
}

console.log(combinationSum([2, 3, 6, 7], 7));
// 輸出:[[2,2,3], [7]]

N-Queens — LeetCode 51

N-Queens 問題:在 n×n 棋盤上放置 n 個皇后,使任意兩個皇后不在同一行、列或對角線上。

N-Queens 搜尋樹(n=4,部分展示):
                  root
    ┌──────┬──────┬──────┐
   Q在col0 Q在col1 Q在col2 Q在col3
    │       │      │       │
  row1:   row1:  row1:   row1:
剪枝(對角) [4,2] [4,1]  [1,3]  ← 合法延伸
                         ↑
              剪枝:提早剪掉無效分支

關鍵洞察:

  • 同一行(row - col 相同)→ 同一條正對角線
  • 同一行(row + col 相同)→ 同一條反對角線
// 時間:O(n!)  空間:O(n)
function solveNQueens(n: number): string[][] {
  const result: string[][] = [];
  // 使用三個 Set 追蹤列、正對角線、反對角線的衝突
  const cols = new Set<number>();
  const diag1 = new Set<number>(); // row - col(正對角線)
  const diag2 = new Set<number>(); // row + col(反對角線)
  const board: number[] = [];      // board[row] = col

  function backtrack(row: number): void {
    // Base Case:所有行都放好皇后
    if (row === n) {
      result.push(
        board.map(col =>
          '.'.repeat(col) + 'Q' + '.'.repeat(n - col - 1)
        )
      );
      return;
    }

    for (let col = 0; col < n; col++) {
      // 可行性剪枝:同列、正對角、反對角已有皇后
      if (cols.has(col) || diag1.has(row - col) || diag2.has(row + col)) {
        continue;
      }

      // choose
      cols.add(col);
      diag1.add(row - col);
      diag2.add(row + col);
      board.push(col);

      backtrack(row + 1); // explore

      // unchoose
      board.pop();
      cols.delete(col);
      diag1.delete(row - col);
      diag2.delete(row + col);
    }
  }

  backtrack(0);
  return result;
}

console.log(solveNQueens(4).length); // 輸出:2(4-Queens 有兩種解法)

電話號碼的字母組合(Letter Combinations)— LeetCode 17

給定手機號碼字串,回傳所有可能的字母組合。

// 時間:O(4^n × n)  空間:O(n)
function letterCombinations(digits: string): string[] {
  if (!digits) return [];

  const phoneMap: Record<string, string> = {
    '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
    '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
  };

  const result: string[] = [];
  const path: string[] = [];

  function backtrack(idx: number): void {
    // Base Case:已處理所有數字
    if (idx === digits.length) {
      result.push(path.join(''));
      return;
    }

    const letters = phoneMap[digits[idx]];
    for (const letter of letters) {
      path.push(letter);   // choose
      backtrack(idx + 1);  // explore
      path.pop();          // unchoose
    }
  }

  backtrack(0);
  return result;
}

console.log(letterCombinations('23'));
// 輸出:['ad', 'ae', 'af', 'bd', 'be', 'bf', 'cd', 'ce', 'cf']

C++ 對照實作

全排列含重複元素(Permutations II)— LeetCode 47

C++ 版本搭配排序和重複剪枝,展示如何處理含重複數字的排列問題:

#include <vector>
#include <algorithm>
using namespace std;

class Solution {
public:
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        vector<vector<int>> result;
        vector<int> path;
        vector<bool> used(nums.size(), false);

        sort(nums.begin(), nums.end()); // 排序是重複剪枝的必要前提
        backtrack(nums, used, path, result);
        return result;
    }

private:
    void backtrack(
        const vector<int>& nums,
        vector<bool>& used,
        vector<int>& path,
        vector<vector<int>>& result
    ) {
        if (path.size() == nums.size()) {
            result.push_back(path);
            return;
        }

        for (int i = 0; i < (int)nums.size(); i++) {
            if (used[i]) continue;
            // 重複剪枝:同一位置不選相同的值
            // 條件:前一個相同值「尚未被使用」表示我們在同層選了重複元素
            if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;

            used[i] = true;
            path.push_back(nums[i]);              // choose
            backtrack(nums, used, path, result);  // explore
            path.pop_back();                      // unchoose
            used[i] = false;
        }
    }
};

N-Queens 位元運算最佳化版

C++ 可利用整數位元遮罩(bitmask)替代 Set,速度更快:

#include <vector>
#include <string>
using namespace std;

class Solution {
public:
    vector<vector<string>> solveNQueens(int n) {
        vector<vector<string>> result;
        vector<int> queens(n, -1); // queens[row] = col

        // cols、diag1、diag2 各為整數,每個位元代表對應欄位是否被佔用
        backtrack(n, 0, 0, 0, 0, queens, result);
        return result;
    }

private:
    void backtrack(
        int n, int row,
        int cols, int diag1, int diag2, // 位元遮罩
        vector<int>& queens,
        vector<vector<string>>& result
    ) {
        if (row == n) {
            result.push_back(buildBoard(queens, n));
            return;
        }

        // available:此行中沒有衝突的欄位集合
        int available = ((1 << n) - 1) & ~(cols | diag1 | diag2);

        while (available) {
            int bit = available & (-available); // 取最低位的可用欄
            available &= available - 1;         // 清除最低位

            int col = __builtin_ctz(bit);       // 轉換為欄號
            queens[row] = col;

            backtrack(
                n, row + 1,
                cols | bit,
                (diag1 | bit) << 1, // 正對角線向下移動(左移)
                (diag2 | bit) >> 1, // 反對角線向下移動(右移)
                queens, result
            );

            queens[row] = -1; // unchoose(位元遮罩在遞迴參數中自動恢復)
        }
    }

    vector<string> buildBoard(const vector<int>& queens, int n) {
        vector<string> board(n, string(n, '.'));
        for (int r = 0; r < n; r++) {
            board[r][queens[r]] = 'Q';
        }
        return board;
    }
};

複雜度分析表

問題類型時間複雜度空間複雜度(Call Stack)說明
全排列 PermutationO(n! × n)O(n)n! 個排列,每個長度 n
組合 CombinationO(C(n,k) × k)O(k)C(n,k) 個組合
子集 SubsetO(2ⁿ × n)O(n)每個元素選/不選
N-QueensO(n!)O(n)每列選一欄,互不衝突
數獨 SudokuO(9^m)O(m)m 為空格數,每格最多 9 選擇
電話號碼字母組合O(4^n × n)O(n)最多 4 個字母/數字

剪枝策略

剪枝(Pruning)是讓回溯法從「暴力搜尋」變成「智慧搜尋」的核心手段。好的剪枝可以將指數時間大幅壓縮。

可行性剪枝(Feasibility Pruning)

當前狀態已違反問題約束,直接返回,不繼續深入探索:

// 組合總和:candidates 排序後,超過 remaining 就直接 break
for (let i = start; i < candidates.length; i++) {
  if (candidates[i] > remaining) break; // 可行性剪枝
  // ...
}

重複元素剪枝(Duplicate Pruning)

處理含重複元素的問題時,排序後跳過同層中相同值的選擇:

// 子集含重複(LeetCode 90)
function subsetsWithDup(nums: number[]): number[][] {
  const result: number[][] = [];
  nums.sort((a, b) => a - b); // 必須先排序!

  function backtrack(start: number, path: number[]): void {
    result.push([...path]);
    for (let i = start; i < nums.length; i++) {
      // 重複剪枝:同一層(start 相同)中,跳過與前一個相同的值
      if (i > start && nums[i] === nums[i - 1]) continue;
      path.push(nums[i]);
      backtrack(i + 1, path);
      path.pop();
    }
  }

  backtrack(0, []);
  return result;
}

上界剪枝(Upper-Bound Pruning)

剩餘候選元素數量已不足以填滿所需長度,提前返回:

// 組合(k 個數,從 [1..n] 中選)— 上界剪枝
function combine(n: number, k: number): number[][] {
  const result: number[][] = [];

  function backtrack(start: number, path: number[]): void {
    if (path.length === k) {
      result.push([...path]);
      return;
    }

    // 上界剪枝:剩餘可選元素 (n - i + 1) 必須 >= 還需要選的個數 (k - path.length)
    for (let i = start; i <= n - (k - path.length) + 1; i++) {
      path.push(i);
      backtrack(i + 1, path);
      path.pop();
    }
  }

  backtrack(1, []);
  return result;
}

剪枝策略整理:

剪枝類型說明典型場景
可行性剪枝當前狀態已違反約束,直接返回N-Queens 衝突檢查、組合超過 target
重複剪枝排序後跳過相同值,避免重複子集Combination Sum II、Subsets II
上界剪枝剩餘元素不足,提前返回組合 k 個數問題
最優性剪枝當前路徑即使走完也比最優解差旅行商問題(TSP)

面試考點

排列 vs 組合:何時用哪個模板

面試中最常見的混淆點是搞不清楚什麼時候用「start 索引控制」,什麼時候用「used[] 陣列控制」:

  • 組合/子集問題(順序不重要):傳遞 start 索引,每層只從 start 之後選取,天然避免重複
  • 排列問題(順序重要):不傳 start,每層從頭開始掃描,用 used[] 陣列標記哪些元素已被選入當前路徑
// 組合:i 從 start 開始,避免 [1,2] 和 [2,1] 重複計算
for (let i = start; i < nums.length; i++) { ... }

// 排列:i 永遠從 0 開始,用 used[] 避免同一元素被選兩次
for (let i = 0; i < nums.length; i++) {
  if (used[i]) continue;
  ...
}

何時應該用回溯法

回溯法適用於以下特徵的問題:

  1. 需要窮舉所有可能解(排列、組合、子集)
  2. 約束條件複雜,難以直接計算(N-Queens、數獨)
  3. 問題可以建模為搜尋樹(每一步有有限選擇,遞迴深度有限)
  4. 需要找出「是否存在」或「所有」合法路徑

不適合的場景:若問題有最優子結構重疊子問題,應優先考慮動態規劃(Dynamic Programming)。

常見陷阱

陷阱一:忘記淺複製結果

// 錯誤:推入 path 的引用,後續修改會影響已記錄的結果
result.push(path);

// 正確:推入淺複製
result.push([...path]);

陷阱二:遺漏 unchoose

// 錯誤:只 choose,沒有 unchoose,path 會帶著舊值繼續迭代
path.push(nums[i]);
backtrack(i + 1);
// 忘記 path.pop()!

// 正確:choose 和 unchoose 必須成對出現
path.push(nums[i]);  // choose
backtrack(i + 1);    // explore
path.pop();          // unchoose ← 不可省略

陷阱三:含重複元素未排序就剪枝

重複剪枝的前提是陣列已排序,否則 nums[i] === nums[i-1] 的比對沒有意義。


LeetCode 練習題

#題目難度核心技巧時間複雜度
46PermutationsMedium排列模板 + used[] 陣列O(n! × n)
78SubsetsMedium子集模板,每節點即記錄O(2ⁿ × n)
39Combination SumMedium組合模板 + 可行性剪枝 + 允許重複O(2^(t/m))
51N-QueensHard可行性剪枝 + 位元遮罩(C++)O(n!)
17Letter CombinationsMedium字元映射 + 回溯,無 used[]O(4^n × n)

建議練習順序: 78(子集)→ 46(排列)→ 39(組合)→ 17(電話號碼)→ 51(N-Queens)

這個順序從最基礎的模板出發,逐步加入更多變化:子集讓你理解「每個節點即結果」;排列讓你掌握 used[];組合加入剪枝;電話號碼驗證字元映射;N-Queens 是最完整的可行性剪枝應用。


總結

遞迴與回溯法是演算法學習中的重要里程碑。我們在這篇文章中涵蓋了:

  • 遞迴三要素:Base Case 阻止無窮遞迴、遞迴關係拆解問題、收斂性確保終止
  • 回溯模板:choose → explore → unchoose 三步驟,配對出現,不可省略 unchoose
  • 四大經典實作:子集(78)、排列(46)、組合總和(39)、N-Queens(51)
  • 剪枝策略:可行性剪枝、重複剪枝、上界剪枝,讓暴力搜尋變成智慧搜尋
  • C++ 位元運算版本:以整數遮罩取代 Set,提升常數倍效能
  • 排列 vs 組合的本質差異start 索引 vs used[] 陣列

理解了這兩個概念,你不只能解出 LeetCode 的回溯題,更能看懂正規表達式引擎的 NFA 回溯、遊戲 AI 的 Minimax 搜尋,乃至拼字檢查器的 Trie 剪枝——它們都是同一個思路的不同應用。

如果這篇文章對你有幫助,歡迎繼續閱讀下一篇:雙指標技巧,我們將深入探索 Two Pointers 在陣列與字串問題中的強大應用。如有任何問題或疑惑,歡迎透過 聯絡頁面 與我交流!

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。