1. 程式人生 > >排序演算法雜談(五) —— 關於快速排序的優化策略分析

排序演算法雜談(五) —— 關於快速排序的優化策略分析

1. 前提

2. 優化策略1:主元(Pivot)的選取

歸併排序(Merge Sort)有一個很大的優勢,就是每一次的遞迴都能夠將陣列平均二分,從而大大減少了總遞迴的次數。

而快速排序(Quick Sort)在這一點上就做的很不好。

快速排序是通過選擇一個主元,將整個陣列劃分(Partition)成兩個部分,小於等於主元 and 大於等於主元。

這個過程對於陣列的劃分完全就是隨機的,俗稱看臉吃飯。

這個劃分是越接近平均二分,那麼這個劃分就越是優秀;而若果不巧取到了陣列的最大值或是最小值,那這次劃分其實和沒做沒有什麼區別。

因此,主元的選取,直接決定了一個快速排序的效率。

通過之前快速排序的學習,我們知道了基本上有兩種主流的劃分方式,我將其稱之為:

  • 挖坑取數
  • 快慢指標

前者將最左側的數作為主元,後者將最右側的數作為主元,這種行為完全就是隨機取數。

最簡單的的方法,就是在範圍內取一個隨機數,但是這種方法從概率的角度上來說,和之前的沒有區別。

進一步的思考,可以從範圍內隨機取出三個數字,找到三個數字的中位數,然後和原主元的位置進行交換。

將中位數作為主元,相比於隨機取出的另外兩個數字,對於劃分的影響還是很明顯的。

 1 package com.gerrard.sort.compare.quick.partition.pivot;
 2 
 3 import com.gerrard.util.RandomHelper;
 4 
 5
public final class MediumPivot implements Pivot { 6 7 @Override 8 public int getPivotIndex(int[] array, int left, int right) { 9 int index1 = RandomHelper.randomBetween(left, right); 10 int index2 = RandomHelper.randomBetween(left, right); 11 int index3 = RandomHelper.randomBetween(left, right);
12 if (array[index1] > array[index2]) { 13 if (array[index2] > array[index3]) { 14 return index2; 15 } else { 16 return array[index1] > array[index3] ? index3 : index1; 17 } 18 } else { 19 if (array[index1] > array[index3]) { 20 return index3; 21 } else { 22 return array[index2] > array[index3] ? index3 : index2; 23 } 24 } 25 } 26 }

3. 優化策略2:閾值的選取

同樣是參考歸併排序的優化策略,歸併排序可以通過判斷陣列的長度,設定一個閾值。

陣列長度大於閾值的,使用歸併排序策略。

陣列長度小於閾值的,使用直接插入排序。

通過這種方式,歸併排序避免了針對小陣列時候的遞迴(遞迴層次增加最多的場景,就是大量的小陣列),從而減輕了JVM的負擔。

 1 public class OptimizedQuickSort implements Sort {
 2 
 3     private ThreeWayPartition partitionSolution = new ThreeWayPartition();
 4     private int threshold = 2 << 4;
 5 
 6     public void setPartitionSolution(ThreeWayPartition partitionSolution) {
 7         this.partitionSolution = partitionSolution;
 8     }
 9 
10     public void setThreshold(int threshold) {
11         this.threshold = threshold;
12     }
13 
14     @Override
15     public void sort(int[] array) {
16         sort(array, 0, array.length - 1);
17     }
18 
19     private void sort(int[] array, int left, int right) {
20         if (right - left < threshold) {
21             insertionSort(array, left, right);
22         } else if (left < right) {
23             int[] partitions = partitionSolution.partition(array, left, right);
24             sort(array, left, partitions[0] - 1);
25             sort(array, partitions[1] + 1, right);
26         }
27     }
28 
29     private void insertionSort(int[] array, int startIndex, int endIndex) {
30         for (int i = startIndex + 1; i <= endIndex; ++i) {
31             int cur = array[i];
32             boolean flag = false;
33             for (int j = i - 1; j > -1; --j) {
34                 if (cur < array[j]) {
35                     array[j + 1] = array[j];
36                 } else {
37                     array[j + 1] = cur;
38                     flag = true;
39                     break;
40                 }
41             }
42             if (!flag) {
43                 array[0] = cur;
44             }
45         }
46     }
47 }

4. 優化策略3:三路劃分

從上面的程式碼中,我們可以看到一個 ThreeWayPartition,這就是現在要講的三路劃分。

回顧之前的快速排序劃分的描述:

快速排序是通過選擇一個主元,將整個陣列劃分成兩個部分,小於等於主元 and 大於等於主元。

不難發現,一次劃分之後,我們將原陣列劃分成了三個部分,小於等於主元 and 主元 and 大於等於主元,劃分結束之後,再將主元兩側進行遞迴。

由此可見,等於主元的部分被劃分到了三個部分,那麼我們就有了這樣的思考:

能不能將陣列明確地劃分成三個部分:小於主元 and 主元和等於主元 and 大於主元。

這樣一來,等於主元的部分就直接從下一次的遞迴中去除了。

回看一下 “挖坑取數” 的程式碼:

 1     @Override
 2     public int partition(int[] array, int left, int right) {
 3         int pivot = array[left];
 4         int i = left;
 5         int j = right + 1;
 6         boolean forward = false;
 7         while (i < j) {
 8             while (forward && array[++i] <= pivot && i < j) ;
 9             while (!forward && array[--j] >= pivot && i < j) ;
10             ArrayHelper.swap(array, i, j);
11             forward ^= true;
12         }
13         return j;
14     }

