八大排序演算法(解釋+程式碼+結果+演算法優化)

落葉歸根的豬發表於2020-12-11

歡迎關注公眾號/知乎(落葉歸根的豬),獲取所有全部乾貨,隨時隨地複習知識~    

       排序演算法可以分為內部排序外部排序內部排序是資料記錄在記憶體中進行排序,不需要訪問外存便能完成. 外部排序是因排序的資料很大,一次不能容納全部的排序記錄,在排序過程中需要訪問外存。常見的內部排序演算法有:插入排序、希爾排序、選擇排序、氣泡排序、歸併排序、快速排序、堆排序、基數排序等。用一張圖概括:

        外部排序點選以下圖片檢視大圖:

 

    關於時間複雜度

        平方階 (O(n2)) 排序 各類簡單排序:直接插入、直接選擇和氣泡排序。

        線性對數階 (O(nlog2n)) 排序 快速排序、堆排序和歸併排序;

        O(n1+§)) 排序,§ 是介於 0 和 1 之間的常數。 希爾排序

        線性階 (O(n)) 排序 基數排序,此外還有桶、箱排序。

    關於穩定性

        穩定的排序演算法:氣泡排序、插入排序、歸併排序和基數排序。

        不是穩定的排序演算法:選擇排序、快速排序、希爾排序、堆排序。

    名詞解釋:

        n:資料規模

        k:"桶"的個數

        In-place:佔用常數記憶體,不佔用額外記憶體

        Out-place:佔用額外記憶體

        穩定性:排序後 2 個相等鍵值的順序和排序之前它們的順序相同

 

1. 氣泡排序 BubbleSort

        氣泡排序是一種交換排序。

        什麼是交換排序呢?

        —— 兩兩比較待排序的關鍵字,並交換不滿足次序要求的那對數,直到整個表都滿足次序要求為止。

        它重複地走訪要排序的數列,一次比較兩個元素,如果他們的順序錯誤就把他們交換過來。走訪數列的工作是重複地進行直到沒有再需要交換,也就是說該數列已經排序完成。

        這個演算法的名字由來是因為越小的元素會經由交換慢慢“浮”到數列的頂端,故名氣泡排序。

        動態效果示意圖:

        假設有一個大小為 N 的無序序列。以升序氣泡排序為例,氣泡排序就是要每趟排序過程中通過兩兩比較相鄰元素,將小的數字放到前面,大的數字放在後面。

 

演算法分析

1、氣泡排序演算法的效能

2、時間複雜度

        若檔案的初始狀態是正序的,一趟掃描即可完成排序。所需的關鍵字比較次數C和記錄移動次數M均達到最小值:Cmin = N - 1, Mmin = 0。所以,氣泡排序最好時間複雜度為O(N)。

        但是上述程式碼,不能掃描一趟就完成排序,它會進行全掃描。所以一個改進的方法就是,當冒泡中途發現已經為正序了,便無需繼續比對下去。改進方法一會兒介紹。

        若初始檔案是反序的,需要進行 N -1 趟排序。每趟排序要進行 N - i 次關鍵字的比較(1 ≤ i ≤ N - 1),且每次比較都必須移動記錄三次來達到交換記錄位置。在這種情況下,比較和移動次數均達到最大值:

        Cmax = N(N-1)/2 = O(N^2)

        Mmax = 3N(N-1)/2 = O(N^2)

        氣泡排序的最壞時間複雜度為O(N^2)。

        因此,氣泡排序的平均時間複雜度為O(N^2)。

        總結起來,其實就是一句話:當資料越接近正序時,氣泡排序效能越好。

    3. 演算法穩定性

        假定在待排序的記錄序列中,存在多個具有相同的關鍵字的記錄,若經過排序,這些記錄的相對次序保持不變,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序後的序列中,r[i]仍在r[j]之前,則稱這種排序演算法是穩定的;否則稱為不穩定的。

        氣泡排序就是把小的元素往前調或者把大的元素往後調。是相鄰的兩個元素的比較,交換也發生在這兩個元素之間。所以相同元素的前後順序並沒有改變,所以氣泡排序是一種穩定排序演算法

    4. 優化

        對氣泡排序常見的改進方法是加入標誌性變數exchange,用於標誌某一趟排序過程中是否有資料交換。

        如果進行某一趟排序時並沒有進行資料交換,則說明所有資料已經有序,可立即結束排序,避免不必要的比較過程。

 

