幾種排序算法的聯系
-
希爾排序相當於直接插入排序的升級,同屬於插入排序類;
-
堆排序相當於簡單選擇排序的升級,同屬於選擇排序類;
-
快速排序是最慢的冒泡排序的升級,屬於交換排序類;
快速排序的基本思想
-
快速排序是通過不斷比較和移動交換來實現排序的,只不過它的實現增大了記錄的比較和移動的距離,將關鍵字較大的記錄從前面直接移動到后面,關鍵字較小的記錄從后面直接移動到前面,減少了總的比較次數和移動交換次數
-
通過一趟排序將待排記錄分割成獨立的兩部分,其中一部分記錄的關鍵字均比另一部分記錄的關鍵字小,分別對這兩部分記錄繼續排序,達到整個序列有序
快速排序的基本實現
QSort()函數
//對順序表L作快速排序
void QuickSort(SqList *L){
QSort(L, 1, L->length);
}
//對順序表L中的子序列L->r[low..high]作快速排序
void QSort(SqList *L, int low, int high){
int pivot;
if (low < high){
//將L->r[low..high]一分為二,算出樞軸值pivot
pivot = Partition(L, low, high);
//對低子表遞歸排序
QSort(L, low, pivot - 1);
//對高子表遞歸排序
QSort(L, pivot + 1, high);
}
}
關於QSort()函數的內容很容易理解,就是一個很簡單的遞歸過程
Partition()函數將序列分為兩個部分,后面兩個QSort()分別對前后的兩個部分遞歸調用快速排序
Partition()函數
其中,Partition()函數要做的是先選取當中的一個關鍵字,如50,想盡辦法將它放到一個位置,使得左邊的值都比它小,右邊的值比它大,這樣的關鍵字稱為樞軸(pivot)
經過第一次Partition(L,1,9)的執行之后,數組變成{20,10,40,30,50,70,80,60,90}
並返回值5給pivot,數字5表明50放置在數組下標為5的位置
計算機把原來的數組變成了兩個位於50左和右小數組{20,10,40,30}和{70,80,60,90}
而后的遞歸調用“QSort(L,1,5-1);”和“QSort(L,5+1,9);”
對{20,10,40,30}和{70,80,60,90}分別進行同樣的Partition操作,直到順序全部正確為止
函數實現如下:
//交換順序表L中子表的記錄,使樞軸記錄到位,並返回其所在位置
//此時在它之前(后)的記錄均不大(小)於它
int Partition(SqList *L, int low, int high){
int pivotkey;
//默認用子表的第一個記錄作樞軸記錄
pivotkey = L->r[low];
//從表的兩端交替向中間掃描
while (low < high){
while (low < high && L->r[high] >= pivotkey)
high--; //注意這里,只有出現了r[high]<pivotkey的情況時,才會執行下一行的交換操作
//將比樞軸記錄小的記錄交換到低端
swap(L, low, high);
while (low < high && L->r[low] <= pivotkey)
low++;
//將比樞軸記錄大的記錄交換到高端
swap(L, low, high);
}
//返回樞軸所在位置
return low;
}
下面的圖片簡單展示了Partition()函數的執行過程,比較容易理解,不再文字說明:






可以看出,Partition()函數,其實就是將選取的pivotkey不斷交換,將比它小的換到它的左邊,比它大的換到它的右邊,它也再交換中不斷更改自己的位置,知道完全滿足這個要求為止。
快速排序的復雜度分析
-
平均的情況的時間復雜度數量級為O(nlogn)
-
平均情況空間復雜度為O(logn)
-
由於關鍵字的比較和交換是跳躍進行的,快速排序是一種不穩定的排序方法
快速排序算法的優化——優化選取樞軸
- 排序速度的快慢取決於L.r[1]的關鍵字處在整個序列的位置,L.r[1]太小或者太大,都會影響性能
三數取中(median-of-three)法
取三個關鍵字先進行排序,將中間數作為樞軸,一般是取左端、右端和中間三個數,也可以隨機選取
這樣至少這個中間數一定不會是最小或者最大的數
代碼實現實例:
int pivotkey;
//計算數組中間的元素的下標
int m = low + (high - low) / 2;
if (L->r[low] > L->r[high])
//交換左端與右端數據,保證左端較小
swap(L, low, high); //使得r[low]<=r[high]
if (L->r[m] > L->r[high])
//交換中間與右端數據,保證中間較小
swap(L, high, m); //使得r[m]<=r[high]
if (L->r[m] > L->r[low])
//交換中間與左端數據,保證左端較小
swap(L, m, low); //使得r[m]<=r[low]
//此時L.r[low]已經為整個序列左中右三個關鍵字的中間值
//使用L->r[low]作樞軸記錄 */
pivotkey = L->r[low];
九數取中(me-dian-of-nine)法
先從數組中分三次取樣,每次取三個數,三個樣品各取出中數,然后從這三個中數當中再取出一個中數作為樞軸
更加保證了取到的pivotkey是比較接近中間值的關鍵字
快速排序算法的優化——優化不必要的交換
觀察上文中Partition()函數的執行過程,我們會發現,50這個關鍵字,其位置變化為:1->9->3->6->5
可是其最終目標就是5,其中的很多交換是不必要的
因此進行如下的優化:
//快速排序優化算法
int Partition1(SqList *L, int low, int high){
int pivotkey;
//這里省略三數取中代碼
//用子表的第一個記錄作樞軸記錄
pivotkey = L->r[low];
//將樞軸關鍵字備份到L->r[0]
L->r[0] = pivotkey;
//從表的兩端交替向中間掃描
while (low < high){
while (low < high && L->r[high] >= pivotkey)
high--;
//采用替換而不是交換的方式進行操作
L->r[low] = L->r[high];
while (low < high && L->r[low] <= pivotkey)
low++;
//采用替換而不是交換的方式進行操作
L->r[high] = L->r[low];
}
//將樞軸數值替換回L.r[low]
L->r[low] = L->r[0];
//返回樞軸所在位置
return low;
}
其實就是將pivotkey備份到L.r[0]中,然后在swap時,將原本的數據交換的工作變為單方面的替換(覆蓋)工作
最終,當low與high會和的時候,即找到了樞軸的位置時,再將L.r[0]的數值賦值回L.r[low]
因此,Partition()函數的執行過程也會變成下圖這樣:

