面試時寫不出排序算法?看這篇就夠了。

本文主要詳細講述常見的八種排序算法的思想、實現以及複雜度。java

:notebook: 本文已歸檔到:「bloggit

:keyboard: 本文中的示例代碼已歸檔到:「algorithm-tutorialgithub

冒泡排序

要點

冒泡排序是一種交換排序。算法

什麼是交換排序呢?shell

交換排序:兩兩比較待排序的關鍵字,並交換不知足次序要求的那對數,直到整個表都知足次序要求爲止。編程

算法思想

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

這個算法的名字由來是由於越小的元素會經由交換慢慢「浮」到數列的頂端,故名。編程語言

假設有一個大小爲 N 的無序序列。冒泡排序就是要每趟排序過程當中經過兩兩比較,找到第 i 個小(大)的元素,將其往上排。ide

以上圖爲例,演示一下冒泡排序的實際流程:post

假設有一個無序序列 { 4. 3. 1. 2, 5 }

  • 第一趟排序:經過兩兩比較,找到第一小的數值 1 ,將其放在序列的第一位。
  • 第二趟排序:經過兩兩比較,找到第二小的數值 2 ,將其放在序列的第二位。
  • 第三趟排序:經過兩兩比較,找到第三小的數值 3 ,將其放在序列的第三位。

至此,全部元素已經有序,排序結束。

要將以上流程轉化爲代碼,咱們須要像機器同樣去思考,否則編譯器可看不懂。

  • 假設要對一個大小爲 N 的無序序列進行升序排序(即從小到大)。
    • 每趟排序過程當中須要經過比較找到第 i 個小的元素。
    • 因此,咱們須要一個外部循環,從數組首端(下標 0) 開始,一直掃描到倒數第二個元素(即下標 N - 2) ,剩下最後一個元素,必然爲最大。
  • 假設是第 i 趟排序,可知,前 i-1 個元素已經有序。如今要找第 i 個元素,只需從數組末端開始,掃描到第 i 個元素,將它們兩兩比較便可。
    • 因此,須要一個內部循環,從數組末端開始(下標 N - 1),掃描到 (下標 i + 1)。

核心代碼

public void bubbleSort(int[] list) {
    int temp = 0; // 用來交換的臨時數

    // 要遍歷的次數
    for (int i = 0; i < list.length - 1; i++) {
        // 從後向前依次的比較相鄰兩個數的大小,遍歷一次後,把數組中第i小的數放在第i個位置上
        for (int j = list.length - 1; j > i; j--) {
            // 比較相鄰的元素,若是前面的數大於後面的數,則交換
            if (list[j - 1] > list[j]) {
                temp = list[j - 1];
                list[j - 1] = list[j];
                list[j] = temp;
            }
        }

        System.out.format("第 %d 趟:\t", i);
        printAll(list);
    }
}
複製代碼

算法分析

冒泡排序算法的性能

參數 結果
排序類別 交換排序
排序方法 冒泡排序
時間複雜度平均狀況 O(N2)
時間複雜度最壞狀況 O(N3)
時間複雜度最好狀況 O(N)
空間複雜度 O(1)
穩定性 穩定
複雜性 簡單

時間複雜度

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

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

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

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

冒泡排序的最壞時間複雜度爲 O(N2)。

所以,冒泡排序的平均時間複雜度爲 O(N2)。

總結起來,其實就是一句話:當數據越接近正序時,冒泡排序性能越好。

算法穩定性

冒泡排序就是把小的元素往前調或者把大的元素日後調。比較是相鄰的兩個元素比較,交換也發生在這兩個元素之間。

因此相同元素的先後順序並無改變,因此冒泡排序是一種穩定排序算法。

優化

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

若是進行某一趟排序時並無進行數據交換,則說明全部數據已經有序,可當即結束排序,避免沒必要要的比較過程。

核心代碼

// 對 bubbleSort 的優化算法
public void bubbleSort_2(int[] list) {
    int temp = 0; // 用來交換的臨時數
    boolean bChange = false; // 交換標誌

    // 要遍歷的次數
    for (int i = 0; i < list.length - 1; i++) {
        bChange = false;
        // 從後向前依次的比較相鄰兩個數的大小,遍歷一次後,把數組中第i小的數放在第i個位置上
        for (int j = list.length - 1; j > i; j--) {
            // 比較相鄰的元素,若是前面的數大於後面的數,則交換
            if (list[j - 1] > list[j]) {
                temp = list[j - 1];
                list[j - 1] = list[j];
                list[j] = temp;
                bChange = true;
            }
        }

        // 若是標誌爲false,說明本輪遍歷沒有交換,已是有序數列,能夠結束排序
        if (false == bChange)
            break;

        System.out.format("第 %d 趟:\t", i);
        printAll(list);
    }
}
複製代碼

