1. 程式人生 > 其它 >備戰秋招之十大排序——O(n)級排序演算法

備戰秋招之十大排序——O(n)級排序演算法

時間複雜度O(n)級排序演算法

九、計數排序

前文說到,19591959 年 77 月,希爾排序通過交換非相鄰元素,打破了 O(n^2)的魔咒,使得排序演算法的時間複雜度降到了 O(nlog n) 級,此後的快速排序、堆排序都是基於這樣的思想,所以他們的時間複雜度都是 O(nlog n)。

那麼,排序演算法最好的時間複雜度就是 O(nlogn) 嗎?是否有比 O(nlogn) 級還要快的排序演算法呢?能否在 O(n) 的時間複雜度下完成排序呢?

事實上,O(n) 級的排序演算法存在已久,但他們只能用於特定的場景。

計數排序就是一種時間複雜度為 O(n) 的排序演算法,該演算法於 1954 年由 Harold H. Seward 提出。在對一定範圍內的整數排序時,它的複雜度為 Ο(n+k)(其中 k 是整數的範圍大小)。

9.1、偽計數排序

舉個例子,我們需要對一列陣列排序,這個陣列中每個元素都是 [1, 9] 區間內的整數。那麼我們可以構建一個長度為 9 的陣列用於計數,計數陣列的下標分別對應區間內的 9 個整數。然後遍歷待排序的陣列,將區間內每個整數出現的次數統計到計數陣列中對應下標的位置。最後遍歷計數陣列,將每個元素輸出,輸出的次數就是對應位置記錄的次數。

演算法實現如下(以 [1,9] 為例 ):

public static void countingSort9(int[] arr) {
    // 建立長度為 9 的陣列,下標 0~8 對應數字 1~9
    int[] counting = new int[9];
    // 遍歷 arr 中的每個元素
    for (int element : arr) {
        // 將每個整數出現的次數統計到計數陣列中對應下標的位置
        counting[element - 1]++;
    }
    int index = 0;
    // 遍歷計數陣列,將每個元素輸出
    for (int i = 0; i < 9; i++) {
        // 輸出的次數就是對應位置記錄的次數
        while (counting[i] != 0) {
            arr[index++] = i + 1;
            counting[i]--;
        }
    }
}

演算法非常簡單,但這裡的排序演算法 並不是 真正的計數排序。因為現在的實現有一個非常大的弊端:排序完成後,arr 中記錄的元素已經不再是最開始的那個元素了,他們只是值相等,但卻不是同一個物件。

在純數字排序中,這個弊端或許看起來無傷大雅,但在實際工作中,這樣的排序演算法幾乎無法使用。因為被排序的物件往往都會攜帶其他的屬性,但這份演算法將被排序物件的其他屬性都丟失了。

就好比業務部門要求我們將 1 號商品,2 號商品,3 號商品,4 號商品按照價格排序,它們的價格分別為 8 元、6 元,6 元,9 元。 我們告訴業務部門:排序完成後價格為 6 元、 6 元、8 元,9 元,但不知道這些價格對應哪個商品。這顯然是不可接受的。

9.2、偽計數排序 2.0

對於這個問題,我們很容易想到一種解決方案:在統計元素出現的次數時,同時把真實的元素儲存到列表中,輸出時,從列表中取真實的元素。演算法實現如下:

public static void countingSort9(int[] arr) {
    // 建立長度為 9 的陣列,下標 0~8 對應數字 1~9
    int[] counting = new int[9];
    // 記錄每個下標中包含的真實元素,使用佇列可以保證排序的穩定性
    HashMap<Integer, Queue<Integer>> records = new HashMap<>();
    // 遍歷 arr 中的每個元素
    for (int element : arr) {
        // 將每個整數出現的次數統計到計數陣列中對應下標的位置
        counting[element - 1]++;
        if (!records.containsKey(element - 1)) {
            records.put(element - 1, new LinkedList<>());
        }
        records.get(element - 1).add(element);
    }
    int index = 0;
    // 遍歷計數陣列,將每個元素輸出
    for (int i = 0; i < 9; i++) {
        // 輸出的次數就是對應位置記錄的次數
        while (counting[i] != 0) {
            // 輸出記錄的真實元素
            arr[index++] = records.get(i).remove();
            counting[i]--;
        }
    }
}

