跳至主要内容

Divide and Conquer

預備知識

分治法(Divide and Conquer)是一種把大問題拆成好幾個相同類型的小問題,各自解決之後,再把結果合併回大問題答案的策略。

白話理解

想像要幫全班改考卷,一個人改要花很久。於是把考卷分成好幾疊,找好幾位小老師分別批改(拆解),每疊改完後再統一登記到成績單上(合併)。這種「拆開、各自處理、再合起來」的做法,就是分治法的精神。

它本身不算是一個具體演算法,而是一種思考框架,很多經典演算法都是建立在這個框架之上,例如 Merge Sort、Quick Sort、Binary Search 與 Quickselect。

三個步驟​

  1. Divide(分解):把原問題拆成幾個規模較小、性質相同的子問題。
  2. Conquer(解決):遞迴地解決每個子問題,直到子問題小到可以直接得出答案(Base Case)。
  3. Combine(合併):把子問題的答案組合起來,變成原問題的答案。
solve(問題) {
if (問題已經夠小 / Base Case) {
直接回傳答案
}

子問題們 = divide(問題) // 1. Divide
子答案們 = 子問題們.map(solve) // 2. Conquer(遞迴呼叫自己)
return combine(子答案們) // 3. Combine
}

看起來是不是很眼熟?沒錯,分治法本質上就是應用在特定場景下的 Recursion,差別在於它特別強調「合併小答案」這個步驟。

範例:用分治法找出陣列中的最大值​

題目:在一堆數字裡找出最大值(雖然這題直接用迴圈掃一遍就能解決,但很適合拿來理解分治法的拆解與合併過程)。

def find_max(arr, left=0, right=None):
if right is None:
right = len(arr) - 1

# Base Case:只剩一個元素,它自己就是這個範圍的最大值
if left == right:
return arr[left]

mid = (left + right) // 2

# Divide + Conquer:分別遞迴找出左右兩半各自的最大值
left_max = find_max(arr, left, mid)
right_max = find_max(arr, mid + 1, right)

# Combine:兩邊的最大值再比一次,就是整個範圍的最大值
return max(left_max, right_max)

逐步拆解:以 [3, 7, 2, 9, 4] 為例​

findMax([3, 7, 2, 9, 4]) 範圍 [0, 4]
├─ findMax 左半:範圍 [0, 2] → [3, 7, 2]
│ ├─ findMax 左半:範圍 [0, 1] → [3, 7]
│ │ ├─ findMax 範圍 [0, 0] → 3(Base Case)
│ │ └─ findMax 範圍 [1, 1] → 7(Base Case)
│ │ Combine:max(3, 7) = 7
│ └─ findMax 範圍 [2, 2] → 2(Base Case)
│ Combine:max(7, 2) = 7
└─ findMax 右半:範圍 [3, 4] → [9, 4]
├─ findMax 範圍 [3, 3] → 9(Base Case)
└─ findMax 範圍 [4, 4] → 4(Base Case)
Combine:max(9, 4) = 9

最後 Combine:max(7, 9) = 9

可以看到,問題不斷被「Divide」成更小的範圍,直到剩下一個元素(Base Case),接著再一層一層「Combine」往上合併,最終得到整體答案 9。

複雜度​

分治法的時間複雜度取決於「拆成幾份」與「合併的成本」,常見的幾種組合:

演算法拆解方式合併成本總時間複雜度
Binary Search每次丟掉一半,只往一邊繼續找O(1)O(log n)
Merge Sort每次切一半,兩邊都要繼續處理O(n)(合併兩個排序好的陣列)O(n log n)
Quickselect每次只往答案所在的那一半繼續找O(n)(Partition)平均 O(n)
新手小提醒

不是所有分治法都是 O(n log n)!

關鍵要看「拆成幾份繼續處理」與「合併要花多少時間」,上面這張表就是很好的對照。

Divide and Conquer vs Dynamic Programming​

兩者都會把問題拆成子問題,但關鍵差異在於子問題之間有沒有重疊:

比較項目Divide and ConquerDynamic Programming
子問題關係互不重疊,每個子問題只會被解決一次重疊子問題,同樣的子問題會重複出現
是否需要記錄結果不需要,算完就直接合併、丟棄需要,通常用陣列或 Hash Map 記錄算過的答案
代表範例Merge Sort、Quick Sort、Binary Search費氏數列、背包問題(可參考 Dynamic Programming)

適用情況​

  • 排序:Merge Sort、Quick Sort 都是把陣列拆成兩半分別排序,再合併或直接組合起來。
  • 在排序好的資料中搜尋:Binary Search 每次只往答案所在的那一半繼續找。
  • 找第 k 大 / 第 k 小的元素:Quickselect 只需要往答案所在的那一半遞迴,另一半直接捨棄。
  • 子問題彼此獨立、不會重疊的問題:例如在陣列中找最大值、最近點對問題(Closest Pair of Points),只要能明確拆解、獨立求解、再合併答案,就適合用分治法。

常見誤區​

  • 忘記寫 Base Case:和一般遞迴一樣,忘記或寫錯 Base Case 會導致無窮遞迴或 Stack Overflow,可參考 Recursion 的說明。
  • 誤以為所有分治法都要「切一半」:拆解的比例依問題而定,Quickselect 甚至每次只需要處理其中一半,另一半直接捨棄。
  • 合併步驟寫錯或漏寫:分治法真正的難點常常在 Combine 這一步(例如 Merge Sort 的合併排序陣列),如果 Divide 和 Conquer 都對,但 Combine 邏輯有誤,還是會得到錯誤答案。
  • 子問題其實有重疊卻沒發現:如果拆解後的子問題會重複出現一樣的內容,代表更適合用 Dynamic Programming 來避免重複計算,而不是單純的分治法。