分治法 — Divide and Conquer 三階段與 Master Theorem | 資料結構與演算法

2026/07/06
分治法 — Divide and Conquer 三階段與 Master Theorem | 資料結構與演算法

分治法(Divide and Conquer) 是演算法設計中最優雅的範式之一:把大問題切成小問題,各自擊破,再將答案組合回來。從 Merge SortQuick Select、從 最近點對FFT,分治法的三階段思維貫穿了無數高效演算法的核心,也是面試中理解遞迴複雜度的重要框架。

前言

想像你要整理一間塞滿雜物的大倉庫。直接從頭到尾清理太耗時,你會怎麼做?把倉庫分成兩半,分別整理,再把兩半合在一起——這就是分治法(Divide and Conquer)的日常縮影。

分治法之所以強大,在於它能將線性時間的暴力搜尋壓縮成對數層次。合併排序從 O(n²) 的比較排序進化到 O(n log n);快速選擇(Quick Select)在 O(n) 期望時間內找出第 K 大元素;最近點對問題從 O(n²) 的暴力比較優化到 O(n log n)。這一切的核心都是同一個思想:把問題規模減半,遞迴地解決子問題。

讀完本文,你將能夠:

  • 掌握分治法三階段(Divide、Conquer、Combine)的核心思維
  • 理解並應用 Master Theorem(主定理) 分析遞迴複雜度
  • 實作 Merge Sort、Quick Select、最近點對、Merge K Sorted Lists
  • 理解分治法與動態規劃的本質差異
  • 解決 LeetCode 23、215、53、169、240 等經典題目

核心概念

分治三階段

分治法的執行流程固定為三個清晰的階段:

1. Divide(分解)

將原問題切割為若干個規模更小的子問題。子問題通常是原問題的同類型縮小版本,且彼此之間盡量獨立。Merge Sort 每次將陣列對半切;Quick Select 則以 pivot 為界,只選其中一側繼續處理。

2. Conquer(征服)

對每個子問題遞迴求解。當子問題規模小到可以直接解決時(base case),直接回傳答案。例如陣列只剩一個元素,就直接回傳,不需再分割。

3. Combine(合併)

將所有子問題的解組合成原問題的解。合併步驟的複雜度往往決定了整個演算法的效率。 Merge Sort 的合併需要 O(n),最近點對的合併也需要 O(n),這決定了它們的總複雜度。

關鍵洞察:分治 vs 動態規劃

分治法要求子問題之間互不重疊——每個子問題只解一次。若子問題之間存在大量重疊(如 Fibonacci 數列的計算),則應改用動態規劃(Dynamic Programming),利用記憶化避免重複計算。

遞迴樹與 Master Theorem

理解分治法的複雜度,最直覺的方式是畫出遞迴樹(Recursion Tree):每個節點代表一個子問題,節點的成本是分解與合併的工作量,葉節點是 base case。

對於形如 T(n) = aT(n/b) + f(n) 的遞迴關係,Master Theorem(主定理) 提供了直接求解複雜度的公式:

  • a:子問題數量(每次分裂出幾個子問題)
  • b:每次分割縮小的比例(子問題規模為 n/b)
  • f(n):分解與合併的額外工作量

三種情況(以 n^(log_b a) 為分界線):

情況條件結論直覺
Case 1f(n) = O(n^(log_b a - ε)),ε > 0T(n) = Θ(n^log_b a)葉節點主導,根節點工作量小
Case 2f(n) = Θ(n^log_b a · log^k n)T(n) = Θ(n^log_b a · log^(k+1) n)各層工作量相等,累乘一個 log
Case 3f(n) = Ω(n^(log_b a + ε)),ε > 0T(n) = Θ(f(n))根節點主導,合併工作量大

經典例子套用:

演算法遞迴關係a, b, f(n)情況複雜度
Merge SortT(n) = 2T(n/2) + O(n)a=2, b=2, f=nCase 2 (k=0)O(n log n)
Binary SearchT(n) = T(n/2) + O(1)a=1, b=2, f=1Case 2 (k=0)O(log n)
Quick Select (avg)T(n) = T(n/2) + O(n)a=1, b=2, f=nCase 3O(n)
Strassen 矩陣乘法T(n) = 7T(n/2) + O(n²)a=7, b=2, f=n²Case 1O(n^2.807)
Merge K ListsT(k) = 2T(k/2) + O(N)O(N log k)

