分治演算法(Divide and Conquer)#
分治法是遞迴的高階應用,核心思想是庖丁解牛(Chunk it up):將大問題拆解為若干個互不相關的子問題,分別解決後合併結果。
分治三步驟#
- Divide(分):將大問題拆解成若干個子問題
- Conquer(治):分別解決這些子問題
- Merge(合):將子問題的結果合併成原問題的解
這與分散式系統中的 MapReduce 思想如出一轍。
分治程式碼模板#
fun divideConquer(problem: Problem?): Result {
// 1. Terminator (終止條件)
if (problem == null) {
return baseResult
}
// 2. Prepare & Split (準備資料/拆分問題)
val data = prepareData(problem)
val subProblems = splitProblem(problem, data)
// 3. Conquer Subproblems (解決子問題)
val subResults = subProblems.map { divideConquer(it) }
// 4. Merge (合併結果)
val result = merge(subResults)
// 5. Revert States (清理狀態,如需要)
return result
}分治的適用條件#
分治法適用的前提是子問題互不相關。如果子問題有重疊(如費氏數列),則不應使用標準分治,而應考慮動態規劃。
經典案例#
Pow(x, n) ↗:快速冪#
計算 x 的 n 次方。
| 方法 | 時間複雜度 | 空間複雜度 | 說明 |
|---|---|---|---|
| 暴力法 | O(n) | O(1) | 迴圈乘 n 次 |
| 分治法(遞迴) | O(log n) | O(log n) | 遞迴堆疊開銷 |
| 快速冪(迭代) | O(log n) | O(1) | 面試最優解 |
分治邏輯:
- 若 n 為偶數:x^n = (x^(n/2))^2
- 若 n 為奇數:x^n = (x^(n/2))^2 * x
n 可能是負數,需先將 x 轉為 1/x,n 轉為正數。
快速冪(位運算版本,Kotlin)
fun myPow(x: Double, n: Int): Double {
var base = x
// 用 Long 承接指數,避免 n = Int.MIN_VALUE 取負號時溢位
var exp = n.toLong()
if (exp < 0) {
base = 1 / base
exp = -exp
}
var result = 1.0
while (exp > 0) {
if (exp and 1L == 1L) { // 最後一位是 1
result *= base
}
base *= base // 基底翻倍
exp = exp shr 1 // 右移一位
}
return result
}原理: 將 n 視為二進位表示,每個位元對應 x 的 2⁰, 2¹, 2²… 次方。
求眾數:分治求多數元素 ↗#
找出陣列中出現次數大於 n/2 的元素。
| 方法 | 時間複雜度 | 說明 |
|---|---|---|
| 暴力法 | O(n²) | 兩層迴圈計數 |
| 雜湊表 | O(n) | 空間換時間 |
| 排序法 | O(n log n) | 排序後取中間 |
| 分治法 | O(n log n) | 左右分別求眾數再比較 |
分治邏輯:
- Divide:將陣列切分為左右兩半
- Conquer:遞迴求出左右兩邊的眾數
- Merge:
- 若左右眾數相同,直接返回
- 若不同,計算兩者在當前區間的出現次數,返回較多者
分治解法程式碼
fun majorityElement(nums: IntArray): Int {
fun countInRange(target: Int, lo: Int, hi: Int): Int {
var count = 0
for (i in lo..hi) {
if (nums[i] == target) count++
}
return count
}
fun majority(lo: Int, hi: Int): Int {
// 終止條件:區間長度為 1
if (lo == hi) return nums[lo]
// 分
val mid = lo + (hi - lo) / 2
val left = majority(lo, mid)
val right = majority(mid + 1, hi)
// 合
if (left == right) return left
// 計數比較
val leftCount = countInRange(left, lo, hi)
val rightCount = countInRange(right, lo, hi)
return if (leftCount > rightCount) left else right
}
return majority(0, nums.size - 1)
}分治 vs 遞迴#
| 特性 | 遞迴 | 分治 |
|---|---|---|
| 本質 | 程式設計技巧 | 演算法策略 |
| 子問題關係 | 可能重疊 | 互不相關 |
| 結果處理 | 直接返回 | 需要合併 |
| 平行化 | 通常不適合 | 非常適合 |
分治法的子問題之間沒有依賴關係,因此非常適合平行計算(Parallel Processing)。
分治天生適合平行化:從合併排序到 MapReduce#
分治的精髓是把大問題拆成彼此獨立的子問題,各自解完再合併。這個「獨立」不只是程式設計上的乾淨,更是性能上的金礦:正因為子問題之間沒有依賴,它們可以同時丟到不同的核心、不同的機器上一起算,最後再把結果拼起來。
記住這條因果鏈:子問題獨立 ➡️ 可平行。一個演算法能不能輕鬆平行化,第一個要看的就是它的子問題之間有沒有資料依賴。分治幾乎自帶這個性質。
幾個直觀的例子:
- 合併排序:就像把一疊牌分給好幾個人,每人各排好手上那一疊(互不干擾),再兩兩合併成有序的大疊。各人排序的階段完全可以同時進行。
- 矩陣乘法:結果矩陣的每一塊(行/列切塊)都能獨立算出,自然可以切成許多小任務分給多台機器。
而 MapReduce 的本質,就是把分治搬到了分散式叢集上:
| 分治步驟 | MapReduce 對應 | 做的事 |
|---|---|---|
| Divide + Conquer | Map | 把資料拆解,在各節點上獨立計算出中間結果 |
| Merge | Reduce | 把各節點的中間結果合併成最終答案 |
差別只在於:MapReduce 額外把任務調度、負載均衡、容錯(節點掛掉自動重跑) 全都自動化了。換句話說,當你理解了分治的「拆—算—合」,你其實已經理解了 MapReduce 的骨架。
一道題、四種複雜度:最大子陣列和 ↗的演算法階梯#
「最大子陣列和」(給一個含正負數的陣列,找出總和最大的連續區間)是一道極佳的教學題——因為同一個問題,可以用四種層次完全不同的思路解出來,剛好串起了演算法設計的階梯。
| 解法 | 時間複雜度 | 核心想法 |
|---|---|---|
| 暴力枚舉 | O(n³) | 枚舉所有 (左, 右) 區間,每個區間再重算一次總和 |
| 前綴和優化 | O(n²) | 保存累加和,區間和用減法 O(1) 取得,省掉內層重算 |
| 分治 | O(n log n) | 左半最大、右半最大、跨中點最大,三者取最大 |
| 掃描法(Kadane) | O(n) | 一次掃描,邊走邊維護「以當前元素結尾的最大和」 |
這道題的線性解,曾在一場研討會上被人聽完題目後一分鐘內口頭給了出來,把原本需要跑十幾天的計算壓到幾毫秒。它最好地說明了一件事:選對演算法,遠勝於拼命微調——常數級的硬體加速永遠追不上一個降階的演算法。
分治版怎麼做#
這是分治章,重點放在 O(n log n) 的分治解。把陣列從中間切開後,最大子陣列只可能落在三種位置之一:
- 完全在左半:遞迴求左半的最大子陣列和。
- 完全在右半:遞迴求右半的最大子陣列和。
- 跨越中點:這段必定「同時包含中點左右」,所以從中點向左掃出最大和、向右掃出最大和,兩者相加即為跨中點的最大和。
三者取最大,就是當前區間的答案。
fun maxSubArray(nums: IntArray): Int {
fun divide(lo: Int, hi: Int): Int {
// 終止條件:單一元素
if (lo == hi) return nums[lo]
val mid = lo + (hi - lo) / 2
// 1 & 2:左半最大、右半最大
val leftMax = divide(lo, mid)
val rightMax = divide(mid + 1, hi)
// 3:跨中點最大(必含 mid 與 mid+1)
var leftBest = Int.MIN_VALUE
var s = 0
for (i in mid downTo lo) {
s += nums[i]
leftBest = maxOf(leftBest, s)
}
var rightBest = Int.MIN_VALUE
s = 0
for (i in mid + 1..hi) {
s += nums[i]
rightBest = maxOf(rightBest, s)
}
val crossMax = leftBest + rightBest
// 三者取大
return maxOf(leftMax, rightMax, crossMax)
}
return divide(0, nums.size - 1)
}遞迴式為 $T(n) = 2T(n/2) + O(n)$,由主定理得 $O(n \log n)$——與合併排序同階。
再往上一階的
O(n)掃描解(Kadane)其實是動態規劃:以「必須以第 i 個元素結尾」為狀態,邊掃邊更新。它與分治版的詳細對照,見 動態規劃的子陣列問題。