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;
}
該演算法主要利用最大堆的資料結構性質,如果原理不太理解,可以看看資料結構關於堆的知識。
後記
程式碼部分其實參考了很多優秀的部落格和文章,因為時間有點久,很多出處都忘記了,如果有人提醒,我會補上參考連結的。
終於寫完了,長舒一口氣。第一次寫部落格,還真的有點忐忑,生怕自己的無知會誤導到別人。本來只是想隨便寫寫排序演算法,沒想到洋洋灑灑寫了這麼多。所有程式碼,我都親自測試過,生怕出現問題。希望在以後的學習中,我能一直保持嚴謹的態度。