[排序演算法] 快速排序 (C++) (含三種寫法)

快速排序解釋

快速排序 Quick Sort 與歸併排序一樣,也是典型的分治法的應用。 (如果有對 歸併排序還不了解的童鞋,可以看看這裡喲~ 歸併排序)❤❤❤

快速排序的分治模式

1、選取基準值,獲取劃分位置。將原數組 a[l, r] 劃分為兩個子數組 a[l, mid – 1]a[mid + 1, r]。在前一個數組中所有元素都小於等於 a[mid],後一個數組中所有元素都大於等於 a[mid]。而此時的 a[mid] 的值就是我們所取的基準值,mid 就每次劃分的位置;

2、遞歸調用快速排序函數,分別對兩個子數組 a[l, mid – 1]a[mid + 1, r] 排序;

3、快速排序我們是在原數組上進行操作的,所以我們並不需要合併,最後 a[l, r] 已經有序。

快速排序的三種寫法

快速排序比較普及的有三種寫法,分別是 左右指針法 挖坑法前後指針法。主要是取得劃分位置實現的方法有所不同
接下來會逐個介紹這三種快速排序的寫法。


左右指針法

左右指針法步驟

1、首先 我們一般選取最左邊的元素作為基準值 key

2、然後我們需要定義兩個變數 ij
其中 i 為左指針(其實不是指針啦,只是為了方便這麼叫它😋),初始 i = 0。 j 為右指針,初始 j = r – 1,向左遍歷不斷找到小於基準值 key 的元素。

3、我們先動右指針 j 向左遍歷直到找到小於當前基準值 key 的元素;然後我們再動左指針 i 向右遍歷直到找到大於當前基準值 key 的元素。
ij 分別找到它們要找的元素時,我們需要將兩個元素進行位置交換。( 在這個過程中我們要保持 i < j )

4、重複步驟3,直到最後我們可愛的左右指針相遇,這時我們再將基準值 key,放到這兩個指針指向的位置。此時我們就得到了當前劃分的位置,基準值 key 也完成了歸位。

左右指針法移動指針先後順序問題

在上述步驟中,有些人會感到疑惑,那就是我們再移動指針時,每次都要 先移動右指針再移動左指針。為什麼呢?😕😕😕

在取基準值時,我們一般都是將序列的最左邊位置的元素作為基準值。我們每次交換完元素後,左右指針都會繼續尋找他們要找的值,觀感上就是相互靠近。而問題就出在我們退出循環的那一刻

我們一直保持 i < j,也就是說,我們會在 i == j 時退出循環。假設 在某次交換之後,此時 j 指向的是交換之後的一個大於基準值的元素,如果我們先動左指針 i 去尋找一個大於基準值的元素,然鵝還未找到就已經和右指針 j 相遇了,這個時候我們需要退出循環,交換基準值 key 到當前兩個指針指向的位置。
但是!!!,此時 ij 指向的是大於基準值的元素,那麼我們進行交換基準值位置操作後,這個大於基準值的元素就被換到了序列的最左端,很明顯,這時候出現了非常非常非常嚴重的錯誤。

那如果我們先動右指針 j,去尋找一個小於基準值的元素,然鵝沒有找到就已經和左指針 i 相遇了,這個時候退出循環,ij 指向的一定是一個小於等於基準值的值。

究其原因,這其實是我們取最左邊的元素作為基準值導致的。我們需要保證每次交換過來的元素是小於等於基準值的,所以我們先動右指針再動左指針


左右指針法動態演示

我們以序列 [3,14,6,1,2,17,7] 為例進行動態演示。(後面還會有先動左指針的錯誤演示)

左右指針法正確演示

左右指針法錯誤演示


左右指針法核心程式碼

//左右指針法  
int Partition_Hoare(vector<int> &a, int left, int right){
    int i = left;
    int j = right;
    int key = a[left];

    while(i != j){
        while(i < j && a[j] >= key)      	 //向左找到小於基準值的值的下標
            j--;
        while(i < j && a[i] <= key)      	 //向右找到大於基準值的值的下標
            i++;
	swap(a[i], a[j]);
	}
    /*   i等於j時跳出循環 當前基準值此時在下標為i的位置(合適的位置)   */
    swap(a[left], a[i]);	                 //最左邊的元素變為處於當前合適位置的元素,把基準值放在合適位置                                                 
    return i;                                  //返回合適位置(i,j都可以)
}

