【C++】競程筆記(分治法 D&C)
【C++】競程筆記(分治法 D&C)
程式碼範例參考:NTUCPC Guide,此筆記僅為個人學習用途。
Introducing Divide and Conquer
Divide and Conquer 英翻中為分治法,這是一個把大問題切分成多個子問題,最後從這些子問題合併來求得主問題解答的一種方法。
而 Divide and Conquer 的步驟主要有三項:
- Divide:把原問題拆成多個小且類似的子問題,直到無法再細分。
- Conquer:用遞迴解決這些子問題;若遇到規模足夠小的「基本情況(base case)」,則直接輸出答案。
- Merge:合併子問題得到最終解,一旦較小的子問題被解決,則遞迴合併所有子問題得到更大問題的答案。
例題:王老先生
Problem Source:https://tioj.sprout.tw/problems/114
題目敘述:
有個正方形土地 $N \times N$ , $N$ 為 $2$ 的正整數次方。
有一格子 $(X, Y)$ 已被王老先生選走了,你要放 $\frac{N \times N - 1}{3}$ 個 3 格的 L 型方塊來填滿剩餘的地。滿足積木都互不重疊、放在沒有被挖掉的格子上,且鋪滿所有沒被選走的地方。
解法:
把這塊土地劃分成 $(N / 2) \times (N / 2)$ 的地,如下示意圖的紅色切割線:

假設灰色那塊是被選走的,則可以發現能夠輕易地擺上一塊 L 型積木。

放好了第一塊積木之後,你可能會抓破頭腦,不知道該怎麼擺放,放一放有可能會像下面的情形:

ok,那麼何妨試試放一塊跨區域的積木呢?如下所示。

那這樣的情形就會變成每個 $(N / 2)$ 區塊都會有像是灰色格子的情形了,接下來就可以依序填滿:

Divide and Conquer 的精神就要準備體現在這了,我們在較小子問題得出了答案,那理所當然的,後續更大的答案也能用這樣的方式解決。比如說 $N = 8$ 的情形。

