更新!萬字長文帶你拿下九大排序的原理、Java 實現以及算法分析

 

0. 前言

你們好,我是多選參數的程序鍋,一個正在搗鼓操做系統、學數據結構和算法以及 Java 的失業人員。數據結構和算法我已經學了有一段日子了,最近也開始在刷 LeetCode 上面的題目了,可是本身感受在算法上仍是 0 ,還得猛補啊。git

今天這篇基於以前的 8 大排序算法基礎之上,增長一個堆排序,也就是這麼 9 個排序算法:冒泡排序、插入排序、選擇排序、歸併排序、快速排序、堆排序、桶排序、計數排序、基數排序。它們對應的時間複雜度以下所示:github

排序算法 時間複雜度 是否基於比較
冒泡、插入、選擇 O(n^2)
快排、歸併、堆排序 O(nlogn)
桶、計數、基數 O(n) ×

整篇文章的主要知識提綱如圖所示,本篇相關的代碼均可以從 https://github.com/DawnGuoDev/algorithm 獲取,另外,該倉庫除了包含了基礎的數據結構和算法實現以外,還會有數據結構和算法的筆記、LeetCode 刷題記錄(多種解法、Java 實現) 、一些優質書籍整理。算法

本文的圖不少都是從極客時間王爭老師專欄那邊拷貝過來或者截圖過來的,少部分圖是本身從新畫的。爲何不全都換成本身畫的圖?主要是我比較懶,我以爲圖能將本身要闡述的點解釋清楚,或者說和本身整理事後的文字結合的不錯,我以爲這個圖就不必從新畫了,人家的畫已經很好看了,也很清晰了,你將它從新畫,其實也是差很少,可能就是換個樣式而已,核心的東西仍是沒有變。可是,在有些地方,我以爲別人的圖跟我闡述的內容不符合,或者不能很好地闡述我想表達的東西,又或者這個地方須要一個圖,那麼我會畫一個。數據庫

1. 排序算法分析

學習排序算法除了學習它的算法原理、代碼實現以外,最重要的是學會如何評價、分析一個排序算法。分析一個排序算法一般從如下幾點出發。編程

1.1. 執行效率

而對執行效率的分析,通常從這幾個方面來衡量:api

  • 最好狀況、最壞狀況、平均狀況數組

    除了須要給出這三種狀況下的時間複雜度還要給出對應的要排序的原始數據是怎麼樣的。緩存

  • 時間複雜度的係數、常數、低階微信

    大 O 時間複雜度反應的是算法時間隨 n 的一個增加趨勢,好比 O(n^2) 表示算法時間隨 n 的增長,呈現的是平方的增加趨勢。這種狀況下每每會忽略掉係數、常數、低階等。可是實際開發過程當中,排序的數據每每是 10 個、100 個、1000 個這樣規模很小的數據,因此在比較同階複雜度的排序算法時,這些係數、常數、低階不能省略。數據結構

  • 比較次數和交換(或移動)次數

    在基於比較的算法中,會涉及到元素比較和元素交換等操做。因此分析的時候,還須要對比較次數和交換次數進行分析。

1.2. 內存消耗

內存消耗其實就是空間複雜度。針對排序算法來講,若是該排序算法的空間複雜度爲 O(1),那麼這個排序算法又稱爲原地排序。

1.3. 穩定性

是什麼

穩定性是指待排序的序列中存在值相等的元素。在排序以後,相等元素的先後順序跟排序以前的是同樣的。

爲何

咱們將排序的原理和實現排序時用的大部分都是整數,可是實際開發過程當中要排序的每每是一組對象,而咱們只是按照對象中的某個 key 來進行排序。

好比一個對象有兩個屬性,下單時間和訂單金額。在存入到數據庫的時候,這些對象已經按照時間前後的順序存入了。可是咱們如今要以訂單金額爲主要 key,在訂單金額相同的時候,如下單時間爲 key。那麼在採用穩定的算法以後,只須要按照訂單金額進行一次排序便可。好比有這麼三個數據,第一個數據是下單時間、第二數據是訂單金額:(2020051五、20)、(2020051六、10)、(2020051七、30)、(2020051八、20)。在採用穩定的算法以後,排序的狀況以下:(2020051六、10)、(2020051五、20)、(2020051八、20)、(2020051七、30)能夠發如今訂單金額相同的狀況下是按訂單時間進行排序的。

2. 經典的經常使用排序算法

2.1. 冒泡排序

冒泡排序就是依次對兩個相鄰的元素進行比較,而後在不知足大小條件的狀況下進行元素交換。一趟冒泡排序下來至少會讓一個元素排好序(元素排序好的區域至關於有序區,所以冒泡排序中至關於待排序數組分紅了兩個已排序區間和未排序區間)。所以爲了將 n 個元素排好序,須要 n-1 趟冒泡排序(第 n 趟的時候就不須要)。

下面用冒泡排序對這麼一組數據四、五、六、三、二、1,從小到大進行排序。第一次排序狀況以下:

能夠看出,通過一次冒泡操做以後,6 這個元素已經存儲在正確的位置上了,要想完成有全部數據的排序,咱們其實只須要 5 次這樣的冒泡排序就好了。圖中給出的是帶第 6 次了的,可是第 6 次其實不必。

2.1.1. 優化

使用冒泡排序的過程當中,若是有一趟冒泡過程當中元素之間沒有發生交換,那麼就說明已經排序好了,能夠直接退出再也不繼續執行後續的冒泡操做了。

2.1.2. 實現

下面的冒泡排序實現是優化以後的:

/** * 冒泡排序: * 以升序爲例,就是比較相鄰兩個數,若是逆序就交換,相似於冒泡; * 一次冒泡肯定一個數的位置,由於要肯定 n-1 個數,所以須要 n-1 * 次冒泡; * 冒泡排序時,其實至關於把整個待排序序列分爲未排序區和已排序區 */
public void bubbleSort(int[] arr, int len) {
    // len-1 趟
    for (int j = 0; j < len-1; j++) {
        int sortedFlag = 0;
        // 一趟冒泡
        for (int i = 0; i < len-1-j; i++) {
            if (arr[i] > arr[i+1]) {
                int temp = arr[i];
                arr[i] = arr[i+1];
                arr[i+1] = temp;
                sortedFlag = 1;
            }
        }

        // 該趟排序中沒有發生,表示已經有序
        if (0 == sortedFlag) {
            break;
        }
    }
}