挖坑法

挖坑法步驟

挖坑法顧名思義,就是每次挖一個坑填入元素使其歸位。和左右指針法一樣,我們同樣需要兩個變數 ij
1、我們一般取最左邊的元素為基準值,然後在基準值處挖空(挖坑)

2、先動右指針 j 向左找到一個小於基準值的元素,然後將其填入先前挖的坑中,同時 j 指向的位置也多出來一個坑位。

3、再動左指針 i 向右找到一個大於基準值的元素,然後將其填入先前挖的坑中,同時 i 指向的位置又多出來一個坑位。

4、重複上述操作,直到最後兩個指針相遇,此時再在這個坑位上填入基準值,即完成了基準值的歸位,也就是我們劃分的位置。

(具體實現這個挖坑填坑的方式可以是元素值的覆蓋,也可以是元素交換的形式,在後面的核心程式碼中會給出這兩種方式)

挖坑法動態演示

我們以序列 [3,14,6,1,2,17,7] 為例進行動態演示。

圖(1)

圖(2)


挖坑法核心程式碼

//挖坑法1  
int Partition_DigI(vector<int> &a, int left, int right){
    int i = left;
    int j = right;
    int key = a[left];

    while(i != j){
	while(i < j && a[j] >= key)          //必須先動右邊的 因為取的基準值在左邊
	    j--;                             //向左尋找直到找到比基準值小的
	a[i] = a[j];                         //挖坑 填入比基準值小的值

	while(i < j && a[i] <= key)          //再動左邊的
	    i++;                             //向右尋找直到找到比基準值大的 
	a[j] = a[i];                         //挖坑 填入比基準值大的值
    }
    /*   i等於j時跳出循環 下標為i的位置即為合適的插入位置   */
    a[i] = key;                              //在i,j相遇的地方填入基準值
    return i;
}

//挖坑法2 
int Partition_DigII(vector<int> &a, int left, int right){
    int i = left;                            //從左邊開始
    int j = right;                           //從右邊開始
    int key = a[left];                       //將最左邊的數設為基準值	

    while(i != j){                           
	while(i < j && a[j] >= key)          //必須先動右邊的 因為取的基準值在左邊
	    j--;                             //向左尋找直到找到比基準值小的
	swap(a[i], a[j]);                    //交換
	i++;                                 //看下一個(可以省略)

	while(i < j && a[i] <= key)          //再動左邊的
	    i++;                             //向右尋找直到找到比基準值大的    
	swap(a[i], a[j]);                    //交換
	j--;                                 //看下一個(可以省略)
    }  
    /* 一輪循環後基準值所在位置左邊的數都比基準值小,右邊的都比基準值大 */
    /*   i等於j時跳出循環 當前基準值此時在下標為i的位置(合適的位置)   */                       
    return i;                                //返回基準值合適位置
}

前後指針法

前後指針法步驟

前後指針法同樣需要兩個指針,一個是 pre,一個是 currpre 最後找到的是劃分後左半部分最後一個小於基準值的元素。
1、一開始我們一般將前指針 pre 指向待排序序列的第一個元素,後指針 curr 指向 pre 後面的一個元素。

2、然後我們讓 後指針 curr 去尋找小於當前基準值的元素,若找到這個元素,此時需要將這個小於基準值的元素和當前 前指針 pre 後一個的位置的元素進行交換,即與 pre + 1 位置的元素進行交換,同時前指針 pre 也往後移動一步。

3、在上述過程中我們需要保持 curr <= r,當 curr > r 退出循環時,需要將當前 pre 所指向的元素和基準值進行交換。最後返回這個pre,即為我們劃分的位置。


前後指針法動態演示

我們以序列 [3,14,6,1,2,17,7] 為例進行動態演示。

圖(1)

圖(2)


前後指針法核心程式碼