一樣將這 $8 \times 8$ 的網格切成四個 $4 \times 4$ 的區域,那這樣子就是四種剛剛的子問題了,一樣都放上橫跨三個區域的 L 型積木,即可完成這 $8 \times 8$ 網格的問題。
範例程式碼:
1 |
|
分割與合併
如剛才例題的王老先生,那是屬於分治法的基本範疇,透過不斷切割子問題,直到不能再切為止,而在較小的子問題中得到解,之後就可將這些子問題各自解決,合併成大問題的解。
Merge Sort 是 Divide and Conquer 中最經典的應用,流程大致如下:
- Divide:將長度為 $n$ 的陣列二分( $n / 2$ )為兩個子陣列。
- 若子陣列長度為 $1$ ,則表示原本的陣列就已經排好了。
- Conquer:將左右子陣列分別遞迴呼叫 Merge Sort,直到陣列長度為 $1$ 。
- Combine:將兩個已經排序好的子陣列合併(Merge)成一個完整排序的陣列。
註:Divide 是遞迴函數的內部邏輯,而 Conquer 為執行呼叫遞迴的行為,需注意這邊很容易混淆。
先來看程式碼:
1 | void mergeSort(vector<int>& arr, int left, int right) { |
以下是 Merge Sort 的遞迴樹圖(From Merge Sort (With Code in Python/C++/Java/C)):

分割的時間複雜度為 $O(1)$ ,為常數時間,就是那條 int mid = left + (right - left) / 2;。
遞迴處理的時間複雜度為 $O(\frac{n}{2}) + O(\frac{n}{2}) + O(n)$ ,因為要處理左右兩個長度被切一半的子陣列,另外再加合併兩子陣列的時間成本 $O(n)$ 。
但其實並沒那麼簡單,看遞迴樹圖就知道這東西有分很多層需要去做遞迴處理,所以在不斷分割之下,拆到長度剩 1 的時間複雜度為:
- 第一層: $2O(\frac{n}{2})$
- 第二層: $4O(\frac{n}{4})$
- 第三層: $8O(\frac{n}{8})$
- 第 k 層: $2^k \cdot O(\frac{n}{2^k})$
最後變成 $2^k \cdot O(\frac{n}{2^k}) + k \cdot O(n)$ ,只取最高次項,因此最後每層所花時間為 $O(n)$ 。
另外每次遞迴呼叫時,就會分割長度 $n / 2$ ,所以層數為 $O(log n)$ ,而每層總共需花 $O(n)$ ,因此整體時間複雜度為 $O(n log n)$ 。
例題:逆序數對
Problem Source:https://tioj.ck.tp.edu.tw/problems/1080
這題的解法就是拿合併排序法的解法來解。
當合併時,若左半部數字 $L[i] > R[i]$ ,則:
- 兩個子陣列皆為遞增數列,表示 $L[i], L[i + 1], \cdots, L[end]$ 都比 $R[j]$ 大。
- 然後就把逆序數對數量 += 左半部剩下的元素個數。
演算流程大致如下:
- 用 mergeSort 遞迴函數排序數列,同時計算逆序數對。
- 在合併階段,當左邊元素大於右邊元素時,更新逆序數對數量。
- 輸出 Data。
範例程式碼:
1 |
|
Quick Sort
這也是一個基於 Divide and Conquer 的排序演算法,跟 Merge Sort 有些類似。
首先在 Divide 的部分,要先挑選一個 pivot(基準值),如在陣列 {19, 17, 15, 12, 16, 18, 4, 11, 13} 中,選擇 13 這個 element 作為 pivot。
然後接下來做 Divide,將這個陣列切成小於及大於 pivot 的子陣列。
接下來是 Conquer 遞迴處理:
(左子陣列)
{7, 12, 4, 11}再做一次 Divide 的動作,這邊選 11 作為 pivot。- 然後分成
{7, 4}、{12} {12}不能分了,而{7, 4}再繼續分下去(4 選作 pivot) →{7}、{4}
再來右子陣列做的事情跟左子陣列一樣,就不贅述了。
最後做 Combine:每層遞迴結束後,就會自動排好結果了(左子陣列排序結果 + pivot + 右子陣列排序結果)。
如左子陣列 {7, 4} 最後得到的結果會是 {4}、{7},合併回去的結果會是 {4, 7}。
然後 {4, 7} 再合併 pivot {11} 和右子陣列 {12},就會是 {4, 7, 11, 12} 了。
以上流程的遞迴樹圖如下所示(
Quick Sort in C | GeeksForGeeks):

時間複雜度分析
分割部分,因為要掃過整個陣列,然後再依據 pivot 去分配左右子陣列,因此這部分的時間複雜度為 $O(n)$ 。
最佳情況:
假設每次取到的 pivot 都剛好把陣列切一半。
每次遞迴呼叫時,就會分割長度 $n / 2$ ,所以層數為 $O(log n)$ ,而每層總共需花 $O(n)$ ,因此最佳情況的時間複雜度為 $O(n log n)$ 。
平均情況:
假設 pivot 是隨機取的。
這邊在做的事情其實跟最佳情況沒什麼差別,分割長度平均都接近 $n / 2$ ,時間複雜度基本上還是 $O(n log n)$ 。
最壞情況:
假設 pivot 每次都取到最大或最小元素。
分割結果是一邊有 $n-1$ 個元素,另一邊是空集合,遞迴深度就變成 $n$ ,再加上每層需花 $O(n)$ ,最後時間複雜度就會是令人咋舌的 $O(n^2)$ 。
範例程式碼
1 | void quickSort(vector<int>& arr, int low, int high) { |
結論
這是一個靠賽的排序法,選對就能起飛,選錯直接烙賽。