2.1.3. 算法分析

  • 冒泡排序是原地排序。由於冒泡過程當中只涉及到相鄰數據的交換,至關於只須要開闢一個內存空間用來完成相鄰的數據交換便可。

  • 在元素大小相等的時候,不進行交換,那麼冒泡排序就是穩定的排序算法。

  • 冒泡排序的時間複雜度。

    • 當元素已是排序好了的,那麼最好狀況的時間複雜度是 O(n)。由於只須要跑一趟,而後發現已經排好序了,那麼就能夠退出了。

    • 當元素正好是倒序排列的,那麼須要進行 n-1 趟排序,最壞狀況複雜度爲 O(n^2)。

    • 通常狀況下,平均時間複雜度是 O(n^2)。使用有序度和逆序度的方法來求時間複雜度,冒泡排序過程當中主要是兩個操做:比較和交換。每交換一次,有序度就增長一,所以有序度增長的次數就是交換的次數。又由於有序度須要增長的次數等於逆序度,因此交換的次數其實就等於逆序度

      所以當要對包含 n 個數據的數組進行冒泡排序時。最壞狀況下,有序度爲 0 ,那麼須要進行 n*(n-1)/2 次交換;最好狀況下,不須要進行交換。咱們取中間值 n*(n-1)/4,來表示初始有序度不是很高也不是很低的平均狀況。因爲平均狀況下須要進行 n*(n-1)/4 次交換,比較操做確定比交換操做要多。可是時間複雜度的上限是 O(n^2),因此平均狀況下的時間複雜度就是 O(n^2)。

      這種方法雖然不嚴格,可是很實用。主要是由於機率的定量分析太複雜,不實用。(PS:我就喜歡這種的)

2.2. 插入排序

**插入排序中將數組中的元素分紅兩個區間:已排序區間和未排序區間(最開始的時候已排序區間的元素只有數組的第一個元素),插入排序就是將未排序區間的元素依次插入到已排序區間(須要保持已排序區間的有序)。最終整個數組都是已排序區間,即排序好了。**假設要對 n 個元素進行排序,那麼未排序區間的元素個數爲 n-1,所以須要 n-1 次插入。插入位置的查找能夠從尾到頭遍歷已排序區間也能夠從頭至尾遍歷已排序區間。

如圖所示,假設要對 四、五、六、一、三、2進行排序。左側橙紅色表示的是已排序區間,右側黃色的表示未排序區間。整個插入排序過程以下所示

2.2.1. 優化

  • 採用希爾排序的方式。

  • **使用哨兵機制。**好比要排序的數組是[二、一、三、4],爲了使用哨兵機制,首先須要將數組的第 0 位空出來,而後數組元素全都日後移動一格,變成[0、二、一、三、4]。 那麼數組 0 的位置用來存放要插入的數據,這樣一來,判斷條件就少了一個,不用再判斷 j >= 0 這個條件了, 只須要使用 arr[j] > arr[0] 的條件就能夠了。由於就算遍歷到下標爲 0 的位置,因爲 0 處這個值跟要插入的值是同樣的,因此會退出循環,不會出現越界的問題。

2.2.2. 實現

這邊查找插入位置的方式採用從尾到頭遍歷已排序區間,也沒有使用哨兵。

/** * 插入排序: * 插入排序也至關於把待排序序列分紅已排序區和未排序區; * 每趟排序都將從未排序區選擇一個元素插入到已排序合適的位置; * 假設第一個元素屬於已排序區,那麼還須要插入 len-1 趟; */
public void insertSort(int[] arr, int len) {
    // len-1 趟
    for (int i = 1; i < len; i++) {
        // 一趟排序
        int temp = arr[i];
        int j;
        for (j = i-1; j >= 0; j--) {
            if (arr[j] > temp) {
                arr[j+1] = arr[j];
            } else {
                break;
            }
        }
        arr[j+1] = temp;
    }
}

2.2.3. 算法分析

  • 插入排序是原地算法。由於只須要開闢一個額外的存儲空間來臨時存儲元素。

  • 當比較元素時發現元素相等,那麼插入到相等元素的後面,此時就是穩定排序。也就是說只有當有序區間中的元素大於要插入的元素時才移到到後面的位置,不大於(小於等於)了的話直接插入。

  • 插入排序的時間複雜度。

    • 待排序的數據是有序的狀況下,不須要搬移任何數據。那麼採用從尾到頭在已排序區間中查找插入位置的方式,最好時間複雜度是 O(n)。

    • 待排序的數據是倒序的狀況,須要依次移動 一、二、三、...、n-1 個數據,所以最壞時間複雜度是 O(n^2)。

    • 平均時間複雜度是 O(n^2)。所以將一個數據插入到一個有序數組中的平均時間度是 O(n),那麼須要插入 n-1 個數據,所以平均時間複雜度是 O(n^2)

      最好的狀況是在這個數組中的末尾插入元素的話,不須要移動數組,時間複雜度是 O(1),假如在數組開頭插入數據的話,那麼全部的數據都須要依次日後移動一位,因此時間複雜度是 O(n)。往數組第 k 個位置插入的話,那麼 k~n 這部分的元素都須要日後移動一位。所以此時插入的平均時間複雜度是 O(n)

2.2.4. VS 冒泡排序

冒泡排序和插入排序的時間複雜度都是 O(n^2),都是原地穩定排序。並且冒泡排序無論怎麼優化,元素交換的次數是一個固定值,是原始數據的逆序度。插入排序是一樣的,無論怎麼優化,元素移動的次數也等於原始數據的逆序度。可是,從代碼的實現上來看,冒泡排序的數據交換要比插入排序的數據移動要複雜,冒泡排序須要 3 個賦值操做,而插入排序只須要一個賦值操做。因此,雖然冒泡排序和插入排序在時間複雜度上都是 O(n^2),可是若是但願把性能作到極致,首選插入排序。其實該點分析的主要出發點就是在同階複雜度下,須要考慮係數、常數、低階等。

2.3. 選擇排序

選擇排序也分爲已排序區間和未排序區間(剛開始的已排序區間沒有數據),選擇排序每趟都會從未排序區間中找到最小的值(從小到大排序的話)放到已排序區間的末尾。

2.3.1. 實現

/** * 選擇排序: * 選擇排序將待排序序列分紅未排序區和已排序區; * 第一趟排序的時候整個待排序序列是未排序區; * 每一趟排序其實就是從未排序區選擇一個最值,放到已排序區; * 跑 len-1 趟就好 */
public void switchSort(int[] arr, int len) {
    // len-1 趟,0-i 爲已排序區
    for (int i = 0; i < len-1; i++) {
        int minIndex = i;
        for (int j = i+1; j < len; j++) {
            if (arr[j] < arr[minIndex]) {
                minIndex = j;
            }
        }

        if (minIndex != i) {
            int temp = arr[i];
            arr[i] = arr[minIndex];
            arr[minIndex] = temp;
        }
    }
}

2.3.2. 算法分析

  • 選擇排序是原地排序,由於只須要用來存儲最小值所處位置的額外空間和交換時所需的額外空間。

  • 選擇排序不是一個穩定的算法。由於選擇排序是從未排序區間中找一個最小值,而且和前面的元素交換位置,這會破壞穩定性。好比 一、五、五、2 這樣一組數據中,使用排序算法的話。當找到 2 爲 五、五、2 當前未排序區間最小的元素時,2 會與第一個 5 交換位置,那麼兩個 5 的順序就變了,就破壞了穩定性。

  • 時間複雜度分析。最好、最壞、平均都是 O(n^2),由於不管待排序數組狀況怎麼樣,就算是已經有序了,都是須要依次遍歷完未排序區間,須要比較的次數依次是 n-一、n-2,因此時間複雜度是 O(n^2)。

