10種常見排序算法原理詳解以及Java代碼的完全實現

  本文詳細介紹了10種常見排序算法的原理,包括冒泡排序、選擇排序、插入排序、希爾排序、堆排序、歸併排序、快速排序、計數排序、桶排序、基數排序。並且每種排序都提供了Java代碼的實現案例。
  本文內容較多,歡迎點贊收藏加關注,慢慢看!

1 排序概述

排序的概念:
  將輸入的數據按照某種比較關係,從小到大或者從大到小順序進行排列,這就是排序。
排序的穩定性:
  假定在待排序的記錄序列中,存在多個具有相同的關鍵字的記錄,若經過排序,這些記錄的相對次序保持不變,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序後的序列中,r[i]仍在r[j]之前,則稱這種排序算法是穩定的;否則稱爲不穩定的。
內排序與外排序:
  根據在排序過程中待排序的記錄是否全部被放置在內存中,排序分爲:內排序和外排序。
內排序是在排序整個過程中,待排序的所有記錄全部被放置在內存中。外排序是由於排序的記錄個數太多,不能同時放置在內存,整個排序過程需要在內外存之間多次交換數據才能進行。這裏主要就介紹內排序的方法。
  根據排序過程中藉助的主要操作,我們將內排序分爲:插入排序、交換排序、選擇排序和歸併排序四類,每一類方法都有自己的多種具體方法和不同的時間複雜度:

插入排序 交換排序 選擇排序 歸併排序
插入排序 冒泡排序 選擇排序 歸併排序
希爾排序 快速排序 堆排序

  上面的7種排序的算法,按照算法的複雜度分爲兩大類,冒泡排序、選擇排序和插入排序屬於簡單算法,而希爾排序、堆排序、歸併排序、快速排序屬於改進算法。而上面7中算法統稱爲比較排序。
  所謂的比較排序,即在排序的最終結果裏,元素之間的次序依賴於它們之間的比較。每個數都必須和其他數進行比較,才能確定自己的位置。不同的算法區別在於比較次數的多少,在冒泡排序之類的排序中,問題規模爲n,又因爲需要比較n次,所以平均時間複雜度爲O(n²)。在歸併排序、快速排序之類的排序中,問題規模通過分治法消減爲logN次,所以時間複雜度平均O(nlogn)。比較排序的優勢是,適用於各種規模的數據,也不在乎數據的分佈,都能進行排序。可以說,比較排序適用於一切需要排序的情況。通過比較兩個數大小來進行排序的算法時間複雜度至少是O(nlgn)。
  因爲我們如果要對一個數組排序,肯定至少要考察每個元素,因此可以推斷O(n)是所有排序算法的下界。那麼到底有沒有哪種排序算法的時間複雜度是線性的(O(n))呢?在一定條件下,其實是有的,計數排序、基數排序、桶排序就屬於線性複雜度的排序,同時他們都有“一定要求”!
  計數排序、基數排序、桶排序則屬於非比較排序。非比較排序要求排序的元素都是整數,而且都在明確的m-n範圍內。針對數組arr,計算arr[i]之前有多少個元素,則唯一確定了arr[i]在排序後數組中的位置。非比較排序只要確定每個元素之前的已有的元素個數即可,所以一次遍歷即可解決,算法時間複雜度O(n)。非比較排序時間複雜度比較低,但由於非比較排序需要佔用空間來確定唯一位置。所以對數據規模和數據分佈有一定的要求,即空間複雜度較高。

2 比較排序

2.1 冒泡排序(Bubble Sort)

2.1.1 冒泡排序的實現

  無論什麼語言,冒泡排序作爲最簡單的排序算法之一,也是新手必會的排序算法之一。
  冒泡排序原理:將前一個數和後一個數進行比較,若前一個比後一個小則交換位置,一輪完成後將最大值排在最前方再開始第二輪,選出第二大的值,排在倒數第二的位置,直至排到順數第二位置,完成排序。
  外層循環控制循環次數,內層循環控制比較的兩個數。
  冒泡排序就是把小的元素往前調或者把大的元素往後調。比較是相鄰的兩個元素比較,交換也發生在這兩個元素之間。所以,如果兩個元素相等,是不會再交換的;如果兩個相等的元素沒有相鄰,那麼即使通過前面的兩兩交換把兩個相鄰起來,這時候也不會交換,所以相同元素的前後順序並沒有改變,所以冒泡排序是一種穩定排序算法。

public class BubbleSort {
    public static void main(String[] args) {
        int[] arr = new int[]{9, 8, 5, 3, 1, 7};
        //外層循環控制循環次數
        //第一次循環比較需要比較五次,然後選出最大值 9,排在末尾
        //第二次循環只需要比較四次,選出第二大的值 8,排在倒數第二的位置
        //第n次循環 需要比較 arr.length-n 次
        //最後一次循環 需要比較一次
        for (int i = 0; i < arr.length - 1; i++) {
            //內層循環控制比較的相鄰的兩個數
            for (int j = 0; j < arr.length - 1 - i; j++) {
                //如果前一個數大於後一個數,則交換位置
                if (arr[j] > arr[j + 1]) {
                    arr[j] = arr[j] ^ arr[j + 1];
                    arr[j + 1] = arr[j] ^ arr[j + 1];
                    arr[j] = arr[j] ^ arr[j + 1];
                }
            }
        }
        System.out.println(Arrays.toString(arr));
    }
}

2.1.1 冒泡排序的複雜度分析

  設有n個需要排序的數,在冒泡排序中,第1 輪需要比較n - 1 次,第2 輪需要比較n - 2 次……第n - 1 輪需要比較1 次。因此,總的比較次數爲(n - 1) + (n - 2) + … + 1 ≈ n²/2。這個比較次數恆定爲該數值,和輸入數據的排列順序無關。因此,冒泡排序的時間複雜度爲O(n²)。
  不過,交換數字的次數和輸入數據的排列順序有關。假設出現某種極端情況,如輸入數據正好以從小到大的順序排列,那麼便不需要任何交換操作;反過來,輸入數據要是以從大到小的順序排列,那麼每次比較數字後便都要進行交換。即
交換元素次數的複雜度最多爲O(n²)。

  冒泡排序並沒有藉助外部輔助變量或者數據結構,因此空間複雜度爲O(1)。

2.2 選擇排序(Selection Sort)

2.2.1 選擇排序的實現

  選擇排序原理:第一輪,使用第一個值,索引爲i,依次與後面的值做比較,並使用臨時變量min=i,記錄比較後的相對較小的值的索引,內層循環完畢之後,判斷如果min不等於第一個元素的下標i,就讓第一個元素跟他交換一下值,這樣就找到整個數組中最小的數了,一輪結束後,此時將最小的值排在了最前方;再循環拿第二個值與後面的值依次作比較,直至倒數第二個值完成比較,即完成排序。
  外層循環控制比較的第一個數,內層循環控制比較的第二個數。
  在一輪選擇之後,如果一個元素比當前元素小,而該小的元素又出現在一個和當前元素相等的元素後面,那麼交換後穩定性就被破壞了。舉個例子,序列5 8 5 2 9,第一輪選擇第1個元素5會和2交換,那麼原序列中兩個5的相對前後順序就被破壞了,所以選擇排序是一個不穩定的排序算法。

public class SelectionSort {
    public static void main(String[] args) {
        //int[] arr = new int[]{9, 8, 5, 3, 1, 7};
        int[] arr = new int[]{49, 38, 65, 97, 76, 13, 27, 49, 78};
        //外層循環控制第一個被比較的數索引   [0 - (arr.length - 1) -1]
        //第一次循環比較需要比較五次,然後選出最小值 9,排在首位
        //第二次循環只需要比較四次,選出第二小的值 8,排在第二位
        //第n次循環 需要比較 arr.length-n 次
        //最後一次循環 需要比較一次
        for (int i = 0; i < arr.length - 1; i++) {
            int min = i;
            //內層循環控制第二個被比較的數索引 [i+1 ~ arr.length-]
            for (int j = i + 1; j < arr.length; j++) {
                //記錄該輪最小的數的索引min
                if (arr[min] > arr[j]) {
                    min = j;
                }
            }
            //內層循環結束之後,判斷索引min是否還是和i相等,不相等則說明有比arr[i]還小的數arr[min],交換元素
            if (min != i) {
                arr[i] = arr[i] ^ arr[min];
                arr[min] = arr[i] ^ arr[min];
                arr[i] = arr[i] ^ arr[min];
            }
        }
        System.out.println(Arrays.toString(arr));
    }
}

2.2.2 選擇排序的複雜度分析

  選擇排序使用了線性查找來尋找最小值,因此在第1 輪中需要比較n-1 個數字,第2 輪需要比較n - 2 個數字……到第n-1 輪的時候就只需比較1 個數字了。因此,總的比較次數與冒泡排序的相同,都是(n-1) + (n-2) + … + 1 ≈ n²/2 次。選擇排序的時間複雜度也和冒泡排序的一樣,都爲O(n²)。
  每輪中交換數字的次數最多爲1 次。如果輸入數據就是按從小到大的順序排列的,便不需要進行任何交換。即交換元素次數的複雜度最多爲O(n),如果交換元素的開銷比較大,那麼選擇排序優於冒泡排序。
  選擇排序僅僅藉助1個外部輔助變量,相對於輸入元素個數n來說是一個常量,並沒藉助其他數據結構,因此空間複雜度爲O(1)。

2.3 插入排序(Insertion Sort)

2.3.1 插入排序的實現

  插入排序原理:在排序過程中,左側的數據默認是排好序的數據,而右側的是還未被排序的數據。插入排序的思路就是從右側的未排序區域內取出第一個數據,然後將它插入到左側已排序區域內合適的位置上。當右側區域數據取出排序只剩左側區域,那麼排序完畢。
  外層循環控制右側未被排序的數,內層循環控制左側已被排序的數。

public class InsertionSort {
    public static void main(String[] args) {
        int[] arr = new int[]{9, 8, 5, 3, 1, 7};
        int j;
        //外層循環控制右側未被排序的數 假設"第一個數"是"已經"排好序的,因此 未排序的數據從第二個數開始取
        for (int i = 1; i < arr.length; i++) {
            int norSort = arr[i];
            //內層循環控制左側已被排序的數,從最大的已排序的數開始比較
            //如果未排序的數小於已排序的數arr[j],則將arr[j]像右移動一位
            for (j = i - 1; j >= 0 && norSort < arr[j]; j--) {
                arr[j + 1] = arr[j];
            }
            //如果未排序的數大於已排序的數arr[j],則將arr[j+1]賦值給norSort,這就是爲排序的數需要插入的已排序數據中的位置
            arr[j + 1] = norSort;
        }
        System.out.println(Arrays.toString(arr));
    }
}

2.3.2 排序過程分析

  該算法可能比冒泡排序和選擇排序稍微繞一點,下面來看看執行步驟,需要對數組{9, 8, 5, 3, 1, 7}進行插入排序。我們可以把對n個數的插入排序看成n-1輪排序組成。上面的數組中n=6,即需要5輪排序。
  未排序:未排序時,默認將最左側的數當作已排序的數據區域。數組結構如下::