//前後指針法  
int Partition_PreAndCurr(vector<int> &a, int left, int right){
    int pre = left;                          //pre最後找到左半部分最後一個小於基準值的元素
    int curr = pre + 1;                      //curr找到pre指向元素之後的下一個小於基準值的元素
    int key = a[left];

    while(curr <= right){
	if(a[curr] < key && ++pre != curr)   //當a[curr]>=key時pre保持不動,直到最後curr找到下一個小於基準值的元素
	    swap(a[curr], a[pre]);           //pre往後移動到其下一個位置,並交換前後指針指向的元素
	curr++;
    }
    swap(a[left], a[pre]);                   //最後將基準值和此時處於pre位置的小於基準值的元素交換
    return pre;
}

快速排序時間複雜度

假設時間複雜度為 T(n)。在進行取得劃分位置上我們需要消耗 O(n) 的時間,在遞歸調用快速排序函數上,最好情況下如果每次我們取得的劃分位置為n/2的位置,即消耗了 2T(n/2) 的時間。最壞情況下如果每次我們取得的劃分位置為首或尾的位置,即消耗 T(n-1)+T(0)的時間。
故時間複雜度如下

最優時間複雜度

最壞時間複雜度


取基準值優化

如何高效取基準值

對於取基準值,我們發現 如果當前所取的基準值恰好是當前序列中最小的數或者最大的數,那麼我們的劃分位置必然是首或尾的位置,如果每次都這個亞籽,我們消耗的時間會非常的多,這顯然就是最壞情況下的時間複雜度的狀況。

那麼我們如何盡量地避免這件事呢?🤔🤔🤔

我們自然會想到隨機取數,這固然是一個很好的辦法,但是這個世界充滿了無限可能性。假如我們原先最左邊的元素並不是最小或最大的一個元素,但是隨機取到的元素卻恰好是那個最小或最大的元素呢?

(當然隨機取數這個方法也是值得嘗試的,後續程式中我不會用這個方式實現取基準值的優化,如果有需要,只需要把取基準值的幾行程式碼改成如下就可以了)

int random = rand() % (right - left + 1) + left;  
swap(a[random], a[left]);
int key = a[left]

為了避免這種充滿戲劇性的情況,我們採取 三數取中 的方法取基準值。

三數取中,就是取序列中的最左邊,中間,以及最右邊三個數進行比較,以這三個數大小為中的元素作為基準值。這樣可以更大程度上避免劃分位置不好的情況,即使其並不能徹底避免,但是是一個很好的辦法。


取基準值優化核心程式碼

int GetMid(int a[], int left, int right){
	int mid = (left + right)>>1;   
	if(a[left] < a[mid])
	    if(a[mid] < a[right])
		return mid;                      //mid為中間值
	    else
		if(a[left] < a[right])
		    return right;                //right為中間值
		else
		    return left;                 //left為中間值
	else
	    if(a[mid] > a[right])
		return mid;                      //mid為中間值
	    else
		if(a[left] > a[right])
		    return right;                //right為中間值
		else
		    return left;                 //left為中間值
}


完整程式源程式碼

#include<iostream>
#include<vector>
#include<time.h>
using namespace std;

/*                               1                                  */
//左右指針法  
int Partition_Hoare(vector<int> &a, int left, int right){
	int i = left;
	int j = right;
	int key = a[left];

	while(i != j){
	    while(i < j && a[j] >= key)      	 //向左找到小於基準值的值的下標
                j--;
            while(i < j && a[i] <= key)      	 //向右找到大於基準值的值的下標
                i++;
	    swap(a[i], a[j]);
	}
	/*   i等於j時跳出循環 當前基準值此時在下標為i的位置(合適的位置)   */
	swap(a[left], a[i]);	                 //最左邊的元素變為處於當前合適位置的元素,把基準值放在合適位置                                     
	return i;                                //返回合適位置(i,j都可以)
}



/*                                 2                                */
//挖坑法1  
int Partition_DigI(vector<int> &a, int left, int right){
    int i = left;
    int j = right;
    int key = a[left];

    while(i != j){
	while(i < j && a[j] >= key)          //必須先動右邊的 因為取的基準值在左邊
	    j--;                             //向左尋找直到找到比基準值小的
	a[i] = a[j];                         //挖坑 填入比基準值小的值

	while(i < j && a[i] <= key)          //再動左邊的
	    i++;                             //向右尋找直到找到比基準值大的 
	a[j] = a[i];                         //挖坑 填入比基準值大的值
    }
    /*   i等於j時跳出循環 下標為i的位置即為合適的插入位置   */
    a[i] = key;                              //在i,j相遇的地方填入基準值
    return i;
}