在這份程式碼中,我們通過佇列來儲存真實的元素,計數完成後,將佇列中真實的元素賦到 arr 列表中,這就解決了資訊丟失的問題,並且使用佇列還可以保證排序演算法的穩定性。

但是,這也不是 真正的計數排序,計數排序中使用了一種更巧妙的方法解決這個問題。

9.3、真正的計數排序

舉個例子,班上有 10 名同學:他們的考試成績分別是:7, 8, 9, 7, 6, 7, 6, 8, 6, 6他們需要按照成績從低到高坐到 0~9 共 10 個位置上。

用計數排序完成這一過程需要以下幾步:

  • 第一步仍然是計數,統計出:4 名同學考了 6 分,3 名同學考了 7 分,2 名同學考了 8 分,1 名同學考了 9 分;
  • 然後從頭遍歷陣列:第一名同學考了 7 分,共有 4 個人比他分數低,所以第一名同學坐在 4 號位置(也就是第 5 個位置);
  • 第二名同學考了 8 分,共有 7 個人(4 + 3)比他分數低,所以第二名同學坐在 7 號位置;
  • 第三名同學考了 9 分,共有 9 個人(4 + 3 + 2)比他分數低,所以第三名同學坐在 9 號位置;
  • 第四名同學考了 7 分,共有 4 個人比他分數低,並且之前已經有一名考了 7 分的同學坐在了 4 號位置,所以第四名同學坐在 5 號位置。
  • ...依次完成整個排序

區別就在於計數排序並不是把計數陣列的下標直接作為結果輸出,而是通過計數的結果,計算出每個元素在排序完成後的位置,然後將元素賦值到對應位置。

程式碼如下:

public static void countingSort9(int[] arr) {
    // 建立長度為 9 的陣列,下標 0~8 對應數字 1~9
    int[] counting = new int[9];
    // 遍歷 arr 中的每個元素
    for (int element : arr) {
        // 將每個整數出現的次數統計到計數陣列中對應下標的位置
        counting[element - 1]++;
    }
    // 記錄前面比自己小的數字的總數
    int preCounts = 0;
    for (int i = 0; i < counting.length; i++) {
        int temp = counting[i];
        // 將 counting 計算成當前數字在結果中的起始下標位置。位置 = 前面比自己小的數字的總數。
        counting[i] = preCounts;
        // 當前的數字比下一個數字小,累計到 preCounts 中
        preCounts += temp;
    }
    int[] result = new int[arr.length];
    for (int element : arr) {
        // counting[element - 1] 表示此元素在結果陣列中的下標
        int index = counting[element - 1];
        result[index] = element;
        // 更新 counting[element - 1],指向此元素的下一個下標
        counting[element - 1]++;
    }
    // 將結果賦值回 arr
    for (int i = 0; i < arr.length; i++) {
        arr[i] = result[i];
    }
}

首先我們將每位元素出現的次數記錄到 counting 陣列中。

然後將 counting[i] 更新為數字 i 在最終排序結果中的起始下標位置。這個位置等於前面比自己小的數字的總數。

例如本例中,考 7 分的同學前面有 4 個比自己分數低的同學,所以 7 對應的下標為 4。

這一步除了使用 temp 變數這種寫法以外,還可以通過多做一次減法省去 temp 變數:

// 記錄前面比自己小的數字的總數
int preCounts = 0;
for (int i = 0; i < counting.length; i++) {
    // 當前的數字比下一個數字小,累計到 preCounts 中
    preCounts += counting[i];
    // 將 counting 計算成當前數字在結果中的起始下標位置。位置 = 前面比自己小的數字的總數。
    counting[i] = preCounts - counting[i];
}

接下來從頭訪問 arr 陣列,根據 counting 中計算出的下標位置,將 arr 的每個元素直接放到最終位置上,然後更新 counting 中的下標位置。這一步中的 index 變數也是可以省略的。

最後將 result 陣列賦值回 arr,完成排序。

這就是計數排序的思想,我們還剩下最後一步,那就是根據 arr 中的數字範圍計算出計數陣列的長度。使得計數排序不僅僅適用於 [1, 9],程式碼如下:

public static void countingSort(int[] arr) {
    // 判空及防止陣列越界
    if (arr == null || arr.length <= 1) return;
    // 找到最大值,最小值
    int max = arr[0];
    int min = arr[0];
    for (int i = 1; i < arr.length; i++) {
        if (arr[i] > max) max = arr[i];
        else if (arr[i] < min) min = arr[i];
    }
    // 確定計數範圍
    int range = max - min + 1;
    // 建立長度為 range 的陣列,下標 0~range-1 對應數字 min~max
    int[] counting = new int[range];
    // 遍歷 arr 中的每個元素
    for (int element : arr) {
        // 將每個整數出現的次數統計到計數陣列中對應下標的位置,這裡需要將每個元素減去 min,才能對映到 0~range-1 範圍內
        counting[element - min]++;
    }
    // 記錄前面比自己小的數字的總數
    int preCounts = 0;
    for (int i = 0; i < range; i++) {
        // 當前的數字比下一個數字小,累計到 preCounts 中
        preCounts += counting[i];
        // 將 counting 計算成當前數字在結果中的起始下標位置。位置 = 前面比自己小的數字的總數。
        counting[i] = preCounts - counting[i];
    }
    int[] result = new int[arr.length];
    for (int element : arr) {
        // counting[element - min] 表示此元素在結果陣列中的下標
        result[counting[element - min]] = element;
        // 更新 counting[element - min],指向此元素的下一個下標
        counting[element - min]++;
    }
    // 將結果賦值回 arr
    for (int i = 0; i < arr.length; i++) {
        arr[i] = result[i];
    }
}

這就是完整的計數排序演算法。

9.4、倒序遍歷的計數排序

計數排序還有一種寫法,在計算元素在最終結果陣列中的下標位置這一步,不是計算初始下標位置,而是計算最後一個下標位置。最後倒序遍歷 arr 陣列,逐個將 arr 中的元素放到最終位置上。

程式碼如下:

public static void countingSort(int[] arr) {
    // 防止陣列越界
    if (arr == null || arr.length <= 1) return;
    // 找到最大值,最小值
    int max = arr[0];
    int min = arr[0];
    for (int i = 1; i < arr.length; i++) {
        if (arr[i] > max) max = arr[i];
        else if (arr[i] < min) min = arr[i];
    }
    // 確定計數範圍
    int range = max - min + 1;
    // 建立長度為 range 的陣列,下標 0~range-1 對應數字 min~max
    int[] counting = new int[range];
    // 遍歷 arr 中的每個元素
    for (int element : arr) {
        // 將每個整數出現的次數統計到計數陣列中對應下標的位置,這裡需要將每個元素減去 min,才能對映到 0~range-1 範圍內
        counting[element - min]++;
    }
    // 每個元素在結果陣列中的最後一個下標位置 = 前面比自己小的數字的總數 + 自己的數量 - 1。我們將 counting[0] 先減去 1,後續 counting 直接累加即可
    counting[0]--;
    for (int i = 1; i < range; i++) {
        // 將 counting 計算成當前數字在結果中的最後一個下標位置。位置 = 前面比自己小的數字的總數 + 自己的數量 - 1
        // 由於 counting[0] 已經減了 1,所以後續的減 1 可以省略。
        counting[i] += counting[i - 1];
    }
    int[] result = new int[arr.length];
    // 從後往前遍歷陣列,通過 counting 中記錄的下標位置,將 arr 中的元素放到 result 陣列中
    for (int i = arr.length - 1; i >= 0; i--) {
        // counting[arr[i] - min] 表示此元素在結果陣列中的下標
        result[counting[arr[i] - min]] = arr[i];
        // 更新 counting[arr[i] - min],指向此元素的前一個下標
        counting[arr[i] - min]--;
    }
    // 將結果賦值回 arr
    for (int i = 0; i < arr.length; i++) {
        arr[i] = result[i];
    }
}

