--- title: Divide and Conquer / Merge Sort tags: 演算法(交大OCW) --- # 數學歸納法 Induction 這就是 Divide and Conquer 的基礎 ## 好處 當你用數學歸納法證明了一個東西,你就無形中建立了一個演算法 因為數學歸納法有要求你每一步要怎麼走要講清楚,而這就是演算法 將一個命題記為 P(n), n 代表第 n 個步驟;根據證明的形式有兩種歸納法 >命題(英語:proposition)是一個陳述句所表達的判斷,具有真值 by Wiki ## Weak Induction - Basic Step:要說明 P(0) 為真、是對的 - Inductive Step:假設 P(k) 是對的,進而推導出 P(k+1) 也是對的 其中 P(k) 又叫做 Induction Hypothesis :::info 因為一次只從一步跳到下一步,所以才叫 weak ::: ## Strong Induction - Basic Step:要說明 P(0) 為真、是對的 - Inductive Step:假設 P(0)\^P(1)\^...\^P(k) 是對的,進而推導出 P(k+1) 也是對的 :::info 因為一次從很多步跳到下一步,所以才叫 strong ::: --- :::success 離散數學中會證明兩者等價 ::: ## 歸納法小例子 - 貼磁磚 一個 8×8 正方形地板,要鋪磁磚,總共 64 格但有一格有 1 個排水孔不能鋪 請問能不能用一個三角形角塊(Triomino)將整片磁磚鋪完 :::warning - Triomino請見下面的圖 - 排水孔的位置是任意的 ::: 答案是可以的,就是藉由歸納法 最後得到的結論是 :::info P(n):任何一個 $2^{n}$ 的正方形地板都可以被 $\frac{2^{n}\times 2^{n}-1}{3}$ 個 Triomino 給貼完 ($n\ge 1$) ::: :::success 順帶一提,因為 $2^{n}\times 2^{n}-1=(2^{n}+1)(2^{n}-1)$ 並且以 2 = 3 - 1 替換後得到 $$ ((3-1)^{n}+1)((3-1)^{n}-1) $$ 當 n 是奇數時,左邊部分會全都剩下 3 的倍數;當 n 是偶數時,右邊部分會全都剩下 3 的倍數 ::: ### 1. Basic Step 當 n = 1 時很明顯 ![](https://drive.google.com/uc?id=1W6ag41_dELOBgBVNoBZCkI2JZpHwqWlH&export=download) ### 2. Inductive Step 假設 n = k 的時候是對的 n = k+1 的情況其實就是整塊大的正方形可以看成 4 個小塊的正方形 而這 4 個小塊的正方形,就是我們所假設可以拼得出來的正方形(n = k) 此時只要把這 4 個小正方形中,其中 3 個的排水孔對在一起,形成一個 Triomino ,這樣就可以剛好被一個 Triomino 給補起來;剩下的 1 個小正方形,根據我們的假設,是可以拼出來的 因此 k = n + 1 是可以拼出來的,證明完畢 結論,對於任意正整數,P(n)均成立 :::info 這裡再次可以發現數學歸納法的好處,他除了給你證明,他還給告訴你每一步要怎麼走 在這裡就是指,把 3 個小正方形的排水孔對在一起的步驟 ::: # Divide and Conquer 暖身:Search 不過是指特別情況的 Search;是從一個已排序好的陣列,找出特定值 ## Naïve O(n) 直接從頭看到尾,O(n) 這個正確性是顯然的,但就是慢了些 ## Binary search O(logn) 每次都選「一個區間」的中間人比較,比完大小就更新區間範圍,然後重複流程 時間就只要 O(logn) :::info Binary Search 其實就有使用 DAC,不過屬於比較特殊的情形 ::: ![](https://drive.google.com/uc?id=181X_DJ7zUHA0Bn5mP31anxQw-lsfJrSh&export=download) 理論上「Divide」,也就是分成兩半後,這兩半都要「Conquer」;但是因為陣列已經排過序,所以可以直接藉由比較中間人的大小,判斷出另一半不需要「Conquer」 ## Divide and Conquer 步驟 - Divide:將輸入拆解成「很多」「性質一樣的」小部件 - 性質一樣這點很重要;白話上就是「長的一樣」 - 通常會分成兩半 - Conquer - 遞迴地(Recursively)處理每個小部件,直到可以一眼看出答案才停止遞迴 - Combine: - 最後,通常也是最難的部分,將每個小部件的解,組合起來 ![](https://drive.google.com/uc?id=1tKuphNncNnZlu0iSABAwS8Ubt_FlIiKb&export=download) :::warning 步驟上來說就是: 1. Enter the Promblem 2. Divide 1 → Conquer 1 → Combine 1,Enter Conquer 1 3. Divide 2 → Conquer 2 → Combine 2,Enter Conquer 2 4. ... 體會何謂 Recursively ::: ### 複雜度 由於 DAD 通常藉由「遞迴的形式」寫出解法,所以複雜度的分析,也是藉由一個遞迴的關係(Recurrence relation),表示出上下界 從上面的步驟可以得出 $$ T(Problem)=T(Divide\ 1)+T(Conquer\ 1)+T(Combine\ 1) $$ ## DAC 模板 老師說通常可以用這個模板處理;但是是通常,必要時還是得要依據原則制定內容 ![](https://drive.google.com/uc?id=1C_1JUiraQ4svjriaq1BCcXHRxH-mRHQN&export=download) --- # Merge Sort - John von Neumann 1945 :::success - 老師說他在計算機架構等方面的成就令眾人所知,殊不知那個年代的人好像都是通才,他在演算法領域也有很多成就 - 老師說如果你的輸入沒有動任何手腳,你排序的依據是 Comparison Based ,也就是以「比較」作為基礎的話,理論上最快就是 nlogn ,不能再更快了 ::: 假設有一堆字母,[A,L,O,G,O,R,I,T,H,M,S],要將他們依照大小排序 ![](https://drive.google.com/uc?id=1ecNZqSblPEJA8T-qA_hk-JFtqZUk1c3q&export=download) - Divide:將這堆字母拆成兩半 - 拆要拆到最小,直到只剩一個的時候才不拆;這樣才可以直接吐答案 ## Pseudo Code ![](https://drive.google.com/uc?id=1E07xwPF4XAUKTDrO1csjbEjTNGeL-nmI&export=download) - 可以發現 q 使用地板函數(Flooring functino),所以另一邊是 q+1 - 使用整數的除法就可以達成 - Mergesort 就是處理遞迴的部分,也就是「Conquer」 - Merge 就是合併的部分,也就是「Combine」 - 可以發現到最後 p = r,就不會進行任何處理 整個步驟流程如下 ![](https://drive.google.com/uc?id=1rZnyxAnZOvau_8zIW7brr9Uhyfw90S8L&export=download) :::info 其實在做的時候不會左右一起做,而是最左半邊先做完 ::: --- # 複雜度分析 將整體的時間記為 T(n) - D(n) 就是「Divide」的部分 - T(n/2) 就是遞迴地呼叫自己,也就是「Conquer」的部分,並且 n 變成一半的大小 - C(n) 就是「Combine」的部分 因此最後的整體時間就會寫成 $$ T(n)=D(n)+2T(\frac{n}{2})+C(n) $$ 這就是上面所提到的 Recurrence Relation ![](https://drive.google.com/uc?id=1sdc824aUyohU3J8goFxd1UuuAtrldUXg&export=download) ## C(n) D(n) 只要一個步驟就可以結束,所以是 O(1) 理論上排序可以達到 nlogn 的速度,因此希望這個 C(n) 可以達到 O(n) 的速度 那要如何 Merge 呢,請看下圖 ![](https://drive.google.com/uc?id=1vu2Aw1m2OISN3X4BA9Q6vTAHtugolEsW&export=download) - 圖中 AGLOR 跟 HIMST 是已經排好序的兩個小部件(陣列) - 這兩個小部件要額外建立兩個 index ,記錄目前比較到哪裡 - 用一個額外的陣列來存放結果 :::success - 兩個小部件的 index 一開始就是 0 ,原因是因為我們要從頭開始比較,也就是比較「他們的最小」,到底誰才更小 - 比較出誰比較小後,讓他所在的陣列所持有的 index 加 1,並把比較小的人放進建立好的額外陣列 - 重複上述過程 當然也要補上是否有部件已經空了的檢查 ::: :::info 假設這兩個小部件各自有 n~1~, n~2~ 個人 那最糟糕的情形就是要比較 n~1~ + n~2~ - 1 次,最後一個剩下的人不用比 不過結果來說就是線性時間 O(n) ::: --- ## T(n/2) 那遞迴的部分要怎麼處理?在數學上有些比較難的、比較複雜的,可以直接查資料,不用自己推導 不過這裡所教的目前都很簡單,可以自己 Handle ### case:n ≦ 2 當遞迴到了最後 p = r ,也就是指 n ≦ 2 的時候,甚麼都不會做,因此是常數時間 用一個常數 c 表示: $$ T(n)\le c $$ > n = 1 就是 p = r ;n = 2 會呼叫兩個 T(1); ### case:n > 2 下面在推導的時候,其實不會先寫 O ,因為 O 代表了「精簡後」,會影響結果;所以這裡要先換成另一個常數 c 乘上 n ,寫作 cn ![](https://drive.google.com/uc?id=1Bv-_gqeLZmwY3yd1LwkGVYHJKIv3UTD6&export=download) 於是得到 $$ T(n)\le 2T(\frac{n}{2})+cn $$ :::warning 眼尖的人應該會發現,當初 q 有用地板函式,那這樣照理來說,不應該是2T(n/2) 因為取地板後,奇數個數會使得兩者的大小不一樣 ::: 沒有錯,就正確性來說應該要寫成下面這樣 ![](https://drive.google.com/uc?id=1hvLCk3zP8wnneJ0QPAa81Cs2Qb0qZtiY&export=download) 但是如果當 n 很大時,在 Asymptotic Analysis 下,這件事不打緊,不會影響結果 差異非常小,可以直接忽略它;因此如果函式的分析有出現地板根天花板函式(Flooring、Ceiling),就可以先忽略他們 --- ## 兩招處理 Recurrence ### 前置處理 :::info - 為了找出 Upper Bound,先將 ≦ 換成 =,也就是做最壞的打算 * 並且為了避免要取地板跟天花板,先假設 n 是 2 的某個次方 ::: $$ T(n)= 2T(\frac{n}{2})+cn $$ ## Unrolling the recurrence / Recurrence Tree :::info 老師說這招很常用 會叫做 Recurrence Tree 是因為會將 T(n) 展開成一個 Tree 的形式 ::: 三個步驟: - Analyzing the first few levels - 就是先畫出少少的一兩層,並找到規律 - Identifying a pattern - 畫出整個樹的樣子 - Summing over all levels - 將每層加起來 ### Analyzing the first few levels 將原本的 T(n) 展開個一兩層,找到規律;並且可以知道,總共只要做 logn 次展開 ![](https://drive.google.com/uc?id=1Re4X_u_jwxVCepaVNfHYqws7UyGW1ofK&export=download) ### Identifying a pattern 和 Summing over all levels Tree 的每個 Node 就代表一個小步驟(一個「Conquer」),最後就是把每個步驟加起來 每次加起來的時候,要 Level by Level 一層一層的加起來 ![](https://drive.google.com/uc?id=1oZe-a6mzL8TSSLjQ4BsZ0RT0lGXfTyX7&export=download) :::warning 因此最後得到的複雜度總和就是 cn × logn 取 Big O 可以得到 O(nlogn) ::: --- ## Substituting a guess :::info 利用數學歸納法 先猜一個答案,然後用數學歸納法證明;如果是很有經驗的人,可能很快就找到答案,但是對於經驗不多的人,可能找了很久都沒找到答案 >這不就是通靈嗎 ::: 上面所謂的猜答案,就是去猜他的時間複雜度;因為上面我們已經知道是 nlogn了,那這裡我們就猜 nlogn 從上面已知: $$ Any\ function\ T\ satisfying\ this\ recurrence\ \\ T(n)\le 2T(\frac{n}{2})+cn\ when\ n>2,\ and\ T(2)\le c\\ $$ 去猜 $$ is\ bounded\ by\ O(nlog_{2}n),\ when\ n>1 $$ 也就是說,我們相信下面這件事 $$ T(n)\le cnlog_{2}n, for\ all\ n\ge 2 $$ n=1 的時候是常數時間 ### Base step 已知的部分 $$ k=2,\ T(2)\le c\le 2c = c×2×log_{2}2 $$ ### Inductive Step (Strong) 開始猜的部分 $$ T(m)\le cmlog_{2}m\ for\ all\ m<n\ \\ T(\frac{n}{2})\le c(\frac{n}{2})log_{2}(\frac{n}{2});log_{2}(\frac{n}{2})=log_{2}n-1\ \\ T(n)\le 2T(\frac{n}{2})+cn\le 2c(\frac{n}{2})log_{2}(\frac{n}{2})+cn=cn[log_{2}n-1]+cn=cnlog_{2}n $$ 證明完畢 結論,對於所有 n ≧ 2 均成立 --- :::info 可以注意到,Unrolling 有換掉 ≦ 的步驟,而通靈法則維持 ≦ 的關係 ::: --- # Heap Sort 跟 Merge Sort 通常來說比較常採用 Merge Sort,因為 Heap Sort 基本上依賴陣列取值的方便性 可是如果資料不是以陣列儲存,而是像是 Linked List 之類的話, Heap Sort 就不是一個好選擇 這時候就是 Merge Sort 出場的地方 --- # 久違的手刻 Merge Sort ```c= #include<stdio.h> #include<stdlib.h> // sort a int array // can easily modify some little parts to sort other kinds of array // the swap function determin ascending or descending void swap(int left,int right,int* array); // merge function merge the two parts of array void merge(int left,int right,int middle,int* array); // main part of DAC void mergeSort(int left,int right,int* array); void pArray(int* array,int length); int main(int argc, char const *argv[]) { int N = 10; int array[10] = {3,2,1,6,5,4,9,8,7,10}; printf("Before\n"); pArray(array,N); mergeSort(N-N,N-1, array); printf("\nAfter\n"); pArray( array,N); return 0; } void pArray(int* array,int length){ for (int i = 0; i < length; i++) { printf("%d ",array[i]); } } void swap(int left,int right,int* array){ int temp = array[left]; array[left] = array[right]; array[right] = temp; } void merge(int left,int right,int middle,int* array){ // start comparing by head of the two parts int head_left = left; int head_right = middle + 1; // a temp array to store sorted elements int* temp = malloc(sizeof(int)*(right-left+1)); int temp_index = 0; // comparing the tow parts from each first element // put the smaller one to the temp array while (head_left <= middle && head_right <= right) { // "=" makes it stable // ascending if(array[head_left] <= array[head_right]){ temp[temp_index] = array[head_left]; temp_index++; head_left ++; }else{ temp[temp_index] = array[head_right]; temp_index++; head_right ++; } } // put remaining elements into temp array if(head_left<= middle){ for (int i = head_left; i <= middle ; i++) { temp[temp_index] = array[i]; temp_index++; } }else{ for (int i = head_right; i <= right ; i++) { temp[temp_index] = array[i]; temp_index++; } } // copy elements back to origin array for (int i = left; i <= right; i++) { array[i] = temp[i-left]; } // don't forget deallocate it free(temp); } void mergeSort(int left,int right,int* array){ // divide the array to two parts repeatedly // in the end it there may be one element in each part, which means right - left = 1 // or left = right, doesn't need to do anything if(right-left == 1){ // ascending if(array[left] > array[right]){ swap(left,right,array); } return; }else if(right-left == 0){ return; }else{ int middle = (left+right)/2; mergeSort(left,middle,array); mergeSort(middle+1,right,array); merge(left,right,middle,array); return; } } ```