大廠面試:如何用快排思想在O(n)內查找第K大元素?

上一片文章我們學習冒泡排序、插入排序、選擇排序這三種排序算法(點擊文末鏈接查看),它們的時間複雜度都是 O(n2),比較高,適合小規模數據的排序。今天,我們學習時間複雜度爲 O(nlogn) 的排序算法,歸併排序快速排序。這兩種排序算法適合大規模的數據排序,比上篇文章學習的那三種排序算法要更常用。

歸併排序

歸併排序的核心思想還是蠻簡單的。如果要排序一個數組,我們先把數組從中間分成前後兩部分,然後對前後兩部分分別排序,再將排好序的兩部分合並在一起,這樣整個數組就都有序了。

歸併排序使用的就是分治思想。分治,顧名思義,就是分而治之,將一個大問題分解成小的子問題來解決。小的子問題解決了,大問題也就解決了。

分治算法一般都是用遞歸來實現的。分治是一種解決問題的處理思想,遞歸是一種編程技巧,這兩者並不衝突。

這裏要重點明說下寫遞歸代碼的技巧就是,分析得出遞推公式,然後找到終止條件,最後將遞推公式翻譯成遞歸代碼。所以,要想寫出歸併排序的代碼,我們先寫出歸併排序的遞推公式。

遞推公式:
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 之間的數據就也排好序了。

喜歡思考的朋友可能會注意到,上面的遞推公式中,最外層的 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]中。可以參考下圖加深理解。

接下來就是 show code time了,你可以參考下我這裏的實現:

快速排序

我們再來看快速排序算法(Quicksort),我們習慣性把它簡稱爲“快排”。快排利用的也是分治思想。乍看起來,它有點像歸併排序,但是思路其實完全不一樣。

快排的思想是這樣的:如果要排序數組中下標從 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 return
  
  q = partition(A, p, r) // 獲取分區點
  quick_sort_c(A, p, q-1)
  quick_sort_c(A, q+1, r)
}

歸併排序中有一個 merge() 合併函數,我們這裏需要有一個 partition() 分區函數。就是隨機選擇一個元素作爲 pivot(一般情況下,可以選擇 p 到 r 區間的最後一個元素),然後對 A[p…r]分區,函數返回 pivot 的下標。

因爲我們都知道快排需要實現空間複雜度爲O(1),因此需要原地分區。僞代碼如下:

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 的相對先後順序就會改變。所以,快速排序並不是一個穩定的排序算法(相同的值在排序過程中位置與原數組不同即爲不穩定)。

show code, 提供給大家參考下:

對比思考

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

可以發現,歸併排序的處理過程是由下到上的,先處理子問題,然後再合併。而快排正好相反,它的處理過程是由上到下的,先分區,然後再處理子問題。歸併排序雖然是穩定的、時間複雜度爲 O(nlogn) 的排序算法,但是它是非原地排序算法。歸併之所以是非原地排序算法,主要原因是合併函數無法在原地執行。快速排序通過設計巧妙的原地分區函數,可以實現原地排序,解決了歸併排序佔用太多內存的問題。

擴展:常見的面試題

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)。

小結

歸併排序和快速排序是兩種稍微複雜的排序算法,它們用的都是分治的思想,代碼都通過遞歸來實現,過程非常相似。理解歸併排序的重點是理解遞推公式和 merge() 合併函數。同理,理解快排的重點也是理解遞推公式,還有 partition() 分區函數。

歸併排序算法是一種在任何情況下時間複雜度都比較穩定的排序算法,這也使它存在致命的缺點,即歸併排序不是原地排序算法,空間複雜度比較高,是 O(n)。正因爲此,它也沒有快排應用廣泛。

 

推薦閱讀

最近面試 字節、BAT,整理一份面試資料《Java 面試 BAT 通關手冊》,覆蓋了 Java 核心技術、JVM、Java 併發、SSM、微服務、數據庫、數據結構等等。獲取方式:點“在看”,關注公衆號並回復 666 領取,更多內容陸續奉上

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章