示例代碼

個人 Github 測試例

樣本包含:數組個數爲奇數、偶數的狀況;元素重複或不重複的狀況。且樣本均爲隨機樣本,實測有效。

快速排序

要點

快速排序是一種交換排序。

快速排序由 C. A. R. Hoare 在 1962 年提出。

算法思想

它的基本思想是:

經過一趟排序將要排序的數據分割成獨立的兩部分:分割點左邊都是比它小的數,右邊都是比它大的數。

而後再按此方法對這兩部分數據分別進行快速排序,整個排序過程能夠遞歸進行,以此達到整個數據變成有序序列。

詳細的圖解每每比大堆的文字更有說明力,因此直接上圖:

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

  1. 初始狀態爲一組無序的數組:二、四、五、一、3。
  2. 通過以上操做步驟後,完成了第一次的排序,獲得新的數組:一、二、五、四、3。
  3. 新的數組中,以 2 爲分割點,左邊都是比 2 小的數,右邊都是比 2 大的數。
  4. 由於 2 已經在數組中找到了合適的位置,因此不用再動。
  5. 2 左邊的數組只有一個元素 1,因此顯然不用再排序,位置也被肯定。(注:這種狀況時,left 指針和 right 指針顯然是重合的。所以在代碼中,咱們能夠經過設置斷定條件 left 必須小於 right,若是不知足,則不用排序了)。
  6. 而對於 2 右邊的數組 五、四、3,設置 left 指向 5,right 指向 3,開始繼續重複圖中的1、2、3、四步驟,對新的數組進行排序。

核心代碼

public int division(int[] list, int left, int right) {
    // 以最左邊的數(left)爲基準
    int base = list[left];
    while (left < right) {
        // 從序列右端開始,向左遍歷,直到找到小於base的數
        while (left < right && list[right] >= base)
            right--;
        // 找到了比base小的元素,將這個元素放到最左邊的位置
        list[left] = list[right];

        // 從序列左端開始,向右遍歷,直到找到大於base的數
        while (left < right && list[left] <= base)
            left++;
        // 找到了比base大的元素,將這個元素放到最右邊的位置
        list[right] = list[left];
    }

    // 最後將base放到left位置。此時,left位置的左側數值應該都比left小;
    // 而left位置的右側數值應該都比left大。
    list[left] = base;
    return left;
}

private void quickSort(int[] list, int left, int right) {

    // 左下標必定小於右下標,不然就越界了
    if (left < right) {
        // 對數組進行分割,取出下次分割的基準標號
        int base = division(list, left, right);

        System.out.format("base = %d:\t", list[base]);
        printPart(list, left, right);

        // 對「基準標號「左側的一組數值進行遞歸的切割,以致於將這些數值完整的排序
        quickSort(list, left, base - 1);

        // 對「基準標號「右側的一組數值進行遞歸的切割,以致於將這些數值完整的排序
        quickSort(list, base + 1, right);
    }
}
複製代碼

算法分析

快速排序算法的性能

參數 結果
排序類別 交換排序
排序方法 快速排序
時間複雜度平均狀況 O(Nlog2N)
時間複雜度最壞狀況 O(N2)
時間複雜度最好狀況 O(Nlog2N)
空間複雜度 O(Nlog2N)
穩定性 不穩定
複雜性 較複雜

時間複雜度

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

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

因此,數據越隨機分佈時,快速排序性能越好;數據越接近有序,快速排序性能越差。

空間複雜度

快速排序在每次分割的過程當中,須要 1 個空間存儲基準值。而快速排序的大概須要 Nlog2N 次的分割處理,因此佔用空間也是 Nlog2N 個。

算法穩定性

在快速排序中,相等元素可能會由於分區而交換順序,因此它是不穩定的算法。

示例代碼

個人 Github 測試例

樣本包含:數組個數爲奇數、偶數的狀況;元素重複或不重複的狀況。且樣本均爲隨機樣本,實測有效。

插入排序

要點

直接插入排序是一種最簡單的插入排序

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

算法思想