在這裏插入圖片描述
  第1輪排序:外層循環i=1,取出未排序的元素8;進入內層循環,j=i-1=0,即0索引元素9,對它們進行比較發現8<9,此時將索引0+1=1的位置賦值爲9,j–變成-1不滿足大於等於0,內層循環結束;進入下一步,將-1+1=0索引的值賦值爲未排序元素8,外層循環結束,第一次輪序結束。數組結構如下:
在這裏插入圖片描述

  第2輪排序:外層循環i=2,取出未排序的元素5;進入內層循環,j=i-1=1,即1索引元素9,對它們進行比較發現5<9,此時將索引1+1=2的位置賦值爲9,j–變成0>=0,對索引0的值8和5進行比較,發現5<8,進入第二次內層循環,此時將索引0+1=1的位置賦值爲8,j–變成-1不滿足大於等於0,內層循環結束;進入下一步,將-1+1=0索引的值賦值爲未排序元素5,外層循環結束,第二輪排序結束。數組結構如下:
在這裏插入圖片描述
  如此往復進行5輪排序,此時即可完成排序:
在這裏插入圖片描述

2.3.3 插入排序的複雜度分析

  在插入排序中,需要將取出的數據與其左邊的數字進行比較。就跟前面講的步驟一樣,如果左邊的數字更小,就不需要繼續比較,本輪操作到此結束,自然也不需要交換數字的位置。如果要排序的表本身就是有序的,那麼n輪只需要比較n次,時間複雜度爲O(n)。
  然而,如果取出的數字比左邊已排序的所有數字都要小,就必須不停地比較大小,交換數字,直到它到達整個序列的最左邊爲止。具體來說,就是外層第n輪需要內層循環比較n次。因此,在最糟糕的情況下,即輸入數據按從大到小的順序排列時,第1輪需要操作1 次,第2 輪操作2 次……第n 輪操作n次,所以時間複雜度和冒泡排序、選擇排序的一樣,都爲O(n²)。
  當表數據基本是有序時,那麼插入排序速度將比冒泡排序、選擇排序更快。 當表數據基本是有序時,那麼插入排序速度將比冒泡排序、選擇排序更快。插入排序適用於已經有部分數據已經排好,並且排好的部分越大越好。一般在輸入規模大於1000的場合下不建議使用插入排序。
  插入排序的空間複雜度爲常數階O(1)。
  到此,我們知道冒泡排序、選擇排序、插入排序的時間複雜度都是O(n²),下面將介紹打破二次時間屏障的排序算法。

2.4 希爾排序(Shell Sort)

2.4.1 希爾排序的原理和實現

  在很長的時間裏,衆人發現儘管各種排序算法花樣繁多(比如前面我們提到的三種不同的排序算法),但時間複雜度都是O(n²),似乎沒法超越了。
  希爾排序是D.L.Shell於1959年提出來的一種排序算法,希爾排序算法是打破二次時間屏障的第一批排序算法之一。可惜的是它被發現若干年後才證明了它的亞二次時間界。
  希爾排序是基於插入排序的以下兩點性質而提出改進方法的:

  1. 插入排序在對幾乎已經排好序的數據操作時,效率高,即可以達到線性排序的效率。
  2. 但對於數據較多且基本無序的數據來說插入排序是低效的,因爲插入排序每次只能將數據移動一位,並且插入排序的工作量和n的平方成正比,如果n比較小,那麼排序的工作量自然要小得多。

  希爾排序算法先是把需要排序的記錄按下標索引的一定增量d1<n分組,每組中記錄的下標相差d1,分別對每組中全部元素進行排序,此時對於每個索引i都有arr[i]<arr[i+d],整個記錄變成了“相對有序”;然後再用一個較小的增量d2<d1對它進行分組,在每組中再進行排序,整個記錄變得更加“相對有序”;重複上述的分組和排序,當增量dn減到1時,即最終所有記錄放在同一組中進行一次完整的插入排序爲止,排序完成。
  由於增量序列d1,d2,……,dn(dn=1)的存在,希爾排序又被稱爲縮小增量排序。該方法實質上是一種分組插入方法。比較相隔較遠距離(稱爲增量)的數,使得數移動時能跨過多個元素,實現跳躍式移動,則進行一次比較就可能消除多個元素交換,並且每一輪的分組數據較少,而且一輪排序之後數據變的“相對有序”,其總體效率相比直接插入排序更高。
  由於多次插入排序,我們知道一次插入排序是穩定的,不會改變相同元素的相對順序,但在不同的插入排序過程中,相同的元素可能在各自的插入排序中移動,最後其穩定性就會被打亂,所以希爾排序是不穩定的。
  希爾排序的一種實現如下:

public class ShellSort {
    public static void main(String[] args) {
        int[] arr = new int[]{49, 38, 65, 97, 76, 13, 27, 49, 78, 34, 12, 64, 1};
        int j;
        //希爾增量初始gap=arr.length / 2   增量每次減半gap /= 2  直到等於0,結束希爾排序
        for (int gap = arr.length / 2; gap > 0; gap /= 2) {
            /*內部對每次分組的元素採用插入排序,因此,與傳統插入排序不同的是,這裏的插入排序實現了數據的跳躍式移動*/
            //外層循環控制某分組右側未被排序的數,同樣假設"第一個數"是"已經"排好序的,因此 未排序的數據從某分組第二個數即0+gap的索引處開始取
            for (int i = gap; i < arr.length; i++) {
                int norSort = arr[i];
                //內層循環控制某分組左側已被排序的數,從最大的已排序的數開始比較,同樣假設"第一個數"是"已經"排好序的,即0索引的數
                //如果未排序的數小於已排序的數arr[j],則將arr[j]像右移動j+gap位
                for (j = i - gap; j >= 0 && norSort < arr[j]; j -= gap) {
                    arr[j + gap] = arr[j];
                }
                //如果未排序的數大於已排序的數arr[j],則將arr[j+gap]賦值給norSort,這就是爲排序的數需要插入的已排序數據中的位置
                arr[j + gap] = norSort;
            }
        }
        System.out.println(Arrays.toString(arr));
    }
}

2.4.2 排序過程分析

  希爾排序是插入排序的改進,這裏詳細說明一下排序過程。
  未分組的數據爲:
在這裏插入圖片描述
  第一輪
  初始希爾增量爲arr.length / 2=4。然後對以4進行索引分組的每組數據進行插入排序。如下圖是分組之後的結構:
在這裏插入圖片描述
  對每一組插入排序之後的結構如下:
在這裏插入圖片描述
  可以看出來,這樣分組之後每組數據量變少了,插入排序效率更高,雖然第一輪之後並沒有元素完全有序,但是它將較小的元素,不是一步一步地往前挪動,而是跳躍式地往前移,相對於最開始的數據變得“更加有序”了,這有助於加快後續第二輪希爾排序的效率。
  第二輪
  希爾增量爲4/2=2。然後對以2進行索引分組的每組數據進行插入排序。如下圖是分組之後的結構:
在這裏插入圖片描述
  對每一組插入排序之後的結構如下:
在這裏插入圖片描述
  可以看出來,第二輪之後元素並沒有完全有序,但是相對於第一輪的數據變得“更加有序”了,這有助於加快後續第三輪希爾排序的效率。
  第三輪
  希爾增量爲2/2=1,這說明是最後一輪排序,該輪排序之後,元素將會變得有序。對以1進行索引分組的每組數據進行插入排序。如下圖是分組之後的結構:
在這裏插入圖片描述
  可以看到,最終所有元素歸爲一組進行總的插入排序,排序之後的結構如下:
在這裏插入圖片描述
  可以看出來,對經過前兩輪希爾排序之後的數據進行插入排序,兩個數據只需要最多交換一次即可實現有序,這相比於對最開始的元素集合進行直接插入排序要快的多。實際上只需要內層交換4次即可:
在這裏插入圖片描述

2.4.3 希爾排序的複雜度分析

  從上面的案例可知,希爾排序的關鍵並不是隨便分組後各自排序,而是將相隔某個“增量”的記錄組成一個子序列,實現跳躍式的移動,使得排序的效率提高。這裏“增量”的選取就非常關鍵了。
  常見的增量序列有兩種,一種是Shell 增量序列,一種是Hibbard 增量序列。
  Shell 增量序列的遞推公式爲:
在這裏插入圖片描述
  Shell 增量序列的最壞時間複雜度爲 Θ(N²)。
  Hibbard 增量序列的遞推公式爲:
在這裏插入圖片描述
   Hibbard 增量序列的最壞時間複雜度爲 Θ(N (3/2) );平均時間複雜度約爲 O(N(5/4)),要好於直接插入排序的O(n²)。 希爾排序突破了亞二次時間界。
  需要注意的是,增量序列的最後一個增量值必須等於1才行。另外由於記錄是跳躍式的移動,希爾排序並不是一種穩定的排序算法。時間複雜度和增量序列直接相關,如果增量序列選擇不好,那麼基本上不能獲得相比直接插入更高的效率。
  希爾排序並沒有藉助外部輔助變量或者數據結構,因此空間複雜度爲O(1)。

2.5 堆排序(Heap Sort)

2.5.1 堆排序的原理和實現

  簡單選擇排序,它在待排序的n個記錄中選擇一個最小的記錄需要比較n-1次。可惜的是,這樣的操作並沒有把每一輪的比較結果保存下來,在後一輪的比較中,有許多比較在前一輪已經做過了,但由於前一輪排序時未保存這些比較結果,所以後一輪排序時又重複執行了這些比較操作,因而記錄的比較次數較多。
  如果可以做到每次在選擇到最小記錄的同時,記住比較的結果,並根據比較結果對其他記錄做出相應的調整,那樣排序的總體效率就會非常高了。而堆排序(HeapSort),就是對簡單選擇排序進行的一種改進,這種改進的效果是非常明顯的。堆排序算法是Floyd和Williams在1964年共同發明的,同時,他們發明了“堆”這樣的數據結構。 堆結構與樹有關,因此需要一些基礎知識,如果對二叉樹不明白可以看看這個專欄:
  堆是具有下列性質的二叉樹:

  1. 它是一顆完全二叉樹,一個重要的性質就是完全二叉樹的節點能夠完美的映射到數組中
  2. 每個節點的值都大於或等於其左右孩子節點的值,稱爲大頂堆/最大堆,如下圖左;或者每個節點的值都小於或等於其左右孩子節點的值,稱爲小頂堆/最小堆,如下圖右。
    在這裏插入圖片描述

  如果按照層序遍歷的方式給結點從1開始編號,由於完全二叉樹節點之間的天然存在的關係(二叉樹的性質),節點之間滿足如下關係:

大頂堆:k[i] >= k[2i] && k[i] >= k[2i+1] (1<=i<=n/2)
小頂堆:k[i] <= k[2i] && k[i] <= k[2i+1] (1<=i<=n/2)

  如果從0開始編號,並把節點映射到數組中之後,則結點之間滿足如下關係:

大頂堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2](0<=i<=n/2 -1)
小頂堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2](0<=i<=n/2 -1)

  大頂堆映射到數組的結構:
在這裏插入圖片描述
  小頂堆映射到數組的結構:
在這裏插入圖片描述
  上面介紹完了堆,下面看看堆排序,堆排序(Heap Sort)就是利用堆進行排序的方法。
  堆排序的原理:先將給定n個值的無序序列構造成一個大/小頂堆(一般升序採用大頂堆,降序採用小頂堆)。然後將堆頂元素與堆尾元素進行交換,使堆尾元素最大/小,並將堆尾元素移除堆。然後繼續調整剩餘的n-1個堆元素成爲大/小頂堆,再將堆頂元素與堆尾元素交換,並將堆尾元素移除堆,得到第二大/小元素。如此反覆進行交換、重建、交換。直到堆剩下最後一個元素,此時完成堆排序,便能得到一個有序序列了。
  由於記錄的比較與交換是跳躍式進行,因此堆排序也是一種不穩定的排序算法。另外,無論多少個數都需要構建堆,由於初始構建堆所需的比較次數較多,因此,它並不適合待排序序列個數較少的情況

  堆排序(大、小頂堆)的一種較好的實現如下:

public class HeapSort {
    public static void main(String[] args) {
        int[] arr = new int[]{49, 38, 65, 97, 76, 13, 27, 49, 78};
        //封裝大頂堆排序算法的方法
        bigHeapSort(arr);
        System.out.println(Arrays.toString(arr));
        //封裝小頂堆排序算法的方法
        //可以看出來,大頂堆和小頂堆排序算法差不多,只需理解其中一個,另外一個自然就理解了
        smallHeapSort(arr);
        System.out.println(Arrays.toString(arr));
    }


    /**
     * 大頂堆排序(順序)
     *
     * @param arr 需要被排序的數據集合
     */
    private static void bigHeapSort(int[] arr) {
        /*1、構建大頂堆*/
        /*i從最後一個非葉子節點的索引開始,遞減構建,直到i=-1結束循環
        這裏元素的索引是從0開始的,所以最後一個非葉子節點array.length/2 - 1,這是利用了完全二叉樹的性質*/
        for (int i = arr.length / 2 - 1; i >= 0; i--) {
            buildBigHeap(arr, i, arr.length);
        }
        /*2、開始堆排序,i = arr.length - 1,即從大頂堆尾部的數開始,直到i=0結束循環*/
        for (int i = arr.length - 1; i > 0; i--) {
            //交換堆頂與堆尾元素順序
            swap(arr, 0, i);
            //重新構建大頂堆
            buildBigHeap(arr, 0, i);
        }
    }

    /**
     * 構建大頂堆
     *
     * @param arr    數組
     * @param i      非葉子節點的索引
     * @param length 堆長度
     */
    private static void buildBigHeap(int[] arr, int i, int length) {
        //先把當前非葉子節點元素取出來,因爲當前元素可能要一直移動
        int temp;
        //節點的子節點的索引
        int childIndex;
        /*循環判斷父節點是否大於兩個子節點,如果左子節點索引大於等於堆長度 或者父節點大於兩個子節點 則結束循環*/
        for (temp = arr[i]; (childIndex = 2 * i + 1) < length; i = childIndex) {
            //childIndex + 1 < length 說明該節點具有右子節點,並且如果如果右子節點的值大於左子節點,那麼childIndex自增1,即childIndex指向右子節點索引
            if (childIndex + 1 < length && arr[childIndex] < arr[childIndex + 1]) {
                childIndex++;
            }
            //如果發現最大子節點(左、右子節點)大於根節點,爲了滿足大頂堆根節點的值大於子節點,需要進行值的交換
            //如果子節點更換了,那麼,以子節點爲根的子樹會受到影響,所以,交換之後繼續循環對子節點所在的樹進行判斷
            if (arr[childIndex] > temp) {
                swap(arr, i, childIndex);
            } else {
                //走到這裏,說明父節點大於最大的子節點,滿足最大堆的條件,直接終止循環
                break;
            }
        }
    }

    /**
     * 小頂堆排序(逆序)
     *
     * @param arr 需要被排序的數據集合
     */
    private static void smallHeapSort(int[] arr) {
        /*1、構建小頂堆*/
        /*i從最後一個非葉子節點的索引開始,遞減構建,直到i=-1結束循環
        這裏元素的索引是從0開始的,所以最後一個非葉子節點array.length/2 - 1,這是利用了完全二叉樹的性質*/
        for (int i = arr.length / 2 - 1; i >= 0; i--) {
            buildSmallHeap(arr, i, arr.length);
        }
        /*2、開始堆排序,i = arr.length - 1,即從小頂堆尾部的數開始,直到i=0結束循環*/
        for (int i = arr.length - 1; i > 0; i--) {
            //交換堆頂與堆尾元素順序
            swap(arr, 0, i);
            //重新構建小頂堆,此時堆的大小爲交換前堆大小-1
            buildSmallHeap(arr, 0, i);
        }
    }

    /**
     * 構建小頂堆
     *
     * @param arr    數組
     * @param i      非葉子節點的索引
     * @param length 堆長度
     */
    private static void buildSmallHeap(int[] arr, int i, int length) {
        //先把當前非葉子節點元素取出來,因爲當前元素可能要一直移動
        int temp;
        //節點的子節點的索引
        int childIndex;
        /*循環判斷父節點是否大於兩個子節點,如果左子節點索引大於等於堆長度 或者父節點大於兩個子節點 則結束循環*/
        for (temp = arr[i]; (childIndex = 2 * i + 1) < length; i = childIndex) {
            //childIndex + 1 < length 說明該節點具有右子節點,並且如果如果右子節點的值小於左子節點,那麼childIndex自增1,即childIndex指向右子節點索引
            if (childIndex + 1 < length && arr[childIndex] > arr[childIndex + 1]) {
                childIndex++;
            }
            //如果發現最小子節點(左、右子節點)小於根節點,爲了滿足小頂堆根節點的值小於子節點,需要進行值的交換
            //如果子節點更換了,那麼,以子節點爲根的子樹會受到影響,所以,交換之後繼續循環對子節點所在的樹進行判斷
            if (arr[childIndex] < temp) {
                swap(arr, i, childIndex);
            } else {
                //走到這裏,說明父節點小於最小的子節點,滿足最小堆的條件,直接終止循環
                break;
            }
        }
    }

    /**
     * 交換元素
     *
     * @param arr 數組
     * @param a   元素的下標
     * @param b   元素的下標
     */
    private static void swap(int[] arr, int a, int b) {
        arr[a] = arr[a] ^ arr[b];
        arr[b] = arr[a] ^ arr[b];
        arr[a] = arr[a] ^ arr[b];
    }
}

2.5.2 排序過程分析

2.5.2.1 構建堆

  該算法首先在第一個大循環中構建堆,其中i從arr.length/2–1=3的索引開始,3-2-1-0的變化,實際上這幾個索引節點都是非葉子節點。如下圖就是數組的元素映射到完全二叉樹中的邏輯結構:
在這裏插入圖片描述
  我們所謂的將待排序的序列構建成爲一個堆,其實就是從下往上、從右到左,將每個非葉子節點當作根結點,將其和其子樹調整成堆。
  下面來看看第一步,如何構建堆,這裏以大頂堆爲例子。

for (int i = arr.length / 2 - 1; i >= 0; i--) {
buildBigHeap(arr, i, arr.length);
}
private static void buildBigHeap(int[] arr, int i, int length) {
    //先把當前非葉子節點元素取出來,因爲當前元素可能要一直移動
    int temp;
    //節點的子節點的索引
    int childIndex;
    /*循環判斷父節點是否大於兩個子節點,如果左子節點索引大於等於堆長度 或者父節點大於兩個子節點 則結束循環*/
    for (temp = arr[i]; (childIndex = 2 * i + 1) < length; i = childIndex) {
        //childIndex + 1 < length 說明該節點具有右子節點,並且如果如果右子節點的值大於左子節點,那麼childIndex自增1,即childIndex指向右子節點索引
        if (childIndex + 1 < length && arr[childIndex] < arr[childIndex + 1]) {
            childIndex++;
        }
        //如果發現最大子節點(左、右子節點)大於根節點,爲了滿足大頂堆根節點的值大於子節點,需要進行值的交換
        //如果子節點更換了,那麼,以子節點爲根的子樹會受到影響,所以,交換之後繼續循環對子節點所在的樹進行判斷
        if (arr[childIndex] > temp) {
            swap(arr, i, childIndex);
        } else {
            //走到這裏,說明父節點大於最大的子節點,滿足最大堆的條件,直接終止循環
            break;
        }
    }
}

  第一次循環:
  第一次循環傳入的i=3,然後獲取左子結點索引childIndex = 2 * i + 1=7,然後判斷是否具有右子節點8,明顯存在,然後判斷左子結點和右子節點最大的節點,明顯是右節點,因此childIndex=7+1=8。
  然後判斷父節點的值是否大於等於最大的子節點,如果不是,則爲了滿足大頂堆根節點的值大於子節點的要求,需要進行值的交換。這裏arr[3]=97>arr[8]=78,因此不需要交換,此時直接break結束循環,進行下一次循環。此時數組並沒有調整結構。
  第二次循環:
  第二次循環傳入的i=2,然後獲取左子結點索引childIndex = 2 * i + 1=5,然後判斷是否具有右子節點6,明顯存在,然後判斷左子結點和右子節點最大的節點,明顯是右節點,因此childIndex=5+1=6。
  然後判斷父節點的值是否大於等於最大的子節點,如果不是,則爲了滿足大頂堆根節點的值大於子節點的要求,需要進行值的交換。這裏arr[2]=65>arr[6]=27,因此不需要交換,此時直接break結束循環,進行下一次循環。此時數組並沒有調整結構。
  第三次循環:
  第三次循環傳入的i=1,然後獲取左子結點索引childIndex = 2 * i + 1=3,然後判斷是否具有右子節點4,明顯存在,然後判斷左子結點和右子節點最大的節點,明顯是左節點,因此childIndex不需要自增。
  然後判斷父節點的值是否大於等於最大的子節點,如果不是,則爲了滿足大頂堆根節點的值大於子節點的要求,需要進行值的交換。這裏arr[1]=38<arr[3]=97,因此需要交換位置,此時數組邊變成:
在這裏插入圖片描述
  然後由於子節點更換了,那麼,以子節點爲根的子樹會受到影響,所以,交換之後繼續循環對子節點所在的樹進行判斷。i=3,然後獲取左子結點索引childIndex = 2 * i + 1=7,然後判斷是否具有右子節點8,明顯存在,然後判斷左子結點和右子節點最大的節點,明顯是右節點,因此childIndex=7+1=8。
  然後判斷父節點的值是否大於等於最大的子節點,如果不是,則爲了滿足大頂堆根節點的值大於子節點的要求,需要進行值的交換。這裏arr[3]=38<arr[8]=78,因此需要交換位置,此時數組邊變成:
在這裏插入圖片描述
  然後由於子節點更換了,那麼,以子節點爲根的子樹會受到影響,所以,交換之後繼續循環對子節點所在的樹進行判斷。i=8,然後獲取左子結點索引childIndex = 2 * i + 1=17,大於長度9,因此結束第三次循環。
  第四次循環:
  第四次循環傳入的i=0,然後獲取左子結點索引childIndex = 2 * i + 1=1,然後判斷是否具有右子節點2,明顯存在,然後判斷左子結點和右子節點最大的節點,明顯是左節點,因此childIndex不需要自增。
  然後判斷父節點的值是否大於等於最大的子節點,如果不是,則爲了滿足大頂堆根節點的值大於子節點的要求,需要進行值的交換。這裏arr[0]=47<arr[1]=97,因此需要交換位置,此時數組邊變成:
在這裏插入圖片描述
  然後由於子節點更換了,那麼,以子節點爲根的子樹會受到影響,所以,交換之後繼續循環對子節點所在的樹進行判斷。i=1,然後獲取左子結點索引childIndex = 2 * i + 1=3,然後判斷是否具有右子節點4,明顯存在,然後判斷左子結點和右子節點最大的節點,明顯是左節點,因此childIndex不需要自增。
  然後判斷父節點的值是否大於等於最大的子節點,如果不是,則爲了滿足大頂堆根節點的值大於子節點的要求,需要進行值的交換。這裏arr[1]=49<arr[3]=78,因此需要交換位置,此時數組邊變成:
在這裏插入圖片描述
  然後由於子節點更換了,那麼,以子節點爲根的子樹會受到影響,所以,交換之後繼續循環對子節點所在的樹進行判斷。i=3,然後獲取左子結點索引childIndex = 2 * i + 1=7,然後判斷是否具有右子節點8,明顯存在,然後判斷左子結點和右子節點最大的節點,明顯是左節點,因此childIndex不需要自增。
  然後判斷父節點的值是否大於等於最大的子節點,如果不是,則爲了滿足大頂堆根節點的值大於子節點的要求,需要進行值的交換。這裏arr[1]=49=arr[3]=49,因此不需要交換,此時直接break結束循環,進行下一次循環。此時數組並沒有調整結構。
  下一次循環時i–= -1,小於i<0,此時第一個大循環結束,構建大頂堆結束,下面來看看構建大頂堆之後的數組結構:
在這裏插入圖片描述
  將其映射到平衡二叉樹中,結構如下:
在這裏插入圖片描述
  可以看到,這顆平衡二叉樹,完全符合大頂堆的特性,即父節點大於它的子節點。剩下的就是第二部分進行重複的交換-再構建。

2.5.2.1 交換-再構建

/*2、開始堆排序,i = arr.length - 1,即從大頂堆尾部的數開始,直到i=0結束循環*/
for (int i = arr.length - 1; i > 0; i--) {
    //交換堆頂與堆尾元素順序
    swap(arr, 0, i);
    //重新構建大頂堆
    buildBigHeap(arr, 0, i);
}

  理解了第一部分,那麼第二部分應該不難理解。首先將堆頂節點即0索引元素與堆尾節點即尾索引節點元素互換位置,然後將最後一個元素“踢出”堆,此時由於堆頂節點元素變了,因此需要重構堆,這個重構並不是像第一部分那樣循環構建,而是構建可能會影響到的元素,由於堆頂節點元素改變,此時傳入的i=0,有序需要踢出堆尾元素,因此傳入的length變成arr.length – 1=8。此時堆在數組中的元素位置如下:
在這裏插入圖片描述
  其中紅色部分表示堆元素,黑色表示被“踢出”堆的元素。然後開始進行一次構建堆。
  i=0,然後獲取左子結點索引childIndex = 2 * i + 1=1,然後判斷是否具有右子節點2,明顯存在,然後判斷左子結點和右子節點最大的節點,明顯是左節點,因此childIndex不需要自增。
  然後判斷父節點的值是否大於等於最大的子節點,如果不是,則爲了滿足大頂堆根節點的值大於子節點的要求,需要進行值的交換。這裏arr[0]=38<arr[1]=78,因此需要交換位置,此時數組邊變成:
在這裏插入圖片描述
  然後由於子節點更換了,那麼,以子節點爲根的子樹會受到影響,所以,交換之後繼續循環對子節點所在的樹進行判斷。i=1,然後獲取左子結點索引childIndex = 2 * i + 1=3,然後判斷是否具有右子節點4,明顯存在,然後判斷左子結點和右子節點最大的節點,明顯是右節點,因此childIndex=3+1=4。
  然後判斷父節點的值是否大於等於最大的子節點,如果不是,則爲了滿足大頂堆根節點的值大於子節點的要求,需要進行值的交換。這裏arr[1]=38<arr[4]=76,因此需要交換位置,此時數組邊變成:
在這裏插入圖片描述
  然後由於子節點更換了,那麼,以子節點爲根的子樹會受到影響,所以,交換之後繼續循環對子節點所在的樹進行判斷。i=4,然後獲取左子結點索引childIndex = 2 * i + 1=9,大於長度8,因此結束該次構建。此時平衡二叉樹即堆的邏輯結構如下:
在這裏插入圖片描述
  這裏紅色節點表示被“踢出”堆的節點,可以看到調整之後的平衡二叉樹同樣完全符合最大堆的特性。
  剩下的就是不斷地循環了,直到最終i=0,即8個元素被“踢出”堆,剩下一個自然是最小的,結束循環,完成堆排序。此時數組結構如下:
在這裏插入圖片描述
  我們可以看到,當從堆中移除一個元素時,只需要對該元素所在的堆樹進行重新構建以及堆該元素構建之後影響的子堆進行構建,而不需要影響到其他的數據,不會進行多餘的比較,這是因爲前面的構建堆操作,將所有元素的順序都保存了下來。這也是堆排序更快的原因。

2.5.3 堆排序的複雜度分析

  堆排序的運行時間主要是消耗在初始構建堆和在排序重建堆時的反覆篩選上。
  構建堆:
  假設高度爲k,則從倒數第二層右邊的節點開始,這一層的節點都要執行子節點比較然後交換(如果順序  是對的就不用交換);倒數第三層呢,則會選擇其子節點進行比較和交換,如果沒交換就可以不用再執行下去了。如果交換了,那麼又要選擇一支子樹進行比較和交換;
  那麼總的時間計算爲:s = 2^( i - 1 ) * ( k - i );其中 i 表示第幾層,2^( i - 1) 表示該層上有多少個元素,( k - i) 表示子樹上要比較的次數,如果在最差的條件下,就是比較次數後還要交換;因爲這個是常數,所以提出來後可以忽略;
  S = 2^(k-2) * 1 + 2(k-3)*2…+2*(k-2)+2(0)*(k-1) ===> 因爲葉子層不用交換,所以i從 k-1 開始到 1;
  S = 2^k -k -1;又因爲k爲完全二叉樹的深度,而k=log(n) + 1,把此式帶入;得到:S = 2n - logn -2,所以時間複雜度爲:O(n)
  排序重建堆:
  每次重建意味着有一個節點出堆,所以需要將堆的容量減一。構建堆的函數的時間複雜度k=log(n),k爲堆的層數。所以在每次重建時,隨着堆的容量的減小,層數會下降,函數時間複雜度會變化。重建堆一共需要n-1次循環,每次循環的比較次數爲log(i),則相加爲:log2+log3+…+log(n-1)+log(n)≈log(n!)。log(n!)和nlog(n)是同階函數,所以時間複雜度爲O(nlogn)
  所以總體來說,堆排序的時間複雜度爲O(n+nlogn)=O(nlogn)。由於堆排序對原始記錄的排序狀態並不敏感,因此它無論是最好、最壞和平均時間複雜度均爲O(nlogn)。這在性能上顯然要遠遠好過於冒泡、簡單選擇、直接插入的O(n2)的時間複雜度了。但由於要使用堆這個相對複雜的數據結構(上面的案例是使用的邏輯堆結構,物理結構還是數組),所以實現起來也較爲困難。
  空間複雜度上,它並沒有真正的藉助堆的物理結構,還是在原數組上進行操作,其空間複雜度爲常數階O(1)。注意有些堆排序的實現藉助了額外的數據結構,此時空間複雜度大大增加,因此本案例的算法算是一種比較好的算法。

2.6 歸併排序(Merge Sort)

2.6.1 歸併排序的原理和實現

  歸併排序(Merge Sort)是建立在歸併操作上的一種有效的排序算法,該算法是採用分治法(Divide and Conquer)的一個非常典型的應用。分(divide)階段將問題分成一些小的問題然後遞歸求解,而治(conquer)的階段則將分的階段得到的各答案"合併"在一起,即分而治之)。
  歸併排序原理:如果初始序列含有n個記錄,先將總記錄拆分成相同長度兩個子序列,然後再對兩個子序列繼續拆分(利用了遞歸),最終拆分成n個有序的子序列,每個子序列的長度爲1;然後兩兩歸併,得到|n/2|(|x|表示不小於x的最小整數)個長度爲2或1的有序子序列;再兩兩歸併,如此重複(利用了遞歸),直至得到一個長度爲n的有序序列爲止,這種排序方法又被稱爲二路歸併排序
  歸併排序是一種穩定的排序算法,即相等的元素的順序不會改變,速度僅次於快速排序。
  一種並歸排序的實現如下:

public class MergeSort {

    public static void main(String[] args) {
        int[] arr = new int[]{49, 38, 65, 97, 76, 13, 27, 49, 78};
        mergeSort(arr, new int[arr.length], 0, arr.length - 1);
        System.out.println(Arrays.toString(arr));
    }

    /**
     * 並歸排序
     *
     * @param arr      要排序的數組
     * @param aidedArr 輔助數組
     * @param l        拆分的左數組的起始索引
     * @param r        拆分的右數組的結束索引
     */
    private static void mergeSort(int[] arr, int[] aidedArr, int l, int r) {
        //如果數組大於等於兩個元素,則進行拆分,否則就遞歸返回
        if (l < r) {
            /*1、拆分*/
            //算出拆分的中值,作爲左數組的結束索引
            int mid = (r + l) / 2;
            //拆分爲左子數組,然後將左子數組作爲父數組繼續遞歸拆分,直到拆分爲只有一個元素的兩個"有序"子數組
            mergeSort(arr, aidedArr, l, mid);
            //拆分爲右子數組,然後繼續遞歸拆分,直到拆分爲只有一個元素的兩個"有序"子數組
            mergeSort(arr, aidedArr, mid + 1, r);
            /*2、拆分到不能再拆了,即一個子數組只有一個元素,那麼就算拆分完畢,開始兩兩排序合併*/
            merge(arr, aidedArr, l, mid, mid + 1, r);
        }
    }

    /**
     * 排序-合併
     *
     * @param arr        需要排序的數組
     * @param aidedArr   輔助數組
     * @param leftStart  左數組的起始索引
     * @param leftEnd    左數組的結束索引
     * @param rightStart 右數組的起始索引
     * @param rightEnd   右數組的結束索引
     */
    private static void merge(int[] arr, int[] aidedArr, int leftStart, int leftEnd, int rightStart, int rightEnd) {
        //備份獲取起始索引m,在後面會用到;獲取該兩個相鄰子數組的元素個數numElements,後面會用到
        int m = leftStart, numElements = rightEnd - leftStart + 1;
        //如果左數組起始位置小於等於左結束位置,並且右數組起始位置小於等於右結束位置,那麼比較它們相同的相對位置的元素大小,並且將較小的元素加入到新的數組對應的索引位置(從左起始索引開始)中
        //然後被添加的元素位置相應的自增1,繼續循環比較,直到其中一個條件不滿足,結束循環
        while (leftStart <= leftEnd && rightStart <= rightEnd) {
            aidedArr[m++] = arr[leftStart] <= arr[rightStart] ? arr[leftStart++] : arr[rightStart++];
        }
        //如果左數組起始位置小於等於左結束位置,說明上面的循環並沒有將左數組的元素添加完畢,繼續添加
        while (leftStart <= leftEnd) {
            aidedArr[m++] = arr[leftStart++];
        }
        //如果右數組起始位置小於等於右結束位置,說明上面的循環並沒有將右數組的元素添加完畢,繼續添加
        while (rightStart <= rightEnd) {
            aidedArr[m++] = arr[rightStart++];
        }
        //然後再將新數組的元素拷貝到原數組對應索引處,這一步是需要的,這保證了後續排序合併元素的有序性
        for (int j = 0; j < numElements; j++, rightEnd--) {
            arr[rightEnd] = aidedArr[rightEnd];
        }
    }
}