2.4. 歸併排序(Merge Sort)

**歸併排序的核心思想就是我要對一個數組進行排序:首先將數組分紅先後兩部分,而後對兩部分分別進行排序,排序好以後再將兩部分合在一塊兒,那整個數組就是有序的了。對於分出的兩部分能夠採用相同的方式進行排序。**這個思想就是分治的思想,就是先將大問題分解成小的子問題來解決,子問題解決以後,大問題也就解決了。而對於子問題的求解也是同樣的套路。這個套路有點相似於遞歸的方式,因此分治算法通常使用遞歸來實現。分治是一種解決問題的處理思想,而遞歸是一種實現它的編程方法。

2.4.1. 實現

下面使用遞歸的方式來實現歸併排序。遞歸的遞推公式是:merge_sort(p...r) = merge(merge_sort(p...q), merge_sort(q+1...r)),終止條件是 p>=r,再也不遞歸下去了。整個實現過程是先調用 __mergeSort() 函數將兩部分分別排好序,以後再使用數組合並的方式將兩個排序好的部分進行合併。

/** * 歸併排序 */
public void mergeSort(int[] arr, int len) {
    __mergerSort(arr, 0, len-1);
}

private void __mergerSort(int[] arr, int begin, int end) {
    if (begin == end){
        return;
    }

    __mergerSort(arr, begin, (begin+end)/2);
    __mergerSort(arr, (begin+end)/2 + 1, end);
    merge(arr, begin, end);
    return;
}

private void merge(int[] arr, int begin, int end) {
    int[] copyArr = new int[end-begin+1];
    System.arraycopy(arr, begin, copyArr, 0, end-begin+1);

    int mid = (end - begin + 1)/2;
    int i = 0;  // begin - mid 的指針
    int j =  mid;   // mid - end 的指針
    int count = begin;  // 合併以後數組的指針

    while (i <= mid-1 && j <= end - begin) {
        arr[count++] = copyArr[i] < copyArr[j] ? copyArr[i++] : copyArr[j++];
    }

    while (i <= mid-1) {
        arr[count++] = copyArr[i++];
    }

    while (j <= end - begin) {
        arr[count++] = copyArr[j++];
    }
}

2.4.2. 算法分析

  • 歸併排序能夠是穩定的排序算法,只要確保合併時,若是遇到兩個相等值的,前半部分那個相等的值是在後半部分那個相等的值的前面便可保證是穩定的排序算法。

  • 歸併排序的時間複雜度爲 O(nlogn),不管是最好、最壞仍是平均狀況都同樣。

    歸併的時間複雜度分析則是遞歸代碼的時間複雜度的分析。假設求解問題 a 能夠分爲對 b、c 兩個子問題的求解。那麼問題 a 的時間是 T(a) 、求解 b、c 的時間分別是 T(b) 和 T(c),那麼 T(a) = T(b) +T(c) + K。k 等於將 b、c 兩個子問題的結果合併問題 a 所消耗的時間。

    套用上述的套路,假設對 n 個元素進行歸併排序須要的時間是 T(n),子問題歸併排序的時間是 T(n/2),合併操做的時間複雜度是 O(n)。因此,T(n) =2 * T(n/2) +O(n),T(1) = C。最終獲得:

    T(n)= 2*T(n/2) + n
        = 2*(2*T(n/4)+ n/2)+n = 2^2*T(n/4) + 2*n
        = 2^2*(2*T(n/8)+n/4) + 2*n = 2^3*T(n/8) + 3*n
        = ....
        = 2^k*T(n/2^K) + k*n
        = ....
        = 2^(log_2^n)*T(1) + log_2^n*n

    最終獲得  ,使用大 O 時間複雜表示 T(n)=O(nlogn)。

    歸併排序中,不管待排數列是有序仍是倒序,最終遞歸的層次都是到只有一個數組爲主,因此歸併排序跟待排序列沒有什麼關係,最好、最壞、平均的時間複雜度都是 O(nlogn)。

  • 歸併排序並非原地排序,由於在歸併排序的合併函數中,還須要額外的存儲空間,這個存儲空間是 O(n)。遞歸過程當中,空間複雜度並不能像時間複雜度那樣累加。由於在每次遞歸下去的過程當中,雖然合併操做都會申請額外的內存空間,可是合併以後,這些申請的內存空間就會被釋放掉。所以其實主要考慮最大問題合併時所需的空間複雜度便可,該空間複雜度爲 O(n)。

2.5. 快速排序(Quick Sort)

快速排序利用的也是分治思想,核心思想是從待排數組中選擇一個元素,而後將待排數組劃分紅兩個部分:左邊部分的元素都小於該元素的值,右邊部分的元素都大於該元素的值,中間是該元素的值。而後對左右兩個部分套用相同的處理方法,也就是將左邊部分的元素再劃分紅左右兩部分,右邊部分的元素也再劃分紅左右兩部分。以此類推,當遞歸到只有一個元素的時候,就說明此時數組是有序了的。

2.5.1. 實現

首先要對下標從 begin 到 end 之間的數據進行分區,能夠選擇 begin 到 end 之間的任意一個數據做爲 pivot(分區點),通常是最後一個數據做爲分區點。以後遍歷 begin 到 end 之間的數據,將小於 pivot 的放在左邊,大於的 pivot 的放在右邊,將pivot 放在中間(位置 p)。通過這一操做以後,數組 begin 到 end 之間的數據就被分紅了三個部分:begin 到 p-一、p、p+1 到 end。最後,返回 pivot 的下標。那麼這個過程通常有三種方式:

  • 首先說明這種方法不可取。在不考慮空間消耗的狀況下,分區操做能夠很是簡單。使用兩個臨時數組 X 和 Y,遍歷 begin 到 end 之間的數據,將小於 pivot 的數據都放到數組 X 中,將大於 pivot 的數據都放到數組 Y 中,最後將數組 X 拷貝到原數組中,而後再放入 pivot,最後再放入數組 Y。可是採用這種方式以後,快排就不是原地排序算法了,所以能夠採用如下兩種方法在原數組的基礎之上完成分區操做。

  • 第一種方法仍是使用兩個指針:i 和 j,i 和 j 一開始都放置在 begin 初。以後 j 指針開始遍歷,若是 j 指針所指的元素小於等於 pivot,那麼則將 j 指針的元素放到 i 指針的處,i  指針的元素放置於 j 處,而後 i 後移,j 後移。若是 j 指針所指的元素大於 pivot 那麼 j 後移便可。首先我的以爲其實整個數組被分紅三個區域:0-i-1 的爲小於等於 pivot 的區域,i-j-1 爲大於 pivot 的區域,j 以後的區域是未排序的區域。

  • 第二種方法仍是使用兩個指針:i 和 j,i 從 begin 處開始,j 從 end 處開始。首先 j 從 end 開始往前遍歷,當遇到小於 pivot 的時候停下來,而後此時 i 從 begin 開始日後遍歷,當遇到大於 pivot 的時候停下來,此時交換 i 和 j 處的元素。以後 j 繼續移動,重複上述過程,直至 i >= j。