兩種演算法的核心思想是一致的,並且都是穩定的。第一種寫法理解起來簡單一些,第二種寫法在效能上更好一些。

在計算下標位置時,不僅計算量更少,還省去了 preCounts 這個變數。在《演算法導論》一書中,便是採用的此種寫法。

實際上,這個演算法最後不通過倒序遍歷也能得到正確的排序結果,但這裡只有通過倒序遍歷的方式,才能保證計數排序的穩定性。

9.5、時間複雜度 & 空間複雜度

從計數排序的實現程式碼中,可以看到,每次遍歷都是進行 n 次或者 k 次,所以計數排序的時間複雜度為 O(n + k),k 表示資料的範圍大小。

用到的空間主要是長度為 k 的計數陣列和長度為 n 的結果陣列,所以空間複雜度也是 O(n + k)。

需要注意的是,一般我們分析時間複雜度和空間複雜度時,常數項都是忽略不計的。但計數排序的常數項可能非常大,以至於我們無法忽略。不知你是否注意到計數排序的一個非常大的隱患,比如我們想要對這個陣列排序:

int[] arr = new int[]{1, Integer.MAX_VALUE};

儘管它只包含兩個元素,但資料範圍是 [1, 2^{31}],我們知道 java 中 int 佔 44 個位元組,一個長度為 2^{31}次方的 int 陣列大約會佔 8G 的空間。如果使用計數排序,僅僅排序這兩個元素,宣告計數陣列就會佔用超大的記憶體,甚至導致 OutOfMemory 異常。

所以計數排序只適用於資料範圍不大的場景。例如對考試成績排序就非常適合計數排序,如果需要排序的數字中存在一位小數,可以將所有數字乘以 10,再去計算最終的下標位置。

9.6、計數排序與 O(nlogn) 級排序演算法的本質區別

前文說到,希爾排序通過交換間隔較遠的元素突破了排序演算法時間複雜度 O(n^2)的下界。同樣地,我們接下來就一起分析一下,計數排序憑什麼能夠突破 O(nlogn) 的下界呢?它和之前介紹的 O(nlog n) 級排序演算法的本質區別是什麼?

這個問題我們可以從決策樹的角度和概率的角度來理解。

9.7、決策樹

決策樹是一棵完全二叉樹,它可以反映比較排序演算法中對所有元素的比較操作。

以包含三個整數的陣列 [a, b, c] 為例,基於比較的排序演算法的排序過程可以抽象為這樣一棵 決策樹

這棵決策樹上的每一個葉結點都對應了一種可能的排列,從根結點到任意一個葉結點之間的最短路徑(也稱為「簡單路徑」)的長度,表示的是完成對應排列的比較次數。所以從根結點到葉結點之間的最長簡單路徑的長度,就表示比較排序演算法中最壞情況下的比較次數。

設決策樹的高度為 h,葉結點的數量為 l,排序元素總數為 n 。

因為葉結點最多有 n! 個,所以我們可以得到:n! ≤ l,又因為一棵高度為 h 的二叉樹,葉結點的數量最多為 2^h,所以我們可以得到:n! ≤ l ≤ 2^h

對該式兩邊取對數,可得:h≥log(n!)

由斯特林(Stirling)近似公式,可知 lg(n!)=O(nlogn)

所以 h≥log(n!)=O(nlogn)

於是我們可以得出以下定理:

《演算法導論》定理 8.1:在最壞情況下,任何比較排序演算法都需要做 O(n \log n)O(nlogn) 次比較。

由此我們還可以得到以下推論:

《演算法導論》推論 8.2:堆排序和歸併排序都是漸進最優的比較排序演算法。

到這裡我們就可以得出結論了,如果基於比較來進行排序,無論怎麼優化都無法突破O(nlogn) 的下界。計數排序和基於比較的排序演算法相比,根本區別就在於:它不是基於比較的排序演算法,而是利用了數字本身的屬性來進行的排序。整個計數排序演算法中沒有出現任何一次比較。

9.8、概率

相信大家都玩過「猜數字」遊戲:一方從 [1, 100]中隨機選取一個數字,另一方來猜。每次猜測都會得到「高了」或者「低了」的回答。怎樣才能以最少的次數猜中呢?