在講解直接插入排序以前,先讓咱們腦補一下咱們打牌的過程。

  • 先拿一張 5 在手裏,
  • 再摸到一張 4,比 5 小,插到 5 前面,
  • 摸到一張 6,嗯,比 5 大,插到 5 後面,
  • 摸到一張 8,比 6 大,插到 6 後面,
  • 。。。
  • 最後一看,我靠,湊到的竟然是同花順,這下牛逼大了。

以上的過程,其實就是典型的直接插入排序,每次將一個新數據插入到有序隊列中的合適位置裏

很簡單吧,接下來,咱們要將這個算法轉化爲編程語言。

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

  • 咱們先將這個序列中下標爲 0 的元素視爲元素個數爲 1 的有序序列。
  • 而後,咱們要依次把 R1, R2, ... , RN-1 插入到這個有序序列中。因此,咱們須要一個外部循環,從下標 1 掃描到 N-1 。
  • 接下來描述插入過程。假設這是要將 Ri 插入到前面有序的序列中。由前面所述,咱們可知,插入 Ri 時,前 i-1 個數確定已是有序了。

因此咱們須要將 Ri 和 R0 ~ Ri-1 進行比較,肯定要插入的合適位置。這就須要一個內部循環,咱們通常是從後往前比較,即從下標 i-1 開始向 0 進行掃描。

核心代碼

public void insertSort(int[] list) {
   // 打印第一個元素
   System.out.format("i = %d:\t", 0);
   printPart(list, 0, 0);

   // 第1個數確定是有序的,從第2個數開始遍歷,依次插入有序序列
   for (int i = 1; i < list.length; i++) {
       int j = 0;
       int temp = list[i]; // 取出第i個數,和前i-1個數比較後,插入合適位置

       // 由於前i-1個數都是從小到大的有序序列,因此只要當前比較的數(list[j])比temp大,就把這個數後移一位
       for (j = i - 1; j >= 0 && temp < list[j]; j--) {
           list[j + 1] = list[j];
       }
       list[j + 1] = temp;

       System.out.format("i = %d:\t", i);
       printPart(list, 0, i);
   }
}
複製代碼

算法分析

直接插入排序的算法性能

參數 結果
排序類別 插入排序
排序方法 直接插入排序
時間複雜度平均狀況 O(N2)
時間複雜度最壞狀況 O(N2)
時間複雜度最好狀況 O(N)
空間複雜度 O(1)
穩定性 穩定
複雜性 簡單

時間複雜度

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

當數據反序時,執行效率最差,每次插入都要前面的元素後移,時間複雜度爲 O(N2)

因此,數據越接近正序,直接插入排序的算法性能越好

空間複雜度

由直接插入排序算法可知,咱們在排序過程當中,須要一個臨時變量存儲要插入的值,因此空間複雜度爲 1

算法穩定性

直接插入排序的過程當中,不須要改變相等數值元素的位置,因此它是穩定的算法。

示例代碼

個人 Github 測試例

樣本包含:數組個數爲奇數、偶數的狀況;元素重複或不重複的狀況。且樣本均爲隨機樣本,實測有效。

希爾排序

要點

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

該方法因 DL.Shell 於 1959 年提出而得名。

算法思想

希爾排序的基本思想是:

把記錄按步長 gap 分組,對每組記錄採用直接插入排序方法進行排序。 隨着步長逐漸減少,所分紅的組包含的記錄愈來愈多,當步長的值減少到 1 時,整個數據合成爲一組,構成一組有序記錄,則完成排序。

咱們來經過演示圖,更深刻的理解一下這個過程。

在上面這幅圖中:

初始時,有一個大小爲 10 的無序序列。

  • 第一趟排序中,咱們不妨設 gap1 = N / 2 = 5,即相隔距離爲 5 的元素組成一組,能夠分爲 5 組。
    • 接下來,按照直接插入排序的方法對每一個組進行排序。
  • 在** 第二趟排序中**,咱們把上次的 gap 縮小一半,即 gap2 = gap1 / 2 = 2 (取整數)。這樣每相隔距離爲 2 的元素組成一組,能夠分爲 2 組。
    • 按照直接插入排序的方法對每一個組進行排序。
  • 第三趟排序中,再次把 gap 縮小一半,即 gap3 = gap2 / 2 = 1。 這樣相隔距離爲 1 的元素組成一組,即只有一組。
    • 按照直接插入排序的方法對每一個組進行排序。此時,排序已經結束