在返回 pivot 的下標 q 以後,再根據分治的思想,將 begin 到 q-1 之間的數據和下標 q+1 到 end 之間的數據進行遞歸。這邊必定要 q-1 和 q+1 而不能是 q 和 q+1 是由於:考慮數據已經有序的極端狀況,一開始是對 begin 到 end;當分區以後 q 的位置仍是 end 的位置,那麼至關於死循環了。最終,當區間縮小至 1 時,說明全部的數據都有序了。

若是用遞推公式來描述上述的過程的話,遞推公式:quick_sort(begin...end) = quick_sort(begin...q-1) + quick_sort(q+1...end),終止條件是:begin >= end。將這兩個公式轉化爲代碼以後,以下所示:

/** * 快速排序 */
public void quickSort(int[] arr, int len) {
    __quickSort(arr, 0, len-1);
}

// 注意邊界條件
private void __quickSort(int[] arr, int begin, int end) {
    if (begin >= end) {
        return;
    }

    // 必定要是 p-1!
    int p = partition(arr, begin, end); // 先進行大體排序,並獲取區分點
    __quickSort(arr, begin, p-1);
    __quickSort(arr, p+1, end);
}

private int partition(int[] arr, int begin, int end) {
    int pValue = arr[end];

    // 整兩個指針,兩個指針都從頭開始
    // begin --- i-1(含 i-1):小於 pValue 的區
    // i --- j-1(含 j-1):大於 pValue 的區
    // j --- end:未排序區
    int i = begin;
    int j = begin;
    while (j <= end) {
        if (arr[j] <= pValue) {
            int temp = arr[j];
            arr[j] = arr[i];
            arr[i] = temp;
            i++;
            j++;
        } else {
            j++;
        }
    }

    return i-1;
}

2.5.2. 優化

  • 因爲分區點很重要(爲何重要見算法分析),所以能夠想方法尋找一個好的分區點來使得被分區點分開的兩個分區中,數據的數量差很少。下面介紹兩種比較常見的算法:

    • **三數取中法。就是從區間的首、尾、中間分別取出一個數,而後對比大小,取這 3 個數的中間值做爲分區點。**可是,若是排序的數組比較大,那「三數取中」可能不夠了,可能就要「五數取中」或者「十數取中」,也就是間隔某個固定的長度,取數據進行比較,而後選擇中間值最爲分區點。

    • 隨機法。隨機法就是從排序的區間中,隨機選擇一個元素做爲分區點。隨機法不能保證每次分區點都是比較好的,可是從機率的角度來看,也不太可能出現每次分區點都不好的狀況。因此平均狀況下,隨機法取分區點仍是比較好的。

  • 遞歸可能會棧溢出,最好的方式是使用非遞歸的方式;

2.5.3. 算法分析

  • 快排不是一個穩定的排序算法。由於分區的過程涉及到交換操做,本來在前面的元素可能會被交換到後面去。好比 六、八、七、六、三、五、九、4 這個數組中。在通過第一次分區操做以後,兩個 6 的順序就會發生改變。

  • 快排是一種原地的排序算法。

  • 快排的最壞時間複雜度是 O(n^2),最好時間複雜度是O(nlogn),平均時間複雜度是 O(nlogn)。

    快排也是使用遞歸來實現,那麼遞歸代碼的時間複雜度處理方式和前面相似。

    快排的時間複雜度取決於 pivot 的選擇,經過合理地選擇 pivot 來使得算法的時間複雜度儘量不出現 O(n^2) 的狀況。

    • 假設每次分區操做均可以把數組分紅大小接近相等的兩個小區間,那麼快排的時間複雜度和歸併排序同樣,都是 O(nlogn)。

    • 可是分區操做不必定都能把數組分紅大小接近相等的兩個小區間。極端狀況如數組中的數組已經有序了,若是仍是取最後一個元素做爲分割點,左邊區間是 n-1 個數,右邊區間沒有任何數。此時, T(n)=T(n-1)+n,最終時間複雜度退化爲 O(n^2)。大部分狀況下,採用遞歸樹的方法可獲得時間複雜度是 O(nlogn)。因爲極端狀況是少數,所以平均時間複雜度是 O(nlogn)。

2.5.4. VS 歸併排序

首先從思想上來看:歸併排序的處理過程是由下到上的,先處理子問題,而後對子問題的解再合併;而快排正好相反,處理過程是由上到下的,先分區,再處理子問題。

從性能上來看:歸併是一個穩定的、時間複雜度爲 O(nlogn) 的排序算法,可是歸併並非一個原地排序算法(因此歸併無快排應用普遍)。而快速排序算法時間複雜度不必定是 O(nlogn),最壞狀況下是 O(n^2),並且不是一個穩定的算法,可是經過設計可讓快速排序成爲一個原地排序算法。

2.6. 堆排序

堆是一種特殊的樹。只要知足如下兩個條件就是一個堆。

  • 堆是一個徹底二叉樹。既然是徹底二叉樹,那麼使用數組存儲的方式將會很方便。

  • 堆中的每一個節點的值都必須大於等於(或小於等於)其子節點的值。對於大於等於子節點的堆又被稱爲「大頂堆」;小於等於子節點的堆又被稱爲「小頂堆」。

因爲」堆是一個徹底二叉樹「,所以通常堆使用數組來存儲,一是節省空間,二是經過數組的下標就能夠找到父節點、左右子節點(數組下標最好從 1 開始,該節的代碼實現都將從數組下標爲 1 的地方開始)。那麼,藉助堆這種數據結構實現的排序被稱爲堆排序。堆排序是一種原地的、時間複雜度爲 O(nlogn) 且不穩定的排序算法。

2.6.1. 實現

整個堆排序的實現分爲建堆和排序兩個步驟。

建堆