答案很簡單:二分。

二分演算法能夠保證每次都排除一半的數字。每次猜測不會出現驚喜(一次排除了多於一半的數字),也不會出現悲傷(一次只排除了少於一半的數字),因為答案的每一個分支都是等概率的,所以它在最差的情況下表現是最好的,猜測的一方在 logn 次以內必然能夠猜中。

基於比較的排序演算法與「猜數字」是類似的,每次比較,我們只能得到 a>b 或者 a≤b 兩種結果,如果我們把陣列的全排列比作一塊區域,那麼每次比較都只能將這塊區域分成兩份,也就是說每次比較最多排除掉 1/2 的可能性。

再來看計數排序演算法,計數排序時申請了長度為 k 的計數陣列,在遍歷每一個數字時,這個數字落在計數陣列中的可能性共有 k 種,但通過數字本身的大小屬性,我們可以「一次」把它放到正確的位置上。相當於一次排除了 (k - 1)/k 種可能性。

這就是計數排序演算法比基於比較的排序演算法更快的根本原因。

十、基數排序

想一下我們是怎麼對日期進行排序的。比如對這樣三個日期進行排序:2014 年 1 月 7 日,2020 年 1 月 9 日,2020 年 7 月 10 日。

我們大腦中對日期排序的思維過程是:

先看年份,2014 比 2020 要小,所以 2014 年這個日期應該放在其他兩個日期前面。

另外兩個日期年份相等,所以我們比較一下月份,1 比 7 要小,所以 1 月這個日期應該放在 7 月這個日期前面

這種利用多關鍵字進行排序的思想就是基數排序,和計數排序一樣,這也是一種線性時間複雜度的排序演算法。其中的每個關鍵字都被稱作一個基數。

比如我們對 999, 997, 866, 666 這四個數字進行基數排序,過程如下:

先看第一位基數:6 比 8 小,8 比 9 小,所以 666 是最小的數字,866 是第二小的數字,暫時無法確定兩個以 9 開頭的數字的大小關係

再比較 9 開頭的兩個數字,看他們第二位基數:9 和 9 相等,暫時無法確定他們的大小關係

再比較 99 開頭的兩個數字,看他們的第三位基數:7 比 9 小,所以 997 小於 999

基數排序有兩種實現方式。本例屬於「最高位優先法」,簡稱 MSD (Most significant digital),思路是從最高位開始,依次對基數進行排序。

與之對應的是「最低位優先法」,簡稱 LSD (Least significant digital)。思路是從最低位開始,依次對基數進行排序。使用 LSD 必須保證對基數進行排序的過程是穩定的。

通常來講,LSD 比 MSD 更常用。以上述排序過程為例,因為使用的是 MSD,所以在第二步比較兩個以 9 開頭的數字時,其他基數開頭的數字不得不放到一邊。體現在計算機中,這裡會產生很多臨時變數。

但在採用 LSD 進行基數排序時,每一輪遍歷都可以將所有數字一視同仁,統一處理。所以 LSD 的基數排序更符合計算機的操作習慣。

基數排序最早是用在卡片排序機上的,一張卡片有 80 列,類似一個 80 位的整數。機器通過在卡片不同位置上穿孔表示當前基數的大小。卡片排序機的排序過程就是採用的 LSD 的基數排序。

基數排序可以分為以下三個步驟:

  • 找出陣列中最大的數字的位數 maxDigitLength
  • 獲取陣列中每個數字的基數
  • 遍歷 maxDigitLength 輪陣列,每輪按照基數對其進行排序

10.1、找出陣列中最大的數字的位數

首先找到陣列中的最大值:

public static void radixSort(int[] arr) {
    if (arr == null) return;
    int max = 0;
    for (int value : arr) {
        if (value > max) {
            max = value;
        }
    }
    // ...
}

通過遍歷一次陣列,找到了陣列中的最大值 max,然後我們計算這個最大值的位數:

int maxDigitLength = 0;
while (max != 0) {
    maxDigitLength++;
    max /= 10;
}

將 maxDigitLength 初始化為 0,然後不斷地除以 10,每除一次,maxDigitLength 就加一,直到 max 為 0。

