快速排序——算法導論(8)


1. 算法描述

    快速排序(quick-sort)與前面介紹的歸並排序(merge-sort)(見算法基礎——算法導論(1))一樣,使用了分治思想。下面是對一個一般的子數組A[p~r]進行快速排序的分治步驟:

分解:數組A[p~r]被划分為兩個子數組A[p~q]和A[q+1~r],使得A[q]大於等於A[p~q]中的每個元素,且小於等於A[q+1~r]中的每個元素。(需要說明的是,我們允許A[p~q]和A[q+1~r]為空)

解決:對子數組A[p~q]和A[q+1~r]遞歸的調用快速排序。

合並:因為子數組都是原址排序的,所以不需要合並操作,此時的A數組已經是排好序的。

ps:所謂原址排序是指:我們在對組進行排序的過程中 只有常數個元素被存儲到數組外面。

下面給出偽代碼:

image

可以看出,算法的關鍵是partiton方法的實現。下面給出它的算法實現:

image

直接看可能覺得很暈,我們結合實例看看它是如何工作的:

image

    上圖(a~i)表示的是對子數組A[p~r] =[2,8,7,1,3,5,6,4]進行排序時,每次迭代之前數組元素和一些變量的值。

    我們可以初步看出,在i和j移動的過程中,數組被分成了三個部分(分別用灰色,黑色,白色表示),其中i和j就是分割線,並且淺灰部分的元素均比A[r]小,黑色部分的元素均比A[r]大((i)圖除外,因為循環完畢之后執行了exchange A[i+1] with A[j])。

    我們再仔細分析一下具體細節:

    ① 首先看迭代之前的部分。它執行了x = A[r],目的是把子數組A的最后一位作為一個“基准”,其他的所有元素都是和它進行比較。它在迭代過程中值一直都沒改變。然后執行i = p –基准 1,此時i在子數組A的左端。

    ② 再看迭代部分。迭代時j從子數組A的開頭逐步移至A的倒數第二位。每次迭代中,會比較當前j位置的值和“基准”的大小,如果小於或相等“基准”,就將灰色部分的長度增加1(i=i+1),然后把j位置的值置換到灰色部分的末尾(exchange A[i] with A[j])。這樣迭代下來,就能保證灰色部分的值都比“基准”小或相等,而黑色部分的值都比“基准”大。

    ③ 最后看迭代完成后的部分。就進行了一步 exchange A[i+1] with A[j]操作,就是把“基准”置換到灰色部分與黑色部分之間的位置。

    這樣所有的操作下來,就產生了一個“臨界”位置q,使得A[q]大於等於A[p~q]中的每個元素,而小於等於A[q+1~r]中的每個元素。

    更嚴格的,我們可以用以前介紹的循環不變式(見算法基礎——算法導論(1))來證明其正確性。但由於敘述起來比較麻煩,這里就不給出了。

    下面我們給出快速排序(quick-sort)算法的Java實現代碼:

public static void main(String[] args) {
	int[] array = { 9, 2, 4, 0, 4, 1, 3, 5 };
	quickSort(array, 0, array.length - 1);
	printArray(array);
}

/**
 * 快速排序
 * 
 * @param array
 *            待排序數組
 * @param start
 *            待排序子數組的起始索引
 * @param end
 *            待排序子數組的結束索引
 */
public static void quickSort(int[] array, int start, int end) {
	if (start < end) {
		int position = partition(array, start, end);
		quickSort(array, start, position - 1);
		quickSort(array, position + 1, end);
	}
}

/**
 * 重排array,並找出“臨界”位置的索引
 * 
 * @param array
 *            待重排數組
 * @param start
 *            待重排子數組的起始索引
 * @param end
 *            待重排子數組的結束索引
 * @return
 */
public static int partition(int[] array, int start, int end) {
	int position = start - 1;
	int base = array[end];
	for (int i = start; i < end; i++) {
		if (array[i] <= base) {
			position++;
			int temp = array[position];
			array[position] = array[i];
			array[i] = temp;
		}
	}
	int temp = array[position + 1];
	array[position + 1] = array[end];
	array[end] = temp;
	return position + 1;
}

/**
 * 打印數組
 * 
 * @param array
 */
public static void printArray(int[] array) {
	for (int i : array) {
		System.out.print(i + "");
	}
	System.out.println();
}

結果:image

2. 快速排序的性能

    快速排序的運行時間是跟划分密切相關的,因為划分影響着子問題的規模。

(1) 最壞情況划分

    當每次划分把問題分解為一個規模為n-1的問題和一個規模為0的問題時,快速排序將產生最壞的情況(以后給出這個結論的證明,目前可以想象的出)。由於划分操作的時間復雜度為θ(n);當對一個長度為0的數組進行遞歸操作時,會直接返回,時間為T(0) = θ(1)。於是算法總運行時間的遞歸式為:

T(n) = T(n-1) + T(0) + θ(n) = T(n-1) + θ(n) 。

可以解得,T(n) = θ(n²)。

    由此可見,在划分都是最大程度不平均的情況下,快速排序算法的運行時間並不比插入排序好,甚至在某些情況下(比如數組本身已按大小排好序),不如插入排序。

 

 

(2) 最好情況划分

 

    當每次划分都是最平均的時候(即問題規模被划分為[n/2]和【n/2】-1時),快速排序性能很好,總運行時間的遞歸式為:

T(n) = 2T(n/2) + θ(n)

可以解得,T(n) = θ(nlg n)。

 