Merge Sort 遞迴樹圖解

原始陣列:[8, 3, 5, 1, 9, 2, 7, 4]

━━━━━━━━━━ Divide 階段(遞迴拆分)━━━━━━━━━━

Level 0:  [8, 3, 5, 1, 9, 2, 7, 4]             ← 1 個問題,規模 n
                    ↙        ↘
Level 1:  [8, 3, 5, 1]    [9, 2, 7, 4]         ← 2 個問題,規模 n/2
              ↙   ↘           ↙    ↘
Level 2:  [8, 3] [5, 1]  [9, 2]  [7, 4]        ← 4 個問題,規模 n/4
           ↙↘    ↙↘      ↙↘      ↙↘
Level 3:  [8][3] [5][1] [9][2]  [7][4]          ← base case(8 個葉節點)

━━━━━━━━━━ Combine 階段(合併)━━━━━━━━━━

[8][3] → merge → [3, 8]
[5][1] → merge → [1, 5]
[9][2] → merge → [2, 9]
[7][4] → merge → [4, 7]

[3,8] + [1,5] → merge → [1, 3, 5, 8]
[2,9] + [4,7] → merge → [2, 4, 7, 9]

[1,3,5,8] + [2,4,7,9] → merge → [1, 2, 3, 4, 5, 7, 8, 9]

━━━━━━━━━━ 複雜度分析(每層工作量)━━━━━━━━━━

Level 0: 1 次合併,規模 n   → O(n)
Level 1: 2 次合併,規模 n/2 → O(n)
Level 2: 4 次合併,規模 n/4 → O(n)
Layer 3: base case,O(n)

共 log n 層,每層 O(n) → 總計 O(n log n)

Quick Select 只遞迴一側

找第 3 小的元素(k=2,0-indexed)
陣列:[7, 2, 9, 4, 1, 8, 3]

Partition(pivot=4,Lomuto 分割法):
  分割後:[2, 1, 3, 4, 7, 9, 8]
                     ^ pivot 落在 index=3

k=2 < pivotIdx=3 → 只遞迴左半 [2, 1, 3](分治:只看一側!)

Partition(pivot=1):
  分割後:[1, 2, 3]
          ^ pivot 落在 index=0

k=2 > pivotIdx=0 → 只遞迴右半 [2, 3](相對索引 k=1)

最終:第 3 小 = 3

對比:
  Merge Sort  → 每層處理兩側 → O(n log n)
  Quick Select → 每層只處理一側 → 平均 O(n)

TypeScript / JavaScript 實作

Merge Sort(in-place + 逆序對計數)

Merge Sort 是分治法最經典的示範:每次對半分、遞迴排序兩半、再線性合併。穩定排序(stable sort),時間 O(n log n),空間 O(n)。

在合併步驟中加入計數邏輯,還能順帶解決逆序對計數問題:

// 標準歸併排序(in-place 版本)
function mergeSortInPlace(arr: number[], left = 0, right = arr.length - 1): void {
  if (left >= right) return;  // base case:單一元素不需排序

  const mid = Math.floor((left + right) / 2);
  mergeSortInPlace(arr, left, mid);       // Conquer 左半
  mergeSortInPlace(arr, mid + 1, right);  // Conquer 右半
  mergeRange(arr, left, mid, right);      // Combine
}

function mergeRange(arr: number[], left: number, mid: number, right: number): void {
  // 暫存左右兩段的拷貝(避免覆蓋)
  const leftPart = arr.slice(left, mid + 1);
  const rightPart = arr.slice(mid + 1, right + 1);

  let i = 0, j = 0, k = left;
  while (i < leftPart.length && j < rightPart.length) {
    // <= 保持穩定性:相等時優先取左側
    if (leftPart[i] <= rightPart[j]) {
      arr[k++] = leftPart[i++];
    } else {
      arr[k++] = rightPart[j++];
    }
  }
  // 將剩餘元素填入
  while (i < leftPart.length) arr[k++] = leftPart[i++];
  while (j < rightPart.length) arr[k++] = rightPart[j++];
}

// 在 Merge Sort 中順便計算逆序對數量
function mergeSortAndCount(arr: number[], left = 0, right = arr.length - 1): number {
  if (left >= right) return 0;

  const mid = Math.floor((left + right) / 2);
  let count = 0;
  count += mergeSortAndCount(arr, left, mid);
  count += mergeSortAndCount(arr, mid + 1, right);
  count += mergeAndCount(arr, left, mid, right);
  return count;
}

