1. 程式人生 > >1017:歸併和快排

1017:歸併和快排

目錄

一、分治思想

二、歸併排序

三、快速排序

四、歸併排序與快速排序的區別


總結:歸併排序和快速排序

一、分治思想

1.分治思想:分治,顧明思意,就是分而治之,將一個大問題分解成小的子問題來解決,小的子問題解決了,大問題也就解決了。

2.分治與遞迴的區別:分治演算法一般都用遞迴來實現的。分治是一種解決問題的處理思想,遞迴是一種程式設計技巧。

二、歸併排序

1.演算法原理

先把陣列從中間分成前後兩部分,然後對前後兩部分分別進行排序,再將排序好的兩部分合併到一起,這樣整個陣列就有序了。這就是歸併排序的核心思想。

如何用遞迴實現歸併排序呢?寫遞迴程式碼的技巧就是分寫得出遞推公式,然後找到終止條件,最後將遞推公式翻譯成遞迴程式碼。

遞推公式怎麼寫?如下

遞推公式:

merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))

終止條件:

p >= r 不用再繼續分解

2.程式碼實現

// 歸併排序演算法, A 是陣列,n 表示陣列大小
merge_sort(A, n) {
  merge_sort_c(A, 0, n-1)
}
// 遞迴呼叫函式
merge_sort_c(A, p, r) {
  // 遞迴終止條件
  if p >= r  then return
  // 取 p 到 r 之間的中間位置 q
  q = (p+r) / 2
  // 分治遞迴
  merge_sort_c(A, p, q)
  merge_sort_c(A, q+1, r)
  // 將 A[p...q] 和 A[q+1...r] 合併為 A[p...r]
  merge(A[p...r], A[p...q], A[q+1...r])
}
merge(A[p...r], A[p...q], A[q+1...r]) {
  var i := p,j := q+1,k := 0 // 初始化變數 i, j, k
  var tmp := new array[0...r-p] // 申請一個大小跟 A[p...r] 一樣的臨時陣列
  while i<=q AND j<=r do {
    if A[i] <= A[j] {
      tmp[k++] = A[i++] // i++ 等於 i:=i+1
    } else {
      tmp[k++] = A[j++]
    }
  }
  // 判斷哪個子陣列中有剩餘的資料
  var start := i,end := q
  if j<=r then start := j, end:=r
  // 將剩餘的資料拷貝到臨時陣列 tmp
  while start <= end do {
    tmp[k++] = A[start++]
  }
  // 將 tmp 中的陣列拷貝回 A[p...r]
  for i:=0 to r-p do {
    A[p+i] = tmp[i]
  }
}
注:merge()合併函式如果藉助哨兵程式碼就會簡潔很多

3.效能分析

1)演算法穩定性:

歸併排序穩不穩定關鍵要看merge()函式,也就是兩個子數組合併成一個有序陣列的那部分程式碼。在合併的過程中,如果 A[p…q] 和 A[q+1…r] 之間有值相同的元素,那我們就可以像虛擬碼中那樣,先把 A[p…q] 中的元素放入tmp陣列,這樣 就保證了值相同的元素,在合併前後的先後順序不變。所以,歸併排序是一種穩定排序演算法。

2)時間複雜度:分析歸併排序的時間複雜度就是分析遞迴程式碼的時間複雜度

如何分析遞迴程式碼的時間複雜度?

遞迴的適用場景是一個問題a可以分解為多個子問題b、c,那求解問題a就可以分解為求解問題b、c。問題b、c解決之後,我們再把b、c的結果合併成a的結果。

若定義求解問題a的時間是T(a),則求解問題b、c的時間分別是T(b)和T(c),那就可以得到這樣的遞推公式:T(a) = T(b) + T(c) + K,其中K等於將兩個子問題b、c的結果合併成問題a的結果所消耗的時間。這裡有一個重要的結論:不僅遞迴求解的問題可以寫成遞推公式,遞迴程式碼的時間複雜度也可以寫成遞推公式。

套用這個公式,那麼歸併排序的時間複雜度就可以表示為:

T(1) = C; n=1 時,只需要常量級的執行時間,所以表示為 C。

T(n) = 2*T(n/2) + n; n>1,其中n就是merge()函式合併兩個子陣列的的時間複雜度O(n)。

T(n) = 2*T(n/2) + n

= 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n

= 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n

= 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n

......

= 2^k * T(n/2^k) + k * n

......

當T(n/2^k)=T(1) 時,也就是 n/2^k=1,我們得到k=log2n。將k帶入上面的公式就得到T(n)=Cn+nlog2n。如用大O表示法,T(n)就等於O(nlogn)。

所以,歸併排序的是複雜度時間複雜度就是O(nlogn)。

3)空間複雜度:歸併排序演算法不是原地排序演算法,空間複雜度是O(n)

為什麼?因為歸併排序的合併函式,在合併兩個陣列為一個有序陣列時,需要藉助額外的儲存空間。

為什麼空間複雜度是O(n)而不是O(nlogn)呢?

如果我們按照分析遞迴的時間複雜度的方法,通過遞推公式來求解,那整個歸併過程需要的空間複雜度就是O(nlogn),但這種分析思路是有問題的!因為,在實際上,遞迴程式碼的空間複雜度並不是像時間複雜度那樣累加,而是這樣的過程,即在每次合併過程中都需要申請額外的記憶體空間,但是合併完成後,臨時開闢的記憶體空間就被釋放掉了,在任意時刻,CPU只會有一個函式在執行,也就只會有一個臨時的記憶體空間在使用。臨時空間再大也不會超過n個數據的大小,所以空間複雜度是O(n)。

三、快速排序

1.演算法原理