(3) 平均划分

    快速排序算法的平均運行時間,更接近於最好情況划分時間而非最壞情況划分時間。理解這一點的關鍵就是理解划分的平均性是如何反映到描述運行時間的遞歸式上的。

    我們舉個例子,對於一個9:1的划分,乍一看,這種划分是很不平均的。此時的運行時間遞歸式為:

T(n)  = T(9n/10) + T(n/10) + cn,

我們可以用如下遞歸樹來更加形象地描述運行時間:

image

遞歸會在深度為log10/9n = θ(lg n )處終止,因此,快速排序的總代價為O(nlgn)。可見,在直觀上看起來非常不平均的划分,其運行時間是接近最好情況划分的時間的。事實上,對於任何一種常數比例的划分,其運行時間總是O(nlgn)。

 

3. 快速排序的隨機化版本

    以上的討論其實都做了一個前提的聲明,輸入數據的所有排列都是等概率的。但是事實上這個條件並不一定總是成立。正如以前介紹的,有時候我們再在算法中引入隨機性,可以使得算法對所以的輸入都有較好的期望性能。很多人都選擇隨機化版本的快速排序作為大數據輸入情況下的排序算法。

    我們可以使用對數組的所有元素進行隨機化排序的方式引入隨機性。但為了簡便,我們這里采用一種叫做隨機抽樣(random sampling)的隨機化技術。

    與以上始終采用A[r]作為“基准”的方法不同的是,隨機抽樣是從子數組A[p~r]中隨機的抽取一個元素,把它作為“基准”,並與A[r]交換。其他的過程與上面介紹的一致。

下面是隨機化版本的算法描述:

image

image 

下面給出隨機化版本的Java實現代碼:

public static void main(String[] args) {
	int[] array = { 9, 2, 4, 0, 4, 1, 3, 5 };
	randomizedQuickSort(array, 0, array.length - 1);
	printArray(array);
}

public static int randomPartition(int[] array, int start, int end) {
	int random = (int) (Math.random() * ((end - start) + 1)) + start;
	int temp = array[random];
	array[random] = array[end];
	array[end] = temp;
	return partition(array, start, end);
}

/**
 * 快速排序
 * 
 * @param array
 *            待排序數組
 * @param start
 *            待排序子數組的起始索引
 * @param end
 *            待排序子數組的結束索引
 */
public static void randomizedQuickSort(int[] array, int start, int end) {
	if (start < end) {
		int position = randomPartition(array, start, end);
		randomizedQuickSort(array, start, position - 1);
		randomizedQuickSort(array, position + 1, end);
	}
}

運行結果:image

4. 快速排序分析

    在第2小節中我們給出了快速排序性能的直觀分析,以及它速度比較快的原因。這一節我們要給出一個更加嚴謹的分析。

(1) 最壞情況分析

   我們用T(n)來表示規模為n的數組采用快速排序法排序所需的時間。PARTION函數生成的兩個子數組的總長度是n-1,我們設其中一個的長度為q(0 ≤ q ≤ n-1),那么另一個的長度為n-q-1,因此有遞歸式:

image

我們容易知道,上式中q在端點上取得最大值。由此我們得到:

image

因此,T(n) = θ(n²) + θ(n) = θ(n²);

這就是說,快速排序算法的最壞情況的運行時間是θ(n²);

(2) 期望運行時間

    現在我們要求,在平均情況下,快速排序的運行時間。因此我們先對問題進行分析。

    從算法的描述中我們可以看出,快速排序的運行時間是由在PARTITION操作上花費的時間決定的。每一次PARTITION操作都會從數組中挑選出一個數作為“基准”,因此PARTITION操作的總次數不會超過數組元素的總個數n。而每一次PARTITION操作的時間包括O(1)加上一段循環的時間。而在該循環的每次迭代中,都會比較“基准”元素與其他元素。因此,如果我們可以統計出總的比較次數(注意這里所說的比較次數是整個快速排序過程中比較的次數),就能夠知道該循環的運行時間,從而就能給出快速排序的運行時間。

    為了便於分析,不失一般性,我們將數組A的各個元素重命名為Z1,Z2,…Zn,其中Zi表示數組A中第i小的元素;此外我們還定義Zij表示一個包含元素Zi到Zj(包括Zi和Zj)的集合,即Zij={Zi,Zi+1,…Zj}。

    和概率分析和隨機算法(2)——算法導論(6)中方式①介紹的方法一樣,我們引入一個指示器隨機變量Xij來表示Zi與Zj是否進行了比較,即:

1 因為每一對元素至多被計較一次,因此我們可以很容易算出總比較次數為:

image

對上式兩邊取期望得:

image

下面我們來分析如何求P(zi與zj進行比較)。

    我們先從一個簡單的例子入手。考慮對一個包含數字1~10的數組A進行快速排序的情況。假設我們第一次進行PARTITION操作時,選定的“基准”元素是7,那么數組A將被划分為{1,2,3,4,5,6}和{8,9,10}兩個數組,我們可以發現,這兩個數組中彼此任何兩個元素是不會相互比較的。因此,我們有如下斷言:如果一個滿足zi < X < zj(假設各元素互異)的元素x被選為“基准”后,zi 和zj就不會被比較了;如果zi在Zij的其他元素之前被選為“基准”,那么zi會與Zij中的其他所有元素進行比較。於是,我們有:

image

進而得到:

image

由此我們可以得出結論:使用RANDOMIZED-PARTITION,在輸入元素互異的情況下,快速排序算法的期望運行時間是O(nlgn)。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM