1. 程式人生 > >Java實現九大內部排序

Java實現九大內部排序

Java實現九大內部排序

前言

排序,其實是一個我們從小就接觸的東西,上小學的時候,課後習題就有過這樣的題目,只是當時我們運用的自然排序,眼睛大概掃描一番,心裡就出現答案。但是隨著需要排序的資料越來越多,這種方式就顯得不太適用。與此同時,計算機並不能像人一樣,可以使用自然排序,為此科學家們設計了紛繁多的排序演算法,這些演算法主要利用了資料比較、資料特性以及資料結構等方式來實現,它們幾乎都能在九大內部排序中有所涉及。這些演算法都有它們自身的優勢以及劣勢,不管是複雜性、時空性、穩定性等。
如果不是為了學習資料結構和演算法,大多數情況,我們根本都不需要自己編寫排序演算法,就拿Java來說,大多數涉及排序的問題,如果是陣列形式,我都是交給Arrays裡面的普通排序和並行排序,如果是列表形式,我也是交給Collections的排序方法來解決。其實這些庫函式裡面的排序方法,核心思想也是這幾種內部排序,只是它們又做了很多優化措施,比如DualPivotQuicksort就是快排的一種優化,TimSort也是歸併排序的一種優化。
不過在真正開始學習排序演算法時,還是被它吸引了,雖然編寫時,出現了很多問題,但最終解決的感覺還真的是爽的不行。所以我才萌發了寫篇部落格的意向,希望和大家多交流一番。
對於基本的內部排序演算法,網上的資料汗牛充棟。本文不會過多介紹演算法的原理,側重點在排序演算法的Java實現,實現過程中所需要注意的問題以及解決的方法

。演算法主要涉及氣泡排序、選擇排序、插入排序、希爾排序、歸併排序、快速排序、桶排序、基數排序以及堆排序。

氣泡排序

氣泡排序可以說是最基礎的一種排序演算法,被大家廣為熟知。雖然經典,但其執行效率極低,實用性也較差。有興趣的可以去知乎看看大家對它的看法,氣泡排序為什麼會被看做經典,寫入所有C語言的教科書?。它的程式碼如下:

public static void bubbleSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    for(int i = 0, limit = len - 1; i < limit; i++){
        for(int j = i + 1; j < len; j++){
            if(array[i] > array[j]){
                swap(array, i, j);
            }
        }
    }
}

對於swap函式,這裡我稍微提一句,因為Java沒有指標,所以當時學習的時候,還真的對這個swap函式費解半天,雖然寫法都是和下面一般

public void swap(int[] array, int i, int j){
    int temp = array[i];
    array[i] = array[j];
    array[j] = temp;
}

但總覺得它和C++的寫法彆扭,可能是因為C++用的指標,看起來簡潔點吧。Java Puzzles這本書的謎題7,介紹了異或版本的swap,作者也分享了他自己對花哨程式碼的一點看法,還是挺有道理的。
雖然氣泡排序實現起來比較簡單,但是我們也需要注意對引數的檢驗,再稍微注意一下兩個for迴圈上下標的邊界問題。

選擇排序

選擇排序算是氣泡排序的升級版,氣泡排序每次比較都需要swap,而選擇排序是冒泡完一次後,才進行swap操作,稍微提升了點效率。程式碼如下:

public static void selectionSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    for(int i = 0, limit = len - 1; i < limit; i++){
        int min = i;
        for(int j = i + 1; j < len; j++){
            if(array[min] > array[j]){
                min = j;
            }
        }
        if(min != i){
            swap(array, min, i);
        }
    }
}

選擇排序和氣泡排序原理差不多,也沒有太多需要說的。

插入排序

插入排序是人類最自然的排序方法,就像鬥地主一般,每次拿牌都是比較後再插牌,下次叫它鬥地主排序,嘿嘿。它的程式碼如下:

public static void insertionSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    insertionSort(array, 0, len - 1);
}
private static void insertionSort(int[] array, int begin, int end){
    int len = array.length;
    if(begin < 0 || begin > end || end >= len){
        throw new IndexOutOfBoundsException();
    }
    for(int i = begin; i <= end; i++){
        int index = i;
        int target = array[i];
        while(--index >= 0 && array[index] > target){
            array[index + 1] = array[index];
        }
        array[index + 1] = target;
    }
}

這次分開寫只是為了方便後面快排小資料時呼叫插排,在小資料排序演算法中,插排的效率很高。因為後面的插排方法是在內部呼叫,其實可以省略引數的檢驗,但是以前看String原始碼時,裡面對引數的檢驗十分嚴格,這裡我也試試,也算是一次學習。並且String原始碼在處理陣列時各種while、++、–,也是fashion的不行,我也算偷學了點。
其實通過程式碼我們可以看出,我們找插入點,是從後向前,一個值一個值找,並且我們應該瞭解到,前面的資料都是已經排序好了的,在排序好的陣列中找東西(或位置),很自然的就會想到二分查詢,因此上面的程式碼也可以優化為二分插入排序。程式碼如下:

public static void binaryInsertionSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    for(int i = 1; i < len; i++){
        int left = 0;
        int right = i - 1;
        int target = array[i];
        while(left <= right){
            int mid = (left + right) >>> 1;
            int midValue = array[mid];
            if(midValue > target){
                right = mid - 1;
            }else if(midValue == target){
                left = mid + 1;
                break;
            }else{
                left = mid + 1;
            }
        }
       for(int j = i - 1; j >= left; j--){
           array[j + 1] = array[j];
       }
       array[left] = target;
    }
}

對於二分那部分,我們一定要保證最終返回的插入索引處的值要大於等於目標值,這樣才能保證後面的插排順利完成。這裡我們需要注意一下溢位的情況,有些時候我們取中間值喜歡如下操作:

int mid = (left + right) / 2;

當left和right比較小時,我們這樣操作是沒問題,但是我們int範圍為-2147483648~2147483647,超出範圍的會被截斷,造成整型溢位。我們操作整型的加、減和乘時,一定要慎之又慎,時刻注意溢位的情況。如果能保證left和right都為正數,或者說保證right - left不溢位,其實取中間值,如下操作也是合理的。

int mid = left + ((right - left) >> 1);

Java算數運算子的優先順序高於移位運算子,因此上面程式碼的括號不能省。我們在同時操作多個運算子時,一定要注意各運算子的優先順序問題,不然出問題了,很難排錯。

希爾排序

希爾排序是插排的優化版。插排在資料基本有序的情況下,執行效率非常高,如果是逆序的情況,他甚至退化成氣泡排序。希爾排序的改進點就是在執行直接插入排序操作之前,儘可能保證待排陣列的有序。它通過選取合適的步長間隔,將待排序陣列分成若干子序列,再對所有子序列執行插入排序,因為該排序演算法的效率主要取決於選取的步長間隔,因此被稱為最難分析執行效率的排序演算法。具體步長間隔選取細節,可以參考維基百科的希爾排序。我一般選取3n + 1作為步長間隔,程式碼如下:
第一種

public static void shellSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    int gap = 1;
    int gapLimit = len / 3;
    while(gap < gapLimit){
        gap = 3 * gap + 1;
    }
    while(gap >= 1){
        for(int i = gap; i < len; i++){
            int index = i - gap;
            int target = array[i];
            while(index >= 0 && array[index] > target){
                array[index + gap] = array[index];
                index -= gap;
            }
            array[index + gap] = target;
        }
        gap = (gap - 1) / 3;
    }
}

第二種

public static void shellSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    int gap = 1;
    int gapLimit = len / 3;
    while(gap < gapLimit){
        gap = 3 * gap + 1;
    }
    while(gap >= 1){
        for(int i = 0; i < len; i++){
            for(int j = i + gap; len / gap >= 1; j += gap){
                int index = j - gap;
                int target = array[j];
                while(index >= 0 && array[index] > target){
                   array[index + gap] = array[index];
                   index -= gap;
                }
                array[index + gap] = target;
            }
        }
        gap = (gap - 1) / 3;
    }
}

第一種方法是從gap索引開始,對整個資料執行步長為gap的插排;第二種方法是從零開始對步長間隔gap的陣列進行插排。
在我的測試環境裡跑:

$ java -version
java version "1.8.0_111"  
Java(TM) SE Runtime Environment (build 1.8.0_111-b14)  
Java HotSpot(TM) 64-Bit Server VM (build 25.111-b14, mixed mode) 
$ 10000 random numbers (The data range is from 0 to 9999.)
use the first shellSort function to sort 10000 random numbers,cost 3
use the second shellSort function to sort 10000 random numbers,cost 140
$ 100000 random numbers  (The data range is from 0 to 99999.)
use the first shellSort function to sort 100000 random numbers,cost 17
use the second shellSort function to sort 100000 random numbers,cost 14398

如果我們在兩個函式的while迴圈裡面新增一個計數器,會發現兩個函式移動資料的次數是相同,按理說兩者執行時間應該相差不大。可是在實際測試中,隨著資料量的增大,兩者執行效率的差距越來越大。
當時這個問題還真讓我束手無策,因為水平有限,我也就放在一邊沒有管它。等到我學習Java記憶體模型的CPU快取部分時,我突然想到一套可以解釋上面問題的理論。
因為while的次數相同,所以我將問題歸結於兩者對陣列的取值上。眾所周知CPU執行處理速度遠遠大於記憶體讀寫速度,為了加快讀取速度,一般的CPU都會設有一級到三級不等的快取,在快取中的資料是記憶體中的一小部分,但這一小部分是短時間內CPU即將訪問的,當CPU呼叫大量資料時,就可先在快取中呼叫。處理器從快取中讀取運算元,而不是從記憶體中讀取,稱為快取命中。
此時,我們再觀察兩者演算法,可以看到方法一執行插排的資料都比較緊密,資料都是在一個步長間隔之間,這些資料有很大概率能被快取命中,而方法二中的第二個for迴圈,是對整個陣列在步長間隔進行插排,資料的跨度比較大,而快取的儲存量本身就比較小,所以隨著陣列長度越大,資料的快取命中率會越來越低,兩者讀取資料的效率也會隨之出現較大差距。(這只是我個人理解,希望後續有人能提出其他的解釋,我也學習學習!)
第一種方法我是修改的維基百科中希爾排序的虛擬碼,第二種方法我是參考的百度百科中希爾排序的Java版本,讓我費解的是,百度百科關於希爾排序的虛擬碼和維基百科虛擬碼的原理一樣,為什麼後面的程式碼實現,卻都是第二種情況。雖然兩個函式執行的方式相同,執行效率卻大大不同,所以以後在編寫演算法時還是應該考慮周全,可能這就是演算法研究的樂趣所在吧。

