排序演算法(五)快速排序多種版本
阿新 • • 發佈:2019-01-07
快速排序
,就像它的名稱一樣,是時間複雜度比較低的一種排序演算法。
我們知道,快速排序是通過分治的方法,將一個大的區間劃分成小區間(找一個樞紐,將大的數放置在樞紐的右邊,小的數放置在樞紐左邊),然後對左右的兩個小區間進行排序的過程。所以,快速排序的主要就是將區間進行劃分,也就是單趟排序。單趟排序有以下的幾種方法:
註明:以下3種方法中的GetMidNum(),下文予以解釋~
1.左右指標法
如果你仔細閱讀程式碼,你就會發現,出了while迴圈並不是直接進行交換,而是先進行判斷,這究竟是為了處理哪一種情況呢??
我們約定:樞紐值給定的是區間右邊的值,right從樞紐值的前一個位置開始。當left一直向後遍歷,遇到的元素都是小於樞紐值的。當left走到3那個元素的時候,與right是相等的,第二個while迴圈不會進入。如果我們沒有判斷直接進行交換,那樣就是不對的。所以,只有當相遇點的值大於end處的值才進行交換。
如果一開始就讓right從end開始遍歷,如果樞紐前的資料都是小於樞紐值的,那麼left會一直走到樞紐處,與right相遇,此時left和end是同一個位置,交換不交換都無所謂,所以這種情況下就是不需要判斷可以直接交換的。
2.挖坑法:
其實挖坑法的思想是類似於左右指標法的。
實現思路:先將區間最右邊的值儲存下來(這個也就是我們的樞紐值),也就是區間最右邊的這個位置可以被隨意覆蓋,此時區間的最右邊就形成了一個坑。我們開始從左向右進行遍歷,如果找到大於樞紐值的元素,將其填充在右邊的坑裡,此時left這個位置就變成了一個坑,然後從右向左找小於樞紐值的元素,找到之後,將其填充在左邊的坑裡,就這樣,以此類推。
下邊給出程式碼實現:
出了while迴圈就是左右指標相遇的情況,將樞紐值直接放置在left和right相遇點。那麼,像前邊的左右指標法一樣,如果一開始right就指向end的前一個位置。
right一開始指向的元素是3,left一直向右遍歷,都沒有找到大於樞紐值的元素。當left到3的那個元素時,left和right相遇,將樞紐值放在left位置是不合適的。
鑑於處理的情況多,所以我們最好將right從end位置開始遍歷。
關於挖坑法的另外一種寫法(用swap操作代替賦值操作):
我將這兩種方法進行書面推導分析,不懂的讀者可以自行閱讀。
3.前後指標法: 實現思路:開始讓prev指向begin的前一個位置,cur指向begin位置,從左向右,當找到cur指向的值小於樞紐值,將prev後移,然後prev所指向的值與cur所指向的值進行交換。程式碼實現如下;
下邊,我依然圖解程式碼。
文章開始就提出了這麼一個函式GetMidNum(),其實它是進行單趟排序的優化。我們知道如果樞紐值找的合適的話(恰好是中位數或者接近中位數),快排的效率就是很高的了,相反,如果樞紐值是最大值或者最小值或者接近最大最小值時,排序效率比較低。我們的這個函式GetMidNum()就是為了儘量不要得到最大數或者最小數。具體方法就是從給定的區間的首尾和中間的三個元素中取出處於中間位置的數。這樣,最差情況得到的只會是次大數或者次小數。下邊給出GetMidNum()實現程式碼:
通過這樣的方法,我們可以進行單趟排序的優化。 下邊我們用遞迴實現快速排序:
我們知道,遞迴程式碼雖然看起來比較簡單,但是遞迴時的函式進行壓棧的開銷是比較大的,效率很低,所以,我們可以對排序進行優化:如果區間比較小時,我們可以採用插入排序。下邊給出程式碼實現:
經過前人的研究,區間大小的標準定為13比較合適。 上邊已經提到遞迴的缺陷,那麼我們是否可以將快速排序寫成非遞迴的呢?其實,所以的遞迴程式碼都是可以通過棧來實現非遞迴,有些遞迴(尾遞迴)程式碼可以寫成迴圈,有些則不能。 下邊給出快速排序的非遞迴實現:
關於快速排序的各種實現方法也就要整理完了,那麼快速排序究竟是有多快?它的時間複雜度是多少? 我們知道,快速排序的時間複雜度取決於它的遞迴的深度。在最優的情況下,每次的單趟排序都很均勻。每次都可以將區間均分。 T(n) <= 2T(n / 2) + n T(n) <= 2(2T(n/4) + n/2) + n ..... T(n) = O(nlogn) 那麼最壞情況下,也就是每次選的樞紐值是(接近)最大值或者最小值,也就是我們給定的待排序的陣列是(接近)升序或者逆序,這時我們就需要n-1次遞迴呼叫。 總的比較次數:n-1 + n-2 + n-3 +...+1 = n(n-1)/2 (第一次需要比較n-1次,第二次需要n-2次,等等,以此類推) 所以,快速排序的最壞的時間複雜度就是O(N*N)。 我們之前說過,演算法的時間複雜度說的就是最壞情況的時間複雜度。 然而,有例外: 有時時間複雜度並不看最壞情況,而看最好情況,比如雜湊表的查詢,快速排序。 關於快速排序就整理到這裡~
實現思路:找出一個樞紐(或區間開始的元素,或區間結束的元素),從左邊開始進行遍歷,找到一個大於樞紐的值停下來,然後從右邊開始找小於樞紐的值,然後停下來,將左右找到的值進行交換 (左邊找到的是大於樞紐的值,右邊找到的是小於樞紐的值)。當左邊與右邊相遇,說明所有的元素已經處理完成。然後將相遇點的元素與樞紐值進行交換。下邊給出程式碼實現:
//左右指標法 int PartSort1(int* a,int begin,int end) { int midNumIndex = GetMidNum(a,begin,end); if(end != midNumIndex) swap(a[end],a[midNumIndex]); int key = a[end]; int left = begin; int right = end - 1; while(left < right) { while(left < right && a[left] <= key)//在左邊找大於key的值 { ++left; } while(left < right && a[right] >= key)//在右邊找小於key的值 { --right; } swap(a[left],a[right]); } //如果left一直沒有找到大於key的值,會停在end的前一個位置,此時並不需要交換 if(a[left] > a[end]) swap(a[left],a[end]); return left; }
如果你仔細閱讀程式碼,你就會發現,出了while迴圈並不是直接進行交換,而是先進行判斷,這究竟是為了處理哪一種情況呢??
5 | 6 | 2 | 1 | 4 | 0 | 3 | 8 |
//挖坑法
int PartSort2(int* a,int begin,int end)
{
int midNumIndex = GetMidNum(a,begin,end);
if(end != midNumIndex)
swap(a[end],a[midNumIndex]);
int key = a[end];
int left = begin;
int right = end;
while(left < right)
{
while(left < right && a[left] <= key)
{
++left;
}
a[right] = a[left];//將找到的值填到預留的坑
while(left < right && a[right] >= key)
{
--right;
}
a[left] = a[right];
}
a[left] = key;
return left;
}
出了while迴圈就是左右指標相遇的情況,將樞紐值直接放置在left和right相遇點。那麼,像前邊的左右指標法一樣,如果一開始right就指向end的前一個位置。
5 | 6 | 2 | 1 | 4 | 0 | 3 | 8 |
int PartSort2(int* a,int begin,int end)
{
int midNumIndex = GetMidNum(a,begin,end);
if(end != midNumIndex)
swap(a[end],a[midNumIndex]);
int key = a[end];
int left = begin;
int right = end;
while(left < right)
{
while(left < right && a[left] <= key)
{
++left;
}
swap(a[right] , a[left]);//將找到的值填到預留的坑
while(left < right && a[right] >= key)
{
--right;
}
swap(a[right] , a[left]);
}
return left;
}
我將這兩種方法進行書面推導分析,不懂的讀者可以自行閱讀。
3.前後指標法: 實現思路:開始讓prev指向begin的前一個位置,cur指向begin位置,從左向右,當找到cur指向的值小於樞紐值,將prev後移,然後prev所指向的值與cur所指向的值進行交換。程式碼實現如下;
//前後指標法
int PartSort3(int* a,int begin,int end)
{
int midNumIndex = GetMidNum(a,begin,end);
if(end != midNumIndex)
swap(a[end],a[midNumIndex]);
int key = a[end];
int prev = begin - 1;
int cur = begin;
while(cur < end)
{
if(a[cur] < key && ++prev != cur)//prev指向大於key的值
{
swap(a[prev],a[cur]);
}
++cur;
}
swap(a[++prev],a[end]);
return prev;
}
下邊,我依然圖解程式碼。
文章開始就提出了這麼一個函式GetMidNum(),其實它是進行單趟排序的優化。我們知道如果樞紐值找的合適的話(恰好是中位數或者接近中位數),快排的效率就是很高的了,相反,如果樞紐值是最大值或者最小值或者接近最大最小值時,排序效率比較低。我們的這個函式GetMidNum()就是為了儘量不要得到最大數或者最小數。具體方法就是從給定的區間的首尾和中間的三個元素中取出處於中間位置的數。這樣,最差情況得到的只會是次大數或者次小數。下邊給出GetMidNum()實現程式碼:
int GetMidNum(int* a,int begin,int end)
{
int mid = begin + (end - begin)/2;
//找出個數中處於中間位置的數
// a[begin] > a[mid]
if(a[begin] > a[mid])
{
if(a[mid] > a[end])//a[begin] > a[mid] > a[end]
return mid;
//a[begin] > a[mid] < a[end]
else if(a[begin] > a[end])//a[begin] > a[end] > a[mid]
return end;
else // a[end] > a[begin]> a[mid]
return begin;
}
//a[mid]> a[begin]
else
{
if(a[begin] > a[end])//a[mid]> a[begin]> a[end]
return begin;
//a[mid]> a[begin]< a[end]
else if(a[mid] > a[end])//a[mid] > a[end]> a[begin]
return end;
else //a[end] > a[mid] > a[begin]
return mid;
}
}
通過這樣的方法,我們可以進行單趟排序的優化。 下邊我們用遞迴實現快速排序:
void QuickSort(int* a,int begin,int end)
{
if(begin < end)
{
int key = PartSort2(a,begin,end);
QuickSort(a,begin,key-1);
QuickSort(a,key + 1,end);
}
}
我們知道,遞迴程式碼雖然看起來比較簡單,但是遞迴時的函式進行壓棧的開銷是比較大的,效率很低,所以,我們可以對排序進行優化:如果區間比較小時,我們可以採用插入排序。下邊給出程式碼實現:
//快排優化版本
void QuickSortOP(int* a,int begin,int end)
{
//由於遞迴太深會導致棧溢位,效率低,所以,當區間比較小時採用插入排序。
if(end - begin > 13)
{
int key = PartSort3(a,begin,end);
QuickSort(a,begin,key-1);
QuickSort(a,key + 1,end);
}
else
InsertSort(a+begin,end-begin + 1);
}
經過前人的研究,區間大小的標準定為13比較合適。 上邊已經提到遞迴的缺陷,那麼我們是否可以將快速排序寫成非遞迴的呢?其實,所以的遞迴程式碼都是可以通過棧來實現非遞迴,有些遞迴(尾遞迴)程式碼可以寫成迴圈,有些則不能。 下邊給出快速排序的非遞迴實現:
void QuickSortNonR(int* a,int begin,int end)
{
stack<int> s;
if(begin < end)
{//先將區間尾放進棧裡
s.push(end);
s.push(begin);
while(!s.empty())
{
int low = s.top();
s.pop();
int high = s.top();
s.pop();
int mid = PartSort1(a, low, high);
if(low < mid-1)
{
s.push(mid-1);
s.push(low);
}
if(mid+1 < high)
{
s.push(high);
s.push(mid+1);
}
}
}
}
關於快速排序的各種實現方法也就要整理完了,那麼快速排序究竟是有多快?它的時間複雜度是多少? 我們知道,快速排序的時間複雜度取決於它的遞迴的深度。在最優的情況下,每次的單趟排序都很均勻。每次都可以將區間均分。 T(n) <= 2T(n / 2) + n T(n) <= 2(2T(n/4) + n/2) + n ..... T(n) = O(nlogn) 那麼最壞情況下,也就是每次選的樞紐值是(接近)最大值或者最小值,也就是我們給定的待排序的陣列是(接近)升序或者逆序,這時我們就需要n-1次遞迴呼叫。 總的比較次數:n-1 + n-2 + n-3 +...+1 = n(n-1)/2 (第一次需要比較n-1次,第二次需要n-2次,等等,以此類推) 所以,快速排序的最壞的時間複雜度就是O(N*N)。 我們之前說過,演算法的時間複雜度說的就是最壞情況的時間複雜度。 然而,有例外: 有時時間複雜度並不看最壞情況,而看最好情況,比如雜湊表的查詢,快速排序。 關於快速排序就整理到這裡~