今天總結一下兩種性能優秀的排序算法,歸並排序與快速排序。
首先,二者都運用了遞歸和分治的兩種重要思想。在這里遞歸就不做詳細介紹。
分治:顧名思義,分而治之,這是在排序中我們非常常見的一種思想,同時也是在其他場景乃至日常生活的優秀解題方法。當我們遇到一個大的難題無從下手時,我們往往都會將其分成幾個小塊,當我們處理好每個小模塊問題后,將其合並,大的問題便能夠的以解決。同樣,在我們處理排序問題時,也能充分利用分治思想來提高性能。
那么我們先來總結歸並排序
歸並排序(自頂向下,之后有簡單的自底向上講解)
歸並排序的思想其實很簡單,總共分為兩步,分與治。
當我們面對一個很大的數組時,用以往學過的冒泡,插入,選擇排序總是有些過於緩慢,希爾排序雖然可以,但是也不是最好的選擇,因為它時間花費是平方級別的。如果我們內存中有足夠大的空間,我們不妨使用提高空間復雜度來換取減少時間復雜度的思想,這樣便能更快完成排序。
歸並排序時,我們先將要進行排序的數組分為兩部分,我們叫做Left和Right,如果我們先將這兩部分都進行排序完成后,即子數組Left和Right都是有序數組。那么我們將這兩個數組進行合並。
下面是具體思路:首先創建一個與原數組容量相同的數組用來存放合並時的數據,然后比較Left和Right中的數組,如果Left[0]<Right[0],將Left[0]放入新數組的0索引處,然后比較Left[1]和Right[0],依次類推按照升序或降序的方式便能將Left和Right中所有數組按照一定順序拷貝進入新數組,此時就完成了數組排序。
那么我們又該如何對Left和Right數組進行排序呢?實際上,歸並排序的模型中並不是將一個數組只分為兩塊,而是分為數組最小單元,length = 1,再對每個最小單元進行治的處理,這種處理方式要通過遞歸思想來進行實現。
下面讓我們結合圖片來進行詳細的解釋。
我們可以看到,歸並排序將一個數組進行有限次的分割,再進行相同次數的合並,將整個數組進行排序,在我們使用遞歸實現歸並排序時,並不需要過多的關注每一步都是如何進行操作,只需要將大體的思路分析清楚即可進行操作。
下面來看一段代碼。
package SORT;
public class MergeSort {
public static void mergeSort(int[] arr) {
int[] temp = new int[arr.length];
mergeSort(arr, 0, arr.length - 1, temp);
}
public static void mergeSort(int[] arr, int left, int right, int[] temp) {
//參數分別為 待排序數組,左指針,有指針,輔助數組
//因為使用了遞歸,所以我們必須規定遞歸條件否則將進行無線循環
while (left < right) {
//將數組進行分割
int mid = (left + right) / 2;
//對左子數組繼續進行歸並排序
mergeSort(arr, left, mid, temp);
//對右子數組繼續進行歸並排序
mergeSort(arr, mid + 1, right, temp);
//將數組進行合並
Merge(arr, left, mid, right, temp);
}
}
//合並函數
public static void Merge(int[] arr, int left, int mid, int right, int[] temp) {
int i = left;
int j = mid + 1;
//t為輔助數組的索引
int t = 0;
while (i <= mid && j <= right) {
//當二者都沒有到達最后一位時,進行比較並向輔助數組復制
if (arr[i] < arr[j]) {
temp[t++] = arr[i++];
} else {
temp[t++] = arr[j++];
}
}
//當其中一個數組復制完畢后,將另一個數組內的數組全部復制進輔助數組
while (i <= mid) {
temp[t++] = arr[i++];
}
while (j <= right) {
temp[t++] = arr[j++];
}
t = 0;
//將輔助數組內已經排好的數據全部復制進原數組,排序完成
while (left <= right) {
arr[left++] = temp[t++];
}
}
}
在注釋中很詳細的分析了代碼各個步驟的意義和實現方法。下面主要說下歸並排序的一些其他特點。
對小規模的數組使用插入排序
我們知道,插入排序適合小規模和數組內數據具有一定順序的情況。當我們使用歸並排序時,在數組規模很小時依舊要進行分治處理,這樣可能會因為過於頻繁的遞歸調用函數,造成一定的性能損失(當然對於總體來說,這個損失並非不可承受),那么我們可以考慮一種更加折中的辦法,我們來設置一個閥值,當我們將數組分給到以這個閥值為容量的子數組時,我們就不再進行遞歸處理,來使用更加快捷的插入排序,這樣能夠提升一定的性能,當然這個閥值的設置並沒有明確指出,需要我們進行多次測試。這算是對歸並排序的一個小小補充,根據一些資料測試,能夠提升大約百分之二十左右的性能。
下面我們總結自底向上的歸並排序
接下來我們簡單談一下,自底向上的歸並排序。這種思想主要是先歸並那些微型數組,然后再成對歸並所得到的子數組,直到我們將整個數組進行完全的排序。實現這種歸並的方式代碼將更加簡潔。
首先我們進行兩兩歸並(把每個元素都當作大小為1的數組),然后四四歸並(將大小為2的數組歸並為大小為4的數組),通過這種方式,最后歸並的兩個數組可能大小不等,但和依舊為原數組。這種方式是從數據一端開始進行歸並,而非自頂向下中從兩端進行歸並。
那么這兩種方式區別又再哪里呢?當數組長度為2的N次冪時,這兩種方式對數組的訪問是相同的,對於時間消耗也是相同的。其他時候,這兩種對數組訪問次數和次序有所不同。
自底向上的歸並排序比較適合用鏈表組織數據,當我們按照大小為1的子鏈表進行排序,然后是大小為2,大小為4.我們只需要重新將鏈表連接就能進行原地排序。不需要開辟額外空間。
關於希爾排序和遞歸排序速度一直是一個具有爭議的問題,《算法》第四版中指出,在實際應用中,二者運行時間之間的差距在常數級別之內(希爾排序使用為經驗證的遞增序列),因此性能取決於具體實現。理論上來說,還沒有人能夠證明希爾排序對於隨機數據的運行時間是線性對數接別的,因此存在平均情況下希爾排序的性能增長率更高的可能性,而在最壞情況下,這種差距的存在已經被證明了,但對於實際使用沒有影響。
快速排序
快速排序作為應用最廣泛的排序算法,流行的原因主要因為實現簡單。快排具有非常大的優勢在於兩方面。
快速排序是原地排序(只需要非常小的一個輔助棧)
快速排序時間消耗,長度為N的數組排序時間與NlgN成正比
目前大多數排序算法都不能將這兩點結合實現。而且快速排序的內循環比大多數排序的內循環要簡潔許多,這樣無論從理論上還是實際使用上都會更快。但是快速排序主要的缺點已經非常明顯,快速排序非常脆弱,使用時要非常小心才能避免性能變的低劣。很多例子表明快速排序因為一些錯誤使時間的消耗成為平方級。
下面來看下快速排序的定義。
快速排序同樣是一種分治思想的排序方法。它將一個數組分為連個數組,將兩部分獨立排序。快速排序和歸並排序是互補的:歸並排序將數組分成兩個子數組分別排序,並將有序的子數組歸並后整個排序,而快速排序將數組排序的方式則是當兩個自數據都有序是整個數組也就自然有序了。這段定義出自於《算法》第四版,相信看起來會有些迷茫。下面我用自己總結的話來描述下快速排序的實現方法。
當我們面對一個要排序的數組時,我們首先要找一個基准數(這個基准數沒有特殊限定,一般去數組第一個數),然后我們想辦法將數組中大於這個基准數的數據放在基准數的一端,小於基准數的數據放在另一端。現在我們就得到了兩個子數組,雖然這兩個子數組並不是排序完成的,但我們能確定,其中一個子數組內所有的數都小於另一個,也就是說,當我們將這兩個子數組排序完成時,整個數組自然就有序了。那么現在就是使用分治思想的地方,分開這兩個數組后,再利用上述思想,同樣找基准數,將數據按基准數分開兩邊,這時,我們就擁有了四個子數組。以此下去,當我們每個子數組都是有序的,那么我們就排序完成了。通過遞歸我們可以實現這一步驟。
以上過程由圖片表示就是
那么我們如何經過代碼實現呢?
首先我們需要在數組兩端分別設置兩個引用(指針),左指針任務是向右掃描數組,當掃描到大於基准數(按照升序)的數據后停下,右指針向左掃描,每當找到小於基准數的數據時停下。當二者都停下時,交換現在二者指向的數據。最后將基准數和分界處數據的交換,整個過程就完成了,這時,數組就由基准數一分為二。
下面我們來看代碼實現
package SORT;
public class QuickSort {
public static void quickSort(int[] arr) {
// 對函數進行封裝
quickSor(arr, 0, arr.length - 1);
}
public static void quickSor(int[] arr, int left, int right) {
if (left >= right) {
return;
}
int i = left;// 左哨兵
int j = right;// 右哨兵
int index = arr[i];// 基准數
int t = 0;
while (i < j) {
//當右側數據大於基准數時,右指針向左掃描
while (arr[j] > index) {
j--;
}
//當左側數據小於基准數時,左指針右左掃描
while (arr[i] < index) {
i++;
}
//當二者都停下時,交換數據
if (i < j) {
t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
//當整個過程完成時,將基准數和分界處的數據互換
arr[left] = arr[i];
arr[i] = index;
//遞歸處理
quickSor(arr, left, i - 1);
quickSor(arr, i + 1, right);
}
}
}
下面我們來總結下快速排序需要注意的幾個重點
防止越界
當我們要處理數組中最大活着最小的元素時,一定要注意指針的計算方法,在這里非常容易發成數組下標越界的異常。
終止遞歸
因為快速排序使用了遞歸處理,所以需要設置終止循環條件,忘記設置編譯器會報錯,但是如果設置錯誤,沒有注意細節,非常可能造成程序陷入死循環,而我們在處理死循環時首先想到的總是內循環代碼,想要再次發現問題有很大困難,這就要求我們在設計程序時,優先將遞歸終止循環條件設計好。
下面來看下快速排序的算法改進問題
當我們要將快排多次執行或者放置在一個大型的庫函數上時,我們應該更多考慮快排的性能優化,優秀的優化方案能對性能有很大幫助。
切換到插入排序
這是我們之前就提到的一個思想,當我們對小數組進行操作時,插入排序可能要比遞歸歸並速度有所提高(《算法》中指出在5-15大小數組時,性能更優秀),所有可以對小數組進行插入排序,實現起來也非常簡單
if (left + M >= right) {
//插入排序代碼,具體操作不進行實現
sort(arr,left,right);
return;
}
只需要將跳出遞歸的語句進行改變。M值即為數組大小參數。原理為,當我們掃描到一定長度后,左指針和右指針差距就是M,這是就不進行遞歸處理了,進行插入排序后直接跳出循環。
熵最優的排序
在實際應用中,我們可能出現一個數組中有大量重復數據,例如人員生日等。在這種情況下,我們使用快速排序性能尚可,但對此有巨大的改進空間,具有將線性對數級改為線性級的潛能。當一個數組內具有很多相同數據時,進行分割后很有可能子數組內的數據都完全一樣,這樣的數組我們可以不需要排序,但是因為遞歸的使用,程序依舊會將其按照歸並分割,這樣勢必將會造成資源的大量浪費。
這種優化方法叫做三向切分法快速排序,具體實現就不在這里進行展示,有時間單開出一個文章來詳細總結一下。
---------------------
作者:問巷
來源:CSDN
原文:https://blog.csdn.net/weixin_41582192/article/details/81239266
版權聲明:本文為博主原創文章,轉載請附上博文鏈接!