歸併排序

歸併排序利用“歸併”和“分治”思想對陣列進行排序。根據具體實現,歸併排序分為“從上往下”和“從下往上”兩種方式。這種排序經常用來和快排比較,並且大多時候也是被快排吊打,但是在外部排序中,歸併排序卻是大顯身手。2016年看過騰訊的一篇新聞:騰訊雲數智98.8秒完成100TB資料排序的架構和演算法,拋開硬體和分散式系統軟體架構不談,單純討論排序演算法部分,就可以發現歸併排序的身影。Java物件排序使用的TimSort(JDK1.7開始使用ComparableTimSort),也是歸併排序和插入排序的混合排序演算法,因此歸併排序還是很重要的。
首先寫個歸併兩個有序陣列的函式,練練手,程式碼如下:

public static int[] mergeArrays(int[] array1, int[] array2){
    int len1 = 0, len2 = 0;
    if(array1 == null || (len1 = array1.length) == 0){
        return array2 == null ? null : Arrays.copyOf(array2, array2.length);
    }
    if(array2 == null || (len2 = array2.length) == 0){
        return Arrays.copyOf(array1, len1);
    }
    int[] mergeArray = new int[len1 + len2]; // May throw OutOfMemoryError or NegativeArraySizeException
    int index1 = 0, index2 = 0, index = 0;
    while(true){
        if(array1[index1] > array2[index2]){
            mergeArray[index++] = array2[index2++];
        }else {
            mergeArray[index++] = array1[index1++];
        }
        if(index1 == len1){
            System.arraycopy(array2, index2, mergeArray, index, len2 - index2);
            break;
        }
        if(index2 == len2){
            System.arraycopy(array1, index1, mergeArray, index, len1 - index1);
            break;
        }
    }
    return mergeArray;
} 

這段程式碼寫起來可能比較簡單,兩個陣列逐個比較,然後新增到新的陣列中。但是一定要注意變數的檢測,我看網上這部分的程式碼好像都不喜歡對傳入的變數進行檢驗,還有合併後的陣列,可能會出現一些異常,因為無法解決,所以就不捕獲了。
接著就是“從上向下”,分治版本的歸併排序,程式碼如下:

public static void mergeSortFromTopToBottom(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    splitGroups(array, 0, len - 1);
}
private static void splitGroups(int[] array, int begin, int end){
    int mid = (begin + end) >>> 1;
    if(begin == mid){
        if(array[begin] > array[end]){
            swap(array, begin, end);
        }
        return;
    }
    splitGroups(array, begin, mid);
    splitGroups(array, mid + 1, end);
    merge(array, begin, mid, end);
}
private static void merge(int[] array, int begin, int mid, int end){
    int len = end - begin + 1;
    int left = begin;
    int leftLimit = mid + 1;
    int right = leftLimit;
    int rightLimit = end + 1;
    int[] mergeArray = new int[len];
    int index = 0;
    while(true){
        if(array[left] > array[right]){
            mergeArray[index++] = array[right++];
        }else{
            mergeArray [index++] = array[left++];
        }
        if(left == leftLimit){
            System.arraycopy(array, right, mergeArray, index, rightLimit - right);
            break;
        }
        if(right== rightLimit){
            System.arraycopy(array, left, mergeArray, index, leftLimit - left);
            break;
        }
    }
    System.arraycopy(mergeArray, 0, array, begin, len);
}

直接看程式碼,其實演算法的思路很清晰,就是分治陣列最後合併排序好的陣列塊。需要注意的是索引下標,如果不注意很容易越界。
最後就是“從下向上”,歸併版本的歸併排序,程式碼如下:

public static void mergeSortFromBottomToTop(int[] array){
     int len = 0;
     if(array == null || (len = array.length) < 2){
         return;
     }
     for(int gap = 1; len / gap >= 1; gap <<= 1){
         int twoGaps = gap << 1;
         int index = 0;
         for(; index + twoGaps - 1 < len; index += twoGaps){
             merge(array, index, index + gap - 1, index + twoGaps - 1);
         }
         if(index < len - gap){
             merge(array, index, index + gap - 1, len - 1);
         }
     }
}