2.6.2 排序過程分析

  從代碼可以看出,實際歸併排序不算太難,重要的是理解它的分治思想,並且是如何藉助了遞歸的特性巧妙地實現該思想。

2.6.2.1 拆分

/*1、拆分*/
//算出拆分的中值,作爲左數組的結束索引
int mid = (r + l) / 2;
//拆分爲左子數組,然後將左子數組作爲父數組繼續遞歸拆分,直到拆分爲只有一個元素的兩個"有序"子數組
mergeSort(arr, aidedArr, l, mid);
//拆分爲右子數組,然後繼續遞歸拆分,直到拆分爲只有一個元素的兩個"有序"子數組
mergeSort(arr, aidedArr, mid + 1, r);

  拆分部分代碼就只有上面幾行,但是運用了遞歸的思想,只要數組元素超過兩個,那麼就是拆分爲兩個子數組,然後對左、右子數組繼續遞歸拆分,當然遞歸的調用都是有返回條件的,這裏的返回條件就是最開始的判斷:l < r,如果l 不小於 r,說明該數組只有一個元素,那麼不會執行if中的語句,此時遞歸方法返回,並且整個序列都被拆分爲只有一個元素的子數組。遞歸返回後開始執行下面的merge方法,即排序—合併。
  下圖表示拆分的過程,共4輪8次:
在這裏插入圖片描述

2.6.2.2 排序-合併

/**
 * 排序-合併
 *
 * @param arr        需要排序的數組
 * @param aidedArr   輔助數組
 * @param leftStart  左數組的起始索引
 * @param leftEnd    左數組的結束索引
 * @param rightStart 右數組的起始索引
 * @param rightEnd   右數組的結束索引
 */
private static void merge(int[] arr, int[] aidedArr, int leftStart, int leftEnd, int rightStart, int rightEnd) {
    //備份獲取起始索引m,在後面會用到;獲取該兩個相鄰子數組的元素個數numElements,後面會用到
    int m = leftStart, numElements = rightEnd - leftStart + 1;
    //如果左數組起始位置小於等於左結束位置,並且右數組起始位置小於等於右結束位置,那麼比較它們相同的相對位置的元素大小,並且將較小的元素加入到新的數組對應的索引位置(從左起始索引開始)中
    //然後被添加的元素位置相應的自增1,繼續循環比較,直到其中一個條件不滿足,結束循環
    while (leftStart <= leftEnd && rightStart <= rightEnd) {
        aidedArr[m++] = arr[leftStart] <= arr[rightStart] ? arr[leftStart++] : arr[rightStart++];
    }
    //如果左數組起始位置小於等於左結束位置,說明上面的循環並沒有將左數組的元素添加完畢,繼續添加
    while (leftStart <= leftEnd) {
        aidedArr[m++] = arr[leftStart++];
    }
    //如果右數組起始位置小於等於右結束位置,說明上面的循環並沒有將右數組的元素添加完畢,繼續添加
    while (rightStart <= rightEnd) {
        aidedArr[m++] = arr[rightStart++];
    }
    //然後再將新數組的元素拷貝到原數組對應索引處,這一步是需要的,這保證了後續排序合併元素的有序性
    for (int j = 0; j < numElements; j++, rightEnd--) {
        arr[rightEnd] = aidedArr[rightEnd];
    }
}

  上面的代碼也比較簡單,就是對兩個子數組比較大小,從小到大放入輔助數組對應的索引中,此時該段元素已經排好序了,最後再將輔助數組對應位置排好序的元素拷貝到原數組相同的索引處,即完成一次合併,然後開始下一次排序-合併,排序-合併是因爲前的拆分造成的,這也是利用了遞歸的性質,在方法返回後,繼續一層層的執行遞歸代碼後面的代碼。下面來一步步解析。
  由遞歸的特性可知,最先返回的方法,將最先執行下面的merge即排序-合併的過程。結合上面的拆分圖和代碼(先遞歸拆分左子數組,後遞歸拆分右子數組)可以看出來,輪次靠後的拆分的方法將最先返回、左子數組的拆分將最先返回,因此,排序-合併的過程和先後順序如下圖:
在這裏插入圖片描述

2.6.3 歸併排序的複雜度分析

時間複雜度:
  具有n個元素的序列,每一輪並歸排序需要將n個元素都掃描一次,時間複雜度爲O(n),完全二叉樹的深度爲丨logn丨,因此整個並歸排序需要進行logn輪,總的時間複雜度爲O(nlogn)。歸併排序需要元素兩兩比較,不存在跳躍,因此歸併排序是一種穩定的排序算法,O(nlogn)是歸併排序算法中最好、最壞、平均的時間性能。
空間複雜度:
  歸併的空間複雜度就是那個臨時的數組和遞歸時壓入棧的數據佔用的空間:n + logn;所以空間複雜度爲O(n)。
  歸併排序雖然比較穩定,在時間上也是非常有效的,但是這種算法比較消耗空間。

2.7 快速排序(Quick Sort)

2.7.1 快速排序的原理

  希爾排序相當於直接插入排序的升級,它們同屬於插入排序類,堆排序相當於簡單選擇排序的升級,它們同屬於選擇排序類。而快速排序(Quicksort)其實就是我們前面認爲最慢的冒泡排序的升級,它們都屬於交換排序類。
  快速排序由C. A. R. Hoare在1960年提出,被稱爲。相對於冒泡排序,快速排序增大了記錄的比較和移動的距離,將關鍵字較大的記錄從前面直接移動到後面,關鍵字較小的記錄從後面直接移動到前面,這樣不會像冒泡一樣每次都只交換相鄰的兩個數,因此總的比較和交換的此數都變少了,速度自然更高。
  Java源碼中的基本類型排序使用的是快速排序,快速排序中比較和數據移動次數達到了平衡,對於基本類型來說更加合適。快速排序的具體實現有很多,簡單分爲單軸快排(如同上面的定義,具有一箇中心點)、雙軸快排(JDK1.8的快排是一種雙軸快排,具有兩個中心點)。
  JDK1.7開始,Java源碼中的泛型(Comparator、Comparable)對象排序使用的是基於歸併排序的TimSort排序,因爲對於對象類型進行比較的開銷會比較大,歸併排序具有流行算法中最少的比較次數,相對較多的數據移動(Java中移動的是對象引用,並不是真正的對象,因此開銷比較小)。
  而TimSort可以說是並歸排序的又一次升級,並且融入了插入排序。TimeSort是一種穩定的、自適應的、迭代的並歸排序,在部分已排序的數組上運行時,能夠識別已排好序的部分,時間複雜度遠小於O(nlogn),在隨機數組上運行時性能開銷和傳統的歸併排序相當。Java版的算法實現是基於原Tim Peters於2002年在Python版實現的改進:Python實現TimSort。TimSort不屬於十大經典排序算法,但是速度相比經典的排序算法更快,在後續文章中,將會單獨介紹一些現代的更快的算法。
  快速排序原理:通過一趟排序將要排序的數據分割成獨立的兩部分,其中一部分的所有數據都比另外一部分的所有數據都要小(找一個基準值,一部分數比基準值小、另一部分數比基準值大),這樣就找到了基準值在數組中的正確位置;然後再按此方法對這兩部分數據分別進行快速排序,以此達到整個數據變成有序序列。可以看到其中還是利用了分治法的思想,並且整個排序過程可以遞歸進行。
  快速排序是一種不穩定的排序算法,也就是說,多個相同的值的相對位置也許會在算法結束時產生變動。

2.7.2 快速排序的優化

  在提供實現之前,先說明幾個問題,以及解決方法。

2.7.2.1 如何選取基準值

  每次快速排序都需要選擇基準值,如果我們選取的基準值是處於整個序列的中間位置,那麼就能能使得兩個子序列的長度爲原本的一半,那麼快速排序的運行時間和歸併排序的一樣,都爲O(nlogn)。
  常見的一種垃圾選擇是將第一個元素作爲基準值,如果輸入是真的隨機的,那麼該方法是正確的,如果輸入是正序或者反序的,那麼每次劃分只得到一個比上一次劃分少一個記錄的子序列,注意另一個爲空。此時需要對所有元素進行遞歸調用,時間複雜度變成O(n²)。並且通常輸入的元素並不是真正隨機的,通常是部分有序的。因此冒然的取某個固定位置的元素作爲基準值是不可取的。
  另一種選擇是在序列中隨機選擇一個基準值,這種方法是完全正確的,在某種程度上,解決了對於基本有序的序列快速排序時的性能瓶頸。但是隨機數的生成開銷一般都很大(比如Java中的Random,並且還不是真隨機數),因此這種方法是一種正確但是不實用的方法。
  常見的正確做法是三數取中(median-of-three)法。即取三個關鍵字先進行排序,將中間數作爲樞軸,一般是取左端、右端和中間三個數,也可以隨機選取。這樣至少這個中間數一定不會是最小或者最大的數,從概率來說,取三個數均爲最小或最大數的可能性是微乎其微的,因此中間數位於較爲中間的值的可能性就大大提高了。由於整個序列是無序狀態,隨機選取三個數和從左中右端取三個數其實是一回事,而且隨機數生成器本身還會帶來時間上的開銷,因此隨機生成不予考慮。
  如果數據量非常大,還可以採用九數取中法

2.7.2.2 分割策略實現

  找到基準值之後,剩下的就是怎麼來根據基準值進行數組對比分割了。分割策略也比較多,這裏介紹一種正確且比較高效的方法。即雙指針法,從數組的兩端分別進行比對
  如數組:{38, 49, 65, 97, 49, 64, 27, 49, 78},對其進行並歸排序。
  首先使用採用三數取中法選取基準值,然後一系列交換元素,使得基準值位置最左邊的索引處:
在這裏插入圖片描述
  這樣我們選擇最左邊的數49作爲基準值,肯定比原來的38好多了。
  基準值選好之後,開始真正的分割,分割階段就是把小於與基準值的元素移動到左邊,而大於基準值的元素移動到右邊。雙指針法的原理如下:
  採用兩個指針,指針i從基準值後一個元素開始,指針j從最後一個元素開始。將i右移,經過小於基準值的元素,直到找到大於基準值的元素或者一直找到j所在的位置則停止;將j左移,經過大於基準值的元素,直到找到小於基準值的元素則停止。如果指針都停止之後,如果還是i<j那麼交換它們所在元素的位置,然後繼續查找,如果停止後i>=j,那麼分割完畢,此時進入下一步,尋找基準值的在數組的正確位置。
  尋找基準值的在數組的正確位置很簡單,只需要將此時j所在位置的值與基準值所在位置的值互換即可,此時分割纔算真正的完畢,基準值的排序後的位置已經被找到了,剩下的就是對基準值兩邊的數組,繼續進行遞歸分割的操作了。而返回的條件是子數組只有一個元素。
  這裏需要考慮的是,當某個元素與基準值相等時該怎麼辦,這裏我們同樣採取停止指針的策略,可以想象如果某個序列的元素全部相等,那麼可能造成很多相同元素的交換,這看起來沒有意義,但是好處是i和j最終會在中間交互,因此可以把數組分割爲兩個幾乎相等的子數組。此時就如同並歸排序,運行時間可達O(logn)。因此,快速排序是一種不穩定的排序算法,也就是說,多個相同的值的相對位置也許會在算法結束時產生變動。
