對於快速排序,相信大家都有聽過,這是一個被封為聖經的算法,足以體現算法的神奇的偉大。接下來本人將從算法思想、算法具體實現、不同情況下的復雜度、算法的優化和代碼展示等幾個方面為讀者深入講解。
一、算法思想
快速排序是一種分治的排序算法,主要操作是進行一趟操作將數據分成兩部分,左邊的部分都比基准值小,右邊的部分都比基准值大(非降序排序),切分的位置取決於數組的內容,再分別對兩部分進行同樣的遞歸操作。
了解二分查找的朋友應該很容易理解,不同的是,二分查找每次划分數據后只需對一邊進行操作,所以它的速度更快(二分查找的復雜度為log2n,(以2為底,n的對數))。
快速排序是對冒泡排序的改進(冒泡排序是對相鄰的兩個元素比較。快速排序是找到一個基准點,將數據划分成兩部分,對兩部分進行遞歸操作)
快速排序之所以快,是因為它采用了分治法的思想,把一個大的數據,分成多個小的塊,對小的塊進行處理,再將處理后的小塊合並再處理。
二、算法具體實現(三種實現)
1、前后指針法(重點介紹)
把前后指針法放到最前面,是因為它有獨有的優勢,由於指針只是單側的移動,對於單鏈表等只支持單向移動的數據結構,它依然能派上用場(另外兩種算法都需要兩指針分別向后向前移動)。
①將數組最左的元素記為key,創建指針i和j,i指向數組最左邊的元素,j指向i的下一位。
②比較array[j]和key:如果array[j]>key,j后移一位,i不動。若array[j]<key,交換i+1和j的值,ij同時后移一位。
③當j走到數組的末尾時,先執行②操作,再交換array[i]和key。
④對兩邊進行上述操作的遞歸操作。
一趟走完key左邊的數都小於key,key右邊的值都大於key,下面用一個例子來理解(希望讀者能動手驗證)。
取key=6;
j走到5時,5<6,交換5和i+1,即交換5和10,ij同時后移一位。
j走到3時,3<6,交換3和i+1,即交換3和13,ij同時后移一位。
j走到2時,2<6,交換2和i+1,即交換2和10,ij同時后移一位。
j走到11時,11>6,不用交換,此時j到達數組末尾,交換key和i,即交換6和2。
一趟排序完成,6左邊的元素都小於6,右邊的元素都大於6。
2、左右指針法(非降序)
之所以叫左右指針,是因為初始的兩指針是在數據的兩端,看作左右,與前后指針法不同的是兩指針是往中間移動的。
①將最右的元素定為key,左指針最左的元素,右指針指向最右的元素。
②右指針向左走,如果遇到了比key小的數,右指針停下,接着左指針往右走,遇到比key大的數時停下。交換左右指針的值,兩指針再向中間移一位。
③檢查兩指針是否重合,不重合時進行②操作,重合時判斷key的值與重合位置的值,如果key<重合位置的值,進行交換。
④對兩邊進行上述操作的遞歸操作。
(放圖說明)
取key為5,初始化兩指針。
右指針向左移動,到2時2<5,停住。
左指針向右移動,到9時9>5,停住。
交換兩指針的值,兩指針都向中間移動一位。
兩指針相遇,交換key和指針位置的值,至此一趟排序完成。
3、填坑法(非降序)
填坑法的坑只有一個,是由指針指向且不斷變化的,有些類似於左右指針法,具體不同會在下面分析。
①將數組最右的端定為坑,並記下坑值為key(一趟排序只記這一次),左指針最左的元素,右指針指向最右的元素。
②左指針向右移動,當遇到比坑值大的數時停下,將這個數填入坑中,將左指針此時的位置設為坑。右指針向左移,當遇到比key小的數時,將這個數填入坑中,將右指針此時的位置設為坑。
③判斷兩指針是否重合,若不重合執行②操作,重合就將初始的key值填入重合的位置。至此一趟排序結束。
(上圖中圈圈代表坑)
初始化兩指針,將最右端設為坑,並記下其值。
左指針向右移動,一開始就發現7>1,將7填入坑中,左指針停留。
右指針向左移動,一直到7都沒有找到比1小的數,這時兩指針重合,將key值填入重合位置。
(這只是一種特殊情況,請讀者自己作更多的數組來練習)
三種方法比較:
指針移動方向:前后指針法比較特別,兩指針都是朝一邊移動(另外兩個方法兩指針移動方向不同)。
兩指針的完成順序:左右指針法是兩指針都移動到位了,才執行交換,填坑法只要一個指針到位了就執行交換。
兩指針的作用:可以說左右指針法和填坑法是相同的。而前后指針法中一個指針決定了插入的位置,一個指針用來檢索元素。
最后key值的插入:前后指針法是后指針到頭后,交換key值和array[i](也就是前一個指針指向的值)。左右指針法和填坑法都是在兩指針相遇后將key值與指針重合位置的值交換。
三、復雜度(要想透徹理解務必看完)
通常情況下,算法在不同情況的復雜度是不同的,對於快排,是否也是這樣?下面我將對多種情況下的快排進行分析。
1、最優情況
根據快排的分治法思想,每次用key值將數組分為兩部分,試想怎樣才算是最優情況?
當key值每次都是放在最中間時,數據被分成均勻的兩半,這時就出現了最優情況。
讓我們看一眼遞歸樹:
得出在最優情況下快排的復雜度是nlgn
2、最壞情況
了解的最優情況,想一下最壞情況的樣子吧。
對於一組排序好的數組,我們一般不去驗證它是否排好,而是將它重新排一遍。想象一下用快排處理一組已經排好的數據,每次的key值不是最大就是最小,划分的兩部分,一部分沒有元素,剩下的n-1都在另一部分。這是很糟糕的情況,此時排序的效率大幅降低。
這時快排的復雜度達到了n²(對計算有疑問請在評論區提出)
3、固定位置排序
若每次排序key都被放在1/10或9/10的位置呢?請讀者參考上面的方式作出分析。
需要注意的是上圖標注的兩條路徑的長度是不一樣的(1/10的路徑更短),所以在右邊每層的相加后面出現小於Cn。
大家應該注意到,在T(n)的算式中沒有出現1/10,Cnlog(9/10)n中log(9/10)n代表的是最長路徑,Cn是每層的相加,而θ(n) 表示的是所以葉節點。
可以得出即使在固定位置的時候,我們依然是處於最優情況。
4、優劣交替排序
發揮想象力,如果在排序時,出現了第一次是最優排序,第二次是最差排序,第三次又回到最優排序...
這時對於整個排序而言,我們到底是在經歷最差情況還是最好情況?(請讀者在心中給出答案)
利用之前的分析和算式,給出下圖:
them中的式子用了等價代換(只是將n/2帶入U(n))
你猜對了嗎,不管猜對與否,你已經對算法有了深刻的認識。除了最壞情況外,快排的復雜度都是一樣的。
四、算法的優化
鄙人才疏學淺,在這里只提供兩個簡單的方法供大家參考。算法的思路都很簡單,具體的實現在后面代碼展示部分。
1、三數取中法
在上面的各種情況中,我們不難看出排序的好壞受制於key值,如果我們不選最大或最小的數作為key,那么就可以避開最壞情況,三數取中法就利用了這種思想。
做法:在數組的頭、中和尾各取一個元素,將三個數進行比較,選擇位於中間的一個數作為key。
2、加入插入排序
當排序進行到接近完成時,數組會被分得很小,這時我們可以用一些特定的算法對這些小塊排序,插入排序正適用於這種情況(復雜度為O(n²))。
做法:遍歷數組,每次將當前元素插入合適的位置。
五、代碼展示
1、前后指針法
1 #include<iostream> 2 using namespace std; 3 #include<assert.h> 4 int GetMidIndex(int *a,int left,int right); 5 int PartSort(int *a,int left,int right); 6 void QuickSort(int *a,int left,int right); 7 void InsertSort(int* a,int n); 8 void printArray(int *a,int n); 9 int main() 10 { 11 int arr[] = { 1, 3, 6, 0, 8, 2, 9, 4, 7, 5 }; 12 //int arr[] = { 49, 38, 65, 97, 76, 13, 27, 49 }; 13 int n = sizeof(arr) / sizeof(arr[0]); 14 cout << "進行快速排序后:"; 15 QuickSort(arr, 0, n - 1); 16 printArray(arr, n); 17 system("pause"); 18 return 0; 19 } 20 21 int GetMidIndex(int *a,int left,int right) 22 {//三數取中法 23 int max,maxAdress; 24 int mid=left+(right-left)/2; 25 if(a[left]>=a[mid]){ 26 max=a[left]; 27 maxAdress=left; 28 } 29 else{ 30 max=a[mid]; 31 maxAdress=mid; 32 } 33 if(a[right]>max){ 34 max=a[right]; 35 maxAdress=right; 36 } 37 } 38 39 int PartSort(int *a,int left,int right) 40 {//前后指針法的實現 41 int mid=GetMidIndex(a,left,right); 42 swap(a[mid],a[left]); 43 int key=a[left]; 44 int j=left+1; 45 int i=left; 46 while(j<=right&&i<j){ 47 if(a[j]<key&&++i!=j){ 48 swap(a[i],a[j]); 49 } 50 j++;//如果最后一個也比key小,j就會超出數組 51 } 52 swap(a[left],a[i]);//交換key值和array[i] 53 return i; 54 } 55 56 void QuickSort(int *a,int left,int right) 57 {//快排主要思想(分治法 58 if(left>=right){ 59 return; 60 } 61 if((right-left)<5){ 62 InsertSort(a,right-left+1); 63 } 64 int div=PartSort(a,left,right); 65 QuickSort(a,left,div-1); 66 QuickSort(a,div+1,right); 67 } 68 69 void InsertSort(int* a,int n)//當數組規模較小或者存在多個局部有序的子數組時,算法的執行速度會更快 70 {//插入排序 每次將一個元素插入以排好的序列 71 for(int i=1;i<n;i++){ 72 for(int j=i;j>0&&a[j]<a[j-1];j--){ 73 swap(a[j],a[j-1]); 74 } 75 } 76 } 77 void printArray(int *a,int n) 78 { 79 for(int i=0;i<n;i++){ 80 printf("%d ",a[i]); 81 } 82 }
2、左右指針法
#include<iostream> using namespace std; #include<assert.h> int PartSort(int* array,int left,int right); void QuickSort(int* array,int left,int right); int GetMidIndex(int* a,int left,int right); void InsertSort(int* a,int n); void PrintArray(int* a,int n); int main()//快速排序(填坑法) { int arr[]={1,4,2,7,8,5,3,9,0,6}; int n=sizeof(arr)/sizeof(arr[0]); QuickSort(arr,0,n-1); PrintArray(arr,n); system("pause"); return 0; } int PartSort(int* array,int left,int right) { int mid=GetMidIndex(array,left,right); swap(array[mid],array[right]); int key=array[right]; while(left<right){//除第一次外,left和right都是從坑開始移動 while(left<right&&array[left]<=key){ ++left; } array[right]=array[left]; while(left<right&&array[right]>=key){ --right; } array[left]=array[right]; } array[right]=key; return right; } void QuickSort(int* array,int left,int right) {//快排核心思想(分治法 if(left>=right){ return; } if(right-left<=5){ //printf("insert!"); InsertSort(array,right-left+1); } int div=PartSort(array,left,right); QuickSort(array,left,div-1); QuickSort(array,div+1,right); } void InsertSort(int* a,int n)//當數組規模較小或者存在多個局部有序的子數組時,算法的執行速度會更快 {//插入排序 每次將一個元素插入以排好的序列 for(int i=1;i<n;i++){ for(int j=i;j>0&&a[j]<a[j-1];j--){ swap(a[j],a[j-1]); } } } void PrintArray(int* a,int n) { for(int i=0;i<n;i++){ printf("%d ",a[i]); } } int GetMidIndex(int *a,int left,int right) {//三數取中法(序列是正序或者逆序時,每次選到的樞軸都是沒有起到划分的作用。快排的效率會極速退化。 assert(a); int max,maxAdress; int mid=left+(right-left)/2; if(a[left]<=a[right]){ if(a[mid]<a[left]){ return left; } else if(a[mid]>a[right]){ return right; } else{ return mid; } } else{ if(a[mid]<a[right]){ return right; } else if(a[mid]>a[left]){ return left; } else{ return mid; } } }
3、填坑法
#include<iostream> using namespace std; #include<assert.h> int PartSort(int* array,int left,int right); void QuickSort(int* array,int left,int right); int GetMidIndex(int* a,int left,int right); void PrintArray(int* a,int n); int main()//快速排序(填坑法) { int arr[]={1,4,2,7,8,5,3,9,0,6}; int n=sizeof(arr)/sizeof(arr[0]); QuickSort(arr,0,n-1); PrintArray(arr,n); system("pause"); return 0; } int PartSort(int* array,int left,int right) { int mid=GetMidIndex(array,left,right); swap(array[mid],array[right]); int key=array[right]; while(left<right){//除第一次外,left和right都是從坑開始移動 while(left<right&&array[left]<=key){ ++left; } array[right]=array[left]; while(left<right&&array[right]>=key){ --right; } array[left]=array[right]; } array[right]=key; return right; } void QuickSort(int* array,int left,int right) { if(left>=right){ return; } int div=PartSort(array,left,right); QuickSort(array,left,div-1); QuickSort(array,div+1,right); } void PrintArray(int* a,int n) { for(int i=0;i<n;i++){ printf("%d ",a[i]); } } int GetMidIndex(int *a,int left,int right) {//三數取中法(序列是正序或者逆序時,每次選到的樞軸都是沒有起到划分的作用。快排的效率會極速退化。 assert(a); int max,maxAdress; int mid=left+(right-left)/2; if(a[left]<=a[right]){ if(a[mid]<a[left]){ return left; } else if(a[mid]>a[right]){ return right; } else{ return mid; } } else{ if(a[mid]<a[right]){ return right; } else if(a[mid]>a[left]){ return left; } else{ return mid; } } }