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

1. 算法描述html

    快速排序(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]遞歸的調用快速排序。dom

合併:由於子數組都是原址排序的,因此不須要合併操做,此時的A數組已是排好序的。函數

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

下面給出僞代碼:大數據

image

能夠看出,算法的關鍵是partiton方法的實現。下面給出它的算法實現:ui

image

直接看可能以爲很暈,咱們結合實例看看它是如何工做的:spa

image

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

    咱們能夠初步看出,在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)。

相關文章
相關標籤/搜索