在這裏插入圖片描述
  第一次交換前後:
在這裏插入圖片描述
  可知arr[i]=arr[j]=49,因此交換結果是數組不發生改變,下面繼續分割。
  第二次交換前後:
在這裏插入圖片描述
  繼續查找,第三次交換前後:
在這裏插入圖片描述
  然後i和j各自繼續查找,然後出現瞭如下的情況:
在這裏插入圖片描述
  根據上面的雙指針法步驟,此時i>=j成立,算是分割完畢,開始查找基準值的位置,前面也說了,位置查找很簡單,就是在最後一次分割之後的j的位置,讓後將j和基準值的位置的元素交換就行了
在這裏插入圖片描述
  此時,實際上基準值49的排序後的位置j已經找到了,並且元素已歸位。可看出來,分割後的左右子數組元素符合條件:左子數組的所有元素值小於等於基準值,右子數組的所有元素值大於等於基準值。因此,繼續對兩個子數組進行遞歸分割時,可以將該值排除:
在這裏插入圖片描述
  下面是左、右子數組的分割過程圖,都比較簡單:
在這裏插入圖片描述
  可以看到,右子數組分割完畢之後,左子數組有三個元素,因此還需要遞歸分割一次
在這裏插入圖片描述
  可以看到,最終全部數組遞歸分割完畢返回(返回的條件是子數組只有一個元素)之後,數組其實已經變得有序了:
在這裏插入圖片描述
  是不是和歸併排序很相似?其實理解了它的思想特別是遞歸分割的思想,快速排序還是比較簡單的!

2.7.2.3 小數組排序

  從快速排序的算法可以看出來,數組最終會被分割爲一個元素長度。如果數組非常小,其實快速排序反而不如直接插入排序來得更好(直接插入是簡單排序中性能最好的)。其原因在於快速排序用到了遞歸操作,在大量數據排序時,這點性能影響相對於它的整體算法優勢而言是可以忽略的,但如果數組只有比較少的記錄需要排序時,遞歸就不合適了
  因此可以加一個判斷,當數組長度小於多小時就使用插入排序而不是快速排序了。這個值具體是多少,並沒有特殊規定,在《數據結構與算法 Java語言描述》一書中該值建議取10,而在JDK1.8的Arrays.sort()方法中,該值爲47。

2.7.3 單軸快排的實現案例

2.7.3.1 普通實現

  下面提供單軸快排的普通實現,即只使用快速排序:

public class QuickSort {
    public static void main(String[] args) {
        //int[] arr = new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8,9};
        int[] arr = new int[]{5, 4, 3, 2, 1, 0, -1, -2, -3, -4};
        //int[] arr = new int[]{49, 38, 65, 97, 64, 49, 27, 49, 78};
        //int[] arr = new int[]{49, 38, 49, 97, 49, 49, 49, 49, 49, 49, 38, 49, 97, 49, 49, 49, 49, 49};
        //int[] arr = new int[]{49, 38, 65, 97, 65, 13, 27, 49, 78};
        //int[] arr = new int[]{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
        //int[] arr = new int[]{1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1,1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1};
        //int[] arr = new int[]{49, 65, 38, 97, 49, 78, 27, 11, 49, 49, 65, 38, 97, 49, 78, 27, 11, 49};
        quickSort(arr);
        System.out.println(Arrays.toString(arr));
    }


    /**
     * 快速排序
     *
     * @param arr 要排序的數組
     */
    private static void quickSort(int[] arr) {

        if (arr == null || arr.length <= 1) {
            return;
        }
        quickSort(arr, 0, arr.length - 1);
    }


    /**
     * 快排核心算法,遞歸實現
     *
     * @param arr   要排序的數組
     * @param left  起始索引
     * @param right 結束索引
     */
    private static void quickSort(int[] arr, int left, int right) {
        /*長度大於4則走快速排序,否則走插入排序*/
        if (left < right) {
            /*1、分割 分割完成了將返回基準值的正確索引位置*/
            int baseIndex = partition(arr, left, right);
            /*2、遞歸對分割後的兩個子數組繼續執行排序,由於還是上面基準值的爲止已經確定了,因此可以排除基準值索引*/
            quickSort(arr, left, baseIndex - 1);
            quickSort(arr, baseIndex + 1, right);
        }
    }

    /**
     * 分割數組  找一個基準值,將數組分成兩部分,一部分比基準值小,另一部分比基準值大
     *
     * @param arr   需要分割的數組
     * @param left  左起始索引
     * @param right 右結束索引
     * @return 基準值在整個數組中的正確索引
     */
    private static int partition(int[] arr, int left, int right) {
        //基準值,這裏取最左邊的值,這是不合理的
        int base = arr[left];
        /*2、開始分割*/
        //記錄前後索引初始值,後面會用到
        int i = left, j = right;
        while (true) {
            /*先從左向右找,然後從右向左找,順序不能亂,如果亂了那麼需要改變代碼*/
            //先從左往右邊找,直到找到大於等於base值的數
            //i一定小於等於j,等於j時說明left所在的數base就是最大數
            while (arr[++i] < base && i < j) {
            }
            //後從右邊往左找,直到找到小於等於base值的數
            //j可能小於等於i
            //j也可能等於left,等於left時說明left所在的數base就是最小的數
            while (arr[j] > base) {
                --j;
            }
            //上面的循環結束表示找到了位置(i<j)或者(i>=j)了
            //如果是找到了位置,那麼交換兩個數在數組中的位置
            if (i < j) {
                swap(arr, i, j);
            } else {
                break;
            }
            //如果還要繼續分割,那麼j--,該操作可以讓與base相同的j繼續向中間靠攏而不是停在原地
            --j;
        }
        /*3、尋找基準值的在數組的正確位置,位置應該在j的值*/
        arr[left] = arr[j];
        arr[j] = base;
        /*4、返回基準值的正確索引*/
        return j;
    }

    /**
     * 交換元素
     *
     * @param arr 數組
     * @param a   元素的下標
     * @param b   元素的下標
     */
    private static void swap(int[] arr, int a, int b) {
        arr[a] = arr[a] ^ arr[b];
        arr[b] = arr[a] ^ arr[b];
        arr[a] = arr[a] ^ arr[b];
    }
}

2.7.3.2 優化實現

  採用三數取中法獲取基準值,當數組元素個數少於某個值時採用插入排序。

public class QuickSort3 {
    public static void main(String[] args) {
        //int[] arr = new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8};
        //int[] arr = new int[]{5, 4, 3, 2, 1, 0, -1, -2, -3};
        int[] arr = new int[]{38, 49, 65, 97, 49, 64, 27, 49, 78};
        //int[] arr = new int[]{49, 38, 49, 97, 49, 49, 49, 49, 49, 49, 38, 49, 97, 49, 49, 49, 49, 49};
        //int[] arr = new int[]{49, 38, 65, 97, 65, 13, 27, 49, 78};
        //int[] arr = new int[]{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
        //int[] arr = new int[]{1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1,1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1};
        //int[] arr = new int[]{49, 65, 38, 97, 49, 78, 27, 11, 49, 49, 65, 38, 97, 49, 78, 27, 11, 49};
        quickSort(arr);
        System.out.println(Arrays.toString(arr));
    }


    /**
     * 快速排序
     *
     * @param arr 要排序的數組
     */
    private static void quickSort(int[] arr) {

        if (arr == null || arr.length <= 1) {
            return;
        }
        quickSort(arr, 0, arr.length - 1);
    }

    /**
     * 當數組長度小於等於4時,採用直接插入排序(這裏爲了演示,值取得比較小,實際值可以更大一些)
     */
    private static final int INSERTION_SORT_THRESHOLD = 4;

    /**
     * 快排核心算法,遞歸實現
     *
     * @param arr   要排序的數組
     * @param left  起始索引
     * @param right 結束索引
     */
    private static void quickSort(int[] arr, int left, int right) {
        /*長度大於4則走快速排序,否則走插入排序*/
        if (right - left + 1 > INSERTION_SORT_THRESHOLD) {
            /*1、分割 分割完成了將返回基準值的正確索引位置*/
            int baseIndex = partition(arr, left, right);
            /*2、遞歸對分割後的兩個子數組繼續執行排序,由於還是上面基準值的爲止已經確定了,因此可以排除基準值索引*/
            quickSort(arr, left, baseIndex - 1);
            quickSort(arr, baseIndex + 1, right);
        } else {
            insertionSort(arr, left, right);
        }
    }

    /**
     * 分割數組  找一個基準值,將數組分成兩部分,一部分比基準值小,另一部分比基準值大
     *
     * @param arr   需要分割的數組
     * @param left  左起始索引
     * @param right 右結束索引
     * @return 基準值在整個數組中的正確索引
     */
    private static int partition(int[] arr, int left, int right) {
        /*1、採用三數取中法選取基準值*/
        int base = median3(arr, left, right);
        /*2、開始分割*/
        //記錄前後索引初始值,後面會用到
        int i = left, j = right;
        while (true) {
            /*先從左向右找,然後從右向左找,順序不能亂,如果亂了那麼需要改變代碼*/
            //從左往右邊找,直到找到大於等於base值的數
            //i一定小於等於j,等於j時說明left所在的數base就是最大數
            while (arr[++i] < base && i < j) {
            }
            //後從右邊往左找,直到找到小於等於base值的數
            //j可能小於等於i
            //j也可能等於left,等於left時說明left所在的數base就是最小的數
            while (arr[j] > base) {
                --j;
            }
            //上面的循環結束表示找到了位置(i<j)或者(i=j)了
            //如果是找到了位置,那麼交換兩個數在數組中的位置
            if (i < j) {
                swap(arr, i, j);
            } else {
                break;
            }
            //如果還要繼續分割,那麼j--,該操作可以讓與base相同的j繼續向中間靠攏而不是停在原地
            --j;
        }
        /*3、尋找基準值的在數組的正確位置*/
        arr[left] = arr[j];
        arr[j] = base;
        /*4、返回基準值的正確索引*/
        return j;
    }

    /**
     * 數組小於等於4時,採用插入排序
     *
     * @param a     數組
     * @param left  元素起始索引
     * @param right 元素結束索引
     */
    private static void insertionSort(int[] a, int left, int right) {
        int j;
        for (int p = left + 1; p <= right; p++) {
            int noSort = a[p];
            for (j = p - 1; j >= left && noSort < a[j]; j--) {
                a[j + 1] = a[j];
            }
            a[j + 1] = noSort;
        }
    }

