最近讀了吳偉民老師的《數據結構》,學習有感,在此記錄
當我們面對規模龐大的問題的時候,往往會一頭霧水不知所措
但是如果我們能把這個大問題分解成小一點的問題,再把小一點的問題分解成更小的問題
最終分解成不能再分解的原子問題(Primitive Problem)
如果我們能找到一個通用的方法適用於所有原子問題,那么我們的大問題就迎刃而解了。
這種把大問題分解成小問題來解決(治理) [ Divide And Conquer 我覺得Conquer應該翻譯成解決比較好 ] 的方法被稱為 ‘ 分治 ’
分治的思想有助於我們解決困難的問題
比如我們要解決一個問題 : 桌子上有八顆大小不同的球,我們要怎么做才能快速地讓所有球從小到大(左到右)排序呢?
常規的想法是找到最小的球,和最左邊的球交換位置,在找到第二小的球,和左邊第二個換位置......
但是,如果采用分治的思想,我們把8顆球看成兩組,每組4顆,我們先把每組的順序排好,再把排好的每一組合並這樣,問題小了,好像我們做起來會比較輕松。
把對8顆球排序變為對兩組4顆球排序,然后把兩組排序后的結果合並,得到我們想要的全部球都有序的結果。
那么對於一組4顆球,我們是否也可以使用同樣的思想呢?
把對4顆球排序看成是對兩組球,每組2顆球排序,合並兩組排序的結果得到4顆球排序的結果
類似的,把對2顆球的排序看作是對兩組球,每組一顆球的排序,合並兩組排序結果得到2顆球排序結果
最后,只有一顆球了,對一顆球的排序,實際上就是不排序,不會再分解出新的問題,那么,回到上一級問題,也就是對2顆球排序
我們把兩組球(每組一顆)的排序結果合並,得到的結果是兩顆球有序
以上就是對兩組球(每組兩顆)的排序結果,如果我們把這個結果合並起來,就是左邊4顆球的排序結果
那么,怎么合並呢?
現在讓我們想像一下,有4個槽位,我們要把這兩組排好序的球放到這四個槽位里,達成我們給4顆球排序的效果,要怎么放置呢?
也就是要怎么合並這兩組球排好序的效果呢?(兩顆球排好序 + 兩顆球排好序 = 四顆球排好序)
容易想到,我們把兩組球的最左邊分別比較,為了讓描述方便,給這4顆球添加字母識別
因為兩組球,組內是有序的,所以只用比較兩組的左邊界就好了,就能找出兩組加起來的所有球中最小的球
並且把這顆球填到最左邊還空着的槽里
同理 A 和 D 比較, A 比較小,放入1槽里
B和D比較也是一樣
這時,已經有一個組是空的了(左邊那一組),沒有了最左邊界的球可以比較(如果是組里有一個球的話,這個球就是最左邊界)
那就把另一組非空的組按左到右順序加入槽中,當然,因為這里非空組里只剩下D,D理所應當地放入3下標位置
對排序后的兩組球(每組兩顆)的合並完成,也就是我們得到了4顆球的排序結果
同理地,用上述方法合並兩組球(每組4顆)的排序結果,可以得到8顆球的排序結果
基於這個思想,正式引出我們今天要講的排序算法 , deng deng deng deng ! 歸並排序 !
如果我們把剛剛的球換成數字呢?而且是數組中的數字,我們要對數組的排序結果合並。如果剛剛的球和數字等同
那么我們剛剛能放球的空槽等價於什么呢?
顯然我們需要一個目標數組來和他等價,用來把排好序的兩組數放進去,得到我們想要的結果
這么一看,好像我們只用把上面那個數組合並到下面那個就夠了。合並一次就能達到目的。但如果數據的數量更多,我們會發現不只移動一次
但實際上我們需要在兩個數組間進行多次移動
比如我們有一個容量為8的數組,我們想用歸並排序對他排序 :
整個過程如下圖
其中合並的過程 :
我們發現數據是在上下兩個數組間來回復制的,最終合並的結果落在目標數組上,因為我們本來就是想把原數組分成兩半,對兩半進行排序后
合並到目標數組里。
但有特殊的情況,也就是我們的數組元素個數是奇數的情況
比如說我們對以下數組進行拆分
我們會發現在半分的過程中,有的組拆分次數不一樣。
如果我們把整個過程逆過來看,一步一步分析,因為我們希望最后排序完的結果是在目標數組上的的,也就是第一行的數組是在目標數組上的
所以第二行一定是在原數組上,這才符合“把原數組分半,兩半的排序結果合並到目標數組”的思想
而第二行與第三行則反之,第二行應該是在原數組上,第三行應該是在目標數組上
同理,第四行是原數組,第三行是目標數組,比較特別的是因為第四行只有2和7合並,其他元素還沒進行操作,所以我們不畫他們
直觀一點,我們用手稿畫一下,左邊被正方形括起來的是‘組’
而沒有括起來的是原子(如最后一行的10和-1)
我們發現原子操作 : 對一個數字的排序和合並 就是直接將他復制到另一邊,成為一個組
而對原子的復制有兩種情況,一種是從原數組到目標數組,也就是倒數第二行
還有一種是從目標數組到原數組,也就是倒數第一行
但是我們發現,從目標數組到原數組的操作實際是空操作,因為原子本來就在我們的原數組里(這時候還沒有進行任何操作,原數組還是原來的樣子,目標數組處於原始狀態),而從原數組到目標數組的原字復制才是實際有操作的,也就是把原子從原數組復制到目標數組對應位置上(倒數第二行)
整個操作只有從上到下合並(原數組到目標數組)和從下到上合並兩種操作,如果我們把從上到制標記為0,從下到上標記為1
那么我們一開始的合並(第一行的合並)要標記為0
當我們對原子合並(也就是將原子復制到另一邊對應位置),此時的操作標記是0的話,說明確實要復制
而為1的時候,不用復制(對應倒數最后一行)
廢話了一大堆,終於要講代碼部分了
如果按照我們剛剛的說法,我們可以先寫一下偽代碼,偽代碼只是一種語義的表示
我們剛剛提到的重要操作有 : 1.合並 2.排序
對這兩個操作賦予語義 : 1.Merge 2.Sort
剛剛我們提到,一個大組的排序結果,就是把他分成兩個小組,兩個小組排序結果的合並
語義 : Sort(Big) = Merge( Sort(Big/2), Sort(Big/2) )
我們想把一個大組從中間分開,而且對分出來的兩個組先進行排序,然后合並
語義 :
偽代碼 :
Sort(int[] arr, int left, int right){ int mid = (left + right) / 2; Sort(arr, left, mid); Sort(arr, mid + 1, right); Merge(arr, left, mid, right); }
引入目標數組tar
Sort(int[] arr, int[] tar, int left, int right){ int mid = (left + right) / 2; Sort(arr, left, mid); Sort(arr, mid + 1, right); Merge(arr, tar, left, mid, right); }
引入操作標志tag
Sort(int[] arr, int[] tar, int tag, int left, int right){ int mid = (left + right) / 2; Sort(arr, left, mid); Sort(arr, mid + 1, right); Merge(arr, tar, left, mid, right); }
引入對原子的判斷
Sort(int[] arr, int[] tar, int tag, int left, int right){ if(left == right){} // 左邊界 == 右邊界 , 表示當前組只有一個元素,也就是原子 int mid = (left + right) / 2; Sort(arr, left, mid); Sort(arr, mid + 1, right); Merge(arr, tar, left, mid, right); }
引入操作的判斷,如果tag是0表示要從原數組復制到目標數組
Sort(int[] arr, int[] tar, int tag, int left, int right){ if(left == right){ if(tag == 0){ tar[left] = arr[left]; }
return; } int mid = (left + right) / 2; Sort(arr, left, mid); Sort(arr, mid + 1, right); Merge(arr, tar, tag, left, mid, right); }
引入操作轉換,比如說現在我們的tag是0,那么下一次tag就應該為1
Sort(int[] arr, int[] tar, int tag, int left, int right){ if(left == right){ if(tag == 0){ tar[left] = arr[left]; }
return; } int mid = (left + right) / 2; Sort(arr, tar, tag ^ 1,left, mid);//這里的^ : 0^1 = 1, 1^1 = 0, 起到取反作用,從而使得操作上下交替 Sort(arr, tar, tag ^ 1,mid + 1, right); Merge(arr, tar, tag, left, mid, right); }
這里我們特別注意 : mid = (left + right) / 2;
為什么我們用的是 left, mid mid + 1, right 這種邊界分組,而不是 left, mid - 1 mid, right 這種邊界分組呢?
假設一下我們的left = a, right = a + 1
也就是 left 和 right 左右邊界是相鄰的
那么,(left + right) / 2 = (2*a + 1) / 2 = a + 1/2
按照整型省略原則 a + 1/2 = a
那么 如果我們使用 left, mid - 1的話,實際用的是 a, a - 1
mid,right 實際是 a, a + 1 相對我們剛剛的 left, right 邊界根本沒有變,而且a , a - 1 會讓右邊界小於左邊界!
如果我們使用 left, mid 實際上用的是 a, a
mid + 1, right 實際上是 a + 1, a+ 1 正好滿足我們期望中的 left == right 的條件!
這是我們常見的,對線性結構分區時的邊界細節。
上述就是我們的並歸排序的大體偽代碼,可以看出來是一個遞歸實現
但是還有一點,我們的Merge函數還沒實現呢!
其實很簡單,也就是把一個數組兩個連續區域的元素按順序加入到另一邊的數組里
也就是我們上面講過的一個圖 :
回顧一下 :
Merge函數的arr參數就是上面的那個數組,原數組,而tar則是下面的數組,目標數組,而left指的是左邊小組的左邊界(2所在位置),mid指的是中間
因為我們剛剛指明了
左邊小組是從 left 到 mid,右邊小組是從 mid + 1 到right
所以mid 指的是上述的 3元素所在位置下標,而 mid + 1 則是1元素所在位置下標
mid 是左邊小組的右邊界,而mid + 1是左邊小組的左邊界
tag 用來判斷並軌操作是要從上到下還是從下到上
那么我們來實現一下圖中的過程:
首先要用兩個變量來遍歷左右兩個小組,比較左右兩個小組當前元素的大小,小的放到另一邊的數組里
定義左邊小組的邊界變量為 j,右邊小組的邊界變量為 k,變量 i 用來記錄現在我們要放東西進去的數組放到第幾個位置了
因為我們上面討論了,元素是在兩個數組間復制來復制去的,所以要放東西過去的數組不一定是我們的原數組,也不一定是目標數組
Merge(int[] arr, int[] tar, int tag, int left, int mid, int right){ int i, j, k; }
接着,既然我們知道數組之間復制來復制去,那么直接按照 tag 來判斷到底誰是要被放元素進去(也就是把並歸結果放進去)
Merge(int[] arr, int[] tar, int tag, int left, int mid, int right){ int i, j, k; if(tag == 1){ int[] temp = arr; arr = tar; tar = temp; } }
函數頭中
Merge(int[] arr, int[] tar, int tag, int left, int mid, int right)
arr 是MergeSort放進來的,只能是原數組,而 tar 只能是目標數組
但是我們為了方便直接認定 arr 是被並歸的數組,而 tar 是要被放並歸結果的數組,反正函數的引用形參交換不會影響外部引用實參(如果是JAVA )
並且直接用 tag 來認定誰是 arr , tar,也就是被並歸數組和接受並歸結果的數組
如果 tag 是 1,說明是從原本的目標數組 tar 並歸到 arr,那么 tar就是被並歸的數組,讓 arr 指向他
原本的 arr 是接受並歸結果的數組,所以把他設置成 tar,這樣只是圖方便簡潔,其實交換還是會降低一定效率的。
再來是,引入兩組待合並數組的邊界比較部分,兩組的邊界元素比較后,小的那個會放入 tar 里
Merge(int[] arr, int[] tar, int tag, int left, int mid, int right){ int i, j, k; if(tag == 1){ int[] temp = arr; arr = tar; tar = temp; } for(i = left,j = left, k = mid + 1; j <= mid && k <= right; i ++) { tar[i] = arr[j] < arr[k] ? arr[j ++] : arr[k ++]; } }
最后,是將剩下的元素壓入 tar ( 如果一個組已經被移入 tar 移空了,那么另外一組剩下的就可以直接放入 tar 了,反正已經有序了)
Merge(int[] arr, int[] tar, int tag, int left, int mid, int right){ int i, j, k; if(tag == 1){ int[] temp = arr; arr = tar; tar = temp; } for(i = left,j = left, k = mid + 1; j <= mid && k <= right; i ++) { tar[i] = arr[j] < arr[k] ? arr[j ++] : arr[k ++]; } while(j <= mid){ tar[i ++] = arr[j ++]; } while(k <= right){ tar[i ++] = arr[k ++]; } }
上面這些不像偽代碼的偽代碼,寫成Java的形式:
//調用 mergeSort 對 arr 數組排序后,arr 並不是有序的, 而 tar 才是有序
public void mergeSort(int[] arr, int[] tar, int tag, int left, int right){ if(left == right){ if(tag == 0){ tar[left] = arr[left]; } return; } int mid = (left + right) / 2; mergeSort(arr, tar, tag ^ 1,left, mid);//這里的^ : 0^1 = 1, 1^1 = 0, 起到取反作用,從而使得操作上下交替 mergeSort(arr, tar, tag ^ 1,mid + 1, right); merge(arr, tar, tag, left, mid, right); } public void merge(int[] arr, int[] tar, int tag, int left, int mid, int right){ int i, j, k; if(tag == 1){ int[] temp = arr; arr = tar; tar = temp; } for(i = left,j = left, k = mid + 1; j <= mid && k <= right; i ++) { tar[i] = arr[j] < arr[k] ? arr[j ++] : arr[k ++]; } while(j <= mid){ tar[i ++] = arr[j ++]; } while(k <= right){ tar[i ++] = arr[k ++]; } }
現在我們可以計算一下並歸排序的時間復雜度
歸於遞歸實現的算法,時間復雜度一般可以用消去法得出
首先,對於一個規模為 n 的問題,我們知道我們主要做了三件事
1.左半邊小組歸並排序
2.右半邊小組歸並排序
3.並歸
那么對於規模 n 的問題 並軌排序耗時的表達式為 :
T ( n ) = 2 * T ( n / 2 ) + Tm( n )
其中 T ( n ) 是歸並排序對規模為 n 的問題的耗時 , Tm ( n )是歸並操作對一個規模為 n 的問題的耗時
其實容易得出 Tm ( n ) = n 因為整個歸納操作只是線性的掃描兩個數組,讓后把他們線性地放入到接受並歸結果的數組,真正耗時可能為 k * n , 但是
算時間復雜度一般把常數 k 省略, 因為當 n 極大時,k << n , 可以忽略
則 T ( n ) = 2 * T ( n / 2 ) + n
我們把 兩邊同時除以 n,會發現等式兩邊出奇對稱
T ( n ) / n = [ 2 * T ( n / 2) / n ] + 1 =[ T ( n / 2) / ( n / 2 ) ] + 1
設 An = T(n) / n
則 An = A(n/2) + 1
A(n/2) = A(n/4) + 1
A(n/4) = A(n/8) + 1
A(n/8) = A(n/16) + 1
......
A(2) = A(1) + 1
把上述所有公式的左邊全部加和等於右邊全部加和
我們發現 第一行右邊的 A(n/2) 可以和第二行左邊的 A(n/2) 消去,第二行右邊的 A(n/4) 可以和第三行左邊的 A(n/4) 消去 ......
最后可以得出 An = A(1) + (log2)(n) = T(1)/1 + (log2)(n) = T(n)/n
最終把 T(n)/n 的 n 乘到左邊,得到 T(n) = n*T(1) + n * (log2)(n) = n * 1 + n * (log2)(n)
從極限的角度看,可以把 n 約去
也即 T(n) = n * (log2)(n) ,
可以看出歸並排序的時間復雜度是 n * logn 級別
實際上,我們整個排序過程的耗時操作幾乎都花在並歸上,因為我們的 MergeSort() 總是將排序委托給下一組
而且到了最后的 MergeSort 直接對原子進行復制就好了,根本沒有排序
而每將數組分一半都要進行一次並歸,如果我們的數組能分成兩半,那么只要並歸一次
如果我們的數組能分成四半,那么要並軌兩次,如果我們的數組是大小接近 2 ^ n , 那么要並歸 n 次
反過來,如果我們的數組大小是 n,那么要並歸 (log2)(n) 次,而每次並軌的都是線性操作,也就是每次並軌的長度總是總長度的 n / k
如果 n >> k ,那么我們可以近似地認為每次並歸的長度都是 n ,這樣最后的時間復雜度是 n * (log2)(n) 級別, 也就是 n * logn 級別
但是實際上,並歸排序需要一個額外的數組,一個額外的存儲空間,對於小內存機器,這無疑是致命的,尤其是對單片機之類的沒有磁盤,無法內存換頁IO的機器
當數據量十分龐大,整個機器可能因為沒有足夠的內存而癱瘓,所以在實際應用中,我們一般不會使用歸並排序,而是使用 時間復雜度同時 n * logn(一般情況下),而空間復雜度
是 O(1) 的快速排序