2. 快速排序 Quicksort

        快速排序是一種交換排序,它由C. A. R. Hoare在1962年提出。快速排序(quick sort)的採用了分而治之(divide and conquer)的策略:將原問題分解為若干個規模更小但結構與原問題相似的子問題。遞迴地解這些子問題,然後將這些子問題的解組合為原問題的解。

    1. 演算法思想

        快速排序的基本思想是:通過一趟排序將要排序的資料分割成獨立的兩部分:分割點左邊都是比它小的數,右邊都是比它大的數。

        然後再按此方法對這兩部分資料分別進行快速排序,整個排序過程可以遞迴進行,以此達到整個資料變成有序序列。

        動態效果示意圖:

        詳細的圖解往往比大堆的文字更有說明力,所以直接上圖:

        上圖中,演示了快速排序的處理過程:

        初始狀態為一組無序的陣列:2、4、5、1、3。

        經過以上操作步驟後,完成了第一次的排序,得到新的陣列:1、2、5、4、3。

        新的陣列中,以2為分割點,左邊都是比2小的數,右邊都是比2大的數。

        因為2已經在陣列中找到了合適的位置,所以不用再動。

        2左邊的陣列只有一個元素1,所以顯然不用再排序,位置也被確定。(注:這種情況時,left指標和right指標顯然是重合的。因此在程式碼中,我們可以通過設定判定條件left必須小於right,如果不滿足,則不用不用不用不不用不用不用不用不用用不用排序了)。

        而對於2右邊的陣列5、4、3,設定left指向5,right指向3,開始繼續重複圖中的一、二、三、四步驟,對新的陣列進行排序。

    快排的工作過程其實比較簡單,三步走:

        a. 選擇基準值 pivot 將陣列分成兩個子陣列:小於基準值的元素和大於基準值的元素。這個過程稱之為 partition

        b. 對這兩個子陣列進行快速排序。

        c. 合併結果,遞迴地(recursive)把小於基準值元素的子數列和大於基準值元素的子數列排序。

    2. 程式碼+結果

    3. 演算法分析

        3.1 快速排序演算法的效能

        3.2 時間複雜度

        當資料有序時,以第一個關鍵字為基準分為兩個子序列,前一個子序列為空,此時執行效率最差。

        而當資料隨機分佈時,以第一個關鍵字為基準分為兩個子序列,兩個子序列的元素個數接近相等,此時執行效率最好。

        所以,資料越隨機分佈時,快速排序效能越好;資料越接近有序,快速排序效能越差。

        3.3 時間複雜度

        快速排序在每次分割的過程中,需要 1 個空間儲存基準值。而快速排序的大概需要 logN次的分割處理,所以佔用空間也是 logN 個。

    3.4 演算法穩定性

        在快速排序中,相等元素可能會因為分割槽而交換順序,所以它是不穩定的演算法。

 

3. 直接插入排序 Straight Insertion Sort

        直接插入排序(Insertion Sort)序是一種最簡單的插入排序。為簡化問題,我們下面只討論升序排序。

    1. 演算法思想

        插入排序:每一趟將一個待排序的記錄,按照其關鍵字的大小插入到有序佇列的合適位置裡,知道全部插入完成。 

        動態效果示意圖:

        以上的過程,其實就是典型的直接插入排序,每次將一個新資料插入到有序佇列中的合適位置裡。

        很簡單吧,接下來,我們要將這個演算法轉化為程式語言。

        假設有一組無序序列 R0, R1, ... , RN-1。

        (1) 我們先將這個序列中下標為 0 的元素視為元素個數為 1 的有序序列。

        (2) 然後,我們要依次把 R1, R2, ... , RN-1 插入到這個有序序列中。所以,我們需要一個外部迴圈,從下標 1 掃描到 N-1 。

        (3) 接下來描述插入過程。假設這是要將 Ri 插入到前面有序的序列中。由前面所述,我們可知,插入Ri時,前 i-1 個數肯定已經是有序了。

        所以我們需要將Ri 和R0 ~ Ri-1 進行比較,確定要插入的合適位置。這就需要一個內部迴圈,我們一般是從後往前比較,即從下標 i-1 開始向 0 進行掃描。