    /**
     * 三數取中法選取基準值,相比於每次選取某一個固定位置的值更加的容易取到較好的基準值
     *
     * @param arr   數組
     * @param left  左邊索引
     * @param right 右邊索引
     * @return 三數取中法選取的基準值
     */
    private static int median3(int[] arr, int left, int right) {
        // 計算數組中間的元素的下標
        int center = (left + right) / 2;
        // 交換左端與右端數據,保證left小於等於right
        if (arr[right] < arr[left]) {
            swap(arr, left, right);
        }
        // 交換中間與右端數據,保證中間小於等於right
        if (arr[right] < arr[center]) {
            swap(arr, center, right);
        }
        // 交換中間與左端數據,保證中間小於等於left
        if (arr[left] < arr[center]) {
            swap(arr, left, center);
        }
        //經過交換,此時left爲返回的最終的基準值,即三數的中間值,並且一定能有如下規則center<=left<=right
        // 爲此我們可以進一步交換,讓數組儘量變得"更加有序",注意該步驟是可以省略的
        swap(arr, center, left + 1);
        return arr[left];
    }

    /**
     * 交換元素,這裏一定要判斷下表是否不等,否則如果下標相等則^會返回0
     *
     * @param arr 數組
     * @param a   元素的下標
     * @param b   元素的下標
     */
    private static void swap(int[] arr, int a, int b) {
        if (a != b) {
            arr[a] = arr[a] ^ arr[b];
            arr[b] = arr[a] ^ arr[b];
            arr[a] = arr[a] ^ arr[b];
        }
    }
}

2.7.4 快速排序的複雜度分析

  分割子序列時需要選擇基準值,如果每次選擇的基準值都能使得兩個子序列的長度爲原本的一半,那麼快速排序的運行時間和歸併排序的一樣,都爲O(nlogn)。和歸併排序類似,將序列對半分割logn次之後,子序列裏便只剩下一個數據,這時子序列的排序也就完成了,總排序也就完成了。
  在最壞的情況下,待排序的序列爲正序或者逆序,每次劃分只得到一個比上一次劃分少一個記錄的子序列,注意另一個爲空。如果遞歸樹畫出來,它就是一棵斜樹。此時需要執行n-1次遞歸調用,且第i次劃分需要經過n-i次關鍵字的比較才能找到第i個記錄,也就是基準值的位置,這個操作也就和選擇排序一樣了。因此比較次數爲sigma(i=1, n-1, n-i)=(n-1)+(n-2)+…+1=n(n-1)/2,最終其時間複雜度爲O(n²)。
  如果數據中的每個數字被選爲基準值的概率都相等,那麼需要的平均運行時間爲O(nlogn)。但是很難做到真隨機的選取,即真隨機數的生成比較困難,而且會耗費更多時間。
  快速排序使用到了遞歸,參考時間複雜度的最好、最壞的情況,空間複雜度範圍是O(logn)~O(n)

2.8 總結

  7種算法的各種指標進行對比如下:
在這裏插入圖片描述
  從算法的簡單性來看,我們將7種算法分爲兩類:

  1. 簡單算法:冒泡、簡單選擇、直接插入。
  2. 改進算法:希爾、堆、歸併、快速。

  從平均情況來看,顯然最後3種改進算法要勝過希爾排序,並遠遠勝過前3種簡單算法。
  從最好情況看,反而冒泡和直接插入排序要更勝一籌,也就是說,如果你的待排序序列總是基本有序,反而不應該考慮4種複雜的改進算法。
  從最壞情況看,堆排序與歸併排序又強過快速排序以及其他簡單排序。
  從待排序記錄的個數上來說,待排序的個數n越小,採用簡單排序方法越合適。反之,n越大,採用改進排序方法越合適。這也就是我們爲什麼對快速排序優化時,增加了一個閥值,低於閥值時換作直接插入排序的原因。
  3種簡單排序算法的移動次數比較圖如下:
在這裏插入圖片描述
  如果移動數據比較耗時的話,那麼簡單選擇排序不失爲一種好的選擇!

3 非比較排序

3.1 計數排序(Counting Sort)

3.1.1 計數排序的原理和實現

  計數排序(Counting sort)是一種穩定的線性時間排序算法。計數排序要求輸入的數據必須是有確定範圍的整數。排序前和排序後相同的數字相對位置保持不變。
  計數排序僅適用於最大最小值相差差不是很大的情況。
  計數排序原理如下:

  1. 準備待排序的數組中最大-max和最小-min的元素,準備一個長度爲(max-min+1)輔助計數數組help。
  2. 分配:統計原數組中值爲arr[i]的元素出現的次數,存入輔助計數數組對應arr[i]-min的索引位置處。
  3. 收集: 收集:根據輔助數組的反向從index=0開始填充目標數組。循環判斷輔助數組索引爲i的元素值help[i]是否>0,如果是,則原數組arr[index++]=i + min
public class CountingSort {
    public static void main(String[] args) {
        //要求數組元素爲整數
        //int[] arr = new int[]{3, 3, 5, 7, 2, 4, 10, 1, 13, 15, 3, 5, 6};
        int[] arr = new int[]{49, 38, 65, 97, 65, 13, 27, 49, 78};
        //int[] arr = new int[]{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
        //int[] arr = new int[]{5, 4, 3, 2, 1, 0, -1, -2, -3};
        //int[] arr = new int[]{1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1};
        /*1、獲取最大值和最小值  這一步應該是預先知道的*/
        int max = Arrays.stream(arr).max().getAsInt();
        int min = Arrays.stream(arr).min().getAsInt();
        /*2、準備輔助計數數組 大小爲max-min+1*/
        int[] help = new int[max - min + 1];
        /*2、分配:輔助計數數組填充*/
        //找出每個元素value出現的次數,找到一次就讓輔助數組的value-min索引處的值自增1
        for (int value : arr) {
            help[value - min]++;
        }
        System.out.println("help填充後" + Arrays.toString(help));
        /*3、收集:根據輔助數組從index=0開始填充目標數組
        循環判斷輔助數組索引爲i的元素值help[i]--是否>0,如果是,則原數組arr[index++]=i + min */
        int index = 0;
        for (int i = 0; i < help.length; i++) {
            while (help[i]-- > 0) {
                arr[index++] = i + min;
            }
        }
        System.out.println("arr排序後" + Arrays.toString(arr));
        System.out.println("help使用後" + Arrays.toString(help));
    }
}

  從實現可以看出來,計數排序僅適用於最大最小值相差差不是很大的情況,如果相差不大,就算待排序的元素再多,輔助數組也比較短。否則,輔助數組將會變得很長,造成空間複雜度提高。
  經計數排序,輸出序列中值相同的元素之間的相對次序與他們在輸入序列中的相對次序相同,換句話說,計數排序算法是一個穩定的排序算法。但是上面的實現並不是穩定算法的實現,上面的實現只是針對基本類型而言的,因爲對於基本類型兩個數都是一致的,這裏提供穩定版本的實現,但是要求所有元素大於等於0:

public class CountingSortStable {
    public static void main(String[] args) {
        //計數排序穩定版本要求數組元素爲整數  且必須大於等於0
        //int[] arr = new int[]{3, 3, 5, 7, 2, 4, 10, 1, 13, 15, 3, 5, 6};
        //int[] arr = new int[]{49, 38, 65, 97, 65, 13, 27, 49, 78};
        //int[] arr = new int[]{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
        //int[] arr = new int[]{5, 4, 3, 2, 1, 0, -1, -2, -3}; 元素小於0報錯
        int[] arr = new int[]{1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1};
        /*1、獲取最大值和最小值  這一步應該是預先知道的*/
        int max = Arrays.stream(arr).max().getAsInt();
        int min = Arrays.stream(arr).min().getAsInt();
        /*2、準備輔助計數數組 大小爲max-min+1*/
        int[] help = new int[max+1];
        //找出每個元素value出現的次數,找到一次就讓輔助數組的value-min索引處的值自增1
        for (int value : arr) {
            help[value]++;
        }
        // 計算數組中小於等於每個元素的個數,即從tmp中的第一個元素開始,每一項和前一項相加
        for (int j = 1; j < help.length; j++) {
            help[j] += help[j - 1];
        }

        /*倒序遍歷很重要,這一步保證了計數排序的穩定性*/
        // result數組用來臨時存放排序結果
        int[] result = new int[arr.length];
        for (int i = arr.length - 1; i >= 0; i--) {
            result[help[arr[i]] - 1] = arr[i];
            //桶數值自減1 因爲下一個相同的數來找索引需要放在上一個數的前面,從而保證穩定性;
            help[arr[i]]--;
        }
        //將臨時數組的數據取出來,賦值給arr
        for (int i = 0, j = 0; i < arr.length; i++, j++) {
            arr[i] = result[j];
        }
        System.out.println("arr排序後" + Arrays.toString(arr));

    }
}

  另外,計數排序本質上是一種特殊的桶排序,當桶的個數最大(max-min+1)的時候,就是計數排序。這裏的桶,就是輔助數組的一個位置。下面來看桶排序。

3.1.2. 計數排序的複雜度分析

  計數排序算法沒有用到元素間的比較,它利用元素的實際值來確定它們在輸出數組中的位置。因此,計數排序算法不是一個基於比較的排序算法,從而它的計算時間下界不再是O(nlogn)。
  非穩定版本的複雜度如下:

平均時間複雜度 O(n + (max - min))
最佳時間複雜度 O(n + (max - min))
最差時間複雜度 O(n + (max - min))
空間複雜度 O(max - min)

  Max表示最大值,min表示最小值,如果max-min=O(n),那麼時間複雜度就是O(n)。
  由於計數排序需要額外的(max – min) + 1 長度的輔助數組,沒有遞歸,因此空間複雜度爲O(max - min),同樣如果max-min=O(n),那麼空間複雜度也是O(n)。
  穩定版本的複雜度如下:
  平均、最佳、最差時間複雜度均爲O(n+k),k爲待排序列最大值,它需要兩個輔助數組,空間複雜度爲O(n+k)。

3.2 桶排序(Bucket Sort)

3.2.1 桶排序的原理和實現

  桶排序(Bucket sort)又被稱爲箱排序。前面講的計數排序,最大值和最小值相差多少就準備多少個桶,試想如果最大值和最小值相差過大的話,會造成桶的數量過多,空間複雜度大幅度提升。計數排序不再適用,此時可以採用桶排序。
  實際上這種情況下,一個桶裏並非總放一個元素,很多時候一個桶裏放多個元素。其實真正的桶排序和散列表有一樣的原理。同理,桶排序需要事先知道元素範圍,即最小值和最大值。
  桶排序可用於最大最小值相差較大的數據情況,但桶排序要求數據的分佈必須均勻,否則可能導致數據都集中到一個桶中,導致桶排序失效。
  穩定性:桶排序是否穩定取決於每個桶採用的排序算法,因爲桶排序可以做到穩定,所以桶排序是穩定的排序算法。
  桶排序詳細過程如下:

  1. 準備待排序的數組中最大-max和最小-min的元素,準備一個桶容器,每個桶裏放的元素用list存儲,因爲每個桶存放元素的數量是不固定的。桶的數量爲k=(max-min)/arr.length+1。
  2. 遍歷數組 arr,按一定的映射規則(這類似於哈希表)計算每個元素放的桶位置,並將待排序元素劃分到不同的桶。
  3. 每個桶各自排序,一般採用快速排序。
  4. 順序遍歷桶集合,把排序好的元素回寫進原數組。