須要注意一下的是,圖中有兩個相等數值的元素 55 。咱們能夠清楚的看到,在排序過程當中,兩個元素位置交換了

因此,希爾排序是不穩定的算法。

核心代碼

public void shellSort(int[] list) {
   int gap = list.length / 2;

   while (1 <= gap) {
       // 把距離爲 gap 的元素編爲一個組,掃描全部組
       for (int i = gap; i < list.length; i++) {
           int j = 0;
           int temp = list[i];

           // 對距離爲 gap 的元素組進行排序
           for (j = i - gap; j >= 0 && temp < list[j]; j = j - gap) {
               list[j + gap] = list[j];
           }
           list[j + gap] = temp;
       }

       System.out.format("gap = %d:\t", gap);
       printAll(list);
       gap = gap / 2; // 減少增量
   }
}
複製代碼

算法分析

希爾排序的算法性能

參數 結果
排序類別 插入排序
排序方法 希爾排序
時間複雜度平均狀況 O(Nlog2N)
時間複雜度最壞狀況 O(N1.5)
時間複雜度最好狀況
空間複雜度 O(1)
穩定性 不穩定
複雜性 較複雜

時間複雜度

步長的選擇是希爾排序的重要部分。只要最終步長爲 1 任何步長序列均可以工做。

算法最開始以必定的步長進行排序。而後會繼續以必定步長進行排序,最終算法以步長爲 1 進行排序。當步長爲 1 時,算法變爲插入排序,這就保證了數據必定會被排序。

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

已知的最好步長序列是由 Sedgewick 提出的(1, 5, 19, 41, 109,...),該序列的項來自這兩個算式。

這項研究也代表「比較在希爾排序中是最主要的操做,而不是交換。」用這樣步長序列的希爾排序比插入排序和堆排序都要快,甚至在小數組中比快速排序還快,可是在涉及大量數據時希爾排序仍是比快速排序慢。

算法穩定性

由上文的希爾排序算法演示圖便可知,希爾排序中相等數據可能會交換位置,因此希爾排序是不穩定的算法。

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

  • 直接插入排序是穩定的;而希爾排序是不穩定的。
  • 直接插入排序更適合於原始記錄基本有序的集合。
  • 希爾排序的比較次數和移動次數都要比直接插入排序少,當 N 越大時,效果越明顯。
  • 在希爾排序中,增量序列 gap 的取法必須知足:**最後一個步長必須是 1 。 **
  • 直接插入排序也適用於鏈式存儲結構;希爾排序不適用於鏈式結構

示例代碼

個人 Github 測試例

樣本包含:數組個數爲奇數、偶數的狀況;元素重複或不重複的狀況。且樣本均爲隨機樣本,實測有效。

簡單選擇排序

要點

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

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

算法思想

  1. 從待排序序列中,找到關鍵字最小的元素;
  2. 若是最小元素不是待排序序列的第一個元素,將其和第一個元素互換;
  3. 從餘下的 N - 1 個元素中,找出關鍵字最小的元素,重複 一、2 步,直到排序結束。

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

核心代碼

算法分析

簡單選擇排序算法的性能

參數 結果
排序類別 選擇排序
排序方法 簡單選擇排序
時間複雜度平均狀況 O(N2)
時間複雜度最壞狀況 O(N2)
時間複雜度最好狀況 O(N2)
空間複雜度 O(1)
穩定性 不穩定
複雜性 簡單

時間複雜度

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

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

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

因此,綜合以上,簡單排序的時間複雜度爲 O(N2)

空間複雜度

簡單選擇排序須要佔用一個臨時空間,在交換數值時使用。

示例代碼

個人 Github 測試例

樣本包含:數組個數爲奇數、偶數的狀況;元素重複或不重複的狀況。且樣本均爲隨機樣本,實測有效。

堆排序

要點

在介紹堆排序以前,首先須要說明一下,堆是個什麼玩意兒。

是一棵順序存儲徹底二叉樹

其中每一個結點的關鍵字都不大於其孩子結點的關鍵字,這樣的堆稱爲小根堆。 其中每一個結點的關鍵字都不小於其孩子結點的關鍵字,這樣的堆稱爲大根堆。 舉例來講,對於 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] 表示,那麼,

  • 它的左孩子結點是:R[2*i+1];
  • 它的右孩子結點是:R[2*i+2];
  • 它的父結點是:R[(i-1)/2];
  • 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 } 來加以說明。

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

核心代碼