function mergeAndCount(arr: number[], left: number, mid: number, right: number): number {
  const leftPart = arr.slice(left, mid + 1);
  const rightPart = arr.slice(mid + 1, right + 1);

  let i = 0, j = 0, k = left;
  let inversions = 0;

  while (i < leftPart.length && j < rightPart.length) {
    if (leftPart[i] <= rightPart[j]) {
      arr[k++] = leftPart[i++];
    } else {
      // rightPart[j] 小於 leftPart[i..end] 中的所有元素
      // 形成 (leftPart.length - i) 個逆序對
      inversions += leftPart.length - i;
      arr[k++] = rightPart[j++];
    }
  }
  while (i < leftPart.length) arr[k++] = leftPart[i++];
  while (j < rightPart.length) arr[k++] = rightPart[j++];

  return inversions;
}

// 測試
const arr1 = [8, 3, 5, 1, 9, 2, 7, 4];
mergeSortInPlace(arr1);
console.log(arr1);  // 輸出:[1, 2, 3, 4, 5, 7, 8, 9]

const arr2 = [5, 2, 3, 1];
console.log(mergeSortAndCount([...arr2]));  // 輸出:6(逆序對:(5,2),(5,3),(5,1),(2,1),(3,1),(3,1) 共 6 對)

Quick Select(找第 K 大元素 — LeetCode 215)

Quick Select 是分治法的「剪枝」版本:每次 Partition 後,pivot 落在最終排序位置;只需要遞迴目標所在的那一側,期望時間 O(n)。

// 隨機化 Quick Select,期望 O(n) 時間,O(1) 額外空間
function findKthLargest(nums: number[], k: number): number {
  // 找第 k 大 = 升序陣列中 index = n-k 的元素(0-indexed)
  return quickSelect(nums, 0, nums.length - 1, nums.length - k);
}

function quickSelect(arr: number[], left: number, right: number, targetIdx: number): number {
  if (left === right) return arr[left];

  // 隨機化 pivot:避免已排序輸入的 O(n²) 退化
  const pivotIdx = left + Math.floor(Math.random() * (right - left + 1));
  const partitionIdx = lomutoPartition(arr, left, right, pivotIdx);

  if (partitionIdx === targetIdx) {
    return arr[partitionIdx];          // pivot 就是目標!
  } else if (partitionIdx < targetIdx) {
    return quickSelect(arr, partitionIdx + 1, right, targetIdx);   // 目標在右半
  } else {
    return quickSelect(arr, left, partitionIdx - 1, targetIdx);    // 目標在左半
  }
}

function lomutoPartition(arr: number[], left: number, right: number, pivotIdx: number): number {
  const pivot = arr[pivotIdx];
  // 將 pivot 移到末尾暫存
  [arr[pivotIdx], arr[right]] = [arr[right], arr[pivotIdx]];

  let storeIdx = left;
  for (let i = left; i < right; i++) {
    if (arr[i] < pivot) {
      [arr[storeIdx], arr[i]] = [arr[i], arr[storeIdx]];
      storeIdx++;
    }
  }
  // 將 pivot 放回正確位置
  [arr[storeIdx], arr[right]] = [arr[right], arr[storeIdx]];
  return storeIdx;
}

// 測試
console.log(findKthLargest([3, 2, 1, 5, 6, 4], 2));         // 輸出:5(第 2 大)
console.log(findKthLargest([3, 2, 3, 1, 2, 4, 5, 5, 6], 4)); // 輸出:4(第 4 大)

最近點對(Closest Pair of Points)

最近點對問題是分治法中最精妙的案例之一。暴力解法是 O(n²),分治法通過巧妙的中線 strip 處理,將合併步驟壓縮到 O(n),總複雜度降至 O(n log n)。

數學關鍵: 在中線左右各 d 寬的 strip 中,每個高度為 d 的矩形區域最多容納 8 個點(由於左右兩側的點距離均 ≥ d),因此內層迴圈最多比較 7 個點,等同於 O(1),讓整個 strip 掃描維持 O(n)。

interface Point {
  x: number;
  y: number;
}

function distance(p1: Point, p2: Point): number {
  return Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2);
}

function closestPair(points: Point[]): number {
  // 預先按 X 排序,後續遞迴只需按 Y 排序
  const sortedByX = [...points].sort((a, b) => a.x - b.x);
  return closestPairRec(sortedByX);
}