public class BucketSort {
    public static void main(String[] args) {
        //要求數組元素爲整數
        //int[] arr = new int[]{3, 3, 5, 7, 2, 4, 10, 1, 13, 15, 3, 5, 6};
        //int[] arr = new int[]{1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15};
        int[] arr = new int[]{49, 38, 65, 97, 65, 13, 27, 49, 78, 49, 38, 65, 97, 65, 65, 13, 27, 49, 78, 100};
        //int[] arr = new int[]{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
        //int[] arr = new int[]{5, 4, 3, 2, 1, 0, -1, -2, -3};
        //int[] arr = new int[]{1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1};


        /*1、獲取最大值和最小值  這一步應該是預先知道的*/
        int max = Arrays.stream(arr).max().getAsInt();
        int min = Arrays.stream(arr).min().getAsInt();


        /*2、準備桶容器*/
        //桶數量計算公式
        int bucketsLength = getBucketsLength(arr, max, min);
        List<Integer>[] buckets = new List[bucketsLength];
        for (int i = 0; i < bucketsLength; i++) {
            buckets[i] = new ArrayList<>();
        }


        /*3、計算arr每個元素應該存放的桶位置,並將每個元素放入對應的桶*/
        //每個桶的範圍大小計算公式
        int bucketRange = getBucketRange(max, min, bucketsLength);
        //將每個元素放入桶
        for (int value : arr) {
            //每個元素映射到某個桶索引的函數f(key)
            int bucketIndex = getBucketIndex(min, bucketRange, value);
            buckets[bucketIndex].add(value);
        }
        System.out.println("buckets填充後:" + Arrays.toString(buckets));


        /*4、對每個桶進行排序*/
        for (List<Integer> list : buckets) {
            //這裏由於底層是Object,實際上sort對每個桶的元素採用ComparableTimSort排序,這是一種基於歸併排序而又優於歸併排序的新的排序手段
            Collections.sort(list);
        }
        System.out.println("buckets排序後:" + Arrays.toString(buckets));


        /*5、將桶裏面不爲null的元素順序回寫到原數組,即完成排序*/
        int index = 0;
        for (List<Integer> bucket : buckets) {
            for (Integer integer : bucket) {
                arr[index++] = integer;
            }
        }
        System.out.println("arr排序後:" + Arrays.toString(arr));
    }

    /**
     * 獲取桶數量
     *
     * @param arr 需要排序的數組
     * @param max 數組最大值
     * @param min 數組最小值
     * @return 桶數量
     */
    private static int getBucketsLength(int[] arr, int max, int min) {
        return (max - min) / arr.length + 1;
    }

    /**
     * 獲取每個桶範圍
     *
     * @param max           數組最大值
     * @param min           數組最小值
     * @param bucketsLength 桶數量
     * @return 桶範圍 該範圍是值直接相減的範圍即8-1=7
     */
    private static int getBucketRange(int max, int min, int bucketsLength) {
        return (max - min + 1) / bucketsLength;
    }

    /**
     * 獲取元素對應桶索引
     *
     * @param min         數組最小值
     * @param bucketRange 桶範圍
     * @param value       元素值
     * @return 桶索引
     */
    private static int getBucketIndex(int min, int bucketRange, int value) {
        return (value - min) / (bucketRange + 1);
    }
}

3.2.2 桶排序的複雜度分析

  假設數據是均勻分佈的,則每個桶的元素平均個數爲 n/k,k表示桶的個數。假設選擇用快速排序對每個桶內的元素進行排序,那麼每次排序的時間複雜度爲 O(n/klog(n/k))。總的時間複雜度爲O(n)+O(m)O(n/klog(n/k)) = O(n+nlog(n/k)) = O(n+nlogn-nlogk) 。當 k 接近於 n 時,桶排序的時間複雜度就可以認爲是 O(n) 的。即桶越多,時間效率就越高,而桶越多,空間複雜度就越大。桶數量達到(max-min+1)時,便成爲了計數排序。
  原始數組的元素也會影響桶中的元素是否均勻分佈,如果原始的元素就是分不極度不均勻的那麼就不適宜採用桶排序。從代碼實現可以看出來,桶的數量和映射函數f(key)的選取也會影響元素是否均勻分佈分佈,爲了使桶排序更加高效,我們需要做到這兩點:

  1. 在空間充足的情況下,儘量增大桶的數量;
  2. 映射函數f(key)需要能夠將輸入的 N 個數據儘量均勻的分配到 K 個桶中。

3.3 基數排序(Radix Sort)

3.3.1 基數排序的實現

  基數排序是一種對於非負整數的非比較排序方法,同樣必須知道最大值。基數排序不是直接根據元素整體的大小進行元素比較,而是將原始列表元素分成多個部分,對每一部分按一定的規則進行排序,進而形成最終的有序列表。即基數排序必須依賴於另外的排序方法。實際上基數排序就是一種多關鍵字排序。注意基數排序的元素必須是非負整數。
  基數排序也是一種桶排序。桶排序是按值區間劃分桶,基數排序是按數位來劃分;基數排序可以看做是多輪桶排序,每個數位上都進行一輪桶排序。
  基數排序法是一種穩定的排序算法。
  具體思路如下

  1. 將所有待排序非負整數統一爲位數相同的整數,位數較少的前面補零。一般用10進制,也可以用16進制甚至2進制,所有的前提是能夠找到最大值,得到最長的位數,設k進制下最長爲位數爲d。實際上相同位數上的值就是要比較的多個鍵。
  2. 對相同的位數上的數值按照大小進行穩定排序(因爲穩定排序能夠將上一次排序的成果保留下來。例如十位數的排序過程能保留個位數的排序成果,百位數的排序過程能保留十位數的排序成果。)。基數排序的方式可以採用LSD(Least significant digital)或MSD(Most significant digital),LSD的排序方式由鍵值的最右邊開始,而MSD則相反,由鍵值的最左邊開始。
    a) MSD:先從高位開始進行排序,在每個關鍵字上,可採用計數排序。
    b) LSD:先從低位開始進行排序,在每個關鍵字上,可採用桶排序。

  如下圖演示了先補齊長度然後從最低位開始進行的排序。當從最低位一直到最高位排序完成以後,整個序列就變成了一個有序序列:
在這裏插入圖片描述
  LSD的代碼實現如下:

public class RadixSort {
    public static void main(String[] args) {
        //要求數組元素爲非負整數
        //int[] arr = new int[]{12, 3, 55, 78, 102, 0, 88, 61, 30, 12, 3, 55, 78, 102, 0, 88, 61, 30};
        //int[] arr = new int[]{1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15};
        //int[] arr = new int[]{11, 12, 13, 14, 15, 16, 17, 18, 20, 21, 22, 23, 24, 25};
        //int[] arr = new int[]{49, 38, 65, 97, 65, 13, 27, 49, 78, 49, 38, 65, 97, 65, 65, 13, 27, 49, 78, 100};
        //int[] arr = new int[]{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1};
        //int[] arr = new int[]{5, 4, 3, 2, 1, 0, -1, -2, -3};  小於0報異常
        int[] arr = new int[]{1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1};

        /*獲取最大元素的有幾位*/
        int max = Arrays.stream(arr).max().getAsInt();
        int num = 0;
        while (max != 0) {
            max = max / 10;
            num++;
        }

        //輔助計數數組 桶
        int[] help = new int[10];

        //臨時數組,放數據,取數據
        int[] bucket = new int[arr.length];

        //k表示第幾位,1代表個位,2代表十位,3代表百位
        for (int k = 1; k <= num; k++) {
            //把count置空,防止上次循環的數據影響
            for (int i = 0; i < 10; i++) {
                help[i] = 0;
            }

            //分別統計第k位是0,1,2,3,4,5,6,7,8,9的數量,統計每個桶中的數據的數量
            /*遍歷數組將每個數(對應位的數字)放進與之對應的桶裏 並且計算桶裏數的個數。*/
            for (int value : arr) {
                help[getFigure(value, k)]++;
            }

            /*將桶裏數的個數變爲前面桶的個數加上自己桶裏的個數 (bucket[i] += bucket[i-1])
            實際上最後的桶裏面的最大值就是元素個數,
            這一步完成之後 我們就可以知道help[j]對應的位數元素索引位置就是在(help[j-1])~(help[j]-1)之間
            這一步完成之後 我們就可以知道arr[i]的值的對應位數j排序後的位置索引就是help[j]-1、help[j]-1-1……。*/
            for (int i = 1; i < 10; i++) {
                help[i] += help[i - 1];
            }

            /*利用循環把數據裝入臨時數組中,注意是從後面遍歷,因爲是從後面的索引開始裝入的
            因爲如果從前遍歷該數組 當碰到相同的數時 相同數的相對位置就變了 原來在後面的數就會排到前面去 這個排序就不穩定了,並且不會按照順序排序*/
            for (int i = arr.length - 1; i >= 0; i--) {
                //獲取arr[i]對應位數的值
                int j = getFigure(arr[i], k);
                //arr[i]的排序後的索引就是help[j]-1
                bucket[help[j] - 1] = arr[i];
                //桶數值自減1 因爲下一個相同的數來找索引需要放在上一個數的前面;
                help[j]--;
            }

            //將桶中的數據取出來,賦值給arr
            for (int i = 0, j = 0; i < arr.length; i++, j++) {
                arr[i] = bucket[j];
            }

        }

        System.out.println("arr排序後" + Arrays.toString(arr));

    }

    /**
     * 獲取整型數i的第k位是什麼
     *
     * @param i 整型數i
     * @param k 第k位
     * @return 第k位的值
     */
    private static int getFigure(int i, int k) {
        int[] a = {1, 10, 100};
        return (i / a[k - 1]) % 10;
    }
}

3.3.2. 基數排序的複雜度分析

  設待排序元素個數爲n,最大的數是d位數,基數爲k(如基數爲10,即10進制,最大有10種可能,即最多需要10個桶來映射數組元素)。
  處理器中一個位數時,需要遍歷原數組和計數器數組,時間複雜度爲O(n+k),總時間複雜度爲O(d*(n+k))。
  基數排序過程中,用到一個計數器數組,長度爲r,還用到一個長爲n第的臨時數組來存放元素,所以空間複雜度爲O(k+n)。
  基數排序基於分別排序,分別收集,所以是穩定的。
  當使用2進制時, k=2 最小,那麼位數 d 最大,時間複雜度 O((d*(n+k)) 會變大,空間複雜度 O(n+k) 會變小。

3.4 總結

  基數排序與計數排序、桶排序這三種排序算法都利用了桶的概念,但對桶的使用方法上有明顯差異:

  1. 計數排序:每個桶只存儲單一鍵值;
  2. 桶排序:每個桶存儲一定範圍的數值;
  3. 基數排序:根據鍵值的每一位的數字來分配桶;

參考
  《算法》
  《數據結構與算法》
  《大話數據結構》
  《算法圖解》
  《我的第一本算法書》

如果有什麼不懂或者需要交流,可以留言。另外希望點贊、收藏、關注,我將不間斷更新各種Java學習博客!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章