1. 程式人生 > 實用技巧 >排序(三)線性排序

排序(三)線性排序

三種時間複雜度為O(n)的排序演算法:桶排序、計數排序、基數排序

這三種排序演算法都不涉及元素之間的比較操作,也叫做線性排序(Linear sort)

桶排序

核心思想

將要排序的資料分散到有序的桶中,分別對桶中的資料進行排序。排序好了之後,按照桶的順序依次取出,就得到排好序的資料了。

時間複雜度

時間複雜度為O(n)。

假設要排序的資料有n個,均勻的劃分到m個桶內,每個桶裡就有k=n/m個元素,對每個桶使用快速排序,時間複雜度為O(k * logk),m個桶的時間複雜度為O(m * k * logk),因為k= n/m,所以整個桶的時間複雜度為O(n*log(n/m)),當桶的個數m接近資料個數n時,桶排序的時間複雜度就接近O(n)

桶排序的要求

桶排序的時間複雜度雖然是線性的,不能替代其他排序演算法,因為他對資料的要求比較高

  • 排序資料需要很容易劃分成m個桶,桶於桶之間有天然的大小關係。對桶內資料排序完之後,不需要再對桶進行排序
  • 資料在每個桶之間分佈比較均勻。如果資料都劃分到一個桶中,就退化為O(nlogn)的演算法了

特點

  • 桶排序比較適合外部排序。外部排序就是資料儲存在外部磁碟中,資料量比較大,記憶體有限,無法將資料全部載入到記憶體中。

思考

比如說我們有 10GB 的訂單資料,我們希望按訂單金額(假設金額都是正整數)進行排序,但是我們的記憶體有限,只有幾百 MB,沒辦法一次性把 10GB 的資料都載入到記憶體中。這個時候該怎麼辦呢?

現在我來講一下,如何藉助桶排序的處理思想來解決這個問題。我們可以先掃描一遍檔案,看訂單金額所處的資料範圍。假設經過掃描之後我們得到,訂單金額最小是 1 元,最大是 10 萬元。我們將所有訂單根據金額劃分到 100 個桶裡,第一個桶我們儲存金額在 1 元到 1000 元之內的訂單,第二桶儲存金額在 1001 元到 2000 元之內的訂單,以此類推。每一個桶對應一個檔案,並且按照金額範圍的大小順序編號命名(00,01,02…99)。

理想的情況下,如果訂單金額在 1 到 10 萬之間均勻分佈,那訂單會被均勻劃分到 100 個檔案中,每個小檔案中儲存大約 100MB 的訂單資料,我們就可以將這 100 個小檔案依次放到記憶體中,用快排來排序。等所有檔案都排好序之後,我們只需要按照檔案編號,從小到大依次讀取每個小檔案中的訂單資料,並將其寫入到一個檔案中,那這個檔案中儲存的就是按照金額從小到大排序的訂單資料了。

不過,你可能也發現了,訂單按照金額在 1 元到 10 萬元之間並不一定是均勻分佈的 ,所以 10GB 訂單資料是無法均勻地被劃分到 100 個檔案中的。有可能某個金額區間的資料特別多,劃分之後對應的檔案就會很大,沒法一次性讀入記憶體。這又該怎麼辦呢?針對這些劃分之後還是比較大的檔案,我們可以繼續劃分,比如,訂單金額在 1 元到 1000 元之間的比較多,我們就將這個區間繼續劃分為 10 個小區間,1 元到 100 元,101 元到 200 元,201 元到 300 元…901 元到 1000 元。如果劃分之後,101 元到 200 元之間的訂單還是太多,無法一次性讀入記憶體,那就繼續再劃分,直到所有的檔案都能讀入記憶體為止。

計數排序(Counting sort)

核心思想

計數排序是桶排序的一種特殊情況。當要排序的資料所處範圍並不大最大值為k時,可以將資料劃分到K個桶內。每個桶內的資料值都是相同的,省去了桶內排序時間

  • 求出排序資料的最大範圍,根據範圍創建出相應個數的桶,並初始化為0
  • 對資料進行遍歷,將資料對應的桶內個數加1
  • 根據桶的順序,依次累加
  • 建立一個和排序一樣長度的空陣列,再次對資料進行遍歷,根據桶中對應的元素個數計算出存入的座標,存入之後將桶中個數減1
  • 將排好序的陣列賦值給原陣列