function closestPairRec(pts: Point[]): number {
  const n = pts.length;

  if (n <= 3) {
    // base case:暴力求解小規模問題
    let minDist = Infinity;
    for (let i = 0; i < n; i++) {
      for (let j = i + 1; j < n; j++) {
        minDist = Math.min(minDist, distance(pts[i], pts[j]));
      }
    }
    return minDist;
  }

  const mid = Math.floor(n / 2);
  const midPoint = pts[mid];

  // Divide + Conquer:分別求左右半的最近距離
  const leftMin = closestPairRec(pts.slice(0, mid));
  const rightMin = closestPairRec(pts.slice(mid));
  let d = Math.min(leftMin, rightMin);

  // Combine:找跨越中線的最近點對
  // 只需考慮距中線 d 以內的點(strip 區域)
  const strip = pts.filter(p => Math.abs(p.x - midPoint.x) < d);
  // 按 Y 排序後,只需比較 Y 差距 < d 的相鄰點(至多 7 個)
  strip.sort((a, b) => a.y - b.y);

  for (let i = 0; i < strip.length; i++) {
    // 數學上可以證明最多只需比較後面 7 個點
    for (let j = i + 1; j < strip.length && strip[j].y - strip[i].y < d; j++) {
      d = Math.min(d, distance(strip[i], strip[j]));
    }
  }

  return d;
}

// 測試
const points: Point[] = [
  { x: 2, y: 3 }, { x: 12, y: 30 }, { x: 40, y: 50 },
  { x: 5, y: 1 }, { x: 12, y: 10 }, { x: 3, y: 4 }
];
console.log(closestPair(points).toFixed(3));  // 輸出:1.414(點(2,3)和(3,4))

Merge K Sorted Lists(LeetCode 23)

合併 k 條已排序的鏈結串列,分治思路:將 k 條鏈結串列分成兩組,遞迴合併各組,再合併兩組的結果。深度 O(log k),每層 O(N),總時間 O(N log k),優於逐一合併的 O(Nk)。

class ListNode {
  val: number;
  next: ListNode | null;
  constructor(val = 0, next: ListNode | null = null) {
    this.val = val;
    this.next = next;
  }
}

function mergeKLists(lists: Array<ListNode | null>): ListNode | null {
  if (lists.length === 0) return null;
  if (lists.length === 1) return lists[0];

  // 分治:對半拆分,遞迴合併
  function divideAndMerge(left: number, right: number): ListNode | null {
    if (left > right) return null;
    if (left === right) return lists[left];

    const mid = Math.floor((left + right) / 2);
    return merge2(divideAndMerge(left, mid), divideAndMerge(mid + 1, right));
  }

  // 合併兩條有序鏈結串列
  function merge2(l1: ListNode | null, l2: ListNode | null): ListNode | null {
    if (!l1) return l2;
    if (!l2) return l1;

    if (l1.val <= l2.val) {
      l1.next = merge2(l1.next, l2);
      return l1;
    }
    l2.next = merge2(l1, l2.next);
    return l2;
  }

  return divideAndMerge(0, lists.length - 1);
}

// 複雜度分析:
// k 條鏈結串列,總節點數 N
// 分治深度:log(k) 層
// 每層總合併工作:O(N)
// 總時間:O(N log k),優於逐一合併的 O(Nk)

最大子陣列和 — 分治版(LeetCode 53)

最大子陣列和(Maximum Subarray)通常用 Kadane’s Algorithm 以 O(n) 解決,但分治版本是理解分治思想的絕佳練習,也說明了 Combine 步驟為何複雜:

// 分治法求最大子陣列和
// 時間 O(n log n),劣於 Kadane's O(n),但展示分治精髓
function maxSubArrayDivide(nums: number[]): number {
  function solve(left: number, right: number): number {
    if (left === right) return nums[left];  // base case:單一元素

    const mid = Math.floor((left + right) / 2);

    // 子問題:左半與右半的最大子陣列和
    const leftMax = solve(left, mid);
    const rightMax = solve(mid + 1, right);

    // Combine:找跨越中點的最大子陣列和(必須包含 nums[mid] 和 nums[mid+1])
    let leftCross = -Infinity, sum = 0;
    for (let i = mid; i >= left; i--) {
      sum += nums[i];
      leftCross = Math.max(leftCross, sum);
    }

    let rightCross = -Infinity;
    sum = 0;
    for (let i = mid + 1; i <= right; i++) {
      sum += nums[i];
      rightCross = Math.max(rightCross, sum);
    }

    const crossMax = leftCross + rightCross;

    // 三者取最大:左半、右半、跨中點
    return Math.max(leftMax, rightMax, crossMax);
  }

  return solve(0, nums.length - 1);
}

