【決戰西二旗】|快速排序的優化
1.前言
前面的一篇文章https://www.cnblogs.com/backnullptr/p/11934841.html講了快速排序的基本概念、核心思想、基礎版本程式碼實現等,讓我們對快速排序有了一個充分的認識,但還無法達到面試中對快速排序靈活應對的程度。
快速排序是圖領獎得主發明的演算法,被譽為20世紀最重要的十大演算法之一,快速排序為了可以在多種資料集都有出色的表現,進行了非常多的優化,因此對我們來說要深入理解一種演算法的最有效的手段就是不斷優化提高效能。
通過本文你將瞭解到以下內容:
- 快速排序和歸併排序的分治過程對比
- 快速排序分割槽不均勻的影響
- 快速排序的隨機化基準值
- 快速排序的三分割槽模式
- 快速排序和插入排序的混合
2.快速排序的分割槽過程
快速排序和歸併排序採用的基本思想都是分治思想Divide&Conquer,從D&C思想來看最主要的部分就是分割和合並
,兩種演算法在使用D&C時側重點有一些差異:
- 歸併排序在分割時處理很簡單,在合併時處理比較多,重點在合併。
- 快速排序在分割時處理比較複雜,由於交換的存在遞迴結束時就相當於合併完成了,重點在分割。
2.1 歸併排序分治示意圖
2.2 快速排序分治示意圖
注:快排的過程就不寫具體的數字了 僅為達意 點到即可。
可以明顯看出來,快速排序在選擇基準值時對整個分治過程影響很大,因為下一個環節的分治是基於前一環節的分割結果進行的。
2.3 快速排序劃分不均勻情況
考慮一種極端的情況下,如果基準值選取的不合理,比如是最大的或者最小的
,那麼將導致只有一邊有資料,對於已經排序或者近乎有序的資料集合來說就可能出現這種極端情況,還是來畫個圖看下:
圖中展示了每次分治都選擇第一個元素作為基準值,但是每次的基準值都是最小值,導致每次基準值左側沒有子序列,除了基準值之外全部元素都在右子序列。
2.4 概率和複雜度計算
每次分割排序之後,只能在有序序列中增加1個元素遞迴樹變成了單支樹並且遞迴深度變大
,極端情況的出現概率和最壞複雜度計算如下:
極端情況概率就是每次在剩餘所有元素中挑出最小的,這樣每次的概率都是1/(n-i),所以組合起來就是1/(n!),所以隨機資料集合出現最差情況的概率非常低,但是有序資料下固定基準值選擇就可能造成極端情況的出現。
最壞複雜度相當於每次從n-i個元素中只找到1個數據,將所有情況累加也就達到了O(n^2)級別,並不是遞迴過程全都挑選了最值作為基準值才會出現O(n^2)的複雜度,複雜度是一個概率化的期望值,具體的係數不同影響也很大。
3. 快速排序基準值選取優化
3.1 分割越均勻速度越快
從上面的幾張圖可以清晰看到基準值的不同對於D&C過程的分割會產生很大的影響
,為了保證快速排序的在通用資料集的效率,因此我們需要在基準值的選取上做一些決策,換句話說就是讓選取的基準值每次都可以儘可能均勻地分割資料集
,這樣的效率是最高的。
3.2 隨機選取基準值
網上有很多選擇方法比如固定選取第一個、固定選取最後一個、固定選擇中間值、三值平均選取等,不過個人覺得每一次都隨機選取某個位置的資料作為基準值,然後與第一個值互換
,這樣就相當於每次的基準值都是隨機選擇
的,就將固定index帶來的問題,大大降低了。
3.3 隨機vs固定對比試驗
接下來做一組對比試驗,生成一個0-100000的有序陣列
,程式碼增加了很多選擇項和時間測量程式碼,測試程式碼如下:
#include<iostream> #include<sys/time.h> #include<stdlib.h> #define SIZE 100000 using namespace std; //獲取從[start-end)之間的隨機index int getrandom(int start,int end){ srand((unsigned int)time(NULL)); return (rand()%(end-start))+start; } //獲得毫秒時間戳 long getCurrentTime() { struct timeval tv; gettimeofday(&tv,NULL); return tv.tv_sec*1000 + tv.tv_usec/1000; } template <typename T> void quick_sort_recursive(T arr[], int start, int end, string& method) { if (start >= end) return; //產生隨機索引 並與end交換 之後與原來的處理一致 if(method=="random"){ int randindex = getrandom(0,end-start); std::swap(arr[end],arr[randindex]); } T mid = arr[end]; int left = start, right = end - 1; //在整個範圍內搜尋比樞紐元值小或大的元素,然後將左側元素與右側元素交換 while (left < right) { //試圖在左側找到一個比樞紐元更大的元素 while (arr[left] < mid && left < right) left++; //試圖在右側找到一個比樞紐元更小的元素 while (arr[right] >= mid && left < right) right--; //交換元素 std::swap(arr[left], arr[right]); } if (arr[left] >= arr[end]) std::swap(arr[left], arr[end]); else left++; quick_sort_recursive(arr, start, left-1, method); quick_sort_recursive(arr, left+1, end, method); } //模板化 template <typename T> void quick_sort(T arr[], int len, string& method) { quick_sort_recursive(arr, 0, len-1, method); } int main(int argc,char *argv[]) { if(argc < 2){ cout<<"give me a method:fix or random"<<endl; return -1; } string method = string(argv[1]); cout<<method<<endl; int a[SIZE]={}; //產生有序資料 for(int i=0;i<100000;i++) a[i]=i+1; int len = sizeof(a)/sizeof(int); long start = getCurrentTime(); quick_sort(a,len-1,method); long end = getCurrentTime(); cout<<len<<"有序資料耗時統計:"<<endl; cout<<"START@:"<<start<<"ms"<<endl; cout<<"END@:"<<end<<"ms"<<endl; cout<<"COST:"<<end-start<<"ms"<<endl; }
筆者使用相同的資料集在fix和random模式下,後者的耗時只有前者的大約1/10,不過在我的電腦上上面的程式碼耗時比我預期大很多,還是存在優化空間,所以某些場景下隨機化帶來的效能提升很明顯,是一個慣用的優化方法。
4. 快速排序的三分割槽模式原理
前面的路子都是以基準值為準分為小於子序列和大於子序列,考慮一種特殊的資料集,資料集中有大量重複元素,這種情況下使用兩分割槽遞迴會對大量重複元素進行處理。
一個優化的方向就是使用三分割槽模式:小於區間、等於區間、大於區間,
這樣在後續的處理中則只需要處理小於區和大於區,降低了等於基準值區間元素的重複處理,加速排序過程。
如圖為三分割槽模式中某個時刻的快照,其中展示了幾個關鍵點和區間,包括基準值、小於區、等於區、處理值、待處理區、大於區。
在實際過程中根據處理值與基準值的大小關係,進行相應分割槽合併和交換,再進行下標移動就可以了,實際中分三種情況,這也是寫程式碼的依據:
- 處理值e==p,將e合併到等於區,i++;
- 處理值e<p,將e與(lt+1)位置的資料交換,擴充套件小於區lt++,等於區長度不變,相當於整體平移;
- 處理值e>p,將e與(gt-1)位置的資料交換,擴充套件大於區gt--,此時i不變,交換後的值是之前待處理區的尾部資料;
- e==p的合併
- e<p的合併
- e>p的合併
- 分割槽最終調整
處理完待處理區的全部資料之後的調整也非常重要,主要是將基準值P與lt位置的資料交換,從而實現最終的三分割槽,如圖所示:
從最終的分割槽可以看到,我們下一次的迴圈可以不處理等於區的資料而只處理兩端分割槽資料,這樣在大量重複場景下優化效果會非常明顯。
4. 三分割槽模式程式碼試驗
#include<iostream> #include <ctime> #include<stdlib.h> #include<sys/time.h> using namespace std; #define SIZE 1000000 void quickSort3Way(int arr[], int l, int r) { if (l >= r) return; //隨機選擇要做比較的值 swap(arr[l], arr[rand() % (r - l + 1) + l]); int v = arr[l]; int i = l + 1; int lt=l; int gt = r; while (i<gt+1) { //處理值小於基準值 合併到小於區 if (arr[i] < v) { swap(arr[i], arr[lt+1]); lt++; i++; } //處理值大於基準值 合併到大於區 else if (arr[i] > v) { swap(arr[i], arr[gt]); gt--; } //處理值等於基準值 合併到等於區 else{ i++; } } //最終調整 swap(arr[l], arr[lt]); //獲取下一次處理的左右邊界 小於區右邊界lt-1 大於區左邊界gt quickSort3Way(arr, l, lt-1 ); quickSort3Way(arr, gt, r); } //獲得毫秒時間戳 long getCurrentTime() { struct timeval tv; gettimeofday(&tv,NULL); return tv.tv_sec*1000 + tv.tv_usec/1000; } int main() { int arr[SIZE]={}; for(int i=0;i<SIZE;i++) arr[i]=i%10; long start = getCurrentTime(); quickSort3Way(arr,0,SIZE-1); long end = getCurrentTime(); int len = sizeof(arr)/sizeof(int); cout<<len<<"有序資料耗時統計:"<<endl; cout<<"START@:"<<start<<"ms"<<endl; cout<<"END@:"<<end<<"ms"<<endl; cout<<"COST:"<<end-start<<"ms"<<endl; return 0; }
程式碼測試了100w資料,資料是0-10的迴圈重複,測試耗時如下:
1000000有序資料耗時統計: START@:1575212629852ms END@:1575212629878ms COST:26ms
筆者使用相同的資料集在二分割槽模式下測試10w資料規模耗時大約是1800ms,資料集減少10倍耗時卻增大了幾十倍,或許二分割槽程式碼還是存在優化空間,不過這個對比可以看到存在大量重複元素時三分割槽效能還是很不錯的。
5. 快速排序和插入排序混合
插入排序在資料集近乎有序的前提下效率可以到達O(n),快速排序在遞迴到末尾時當序列的元素數較少時,可以用插入排序來代替後續的遞迴處理過程,從而結合二者的優點進行加速,寫一段簡單的偽程式碼表示:
//當序列中的資料數量小於15時 採用插入排序 if(r-l < 15){ insertsort(arr,l,r) }
6. 總結
快速排序是基於D&C思想實現的,最核心的部分就是分割槽Partition的過程,該過程可以有很多寫法。
對快速排序的優化主要體現在基準值選取、資料集分割、遞迴子序列選取、其他排序演算法混合等方面,換句話說就是讓每次的分割槽儘量均勻且沒有重複被處理的元素,這樣才能保證每次遞迴都是高效簡潔