// 計數排序,a是陣列,n是陣列大小。假設陣列中儲存的都是非負整數。
public void countingSort(int[] a, int n) {
  if (n <= 1) return;

  // 查詢陣列中資料的範圍
  int max = a[0];
  for (int i = 1; i < n; ++i) {
    if (max < a[i]) {
      max = a[i];
    }
  }

  int[] c = new int[max + 1]; // 申請一個計數陣列c,下標大小[0,max]
  for (int i = 0; i <= max; ++i) {
    c[i] = 0;
  }

  // 計算每個元素的個數,放入c中
  for (int i = 0; i < n; ++i) {
    c[a[i]]++;
  }

  // 依次累加
  for (int i = 1; i <= max; ++i) {
    c[i] = c[i-1] + c[i];
  }

  // 臨時陣列r,儲存排序之後的結果
  int[] r = new int[n];
  // 計算排序的關鍵步驟,有點難理解
  for (int i = n - 1; i >= 0; --i) {
    int index = c[a[i]]-1;
    r[index] = a[i];
    c[a[i]]--;
  }

  // 將結果拷貝給a陣列
  for (int i = 0; i < n; ++i) {
    a[i] = r[i];
  }
}

時間複雜度

時間複雜度為O(n)。因為計數排序只涉及到遍歷排序資料

要求

  • 只能在資料範圍不大的場景應用。如果資料範圍要比資料數量大得多,就不適合計數排序
  • 只能給非負整數排序。如果是其他型別,要在不改變資料相對大小情況下,轉換為非負整數。如果考生成績精確到小數後一位,我們就需要將所有的分數都先乘以 10,轉化成整數,然後再放到 9010 個桶內。再比如,如果要排序的資料中有負數,資料的範圍是[-1000, 1000],那我們就需要先對每個資料都加 1000,轉化成非負整數。

思考

如果你所在的省有 50 萬考生,如何通過成績快速排序得出名次呢?

思路:使用計數排序思路。考生的滿分是 900 分,最小是 0 分,這個資料的範圍很小,所以我們可以分成 901 個桶,對應分數從 0 分到 900 分。根據考生的成績,我們將這 50 萬考生劃分到這 901 個桶裡。桶內的資料都是分數相同的考生,所以並不需要再進行排序。我們只需要依次掃描每個桶,將桶內的考生依次輸出到一個數組中,就實現了 50 萬考生的排序。因為只涉及掃描遍歷操作,所以時間複雜度是 O(n)。

如果考生成績精確到小數後一位,我們就需要將所有的分數都先乘以 10,轉化成整數,然後再放到 9010 個桶內。

基數排序

基數排序就是將資料劃分為一位一位的,然後對每一位進行比較排序

基數排序對要排序的資料是有要求的,需要可以分割出獨立的“位”來比較,而且位之間有遞進的關係,如果 a 資料的高位比 b 資料大,那剩下的低位就不用比較了。除此之外,每一位的資料範圍不能太大,要可以用線性排序演算法來排序,否則,基數排序的時間複雜度就無法做到 O(n) 了

思考

1.假設我們有 10 萬個手機號碼,希望將這 10 萬個手機號碼從小到大排序?

分析:使用快速排序時間複雜度為O(nlogn),因為手機號有11位範圍太大,顯然不適合使用,因此可以使用基數排序

思路:比較兩個手機號大小,如果一個手機號前幾位比另一個大,那之後就不需要比較了。因此可以藉助排序演算法的穩定性,先按照最後一位排序手機號,在按照倒數第二位重新排序,以此類推,經過11次排序之後手機號碼就有序了。

根據每一位來排序,必須使用穩定排序演算法,我們可以用剛講過的桶排序或者計數排序,它們的時間複雜度可以做到 O(n)。如果要排序的資料有 k 位,那我們就需要 k 次桶排序或者計數排序,總的時間複雜度是 O(k*n)。當 k 不大的時候,比如手機號碼排序的例子,k 最大就是 11,所以基數排序的時間複雜度就近似於 O(n)。

2.如何根據年齡給 100 萬用戶排序?

假設年齡的範圍最小 1 歲,最大不超過 120 歲。我們可以遍歷這 100 萬用戶,根據年齡將其劃分到這 120 個桶裡,然後依次順序遍歷這 120 個桶中的元素。這樣就得到了按照年齡排序的 100 萬用戶資料。