資料結構:快速排序優化思路
首先快排的平均時間複雜度 優於很多排序,但是時間複雜度也有和他一樣的,也就是堆排序,但為什麼實際應用中快排要好於堆排呢?
原因主要有三個:
- 雖然都是 級別,但是時間複雜度是近似得到的,快排前面的係數更小,所以效能更好些。
- 堆排比較交換次數更多。因為快排是樞軸(pivot)左邊的元素都比pivot小,右邊的元素都更大,比較交換次數會比堆排更少些。
- 第三個原因也是最主要的原因,和CPU快取(cache)有關。CPU有一塊快取記憶體區(cache)。堆排序要經常處理距離很遠的數,不符合區域性性原理,會導致cache命中率降低,頻繁讀寫記憶體。
快排還能再快?
答案是肯定的,可以說在多數情況下,基本的快排速度是優於其他排序的,但是凡事都有侷限性,快速排序對於資料不平衡的陣列,重複陣列,小陣列等情況速度是比較不理想的,這個時候自然就需要優化,來儘可能的減小侷限性。
如何優化
對尋找樞軸的方法進行優化
我們知道基本的快速排序選取第一個或最後一個元素作為樞軸,但是,這一直很不好的處理方法。對於這個問題一般有兩種處理方法:隨機選取樞軸、三數取中(median-of-three)。
三數取中:
選三個數(或更多,一般為左中右)作為樣本,取其中位數作為樞軸點,這樣劃分可以儘可能的使樞軸在中間,使兩邊資料更均衡。
/*函式作用:取待排序序列中low、mid、high三個位置上資料,選取他們中間的那個資料作為樞軸*/ int PickMiddle(int arr[],int low,int high) { int mid = (high+low) / 2;//計算陣列中間的元素的下標 //使用三數取中法選擇樞軸 if (arr[low] > arr[high]) sawp(arr,low,high);//目標: arr[low] <= arr[high] if (arr[mid] > arr[high]) sawp(arr,mid,high);//目標: arr[mid] <= arr[high] //以上兩步保證把最大的移到最右端 if (arr[mid] > arr[low]) sawp(arr,mid,low);//目標: arr[low] >= arr[mid] //此時,arr[mid] <= arr[low] <= arr[high] return arr[low]; //low的位置上儲存這三個位置中間的值 //分割時可以直接使用low位置的元素作為樞軸,而不用改變分割函數了 } 複製程式碼
小陣列使用插入排序
對於很小和部分有序的陣列,快排不如插排好。當待排序序列分割到一定長度後,繼續分割的效率比插入排序要差,此時可以使用插排而不是快排。
if(high-low < 7) { InsertSort(L);//進行插入排序 return; }//else進行正常的快排 複製程式碼
三向切分思想
快速排序對於元素重複率特別高的陣列,效率顯得非常低下。舉個例子,假如在排序過程中一個子陣列已全部為重複元素,則對於此陣列排序就應該停止了,但快排演算法依然會將其切分為更小的陣列。
一個簡單的改進想法就是將陣列分為三部分:小於當前切分元素的部分,等於當前切分元素的部分,大於當前切分元素的部分。用一張圖說明就是這樣:

- 使得元素 的值均小於切分元素;
- 使得元素 的值均大於切分元素;
- 使得元素 的值均等於切分元素, 的元素還沒被掃描,切分演算法執行到 為止。
int QSort(int arr[],int low,int high) { if(low < high) { int lt = low;//low為樞軸位置 int gt = high; int i=low+1; //low位置的元素為樞軸元素,所以用於比較的元素從low+1開始 int temp = arr[low]; //將樞軸的元素儲存到temp中 while(i <= gt) { if(arr[i] < temp) //小於樞軸元素的放在lt左邊 sawp(arr,lt++,i++);//即交換lt和i位置的元素,此時樞紐位置(lt)右移一位,i也因此右移 else if(arr[i] > temp)//大於樞軸元素的放在gt右邊 sawp(arr,i,gt--);//交換i和gt位置的元素,gt需要左移,i由於變為gt位置元素,所以不需要移動 else //相等時,無需交換,只需把i右移一位 i++; } //lt-gt的元素已經排定,只需對it左邊和gt右邊的元素進行遞迴求解 QSort(arr,low,lt-1); QSort(arr,gt+1,high); } } 複製程式碼
遞迴優化
我們知道快排是一個遞迴演算法,而遞迴的問題是如果遞迴太深容易棧溢位。所以針對快排的優化,還有一個角度是對遞迴的優化。網上很多文章中都提到一個叫做尾遞迴優化的東西。
為了更好的理解,得先了解了一下什麼叫尾遞迴。
尾遞迴
如果一個函式中所有遞迴形式的呼叫都出現在函式的末尾,我們稱這個遞迴函式是尾遞迴的。當遞迴呼叫是整個函式體中最後執行的語句且它的返回值不屬於表示式的一部分時,這個遞迴呼叫就是尾遞迴。尾遞迴函式的特點是在迴歸過程中不用做任何操作,這個特性很重要,因為大多數現代的編譯器會利用這種特點自動生成優化的程式碼。
具體用法看這篇文章吧,我就不復述了。 尾呼叫優化 - 阮一峰的網路日誌
所謂的“快排尾遞迴優化”真的是尾遞迴優化嗎?
瞭解完尾遞迴後,再來看看網上流行的快排尾遞迴優化方法。
關鍵程式碼如下
void QSort(int arr[],int low,int high) { int pivot;//樞軸 while(low<high)//直到把右半邊陣列劃分成最多隻有一個元素為止,就排完了! { pivot=Partition(arr,low,high); QSort(arr,low,pivot-1);//對低子表遞迴排序 low=pivot+1; } } 複製程式碼
首先上面程式碼中遞迴呼叫並不是最後一步,甚至最後一行都不是,和我們上面看到的尾遞迴的定義不太一樣,我查閱了網上很多資料,其中演算法導論中7-4章也提到了這個問題。
演算法導論中的題目:
中文版本:

原書中的問題是這樣的:
The QUICKSORT algorithm of Section 7.1 contains two recursive calls to itself. After the call to PARTITION, the left subarray is recursively sorted and then the right subarray is recursively sorted. The second recursive call in QUICKSORT is not really necessary; it can be avoided by using an iterative control structure. This technique, called tail recursion, is provided automatically by good compilers. Consider the following version of quicksort, which simulates tail recursion.
這個問題也提到尾遞迴技術,但在後續的描述中更像是描述尾遞迴的思想,which simulates tail recursion這是書中的原話,意思也很明顯,就是模擬尾遞迴技術,而非真正的尾遞迴。
那麼用這樣的方式到底能不能降低遞迴深度呢?
答案是能,不妨來畫一下這兩種方法的遞迴樹:
首先設定模擬陣列為(1,2,3,4,5,6,7,8,9,10,11),樞軸選取採用三數取中方式
普通雙遞迴的遞迴樹為:

而如果採用模擬尾遞迴的方法,由於去掉了高子表的遞迴,而採用直接呼叫快排處理函式;不難發現其實本質是相當於把右節點擦掉,把右節點的子節點直接連線到當前節點。這樣右邊的葉深度,也就是最大棧深度就下降了。這個是不需要依賴編譯器優化的。


不過這種優化我覺得沒什麼必要,畢竟這只是把遞迴演算法轉化為了迭代演算法,而所有的遞迴都可以轉化為迭代,為何又不把另一個遞迴寫成迭代呢;更何況經過優化後快排的平均遞迴深度基本為 ,雖然可能為n,但這種情況在使用了各種優化後,幾乎不可能出現了,所以我覺得優化演算法,最重要的應該是結合具體情況對演算法本身優化,而不是粗暴的把遞迴改成非遞迴。
參考文獻: