分治法 — Divide and Conquer 三階段與 Master Theorem | 資料結構與演算法
分治法(Divide and Conquer) 是演算法設計中最優雅的範式之一:把大問題切成小問題,各自擊破,再將答案組合回來。從 Merge Sort 到 Quick 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 1 | f(n) = O(n^(log_b a - ε)),ε > 0 | T(n) = Θ(n^log_b a) | 葉節點主導,根節點工作量小 |
| Case 2 | f(n) = Θ(n^log_b a · log^k n) | T(n) = Θ(n^log_b a · log^(k+1) n) | 各層工作量相等,累乘一個 log |
| Case 3 | f(n) = Ω(n^(log_b a + ε)),ε > 0 | T(n) = Θ(f(n)) | 根節點主導,合併工作量大 |
經典例子套用:
| 演算法 | 遞迴關係 | a, b, f(n) | 情況 | 複雜度 |
|---|---|---|---|---|
| Merge Sort | T(n) = 2T(n/2) + O(n) | a=2, b=2, f=n | Case 2 (k=0) | O(n log n) |
| Binary Search | T(n) = T(n/2) + O(1) | a=1, b=2, f=1 | Case 2 (k=0) | O(log n) |
| Quick Select (avg) | T(n) = T(n/2) + O(n) | a=1, b=2, f=n | Case 3 | O(n) |
| Strassen 矩陣乘法 | T(n) = 7T(n/2) + O(n²) | a=7, b=2, f=n² | Case 1 | O(n^2.807) |
| Merge K Lists | T(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 應用
| 演算法 | 遞迴關係 | a | b | f(n) | log_b(a) | 情況 | 最終複雜度 |
|---|---|---|---|---|---|---|---|
| Merge Sort | T(n)=2T(n/2)+n | 2 | 2 | n | 1 | Case 2 (k=0) | O(n log n) |
| Quick Sort (avg) | T(n)=2T(n/2)+n | 2 | 2 | n | 1 | Case 2 (k=0) | O(n log n) |
| Quick Select (avg) | T(n)=T(n/2)+n | 1 | 2 | n | 0 | Case 3 | O(n) |
| Binary Search | T(n)=T(n/2)+1 | 1 | 2 | 1 | 0 | Case 2 (k=0) | O(log n) |
| Closest Pair | T(n)=2T(n/2)+n | 2 | 2 | n | 1 | Case 2 (k=0) | O(n log n) |
| Strassen | T(n)=7T(n/2)+n² | 7 | 2 | n² | 2.807 | Case 1 | O(n^2.807) |
| Merge K Lists | T(k)=2T(k/2)+N | — | — | — | — | — | O(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) ← 遞迴解決右半
面試考點 — 何時用分治?
適合使用分治的問題特徵:
- 問題可以分解為獨立子問題:子問題之間無重疊(否則考慮 DP)
- 子問題結構與原問題相同:天然具備遞迴性質(排序、搜尋、計算)
- 合併步驟有效率:合併成本不超過 O(n),才能達到 O(n log n) 等高效複雜度
- 可以快速縮減問題規模:每次至少縮減一半(log n 層)
分治 vs 動態規劃 決策表:
| 特性 | 分治法 | 動態規劃 |
|---|---|---|
| 子問題重疊 | 無(各自獨立) | 有(記憶化避免重複) |
| 子問題數量 | 通常選一個或全部 | 解所有子問題 |
| 典型代表 | Merge Sort, Quick Select | Fibonacci, 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²);解法是隨機化 pivot 或 Median of Medians(保證 O(n) 最壞,但常數較大)。
- 「分治法的遞迴棧空間如何分析?」 — 遞迴深度決定棧空間:Merge Sort O(log n);Quick Sort 平均 O(log n),最壞 O(n)(已排序時)。
LeetCode 練習
| # | 題目 | 難度 | 核心分治思路 |
|---|---|---|---|
| 23 | Merge k Sorted Lists | Hard | 對半分治 k 條鏈結串列,O(N log k) |
| 215 | Kth Largest Element in an Array | Medium | Quick Select,期望 O(n) |
| 53 | Maximum Subarray | Easy | 分治版:跨中點的子陣列必須包含 mid |
| 169 | Majority Element | Easy | 分治:左右各自找眾數,比較後選更多的 |
| 240 | Search a 2D Matrix II | Medium | 分治:以右上角為 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]) { ... } // ← 安全
總結
分治法的本質是一種遞迴思維的系統化框架:把大問題切成獨立的小問題,各自解決,再將答案合併。三個關鍵問題幫助你設計任何分治演算法:
- 如何 Divide? — 如何切割問題,讓子問題規模縮小(通常對半)
- Base case 是什麼? — 規模小到多少時可以直接解決(通常是 1 或 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 頁面 留言討論!