2. 程式碼+答案

3. 演算法分析

    3.1 直接插入排序的演算法效能

    3.2 時間複雜度

        當資料正序時,執行效率最好,每次插入都不用移動前面的元素,時間複雜度為O(N)。 

        當資料反序時,執行效率最差,每次插入都要前面的元素後移,時間複雜度為O(N^2)

        所以,資料越接近正序,直接插入排序的演算法效能越好。 

    3.3 空間複雜度

        由直接插入排序演算法可知,我們在排序過程中,需要一個臨時變數儲存要插入的值,所以空間複雜度為 1 。

    3.4 演算法穩定性

        直接插入排序的過程中,不需要改變相等數值元素的位置,所以它是穩定的演算法。 

    4. 優化

        因為在一個有序序列中查詢一個插入位置,以保證有序序列的序列不變,所以可以使用二分查詢,減少元素比較次數提高效率。

        二分查詢是對於有序陣列而言的,假設如果陣列是升序排序的。那麼,二分查詢演算法就是不斷對陣列進行對半分割,每次拿中間元素和目標數字進行比較,如果中間元素小於目標數字,則說明目標數字應該在右側被分割的陣列中,如果中間元素大於目標數字,則說明目標數字應該在左側被分割的陣列中。

 

4. 希爾排序 Shell's Sort

        希爾(Shell)排序又稱為縮小增量排序,它是一種插入排序。它是直接插入排序演算法的一種威力加強版。

        希爾排序,也稱遞減增量排序演算法,以其設計者希爾(Donald Shell)的名字命名,該演算法由 1959 年公佈。

    1. 演算法思想

        我們舉個例子來描述演算法流程(以下摘自維基百科):

        假設有這樣一組數 {13, 14, 94, 33, 82, 25, 59, 94, 65, 23, 45, 27, 73, 25, 39, 10},如果我們以步長為 5 開始進行排序:

13 14 94 33 82

25 59 94 65 23

45 27 73 25 39

10

        然後我們對每列進行排序:

10 14 73 25 23

13 27 94 33 39

25 59 94 65 82

45

        將上述四行數字,依序接在一起時我們得到:{10, 14, 73, 25, 23, 13, 27, 94, 33, 39, 25, 59, 94, 65, 82, 45},然後再以 3 為步長:

10 14 73

25 23 13

27 94 33

39 25 59

94 65 82

45

        排序之後變為:

10 14 13

25 23 33

27 25 59

39 65 73

45 94 82

94

        最後以 1 為步長進行排序(此時就是簡單的插入排序了)。

        可想而知,步長的選擇是希爾排序的重要部分。演算法最開始以一定的步長進行排序,然後會繼續以更小的步長進行排序,最終演算法以步長為 1 進行排序。當步長為 1 時,演算法變為直接插入排序,這就保證了資料一定會被全部排序。

        下面以n/2作為步長為例進行講解。(步長自己取)

