算法系列-排序上-如何用快排思想在O(n)內查詢第K大元素?
整理自極客時間-資料結構與演算法之美。原文內容更完整具體,且有音訊。購買地址:
一 .前言
上一節我講了氣泡排序、插入排序、選擇排序這三種排序演算法,它們的時間複雜度都是 O(n2),比較高,適合小規模資料的排序。今天,我講兩種時間複雜度為 O(nlogn) 的排序演算法,歸併排序和快速排序。這兩種排序演算法適合大規模的資料排序,比上一節講的那三種排序演算法要更常用。
歸併排序和快速排序都用到了分治思想,非常巧妙。我們可以借鑑這個思想,來解決非排序的問題,比如:如何在 O(n) 的時間複雜度內查詢一個無序陣列中的第 K 大元素? 這就要用到我們今天要講的內容。
二.歸併排序原理
(牛客網刷題連線)
歸併排序的核心思想: 如果要排序一個數組,我們先把陣列從中間分成前後兩部分,然後對前後兩部分分別排序,再將排好序的兩部分合並在一起,這樣整個陣列就都有序了。
歸併排序使用的是分治思想。分治,顧名思義,就是分而治之,將一個大問題分解成小的子問題來解決。小的子問題解決了,大問題也就解決了。
分治演算法一般都是用遞迴來實現的。分治是一種解決問題的處理思想,遞迴是一種程式設計技巧,這兩者並不衝突。
我們先寫出歸併排序的遞推公式:
遞推公式: merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r)) 終止條件: p >= r 不用再繼續分解 來解釋一下這個遞推公式。 merge_sort(p…r) 表示,給下標從 p 到 r 之間的陣列排序。我們將這個排序問題轉化為了兩個子問題, merge_sort(p…q) 和 merge_sort(q+1…r),其中下標 q 等於 p 和 r 的中間位置,也就是 (p+r)/2。 當下標從 p 到 q 和從 q+1 到 r 這兩個子陣列都排好序之後,我們再將兩個有序的子數組合並在一起, 這樣下標從 p 到 r 之間的資料就也排好序了。
有了遞推公式,轉化成程式碼就簡單多了。為了閱讀方便,我這裡只給出虛擬碼,你可以翻譯成你熟悉的程式語言。(PHP實現歸併排序)
// 歸併排序演算法, 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]) 這個函式的作用就是,將已經有序的 A[p…q] 和 A[q+1…r] 合併成一個有序的陣列。
我們把 merge() 函式寫成虛擬碼,就是下面這樣:
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() 合併函式如果藉助哨兵,程式碼就會簡潔很多,這個問題留給你思考。
三.歸併排序分析
第一,歸併排序是穩定的排序演算法嗎?
歸併排序穩不穩定關鍵要看 merge() 函式,也就是兩個有序子數組合併成一個有序陣列的那部分程式碼。
在合併的過程中,如果 A[p…q] 和 A[q+1…r] 之間有值相同的元素,那我們可以像虛擬碼中那樣,先把 A[p…q] 中的元素放入 tmp 陣列。這樣就保證了值相同的元素,在合併前後的先後順序不變。所以,歸併排序是一個穩定的排序演算法。
第二,歸併排序的時間複雜度是多少?
歸併排序涉及遞迴,時間複雜度的分析稍微有點複雜。
在遞迴那一節我們講過,遞迴的適用場景是,一個問題 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 的結果所消耗的時間。
從剛剛的分析,我們可以得到一個重要的結論:不僅遞迴求解的問題可以寫成遞推公式,遞迴程式碼的時間複雜度也可以寫成遞推公式。
套用這個公式,我們來分析一下歸併排序的時間複雜度。
我們假設對 n 個元素進行歸併排序需要的時間是 T(n),那分解成兩個子陣列排序的時間都是 T(n/2)。我們知道,merge() 函式合併兩個有序子陣列的時間複雜度是 O(n)。所以,套用前面的公式,歸併排序的時間複雜度的計算公式就是:
T(1) = C; n=1 時,只需要常量級的執行時間,所以表示為 C。
T(n) = 2*T(n/2) + n; n>1
那我們再進一步分解一下計算過程:
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^kT(n/2^k)+kn。當 T(n/2^k)=T(1) 時,也就是 n/2^k=1,我們得到 k=log2n 。我們將 k 值代入上面的公式,得到 T(n)=Cn+nlog2n 。如果我們用大 O 標記法來表示的話,T(n) 就等於 O(nlogn)。所以歸併排序的時間複雜度是 O(nlogn)。
從我們的原理分析和虛擬碼可以看出,歸併排序的執行效率與要排序的原始陣列的有序程度無關,所以其時間複雜度是非常穩定的,不管是最好情況、最壞情況,還是平均情況,時間複雜度都是 O(nlogn)。
第三,歸併排序的空間複雜度是多少?
歸併排序的時間複雜度任何情況下都是 O(nlogn),看起來非常優秀。(待會兒你會發現,即便是快速排序,最壞情況下,時間複雜度也是 O(n2)。)但是,歸併排序並沒有像快排那樣,應用廣泛,這是為什麼呢?因為它有一個致命的“弱點”,那就是歸併排序不是原地排序演算法。
這是因為歸併排序的合併函式,在合併兩個有序陣列為一個有序陣列時,需要藉助額外的儲存空間。這一點你應該很容易理解。
實際上,遞迴程式碼的空間複雜度並不能像時間複雜度那樣累加。剛剛我們忘記了最重要的一點,那就是,儘管每次合併操作都需要申請額外的記憶體空間,但在合併完成之後,臨時開闢的記憶體空間就被釋放掉了。在任意時刻,CPU 只會有一個函式在執行,也就只會有一個臨時的記憶體空間在使用。臨時記憶體空間最大也不會超過 n 個數據的大小,所以空間複雜度是 O(n)。
四.快速排序原理
快排的思想是這樣的:如果要排序陣列中下標從 p 到 r 之間的一組資料,我們選擇 p 到 r 之間的任意一個數據作為 pivot(分割槽點)。
我們遍歷 p 到 r 之間的資料,將小於 pivot 的放到左邊,將大於 pivot 的放到右邊,將 pivot 放到中間。經過這一步驟之後,陣列 p 到 r 之間的資料就被分成了三個部分,前面 p 到 q-1 之間都是小於 pivot 的,中間是 pivot,後面的 q+1 到 r 之間是大於 pivot 的。
根據分治、遞迴的處理思想,我們可以用遞迴排序下標從 p 到 q-1 之間的資料和下標從 q+1 到 r 之間的資料,直到區間縮小為 1,就說明所有的資料都有序了。
如果我們用遞推公式來將上面的過程寫出來的話,就是這樣:
遞推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)
終止條件:
p >= r
我將遞推公式轉化成遞迴程式碼。跟歸併排序一樣,我還是用虛擬碼來實現,你可以翻譯成你熟悉的任何語言。(php實現快速排序)
// 快速排序,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() 分割槽函式。partition() 分割槽函式實際上我們前面已經講過了,就是隨機選擇一個元素作為 pivot(一般情況下,可以選擇 p 到 r 區間的最後一個元素),然後對 A[p…r] 分割槽,函式返回 pivot 的下標。
如果我們不考慮空間消耗的話,partition() 分割槽函式可以寫得非常簡單。我們申請兩個臨時陣列 X 和 Y,遍歷 A[p…r],將小於 pivot 的元素都拷貝到臨時陣列 X,將大於 pivot 的元素都拷貝到臨時陣列 Y,最後再將陣列 X 和陣列 Y 中資料順序拷貝到 A[p…r]。
但是,如果按照這種思路實現的話,partition() 函式就需要很多額外的記憶體空間,所以快排就不是原地排序演算法了。如果我們希望快排是原地排序演算法,那它的空間複雜度得是 O(1),那 partition() 分割槽函式就不能佔用太多額外的記憶體空間,我們就需要在 A[p…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] 分成兩部分。A[p…i-1] 的元素都是小於 pivot 的,我們暫且叫它“已處理區間”,A[i…r-1] 是“未處理區間”。我們每次都從未處理的區間 A[i…r-1] 中取一個元素 A[j],與 pivot 對比,如果小於 pivot,則將其加入到已處理區間的尾部,也就是 A[i] 的位置。
陣列的插入操作還記得嗎?在陣列某個位置插入元素,需要搬移資料,非常耗時。當時我們也講了一種處理技巧,就是交換,在 O(1) 的時間複雜度內完成插入操作。這裡我們也藉助這個思想,只需要將 A[i] 與 A[j] 交換,就可以在 O(1) 時間複雜度內將 A[j] 放到下標為 i 的位置。
文字不如圖直觀,所以我畫了一張圖來展示分割槽的整個過程。
因為分割槽的過程涉及交換操作,如果陣列中有兩個相同的元素,比如序列 6,8,7,6,3,5,9,4,在經過第一次分割槽操作之後,兩個 6 的相對先後順序就會改變。所以,快速排序並不是一個穩定的排序演算法。
快速排序和歸併的區別
可以發現,歸併排序的處理過程是由下到上的,先處理子問題,然後再合併。而快排正好相反,它的處理過程是由上到下的,先分割槽,然後再處理子問題。歸併排序雖然是穩定的、時間複雜度為 O(nlogn) 的排序演算法,但是它是非原地排序演算法。我們前面講過,歸併之所以是非原地排序演算法,主要原因是合併函式無法在原地執行。快速排序通過設計巧妙的原地分割槽函式,可以實現原地排序,解決了歸併排序佔用太多記憶體的問題。
五.快速排序分析
快排也是用遞迴來實現的。對於遞迴程式碼的時間複雜度,我前面總結的公式,這裡也還是適用的。如果每次分割槽操作,都能正好把陣列分成大小接近相等的兩個小區間,那快排的時間複雜度遞推求解公式跟歸併是相同的。所以,快排的時間複雜度也是 O(nlogn)。
T(1) = C; n=1 時,只需要常量級的執行時間,所以表示為 C。
T(n) = 2*T(n/2) + n; n>1
但是,公式成立的前提是每次分割槽操作,我們選擇的 pivot 都很合適,正好能將大區間對等地一分為二。但實際上這種情況是很難實現的。
我舉一個比較極端的例子。如果陣列中的資料原來已經是有序的了,比如 1,3,5,6,8。如果我們每次選擇最後一個元素作為 pivot,那每次分割槽得到的兩個區間都是不均等的。我們需要進行大約 n 次分割槽操作,才能完成快排的整個過程。每次分割槽我們平均要掃描大約 n/2 個元素,這種情況下,快排的時間複雜度就從 O(nlogn) 退化成了 O(n2)。
我們剛剛講了兩個極端情況下的時間複雜度,一個是分割槽極其均衡,一個是分割槽極其不均衡。它們分別對應快排的最好情況時間複雜度和最壞情況時間複雜度。那快排的平均情況時間複雜度是多少呢?
我們假設每次分割槽操作都將區間分成大小為 9:1 的兩個小區間。我們繼續套用遞迴時間複雜度的遞推公式,就會變成這樣:
T(1) = C; n=1 時,只需要常量級的執行時間,所以表示為 C。
T(n) = T(n/10) + T(9*n/10) + n; n>1
這個公式的遞推求解的過程非常複雜,雖然可以求解,但我不推薦用這種方法。實際上,遞迴的時間複雜度的求解方法除了遞推公式之外,還有遞迴樹,在樹那一節我再講,這裡暫時不說。我這裡直接給你結論:T(n) 在大部分情況下的時間複雜度都可以做到 O(nlogn),只有在極端情況下,才會退化到 O(n2)。而且,我們也有很多方法將這個概率降到很低,如何來做?我們後面章節再講。
六解答開篇
快排核心思想就是分治和分割槽,我們可以利用分割槽的思想,來解答開篇的問題:O(n) 時間複雜度內求無序陣列中的第 K 大元素。比如,4, 2, 5, 12, 3 這樣一組資料,第 3 大元素就是 4。
我們選擇陣列區間 A[0…n-1] 的最後一個元素 A[n-1] 作為 pivot,對陣列 A[0…n-1] 原地分割槽,這樣陣列就分成了三部分,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] 區間查詢。
我們再來看,為什麼上述解決思路的時間複雜度是 O(n)?
第一次分割槽查詢,我們需要對大小為 n 的陣列執行分割槽操作,需要遍歷 n 個元素。第二次分割槽查詢,我們只需要對大小為 n/2 的陣列執行分割槽操作,需要遍歷 n/2 個元素。依次類推,分割槽遍歷元素的個數分別為、n/2、n/4、n/8、n/16.……直到區間縮小為 1。
如果我們把每次分割槽遍歷的元素個數加起來,就是:n+n/2+n/4+n/8+…+1。這是一個等比數列求和,最後的和等於 2n-1。所以,上述解決思路的時間複雜度就為 O(n)。
你可能會說,我有個很笨的辦法,每次取陣列中的最小值,將其移動到陣列的最前面,然後在剩下的陣列中繼續找最小值,以此類推,執行 K 次,找到的資料不就是第 K 大元素了嗎?
不過,時間複雜度就並不是 O(n) 了,而是 O(K * n)。你可能會說,時間複雜度前面的係數不是可以忽略嗎?O(K * n) 不就等於 O(n) 嗎?
這個可不能這麼簡單地劃等號。當 K 是比較小的常量時,比如 1、2,那最好時間複雜度確實是 O(n);但當 K 等於 n/2 或者 n 時,這種最壞情況下的時間複雜度就是 O(n2) 了。
七.小結
歸併排序和快速排序是兩種稍微複雜的排序演算法,它們用的都是分治的思想,程式碼都通過遞迴來實現,過程非常相似。理解歸併排序的重點是理解遞推公式和 merge() 合併函式。同理,理解快排的重點也是理解遞推公式,還有 partition() 分割槽函式。
歸併排序演算法是一種在任何情況下時間複雜度都比較穩定的排序演算法,這也使它存在致命的缺點,即歸併排序不是原地排序演算法,空間複雜度比較高,是 O(n)。正因為此,它也沒有快排應用廣泛。
快速排序演算法雖然最壞情況下的時間複雜度是 O(n2),但是平均情況下時間複雜度都是 O(nlogn)。不僅如此,快速排序演算法時間複雜度退化到 O(n2) 的概率非常小,我們可以通過合理地選擇 pivot 來避免這種情況。
八.思考
現在你有 10 個介面訪問日誌檔案,每個日誌檔案大小約 300MB,每個檔案裡的日誌都是按照時間戳從小到大排序的。你希望將這 10 個較小的日誌檔案,合併為 1 個日誌檔案,合併之後的日誌仍然按照時間戳從小到大排列。如果處理上述排序任務的機器記憶體只有 1GB,你有什麼好的解決思路,能“快速”地將這 10 個日誌檔案合併嗎?