首先是將待排序數組創建成一個堆,秉着能不借助額外數組則不借助的原則,咱們能夠直接在原數組上直接操做。這樣,建堆有兩個方法:

  • 第一種方法相似於上述堆的操做中「往堆中插入一個元素」的思想。剛開始的時候假設堆中只有一個元素,也就是下標爲 1 的元素。而後,將下標爲 2 的元素插入堆中,並對堆進行調整。以此類推,將下標從 2 到 n 的元素依次插入到堆中。這種建堆方式至關於將待排序數組分紅「堆區」和「待插入堆區」。

    如圖所示,咱們將對待排序數據 七、五、1九、八、4 進行建堆(大頂堆)。能夠看到初始化堆就一個元素 7。以後將指針移到下標爲 2 的位置,將 5 這個元素添加到堆中並從下往上進行堆化。以後,再將指針移到下標爲 3 的位置,將 19 這個元素添加到堆中並從下往上進行堆化。依次類推。

  • 第二種方法是將整個待排序數組都當成一個「堆」,可是這個「堆」不必定知足堆的兩個條件,所以咱們須要對其進行總體調整。那麼,調整的時候是從數組的最後開始,依次往前調整。調整的時候,只須要調整該節點及其子樹知足堆的要求,而且是從上往下的方式進行調整。因爲,葉子節點沒有子樹,因此葉子節點不必調整,咱們只須要從第一個非葉子節點開始調整(這邊的第一是從後往前數的第一個)。那麼第一個非葉子節點的下標爲 n/2,所以咱們只須要對 n/2 到 1 的數組元素進行從上往下堆化便可(下標從 n/2 到 1 的數組元素所在節點都是非葉子節點,下標從 n/2+1 到 n 的數組元素所在節點都是葉子節點)。

    如圖所示,咱們將對待排序數據 七、五、1九、八、四、一、20、1三、16 進行建堆(大頂堆)。能夠看到整個過程是從 8 這個元素開始進行堆化。在對 8 進行堆化的時候,僅對 8 及其子樹進行堆化。在對 5 進行堆化的時候,僅對 5 及其子樹進行堆化。

咱們將第二種思路實現成以下代碼段所示:

public void buildHeap(int[] datas, int len) {
    this.heap = datas;
    this.capacity = len - 1;
    this.count = len - 1;
    for (int i = this.count/2; i >=1; i--) {
        heapifyFromTop(i);
    }
}

public void heapifyFromTop(int begin) {
    while (true) {
        int i = begin;   // i 是節點及其左右子節點中較大值的那個節點的下標

        /* 就是在節點及其左右子節點中選擇一個最大的值,與節點所處的位置進行; 可是,須要注意的是假如這個值正好是節點自己,那麼直接退出循環; 不然須要進行交換,而後從交換以後的節點開始繼續堆化 */
        if (begin * 2 <= this.count && this.heap[begin] < this.heap[2 * begin]) {
            i = 2 * begin;
        }

        if ((2 * begin + 1) <= this.count && this.heap[i] < this.heap[2 * begin + 1]) {
            i = 2 * begin + 1;
        }

        if (i == begin) {
            break;
        }

        swap(begin, i);

        begin = i;
    }
}

爲何下標從 n/2 到 1 的數組元素所在節點都是非葉子節點,下標從 n/2+1 到 n 的數組元素所在節點都是葉子節點?這個算是徹底二叉樹的一個特性。嚴格的證實暫時沒有,不嚴謹的證實仍是有的。這裏採用反證法,假如 n/2 + 1 不是葉子節點,那麼它的左子節點下標應該爲 n+2,可是整個徹底二叉樹最大的節點的下標爲 n。因此 n/2 + 1 不是葉子節點不成立,即 n/2 + 1 是葉子節點。那麼同理可證 n/2 + 1 到 n 也是如此。而對於下標爲 n/2 的節點來講,它的左子節點有的話下標應該爲 n,n 在數組中有元素,所以 n/2 有左子節點,即 n/2 不是葉子節點。同理可證 1 到 n/2 都不是葉子節點。

排序

建完堆(大頂堆)以後,接下去的步驟是排序。那麼具體該怎麼實現排序呢?

此時,咱們能夠發現,堆頂的元素是最大的,即數組中的第一個元素是最大的。實現排序的過程大體以下:咱們把它跟最後一個元素交換,那最大元素就放到了下標爲 n 的位置。此時堆頂元素不是最大,所以須要進行堆化。採用從上而下的堆化方法(參考刪除堆頂元素的堆化方法),將剩下的 n-1 個數據構建成堆。最後一個數據由於已知是最大了,因此不用參與堆化了。n-1 個數據構建成堆以後,再將堆頂的元素(下標爲 1 的元素)和下標爲 n-1 的元素進行交換。一直重複這個過程,直至堆中只剩一個元素,此時排序工做完成。如圖所示,這是整個過程的示意圖。

下面將排序的過程使用 Java 實現,以下所示。那麼講建堆和排序的過程結合在一塊兒以後就是完整的堆排序了。

public void heapSort() {
    while (this.count > 1) {
        swap(this.count, 1);
        this.count--;
        heapifyFromTop(1);
    }
}

詳細的代碼看文章開頭給出的 Github 地址。

2.6.2. 算法分析

時間複雜度

堆排序的時間複雜度是由建堆和排序兩個步驟的時間複雜度疊加而成。

  • 建堆的時間複雜度

在採用第二方式建堆時,從粗略的角度來看,每一個節點進行堆化的時間複雜度是 O(logn),那麼 n/2 個節點堆化的總時間複雜度爲 O(nlogn)。可是這此時粗略的計算,更加精確的計算結果不是 O(nlogn),而是 O(n)

由於葉子節點不須要進行堆化,因此須要堆化的節點從倒數第二層開始。每一個節點須要堆化的過程當中,須要比較和交換的次數,跟這個節點的高度 k 成正比。那麼全部節點的高度之和,就是全部節點堆化的時間複雜度。假設堆頂節點對應的高度爲 h ,那麼整個節點對應的高度如圖所示(以滿二叉樹爲例,最後一層葉子節點不考慮)。

那麼將每一個非葉子節點的高度求和爲

 

求解這個公式可將兩邊同時乘以 2 獲得 S2,

 

而後再減去 S1,從而就獲得 S1

 

因爲

 

因此最終時間複雜度爲 O(2n-logn),也就是 O(n)。

  • 排序的時間複雜度

排序過程當中,咱們須要進行 (n-1) 次堆化,每次堆化的時間複雜度是 O(logn),那麼排序階段的時間複雜度爲 O(nlogn)。

  • 總的時間複雜度

那麼,整個總的時間複雜度爲 O(nlogn)

不對建堆過程的時間複雜度進行精確計算,也就是建堆以 O(nlogn) 的時間複雜度算的話,那麼總的時間複雜度仍是 O(nlogn)。

穩定與否

堆排序不是穩定的排序算法。由於在排序階段,存在將堆的最後一個節點跟堆頂點進行互換的操做,因此有可能會改變相同數據的原始相對順序。好比下面這樣一組待排序 20、1六、1三、13 ,在排序時,第二個 13 會跟 20 交換,從而更換了兩個 13 的相對順序。

是否原地

堆排序是原地排序算法,由於堆排序的過程當中的兩個步驟中都只須要極個別臨時存儲空間。

2.6.3. 總結

在實際開發中,爲何快速排序要比堆排序性能要好?

  1. 對於一樣的數據,在排序過程當中,堆排序算法的數據交換次數要多於快速排序

    對於基於比較的排序算法來講,整個排序過程就是由比較和交換這兩個操做組成。快速排序中,交換的次數不會比逆序度多。可是堆排序的過程,第一步是建堆,這個過程存在大量的比較交換操做,而且頗有可能會打亂數據原有的相對前後順序,致使原數據的有序度下降。好比,在對一組已經按從小到大的順序排列的數據進行堆排序時,那麼建堆過程會將這組數據構建成大頂堆,而這一操做將會讓數據變得更加無序。而採用快速排序的方法時,只須要比較而不須要交換。

    最直接的方式就是作個試驗看一下,對交換次數進行統計。

  2. 堆排序的訪問方式沒有快速排序友好

    快速排序來講,數據是順序訪問的。而堆排序,數據是跳着訪問的。訪問的數據量如何很大的話,那麼堆排序可能對 CPU 緩存不太友好。