在內迴圈中,我們的判斷條件是: array[++i] <= pivot。

在這個基礎上,再做一次判斷,針對等於 pivot 的情況,將等於 pivot 的值,與一個已經遍歷過的位置交換:

  • 從左往右找大於 pivot 的值時,與陣列開頭部分交換。
  • 從右往左找小於 pivot 的值時,與陣列結束部分交換。

那麼,在整個劃分結束之後,我們會得到這麼一個數據模型:

其中:

  • 等於 pivot:[left,p) & i & (q,right]
  • 小於 pivot:[p,i)
  • 大於 pivot:(j,q]

然後將 left->p 的資料依次交換到 i 的左側,同理,將q->right 的資料依次交換到 j 的右側。

這樣我們就能得到整個陣列關於 pivot 的嚴格大小關係:

  • 等於 pivot:[p',q']
  • 小於 pivot:[left,p')
  • 大於 pivot:(q',right]
 1 package com.gerrard.sort.compare.quick.partition;
 2 
 3 import com.gerrard.sort.compare.quick.partition.pivot.Pivot;
 4 import com.gerrard.util.ArrayHelper;
 5 
 6 /**
 7  * Three-Way-partition is an optimized solution for partition, also with complexity O(n).
 8  * It directly separate the original array into three parts: smaller than pivot, equal to pivot, larger than pivot.
 9  * It extends {@link SandwichPartition} solution.
10  *
11  * Step1: Select the left one as pivot.
12  * Step2: Besides i and j, define two more index p and q as two sides index.
13  * Step3: Work as SandwichPartition, from sides->middle, the only difference is:
14  *        when meeting equal to pivot scenario, swap i and p or j and q.
15  *
16  * Step4: After iterator ends, the array should look like:
17  *
18  *        left                   i=j                     right
19  *        ---------------------------------------------------
20  *        |     |           |     |     |               |   |
21  *        ---------------------------------------------------
22  *              p           p'          q'              q
23  *
24  *        The distance between left->p and p'->i should be same.
25  *        The distance between j->q' and q->right should also be same.
26  *        [left,p) and (q,right] is equal to pivot, [p,i) is smaller than pivot, (j,q] is larger than pivot.
27  *
28  * Step5: Exchange [left,p) and [p',i), exchange (q,right] and (j,q'].
29  * Step6: Returns two number p'-1 and q'+1.
30  *
31  */
32 public final class ThreeWayPartition {
33 
34     public int[] partition(int[] array, int left, int right) {
35         if (pivotSolution != null) {
36             int newPivot = pivotSolution.getPivotIndex(array, left, right);
37             ArrayHelper.swap(array, left, newPivot);
38         }
39         int pivot = array[left];
40         int i = left;
41         int j = right + 1;
42         int p = i;
43         int q = j - 1;
44         boolean forward = false;
45         while (i < j) {
46             while (forward && array[++i] <= pivot && i < j) {
47                 if (array[i] == pivot) {
48                     ArrayHelper.swap(array, i, p++);
49                 }
50             }
51             while (!forward && array[--j] >= pivot && i < j) {
52                 if (array[j] == pivot) {
53                     ArrayHelper.swap(array, j, q--);
54                 }
55             }
56             ArrayHelper.swap(array, i, j);
57             forward ^= true;
58         }
59         while (p > left) {
60             ArrayHelper.swap(array, --p, --i);
61         }
62         while (q < right) {
63             ArrayHelper.swap(array, ++q, ++j);
64         }
65         return new int[]{i, j};
66     }
67 }

5. 優化測試

最後,針對各種快速排序的演算法,我做了一系列的效能測試:

 1 package com.gerrard.helper;
 2 
 3 import com.gerrard.sort.Sort;
 4 
 5 public final class ComparableTestHelper {
 6 
 7     private ComparableTestHelper() {
 8 
 9     }
10 
11     public static void printCompareResult(int[] array, Sort... sorts) {
12         for (Sort sort : sorts) {
13             int[] copyArray = ArrayTestHelper.copyArray(array);
14             long t1 = System.nanoTime();
15             sort.sort(copyArray);
16             long t2 = System.nanoTime();
17             double timeInSeconds = (t2 - t1) / Math.pow(10, 9);
18             System.out.println("Algorithm " + sort + ", using " + timeInSeconds + " seconds");
19         }
20     }
21 }

測試結果:

 從測試結果中,我們可以發現:

  • 取原來的主元,和用隨機數做主元,對於效能的影響完全是隨機的。
  • 取中位數做主元,對於效能有著比較明顯的提高。
  • 增加閾值,對於效能也有提高,但是閾值選取的數值,還有待深一步的研究。
  • 三路快排,在陣列區間較小的情況,對於效能的影響是顯著的,但是陣列區間較大時,對於效能有一定的影響。
  • 遞迴轉迭代的方式,能規避StackOverFlow的情況。

但是還有幾個比較奇怪的現象:

  • 快速排序,對於陣列內部有很多數字相等的情況,處理情況不佳。
  • 快慢指標的方式,對於數字相等的情況,效率降低明顯。
  • 挖坑填數的方式,比快慢指標的方式,更容易出現StackOverFlow的情況,而快慢指標似乎通過了某種時間為代價的方式,規避了這種情況。

希望有讀者能夠解惑這些現象。