資料結構與演算法——排序演算法-基數排序

天然呆dull發表於2021-09-01

簡單介紹

基數排序(radix sort)屬於 分配式排序(distribution sort),又稱 桶子法(bucket sort 或 bin sort),顧名思義,它是通過鍵值的各個位的值,將要排序的 元素分配 至某些「桶」中,達到排序的作用。基數排序是對 傳統桶排序 的擴充套件。

基數排序屬於 穩定性 的排序,基數排序法是效率高的穩定性排序法。

時間複雜度為O (nlog(r)m),其中r為所採取的基數,而m為堆數,在某些時候,基數排序法的效率高於其它的穩定性排序法。

穩定性簡介

2,1,43,1 陣列進行排序後變成:1,1,2,43

穩定性指的是:兩個 1 的先後順序不改變。

基數排序(Radix Sort)是 桶排序 的擴充套件。

基數排序是 1887 年赫爾曼·何樂禮發明的。實現方式:將所有待比較數值(正整數)統一為同樣的數位長度,數位較短的數前面補零。然後,從最低位開始,依次進行一次排序。這樣從最低位排序一直到最高位排序完成以後,數列就變成一個有序序列。

基本思想

  1. 將所有待比較數值 統一為同樣的數位長度,數位較短的數 前面補零
  2. 然後從最低位開始(個位),依次進行一次排序
  3. 這樣從最低位排序一直到最高位排序完成以後,序列就變成了一個有序序列

基本思想是抽象的,下面看看思路分析,你就明白是咋回事了。

思路分析

解析上面的圖:

第一輪:判斷 個位數

  1. 將每個元素的 個位數 取出,找到其 個位數 所對應的下標的桶,然後把這個數放到桶中(桶為一個一維陣列

  2. 按照這個桶的順序,依次取出資料,放回原來的陣列

    注意:取出桶裡的資料時,不僅要按桶的順序依次來取,而且單個桶裡的資料取出順序也要按先放進去的先取出來的規則。

以上步驟中,每一輪除了用什麼位數的值來判斷放在哪個桶裡不同外,其他的都相同。

第二輪:判斷 十位數

需要注意的是:

  • 第一輪使用後的桶並未清理,上圖為了講解方便,並未展示桶中已有的資料,不過會進行覆蓋。
  • 長度不足的數,用零表示。如 3,沒有十位數,則歸類到第一個桶中(0),即該數看成 03

第三輪:判斷 百位數

一個流程下來,你會發現,在第三輪排序後,陣列已經是有序的了

動圖:

程式碼實現

推導實現

為了更容易理解,下面進行分步推導來找出裡面的規律,從而實現演算法。

    /**
     * 推導:推導每一步的狀態,然後找規律
     */
    @Test
    public void processDemo() {
        //這裡我就隨便一個陣列了
        int arr[] = {53, 3, 542, 748, 14, 214};
        System.out.println("原始陣列:" + Arrays.toString(arr));
        processRadixSort(arr);
    }

    public void processRadixSort(int[] arr) {
        // 第一輪
        // 1. 將每個元素的 **個位數** 取出,找到其 個位數 所對應的下標的桶,然後把這個數放到桶中(**桶為一個一維陣列**)
        // 2. 按照這個桶的順序,**依次取出資料**,放回原來的陣列

        // 定義 10 個桶,每個桶是一個一維陣列
        // 由於無法知道每個桶需要多少個元素,所以宣告為陣列長度
        // 例如:加入10 個數字都是 1,那麼只會分配到同一個通中
        int[][] buckets = new int[10][arr.length];
        // 定義每個桶中有效的資料個數
        // 桶長度為陣列大小,那麼每一個桶中存放了幾個有效的元素呢?就需要有這個變數來指示
        int[] bucketCounts = new int[buckets.length];

        // 開始第一輪的程式碼實現
        // 1. 將每個元素的 **個位數** 取出,找到其 個位數 所對應的下標的桶,然後把這個數放到桶中(**桶為一個一維陣列**)
        for (int i = 0; i < arr.length; i++) {
            // 獲取到個位數
            int temp = arr[i] % 10;
            // 根據規則,將該數放到對應的桶中
            buckets[temp][bucketCounts[temp]] = arr[i];
            // 並將該桶的有效個數+1
            bucketCounts[temp]++;
        }
        // 2. 按照這個桶的順序,**依次取出資料**,放回原來的陣列
        int index = 0; // 原始陣列下標索引
        for (int i = 0; i < buckets.length; i++) {
            if (bucketCounts[i] == 0) {
                // 標識該桶無資料
                continue;
            }
            for (int j = 0; j < bucketCounts[i]; j++) {
                arr[index++] = buckets[i][j];
            }
            // 取完資料後,要重置每個桶的有效資料個數,注意,這裡一步一定要做!!!
            bucketCounts[i] = 0;
        }
        System.out.println("第一輪排序後:" + Arrays.toString(arr));

        // 第 2 輪:判斷 十位數
        for (int i = 0; i < arr.length; i++) {
            // 獲取到十位數
            int temp = arr[i] / 10 % 10;//就這一步不同
            buckets[temp][bucketCounts[temp]] = arr[i];
            bucketCounts[temp]++;
        }
        index = 0; // 原始陣列下標索引
        for (int i = 0; i < buckets.length; i++) {
            if (bucketCounts[i] == 0) {
                continue;
            }
            for (int j = 0; j < bucketCounts[i]; j++) {
                arr[index++] = buckets[i][j];
            }
            bucketCounts[i] = 0;
        }
        System.out.println("第二輪排序後:" + Arrays.toString(arr));

        // 第 3 輪:判斷 百位數
        for (int i = 0; i < arr.length; i++) {
            // 獲取到十位數
            int temp = arr[i] / 100 % 10;//就這一步不同
            buckets[temp][bucketCounts[temp]] = arr[i];
            bucketCounts[temp]++;
        }
        index = 0; // 標識當前放回原陣列的哪一個了
        for (int i = 0; i < buckets.length; i++) {
            if (bucketCounts[i] == 0) {
                continue;
            }
            for (int j = 0; j < bucketCounts[i]; j++) {
                arr[index++] = buckets[i][j];
            }
            bucketCounts[i] = 0;
        }
        System.out.println("第三輪排序後:" + Arrays.toString(arr));
    }

測試輸出

原始陣列:[53, 3, 542, 748, 14, 214]
第一輪排序後:[542, 53, 3, 14, 214, 748]
第二輪排序後:[3, 14, 214, 542, 748, 53]
第三輪排序後:[3, 14, 53, 214, 542, 748]

根據前面的推導,整理發現有如下的規律:整體程式碼比較固定,少數變數在變化,即判斷的位數

  1. 要迴圈幾輪?這個與待排序陣列中的最大值有幾位數有關係

    需要找到陣列中的最大值,並且得到該值的位數

  2. 獲取陣列中每個數的個、十、百 位數的公式可以如下整理:

    // 獲取個位數
    arr[i] % 10  -> arr[i] / 1 % 10
    // 獲取十位數
    arr[i] / 10 % 10
    // 獲取百位數
    arr[i] / 100 % 10
    

    可以發現規律,每一次變化的都是 10 的倍數

因此通過推到以及上面的分析整理,下面完成了該演算法的具體程式碼

完整實現

    @Test
    public void radixSortTest() {
        int arr[] = {53, 3, 542, 748, 14, 214};
        System.out.println("原始陣列:" + Arrays.toString(arr));
        radixSort(arr);
        System.out.println("排序後:" + Arrays.toString(arr));
    }

    /**
     * 根據推導規律,整理出完整演算法
     *
     * @param arr
     */
    public void radixSort(int[] arr) {
        // 1. 得到陣列中的最大值,並獲取到該值的位數。用於知道要迴圈幾輪
        int max = arr[0];
        for (int i = 0; i < arr.length; i++) {
            if (arr[i] > max) {
                max = arr[i];
            }
        }
        // 得到位數
        int maxLength = (max + "").length();//這裡很巧妙!

        // 定義桶 和 標識桶中元素個數
        int[][] bucket = new int[10][arr.length];
        int[] bucketCounts = new int[bucket.length];

        // 總共需要進行 maxLength 輪
        for (int k = 1, n = 1; k <= maxLength; k++, n *= 10) {//注意看這裡,有兩個變數,k控制迴圈,n用於後面獲取到對應的位數
            // 進行桶排序
            for (int i = 0; i < arr.length; i++) {
                // 獲取該輪的桶索引:n 每一輪按 10 的倍數遞增,獲取到對應數位數
                // 這裡額外使用一個步長為 10 的變數 n 來得到每一次遞增後的值
                int bucketIndex = arr[i] / n % 10;//這裡很巧妙!
                // 放入該桶中
                bucket[bucketIndex][bucketCounts[bucketIndex]] = arr[i];
                // 標識該桶元素多了一個
                bucketCounts[bucketIndex]++;
            }
            // 將桶中元素獲取出來,放到原陣列中,注意,要按單個桶中的資料取出是先進先出的順序
            int index = 0;
            for (int i = 0; i < bucket.length; i++) {
                if (bucketCounts[i] == 0) {
                    // 該桶無有效元素,跳過不獲取
                    continue;
                }
                // 獲取桶中有效的個數
                for (int j = 0; j < bucketCounts[i]; j++) {
                    arr[index++] = bucket[i][j];
                }
                // 取完後,重置該桶的元素個數為 0 ,下一次才不會錯亂資料,這一步很重要!!!
                bucketCounts[i] = 0;
            }
            System.out.println("第" + k + "輪排序後:" + Arrays.toString(arr));
        }
    }

測試輸出

原始陣列:[53, 3, 542, 748, 14, 214]
第1輪排序後:[542, 53, 3, 14, 214, 748]
第2輪排序後:[3, 14, 214, 542, 748, 53]
第3輪排序後:[3, 14, 53, 214, 542, 748]
排序後:[3, 14, 53, 214, 542, 748]

動圖:

大資料量耗時測試

 /**
     * 大量資料排序時間測試
     */
    @Test
    public void bulkDataSort() {
        int max = 80000;
//        max = 8;
        int[] arr = new int[max];
        for (int i = 0; i < max; i++) {
            arr[i] = (int) (Math.random() * 80000);
        }
        if (arr.length < 10) {
            System.out.println("原始陣列:" + Arrays.toString(arr));
        }
        Instant startTime = Instant.now();
        radixSort(arr);
        if (arr.length < 10) {
            System.out.println("排序後:" + Arrays.toString(arr));
        }
        Instant endTime = Instant.now();
        System.out.println("共耗時:" + Duration.between(startTime, endTime).toMillis() + " 毫秒");
    }

多次測試輸出資訊

共耗時:31 毫秒
共耗時:29 毫秒
共耗時:22 毫秒
共耗時:39 毫秒

如果增加資料量到 800 萬,也發現只會用時 400 毫秒左右,速度非常快。

但是,如果資料量增加到 8000 萬,則會報錯(這個就得看你的電腦記憶體大小了)

java.lang.OutOfMemoryError: Java heap space

	at cn.mrcode.study.dsalgtutorialdemo.datastructure.sort.radix.RadixSortTest.radixSort(RadixSortTest.java:125)

這是為什麼呢?原因就在於開啟了 10 個桶,每個桶都是 8000 萬個資料。那麼換算下單位:

80000000 * 11 * 4 / 1024 /1024 / 1024 = 3.2 G 左右的堆空間
# 11 = 10 個桶 + 原始陣列
# 4 :一個 int 佔用 4 位元組   

也就是說,犧牲記憶體來提升速度。

注意事項

  • 基數排序是對 傳統桶排序 的擴充套件,速度很快

  • 是經典的空間換時間的方式,佔用記憶體空間很大

    當資料量太大的時候,所耗費的額外空間較大。是原始資料的 10 倍空間

  • 基數排序是穩定的

    相同的數,排序之後,他們的先後順序沒有發生變化。

  • 有負數時,不用基數排序來進行排序

    如果要支援負數可以參考 中文維基百科

    由於上述演算法使用的按位比較,並未考慮負數,直接使用,將導致陣列越界。

    改造支援負數的核心思想是:將負數取絕對值,然後再反轉成負數。

相關文章