2.7. 桶排序

**桶排序的核心思想就是將要排序的數據分到幾個有序的桶裏,每一個桶裏的數據再單獨進行排序。**桶內排序完成以後,再把每一個桶裏的數據按照順序依次取出,組成的序列就是有序的了。通常步驟是:

  • 先肯定要排序的數據的範圍;

  • 而後根據範圍將數據分到桶中(能夠選擇桶的數量固定,也能夠選擇桶的大小固定);

  • 以後對每一個桶進行排序;

  • 以後將桶中的數據進行合併;

2.7.1. 實現

public void buckerSort(int[] arr, int len, int bucketCount) {

    // 肯定數據的範圍
    int minVal = arr[0];
    int maxVal = arr[0];
    for (int i = 1; i < len; ++i) {
        if (arr[i] < minVal) {
            minVal = arr[i];
        } else if (arr[i] > maxVal){
            maxVal = arr[i];
        }
    }

    // 確認每一個桶的所表示的範圍
    bucketCount =  (maxVal - minVal + 1) < bucketCount ? (maxVal - minVal + 1) : bucketCount;
    int bucketSize = (maxVal - minVal + 1) / bucketCount;
    bucketCount = (maxVal -  minVal + 1) % bucketCount == 0 ? bucketCount : bucketCount + 1;

    int[][] buckets = new int[bucketCount][bucketSize];
    int[] indexArr = new int[bucketCount];  // 數組位置記錄

    // 將數據依次放入桶中
    for (int i = 0; i < len; i++) {
        int bucketIndex = (arr[i] - minVal) / bucketSize;
        if (indexArr[bucketIndex] == buckets[bucketIndex].length) {
            expandCapacity(buckets, bucketIndex);
        }
        buckets[bucketIndex][indexArr[bucketIndex]++] = arr[i];
    }

    // 桶內排序
    for (int i = 0; i < bucketCount; ++i) {
        if (indexArr[i] != 0) {
            quickSort(buckets[i], 0, indexArr[i] - 1);
        }
    }

    // 桶內數據依次取出
    int index = 0;
    for (int i = 0; i < bucketCount; ++i) {
        for (int j = 0; j < indexArr[i]; ++j) {
            arr[index++] = buckets[i][j];
        }
    }

    // 打印
    for (int i = 0; i < len; ++i) {
        System.out.print(arr[i] + " ");
    }
    System.out.println();
}

// 對數組進行擴容
public void expandCapacity(int[][] buckets, int bucketIndex) {
    int[] newArr = new int[buckets[bucketIndex].length * 2];
    System.arraycopy(buckets[bucketIndex], 0, newArr, 0, buckets[bucketIndex].length);
    buckets[bucketIndex] = newArr;
}

2.7.2. 算法分析

  • 最好時間複雜度爲 O(n),最壞時間複雜度爲 O(nlogn),平均時間複雜度爲 O(n)。

    若是要排序的數據爲 n 個,把這些數據均勻地分到 m 個桶內,每一個桶就有 k=n/m 個元素。每一個桶使用快速排序,時間複雜度爲 O(k.logk)。m 個 桶的時間複雜度就是 O(m*k*logk),轉換的時間複雜度就是 O(n*log(n/m))。當桶的數量 m 接近數據個數 n 時,log(n/m) 就是一個很是小的常量,這個時候桶排序的時間複雜度接近 O(n)。

    若是數據通過桶的劃分以後,每一個桶的數據很不平均,好比一個桶中包含了全部數據,那麼桶排序就退化爲 O(nlogn) 的排序算法了。

    這邊的平均時間複雜度爲 O(n) 沒有通過嚴格運算,只是採用粗略的方式得出的。由於桶排序大部分狀況下,都能將數據進行大體均分,而極少狀況出現全部的數據都在一個桶裏。

  • 非原地算法

    由於桶排序的過程當中,須要建立 m 個桶這個的空間複雜度就確定不是 O(1) 了。在桶內採用快速排序的狀況下,桶排序的空間複雜度應該是 O(n)。

  • 桶排序的穩定與否,主要看兩塊:1.將數據放入桶中的時候是否按照順序放入;2.桶內採用的排序算法。因此將數據放入桶中是按照順序的,而且桶內也採用穩定的排序算法的話,那麼整個桶排序則是穩定的。既然能穩定的話,那麼通常算穩定的。

2.7.3. 總結

  • 桶排序對要排序的數據的要求是很是苛刻的。

    • 首先,要排序的數據須要很容易被劃分到 m 個桶。而且,桶與桶之間有着自然的大小順序,這樣子每一個桶內的數據都排序完以後,桶與桶之間的數據不須要再進行排序;

    • 其次,數據在各個桶中的分佈是比較均勻的。若是數據通過桶的劃分以後,每一個桶的數據很不平均,好比一個桶中包含了全部數據,那麼桶排序就退化爲 O(nlogn) 的排序算法了。

  • **桶排序適合應用在外部排序中。**好比要排序的數據有 10 GB 的訂單數據,可是內存只有幾百 MB,沒法一次性把  10GB 的數據全都加載到內存中。這個時候,就能夠先掃描 10GB 的訂單數據,而後肯定一下訂單數據的所處的範圍,好比訂單的範圍位於 1~10 萬元之間,那麼能夠將全部的數據劃分到 100 個桶裏。再依次掃描 10GB 的訂單數據,把 1~1000 元以內的訂單存放到第一個桶中,1001~2000 元以內的訂單數據存放到第二個桶中,每一個桶對應一個文件,文件的命名按照金額範圍的大小順序編號如 00、01,即第一個桶的數據輸出到文件 00 中。

    理想狀況下,若是訂單數據是均勻分佈的話,每一個文件的數據大約是 100MB,依次將這些文件的數據讀取到內存中,利用快排來排序,再將排序好的數據存放回文件中。最後只要按照文件順序依次讀取文件中的數據,並將這些數據寫入到一個文件中,那麼這個文件中的數據就是排序好了的。

    可是,訂單數據不必定是均勻分佈的。劃分以後可能還會存在比較大的文件,那就繼續劃分。好比訂單金額在 1~1000 元之間的比較多,那就將這個區間繼續劃分爲 10 個小區間,1~100、101~200 等等。若是劃分以後仍是很大,那麼繼續劃分,直到全部的文件都能讀入內存。

    外部排序就是數據存儲在磁盤中,數據量比較大,內存有限,沒法將數據所有加載到內存中。

2.8. 計數排序