// 測試
console.log(maxSubArrayDivide([-2, 1, -3, 4, -1, 2, 1, -5, 4]));  // 輸出:6(子陣列 [4,-1,2,1])
console.log(maxSubArrayDivide([1]));   // 輸出:1
console.log(maxSubArrayDivide([5, 4, -1, 7, 8]));  // 輸出:23

C++ 對照實作

#include <vector>
#include <algorithm>
#include <cmath>
#include <climits>
#include <iostream>

// ━━━━━━━━━━ Merge Sort + 逆序對計數 ━━━━━━━━━━

class MergeSort {
public:
    long long inversions = 0;

    void sort(std::vector<int>& arr, int left, int right) {
        if (left >= right) return;
        int mid = left + (right - left) / 2;
        sort(arr, left, mid);
        sort(arr, mid + 1, right);
        merge(arr, left, mid, right);
    }

private:
    void merge(std::vector<int>& arr, int left, int mid, int right) {
        std::vector<int> temp(right - left + 1);
        int i = left, j = mid + 1, k = 0;

        while (i <= mid && j <= right) {
            if (arr[i] <= arr[j]) {
                temp[k++] = arr[i++];
            } else {
                // arr[i..mid] 都比 arr[j] 大,形成 (mid - i + 1) 個逆序對
                inversions += (mid - i + 1);
                temp[k++] = arr[j++];
            }
        }
        while (i <= mid) temp[k++] = arr[i++];
        while (j <= right) temp[k++] = arr[j++];
        std::copy(temp.begin(), temp.end(), arr.begin() + left);
    }
};

// ━━━━━━━━━━ Quick Select(第 K 大元素)━━━━━━━━━━

class QuickSelectSolver {
public:
    // 手工實作:隨機化 Quick Select
    int findKthLargest(std::vector<int>& nums, int k) {
        int targetIdx = nums.size() - k;  // 第 k 大 = 第(n-k)小(0-indexed)
        return select(nums, 0, nums.size() - 1, targetIdx);
    }

    // STL nth_element(底層使用 Introselect,保證 O(n) 最壞情況)
    int findKthLargestSTL(std::vector<int> nums, int k) {
        int targetIdx = nums.size() - k;
        std::nth_element(nums.begin(), nums.begin() + targetIdx, nums.end());
        return nums[targetIdx];
    }

private:
    int select(std::vector<int>& arr, int left, int right, int targetIdx) {
        if (left == right) return arr[left];

        int pivotIdx = left + rand() % (right - left + 1);  // 隨機化 pivot
        pivotIdx = partition(arr, left, right, pivotIdx);

        if (pivotIdx == targetIdx) return arr[pivotIdx];
        else if (pivotIdx < targetIdx) return select(arr, pivotIdx + 1, right, targetIdx);
        else return select(arr, left, pivotIdx - 1, targetIdx);
    }

    int partition(std::vector<int>& arr, int left, int right, int pivotIdx) {
        int pivot = arr[pivotIdx];
        std::swap(arr[pivotIdx], arr[right]);
        int storeIdx = left;

        for (int i = left; i < right; i++) {
            if (arr[i] < pivot) {
                std::swap(arr[storeIdx++], arr[i]);
            }
        }
        std::swap(arr[storeIdx], arr[right]);
        return storeIdx;
    }
};

// ━━━━━━━━━━ Merge K Sorted Lists(分治)━━━━━━━━━━

struct ListNode {
    int val;
    ListNode* next;
    ListNode(int x) : val(x), next(nullptr) {}
};

class MergeKListsSolver {
public:
    ListNode* mergeKLists(std::vector<ListNode*>& lists) {
        if (lists.empty()) return nullptr;
        return mergeRange(lists, 0, lists.size() - 1);
    }

private:
    ListNode* mergeRange(std::vector<ListNode*>& lists, int left, int right) {
        if (left == right) return lists[left];
        int mid = left + (right - left) / 2;
        auto l = mergeRange(lists, left, mid);
        auto r = mergeRange(lists, mid + 1, right);
        return merge2(l, r);
    }

    ListNode* merge2(ListNode* l1, ListNode* l2) {
        ListNode dummy(0);
        ListNode* cur = &dummy;

        while (l1 && l2) {
            if (l1->val <= l2->val) { cur->next = l1; l1 = l1->next; }
            else                    { cur->next = l2; l2 = l2->next; }
            cur = cur->next;
        }
        cur->next = l1 ? l1 : l2;
        return dummy.next;
    }
};

// ━━━━━━━━━━ LeetCode 493 — Reverse Pairs ━━━━━━━━━━
// 計算所有 i < j 且 nums[i] > 2 * nums[j] 的配對數

class Solution493 {
    int total = 0;

    void mergeSort(std::vector<int>& nums, int left, int right) {
        if (left >= right) return;
        int mid = left + (right - left) / 2;
        mergeSort(nums, left, mid);
        mergeSort(nums, mid + 1, right);

        // Step 1:計數(兩指標,O(n),必須在合併前執行)
        int j = mid + 1;
        for (int i = left; i <= mid; i++) {
            while (j <= right && (long long)nums[i] > 2LL * nums[j]) j++;
            total += j - (mid + 1);
        }

        // Step 2:合併(獨立步驟,不能與計數混用)
        std::inplace_merge(nums.begin() + left,
                           nums.begin() + mid + 1,
                           nums.begin() + right + 1);
    }

public:
    int reversePairs(std::vector<int>& nums) {
        mergeSort(nums, 0, nums.size() - 1);
        return total;
    }
};

複雜度分析表 — Master Theorem 應用

演算法遞迴關係abf(n)log_b(a)情況最終複雜度
Merge SortT(n)=2T(n/2)+n22n1Case 2 (k=0)O(n log n)
Quick Sort (avg)T(n)=2T(n/2)+n22n1Case 2 (k=0)O(n log n)
Quick Select (avg)T(n)=T(n/2)+n12n0Case 3O(n)
Binary SearchT(n)=T(n/2)+11210Case 2 (k=0)O(log n)
Closest PairT(n)=2T(n/2)+n22n1Case 2 (k=0)O(n log n)
StrassenT(n)=7T(n/2)+n²722.807Case 1O(n^2.807)
Merge K ListsT(k)=2T(k/2)+NO(N log k)

小技巧: 套用 Master Theorem 時,先計算 log_b(a)(葉節點代表的複雜度),再比較 f(n) 的成長速度。若 f(n) 成長較慢 → Case 1(葉節點主導);相等 → Case 2(各層均等);成長較快 → Case 3(根節點主導)。


變體與延伸

多路分治

標準分治通常一分為二(binary),但也可以一分為三(ternary)或更多:

  • 三路快速排序(3-way Quick Sort):將陣列分為小於、等於、大於 pivot 三部分,對處理大量重複元素特別有效
  • 外部排序(External Merge Sort):受限於記憶體,資料庫引擎將大檔案切成多個 chunk 分別排序,再做 K-way merge(分治合併)
  • 多項式快速乘法(FFT):將 n 項多項式分為偶數項和奇數項兩個 n/2 項子多項式,T(n) = 2T(n/2) + O(n) → O(n log n)

並行分治

分治法天然適合並行化——各子問題彼此獨立,可同時執行:

// 並行 Merge Sort(利用 Promise.all 同時處理兩側)
class ParallelMergeSort {
  private readonly threshold: number;

  constructor(threshold: number = 1000) {
    this.threshold = threshold;  // 小於此規模時切回串行
  }

  async sort(arr: number[]): Promise<number[]> {
    if (arr.length <= this.threshold) {
      // 小問題直接串行排序(避免並行開銷超過收益)
      return [...arr].sort((a, b) => a - b);
    }

    const mid = Math.floor(arr.length / 2);

    // 並行處理左右兩側(實際場景可使用 Web Workers)
    const [left, right] = await Promise.all([
      this.sort(arr.slice(0, mid)),
      this.sort(arr.slice(mid))
    ]);

    return this.merge(left, right);  // 串行合併(合併本身難以並行化)
  }

  private merge(left: number[], right: number[]): number[] {
    const result: number[] = [];
    let i = 0, j = 0;
    while (i < left.length && j < right.length) {
      result.push(left[i] <= right[j] ? left[i++] : right[j++]);
    }
    return result.concat(left.slice(i), right.slice(j));
  }
}

