做題做到 QuickSelect,結果感覺已經有點記不清 QS 了……在此用力復習一下 QS。
本文解答所有關于 QS 的疑難雜癥。
首先上代碼,來自 Sedgewick 的 Algorithm:
public class Quick
{
public static void sort(Comparable[] a)
{
StdRandom.shuffle(a); // Eliminate dependence on input.
sort(a, 0, a.length - 1);
}
private static void sort(Comparable[] a, int lo, int hi)
{
if (hi <= lo) return;
int j = partition(a, lo, hi); // Partition (see page 291).
sort(a, lo, j - 1); // Sort left part a[lo .. j-1].
sort(a, j + 1, hi); // Sort right part a[j+1 .. hi].
}
private static int partition(Comparable[] a, int lo, int hi)
{ // Partition into a[lo..i-1], a[i], a[i+1..hi].
int i = lo, j = hi + 1; // left and right scan indices
Comparable v = a[lo]; // partitioning item
while (true)
{ // Scan right, scan left, check for scan complete, and exchange.
while (less(a[++i], v)) if (i == hi) break;
while (less(v, a[--j])) if (j == lo) break;
if (i >= j) break;
exch(a, i, j);
}
exch(a, lo, j); // Put v = a[j] into position
return j; // with a[lo..j-1] <= a[j] <= a[j+1..hi].
}
}
1. QuickSort 的總體思路:
在要排序的序列中選定一個 pivot(這里選最左邊的元素),將序列進行 partition,使得所有位于pivot 左邊的元素都小于 pivot,位于右邊的元素都大于 pivot,但此時左右兩部分被視為無序狀態:
[……(無序的左邊部分)……],[(pivot)],[……(無序的右邊部分)……]
這樣還不足以排序, 但我們發現,只要分別 sort 一下左邊和右邊部分,整個序列就有序了:
function sort(a[])
{
partition(a[]);
sort(a[]左邊部分);
sort(a[]右邊部分);
}
到此QuickSort 已經結束了,EOF
——“然鵝,sort 左邊部分和右邊部分不還是要 sort 嗎?我們還是沒有實現 sort 啊!”
不過可能你已經發現了,上面那個 function sort(a[]) 是一個遞歸函數!也就是說,每次我們分成左右兩個子序列,都要進行 parttition,直到這個子序列只有一個元素!這樣僅靠 partition,我們就完成了排序,sort()函數作為遞歸體,不斷調用 partition()來處理子序列。
2. partition()
到此,我們已經知道 partition 要達到什么目的,只需要再實現 partition 的功能:首先先要選取一個 pivot,關于 pivot 的選取至關重要,因為會極大地影響復雜度,稍后詳細分析時間復雜度。
public class QuickSort
{
public static int partition(int[] a, int low, int high)
{
int pivot = int[low];
int i = low, j = high + 1;
while(true)
{
while(a[++ i] < pivot) // pointer i keeps going if pointed element is less than pivot
{
if(i >= high) break;
}
while(a[-- j] > pivot) // pointer j keeps going if pointed element is larger than pivot
{
if(j <= low) break;
}
if(i >= j) // if two pointer cross
break;
swap(a, i , j);
}
swap(a, low, j); // put pivot between two partitions
return j; // return the index of pivot
}
public static void sort(int[] a, int low, int high)
{
int pivotIndex = partition(a, low, high);
sort(a, low, pivot - 1);
sort(a, pivot + 1, high);
}
}
*為什么與 pivot 比較的時候是“<”、">"?為什么還要交換兩個相同的元素?
理想狀態下我們希望每次切分都得到兩個規模相同的子序列,也就是說 i,j 兩個指針能停下來的時候就停下來,從而使最后 Pivot 的位置保持一個比較靠中間的位置。否則,pivot 最終的 index 過于偏向一邊,就會增大遞歸的深度(best case是logN,而 worst case 則是 N)。
3. 3-way-partition
如果元素大量重復,上述辦法則還有可以提高的空間,因為我們交換了大量重復的元素,還可以壓榨這部分的復雜度:
對于每次切分:從數組的左邊到右邊遍歷一次,維護三個指針,其中lt指針使得元素(arr[0]-arr[lt-1])的值均小于切分元素;gt指針使得元素(arr[gt+1]-arr[N-1])的值均大于切分元素;i指針使得元素(arr[lt]-arr[i-1])的值均等于切分元素,(arr[i]-arr[gt])的元素還沒被掃描,切分算法執行到i>gt為止。每次切分之后,位于gt指針和lt指針之間的元素的位置都已經被排定,不需要再去處理了。之后將(lo,lt-1),(gt+1,hi)分別作為處理左子數組和右子數組的遞歸函數的參數傳入,遞歸結束,整個算法也就結束。
public class Quick3way
{
private static void sort(Comparable[] a, int lo, int hi)
{
if (hi <= lo) return;
int lt = lo, i = lo+1, gt = hi;
Comparable v = a[lo];
while (i <= gt)
{
int cmp = a[i].compareTo(v);
if (cmp < 0) exch(a, lt++, i++);
else if (cmp > 0) exch(a, i, gt--);
else i++;
} // Now a[lo..lt-1] < v = a[lt..gt] < a[gt+1..hi].
sort(a, lo, lt - 1);
sort(a, gt + 1, hi);
}
}
這里就沒有一個單獨的 partition(),而是將其整合進了 sort() 里面。
4. pivot 的選取
pivot 的選取至關重要,理想狀態是每次都取到位于中間的 pivot,這樣就能保證遞歸深度為 LogN。如果對一個一定程度上有序的序列使用這種快排,復雜度則是 O(n)。
改進:雖然我們每次都取最左邊的當 pivot,但只要在取之前對 array 進行 shuffle,將有序性去除,就能很好的避免掉進 O(N)遞歸深度的坑里。
至于 shuffle 的方式有好幾種,比如 kunth-shuffle 等等,another story。我們也可以直接用 API。
5. 復雜度分析
(鴿)