計數排序跟桶排序相似,能夠說計數排序實際上是桶排序的一種特殊狀況。**當要排序的 n 個數據,所處的範圍並不大的時候,好比最大值是 K,那麼就能夠把數據劃分到 K 個桶,每一個桶內的數據值都是相同的,**從而省掉了桶內排序的時間。能夠說計數排序和桶排序的區別其實也就在於桶的大小粒度不同。

下面經過舉例子的方式來看一下計數排序的過程。假設數組 A 中有 8 個數據,值在 0 到 5 之間,分別是:二、五、三、0、二、三、0、3。

  • 首先使用大小爲 6 的數組 C[6] 來存儲每一個值的個數,下標對應具體值。從而獲得,C[6] 的狀況爲:二、0、二、三、0、1。

  • 那麼,值爲 3 分的數據個數有 3 個,小於 3 分的數據個數有 4 個,因此值爲 3 的數據在有序數組 R 中所處的位置應該是 四、五、6。爲了快速計算出位置,對 C[6] 這個數組進行變化,C[k] 裏存儲小於等於值 k 的數據個數。變化以後的數組爲 二、二、四、七、七、8。

  • 以後咱們從後往前依次掃描數據 A( 從後往前是爲了穩定),好比掃描到 3 的時候,從數據 C 中取出下標爲 3 的值,是7(也就說到目前爲止,包含本身在內,值小於等於 3 的數據個數有 7 個),那麼 3 就是數組 R 中第 7 個元素,也就是下標爲 6。固然 3 放入到數組 R 中後,C[3] 要減 1,變成 6,表示此時未排序的數據中小於等於 3 的數據個數有 6 個。

  • 以此類推,當掃描到第 2 個值爲 3 的數據的時候,就會將這個數據放入到 R 中下標爲 5 的位置。當掃描完整個數組 A 後,數組 R 內的數據就是按照值從小到大的有序排列了。

2.8.1. 實現

/** * 計數排序,暫時只能處理整數(包括整數和負數) * @param arr * @param len */
public void countingSort(int[] arr, int len) {
    // 肯定範圍
    int minVal = arr[0];
    int maxVal = arr[0];
    for (int i = 1; i < len; ++i) {
        if (maxVal < arr[i]) {
            maxVal = arr[i];
        } else if (arr[i] < minVal) {
            minVal = arr[i];
        }
    }

    // 對數據進行處理
    for (int i = 0; i < len; ++i) {
        arr[i] = arr[i] - minVal;
    }
    maxVal = maxVal - minVal;

    // 遍歷數據數組,求得計數數組的個數
    int[] count = new int[maxVal + 1];
    for (int i = 0; i < len; ++i) {
        count[arr[i]] ++;
    }
    printAll(count, maxVal + 1);

    // 對計數數組進行優化
    for (int i = 1; i < maxVal + 1; ++i) {
        count[i] = count[i - 1] + count[i];
    }
    printAll(count, maxVal + 1);

    // 進行排序,從後往前遍歷(爲了穩定)
    int[] sort = new int[len];
    for (int i = len - 1; i >= 0; --i) {
        sort[count[arr[i]] - 1] = arr[i] + minVal;
        count[arr[i]]--;
    }
    printAll(sort, len);
}

2.8.2. 算法分析

  • 非原地算法

    計數排序至關於桶排序的特例同樣。計數排序須要額外的 k 個內存空間和 n 個新的內存空間存放排序以後的數組。

  • 穩定算法

    前面也提到了,假如採用從後往前遍歷的方式話,那麼是穩定算法。

  • 時間複雜度

    最好、最壞、平均時間複雜度都是同樣,爲 O(n+k),k 爲數據範圍。這個從代碼的實現能夠看出,不管待排數組的狀況怎麼樣,都是要循環一樣的次數。

2.8.3. 總結

  • 計數排序只能用在數據範圍不大的場景中,若是數據範圍 k 比要排序的數據 n 大不少,就不適合用計數排序了。

  • 計數排序只能直接對非負整數進行排序,若是要排序的數據是其餘類型的,須要在不改變相對大小的狀況下,轉化爲非負整數。好比當要排序的數是精確到小數點後一位時,就須要將全部的數據的值都先乘以 10,轉換爲整數。再好比排序的數據中有負數時,數據的範圍是[-1000,1000],那麼就須要先將每一個數據加上 1000,轉換爲非負整數。

2.9. 基數排序

桶排序和計數排序都適合範圍不是特別大的狀況(請注意是範圍),可是桶排序的範圍能夠比計數排序的範圍稍微大一點。假如數據的範圍很大很大,好比對手機號這種的,桶排序和技術排序顯然不適合,由於須要的桶的數量也是十分巨大的。此時,可使用基數排序。**基數排序的思想就是將要排序的數據拆分紅位,而後逐位按照前後順序進行比較。**好比手機號中就能夠從後往前,先按照手機號最後一位來進行排序,以後再按照倒數第二位來進行排序,以此類推。當按照第一位從新排序以後,整個排序就算完成了。

須要注意的是**,按照每位排序的過程須要穩定的**,由於假如後一次的排序不穩定,前一次的排序結果將功虧一簣。好比,第一次對個位進行排序結果爲 2一、十一、4二、2二、62,此時 21 在 22 前面;第二次對十位的排序假如是不穩定的話,22 可能跑到 21 前面去了。那麼整個排序就錯了,對個位的排序也就至關於白費了。

下面舉個字符串的例子,整個基數排序的過程以下圖所示:

2.9.1. 實現

/** * 基數排序 * @param arr * @param len */
public void radixSort(int[] arr, int len, int bitCount) {
    int exp = 1;
    for (int i = 0; i < bitCount; ++i) {
        countingSort(arr, len, exp);
        exp = exp * 10;
    }
}

public int getBit(int value, int exp) {
    return (value / exp) % 10;
}
/** * 計數排序,暫時只能處理整數(包括整數和負數) * @param arr * @param len */
public void countingSort(int[] arr, int len, int exp) {

    // 肯定範圍
    int maxVal = getBit(arr[0], exp);
    for (int i = 1; i < len; ++i) {
        if (maxVal < getBit(arr[i], exp)) {
            maxVal = getBit(arr[i], exp);
        }
    }

    // 遍歷數據數組,求得計數數組的個數
    int[] count = new int[maxVal + 1];
    for (int i = 0; i < len; ++i) {
        count[getBit(arr[i], exp)] ++;
    }

    // 對計數數組進行優化
    for (int i = 1; i < maxVal + 1; ++i) {
        count[i] = count[i - 1] + count[i];
    }

    // 進行排序,從後往前遍歷(爲了穩定)
    int[] sort = new int[len];
    for (int i = len - 1; i >= 0; --i) {
        sort[count[getBit(arr[i], exp)] - 1] = arr[i];
        count[getBit(arr[i], exp)]--;
    }
    System.arraycopy(sort, 0, arr, 0, len);
    printAll(sort, len);
}