CDQ 分治(陳丹琦分治)

CDQ 分治是一種特殊的離線分治技術,用於解決帶有時間順序的三維偏序問題。核心思想是對下標(時間維度)分治,在合併時計算左半對右半的貢獻,從而消去一個維度的排序需求。

典型應用:計算動態逆序對(帶插入操作)、三維偏序計數(LeetCode 315 的進階版)。

CDQ 分治框架(偽代碼):
solve(L, R):
  if L == R: return
  mid = (L + R) / 2
  solve(L, mid)               ← 遞迴解決左半
  calcContribution(L, mid, R) ← 計算左半對右半的貢獻(核心步驟)
  solve(mid+1, R)             ← 遞迴解決右半

面試考點 — 何時用分治?

適合使用分治的問題特徵:

  1. 問題可以分解為獨立子問題:子問題之間無重疊(否則考慮 DP)
  2. 子問題結構與原問題相同:天然具備遞迴性質(排序、搜尋、計算)
  3. 合併步驟有效率:合併成本不超過 O(n),才能達到 O(n log n) 等高效複雜度
  4. 可以快速縮減問題規模:每次至少縮減一半(log n 層)

分治 vs 動態規劃 決策表:

特性分治法動態規劃
子問題重疊無(各自獨立)有(記憶化避免重複)
子問題數量通常選一個或全部解所有子問題
典型代表Merge Sort, Quick SelectFibonacci, Knapsack
適合場景可完全獨立分解的問題有重疊子問題的最優化問題
複雜度T(n) = aT(n/b) + f(n)通常 O(n²) 或 O(n³)

面試常見追問:

  • 「為什麼 Merge Sort 穩定但 Quick Sort 不穩定?」 — Merge Sort 合併時相等元素保持相對順序(<= 取左側),Quick Sort 的 Partition 會改變相等元素的相對位置。
  • 「Quick Select 最壞情況是什麼?如何避免?」 — 固定選首元素為 pivot,對已排序陣列每次只排除一個元素,退化 O(n²);解法是隨機化 pivotMedian of Medians(保證 O(n) 最壞,但常數較大)。
  • 「分治法的遞迴棧空間如何分析?」 — 遞迴深度決定棧空間:Merge Sort O(log n);Quick Sort 平均 O(log n),最壞 O(n)(已排序時)。

LeetCode 練習

#題目難度核心分治思路
23Merge k Sorted ListsHard對半分治 k 條鏈結串列,O(N log k)
215Kth Largest Element in an ArrayMediumQuick Select,期望 O(n)
53Maximum SubarrayEasy分治版:跨中點的子陣列必須包含 mid
169Majority ElementEasy分治:左右各自找眾數,比較後選更多的
240Search a 2D Matrix IIMedium分治:以右上角為 pivot 剪掉一行或一列

LeetCode 169 — Majority Element(眾數問題)分治解法:

function majorityElement(nums: number[]): number {
  function solve(left: number, right: number): number {
    if (left === right) return nums[left];  // base case:只有一個元素

    const mid = Math.floor((left + right) / 2);
    const leftMajority = solve(left, mid);    // 左半的眾數
    const rightMajority = solve(mid + 1, right); // 右半的眾數

    if (leftMajority === rightMajority) return leftMajority;

    // 兩側眾數不同,計算各自在整個範圍內出現的次數
    let leftCount = 0, rightCount = 0;
    for (let i = left; i <= right; i++) {
      if (nums[i] === leftMajority) leftCount++;
      else if (nums[i] === rightMajority) rightCount++;
    }

    return leftCount > rightCount ? leftMajority : rightMajority;
  }

  return solve(0, nums.length - 1);
}

// 測試
console.log(majorityElement([3, 2, 3]));       // 輸出:3
console.log(majorityElement([2, 2, 1, 1, 1, 2, 2]));  // 輸出:2
// 注意:此分治解法時間 O(n log n),優化版可用 Boyer-Moore 投票法達 O(n)

LeetCode 240 — Search a 2D Matrix II(分治剪枝):

// 非標準分治,但體現分治「剪掉無關部分」的精神
function searchMatrix(matrix: number[][], target: number): boolean {
  // 從右上角開始,每次可以剪掉一整行或一整列
  let row = 0, col = matrix[0].length - 1;

  while (row < matrix.length && col >= 0) {
    if (matrix[row][col] === target) {
      return true;
    } else if (matrix[row][col] > target) {
      col--;  // 當前列所有元素都 >= target,剪掉整列
    } else {
      row++;  // 當前行所有元素都 <= target,剪掉整行
    }
  }
  return false;
}