上面的程式碼主要是實現步長間隔的合併操作,因為陣列長度不都是等於2的冪次方,所以剩餘部分也要進行合併操作,演算法也沒有太多技巧。但這段程式碼有一個隱藏很深的陷阱,網上很多合併排序程式碼的迴圈操作如下所示:

for(int gap = 1; gap < len; gap <<= 1){
    do something...
}

如果這個陣列的長度範圍在230 + 1 ~ 231 - 1之間時,這段程式碼就會陷入死迴圈,因為gap <<= 1這句話很容易造成數值溢位。平時我們直接寫gap < len,前提是gap的增量為1,它的溢位被len死死限制住了,但是gap << 1很容易跨越len,直接溢位。此時我們必須保證gap = 231(第一次溢位)時能正常退出,所以我使用len / gap >= 1來限制它的溢位。可能這裡有人注意到,twoGaps比gap要更快的溢位,當gap = 230時,twoGaps = 231,為什麼我不對twoGaps進行溢位處理呢?這是因為下一個for迴圈裡面的判斷語句是index + twoGaps - 1 < len,index起始值為0,0 + 231 - 1 = 230,這個值剛好為整型的最大值,所以這個值絕對會大於等於整型的len,並不會我們汙染我們後續的操作,所以才沒對它進行處理。
我們平時在寫迴圈程式碼時,喜歡按照慣性思維,上來就小於或小於等於限制值,以後一定要充分考慮增量問題,如果增量的結果可能跨越限制值而發生溢位,一定要使用其他的限制條件來約束它的溢位。並且不是大多情況都是len / gap >= 1來防止,只是我的這種情況很巧,剛好利用它能規避2倍的溢位,如果增量是3倍或者其他的,這種也是不行的,一切根據實際情況而定。
最後提一點,不管是“從上向下”還是“從下向上”版本的歸併排序,都可以在小資料時使用插排處理,因為小資料就使用merge函式,啟動的代價比較大,殺雞焉用牛刀。

快速排序

終於寫到這種被世人稱讚的快排了,嘿嘿。快速排序其實也是使用了分治策略。該方法首先從待排陣列中選取一個基準值,通過它將陣列分割成兩部分,其中一部分的所有資料都比另外一部分的任意資料都要小。然後,再按照此方法對這兩部分進行快速排序,遞迴結束後,整個陣列也將變成有序陣列。這裡我先從最原始的快排入手,然後逐步優化,多介紹幾種快排版本。
首先就是原始版本的快排(Python程式碼一行即可),程式碼如下:

public static void originalQuickSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    originalQuickSort(array, 0, len - 1);
}
private static void originalQuickSort(int[] array, int begin, int end){
    if(begin >= end){
        return;
    }
    int randIndex = begin + new Random().nextInt(end - begin + 1);
    swap(array, begin, randIndex);
    int pivot = array[begin];
    int left = begin;
    for(int index = begin + 1; index <= end; index++){
        if(array[index] < pivot){
            swap(array, ++left, index);
        }
    }
    swap(array, begin, left);
    originalQuickSort(array, begin, left - 1);
    originalQuickSort(array, left + 1, end);
}

上面其實我取了巧,並不是如傳統那般,直接使用最左邊的值作為樞紐值,而是使用待排陣列塊中任意值作為樞紐值,有些時候使用隨機方法去解決隨機問題,反而會有奇效,這還真是個神奇的事。
原始快排原理比較簡單,程式碼量較少,但是很多人寫起來,很容易出現各種問題,我覺得他們是隻知道原理,卻忽略了兩點比較重要的東西。第一是要保證不能出現重複的begin和end,如果出現了,很容易出現爆棧或者死迴圈,所以originalQuickSort(array, left + 1, end);這句程式碼,不管中間的是left + 1,還是什麼值,一定要確保該值大於begin。第二點是要保證分割出來的左右兩個陣列塊,左邊的所有值都要小於等於右邊的任意一個值,這也是大多快排演算法執行完了,卻排錯的原因。我相信只要謹記這兩點,快排演算法寫起來會又快又穩。

前面說過對於小資料可以使用插入排序來提升效率,快排也不例外,程式碼如下:

private static final int INSERTION_SORT_THRESHOLD = 47; // Get a threshold for insertion, prevent code from the magic numbers
if((end - begin) <= INSERTION_SORT_THRESHOLD){
    if(end > begin){
        insertionSort(array, begin, end);
    }
    return;
}

前面原始快排部分,我使用的是隨機法確定的樞紐值,不過這玩意總覺得有點玄幻,每次用它的時候都是忐忑不安,因此使用三點取中法,顧名思義,該方法就是選取首、中和尾資料裡面第二大資料,作為樞紐值,不過命名為三點取中法,瞬間高階大氣起來,哈哈哈。程式碼如下:

private static int getPivot(int[] array, int begin, int end){
    int mid = (begin + end) >>> 1;
    if(array[mid] > array[end]){
        swap(array, mid, end);
    }
    if(array[begin] > array[end]){
        swap(array, begin, end);
    }
    if(array[begin] < array[mid]){
        swap(array, begin, mid);
    }
    return array[begin];
}