public void HeapAdjust(int[] array, int parent, int length) {
    int temp = array[parent]; // temp保存當前父節點
    int child = 2 * parent + 1; // 先得到左孩子

    while (child < length) {
        // 若是有右孩子結點,而且右孩子結點的值大於左孩子結點,則選取右孩子結點
        if (child + 1 < length && array[child] < array[child + 1]) {
            child++;
        }

        // 若是父結點的值已經大於孩子結點的值,則直接結束
        if (temp >= array[child])
            break;

        // 把孩子結點的值賦給父結點
        array[parent] = array[child];

        // 選取孩子結點的左孩子結點,繼續向下篩選
        parent = child;
        child = 2 * child + 1;
    }

    array[parent] = temp;
}

public void heapSort(int[] list) {
    // 循環創建初始堆
    for (int i = list.length / 2; i >= 0; i--) {
        HeapAdjust(list, i, list.length);
    }

    // 進行n-1次循環,完成排序
    for (int i = list.length - 1; i > 0; i--) {
        // 最後一個元素和第一元素進行交換
        int temp = list[i];
        list[i] = list[0];
        list[0] = temp;

        // 篩選 R[0] 結點,獲得i-1個結點的堆
        HeapAdjust(list, 0, i);
        System.out.format("第 %d 趟: \t", list.length - i);
        printPart(list, 0, list.length - 1);
    }
}
複製代碼

算法分析

堆排序算法的整體狀況

參數 結果
排序類別 選擇排序
排序方法 堆排序
時間複雜度平均狀況 O(nlog2n)
時間複雜度最壞狀況 O(nlog2n)
時間複雜度最好狀況 O(nlog2n)
空間複雜度 O(1)
穩定性 不穩定
複雜性 較複雜

時間複雜度

堆的存儲表示是順序的。由於堆所對應的二叉樹爲徹底二叉樹,而徹底二叉樹一般採用順序存儲方式。

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

由於堆排序的時間複雜度是 O(n+klog2n),若 k ≤ n/log2n,則可獲得的時間複雜度爲 O(n)

算法穩定性

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

由於在堆的調整過程當中,關鍵字進行比較和交換所走的是該結點到葉子結點的一條路徑,

所以對於相同的關鍵字就可能出現排在後面的關鍵字被交換到前面來的狀況。

示例代碼

個人 Github 測試例

樣本包含:數組個數爲奇數、偶數的狀況;元素重複或不重複的狀況。且樣本均爲隨機樣本,實測有效。

歸併排序

要點

歸併排序是創建在歸併操做上的一種有效的排序算法,該算法是採用**分治法(Divide and Conquer)**的一個很是典型的應用。

將已有序的子序列合併,獲得徹底有序的序列;即先使每一個子序列有序,再使子序列段間有序。若將兩個有序表合併成一個有序表,稱爲二路歸併

算法思想

將待排序序列 R[0...n-1] 當作是 n 個長度爲 1 的有序序列,將相鄰的有序表成對歸併,獲得 n/2 個長度爲 2 的有序表;將這些有序序列再次歸併,獲得 n/4 個長度爲 4 的有序序列;如此反覆進行下去,最後獲得一個長度爲 n 的有序序列。

綜上可知:

歸併排序其實要作兩件事:

  • 「分解」——將序列每次折半劃分
  • 「合併」——將劃分後的序列段兩兩合併後排序

咱們先來考慮第二步,如何合併

在每次合併過程當中,都是對兩個有序的序列段進行合併,而後排序。

這兩個有序序列段分別爲 R[low, mid] 和 R[mid+1, high]。

先將他們合併到一個局部的暫存數組R2 中,帶合併完成後再將 R2 複製回 R 中。

爲了方便描述,咱們稱 R[low, mid] 第一段,R[mid+1, high] 爲第二段。

每次從兩個段中取出一個記錄進行關鍵字的比較,將較小者放入 R2 中。最後將各段中餘下的部分直接複製到 R2 中。

通過這樣的過程,R2 已是一個有序的序列,再將其複製回 R 中,一次合併排序就完成了。

核心代碼