演算法分析

     希爾排序的演算法效能

    1. 時間複雜度

        步長的選擇是希爾排序的重要部分,只要最終步長為1任何步長序列都可以工作。

        演算法最開始以一定的步長進行排序。然後會繼續以一定步長進行排序,最終演算法以步長為1進行排序。當步長為1時,演算法變為插入排序,這就保證了資料一定會被排序。

        步長序列的不同,會導致最壞的時間複雜度情況的不同。

        本文中,以N/2為步長的最壞時間複雜度為N^2。

        Donald Shell 最初建議步長選擇為N/2並且對步長取半直到步長達到1。雖然這樣取可以比O(N^2)類的演算法(插入排序)更好,但這樣仍然有減少平均時間和最差時間的餘地。可能希爾排序最重要的地方在於當用較小步長排序後,以前用的較大步長仍然是有序的。比如,如果一個數列以步長5進行了排序然後再以步長3進行排序,那麼該數列不僅是以步長3有序,而且是以步長5有序。如果不是這樣,那麼演算法在迭代過程中會打亂以前的順序,那就不會以如此短的時間完成排序了。

        用這樣步長序列的希爾排序比插入排序要快,甚至在小陣列中比快速排序和堆排序還快,但是在涉及大量資料時希爾排序還是比快速排序慢。

    2. 演算法穩定性

        希爾排序中相等資料可能會交換位置,所以希爾排序是不穩定的演算法。

    3. 直接插入排序和希爾排序的比較

        直接插入排序是穩定的;而希爾排序是不穩定的。

        直接插入排序更適合於原始記錄基本有序的集合。

        希爾排序的比較次數和移動次數都要比直接插入排序少,當N越大時,效果越明顯。  

        希爾排序的比較次數和移動次數都要比直接插入排序少,當N越大時,效果越明顯。  

        直接插入排序也適用於鏈式儲存結構;希爾排序不適用於鏈式結構

 

5. 簡單選擇排序

        簡單選擇排序是一種選擇排序

        選擇排序:每趟從待排序的記錄中選出關鍵字最小的記錄,順序放在已排序的記錄序列末尾,直到全部排序結束為止。

    1. 演算法思想

        簡單排序很簡單,它的大致處理流程為:

        從待排序序列中,找到關鍵字最小的元素;

        如果最小元素不是待排序序列的第一個元素,將其和第一個元素互換;

        從餘下的 N - 1 個元素中,找出關鍵字最小的元素,重複(1)、(2)步,直到排序結束。

        動態效果示意圖:

        舉例說明,處理過程示意圖如下所示:

        如圖所示,每趟排序中,將當前 i 小的元素放在位置 i 上。

    2. 程式碼+結果

3. 演算法分析

    3.1 簡單演算法排序效能

        其中,N2為N^2。

    3.2 時間複雜度

        簡單選擇排序的比較次數與序列的初始排序無關。 假設待排序的序列有 N 個元素,則比較次數總是N (N - 1) / 2

        而移動次數與序列的初始排序有關。當序列正序時,移動次數最少,為 0.

        當序列反序時,移動次數最多,為3N (N - 1) /  2。

        所以,綜合以上,簡單排序的時間複雜度為O(N^2)

    3.3 空間複雜度

        簡單選擇排序需要佔用1個臨時空間,用於儲存最小值得索引。

 

6. 堆排序

        堆排序是一種選擇排序。

        選擇排序:每趟從待排序的記錄中選出關鍵字最小的記錄,順序放在已排序的記錄序列末尾,直到全部排序結束為止。