讀者可能會有疑惑,如果 max 初始值就是 0 呢?嚴格來講,0 在數學上屬於 1 位數。

但實際上,基數排序時我們無需考慮 max 為 0 的場景,因為 max 為 0 只有一種可能,那就是陣列中所有的數字都為 0,此時陣列已經有序,我們無需再進行後續的排序過程。

10.2、獲取基數

獲取基數有兩種做法:

第一種:

int mod = 10;
int dev = 1;
for (int i = 0; i < maxDigitLength; i++) {
    for (int value : arr) {
        int radix = value % mod / dev;
        // 對基數進行排序
    }
    mod *= 10;
    dev *= 10;
}

第二種

int dev = 1;
for (int i = 0; i < maxDigitLength; i++) {
    for (int value : arr) {
        int radix = value / dev % 10;
        // 對基數進行排序
    }
    dev *= 10;
}

兩者的區別是先做除法運算還是先做模運算,推薦使用第二種寫法,因為它可以節省一個變數。

10.3、對基數進行排序

對基數進行排序非常適合使用我們在上一節中學習的計數排序演算法,因為每一個基數都在 [0, 9][0,9] 之間,並且計數排序是一種穩定的演算法。

LSD 方式的基數排序程式碼如下:

public class RadixSort {

    public static void radixSort(int[] arr) {
        if (arr == null) return;
        // 找出最大值
        int max = 0;
        for (int value : arr) {
            if (value > max) {
                max = value;
            }
        }
        // 計算最大數字的長度
        int maxDigitLength = 0;
        while (max != 0) {
            maxDigitLength++;
            max /= 10;
        }
        // 使用計數排序演算法對基數進行排序
        int[] counting = new int[10];
        int[] result = new int[arr.length];
        int dev = 1;
        for (int i = 0; i < maxDigitLength; i++) {
            for (int value : arr) {
                int radix = value / dev % 10;
                counting[radix]++;
            }
            for (int j = 1; j < counting.length; j++) {
                counting[j] += counting[j - 1];
            }
            // 使用倒序遍歷的方式完成計數排序
            for (int j = arr.length - 1; j >= 0; j--) {
                int radix = arr[j] / dev % 10;
                result[--counting[radix]] = arr[j];
            }
            // 計數排序完成後,將結果拷貝回 arr 陣列
            System.arraycopy(result, 0, arr, 0, arr.length);
            // 將計數陣列重置為 0
            Arrays.fill(counting, 0);
            dev *= 10;
        }
    }
}

計數排序的思想上一節已經介紹過,這裡不再贅述。當每一輪對基數完成排序後,我們將 result 陣列的值拷貝回 arr 陣列,並且將 counting 陣列中的元素都置為 0,以便在下一輪中複用。

10.4、對包含負數的陣列進行基數排序

如果陣列中包含負數,如何進行基數排序呢?

我們很容易想到一種思路:將陣列中的每個元素都加上一個合適的正整數,使其全部變成非負整數,等到排序完成後,再減去之前加的這個數就可以了。

但這種方案有一個缺點:加法運算可能導致數字越界,所以必須單獨處理數字越界的情況。

事實上,有一種更好的方案解決負數的基數排序。那就是在對基數進行計數排序時,申請長度為 19 的計數陣列,用來儲存 [−9,9] 這個區間內的所有整數。在把每一位基數計算出來後,加上 9,就能對應上 counting 陣列的下標了。也就是說,counting 陣列的下標 [0, 18] 對應基數 [-9, 9]。

程式碼如下:

public class RadixSort {