public void Merge(int[] array, int low, int mid, int high) {
    int i = low; // i是第一段序列的下標
    int j = mid + 1; // j是第二段序列的下標
    int k = 0; // k是臨時存放合併序列的下標
    int[] array2 = new int[high - low + 1]; // array2是臨時合併序列

    // 掃描第一段和第二段序列,直到有一個掃描結束
    while (i <= mid && j <= high) {
        // 判斷第一段和第二段取出的數哪一個更小,將其存入合併序列,並繼續向下掃描
        if (array[i] <= array[j]) {
            array2[k] = array[i];
            i++;
            k++;
        } else {
            array2[k] = array[j];
            j++;
            k++;
        }
    }

    // 若第一段序列還沒掃描完,將其所有複製到合併序列
    while (i <= mid) {
        array2[k] = array[i];
        i++;
        k++;
    }

    // 若第二段序列還沒掃描完,將其所有複製到合併序列
    while (j <= high) {
        array2[k] = array[j];
        j++;
        k++;
    }

    // 將合併序列複製到原始序列中
    for (k = 0, i = low; i <= high; i++, k++) {
        array[i] = array2[k];
    }
}
複製代碼

掌握了合併的方法,接下來,讓咱們來了解如何分解

在某趟歸併中,設各子表的長度爲 gap,則歸併前 R[0...n-1] 中共有 n/gap 個有序的子表:R[0...gap-1], R[gap...2*gap-1], ... , R[(n/gap)*gap ... n-1]

調用 Merge 將相鄰的子表歸併時,必須對錶的特殊狀況進行特殊處理。

若子表個數爲奇數,則最後一個子表無須和其餘子表歸併(即本趟處理輪空):若子表個數爲偶數,則要注意到最後一對子表中後一個子表區間的上限爲 n-1。

核心代碼

public void MergePass(int[] array, int gap, int length) {
    int i = 0;

    // 歸併gap長度的兩個相鄰子表
    for (i = 0; i + 2 * gap - 1 < length; i = i + 2 * gap) {
        Merge(array, i, i + gap - 1, i + 2 * gap - 1);
    }

    // 餘下兩個子表,後者長度小於gap
    if (i + gap - 1 < length) {
        Merge(array, i, i + gap - 1, length - 1);
    }
}

public int[] sort(int[] list) {
    for (int gap = 1; gap < list.length; gap = 2 * gap) {
        MergePass(list, gap, list.length);
        System.out.print("gap = " + gap + ":\t");
        this.printAll(list);
    }
    return list;
}
複製代碼

算法分析

歸併排序算法的性能

參數 結果
排序類別 歸併排序
排序方法 歸併排序
時間複雜度平均狀況 O(nlog2n)
時間複雜度最壞狀況 O(nlog2n)
時間複雜度最好狀況 O(nlog2n)
空間複雜度 O(n)
穩定性 穩定
複雜性 較複雜

時間複雜度

歸併排序的形式就是一棵二叉樹,它須要遍歷的次數就是二叉樹的深度,而根據徹底二叉樹的能夠得出它的時間複雜度是 O(n*log2n)

空間複雜度

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

算法穩定性

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

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

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

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

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

示例代碼

個人 Github 測試例

樣本包含:數組個數爲奇數、偶數的狀況;元素重複或不重複的狀況。且樣本均爲隨機樣本,實測有效。

基數排序

要點

基數排序與本系列前面講解的七種排序方法都不一樣,它不須要比較關鍵字的大小

它是根據關鍵字中各位的值,經過對排序的 N 個元素進行若干趟「分配」與「收集」來實現排序的。

不妨經過一個具體的實例來展現一下,基數排序是如何進行的。

設有一個初始序列爲: 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}。

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

算法分析

基數排序的性能

參數 結果
排序類別 基數排序
排序方法 基數排序
時間複雜度平均狀況 O(d(n+r))
時間複雜度最壞狀況 O(d(n+r))
時間複雜度最好狀況 O(d(n+r))
空間複雜度 O(n+r)
穩定性 穩定
複雜性 較複雜

時間複雜度

經過上文可知,假設在基數排序中,r 爲基數,d 爲位數。則基數排序的時間複雜度爲 O(d(n+r))

咱們能夠看出,基數排序的效率和初始序列是否有序沒有關聯。

空間複雜度

在基數排序過程當中,對於任何位數上的基數進行「裝桶」操做時,都須要 n+r 個臨時空間。

算法穩定性

在基數排序過程當中,每次都是將當前位數上相同數值的元素統一「裝桶」,並不須要交換位置。因此基數排序是穩定的算法。

示例代碼

個人 Github 測試例

樣本包含:數組個數爲奇數、偶數的狀況;元素重複或不重複的狀況。且樣本均爲隨機樣本,實測有效。

相關文章
相關標籤/搜索