1. 程式人生 > >演算法導論(一):快速排序與隨機化快排

演算法導論(一):快速排序與隨機化快排

排序演算法是演算法學習的第一步,想當初我學的第一個演算法就是選擇排序,不過當時很長一段時間我都不清楚我到底用的是選擇還是冒泡還是插入。只記得兩個for一個if排序就完成了。

再後來更系統地接觸演算法,才發現那才是排序演算法隊伍中小小而基本的一員。

買的《演算法導論》一直沒有認真地看一看,下來要找實習找工作,為了做準備,也是為了複習一下演算法,便扒出來好好學一學,並做一些記錄,免得我金魚般的記憶使我看了和沒看一樣。

快速排序

快速排序用到了分治思想,同樣的還有歸併排序。乍看起來快速排序和歸併排序非常相似,都是將問題變小,先排序子串,最後合併。不同的是快速排序在劃分子問題的時候經過多一步處理,將劃分的兩組資料劃分為一大一小,這樣在最後合併的時候就不必像歸併排序那樣再進行比較。但也正因為如此,劃分的不定性使得快速排序的時間複雜度並不穩定。

快速排序的期望複雜度是O(nlogn),但最壞情況下可能就會變成O(n^2),最壞情況就是每次將一組資料劃分為兩組的時候,分界線都選在了邊界上,使得劃分了和沒劃分一樣,最後就變成了普通的選擇排序了。

快速排序分為三步分治過程,劃分,解決,合併。

分解是將輸入陣列A[l..r]劃分成兩個子陣列的過程。選擇一個p,使得a被劃分成三部分,分別是a[l..p-1],a[p]和a[p+1..r]。並且使得a[l..p-1]中的元素都小於等於(或大於等於)a[p],同時a[p]小於等於(或大於等於)a[p+1..r]中的所有元素。

解決是呼叫遞迴程式,解決分解中劃分生成的兩個子序列。

合併

是遞迴到最深層,已經不能再劃分成更小的子序列了,便開始合併。因為在分解的時候已經比較過大小,每一個父序列分解而來的兩個子序列不僅是有序的,而且合併成一個序列之後還是有序的。因為快排可以在輸入陣列上進行操作,所以合併這一步不需要編寫程式碼。

《演算法導論》上稱這樣的排序為原址排序,即在原陣列上操作就可以完成排序,不需要臨時陣列。

書上的程式碼非常簡潔巧妙,我就不把書上的虛擬碼照抄上來了,這裡給出Java的實現程式碼以供參考:

//快速排序
public static void QuickSort(int[] a, int left, int right) {
    if (left < right) {
        int
p = partition(a, left, right); QuickSort(a, left, p - 1); QuickSort(a, p + 1, right); } } //快速排序陣列劃分 private static int partition(int[] a, int left, int right) { int x = a[right]; int p = left - 1; for (int i = left; i < right; i++) { if (a[i] <= x) { p++; swap(a, p, i); } } swap(a, p+1, right); return p+1; }

其中的swap函式如下:

//交換陣列a中的a[i]和a[j]
private static void swap(int[] a, int i, int j) {
    int temp = a[i];
    a[i] = a[j];
    a[j] = temp;
}

QuickSort(int[] a,int left,int right)函式沒什麼好說的,設定遞迴邊界,接下來遞迴處理左序列,再處理右序列。

下來的partition(int[] a, int left, int right)就比較有意思了。

int x = a[right];這行程式碼選中一個主元,這裡我們每次選擇的都是當前序列中最右邊那個。int p = left - 1;這行程式碼儲存了一個變數p,用來記錄比主元小的所有元素中,在序列中存放的位置最靠右的那個。接下來是個迴圈,從當前序列的第一個迴圈到倒數第二個(right-1)元素,來進行和主元比較。因為最後一個已經是主元了,所以就沒有必要迴圈到right了。迴圈裡面先是一個比較if (a[i] <= x)。這裡寫的是小於等於,更改這個就可以改變序列式由小到大還是由大到小排列。這裡則是由小到大排列。如果進入了if語句,則說明a[i](當前元素)比主元小,還記得之前的變數p嗎,儲存著比主元小的元素最右邊的位置,這裡先p++,接著把a[i]和a[p]交換,就是說把a[p]右邊的元素和當前元素換位置。a[p]右邊的元素是什麼呢?可能就是當前元素,也可能是比主元大的元素。這樣,就完成了比主元小的元素的處理。

可是如果a[i]>x呢,則不進入if執行這兩行程式碼,也就是不動那個比主元大的元素。

這樣直到迴圈結束,整個序列就變成了三部分,從a[left..p]是比主元小的元素,a[p+1..right-1]是比主元大的元素,a[right]則是主元。而我們劃分的目的是將主元放在這兩個序列的中間,則再執行一行語句swap(a, p+1, right);,將主元和比它大序列的第一個元素互換位置,就大功告成了。

書上的圖解非常的清晰:(標號是根據上面程式碼所標,和書上不太一樣,但意思是一樣的)

快排劃分圖解

這張圖描述了一次劃分。淺藍色部分是不大於主元的部分,深藍色部分是大於主元的部分。沒有顏色的是還未處理的元素,最後的元素則是主元。