//挖坑法2 
int Partition_DigII(vector<int> &a, int left, int right){
    int i = left;                            //從左邊開始
    int j = right;                           //從右邊開始
    int key = a[left];                       //將最左邊的數設為基準值	

    while(i != j){                           
	while(i < j && a[j] >= key)          //必須先動右邊的 因為取的基準值在左邊
	    j--;                             //向左尋找直到找到比基準值小的
	swap(a[i], a[j]);                    //交換
	i++;                                 //看下一個(可以省略)

	while(i < j && a[i] <= key)          //再動左邊的
	    i++;                             //向右尋找直到找到比基準值大的    
	swap(a[i], a[j]);                    //交換
	j--;                                 //看下一個(可以省略)
	}  
	/* 一輪循環後基準值所在位置左邊的數都比基準值小,右邊的都比基準值大 */
    /*   i等於j時跳出循環 當前基準值此時在下標為i的位置(合適的位置)   */                       
    return i;                                //返回基準值合適位置
}



/*                               3                                  */
//前後指針法  
int Partition_PreAndCurr(vector<int> &a, int left, int right){
    int pre = left;                          //pre最後找到左半部分最後一個小於基準值的元素
    int curr = pre + 1;                      //curr找到pre指向元素之後的下一個小於基準值的元素
    int key = a[left];

    while(curr <= right){
	if(a[curr] < key && ++pre != curr)   //當a[curr]>=key時pre保持不動
	    swap(a[curr], a[pre]);           //pre往後移動到其下一個位置,並交換前後指針指向的元素
	curr++;
    }
    swap(a[left], a[pre]);                   //最後將基準值和此時處於pre位置的小於基準值的元素交換
    return pre;
}



/*                               4                                  */
//取基準值的優化    三數取中
int GetMid(int a[], int left, int right){
	int mid = (left + right)>>1;   
	if(a[left] < a[mid])
	    if(a[mid] < a[right])
		return mid;                      //mid為中間值
	    else
		if(a[left] < a[right])
		    return right;                //right為中間值
		else
		    return left;                 //left為中間值
	else
	    if(a[mid] > a[right])
		return mid;                      //mid為中間值
	    else
		if(a[left] > a[right])
		    return right;                //right為中間值
		else
		    return left;                 //left為中間值
}


//左右指針法 三數取中優化
int Partition_Better(vector<int> &a, int left, int right){
    int pos = GetMid(a, left, right);
    swap(a[pos], a[left]);                   //將取得的基準值和第一個位置交換

    int i = left;
    int j = right;
    int key = a[left];
	
    while(i != j){
	while(i < j && a[j] >= key)
	    j--;
	while(i < j && a[i] <= key)
	    i++;
	swap(a[i], a[j]);
    }

    swap(a[i], a[left]);
    return i;
}



/*******************************************************************/
void Quick_Sort(vector<int> &a, int left, int right){
    if( left >= right )
	return;   

    //int i = Partition_Hoare(a, left ,right); //分割    C(n) = n - 1
    //int i = Partition_DigI(a, left, right);
    //int i = Partition_DigII(a, left, right);
    //int i = Partition_PreAndCurr(a, left, right);

    int i = Partition_Better(a, left, right);   
    Quick_Sort(a, left, i - 1);              //遞歸左邊的部分   
    Quick_Sort(a, i + 1, right);             //遞歸右邊的部分  
}  


void show(vector<int> &v){
    for(auto &x : v)
	cout<<x<<" ";
    cout<<endl;
}


main(){
    vector<int> v;
    srand((int)time(0));
    int n = 50;
    while(n--)
	v.push_back(rand() % 100 + 1);
    show(v);

    Quick_Sort(v, 0, v.size() - 1);

    cout<<endl<<endl;
    show(v);
}

程式運行結果圖