與歸並排序一樣,快速排序使用也使用了分治的思想。下面是對一個典型的子數組A[p,...,r]進行快速排序的三步分治過程:
分解:數組A[p,...,r]被划分成兩個(可能為空)子數組A[P,...,q-1]和A[q+1,...,r],使得A[p,...,q-1]中每個元素都小於等於A[q],而A[q]也小於等於A[q+1,...,r]中的每個元素。其中,計算下標q也是划分過程的一部分。
解決:通過遞歸調用快速排序,對子數組啊A[P,...,q-1]和A[q+1,...,r]進行排序。
合並:因為子數組都是原址排序的,所以不需要合並操作:數組A[P,...r]已經有序。
下面是程序實現快速排序:
1 void QuickSort(int a[], int p, int r) { 2 if (p < r) { 3 int q = Partition(a, p, r); 4 QuickSort(A, p, q - 1); 5 QuickSort(A, q + 1, r); 6 } 7 }
數組的划分:
算法的關鍵部分是Partition過程,它實現了對子數組A[p,...r]的原址重排。快速排序的分治partition過程有兩種方法。
1) 兩個下標分別從首、尾向中間掃描的方法。
假設每次總是以當前表中第一個元素作為樞紐值(基准)對表進行划分,則必須將表中比樞紐值大的元素向右移動,比樞紐值小的元素向左移動,使得一趟Partition()操作之后,表中的元素被樞紐值一分為二。
1 int Partition(int a[], int low, int high) { 2 int pivot = a[low]; 3 while (low < high) { 4 while (low < high && a[high] >= pivot) --high; 5 a[low] = a[high]; 6 while (low < high && a[low] <= pivot) ++low; 7 a[high] = a[low]; 8 } 9 a[low] = pivot; 10 return low; 11 }
2)兩個指針索引一前一后逐步向后掃描的方法(算法導論)。
1 int Partition(int a[], int low, int high) { 2 int pivot = a[high]; 3 int i = low - 1; 4 for (int j = low; j <= high - 1; j++) { 5 if (a[low] <= pivot) { 6 ++i; 7 swap(a[i], a[j]); 8 } 9 } 10 swap(a[i + 1], a[high]); 11 return i+1; 12 }
注意:上述算法有一個特點,即一次划分后,樞紐左邊的相對位置不變。比如,原始序列:[3,8,7,1,2,5,6,4]->[3,1,2,4,7,5,6,8]。樞紐4左邊的相對順序不變,元素3,1,2保持在初始序列中的相對順序(原序列中為3,...,1,2,...),某些應用要求序列的一部分保持相對順序,這時可以考慮此種划分。
快速排序算法的性能分析如下:
空間效率:由於快速排序是遞歸的,需要借助一個遞歸工作棧來保存每一層遞歸調用的必要信息,其容量應與遞歸調用的最大深度一致。最好情況下為⌈log2(n+1)⌉;最壞情況下,因為要進行n-1次遞歸調用,所以棧的深度為O(n);平均情況下棧的深度為O(log2n)。因而空間復雜度在最壞情況下為O(n),平均情況下為O(log2n)。
時間效率:快速排序的運行時間與划分是否對稱有關,而后者又與具體使用的划分算法有關。快速排序最壞的情況發生在兩個區域分別包含n-1個元素和0個元素時,這種程度的不對稱性若發生在每一層遞歸上,即對應於初始排序表基本有序貨基本逆序時,就得到最壞情況下的時間復雜度為O(n2)。
有很多方法可以提高算法的效率。一種方法是當遞歸過程中划分得到的子序列的規模較小時不要再繼續調用快速排序,可以直接采用直接插入排序算法進行后續的排序工作。另一種方法就是盡量選取一個可以將數據中分的樞軸元素。如從序列的頭尾以及中間選取三個元素,再取這三個元素的中間值作為最終的樞軸元素(數據結構與算法分析);或者隨機從當前列表中選取樞軸元素(算法導論),這樣做使得最壞情況在實際安排中幾乎不會發生。
在最理想狀態下,也即Partition()可能做到最平衡的划分中,得到的兩個子問題的大小都不可能大於n/2,這種情況下,快速排序的運行速度將大大提升,此時,時間復雜度為O(nlog2n)。好在快速排序平均情況下運行時間與其最佳情況下的運行時間很接近,而不是接近最壞情況下的運行時間。
快速排序是所有內部排序算法中平均性能最優的排序算法。
穩定性:快速排序不是穩定的排序算法。
一、快速排序一次排序的應用
1.一個數組中存儲有且僅有大寫和小寫字母,編寫一個函數對數組內的字母重新排列,讓小寫字母在所有大寫字母之前。
1 void Partition(char a[], int length) { 2 if (a == NULL || length <= 0) 3 return; 4 5 int i = 0; 6 for (int j = 0; j <= length - 1; ++j) { 7 if (a[j] >= 'a' && a[j] <= 'z') { 8 i++; 9 char temp = a[i]; 10 a[i] = a[j]; 11 a[j] = temp; 12 } 13 } 14 }
2. 給定含有n個元素的整形數組a, 其中包括0元素和非0元素,對數組進行排序,要求:
1)排序后所有0元素在前,所有非零元素在后,且非零元素排序前后相對位置不變。
2)不能使用額外存儲空間。、
例如:
輸入 0、3、0、2、1、0、0
輸出 0、0、0、0、3、2、1
解答:此處要求非零元素排序前后相對位置不變,可以利用快排一次排序的第二種情況。
void Partition(int A[], int p, int r) { int i = r + 1; for (int j = r; j >= p; --j) { if (A[j] != 0) { --i; int temp = A[i]; A[i] = A[j]; A[j] = temp; } } }
3. 荷蘭國旗問題
將亂序的紅白藍三色小球排列成同顏色在一起的小球組(按照紅白藍排序),這個問題稱為荷蘭國旗問題。這是因為我們可以將紅白藍小球想象成為條狀物,有序排列后正好組成荷蘭國旗,用0表示紅球,2為籃球,1為白球。
解答:這個問題,類似於快排中partition過程。不過,要用三個指針,一個begin,一中current,一后end,begin與current都初始化指向數組首部,end初始化指向數組尾部。
1. current遍歷整個數組序列,current指1時,不交換,current++;
2. current指0時,與begin交換,而后current++,begin++;
3. current指2時,與end交換,而后,current不動,end--。
1 while (current <= end) { 2 if (array[current] == 0) { 3 swap(array[current], array[begin]); 4 current++; 5 begin++; 6 } else if (array[current] == 1) { 7 current++; 8 } else { 9 swap(array[current], array[end]); 10 end--; 11 } 12 }
二、最小的k個數
輸入n個整數,輸出其中最小的k個。
例如輸入1,2,3,4,5,6,7,8這8個數字,則最小的4個數字為1,2,3,4
解答:分析:這到底最簡單的思路莫過於把輸入的n個整數排序,這樣排在最前面的k個數就是最小的k個數。只是這種思路的時間復雜度為O(nlgn)。我們試着尋找更快的解題思路。
我們設最小的k個數中最大的數為A。在快速排序算法中,我們現在數組中隨機選擇一個數字,然后調整數組中數字的順序,使得比選中的數字小的數字都排在他的左邊,比選中的數字大的數字都排在它的右邊(即快排一次排序)。如果這個選中的數字的下標剛好是k-1(下標從0)開始,那么這個數字(就是A)加上左側的k-1個數字就是最小的k個數。
如果它的下標大於k-1,那么A應該位於它的左邊,我們可以接着在它的右邊部分的數組中尋找。可見這是一個遞歸問題,但是注意我們找到的k個數不一定是有序的。
1 int Partition(int a[], int p, int r) { 2 int pivot = a[r]; 3 int i = p - 1; 4 for (int j = p; j <= r - 1; ++j) { 5 if (a[j] <= pivot) { 6 ++i; 7 swap(a[i], a[j]); 8 } 9 } 10 swap(a[i + 1], a[r]); 11 return i + 1; 12 } 13 void GetLeastKNum(int *input, int n, int k) { 14 if (input == NULL || n <= 0 || k > n || k <= 0) 15 return; 16 17 int start = 0; 18 int end = n - 1; 19 int index = Partition(input, start, end); 20 while (index != k - 1) { 21 if (index < k - 1) { 22 start = index + 1; 23 index = Partition(input, start, end); 24 } else { 25 end = index - 1; 26 index = Partition(input, start, end); 27 } 28 } 29 30 for (int i = 0; i <= k - 1; ++i) { 31 cout << input[i]; 32 } 33 cout << endl; 34 }
上述方法的時間復雜度是O(n)。