    public static void radixSort(int[] arr) {
        if (arr == null) return;
        // 找出最長的數
        int max = 0;
        for (int value : arr) {
            if (Math.abs(value) > max) {
                max = Math.abs(value);
            }
        }
        // 計算最長數字的長度
        int maxDigitLength = 0;
        while (max != 0) {
            maxDigitLength++;
            max /= 10;
        }
        // 使用計數排序演算法對基數進行排序,下標 [0, 18] 對應基數 [-9, 9]
        int[] counting = new int[19];
        int[] result = new int[arr.length];
        int dev = 1;
        for (int i = 0; i < maxDigitLength; i++) {
            for (int value : arr) {
                // 下標調整
                int radix = value / dev % 10 + 9;
                counting[radix]++;
            }
            for (int j = 1; j < counting.length; j++) {
                counting[j] += counting[j - 1];
            }
            // 使用倒序遍歷的方式完成計數排序
            for (int j = arr.length - 1; j >= 0; j--) {
                // 下標調整
                int radix = arr[j] / dev % 10 + 9;
                result[--counting[radix]] = arr[j];
            }
            // 計數排序完成後,將結果拷貝回 arr 陣列
            System.arraycopy(result, 0, arr, 0, arr.length);
            // 將計數陣列重置為 0
            Arrays.fill(counting, 0);
            dev *= 10;
        }
    }
}

程式碼中主要做了兩處修改:

  • 當陣列中存在負數時,我們就不能簡單的計算陣列的最大值了,而是要計算陣列中絕對值最大的數,也就是陣列中最長的數
  • 在獲取基數的步驟,將計算出的基數加上 9,使其與 counting 陣列下標一一對應

10.5、LSD VS MSD

前文介紹的基數排序都屬於 LSD,接下來我們看一下基數排序的 MSD 實現。

public class RadixSort {

    public static void radixSort(int[] arr) {
        if (arr == null) return;
        // 找到最大值
        int max = 0;
        for (int value : arr) {
            if (Math.abs(value) > max) {
                max = Math.abs(value);
            }
        }
        // 計算最大長度
        int maxDigitLength = 0;
        while (max != 0) {
            maxDigitLength++;
            max /= 10;
        }
        radixSort(arr, 0, arr.length - 1, maxDigitLength);
    }

    // 對 arr 陣列中的 [start, end] 區間進行基數排序
    private static void radixSort(int[] arr, int start, int end, int position) {
        if (start == end || position == 0) return;
        // 使用計數排序對基數進行排序
        int[] counting = new int[19];
        int[] result = new int[end - start + 1];
        int dev = (int) Math.pow(10, position - 1);
        for (int i = start; i <= end; i++) {
            // MSD, 從最高位開始
            int radix = arr[i] / dev % 10 + 9;
            counting[radix]++;
        }
        for (int j = 1; j < counting.length; j++) {
            counting[j] += counting[j - 1];
        }
        // 拷貝 counting,用於待會的遞迴
        int[] countingCopy = new int[counting.length];
        System.arraycopy(counting, 0, countingCopy, 0, counting.length);
        for (int i = end; i >= start; i--) {
            int radix = arr[i] / dev % 10 + 9;
            result[--counting[radix]] = arr[i];
        }
        // 計數排序完成後,將結果拷貝回 arr 陣列
        System.arraycopy(result, 0, arr, start, result.length);
        // 對 [start, end] 區間內的每一位基數進行遞迴排序
        for (int i = 0; i < counting.length; i++) {
            radixSort(arr, i == 0 ? start : start + countingCopy[i - 1], start + countingCopy[i] - 1, position - 1);
        }
    }

}

使用 MSD 時,下一輪排序只應該發生在當前輪次基數相等的數字之間,對每一位基數進行遞迴排序的過程中會產生許多臨時變數。

相比 LSD,MSD 的基數排序顯得較為複雜。因為我們每次對基數進行排序後,無法將所有的結果一視同仁地進行下一輪排序,否則下一輪排序會破壞本次排序的結果。

10.6、時間複雜度 & 空間複雜度

無論 LSD 還是 MSD,基數排序時都需要經歷 maxDigitLength 輪遍歷,每輪遍歷的時間複雜度為 O(n + k) ,其中 k 表示每個基數可能的取值範圍大小。如果是對非負整數排序,則 k = 10,如果是對包含負數的陣列排序,則 k = 19。

所以基數排序的時間複雜度為 O(d(n + k)) (d 表示最長數字的位數,k 表示每個基數可能的取值範圍大小)。

使用的空間和計數排序是一樣的,空間複雜度為 O(n + k)(k 表示每個基數可能的取值範圍大小)。