快速排序的主要內容差不多就這些了,書上接下來證明了快速排序的正確性,以及計算了其時間複雜度。之後討論了劃分的不平衡性所導致的效能退化。對一個排好序的序列使用上述快排,時間複雜度為O(n^2),而插入排序則僅為O(n)。因此便有了隨機化快速排序的出現。

快速排序的隨機化版本

上面版本的快排在選取主元的時候,每次都選取最右邊的元素。當序列為有序時,會發現劃分出來的兩個子序列一個裡面沒有元素,而另一個則只比原來少一個元素。為了避免這種情況,引入一個隨機化量來破壞這種有序狀態。

在隨機化的快排裡面,選取a[left..right]中的隨機一個元素作為主元,然後再進行劃分,就可以得到一個平衡的劃分。

實現起來其實只需要對上面的程式碼做小小的修改就可以了。

//快速排序的隨機化版本,除了呼叫劃分函式不同,和之前快排的程式碼結構一模一樣
public static void RandomQuickSort(int[] a, int left, int right) {
    if (left < right) {
        int p = randomPartition(a, left, right);
        RandomQuickSort(a, left, p - 1);
        RandomQuickSort(a, p + 1, right);
    }
}

//隨機化劃分
public static int randomPartition(int[] a, int left, int right) {
    int r = random.nextInt(right - left) + left; //生成一個隨機數,即是主元所在位置
    swap(a, right, r); //將主元與序列最右邊元素互換位置,這樣就變成了之前快排的形式。
    return partition(a, left, right); //直接呼叫之前的程式碼
}

這裡的random是一個已經初始化過的Random的靜態物件。

隨機化快排就這樣就ok了。

效能分析

隨機序列

為了比較普通快排和隨機化快排的效能,我做了一些測試。因為沒有太多的經驗,測試結果僅供參考。:)

陣列生成程式碼

Random random = new Random(Calendar.getInstance().getTimeInMillis());
int[] a = new int[10000000];
int[] b = new int[10000000];
for (int i = 0; i < a.length; i++) {
    a[i] = random.nextInt(Integer.MAX_VALUE);
    b[i] = a[i];
}

測試程式碼:

//隨機化快排
startTime = System.currentTimeMillis();
Sort.RandomQuickSort(a, 0, a.length - 1);
endTime = System.currentTimeMillis();
o(String.format("RandomQuickSort Finished. Cost %dms\n", endTime - startTime));//o是一個輸出函式,把系統的System.out.print()簡單封裝了一下,打起來短一些……
//快排
startTime = System.currentTimeMillis();
Sort.QuickSort(b, 0, b.length - 1);
endTime = System.currentTimeMillis();
o(String.format("QuickSort Finished. Cost %dms\n", endTime - startTime));

結果:

RandomQuickSort Finished. Cost 1417ms
QuickSort Finished. Cost 1367ms

多次實驗結果:

10w:

演算法 第1次耗時 第2次耗時 第3次耗時 平均耗時
普通快排 13ms 15ms 15ms 14.333ms
隨機化版本快排 25ms 25ms 27ms 25.667ms

100w:

演算法 第1次耗時 第2次耗時 第3次耗時 平均耗時
普通快排 101ms 103ms 96ms 100ms
隨機化版本快排 119ms 101ms 105ms 108.333ms

1000w:

演算法 第1次耗時 第2次耗時 第3次耗時 平均耗時
普通快排 1397ms 1379ms 1338ms 1371.333ms
隨機化版本快排 1241ms 1187ms 1258ms 1228.667ms

隨機化快排因為要生成隨機數,所以有一些效能損失,所以資料規模較小,資料分佈均勻時普通快排還是比隨機化快排要快些的,不過隨著資料規模的上升,隨機化快排的效能優勢就展現出來了。

有序序列

下來才是展示快排才華的時候,假設當輸入陣列已經是排好序的,這兩個演算法的效能差距又有多少?
之前的陣列生成程式碼不變,只是在呼叫兩個演算法之前,先呼叫一下快排將陣列排序,然後將兩個有序的陣列作為引數傳進去。

10w:

10w的普通快排……已經棧溢位了。

演算法 第1次耗時 第2次耗時 第3次耗時 平均耗時
普通快排 溢位 溢位 溢位 溢位
隨機化版本快排 15ms 7ms 6ms 9.333ms

1w:

試一試1w的

演算法 第1次耗時 第2次耗時 第3次耗時 平均耗時
普通快排 98ms 94ms 92ms 94.667ms
隨機化版本快排 2ms 1ms 0ms 1ms

1000w:

看下1000w下隨機化快排是否有影響

演算法 第1次耗時 第2次耗時 第3次耗時 平均耗時
隨機化版本快排 696ms 733ms 689ms 706ms

這篇筆記就到這兒了,希望能通過講解一遍加深記憶,也希望能給別人帶來哪怕一點點幫助。

本人水平有限,如果有什麼錯誤,請告訴我[email protected],我會感激不盡!因為錯誤不僅會矇蔽自己,也有可能會誤導別人。

參考書籍:機械工業出版社 第三版《演算法導論》部分內容引自原書