前面原始快排部分,我們只是單純通過樞紐值劃分小資料在左邊,大資料在右邊,其實我們也可以將其分成三部分,小於pivot的放在左邊,等於pivot的放在中間,大於pivot的放在右邊,該方法被稱為三向切分快排法。程式碼如下:

public static void threeWayQuickSort(int[] array){
     int len = 0;
     if(array == null || (len = array.length) < 2){
        return;
    }
    threeWayQuickSort(array, 0, len - 1);
}
private static void threeWayQuickSort(int[] array, int begin, int end){
    if((end - begin) <= INSERTION_SORT_THRESHOLD){
        if(end > begin){
            insertionSort(array, begin, end);
        }
        return;
    }
    int pivot = getPivot(array, begin, end);
    int left = begin;
    int right = end;
    int index = begin + 1;
    while(index <= right){
        int value = array[index];
        if(value < pivot){
            swap(array, index++, left++);
        }else if(value == pivot){
            index++;
        }else{
            swap(array, index, right--);
        }
    }
    threeWayQuickSort(array, begin, left - 1);
    threeWayQuickSort(array, right + 1, end);
}

這方法保證在原始排序比較樞紐值的過程中,小的放在左邊,相同的放在中間,大的放在右邊。此時我們來看看該演算法是否滿足原始排序中需要注意的兩點。第一點,因為left和right索引處的值等於樞紐值,所以begin到left - 1索引之間的數值絕對小於等於任意處於索引值為right + 1到 end的值,滿足。第二點,因為index > left,right + 1 = index,所以right + 1 > begin,滿足。哈哈哈,這時我就很有信心證明我這演算法正確了。

前面原始排序在partition過程中,都是使用的swap進行資料交換,其實也可以通過賦值(或稱為移動)達到交換的目的。移動資料有點像小時候玩的智慧拼圖,只有一個空,通過有限次的移動來拼出完整影象,如果我們將pivot摳出來,當做智慧拼圖的一個空,這些資料總能在有限次拼出左邊小於pivot,右邊大於pivot的陣列,並且此時的移動都是賦值,比swap交換資料更加高效一點。
這樣說可能有點不直觀,大家可以參考網上的一些雙端掃描填坑快排演算法(這個名字是我取的,大多數叫填坑法),他們有圖片,可能更加形象點。放個連結:快速排序。程式碼如下:

public static void fullPitsQuickSort(int[] array){
     int len = 0;
     if(array == null || (len = array.length) < 2){
        return;
    }
    fullPitsQuickSort(array, 0, len - 1);
}
private static void fullPitsQuickSort(int[] array, int begin, int end){
    if((end - begin) <= INSERTION_SORT_THRESHOLD){
        if(end > begin){
             insertionSort(array, begin, end);
        }
        return;
    }
    int pivot = getPivot(array, begin, end);
    int left = begin;
    int right = end;
    while(left < right){
        while(left < right && array[right] > pivot){
            right--;
        }
        if(left < right){
            array[left++] = array[right];
        }
        while(left < right && array[left] < pivot){
            left++;
        }
        if(left < right){
            array[right--] = array[left];
        }
    }
    array[left] = pivot;
    fullPitsQuickSort(array, begin, left - 1);
    fullPitsQuickSort(array, left + 1, end);
}

這個演算法需要注意的是如何填坑。相比於三向切分快排法,雙端填坑快排法的優勢就是排序資料時,使用的是賦值操作,效率比swap要高(填坑掃描需要兩次填坑才能實現一次swap,高也高不到哪去),劣勢是填坑演算法每次partition時,都可能把與樞紐值相等的值分到左邊或右邊陣列塊。而三向切分法每次partition時,都會將待排陣列分為三部分,而且也只需要再排序小於pivot以及大於pivot的待排陣列,效率更高。
最後驗證一下程式正確性,第一點,left處是pivot,因此begin到left - 1都是小於等於pivot的資料,left + 1到end的都是大於等於pivot的資料,滿足。第二點,left大於等於begin,因此begin + 1恆大於begin,滿足。因此該演算法正確,嘿嘿!
前面三向切分演算法,我們是選取一個樞紐值,將資料分為小於pivot、等於pivot和大於pivot三部分,如果我們選用兩個樞紐值,一大(p1)一小(p2),就能將資料分為< p1、p1 =< <= p2、和> p2三部分,這部分可以參考java.util包下的DualPivotQuicksort類,partition部分的碼行為343。其中的雙端排序程式碼整理如下:

