1. 簡介
快速排序是由C.A.R.Hoare在1960年發(fā)明的。快速排序可能是應用最廣泛的排序算法了,快速排序的實現(xiàn)簡單,平均時間復雜度是O(NlgN)
,而且它是原地排序。其實在快排的實現(xiàn)有一些坑,如果不仔細一點,快排也許就變成慢排了。
接下來所講的排序都是從小到大排序的,代碼也是java描述的:
與歸并排序一樣,快速排序也采用了分而治之的思想。
- 在數(shù)組中選取一個元素作為主元
- 將數(shù)組切分成左右兩半,左邊一半的元素小于等于主元,右邊一半的元素大于等于主元
- 將左邊排序
- 將右邊排序
- 因為左邊已經(jīng)小于等于右邊了,所以當左右兩邊都排完序,整體也就有序了
2. 代碼實現(xiàn)
public class QuickSort {
//交換數(shù)組中兩個元素的位置
private static void swap(Comparable[] a, int i, int j){
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
//切分數(shù)組的函數(shù)
private static int partition(Comparable[] a, int left, int right){
swap(a, (left + right) / 2, left);
Comparable v = a[left]; //v是主元
int i = left, j = right + 1;
while (true) {
while (a[++i].compareTo(v) < 0)
if (i == right)
break;
while (a[--j].compareTo(v) > 0)
if (j == left)
break;
if (i < j)
swap(a, i, j);
else
break;
}
swap(a, j, left);
return j;
}
private static void sort(Comparable[] a, int left, int right){
if (left >= right)
return;
int i = partition(a, left, right); //切分數(shù)組,返回切分的位置,也就是主元的位置
sort(a, left, i - 1); //對數(shù)組的左半邊排序
sort(a, i + 1, right); //對數(shù)組的右半邊排序
}
public static void sort(Comparable[] a){
sort(a, 0, a.length - 1);
}
//測試
public static void main(String[] args) {
Integer[] a = new Integer[]{1, 3, 4, 7, 9, 2};
sort(a);
}
}
輔助函數(shù):
這一段是快速排序的簡單實現(xiàn),還有一些可以優(yōu)化的地方。先來介紹一下實現(xiàn)過程需要用的輔助函數(shù):
- 因為排序過程中需要與主元進行比較且參與排序的元素是類變量,所以要求排序的元素需要實現(xiàn)
Comparable
接口重寫compareTo()
函數(shù)。 - 在與主元比較后可能需要交換位置所以用一個
swap()
函數(shù)交換兩個元素的位置。
3. 快速排序性能與復雜度分析
快速排序的運行時間取決于切分是否平衡,而是否平衡又依賴于切分的元素,也就是主元的選擇。
-
最壞情況
假設我們每次選擇的主元恰好是待排數(shù)組中的極值且元素都不重復時,例如最小值:根據(jù)切分函數(shù),指針i在遇到第一個元素就停下來,而j
卻一直向左遍歷直到遇到主元才停下來。最終切分的位置變成了left
,切分出一個大小為0的數(shù)組和一個大小為n - 1
的數(shù)組,不煩假設每次都出現(xiàn)這種不平等的切分,切分的操作時間復雜度為O(n)
,對一個大小為0的數(shù)組遞歸調(diào)用排序會直接返回,因此T(0) = O(1)。于是算法的運行時間的遞歸式可表達為:T(n) = T(0) + T(n - 1) + O(n) = T(n - 1) + O(n),T(n)的解是O(n^2)
。 -
最好情況
最好的情況是每次切分后的兩個數(shù)組大小都不大于n / 2
時,這時一個的數(shù)組的大小為[n / 2 - 1]
,另一個為[n / 2]
,此時算法運行時間的遞歸式為:T(n) = 2T(n / 2) + O(n),T(n)的解是O(nlgn)
。 -
平均情況
快速排序的平均運行時間其實更接近與最好情況,而非最壞情況。
4. 算法優(yōu)化
1. 切換到插入排序
- 對于小數(shù)組,快速排序比插入排序慢
- 因為遞歸,快速排序的
sort()
方法在小數(shù)組中也會調(diào)用自己
所以可以當數(shù)組在大小在M以內(nèi)時調(diào)用插入排序,M的取值可以是5 ~ 15。
2. 選擇合適的主元
如我上面所說,假設我們每次選擇的主元恰好是待排數(shù)組中的極值時,那就是最壞的情況,如果要避免這種情況的發(fā)生,那就是要選擇合適的主元。我們可以在待排數(shù)組取左,中,右3個數(shù),取其中位數(shù)作為主元。這樣就可以在一定程度上避免最壞情況。
3. 重復的元素不必排序
當數(shù)組中存在大量的重復元素時,如果我們用上面所實現(xiàn)的快排,時間復雜度還是要O(nlgn)
,這開銷是在太大相對于插入排序來說。這時我們可以采用三向切分來實現(xiàn)快排。如下所示:
left part center part right part * +--------------------------------------------------------------+ * | < pivot | ==pivot | ? | > pivot | * +--------------------------------------------------------------+ * ^ ^ ^ * | | | * lt i gt
通過維持三個指針來控制[left, lt )
小于主元(pivot),[lt, i)
等于主元,[i, gt]
未知,(gt, right]
大于主元。
一開始,lt
指向主元的位置left
,gt
指向right
,而i
從left
右邊接下來的第一個索引開始遍歷,每當遇到一個數(shù),就判斷它與主元之間的大小關系,有三種情況:
- 小于主元就把這個數(shù)與
lt
指向的數(shù)交換,然后lt
,i
都自增1,然后繼續(xù)遍歷 - 大于主元就把這個數(shù)與
gt
指向的數(shù)交換,gt
自減1,此時i還得不能自增,因為它不知道gt
用一個什么樣的元素跟它交換,所以留到下一次循環(huán)判斷交換過來的這個元素的去留 - 等于主元就不用跟誰進行交換,直接自增1就可以
三向切分快速排序如下:
public class Quick {
//獲取中位數(shù)
private static int getMedian(Comparable[] a, int i, int j, int k){
return a[i].compareTo(a[j]) > 0
? (a[i].compareTo(a[k]) < 0 ? i : a[j].compareTo(a[k]) > 0 ? j : k)
: (a[i].compareTo(a[k]) > 0 ? i : a[j].compareTo(a[k]) < 0 ? j : k);
}
private static void swap(Comparable[] a, int i, int j){
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
//插入排序
private static void insertSort(Comparable[] a, int left, int right) {
for (int i = left; i <= right; ++i) {
int j;
Comparable value = a[i];
for (j = i - 1; j >= left && value.compareTo(a[j]) < 0; --j)
a[j + 1] = a[j];
a[j + 1] = value;
}
}
private static void sort(Comparable[] a, int left, int right){
if (right - left < 15) {
insertSort(a, left, right);
return;
}
swap(a, getMedian(a, left, (left + right) / 2, right), left);
Comparable v = a[left];
int lt = left, i = left + 1, gt = right;
while (i <= gt){
int cmp = a[i].compareTo(v);
if (cmp < 0)
swap(a, lt++, i++);
else if (cmp > 0)
swap(a, i, gt--);
else
i++;
}
sort(a, left, lt - 1);
sort(a, gt + 1, right);
}
public static void sort(Comparable[] a){
sort(a, 0, a.length - 1);
}
//測試
public static void main(String[] args) {
int size = 10000000;
Integer[] a = new Integer[size];
for (int i = 0; i < 10000000; ++i)
a[i] = 88;
sort(a);
}
}
5. 注意:
目前所實現(xiàn)的三向切分并不完美,雖然它解決了大量重復元素的不必要排序,將排序時間從線性對數(shù)級別降到線性級別,但它在數(shù)組元素重復不多的情況下,它的交換次數(shù)比標準的二分法多很多。不過在90年代J.Bently和D.Mcllroy找到一個聰明的辦法解決了這個問題。接下來的快速三向切分就是解決辦法。
快速的三向切分
* left part center part right part * +----------------------------------------------------------+ * | == pivot | < pivot | ? | > pivot | == pivot | * +----------------------------------------------------------+ * ^ ^ ^ ^ * | | | | * p i j q
在這個算法中,[p, i)
里面的元素小于主元,(j, q]
里面的元素大于主元,而左右兩端[left, p)
和(q, right]
等于主元。在算法一開始,p
和 i
都指向left
后面的第一個元素, j
和q
都指向right
,先把i從左到右遍歷時每遇到一個元素都會有三種情況:
- 等于主元,這時只要與
p
指向的元素交換然后各自自增1即可 - 小于主元,這就是指針
p
和i
所要維護的元素,直接把i
自增1跳過就可以 - 大于主元,這時就是
j
和q
所要維護的元素,先退出循環(huán)等待與他們交換
同理,對于j
從right
向左遍歷也是一樣。當 i > j
時,切分也就結束,最后還要把數(shù)組調(diào)整為左邊小右邊大,中間等于主元的形式,再依次排序左邊和右邊。在這個算法中,既解決了重復元素排序的問題,又解決了少量元素重復時,交換次數(shù)過多的問題。接下來是我的實現(xiàn),不過我覺得我有些地方實現(xiàn)的不太好,湊合著用吧。
快速的三向切分的實現(xiàn)
public class Quick3WayPartitionSort {
//獲取中位數(shù)
private static int getMedian(Comparable[] a, int i, int j, int k){
return a[i].compareTo(a[j]) > 0
? (a[i].compareTo(a[k]) < 0 ? i : a[j].compareTo(a[k]) > 0 ? j : k)
: (a[i].compareTo(a[k]) > 0 ? i : a[j].compareTo(a[k]) < 0 ? j : k);
}
private static void swap(Comparable[] a, int i, int j){
Comparable t = a[i];
a[i] = a[j];
a[j] = t;
}
public static void insertSort(Comparable[] a, int left, int right) {
for (int i = left; i <= right; ++i) {
int j;
Comparable value = a[i];
for (j = i - 1; j >= left && value.compareTo(a[j]) < 0; --j)
a[j + 1] = a[j];
a[j + 1] = value;
}
}
//調(diào)整數(shù)組
private static void adjust(Comparable[] a, int start, int end, int toStart){
for (int i = start; i <= end; ++i)
swap(a, i, toStart++);
}
public static void sort(Comparable[] a){
temps = new Comparable[a.length];
sort(a, 0, a.length - 1);
}
private static void sort(Comparable[] a, int left, int right){
if (right - left < 10) {
insertSort(a, left, right);
return;
}
swap(a, getMedian(a, left, (left + right) / 2, right), left);
Comparable v = a[left];
int p = left + 1, i = p, j = right, q = j;
while (true){
while (i <= j){
int cmp = a[i].compareTo(v);
if (cmp == 0)
swap(a, i++, p++);
else if (cmp < 0)
i++;
else
break;
}
while (i <= j){
int cmp = a[j].compareTo(v);
if (cmp == 0)
swap(a, j--, q--);
else if (cmp > 0)
j--;
else
break;
}
if (i < j)
swap(a, i++, j--);
else
break;
}
if (p - left > i - p)
adjust(a, p, i - 1, left);
else
adjust(a, left, p - 1, left + i - p);
if (right - q > q - j)
adjust(a, j + 1, q, right - q + j);
else
adjust(a, q + 1, right, j + 1);
sort(a, left, left + i - p - 1);
sort(a, right + j - q - 1, right);
}
public static void main(String[] args) {
}
}
6. 最后
快速排序不是穩(wěn)定的排序算法,所謂穩(wěn)定就是當待排數(shù)組中存在重復元素的時候,排序后重復元素的相對順序不會改變。在多關鍵字排序時,穩(wěn)定的排序算法就很有用處。比如當一個學生按照學號先排序,然后再根據(jù)成績進行排序,因為成績存在重復的值,此時穩(wěn)定的排序算法就會導致排序后具有相同成績的學生按照學號排序,不會混亂。