1. 演算法思想

        堆排序是利用堆的性質進行的一種選擇排序。

        動態效果示意圖:

        是一棵順序儲存完全二叉樹

        其中每個結點的關鍵字都不大於其孩子結點的關鍵字,這樣的堆稱為小根堆

        其中每個結點的關鍵字都不小於其孩子結點的關鍵字,這樣的堆稱為根堆

        舉例來說,對於n個元素的序列{R0, R1, ... , Rn}當且僅當滿足下列關係之一時,稱之為堆:

        Ri <= R2i+1 且 Ri <= R2i+2 (小根堆)

        Ri >= R2i+1 且 Ri >= R2i+2 (大根堆)

        其中i=1,2,…,n/2向下取整;

        如上圖所示,序列R{3, 8, 15, 31, 25}是一個典型的小根堆。

        堆中有兩個結點,元素3和元素8。

        元素3在陣列中以R[0]表示,它的左孩子結點是R[1],右孩子結點是R[2]。

        元素8在陣列中以R[1]表示,它的左孩子結點是R[3],右孩子結點是R[4],它的父結點是R[0]。可以看出,它們滿足以下規律

    設當前元素在陣列中以R[i]表示,那麼,

        (1) 它的左孩子結點是:R[2*i+1];

        (2) 它的右孩子結點是:R[2*i+2];

        (3) 它的父結點是:R[(i-1)/2];

        (4) R[i] <= R[2*i+1] 且 R[i] <= R[2i+2]。

        首先,按堆的定義將陣列R[0..n]調整為堆(這個過程稱為建立初始堆),交換R[0]和R[n];

        然後,將R[0..n-1]調整為堆,交換R[0]和R[n-1];

        如此反覆,直到交換了R[0]和R[1]為止。

        以上思想可歸納為兩個操作:

        (1)根據初始陣列去構造初始堆(構建一個完全二叉樹,保證所有的父結點都比它的孩子結點數值大)。

        (2)每次交換第一個和最後一個元素,輸出最後一個元素(最大值),然後把剩下元素重新調整為大根堆。

        當輸出完最後一個元素後,這個陣列已經是按照從小到大的順序排列了。

        先通過詳細的例項圖來看一下,如何構建初始堆。

        設有一個無序序列 { 1, 3, 4, 5, 2, 6, 9, 7, 8, 0 }。

        構造了初始堆後,我們來看一下完整的堆排序處理:

        還是針對前面提到的無序序列 { 1, 3, 4, 5, 2, 6, 9, 7, 8, 0 } 來加以說明。

         比如如下陣列 {57, 40, 38, 11, 13, 34, 48, 75, 6, 19, 9, 7}堆排序前如下:

進行堆排序後如下:

        最大堆的儲存結構如下:

        接著,最後一步,堆排序,進行(n-1)次迴圈。

         相信,通過以上兩幅圖,應該能很直觀的演示堆排序的操作處理。 

        看完上面所述的流程你至少有一個疑問:

    如何確定最後一個非葉子結點?

        其實這是有一個公式的,設二叉樹結點總數為 n,則最後一個非葉子結點是第⌊n/2⌋個。

2. 程式碼+結果:

3. 演算法分析

    3.1 堆排序演算法的總體情況

    3.2 時間複雜度

        首先計算建堆的時間,也就是下面的程式碼,

// 迴圈建立初始堆

for (int i = length / 2; i >= 0; i--){

HeapAdjust(list, i, length);

}

        n 個結點,從第 0 層至第logn層。對於第 i 層的2i個點如果需要往下走logn−i步,那麼把走的所有步相加得:

        接下來就是排序的時間,即下面的程式碼:

// 進行n-1次迴圈,完成排序

for (int i = length - 1; i > 0; i--){

// 最後一個元素和第一元素進行交換

int temp = list[i];

list[i] = list[0];

list[0] = temp;

// 篩選 R[0] 結點,得到i-1個結點的堆

HeapAdjust(list, 0, i);

}

        HeapAdjust() 耗時logn,共 n 次,故排序時間為O(nlogn)。

        堆的儲存表示是順序的。因為堆所對應的二叉樹為完全二叉樹,而完全二叉樹通常採用順序儲存方式。

        當想得到一個序列中第k個最小的元素之前的部分排序序列,最好採用堆排序。

    3.3 演算法穩定性

        堆排序是一種不穩定的排序方法。

        因為在堆的調整過程中,關鍵字進行比較和交換所走的是該結點到葉子結點的一條路徑,因此對於相同的關鍵字就可能出現排在後面的關鍵字被交換到前面來的情況。 

 