// 測試
const matrix = [
  [1,  4,  7,  11, 15],
  [2,  5,  8,  12, 19],
  [3,  6,  9,  16, 22],
  [10, 13, 14, 17, 24],
  [18, 21, 23, 26, 30]
];
console.log(searchMatrix(matrix, 5));   // 輸出:true
console.log(searchMatrix(matrix, 20));  // 輸出:false

常見陷阱

陷阱 1:忘記設定 base case,導致無限遞迴

// 錯誤:沒有處理 arr.length === 1 的情況
function badMergeSort(arr: number[]): number[] {
  // if (arr.length <= 1) return arr;  ← 忘記!空陣列仍會再次分割
  const mid = Math.floor(arr.length / 2);
  return merge(badMergeSort(arr.slice(0, mid)), badMergeSort(arr.slice(mid)));
}

// 正確:所有 arr.length <= 1 的情況直接回傳
function goodMergeSort(arr: number[]): number[] {
  if (arr.length <= 1) return arr;  // ← 必要的 base case
  const mid = Math.floor(arr.length / 2);
  return merge(goodMergeSort(arr.slice(0, mid)), goodMergeSort(arr.slice(mid)));
}

陷阱 2:LeetCode 493 計數與合併順序錯誤

// 錯誤:計數和合併混在同一個 while 迴圈
// 合併過程改變陣列順序,導致計數不正確
function badReversePairs(arr: number[], left: number, mid: number, right: number): number {
  let count = 0, i = left, j = mid + 1;
  const temp: number[] = [];
  while (i <= mid && j <= right) {
    if (arr[i] > 2 * arr[j]) {
      count += (mid - i + 1); // arr[i] 被推進合併後,計數邏輯已錯
      temp.push(arr[j++]);
    } else {
      temp.push(arr[i++]);  // ← 計數與合併混用,結果錯誤
    }
  }
  return count;
}

// 正確:計數和合併必須分成兩個獨立步驟
function goodReversePairs(arr: number[], left: number, mid: number, right: number): number {
  // Step 1:計數(雙指標掃描,不修改陣列)
  let count = 0;
  let j = mid + 1;
  for (let i = left; i <= mid; i++) {
    while (j <= right && arr[i] > 2 * arr[j]) j++;
    count += j - (mid + 1);
  }
  // Step 2:標準合併(獨立執行)
  // ...(合併邏輯)
  return count;
}

陷阱 3:C++ 中整數溢位(2 * nums[j] 可能超出 int 範圍)

// 錯誤:nums[j] 很大時 2 * nums[j] 溢位 int
if (nums[i] > 2 * nums[j]) { ... }  // ← 危險!

// 正確:轉型為 long long 再計算
if ((long long)nums[i] > 2LL * nums[j]) { ... }  // ← 安全

總結

分治法的本質是一種遞迴思維的系統化框架:把大問題切成獨立的小問題,各自解決,再將答案合併。三個關鍵問題幫助你設計任何分治演算法:

  1. 如何 Divide? — 如何切割問題,讓子問題規模縮小(通常對半)
  2. Base case 是什麼? — 規模小到多少時可以直接解決(通常是 1 或 3 個元素)
  3. 如何 Combine? — 合併步驟的複雜度決定了整個演算法的效率

Master Theorem 是你分析分治複雜度的計算機:確定 a(子問題數)、b(縮小比例)、f(n)(合併成本),比較 f(n) 與 n^log_b(a) 的成長速度,立即得出答案。

分治法與動態規劃共享「最優子結構」的特性,區別在於:分治的子問題不重疊(Merge Sort 的每個元素只被排序一次),DP 的子問題有重疊(Fibonacci 的 fib(3) 被多次計算)。

下一篇將深入探討位元運算(Bit Manipulation)——如何利用硬體指令層次的操作,以 O(1) 解決看似需要多步驟的問題,包括位元遮罩、XOR 技巧,以及 Brian Kernighan 的位元計數法。

希望這篇文章能幫助你理解分治法的核心思維,並在面試中靈活運用。如有任何問題或疑惑,歡迎至 Contact 頁面 留言討論!

BenZ Software Developer

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