快速排序算法的優化——優化小數組的排序方案
如果數組非常小,其實快速排序算法反而不如直接插入排序來得更好(直接插入是簡單排序中性能最好的)
因此又對其做如下改進:
#define MAX_LENGTH_INSERT_SORT 7 //數組長度閥值
//對順序表L中的子序列L.r[low..high]作快速排序
void QSort(SqList &L, int low, int high){
int pivot;
if ((high - low) > MAX_LENGTH_INSERT_SORT){
//當high-low大於常數時用快速排序
//將L.r[low..high]一分為二
//並算出樞軸值pivot
pivot = Partition(L, low, high);
//對低子表遞歸排序
QSort(L, low, pivot - 1);
//對高子表遞歸排序
QSort(L, pivot + 1, high);
}
else //當high-low小於等於常數時用直接插入排序
InsertSort(L);
}
快速排序算法的優化——優化遞歸操作(目前我對於這里通過這種方式提高算法效率的原理並不十分理解)
QSort函數在其尾部有兩次遞歸操作
如果待排序的序列划分極端不平衡,遞歸深度將趨近於n,而不是平衡時的log2 n(2為底)
棧的大小是很有限的,每次遞歸調用都會耗費一定的棧空間,函數的參數越多,每次遞歸耗費的空間也越多
如果能減少遞歸,將大大提高性能
優化代碼如下:
//對順序表L中的子序列L.r[low..high]作快速排序
void QSort1(SqList *L, int low, int high){
int pivot;
if ((high - low) > MAX_LENGTH_INSERT_SORT){
while (low < high){
//L.r[low..high]一分為二
//算出樞軸值pivot
pivot = Partition1(L, low, high);
//對低子表遞歸排序
QSort1(L, low, pivot - 1);
//尾遞歸
low = pivot + 1;
}
}
else
InsertSort(L);
}
將if改成while,因為第一次遞歸以后,變量low就沒有用處了,所以可以將pivot+1賦值給low
再循環后,來一次Partition(L,low,high),其效果等同於“QSort(L,pivot+1,high);”
結果相同,但因采用迭代而不是遞歸的方法可以縮減堆棧深度,從而提高了整體性能
對於這個優化的操作,在很多地方(如《大話數據結構》)
將其視作尾迭代優化
但是實際上,這個應該並不算是尾迭代優化
知乎上關於這里不是尾迭代的一種說法
這里的優化跟尾遞歸優化應該沒有關系,尾遞歸是因為遞歸在最后一句不需要保存當前環境的變量,所以沒有導致棧的加深。這里有個while,怎么都不會是最后一句,肯定還是要保存當前環境信息的。
這里的優化核心這個wille,快排的調用過程,或說整個路線可以看成一個二叉樹。一個區間A的排序,會調用子區間B1和B2的排序,B1是A的前半段,B2是A的后半段。但是在這里,調用前半段沒變,但是沒有直接調用后半段,而是直接變成了后半段。下一次while循環時,區間的開始位置變化,然后變成了后半段的區間。
沒優化前是一顆二叉樹,現在相當於把右節點擦掉,把右節點的子節點直接連接到當前節點。這樣右邊的葉深度,也就是最大棧深度就下降了。這個是不需要依賴編譯器優化的。
作者:SeltonFD
鏈接:https://www.zhihu.com/question/285631475/answer/445282667
尾迭代
概念
如果一個函數中所有遞歸形式的調用都出現在函數的末尾,我們稱這個遞歸函數是尾遞歸的。
當遞歸調用是整個函數體中最后執行的語句且它的返回值不屬於表達式的一部分時,這個遞歸調用就是尾遞歸
尾遞歸函數的特點是在回歸過程中不用做任何操作,這個特性很重要,因為大多數現代的編譯器會利用這種特點自動生成優化的代碼
原理
當編譯器檢測到一個函數調用是尾遞歸的時候,它就覆蓋當前的活動記錄而不是在棧中去創建一個新的
編譯器可以做到這點,因為遞歸調用是當前活躍期內最后一條待執行的語句,於是當這個調用返回時棧幀中並沒有其他事情可做,因此也就沒有保存棧幀的必要了
通過覆蓋當前的棧幀而不是在其之上重新添加一個,這樣所使用的棧空間就大大縮減了,這使得實際的運行效率會變得更高
所有排序算法總結

從算法的簡單性,將7種算法分為兩類:
-
簡單算法:冒泡、簡單選擇、直接插入
-
改進算法:希爾、堆、歸並、快速
平均情況:最后3種改進算法要勝過希爾排序,並遠遠勝過前3種簡單算法
最好情況:冒泡和直接插入排序要更勝一籌
最壞情況:堆排序與歸並排序強過快速排序以及其他簡單排序
執行算法的軟件所處的環境非常在乎內存使用量的多少時,歸並排序和快速排序不是一個較好的決策
非常在乎排序穩定性的應用中,歸並排序是個好算法
從待排序記錄的個數上來說,待排序的個數n越小,采用簡單排序方法越合適;反之,n越大,采用改進排序方法越合適