數(shù)據(jù)結(jié)構(gòu)與算法——快速排序
快速排序,顧名思義,它速度很快,針對一般應(yīng)用中各種不同的輸入都要比其他排序算法快很多,因此在各種排序算法中,應(yīng)用最廣泛。
快速排序?qū)?shù)組排序的方式是 :先取數(shù)組中第一個元素作為切分元素,同時正向、反向遍歷數(shù)組,通過若干次交換元素,將處于數(shù)組第一個位置的切分元素交換到合適的位置,使得切分元素左邊的元素全小于等于它,切分元素右邊的元素全部大于等于它。此時切分元素已排定了,如果再將切分元素左邊的子數(shù)組和右邊子數(shù)組都排序,那么由切分元素左數(shù)組、切分元素、切分元素的右子數(shù)組組成的數(shù)組就是有序的。
示意圖如下,K是切分元素,放到合適位置后,分別將切分元素左右子數(shù)組都進行排序,之后數(shù)組變得有序。
左右數(shù)組的排序是通過遞歸調(diào)用切分來排序的。由歸納法不難證明遞歸能夠正確地將數(shù)組排序:如果左子數(shù)組和右子數(shù)組都是有序的,那么左子數(shù)組、切分元素、右子數(shù)組三者組成的結(jié)果數(shù)組也一定有序。所以通過遞歸不斷將數(shù)組從切分元素處(得先求得切分元素)分解成左右兩半,每次遞歸調(diào)用都會排定一個元素——就是切分元素,且保持著切分元素左邊的元素都小于等于它,切分元素右邊的元素都大于等于它這個關(guān)系。隨著遞歸的深入,數(shù)組被分解得很小,它們依然滿足前述關(guān)系,最后當數(shù)組被分解到最小時(即只有一個元素),已經(jīng)不能再切分,依然很好地維持著這個關(guān)系。
可以看到,快速排序的關(guān)鍵在于切分,整個算法自始至終滿足下面三個條件:
- 對于某個切分元素
a[j]
,它已經(jīng)排定; -
a[low]
到a[j - 1]
中所有元素都小于等于a[j]
; -
a[j + 1]
到a[high]
中的所有元素都大于等于a[j]
.
切分的一般做法是:隨意取a[low]
作為切分元素,先從數(shù)組的左端開始向右掃描直到找到第一個大于等于切分元素的元素,然后從數(shù)組的右端開始向左掃描直到找到第一個小于等于切分元素的元素。這兩個元素對于切分元素,位置順序顯然是不對的,因此交換它們使得數(shù)組滿足上面的條件2、條件3;接著掃描、交換元素,直到從左到右的指針i大于等于從右到左的指針j(表示兩個掃描指針相遇),此時只需將a[low]
和a[j]
交換位置,切分元素就被放到了合適的位置。最后返回j表示切分元素的位置,給下次遞歸排序調(diào)用。
下圖說明了切分前后的示意圖。
由上面的描述已經(jīng)可以寫出切分的代碼了
public class QuickSort {
private static int partition(Comparable[] a, int low, int high) {
// 下面使用++i和--j的形式,因此i和j的定義如下
int i = low;
int j = high + 1;
// 切分元素保存下來
Comparable v = a[low];
while (true) {
// 從左到右掃描,直到遇到大于等于v的元素為止
while (less(a[++i], v)) {
if (i == high) {
break;
}
}
// 從右到左掃描,直到遇到小于等于v的元素為止
while (less(v, a[--j])) {
if (j == low) {
break;
}
}
// 由于指針是先自增,所以先判斷指針是否相遇,相遇就退出while
if (i >= j) {
break;
}
// 若沒有相遇就交換元素
swap(a, i, j);
}
// 切分元素交換到合適的位置
swap(a, low, j);
return j;
}
}
最后為什么是low和j交換(而不是和i),切分元素就換到了合適的位置?
看圖說明一切
最后一次交換i = 5, j = 6
,while循環(huán)繼續(xù),所以i變成6,j變成5,break跳出。將i處的L與切分元素K交換肯定是不對的(這樣比K大的L排在了切分元素的左邊),所以應(yīng)該用位置j處的E和切分元素交換,結(jié)果如上頭最后一行所示,是正確的。
接著寫快速排序的代碼就順理成章了。
public static void sort(Comparable[] a) {
// 隨機打亂數(shù)組,大大減小最壞情況的概率
shuffle(a);
sort(a, 0, a.length - 1);
}
private static void shuffle(Comparable[] a) {
// asList返回的是實際上是ArrayList,而ArrayList的底層是數(shù)組,所以打亂了b,a也被打亂了
List<Comparable> b = Arrays.asList(a);
Collections.shuffle(b);
}
private static void sort(Comparable[] a, int low, int high) {
// 當只有一個元素時,不能再切分,直接返回
if (high <= low) {
return;
}
// 切分元素已經(jīng)排定
int j = partition(a, low, high);
// 對切分元素左數(shù)組排序
sort(a, low, j - 1);
// 對切分元素右數(shù)組排序
sort(a, j + 1, high);
// 三者結(jié)合起來的數(shù)組有序!
}
注意在排序之前,對數(shù)組進行了隨機打亂。這個操作是有必要的!雖然看似多了一兩步操作,但試想一種極端的情況:如果切分元素本來就是數(shù)組中最小或者最大的,每次調(diào)用只會有一個元素被交換,剩下的數(shù)組還是一個大數(shù)組;如果第二次切分元素依然是最小或者最大的元素....這將導(dǎo)致一個大子數(shù)組需要切分很多次,我們事先打亂數(shù)組就是為了規(guī)避這種情況。它能使產(chǎn)生糟糕的切分情況的可能性降到極低。
對一個數(shù)組的快速排序軌跡,見下圖
紅色圓圈的元素就是被換到合適位置后的切分元素。
快速排序的效率依賴于切分數(shù)組的效果,而這依賴于切分元素的值,切分有可能發(fā)生在數(shù)組中的任何位置。如果每次切分都發(fā)生在數(shù)組的中間,即每次都能將數(shù)組對半分,這是最好情況。
快速排序的時間復(fù)雜度為O(Nlg N)
快速排序的改進
對于任何遞歸的排序算法,當數(shù)組規(guī)模較小時,切換到插入排序是個明智的選擇。因為
- 對于小數(shù)組,快速排序比插入排序慢;
- 因為遞歸,快速排序的sort方法在小數(shù)組中也會調(diào)用自己。
private static void sort(Comparable[] a, int low, int high) {
// high = low說明數(shù)組被劃分到只有一個元素,不能再切分,直接返回
// high <= low + 15 說明當數(shù)組長度不超過16時都換用插入排序
if (high <= low + 15) {
InsertSort.sort(a);
return;
}
// 切分元素已經(jīng)排定
int j = partition(a, low, high);
// 對切分元素左數(shù)組排序
sort(a, low, j - 1);
// 對切分元素右數(shù)組排序
sort(a, j + 1, high);
// 三者結(jié)合起來的數(shù)組有序!
}
三向切分的快速排序
實際應(yīng)用中可能出現(xiàn)大量重復(fù)元素,最特殊的情況:一個數(shù)組中所有元素都相同,此時無需繼續(xù)排序了,但是上述算法還是會對數(shù)組進行切分。基于此可以將數(shù)組切分成三部分,分別對應(yīng)小于、等于、大于切分元素的數(shù)組元素。
我們來看這種被稱為三向切分的快速排序。它從左到右遍歷數(shù)組一次,維護一個指針lt使得a[low...lt-1]
中的元素都小于v,一個指針gt使得a[gt + 1...high]
中的元素都大于v,一個指針i使得a[lt..i-1]
中的元素都等于v,a[i..gt]
中的元素暫定。一開始i和low相等。隨著循環(huán),a[i...gt]
越來越小,即gt-i
不斷減小,當i > gt
時循環(huán)結(jié)束。循環(huán)中進行下面的操作:
- 如果a[i]小于v,將a[i]和a[lt]交換,lt和i都加1;
- 如果a[i]大于v,將a[i]和a[gt]交換,gt減1;
- 如果a[i]等于v,將i加1
上面的這些操作保證了最后i > gt
可以推出循環(huán)。
三向切分的快速排序示意圖如下:
代碼如下
public class Quick3way {
public static void sort(Comparable[] a) {
shuffle(a);
sort(a, 0, a.length - 1);
}
private static void sort(Comparable[] a, int low, int high) {
if (high <= low) {
return;
}
int lt = low;
int gt = high;
int i = low + 1;
// 切分元素
Comparable v = a[low];
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++;
}
}
// 現(xiàn)在a[lo..lt-1] < v=a[lt..gt] < a[gt+1..high]成立
// 切分元素相同的數(shù)組不會被遞歸算法訪問到,對其左右的子數(shù)組遞歸排序
sort(a, low, lt - 1);
sort(a, gt + 1, high);
}
}
這段排序能夠?qū)⒑颓蟹衷叵嗟鹊脑鼐奂揭粔K兒,這樣它們就不會被包含在遞歸調(diào)用處理的子數(shù)組中了。對于存在大量重復(fù)元素的數(shù)組,這種方法比標準的快速排序要快。三向切分的最壞情況是所有元素各不相同,這時會比標準的快速排序要慢,因為比起標準的快速排序使用了更多的比較。
結(jié)合上圖來看,上面代碼做的事情是:
- 在指針i的移動過程中,如果a[i]比切分元素v小,就將a[i]交換到左邊(具體做法是將a[i]于a[lt]交換,同時lt和i都要向右移動一格,相當于新元素插入進來了嘛,要騰出空間的)從而保證了
a[low..lt-1]
中的元素都比v小; - 如果a[i]比v大,將a[i]交換到右邊,具體做法是將a[i]和a[gt]交換,此時只需將gt向左移動一格,lt和i都無需移動(看圖可以很好理解),從而保證了
a[gt+1..high]
中的元素都比v要大; - 如果a[i]和v相等,只需將i向右移動一格,相當于將a[i]添加到相等切分元素集合的末尾,從而保證了
a[lt...i-1]
中的元素都等于v。
由于i和gt不能同時改變,最后退出循環(huán)時,必然有關(guān)系i = gt +1
,所以最后a[lt...i-1]
= a[lt...gt]
中的元素都和v相等。這串元素都不會被包含在遞歸調(diào)用的排序中,排除掉它們后,在遞歸調(diào)用中自然是sort(a, low, lt - 1); sort(a, gt + 1, high);
了。
三向切分的快速排序軌跡如下圖所示。
對于包含大量重復(fù)元素的數(shù)組,三向切分的快速排序算法將排序時間從線性對數(shù)級降低到線性級別,因此時間復(fù)雜度介于O(N)和O(Nlg N)之間,這依賴于輸入數(shù)組中重復(fù)元素的數(shù)量。
快速排序交換兩個元素跨度很大,是跳躍性的,可以想象這很容易造成等值元素相對位置改變。而且從代碼中可以更直觀的看出,左往右掃描時,是遇到大于等于切分元素停止,右往左掃描時是遇到小于等于切分元素停止,如果都是遇到等于切分元素時停止,切分中將會交換這兩個相等的元素,因此等值元素的相對位置改變,即快速排序不是穩(wěn)定的排序算法。
by @sunhaiyu
2017.10.30