public static void dualPivotQuickSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    dualPivotQuickSort(array, 0, len - 1);
}
private static void dualPivotQuickSort(int[] array, int begin, int end){
    if((end - begin) <= INSERTION_SORT_THRESHOLD){
        if(end - begin > 0){
              insertionSort(array, begin, end);
        }
        return;
    }
    if(array[begin] > array[end]){
        swap(array, begin, end);
    }
    int smallPivot = array[begin];
    int bigPivot = array[end];
    int less = begin;
    int great = end;
    while(array[++less] < smallPivot);
    while(array[--great] > bigPivot);
    int index = less - 1;
    outer:
    while(++index <= great){
        int value = array[index];
        if(value < smallPivot){
            array[index] = array[less];
            array[less++] = value;
        }else if(value > bigPivot){
            while(array[great] > bigPivot){
                if(great-- == index){
                    break outer;
                }
            }
            // At this time array[great] < bigPivot
            if(array[great] < smallPivot){
                array[index] = array[less];
                array[less++] = array[great];
            }else{
                array[index] = array[great];
            }
            array[great--] = value;
        }
    }
    array[begin] = array[less - 1]; array[less - 1] = smallPivot;
    array[end] = array[great + 1]; array[great + 1] = bigPivot;
    dualPivotQuickSort(array, begin, less - 2);
    dualPivotQuickSort(array, less, great);
    dualPivotQuickSort(array, great + 2, end);
}

當partition執行完時,less是大於等於smallPivot並且小於等於bigPivot的邊界索引,因此less - 1處的值小於smallPivot,與此同時begin處的值等於smallPivot,因此交換begin和less - 1,能保證begin到less - 2的值都小於smallPivot。great也是大於等於smallPivot並且小於等於bigPivot的邊界索引,因此great + 1處的值大於大樞紐值,與此同時end處的值等於bigPivot,因此交換end和great + 1,能保證great + 2到end的值都大於大樞紐值,並且less到great的值都大於等於smallPivot小於等於bigPivot,滿足第一點。因為less - 2和great + 2的關係,less~great不會與上下邊界發生衝突,又因為great大於等於begin,所以great + 2 恆大於begin,滿足第二點,演算法正確。

桶排序

前面講的這些排序都是通過資料比較來進行排序,而桶排序卻是根據資料特性來進行排序。儘管它只適用於對非負整數進行操作,但是其排序效率遠遠高於常規排序,就算是極盡優化版本的Arrays.sort,在排序正整數時,都不敢直纓其鋒。程式碼如下:

public static void bucketSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    int maxValue = array[0];
    for(int i = 1; i < len; i++){
        int value = array[i];
        if(value < 0){
            throw new IllegalArgumentException("The array contains negative numbers.");
        }
        if(value > maxValue){
            maxValue = value;
        }
    }
    int bucketLen = maxValue + 1;
    int[] bucketArray = new int[bucketLen]; // May throw OutOfMemoryError or NegativeArraySizeException
    for(int i = 0; i < len; i++){
        bucketArray[array[i]]++;
    }
    int index = 0;
    for(int i = 0; i < bucketLen ;  i++){
        int count = bucketArray[i];
        while(--count >= 0){
            array[index++] = i;
        }
    }
}

桶排序的限制條件比較多,不僅有資料型別的問題,如果陣列中最大值較大,很容易出現記憶體不足的錯誤,演算法的空間利用率較低。如果資料型別滿足要求,且資料的分佈比較均勻,最大值也比較小,使用桶排序最合適不過了。

基數排序

基數排序是桶排序的擴充套件,利用了整數位數特性來實現排序。它將整數按位數切割成不同數字,然後按每個位數分別比較。因為它固定使用十個桶,空間利用率相對於桶排序大大提高,基數排序一般分為兩類,一類是從最低位開始排序,即(Least Significant Digit first)。一類是從最高位開始排序,即(Most Significant Digit first)。
首先是大家比較熟悉的,從低位開始排序的程式碼:

public static void lsdfSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    int maxValue = array[0];
    for(int i = 1; i < len; i++){
        int value = array[i];
        if(value < 0){
            throw new IllegalArgumentException("The array contains negative numbers.");
        }
        if(value > maxValue){
            maxValue = value;
        }
    }
    for(int radix = 1; radix < maxValue && radix < 1000000001; radix *= 10){
        int[] bucketArray = new int[10];
        for(int i = 0; i < len; i++){
            bucketArray[array[i] / radix % 10]++;
        }
        for(int i = 1; i < 10; i++){
            bucketArray[i] += bucketArray[i - 1];
        }
        int[] tempArray = new int[len];
        for(int i = len - 1; i >= 0; i--){
            tempArray[--bucketArray[array[i] / radix % 10]] = array[i];
        }
        System.arraycopy(tempArray, 0, array, 0, len);
    }
}