2.9.2. 算法分析

  • 非原地算法

    是否是原地算法其實看針對每一位排序時所使用的算法。爲了確保基數排序的時間複雜度以及每一位的穩定性,通常採用計數排序,計數排序是非原地算法,因此能夠把基數排序當成非原地排序。

  • 穩定算法

    由於基數排序須要確保每一位進行排序時都是穩定的,因此整個基數排序時穩定的。

  • 時間複雜度是 O(kn),k 是數組的位數

    最好、最壞、平均的時間複雜度都是 O(n)。由於不管待排數組的狀況怎麼樣,基數排序其實都是遍歷每一位,對每一位進行排序。假如每一位排序的過程當中使用計數排序,時間複雜度爲 O(n)。假若有 k 位的話,那麼則須要 k 次桶排序或者計數排序。所以總的時間複雜度是 O(kn),當 k 不大時,好比手機號是 11 位,那麼基數排序的時間複雜度就近似於 O(n)。也能夠從代碼中看出。

2.9.3. 總結

  • 基數排序的一個要求是排序的數據要是等長的。當不等長時候能夠在前面或者後面補 0,好比字符串排序的話,就能夠在後面補 0,由於 ASCII 碼中全部的字母都大於 「0」,因此補 「0」 不會影響到原有的大小排序。

  • 基數排序的另外一個要求就是數據能夠分割出獨立的 「位」 來比較,並且位之間存在遞進關係:若是 a 數據的高位比 b 數據大,那麼剩下的低位就不用比較了。

  • 除此以外,每個位的數據範圍不能太大,要能用線性排序算法來排序,不然,基數排序時間複雜度沒法達到 O(n)。

3. 排序函數

幾乎全部編程語言都會提供排序函數,好比 C 語言中 qsort()、C++ STL 中的 sort()/stable_sort()、Java 中的 Collections.sort()。這些排序函數,並不會只採用一種排序算法,而是多種排序算法的結合。固然主要使用的排序算法都是 O(nlogn) 的。

  • glibc 的 qsort() 排序函數。qsort() 會優先使用歸併排序算法。當排序的數據量很大時,會使用快速排序。使用排序算法的時候也會進行優化,如使用 「三數取中法」、在堆上手動實現一個棧來模擬遞歸來解決。在快排的過程當中,若是排序的區間的元素個數小於等於 4 時,則使用插入排序。並且在插入排序中還用到了哨兵機制,減小了一次判斷。

    在小規模數據面前 O(n^2) 時間複雜度的算法並不必定比 O(nlogn)的算法執行時間長。主要是由於時間複雜度會將係數和低階去掉。

  • Array.sort() 排序函數,使用 TimSort 算法。TimSort 算法是一種歸併算法和插入排序算法混合的排序算法。基本工做過程就是:

    整個排序過程,分段選擇策略能夠保證 O(nlogn) 的時間複雜度。TimSort 主要利用了待排序列中可能有些片斷已經基本有序的特性。以後,對於小片斷採用插入算法進行合併,合併成大片斷。最後,再使用歸併排序的方式進行合併,從而完成排序工做。

    • 掃描數組,肯定其中的單調上升段和單調降低段,將嚴格降低段反轉;

    • 定義最小基本片斷長度,長度不知足的單調片斷經過插入排序的方式造成知足長度的單調片斷(就是長度大於等於所要求的最小基本片斷長度)

    • 反覆歸併一些相鄰片斷,過程當中避免歸併長度相差很大的片斷,直至整個排序完成。

4. 附加知識

4.1. 有序度、逆序度

在以從小到大爲有序的狀況中,有序度是數組中有序關係的元素對的個數,用數學公式表示以下所示。

若是 i < j,那麼 a[i] < a[j]

好比 二、四、三、一、五、6 這組數據的有序度是 11;倒序排列的數組,有序度是 0;一個徹底有序的數組,有序度爲滿有序度,爲 n*(n-1)/2,好比一、二、三、四、五、6,有序度就是 15。

逆序度的定義正好跟有序度的定義相反

若是 i < j,那麼 a[i] > a[j]

關於逆序度、有序度、滿有序度知足以下公式

逆序度 = 滿有序度 - 有序度

排序的過程其實就是減小逆序度,增長有序度的過程,若是待排序序列達到滿有序度了,那麼此時的序列就是有序了

5. 總結

  • 冒泡排序、選擇排序可能就停留在理論的層面,實際開發應用中很少,可是插入排序仍是挺有用的,有些排序算法優化的時候就會用到插入排序,好比在排序數據量小的時候會先選擇插入排序。

  • 冒泡、選擇、插入三者的時間複雜度通常都是按 n^2 來算。**而且這三者都有一個共同特色,那就是都會將排序數列分紅已排序和未排序兩部分。**外層循環一次,實際上是讓有序部分增長一個,所以外層循環至關於對有序部分和未排序部分進行分割。而外層循環次數就是待排序的數據的個數;內層循環則主要負責處理未排序部分的元素。

  • 快排的分區過程和分區思想其實特別好用,在解決不少非排序的問題上都會遇到。好比如何在 O(n) 的時間複雜度內查找一個 k 最值的問題(還用到分治,更可能是分區這種方式);好比將一串字符串劃分紅字母和數字兩部分(其實就是分區,因此須要注意分區過程的應用)。之後看到相似分區什麼的,能夠想一想快排分區過程的操做。

  • 快排和歸併使用都是分治的思想,均可使用遞歸的方式實現。只是歸併是從下往上的處理過程,是先進行子問題處理,而後再合併;而快排是從上往下的處理過程,是先進行分區,然後再進行子問題處理。

  • 桶排序、計數排序、基數排序的時間複雜度是線性的,因此這類排序算法叫作線性排序。之因此這能作到線性排序,主要是由於這三種算法都不是基於比較的排序算法,不涉及到元素之間的比較操做。可是這三種算法對排序的數據要求很苛刻。若是數據特徵比較符合這些排序算法的要求,這些算法的複雜度能夠達到 O(n)。

  • 桶排序、計數排序針對範圍不大的數據是可行的,它們的基本思想都是將數據劃分爲不一樣的桶來實現排序。

  • 各類算法比較

    排序算法 平均時間複雜度 最好時間複雜度 最壞時間複雜度 是不是原地排序 是否穩定
    冒泡 O(n^2) O(n) O(n^2)
    插入 O(n^2) O(n) O(n^2)
    選擇 O(n^2) O(n^2) O(n^2) ×
    歸併 O(nlogn) O(nlogn) O(nlogn) ×  O(n)
    快排 O(nlogn) O(nlogn) O(n^2) ×
    堆排序 O(nlogn) O(nlogn) O(nlogn) ×
    桶排序 O(n) O(n) O(nlogn) ×
    計數排序 O(n+k) O(n+k) O(n+k) ×
    基數排序 O(kn) O(kn) O(kn) ×

6. 巨人的肩膀

  1. 極客時間,《數據結構與算法之美》,王爭

  2. 《算法圖解》

 

不甘於「本該如此」,多選參數 值得關注

 

 

本文分享自微信公衆號 - 多選參數(zhouxintalk)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索