12 | 排序(下):如何用快排思想在O(n)内查找第K大元素?
算法對比:
| 算法 | 時間復雜度 | 適合場景 |
| 冒泡排序、插入排序、選擇排序 | O(n2) | 小規模數據 |
| 歸并排序、快速排序 | O(nlogn) | 大規模數據 |
歸并排序和快速排序都用到了分治思想,非常巧妙。我們可以借鑒這個思想,來解決非排序的問題,比如:如何在 O(n) 的時間復雜度內查找一個無序數組中的第 K 大元素?
歸并排序
使用分治思想:大問題分解為小問題,分而治之,小問題解決了,大問題也就解決了。
歸并排序的遞推公式:
遞推公式 merger_sort(p..r) = merge(merger_sort(p..q),merger_sort(q+1..r)) 終止條件 p>=r 不再繼續分解歸并排序的偽代碼
merge_sort(A,n){merge_sort_c(A,0,n-1) } merge_sort_c(A,p,r){if p >= r then returnq = (p+r)/2;//分治遞歸merge_sort_c(A,p,q)merge_sort_c(A,q+1,r)//合并merge(A[p...r],A[p...q],A[q+1...r])}merge()函數的實現思路:
申請一個臨時數組 tmp,大小與 A[p...r]相同。我們用兩個游標 i 和 j,分別指向 A[p...q]和 A[q+1...r]的第一個元素。比較這兩個元素 A[i]和 A[j],如果 A[i]<=A[j],就把 A[i]放入到臨時數組 tmp,并且 i 后移一位,否則將 A[j]放入到數組 tmp,j 后移一位。繼續上述比較過程,直到其中一個子數組中的所有數據都放入臨時數組中,再把另一個數組中的數據依次加入到臨時數組的末尾,這個時候,臨時數組中存儲的就是兩個子數組合并之后的結果了。最后再把臨時數組 tmp 中的數據拷貝到原數組 A[p...r]中。
merge函數的偽代碼
merge(A[p...r], A[p...q], A[q+1...r]) {var i := p,j := q+1,k := 0 // 初始化變量i, j, kvar 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 := qif j<=r then start := j, end:=r// 將剩余的數據拷貝到臨時數組tmpwhile start <= end do {tmp[k++] = A[start++]}// 將tmp中的數組拷貝回A[p...r]for i:=0 to r-p do {A[p+i] = tmp[i]} }歸并排序性能分析
穩定排序、非原地排序、時間復雜度為?O(nlogn),歸并排序的執行效率與要排序的原始數組的有序程度無關,所以其時間復雜度是非常穩定的,不管是最好情況、最壞情況,還是平均情況,時間復雜度都是 O(nlogn)??臻g復雜度為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快排的偽代碼
// 快速排序,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 returnq = partition(A, p, r) // 獲取分區點quick_sort_c(A, p, q-1)quick_sort_c(A, q+1, r) }歸并排序中有一個 merge() 合并函數,我們這里有一個 partition() 分區函數。partition() 分區函數實際上我們前面已經講過了,就是隨機選擇一個元素作為 pivot(一般情況下,可以選擇 p 到 r 區間的最后一個元素),然后對 A[p...r]分區,函數返回 pivot 的下標。
如果不考慮空間消耗的,我們申請2個臨時數組X,Y,遍歷A[p...r],將小于pivot的元素復制到X,大于pivot的元素復制到Y,最后再將數組X和Y的數據順序拷貝到A[p...r]即可。示意圖:
如果我們希望快排是原地排序算法,那它的空間復雜度得是 O(1),那 partition() 分區函數就不能占用太多額外的內存空間,我們就需要在 A[p...r]的原地完成分區操作:
/**游標i對應的是什么數字并不關心,它只是一個分界線,游標i前面的區間都是小于pivot的,后面遍歷到某個數時,如果小于pivot,那么就跟當前的游標i進行數字交換,然后游標i++ 游標j是遍歷游標,一直往前走 游標i是分界游標,只有出現交換操作才會往前走 **/ partition(A, p, r) {pivot := A[r]i := pfor 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 的位置。因此快排不是穩定排序算法
快排和歸并排序區別:歸并排序是自頂向下,先處理子問題,然后再合并;快排是自底向上,先分區,然后再處理子問題。
歸并排序雖然是穩定排序,但是是非原地排序;而快速可以實現原地排序。歸并排序的時間復雜度跟原數據的是否有序無關,始終是O(nlogn);快速排序的時間復雜度在極端情況下:如果數組中的數據原來已經是有序的了,比如 1,3,5,6,8。如果每次選擇最后一個元素作為 pivot,那每次分區得到的兩個區間都是不均等的。需要進行大約 n 次分區操作,才能完成快排的整個過程。每次分區我們平均要掃描大約 n/2 個元素,這種情況下,快排的時間復雜度就從 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]區間查找。
?
總結
以上是生活随笔為你收集整理的12 | 排序(下):如何用快排思想在O(n)内查找第K大元素?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: LSTM神经网络图解
- 下一篇: Jenkins教程:使用Jenkins进