這個演算法雖然看起來比較簡單,但是如果真的要你自己實現,一時間還真有點措手不及。接下來我來說說我自己理解的思路:要根據位數排序,首先就是要取每個位數上的值,這個通過求商再求餘,倒是很簡單,然後我們根據位數上的值對應到0到9這十個桶中,並且對它們進行計數。此時此刻我們只是知道位數值為0到9各自包含的資料有多少,這時我們應該想到,我們都是按照0123456789進行排序的,如果我們把0桶的值加到1桶中,其值記做m,那麼m就是位數值為2開始的索引值,m-1也就是位數為1結束的索引值,同理,如果我們把0、1桶的值加到2桶中,其值記做n,那麼n就是位數值為3開始的索引值,n-1也就是位數為2結束的索引值。所以第二個for將個桶值疊加,就是為了確定各位數值在陣列中的索引值。此時我們已經知道每個位數在陣列的位置了,那麼接下來只需要按照位數,把資料放到指定索引處即可將資料按照0123456789排序了。在放資料時,我們並不是按照0到len - 1來取資料,而是按照len - 1到0,這是因為我們的桶提供的索引值是從大到小開始。比如排序25和29,首先個位排序,肯定是25和29,但是十位後,兩者相同,如果先取25,那麼桶提供索引值將會比給29提供的大,那麼十位排序時是29和25。前面說了桶疊加的值是一個位數值索引的開始,也是一個位數值索引的結束,如果強行要讓最後的迴圈從0到len-1遍歷也是可以的,只需要將桶疊加數向前一位,使桶值稱為開始索引值,再將0桶附為0,即可,前面部分都是一樣,從桶疊加結束開始修改,程式碼如下:

int temp1 = bucketArray[0];
int temp2 = temp1;
bucketArray[0] = 0;
for(int i = 1; i < 10; i++){
    temp1 = bucketArray[i];
    bucketArray[i] = temp2;
    temp2 = temp1;
}
int[] tempArray = new int[len];
for(int i = 0; i < len; i++){
    tempArray[bucketArray[array[i] / radix % 10]++] = array[i];
}
System.arraycopy(tempArray, 0, array, 0, len);

這裡桶資料移動其實可以使用陣列複製,但是這可能需要另外開闢一個數組,如果陣列太大,對空間來說也是個負擔,所以這裡我使用的swap原理,通過兩個臨時值進行交換移動。
從這裡我們看到,如果我們真正理解這個演算法,修改起來其實是很方便,遍歷方向從前往後、從後往前都可以。
可能前面我完全通過語言說演算法思路,總覺得乾澀澀的,不夠形象,我也特意從網上找了篇原理講解比較生動的文章(主要是有圖,哈哈哈!),連結如下:演算法 排序演算法之基數排序
最後還有一個地方,不知道大家注意了沒,我的radix迭代時,判斷語句好像多加了的東西。在歸併排序時,我說過,如果增量的結果可能跨越限制值而發生溢位,就要仔細考慮是否需要加判斷語句進行限制。在lsdfSort函式中,radix增量為10倍,第一次發生溢位時,radix = 1410065408,如果還是使用以前的判定條件len / radix >= 1,當len大於1410065408時,就會出現問題。我們仔細觀察這個溢位值,發現它大於整型的最大位數1000000000,所以我們只需要判斷其radix小於1000000001到1410065407之間任意一個數,或者直接小於等於1000000000,都是可以避免溢位造成的錯誤。其實還有兩種不同的方法來解決,這只是針對lsdfSort函式。
第一種方法如下

int maxValueLen = String.valueOf(maxValue).length();
int radixLimit = (int)Math.pow(10, maxValueLen - 1); 
for(int radix = 1; radix <= radixLimit ; radix *= 10){
    do something...
}

double轉int,轉換的是整數部分,而10的冪都是整數,所以並不會發生精度缺失,可以放心轉換。
有時程式碼寫new Double(value).intValue(),以為可以安全的轉換,其實Double類中的intValue方法就是用(int)強轉實現的,但是在程式碼裡面直接寫(int),總覺得心慌慌的,哎,掩耳盜鈴啊!
第二種方法,靈感來自於Integer的stringSize方法,程式碼如下:

final int[] radixTable = {1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000, 1_000_000_000};
int radixLimit = 1;
for(int i = 9; i >= 0; i--){
    if(maxValue >= radixTable[i]){
        radixLimit = radixTable[i];
        break;
    }
}
for(int radix = 1; radix <= radixLimit ; radix *= 10){
    do something...
}

jdk1.7開始支援數字下劃線,用於提高程式碼可讀性,今天一試還是有點方便呢。如果還不太瞭解下劃線的,給個傳送門:為什麼Java7開始在數字中使用下劃線

當時學完從低位開始排序的基數排序,很自然的就會想著學習一下從高位開始排序的基數排序。我也是伸手黨,說去百度看看,有沒有相關的程式碼,查了半天,發現都是從低位開始排序的基數排序,最後想了想,決定自己造個輪子,手撕這個演算法。
我的思路:首先我也是參考了低位的基數排序,決定從高位開始裝桶排序,處理最高位時好像還可以,但是接著處理下一位,又把以前的排序打亂了,這肯定是不行,所以思路得改改。我發現每處理一位時,桶資料的相對位置應該是固定的。比如18 33 32 15 27 22,我們先進行最高位十位處理時,變為 18 15 27 22 33 32,下次再進行個位操作時,18和15這兩個數只能在索引0和1之間進行排序,而27和22只能在2和3之間進行排序,同理33和32只能在4和5之間排序。也就是說,十位分了十個桶進行十位排序,這十個桶每個分別再分十個桶來進行個位排序,以此類推。直到radix等於1即個位排序完,整個演算法結束,就大功告成了。
下面的程式碼絕對原創,有更好思路,能交流就更好了。