快排的思想是這樣的:如果要排序陣列中下標從p到r之間的一組資料,我們選擇p到r之間的任意一個數據作為pivot(分割槽點)。然後遍歷p到r之間的資料,將小於pivot的放到左邊,將大於pivot的放到右邊,將povit放到中間。經過這一步之後,陣列p到r之間的資料就分成了3部分,前面p到q-1之間都是小於povit的,中間是povit,後面的q+1到r之間是大於povit的。

根據分治、遞迴的處理思想,我們可以用遞迴排序下標從p到q-1之間的資料和下標從q+1到r之間的資料,直到區間縮小為1,就說明所有的資料都有序了。

遞推公式:

quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)

終止條件:

p >= r

2.程式碼實現

將遞推公式轉換為虛擬碼如下:

// 快速排序,A 是陣列,n 表示陣列的大小
quick_sort(A, n) {
  quick_sort_c(A, 0, n-1)
}
// 快速排序遞迴函式,p,r 為下標
quick_sort_c(A, p, r) {
  if p >= r then return
  q = partition(A, p, r) // 獲取分割槽點
  quick_sort_c(A, p, q-1)
  quick_sort_c(A, q+1, r)
}
//分割槽函式
partition(A, p, r) {
  pivot := A[r]
  i := p
  for j := p to r-1 do {
    if A[j] < pivot {
      swap A[i] with A[j]
      i := i+1
    }
  }
  swap A[i] with A[r]
  return i
}

分割槽函式程式碼說明:通過遊標i把A[p...r-1]分成2部分,A[p...i-1]的元素都是小於pivot的,我們暫且叫它“已處理區間”,A[i+1...r-1]是“未處理區間”。我們每次都從未處理區間取出一個元素A[j],與poivt相比,如果小於pivot,則將其加入到已處理區間的尾部,也就是A[i]位置。

3.效能分析

1)演算法穩定性:

因為分割槽過程中涉及交換操作,如果陣列中有兩個8,其中一個是pivot,經過分割槽處理後,後面的8就有可能放到了另一個8的前面,先後順序就顛倒了,所以快速排序是不穩定的排序演算法。比如陣列[1,2,3,9,8,11,8],取後面的8作為pivot,那麼分割槽後就會將後面的8與9進行交換。

2)時間複雜度:最好、最壞、平均情況

快排也是用遞迴實現的,所以時間複雜度也可以用遞推公式表示。

如果每次分割槽操作都能正好把陣列分成大小接近相等的兩個小區間,那快排的時間複雜度遞推求解公式跟歸併的相同。

T(1) = C; n=1 時,只需要常量級的執行時間,所以表示為 C。

T(n) = 2*T(n/2) + n; n>1

所以,快排的時間複雜度也是O(nlogn)。

如果陣列中的元素原來已經有序了,比如1,3,5,6,8,若每次選擇最後一個元素作為pivot,那每次分割槽得到的兩個區間都是不均等的,需要進行大約n次的分割槽,才能完成整個快排過程,而每次分割槽我們平均要掃描大約n/2個元素,這種情況下,快排的時間複雜度就是O(n^2)。

前面兩種情況,一個是分割槽及其均衡,一個是分割槽極不均衡,它們分別對應了快排的最好情況時間複雜度和最壞情況時間複雜度。那快排的平均時間複雜度是多少呢?T(n)大部分情況下是O(nlogn),只有在極端情況下才是退化到O(n^2),而且我們也有很多方法將這個概率降低。

3)空間複雜度:快排是一種原地排序演算法,空間複雜度是O(1)

四、歸併排序與快速排序的區別

歸併和快排用的都是分治思想,遞推公式和遞迴程式碼也非常相似,那它們的區別在哪裡呢?

1.歸併排序,是先遞迴呼叫,再進行合併,合併的時候進行資料的交換。所以它是自下而上的排序方式。何為自下而上?就是先解決子問題,再解決父問題。

2.快速排序,是先分割槽,在遞迴呼叫,分割槽的時候進行資料的交換。所以它是自上而下的排序方式。何為自上而下?就是先解決父問題,再解決子問題。

五、思考

1.O(n)時間複雜度內求無序陣列中第K大元素,比如4,2,5,12,3這樣一組資料,第3大元素是4。

我們選擇陣列區間A[0...n-1]的最後一個元素作為pivot,對陣列A[0...n-1]進行原地分割槽,這樣陣列就分成了3部分,A[0...p-1]、A[p]、A[p+1...n-1]。

如果如果p+1=K,那A[p]就是要求解的元素;如果K>p+1,說明第K大元素出現在A[p+1...n-1]區間,我們按照上面的思路遞迴地在A[p+1...n-1]這個區間查詢。同理,如果K<p+1,那我們就在A[0...p-1]區間查詢。

時間複雜度分析?

第一次分割槽查詢,我們需要對大小為n的陣列進行分割槽操作,需要遍歷n個元素。第二次分割槽查詢,我們需要對大小為n/2的陣列執行分割槽操作,需要遍歷n/2個元素。依次類推,分割槽遍歷元素的個數分別為n、n/2、n/4、n/8、n/16......直到區間縮小為1。如果把每次分割槽遍歷的元素個數累加起來,就是等比數列求和,結果為2n-1。所以,上述解決問題的思路為O(n)。

2.有10個訪問日誌檔案,每個日誌檔案大小約為300MB,每個檔案裡的日誌都是按照時間戳從小到大排序的。現在需要將這10個較小的日誌檔案合併為1個日誌檔案,合併之後的日誌仍然按照時間戳從小到大排列。如果處理上述任務的機器記憶體只有1GB,你有什麼好的解決思路能快速地將這10個日誌檔案合併?