莊周夢蝶

          生活、程序、未來
             :: 首頁 ::  ::  :: 聚合  :: 管理

          快速排序及優化

          Posted on 2010-09-08 19:10 dennis 閱讀(19935) 評論(9)  編輯  收藏 所屬分類: 數據結構與算法
              update:更正選擇中數的描述,在7到39之間的數組大小選擇median-of-three來選擇pivot,大小等于7的數組則直接使用中數作為pivot。

              quicksort可以說是應用最廣泛的排序算法之一,它的基本思想是分治法,選擇一個pivot(中軸點),將小于pivot放在左邊,將大于pivot放在右邊,針對左右兩個子序列重復此過程,直到序列為空或者只有一個元素。這篇blog主要目的是關注quicksort可能的改進方法,并對這些改進方法做評測。其目的是為了理解Arrays.sort(int [ ]a)的實現。實現本身有paper介紹。

              quicksort一個教科書式的簡單實現,采用左端點做pivot(《算法導論》上偽代碼是以右端點做pivot):
          public void qsort1(int[] a, int p, int r) {
                  
          // 0個或1個元素,返回
                  if (p >= r)
                      
          return;
                  
          // 選擇左端點為pivot
                  int x = a[p];
                  
          int j = p;
                  
          for (int i = p + 1; i <= r; i++) {
                      
          // 小于pivot的放到左邊
                      if (a[i] < x) {
                          swap(a, 
          ++j, i);
                      }
                  }
                  
          // 交換左端點和pivot位置
                  swap(a, p, j);
                  
          // 遞歸子序列
                  qsort1(a, p, j - 1);
                  qsort1(a, j 
          + 1, r);
              }
              其中的swap用于交換數組元素:
              public static void swap(int[] a, int i, int j) {
                  
          int temp = a[i];
                  a[i] 
          = a[j];
                  a[j] 
          = temp;
              }

              quicksort的最佳情況下的時間復雜度O(n logn),最壞情況下的時間復雜度是O(n^2),退化到插入排序的最壞情況,平均情況下的平均復雜度接近于最佳情況也就是O(nlog n),這也是基于比較的排序算法的比較次數下限。

              為了對排序算法的性能改進有個直觀的對比,我們建立一個測試基準,分別測試隨機數組的排序、升序數組的排序、降序數組的排序以及重復元素的數組排序。首先使用java.util.Arrays.sort建立一個評測基準,注意這里的時間單位是秒,這些絕對時間沒有意義,我們關注的是相對值,因此這里我不會列出詳細的評測程序:
           算法  隨機數組  升序數組  降序數組  重復數組
           Arrays.sort  136.293  0.548  0.524  26.822

             qsort1對于輸入做了假設,假設輸入是隨機的數組,如果排序已經排序的數組,qsort1馬上退化到O(n^2)的復雜度,這是由于選定的pivot每次都會跟剩余的所有元素做比較。它跟Arrays.sort的比較:
           算法  隨機數組  升序數組  降序數組  重復數組
           Arrays.sort  136.293  0.548  0.524  26.822
           qsort1  134.475  48.498  141.968  45.244

              果然,在排序已經排序的數組的時候,qsort的性能跟Arrays.sort的差距太大了。那么我們能做的第一個優化是什么?答案是將pivot的選擇隨機化,不再是固定選擇左端點,而是利用隨機數產生器選擇一個有效的位置作為pivot,這就是qsort2:
          public void qsort2(int[] a, int p, int r) {
                  
          // 0個或1個元素,返回
                  if (p >= r)
                      
          return;
                  
          // 隨機選擇pivot
                  int i = p + rand.nextInt(r - p + 1);
                  
          // 交換pivot和左端點
                  swap(a, p, i);
                  
          // 劃分算法不變
                  int x = a[p];
                  
          int j = p;
                  
          for (i = p + 1; i <= r; i++) {
                      
          // 小于pivot的放到左邊
                      if (a[i] < x) {
                          swap(a, 
          ++j, i);
                      }
                  }
                  
          // 交換左端點和pivot位置
                  swap(a, p, j);
                  
          // 遞歸子序列
                  qsort2(a, p, j - 1);
                  qsort2(a, j 
          + 1, r);
              }

              再次進行測試,查看qsort1和qsort2的對比:
           算法  隨機數組  升序數組  降序數組  重復數組
           qsort1  134.475  48.498  141.968  45.244
           qsort2  227.87  19.009  18.597  74.639

             從隨機數組的排序來看,qsort2比之qsort1還有所下降,這主要是隨機數產生器帶來的消耗,但是在已經排序數組的排序上面,qsort2有很大進步,在有大量隨機重復元素的數組排序上,qsort2卻有所下降,主要消耗也是來自隨機數產生器的影響。

             更進一步的優化是在劃分算法上,現在的劃分算法只使用了一個索引i,i從左向右掃描,遇到比pivot小的,就跟從p+1開始的位置(由j索引進行遞增標志)進行交換,最終的劃分點落在了j,然后將pivot調換到j上,再遞歸排序左右兩邊子序列。一個更高效的劃分過程是使用兩個索引i和j,分別從左右兩端進行掃描,i掃描到大于等于pivot的元素就停止,j掃描到小于等于pivot的元素也停止,交換兩個元素,持續這個過程直到兩個索引相遇,此時的pivot的位置就落在了j,然后交換pivot和j的位置,后續的工作沒有不同,示意圖




               改進后的qsort3代碼如下:

          public void qsort3(int[] a, int p, int r) {
                  
          if (p >= r)
                      
          return;

                  
          // 隨機選
                  int i = p + rand.nextInt(r - p + 1);
                  swap(a, p, i);

                  
          // 左索引i指向左端點
                  i = p;
                  
          // 右索引j初始指向右端點
                  int j = r + 1;
                  
          int x = a[p];
                  
          while (true) {
                      
          // 查找比x大于等于的位置
                      do {
                          i
          ++;
                      } 
          while (i <= r && a[i] < x);
                      
          // 查找比x小于等于的位置
                      do {
                          j
          --;
                      } 
          while (a[j] > x);
                      
          if (j < i)
                          
          break;
                      
          // 交換a[i]和a[j]
                      swap(a, i, j);
                  }
                  swap(a, p, j);
                  qsort3(a, p, j 
          - 1);
                  qsort3(a, j 
          + 1, r);

              }

              這里要用do……while是因為i索引的初始位置是pivot值存儲的左端點,而j所在初始位置是右端點之外,因此都需要先移動一個位置才是合法的。查看下qsort2和qsort3的基準測試對比:

           算法  隨機數組  升序數組  降序數組  重復數組
           qsort2  227.87  19.009  18.597  74.639
           qsort3  229.44  18.696  18.507  43.428

          可以看到qsort3的改進主要體現在了大量重復元素的數組的排序上,這是因為qsort3在遇到跟pivot相等的元素的時候,還是進行停止并交換,而非跳過;假設遇到相等的元素你不停止,那么這些相等的元素在下次劃分的時候需要再次進行比較,比較次數退化到最差情況的O(n^2),而通過在遇到相等元素的時候停止并交換,盡管增加了交換的次數,但是卻避免了所有元素相同情況下最差情況的發生。

              改進到這里,回頭看看我們做了什么,首先是使用隨機挑選pivot替代固定選擇,其次是改進了劃分算法,從兩端進行掃描替代單向查找,并仔細處理元素相同的情況。

              插入排序的時間復雜度是O(N^2),但是在已經排序好的數組上面,插入排序的最佳情況是O(n),插入排序在小數組的排序上是非常高效的,這給我們一個提示,在快速排序遞歸的子序列,如果序列規模足夠小,可以使用插入排序替代快速排序,因此可以在快排之前判斷數組大小,如果小于一個閥值就使用插入排序,這就是qsort4:
          public void qsort4(int[] a, int p, int r) {
                  
          if (p >= r)
                      
          return;

                  
          // 在數組大小小于7的情況下使用快速排序
                  if (r - p + 1 < 7) {
                      
          for (int i = p; i <= r; i++) {
                          
          for (int j = i; j > p && a[j - 1> a[j]; j--) {
                              swap(a, j, j 
          - 1);
                          }
                      }
                      
          return;
                  }

                  
          int i = p + rand.nextInt(r - p + 1);
                  swap(a, p, i);

                  i 
          = p;
                  
          int j = r + 1;
                  
          int x = a[p];
                  
          while (true) {
                      
          do {
                          i
          ++;
                      } 
          while (i <= r && a[i] < x);
                      
          do {
                          j
          --;
                      } 
          while (a[j] > x);
                      
          if (j < i)
                          
          break;
                      swap(a, i, j);
                  }
                  swap(a, p, j);
                  qsort4(a, p, j 
          - 1);
                  qsort4(a, j 
          + 1, r);
              }

              如果數組大小小于7就使用插入排序,7這個數字完全是經驗值。查看qsort3和qsort4的測試比較:

           算法  隨機數組  升序數組  降序數組  重復數組
           qsort3  229.44  18.696  18.507  43.428
           qsort4  173.201  7.436  7.477  32.195

             qsort4改進的效果非常明顯,所有基準測試的結果都取得了明顯的進步。qsort4還有一種變形,現在是在每個遞歸的子序列上進行插入排序,也可以換一種形式,當小于某個特定閥值的時候直接返回不進行任何排序,在遞歸返回之后,對整個數組進行一次插入排序,這個時候整個數組是由一個一個沒有排序的子序列按照順序組成的,因此插入排序可以很快地將整個數組排序,這個變形的qsort5跟qsort4沒有本質上的不同:
          public void qsort5(int[] a, int p, int r) {
                  
          if (p >= r)
                      
          return;

                  
          // 遞歸子序列,并且數組大小小于7,直接返回
                  if (p != 0 && r!=(a.length-1) && r - p + 1 < 7)
                      
          return;

                  
          // 隨機選
                  int i = p + rand.nextInt(r - p + 1);
                  swap(a, p, i);

                  i 
          = p;
                  
          int j = r + 1;
                  
          int x = a[p];
                  
          while (true) {
                      
          do {
                          i
          ++;
                      } 
          while (i <= r && a[i] < x);
                      
          do {
                          j
          --;
                      } 
          while (a[j] > x);
                      
          if (j < i)
                          
          break;
                      swap(a, i, j);
                  }
                  swap(a, p, j);
                  qsort5(a, p, j 
          - 1);
                  qsort5(a, j 
          + 1, r);

                  
          // 最后對整個數組進行插入排序
                  if (p == 0 && r==a.length-1) {
                      
          for (i = 0; i <= r; i++) {
                          
          for (j = i; j > 0 && a[j - 1> a[j]; j--) {
                              swap(a, j, j 
          - 1);
                          }
                      }
                      
          return;
                  }

              }

              基準測試的結果也證明了qsort4和qsort5是一樣的:
           算法  隨機數組  升序數組  降序數組  重復數組
           qsort4  173.201  7.436  7.477  32.195
           qsort5  175.031  7.324  7.453  32.322

              現在,最大的開銷還是隨機數產生器選擇pivot帶來的開銷,我們用隨機數產生器來選擇pivot,是希望pivot能盡量將數組劃分得均勻一些,可以選擇一個替代方案來替代隨機數產生器來選擇pivot,比如三數取中,通過對序列的first、middle和last做比較,選擇三個數的中間大小的那一個做pivot,從概率上可以將比較次數下降到12/7 ln(n)。
             median-of-three對小數組來說有很大的概率選擇到一個比較好的pivot,但是對于大數組來說就不足以保證能夠選擇出一個好的pivot,因此還有個辦法是所謂median-of-nine,這個怎么做呢?它是先從數組中分三次取樣,每次取三個數,三個樣品各取出中數,然后從這三個中數當中再取出一個中數作為pivot,也就是median-of-medians。取樣也不是亂來,分別是在左端點、中點和右端點取樣。什么時候采用median-of-nine去選擇pivot,這里也有個數組大小的閥值,這個值也完全是經驗值,設定在40,大小大于40的數組使用median-of-nine選擇pivot,大小在7到40之間的數組使用median-of-three選擇中數,大小等于7的數組直接選擇中數,大小小于7的數組則直接使用插入排序,這就是改進后的qsort6,已經非常接近于Arrays.sort的實現:
          public void qsort6(int[] a, int p, int r) {
                  
          if (p >= r)
                      
          return;

                  
          // 在數組大小小于7的情況下使用快速排序
                  if (r - p + 1 < 7) {
                      
          for (int i = p; i <= r; i++) {
                          
          for (int j = i; j > p && a[j - 1> a[j]; j--) {
                              swap(a, j, j 
          - 1);
                          }
                      }
                      
          return;
                  }

                  
          // 計算數組長度
                  int len = r - p + 1;
                  
          // 求出中點,大小等于7的數組直接選擇中數
                  int m = p + (len >> 1);
                  
          // 大小大于7
                  if (len > 7) {
                      
          int l = p;
                      
          int n = p + len - 1;
                      
          if (len > 40) { // 大數組,采用median-of-nine選擇
                          int s = len / 8;
                          l 
          = med3(a, l, l + s, l + 2 * s); // 取樣左端點3個數并得出中數
                          m = med3(a, m - s, m, m + s); // 取樣中點3個數并得出中數
                          n = med3(a, n - 2 * s, n - s, n); // 取樣右端點3個數并得出中數
                      }
                      m 
          = med3(a, l, m, n); // 取中數中的中數,median-of-three
                  }
                  
          // 交換pivot到左端點,后面的操作與qsort4相同
                  swap(a, p, m);

                  m 
          = p;
                  
          int j = r + 1;
                  
          int x = a[p];
                  
          while (true) {
                      
          do {
                          m
          ++;
                      } 
          while (m <= r && a[m] < x);
                      
          do {
                          j
          --;
                      } 
          while (a[j] > x);
                      
          if (j < m)
                          
          break;
                      swap(a, m, j);
                  }
                  swap(a, p, j);
                  qsort6(a, p, j 
          - 1);
                  qsort6(a, j 
          + 1, r);

              }

              其中的med3函數用于取三個數的中數:
              private static int med3(int x[], int a, int b, int c) {
                  
          return x[a] < x[b] ? (x[b] < x[c] ? b : x[a] < x[c] ? c : a)
                          : x[b] 
          > x[c] ? b : x[a] > x[c] ? c : a;
              }

              運行基準測試跟qsort4進行比較:

           算法  隨機數組  升序數組  降序數組  重復數組
           qsort4  173.201  7.436  7.477  32.195
           qsort6  143.264  0.54  0.836  27.311

              觀察到qsort6的改進也非常明顯,消除了隨機產生器帶來的開銷,取中數的時間復雜度在O(1)。此時qsort6跟Arrays.sort的差距已經非常小了。Array.sort所做的最后一個改進是針對劃分算法,采用了所謂"split-end"的劃分算法,這主要是為了針對equals的元素,降低equals元素參與遞歸的開銷。我們原來的劃分算法是分為兩個區域加上一個pivot:

          跟pivot equals的元素分散在左右兩個子序列里,繼續參與遞歸調用。當數組里的相同元素很多的時候,這個開銷是不可忽視的,因此一個方案是將這些相同的元素集中存放到中間這個地方,不參與后續的遞歸處理,這就是"fat partition",此時是將數組劃分為3個區域:小于pivot,等于pivot以及大于pivot:



          但是Arrays.sort采用的卻不是"fat partition",這是因為fat partition的實現比較復雜并且低效,Arrays.sort是將與pivot相同的元素劃分到兩端,也就是將數組分為了4個區域:



               這就是split-end名稱的由來,這個算法的實現可以跟qsort3的改進結合起來,同樣是進行兩端掃描,但是遇到equals的元素不是進行互換,而是各自交換到兩端。當掃描結束,還要將兩端這些跟pivot equals的元素交換到中間位置,不相同的元素交換到兩端,左邊仍然是比pivot小的,右邊是比pivot大的,分別進行遞歸的快速排序處理,這樣改進后的算法我們成為qsort7:

          public void qsort7(int[] x, int p, int r) {
                  
          if (p >= r)
                      
          return;

                  
          // 在數組大小小于7的情況下使用快速排序
                  if (r - p + 1 < 7) {
                      
          for (int i = p; i <= r; i++) {
                          
          for (int j = i; j > p && x[j - 1> x[j]; j--) {
                              swap(x, j, j 
          - 1);
                          }
                      }
                      
          return;
                  }

                  
          // 選擇中數,與qsort6相同。
                  int len = r - p + 1;
                  
          int m = p + (len >> 1);
                  
          if (len > 7) {
                      
          int l = p;
                      
          int n = p + len - 1;
                      
          if (len > 40) {
                          
          int s = len / 8;
                          l 
          = med3(x, l, l + s, l + 2 * s);
                          m 
          = med3(x, m - s, m, m + s);
                          n 
          = med3(x, n - 2 * s, n - s, n);
                      }
                      m 
          = med3(x, l, m, n);
                  }

                  
          int v = x[m];

                  
          // a,b進行左端掃描,c,d進行右端掃描
                  int a = p, b = a, c = p + len - 1, d = c;
                  
          while (true) {
                      
          // 嘗試找到大于pivot的元素
                      while (b <= c && x[b] <= v) {
                          
          // 與pivot相同的交換到左端
                          if (x[b] == v)
                              swap(x, a
          ++, b);
                          b
          ++;
                      }
                      
          // 嘗試找到小于pivot的元素
                      while (c >= b && x[c] >= v) {
                          
          // 與pivot相同的交換到右端
                          if (x[c] == v)
                              swap(x, c, d
          --);
                          c
          --;
                      }
                      
          if (b > c)
                          
          break;
                      
          // 交換找到的元素
                      swap(x, b++, c--);
                  }

                  
          // 將相同的元素交換到中間
                  int s, n = p + len;
                  s 
          = Math.min(a - p, b - a);
                  vecswap(x, p, b 
          - s, s);
                  s 
          = Math.min(d - c, n - d - 1);
                  vecswap(x, b, n 
          - s, s);

                  
          // 遞歸調用子序列
                  if ((s = b - a) > 1)
                      qsort7(x, p, s 
          + p - 1);
                  
          if ((s = d - c) > 1)
                      qsort7(x, n 
          - s, n - 1);

              }

              其中用到了vecswap方法用于批量交換一批數據,將a位置(包括a)之后n個元素與b位置(包括b)之后n個元素進行交換:

              
          private static void vecswap(int x[], int a, int b, int n) {
                  
          for (int i = 0; i < n; i++, a++, b++)
                      swap(x, a, b);
              }

             主要是用于劃分后將兩端與pivot相同的元素交換到中間來。qsort7的實現跟Arrays.sort的實現是一樣的,看看split-end劃分帶來的改進跟qsort6的對比:
           算法  隨機數組  升序數組  降序數組  重復數組
           qsort6  143.264  0.54  0.836  27.311
           qsort6  140.468  0.491  0.506  26.639

             這個結果跟Arrays.sort保持一致。

             最后給幾張7個快排程序的在4種測試中的對比圖







                 還可以做的優化:
          1、內聯swap和vecswap函數,循環中的swap調用可以展開。
          2、改進插入排序,這是《編程珠璣》里提到的,減少取值次數。
                       for (int i = p; i <= r; i++) {
                          
          int t = x[i];
                          
          int j = i;
                          
          for (; j > p && x[j - 1> t; j--) {
                              x[j] 
          = x[j - 1];
                          }
                          x[j] 
          = t;
                      }

          3、遞歸可以展開為循環,最后一個遞歸調用是尾遞歸調用,很容易展開為循環,左子序列的遞歸調用就沒那么容易展開了。
          4、嘗試更多取樣算法來提高選擇好的pivot的概率。
          5、并行處理子序列

          評論

          # re: 快速排序及優化  回復  更多評論   

          2010-09-08 21:58 by hoorace
          循序漸進,很容易就懂了。

          # re: 快速排序及優化  回復  更多評論   

          2010-09-08 22:08 by cd
          dennis 大牛真有閑工夫,居然搞起這么古老的題目qsort了。話說剛看jdk src時覺得sort在7以下用插入排序很奇怪,是sun的工程師做過測試還是怎么。居然選個7

          # re: 快速排序及優化  回復  更多評論   

          2010-09-08 22:25 by 楊冬
          除了崇拜我已經無話可說了……看來我的路還很長啊~~

          # re: 快速排序及優化  回復  更多評論   

          2010-09-09 08:22 by xjf2043
          太有才了!

          # re: 快速排序及優化  回復  更多評論   

          2010-09-09 18:24 by lokkizhou
          沒單獨顯示 比較qsort7和Arrays.sort 四種樣本一起顯示的圖

          # re: 快速排序及優化  回復  更多評論   

          2010-09-10 17:04 by java小爬蟲
          牛人!!!

          # re: 快速排序及優化[未登錄]  回復  更多評論   

          2014-05-27 10:55 by bill
          感覺 小于7 之后用 插入排序的 那段代碼,不是插入,而是冒泡的改進。 插入不是應該 每次移動,不交換嗎? 最后確定位置,就把數再插進去?

          # re: 快速排序及優化  回復  更多評論   

          2014-11-08 13:58 by Toby
          太棒了!

          # re: 快速排序及優化  回復  更多評論   

          2015-12-29 23:51 by meng
          向前輩學習了!感覺寫得超贊。我從理論角度又寫了一個類似的博文,還望前輩指教。http://xiaozuwang.com/%E5%AF%BC%E8%88%AA/%E5%BF%AB%E9%80%9F%E6%8E%92%E5%BA%8F%E5%AE%9E%E7%8E%B0
          主站蜘蛛池模板: 福贡县| 瑞金市| 沛县| 淮安市| 革吉县| 绥滨县| 峨边| 虎林市| 盖州市| 安乡县| 桃源县| 泸溪县| 德令哈市| 南安市| 汝阳县| 天台县| 平度市| 邯郸县| 靖州| 长寿区| 临清市| 保亭| 溆浦县| 巨鹿县| 武宁县| 布拖县| 兴国县| 英山县| 隆化县| 无锡市| 射洪县| 定边县| 榆中县| 南部县| 北京市| 拜城县| 中西区| 龙里县| 云浮市| 柘城县| 奉化市|