public static void mdsfSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    int maxValue = array[0];
    for(int i = 1; i < len; i++){
        int value = array[i];
        if(value < 0){
            throw new IllegalArgumentException("The array contains negative numbers.");
        }
        if(value > maxValue){
            maxValue = value;
        }
    }
    int maxValueLen = String.valueOf(maxValue).length();
    int radix = (int)Math.pow(10, maxValueLen - 1); 
    mdsfSort(array, 0, len - 1, radix);
}
private static void mdsfSort(int[] array, int begin, int end, int radix){
    int[] bucketArray = new int[10];
    int len = end - begin + 1;
    for(int i = begin; i <= end; i++){
        bucketArray[array[i] / radix % 10]++;
    }
    for(int i = 1; i < 10; i++){
        bucketArray[i] += bucketArray[i - 1];
    }
    int[] tempBucketArray = Arrays.copyOf(bucketArray, 10);
    int[] tempArray = new int[len];
    for(int i = begin; i <= end; i++){
        tempArray[--bucketArray[array[i] / radix % 10]] = array[i];
    }
    System.arraycopy(tempArray, 0, array, begin, len);
    if(radix == 1){
        return;
    }
    if(tempBucketArray[0] > 1){
        mdsfSort(array, begin, begin + tempBucketArray[0] - 1, radix / 10);
    }
    for(int i = 1; i < 10; i++){
        if(tempBucketArray[i] - tempBucketArray[i - 1] > 1){
            mdsfSort(array, begin + tempBucketArray[i - 1], begin + tempBucketArray[i] - 1, radix / 10);
        }
    }
}

該演算法就是從最高位開始分十個桶,再對最高位的每個位數值分別分十個桶為下一位排序做準備,依次類推,直到個位排序完。
上面的程式碼在排資料時,我並沒有移動桶資料的操作,但是遍歷的方向卻是0到len-1,好像和前面低位基數排序演算法衝突。其實不然,因為我的每個桶陣列都是針對每一位的每一個位數值,他們根本不與其他位數進行衝突,就比如,你單純排0到9之間的資料,你使用低位基數排序時,也是可以不用任何操作,遍歷方向為0到len-1,一個道理。

堆排序

堆排序就是利用特殊的資料結構堆來實現對資料的排序。堆分為“最大堆”和“最小堆”,最大堆通常被用來進行"升序"排序,而最小堆通常被用來進行"降序"排序。本文主要分析最大堆的“升序”排序。
我利用陣列實現最大堆,最大堆排序程式碼如下:

public static void maxHeapSort(int[] array){
    int len = 0;
    if(array == null || (len = array.length) < 2){
        return;
    }
    int[] maxHeapArray = new int[len];
    for(int i = 0; i < len; i++){
        addElement(maxHeapArray, i, array[i]);
    }
    for(int i = len - 1; i >= 0; i--){
        array[i] = removeElement(maxHeapArray, i);
    }
}
private static void addElement(int[] maxHeapArray, int index, int value){
    maxHeapArray[index] = value;
    while(index > 0){
        int fatherIndex = (index - 1) >> 1;
        if(value  > maxHeapArray[fatherIndex]){
            swap(maxHeapArray, index, fatherIndex);
            index = fatherIndex;
        }else{
            break;
        }
    }
}
private static int removeElement(int[] maxHeapArray, int index){
    int oldValue = maxHeapArray[0];
    maxHeapArray[0] = maxHeapArray[index];
    int indexLimit = index - 1;
    index = 0;
    while(index <= indexLimit){
        int left = 2 * index + 1;
        left = (left > indexLimit) ? index : left;
        int right = 2 * index + 2;
        right = (right > indexLimit) ? index : right;
        int maxIndex = (maxHeapArray[left] > maxHeapArray[right]) ? left : right;
        if(maxHeapArray[index] < maxHeapArray[maxIndex]){
            swap(maxHeapArray, index, maxIndex);
            index = maxIndex;
        }else{
            break;
        }
    }
    return oldValue;
}

該演算法主要利用最大堆的資料結構性質,如果原理不太理解,可以看看資料結構關於堆的知識。

後記

程式碼部分其實參考了很多優秀的部落格和文章,因為時間有點久,很多出處都忘記了,如果有人提醒,我會補上參考連結的。
終於寫完了,長舒一口氣。第一次寫部落格,還真的有點忐忑,生怕自己的無知會誤導到別人。本來只是想隨便寫寫排序演算法,沒想到洋洋灑灑寫了這麼多。所有程式碼,我都親自測試過,生怕出現問題。希望在以後的學習中,我能一直保持嚴謹的態度。