7. 歸併排序 MergeSort

        歸併排序是建立在歸併操作上的一種有效的排序演算法,該演算法是採用分治法(Divide and Conquer的一個非常典型的應用。

1. 演算法思想

        該演算法採用經典的分治(divide-and-conquer)策略(分治法將問題(divide)成一些小的問題然後遞迴求解,而治(conquer)的階段則將分的階段得到的各答案"修補"在一起,即分而治之)。

        動態效果示意圖:

        分而治之:

        1. 分階段

        可以看到這種結構很像一棵完全二叉樹,本文的歸併排序我們採用遞迴去實現(也可採用迭代的方式去實現)。階段可以理解為就是遞迴拆分子序列的過程,遞迴深度為logn。

        2. 治階段

        再來看看階段,我們需要將兩個已經有序的子序列合併成一個有序序列,比如上圖中的最後一次合併,要將[4,5,7,8]和[1,2,3,6]兩個已經有序的子序列,合併為最終序列[1,2,3,4,5,6,7,8],來看下實現步驟。

 

3. 演算法分析

    3.1. 歸併排序演算法的效能

        其中,log2n為以2為底,n的對數。

    3.2 時間複雜度

        歸併排序的形式就是一棵二叉樹,它需要遍歷的次數就是二叉樹的深度,而根據完全二叉樹的可以得出它的時間複雜度是O(n*log2n)

    3.3 空間複雜度

        由前面的演算法說明可知,演算法處理過程中,需要一個大小為n的臨時儲存空間用以儲存合併序列。

    3.4 演算法穩定性

        在歸併排序中,相等的元素的順序不會改變,所以它是穩定的演算法。

    3.5 歸併排序和堆排序、快速排序的比較

        若從空間複雜度來考慮:首選堆排序,其次是快速排序,最後是歸併排序。

        若從穩定性來考慮,應選取歸併排序,因為堆排序和快速排序都是不穩定的。

        若從平均情況下的排序速度考慮,應該選擇快速排序。 

 

8. 基數排序 RadixSort

        基數排序是一種非比較型整數排序演算法,其原理是將整數按位數切割成不同的數字,然後按每個位數分別比較。由於整數也可以表達字串(比如名字或日期)和特定格式的浮點數,所以基數排序也不是隻能使用於整數。

1. 演算法思想

        基本思想:將所有待比較數值(正整數)統一為同樣的數位長度,數位較短的數前面補零。然後,從最低位開始,依次進行一次排序。這樣從最低位排序一直到最高位排序完成以後,數列就變成一個有序序列。

        演算法步驟:

        將所有待比較數值(正整數)統一為同樣的數位長度,數位較短的數前面補零。

        從最低位開始,依次進行一次排序。

        這樣從最低位排序一直到最高位排序完成以後, 數列就變成一個有序序列。

        基數排序的方式可以採用 LSD(Least significant digital)或 MSD(Most significant digital),LSD 的排序方式由鍵值的最右邊開始,而 MSD 則相反,由鍵值的最左邊開始。

        不妨通過一個具體的例項來展示一下基數排序是如何進行的。 設有一個初始序列為: R {50, 123, 543, 187, 49, 30, 0, 2, 11, 100}。

        我們知道,任何一個阿拉伯數,它的各個位數上的基數都是以 0~9 來表示的,所以我們不妨把 0~9 視為 10 個桶。

        我們先根據序列的個位數的數字來進行分類,將其分到指定的桶中。例如:R[0] = 50,個位數上是 0,將這個數存入編號為 0 的桶中。

        分類後,我們在從各個桶中,將這些數按照從編號 0 到編號 9 的順序依次將所有數取出來。這時,得到的序列就是個位數上呈遞增趨勢的序列。

        按照個位數排序: {50, 30, 0, 100, 11, 2, 123, 543, 187, 49}。

        接下來,可以對十位數、百位數也按照這種方法進行排序,最後就能得到排序完成的序列。

        動態效果示意圖:

3. 演算法分析

    3.1 基數排序的效能

        其中,d代表陣列元素最高為位數,n代表元素個數。

    3.2 時間複雜度

        這個時間複雜度比較好計算:count * length;其中 count 為陣列元素最高位數,length為元素個數;所以時間複雜度:O(n * d)

    3.3 空間複雜度

        空間複雜度是使用了兩個臨時的陣列:10 + length;所以空間複雜度:O(n)。

    3.4 演算法穩定性

        在基數排序過程中,每次都是將當前位數上相同數值的元素統一“裝桶”,並不需要交換位置。所以基數排序是穩定的演算法。

相關文章