一、快速排序總結:
給快速排序做個總結是看到之前上數據結構的時候給老師寫的一個關於考試程序糾錯的郵件。錯誤的程序如下:
void QuickSort(RecType R[],int s,int t)
{
int i=s,j=t;
RecType tmp;
if (s<t)
{
tmp=R[s];
while (i!=j)
{
while (j>i && R[j].key>tmp.key) //在該處若初始序列首尾值相等,將陷入死循環
j--; //一種修改方法是 R[j].key>=tmp.key
R[i]=R[j]; //另一種是在R[i]=R[j]後加i++語句(後面是j--)
while (i<j && R[i].key<tmp.key)
i++;
R[j]=R[i];
}
R[i]=tmp;
QuickSort(R,s,i-1);
QuickSort(R,i+1,t);
}
}
之前寫快速排序都是喜歡上面那樣寫一個函數,快排的經典思想就是分而治之。對一個數組,先選一個pivotkey(樞軸中心),一般情況下就直接選數組的第一個元素。然後對數組從後向前遍歷,將小於pivotkey的元素放在數組左邊;又對數組從前向後遍歷,將大於pivotkey的元素放在數組的右邊;如此反覆之後前後遍歷的僞指針相等,這時候也就是pivotkey值應在的位置:左半部分全小於pivotkey,右半部分全大於pivotkey。 接下來分而治之,採用遞歸在將左半部分和右半部分分別再做上述操作。時間複雜度最好的情況下(每次樞軸放在數組正中間)是O(n*logn)。 最壞的情況(數組本身已經有序順序或者逆序)是O(n^2)。 快速排序複雜度分析
上面的寫法將數組partition和算法遞歸的過程寫在了一起。兩個過程分開寫的寫法如下(易於理解):
//partition 過程,返回數組樞軸值正確位置的 index
int quickpass(int low, int high, int *a)
{
int pivotkey = a[low];
while(low < high)
{
while(low<high && a[high]>=pivotkey)
high--;
a[low] = a[high];
while(low<high && a[low]<=pivotkey)
low++;
a[high] = a[low];
}
a[low] = pivotkey;
return low;
}
//算法遞歸過程,分而治之
void quicksort(int low, int high, int *a)
{
int pivot;
if(low < high)
{
pivot = quickpass(low, high, a);
quicksort(low, pivot-1, a);
quicksort(pivot+1, high, a);
}
}
上面兩個寫法的特點都是選定數組的第一個元素作爲樞軸值。然後從後向前掃描,將大小於樞軸值得元素放到數組左邊,僞指針j初始值爲end,操作爲j--;然後又從前向後掃描,將大於樞軸值的元素放在數組右邊,僞指針i初始值爲start,操作爲 i++;交替兩種操作,直到 i=j,就是樞軸值應該放的位置,也就是partition過程要返回的index,另外數組在c語言中實現是採用的不是值傳遞,而是引用傳遞,那麼同樣也是返回了樞軸值放在了正確位置的排序後數組。
算法教材《算法設計技巧與分析》採用的寫法就比較直接些:
#include <iostream>
using namespace std;
void swap(int &a, int &b)
{
int temp = a;
a = b;
b = temp;
/* 幾種比較好的swap寫法
a = a^b;
b = a^b;
a = a^b;
*/
/*
a = a+b;
b = a-b;
a = a-b;
*/
}
int partition(int low, int high, int *a)
{
int pivotkey = a[low];
int i = low;
for(int j=low+1; j<=high; j++)
{
if(a[j]<=pivotkey)
{
i = i+1;
if(i!=j)
swap(a[i], a[j]);
}
}
swap(a[low], a[i]);
return i;
}
void quicksort(int low, int high, int *a)
{
int pivot = 0;
if(low<high) //注意此處不能寫成low!=high,有時partition部分返回的index就是pivot=low,如果if判斷條件是low!=high,那麼pivot-1就越界了
{
pivot = partition(low, high, a);
quicksort(low, pivot-1, a);
quicksort(pivot+1, high, a);
}
}
int main()
{
int a[3];
for(int i =0; i<3; i++)
cin>>a[i];
quicksort(0, 2, a);
for(int j=0; j<3; j++)
cout<<a[j];
system("pause");
return 0;
}
算法教材寫的思路也是在partition部分設置第一個元素是樞軸值,但是在掃描整個數組的時候,不是和之前的寫法那樣前後交替掃描,而是同向設置兩個僞指針i和j,j從第二個元素開始依次掃描整個數組,當找到一個小於等於樞軸值得元素時(也就是該元素應該放在數軸值的左邊),由於i 其實總是指向數組中最後一個小於樞軸值的元素,那麼i=i+1的後,如果i!=j,意思就是ij之間有元素,這些元素(包括i)必須是大於pivotkey的,交換ij所指的值,就是讓大於樞軸值得在右邊,小於樞軸值得在左邊;但如果i=j,說明ij指的元素都是小於等於pivokey的,沒有必要交換。 i+1的操作其實保證i每次都是指向數組中最後一個小於等於樞軸值的元素(這也是說快速排序是不穩定的排序算法的原因所在,對 8 4 5 6 4 7這樣的序列會讓兩個4的相對位置發生改變),j就繼續向前掃描。
注意j掃描完全數組之後i就是指向了數組中最後一個元素值小於數軸值的位置,此時交換樞軸值和a[i],然後返回樞軸值應處於的正確位置index。
前面的快速排序中partition過程每次利用樞軸值將數組分成左半部分全小於樞軸值,右半部分全大於樞軸值(或者相反),算法遞歸過程再將左半部分和右半部分分別再進行快排遞歸,得到左半部分和右半部分分別有序,這樣整個數組就有序。
有時候需要處理的問題不需對整個數組排序,只需要對數組做一個partition,在某index處,數組前半部分全小於index_key, 數組後半部分全大於index_key(或者相反)。比如經典面試題: 求數組中出現次數超過一半的數字(劍指offer面試題T29) Problem 1203 - 找相同 Problem 1204 - 繼續找相同以及 對N個數,找出前最小的K個數(劍指offer面試題30)
對前者,如果要將數組全部排好序之後,再計算每個數字出現的次數(該書作者的假想這麼做,那麼時間複雜度是O(n*logn))。 但是對要統計每個數字出現的個數的話,一般想到的應該是 計數排序,a[i]存儲數組元素,b[a[i]]存儲每個元素出現的次數,到時遍歷b數組,看是否有大於一般的值。若b[i]值大於數組元素個數的一般,那麼答案就是 i,b[a[i]] = b[i]。但是計數排序有個限制就是要求數組元素都是非負數。
對前者,其實可以考慮到這種情況下數組特點: 數組中超過一半的那個數必然出現在數組中間的位置,那麼問題就轉化爲求n個元素的數組中第 n/2 大的元素,也就是求n個元素中第K大的元素(經典題)
這一類問題都可以採用半快速排序的思想,快排中每一次partition都將返回一個index,如果index>k,那麼再對start到index-1這一段快排嗎;如果index<k,那麼再對inde+1到end這一段快排。直到partition返回的index = k (上面的題目中k=1/2)。
對後者,其實也是同樣的道理,找出前最小的k個數,那麼快排 partition 部分返回的index要是等於k,那麼位於index左半部分的數都是前最小的k個數,只是這k個數沒有排序而已(如果題目不要順序輸出前K個數的話)。
三、半快速排序思想時間複雜度的分析
前面兩個題要是採用直觀的方法:先對整個數組排序,然後再處理數組的話,顯然時間複雜度是O(n*logn);
採用半快速排序思想,時間複雜度可以降到O(n),但是也正如同快速排序的實際算法複雜度依靠初始數組一樣: 如果初始數組已經是順序/逆序數組,那麼每次partition過程後遞歸的子樹就是一個單向樹,複雜度是O(n^2),所以woj1203的題目用半排序思想其實過不了。對於空間複雜度,半快速排序由於存在遞歸設計,無法做到O(1)的情況。 這也是對於求數組中出現次數超過一半的數字類型問題,爲什麼前面WOJ1203題目用半快速排序可以過(忽略時間複雜度的話),但是WOJ1204不可以。前者給的內存有65536KB,而後者之後5240KB。解決這個問題就可以採用算法教材candidate算法,後一篇博文分析。
另外WOJ1203尋找多數元素的這種題目也可以用C++中的std_map 或者java中的HashMap做,用空間換時間.