排序算法總結

排序算法總結

這是《大話數據結構》第九章排序算法的知識點總結。

排序的基本概念與分類

假設含有n個記錄的序列爲r1,r2,,rn

,其相應的關鍵字分別爲k1,k2,,kn,需要確定1,2,,n的一種排列$p_1,p_2,\cdots,pn使滿k{p1}\le k{p2}\le \cdots \le k{pn}使

{r{p1}, r{p2}, \cdots, r_{pn}}$,這樣的操作就稱爲排序。

在排序問題中,通常將數據元素稱爲記錄。

排序的依據是關鍵字之間的大小關係,那麼,對同一個記錄集合,針對不同的關鍵字進行排序,可以得到不同序列。

這裏關鍵字ki

可以是記錄r

的主關鍵字,也可以是次關鍵字,甚至是若干數據項的組合。

排序的穩定性

由於排序不僅是針對主關鍵字,還有針對次關鍵字,因爲待排序的記錄序列中可能存在兩個或兩個以上的關鍵字相等的記錄,排序結果可能會存在不唯一的情況,下面給出穩定與不穩定排序的定義。

假設ki=kj (1in,1jn,ij)

,且在排序前的序列中ri領先於rj(即i<j)。如果排序後ri仍領先於rj,則稱所用的排序方法是穩定的;反之,若可能使得排序後的序列中rj領先於ri

,則稱所用的排序方法是不穩定的。

不穩定的排序算法有:希爾、快速、堆排和選擇排序

內排序和外排序

根據在排序過程中待排序的記錄是否全部被放置在內存中,排序可以分爲:內排序和外排序。

內排序是在排序整個過程中,待排序的所有記錄全部被放置在內存中。外排序是由於排序的記錄個數太多,不能同時放置在內存,整個排序過程需要在內外存之間多次交換數據才能進行

對於內排序來說,排序算法的性能主要是受到3個方面的影響:

時間性能

在內排序中,主要進行兩種操作:比較和移動。高效率的內排序算法應該是具有儘可能少的關鍵字比較次數和儘可能少的記錄移動次數。

輔助空間

輔助存儲空間是除了存放待排序所佔用的存儲空間之外,執行算法所需要的其他存儲空間。

算法的複雜性

這裏指的是算法本身的複雜度,而不是算法的時間複雜度。

根據排序過程中藉助的主要操作,我們把內排序分爲:插入排序、交換排序、選擇排序和歸併排序。

排序用到的結構與函數

這裏先提供一個用於排序用的順序表結構,這個結構將用於接下來介紹的所有排序算法。

1
2
3
4
5
6
7
8
#define MAXSIZE 10
typedef struct
{
  // 用於存儲待排序數組
  int r[MAXSIZE]; 
  // 用於記錄順序表的長度
  int length;
}SqList;

此外,由於排序最常用到的操作是數組兩元素的交換,這裏寫成一個函數,如下所示:

1
2
3
4
5
6
// 交換L中數組r的下標爲i和j的值
void swap(SqList *L, int i, int j){
  int temp = L->r[i];
  L->r[i] = L->r[j];
  L->r[j] = temp;
}

冒泡排序

冒泡排序(Bubble sort)是一種交換排序。它的基本思想是:兩兩比較相鄰記錄的關鍵字,如果反序則交換,知道沒有反序的記錄爲止。

首先介紹一個簡單版本的冒泡排序算法的實現代碼。

1
2
3
4
5
6
7
8
9
10
11
12
// 冒泡排序初級版
void BubbleSort0(SqList *L){
	int i, j;
	for (i = 0; i < L->length - 1; i++) {
		for (j = i + 1; j <= L->length - 1; j++){
			if (L->r[i] > L->r[j]){
				// 實現遞增排序
				swap(L, i, j);
			}
		}
	}
}

這段代碼不算是標準的冒泡排序算法,因爲不滿足“兩兩比較相鄰記錄”的冒泡排序思想,它更應該是最簡單的交換排序。它的思路是讓每一個關鍵字都和後面的每一個關鍵字比較,如果大或小則進行交換,這樣關鍵字在一次循環後,第一個位置的關鍵字會變成最大值或者最小值。

這個最簡單的實現算法效率是非常低的。

下面介紹正宗的冒泡排序算法實現。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 正宗的冒泡排序算法實現代碼
void BubbleSort(SqList *L){
	int i, j;
	for (i = 0; i < L->length; i++) {
		for (j = L->length - 2; j >= i; j--){
			// j是從後往前循環
			if (L->r[j] > L->r[j + 1]){
				// 實現遞增排序
				swap(L, j, j + 1);
			}
		}
	}
}

這裏改變的地方是在內循環中,j是從數組最後往前進行比較,並且是逐個往前進行相鄰記錄的比較,這樣最大值或者最小值會在第一次循環過後,從後面浮現到第一個位置,如同氣泡一樣浮到上面。

這段實現代碼其實還是可以進行優化的,例如待排序數組是{2,1,3,4,5,6,7,8,9},需要進行遞增排序,可以發現其實只需要交換前兩個元素的位置即可完成,但是上述算法還是會在交換完這兩者位置後繼續進行循環,這樣效率就不高了,所以可以在算法中增加一個標誌,當有一次循環中沒有進行數據交換,就證明數組已經是完成排序的,此時就可以退出算法,實現代碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 改進版冒泡算法
void BubbleSortOptimz(SqList *L){
	int i, j;
	bool flag = true;
	for (int i = 0; i < L->length && flag; i++){
		// 若 flag爲false則退出循環
		flag = false;
		for (j = L->length - 2; j >= i; j--){
			// j是從後往前循環
			if (L->r[j] > L->r[j + 1]){
				// 實現遞增排序
				swap(L, j, j + 1);
				// 如果有數據交換,則flag是true
				flag = true;
			}
		}
	}
}

冒泡排序算法的時間複雜度是O(n2)

完整的冒泡排序算法代碼可以查看BubbleSort

簡單選擇排序

簡單選擇排序算法(Simple Selection Sort)就是通過ni

次關鍵字間的比較,從ni+1個記錄中選出關鍵字中最小的記錄,並和第i(1in)

個記錄進行交換。

下面是實現的代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 簡單選擇排序算法
void SelectSort(SqList *L){
	int i, j, min;
	for (i = 0; i < L->length - 1; i++){
		// 將當前下標定義爲最小值下標
		min = i;
		for (j = i + 1; j <= L->length - 1; j++){
			if (L->r[j] < L->r[min])
				min = j;
		}
		// 若min不等於i,說明找到最小值,進行交換
		if (min != i)
			swap(L, i, min);
	}
}

簡單選擇排序的最大特點就是交換移動數據次數相當少。分析其時間複雜度發現,無論最好最差的情況,比較次數都是一樣的,都需要比較n1i=1(ni)=(n1)+(n2)++2+1=n(n1)2

次。對於交換次數,最好的時候是交換0次,而最差的情況是n1次。因此,總的時間複雜度是O(n2)

,雖然與冒泡排序一樣的時間複雜度,但是其性能上還是略好於冒泡排序。

直接插入排序

直接插入排序(Straight Insertion Sort)的基本操作是將一個記錄插入到已經排好序的有序表中,從而得到一個新的、記錄數增加1的有序表。

實現代碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 直接插入排序
void InsertSort(SqList *L){
	int i, j,val;
	for (i = 1; i <= L->length - 1; i++){
		if (L->r[i] < L->r[i - 1]){
			// 將L->r[i]插入有序表中,使用val保存待插入的數組元素L->r[i]
			val = L->r[i];
			for (j = i - 1; L->r[j]>val; j--)
				// 記錄後移
				L->r[j + 1] = L->r[j];	
			// 插入到正確位置
			L->r[j + 1] =val;
		}
	}
}

直接插入排序算法是需要有一個保存待插入數值的輔助空間。

在時間複雜度方面,最好的情況是待排序的表本身就是有序的,如{2,3,4,5,6},比較次數則是n1

次,然後不需要進行移動,時間複雜度是O(n)

最差的情況就是待排序表是逆序的情況,如{6,5,4,3,2},此時需要比較$\sum{i=2}^{n} i = \frac{(n+2)(n-1)}{2}

\sum{i=2}^{n} (i+1) = \frac{(n+4)(n-1)}{2}$次。

如果排序記錄是隨機的,那麼根據概率相同的原則,平均比較和移動次數約爲n24

。因此,可以得出直接插入排序算法的時間複雜度是O(n2)

。同時也可以看出,直接插入排序算法會比冒泡排序和簡單選擇排序算法的性能要更好一些。

希爾排序

上述三種排序算法的時間複雜度都是O(n2)

,而希爾排序是突破這個時間複雜度的第一批算法之一。

其實直接插入排序的效率在某些情況下是非常高效的,這些情況是指記錄本來就很少或者待排序的表基本有序的情況,但是這兩種情況都是特殊情況,在現實中比較少見。而希爾排序就是通過創造條件來改進直接插入排序的算法。

希爾排序的做法是將原本有大量記錄數的記錄進行分組,分割成若干個序列,這樣每個子序列待排序的記錄就比較少了,然後就可以對子序列分別進行直接插入排序,當整個序列基本有序時,再對全體記錄進行一次直接插入排序。

這裏的基本有序是指小的關鍵字基本在前面,大的基本在後面,不大不小的在中間。像{2,1,3,6,4,7,5,8,9}可以稱爲基本有序。

這裏的關鍵就是如何進行分割,希爾排序採取的是跳躍分割的策略:將相距某個“增量”的記錄組成一個子序列,這樣才能保證在子序列內分別進行直接插入排序後得到的結果是基本有序而不是局部有序。

實現的代碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 希爾排序
void ShellSort(SqList *L){
	int i, j,val;
	int increment = L->length;
	do{
		// 增量序列
		increment = increment / 3 + 1;
		for (i = increment; i <= L->length - 1; i++){
			if (L->r[i]<L->r[i - increment]){
				// 將L->r[i]插入有序表中,使用val保存待插入的數組元素L->r[i]
				val = L->r[i];
				for (j = i - increment; j >= 0 && L->r[j]>val; j -= increment)
					// 記錄後移,查找插入位置
					L->r[j + increment] = L->r[j];
				L->r[j + increment] = val;
			}
		}
	} while (increment > 1);
}

上述代碼中增量的選取是increment = increment / 3 + 1,實際上增量的選取是非常關鍵的,現在還沒有人找到一種最好的增量序列,但是大量研究表明,當增量序列是δ[k]=2tk+11(0ktlog2(n+1))

時,可以獲得不錯的效率,其時間複雜度是O(n32),要好於直接插入排序的O(n2)

。當然,這裏需要注意的是增量序列的最後一個增量值必須等於1才行。此外,由於記錄是跳躍式的移動,希爾排序是不穩定的排序算法

堆排序

簡單選擇排序在待排序的n

個記錄中選擇一個最小的記錄需要比較n1

次,這是查找第一個數據,所以需要比較這麼多次是比較正常的,但是可惜的是它沒有把每一趟的比較結果保存下來,這導致在後面的比較中,實際有許多比較在前一趟中已經做過了。因此,如果可以做到每次在選擇到最小記錄的同時,並根據比較結果對其他記錄做出相應的調整,那樣排序的總體效率就會變得很高了。

堆排序(Heap Sort)就是對簡單選擇排序進行的一種改進,並且效果非常明顯。

堆是具有下列性質的完全二叉樹:每個結點的值都大於或等於其左右孩子結點的值,稱爲最大堆或者大頂堆;或者每個結點的值都小於或等於其左右孩子結點的值,稱爲最小堆或者小頂堆。

下圖是一個例子,左邊的是大頂堆,而右邊的是小頂堆。

而根據堆的定義,可以知道根結點一定是堆中所有結點最大或者最小者。如果按照層遍歷的方式給結點從1開始編號,則結點之間滿足下列關係:
$$
\begin{cases}
ki \ge k{2i} \\
ki \ge k{2i+1}
\end{cases}

\begin{cases}
ki \le k{2i} \\
ki \le k{2i+1}
\end{cases}
1 \le i \le \lfloor \frac{n}{2} \rfloor
$$
如果將上圖按照層遍歷存入數組,則一定滿足上述關係表達,得到的數組如下圖所示。

堆排序的基本思想是,將待排序的序列構成一個最大堆。此時,整個序列的最大值就是堆頂的根結點。將它移走(其實就是將其與堆數組的末尾元素進行交換,此時末尾元素就是最大值),然後將剩餘的n1

個序列重新構成一個堆,這樣就會得到n

個元素中的次最大值。如此反覆執行,便能得到一個有序序列。

下面將給出堆排序算法的代碼實現。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 已知L->r[s...m]中記錄的關鍵字除L->r[s]之外均滿足堆的定義
// 本函數調整L->r[s]的關鍵字,使L->r[s..m]成爲一個大頂堆
void HeapAdjust(SqList *L, int s, int m){
	int temp, j;
	temp = L->r[s];
	for (j = 2 * s; j <= m - 1; j *= 2){
		// 沿關鍵字較大的孩子結點向下篩選
		if (j < m-1 && L->r[j] < L->r[j + 1])
			// j是關鍵字中較大的記錄的下標
			++j;
		if (temp >= L->r[j])
			// 當前值不需要進行調整
			break;
		L->r[s] = L->r[j];
		s = j;
	}
	// 插入
	L->r[s] = temp;
}

// 堆排序
void HeapSort(SqList *L){
	int i;
	for (i = L->length / 2; i >= 0; i--){
		// 將待排序的序列構成一個最大堆
		HeapAdjust(L, i, L->length);
	}
	// 開始進行排序
	for (i = L->length - 1; i > 0; i--){
		// 將堆頂記錄與當前未經排序的子序列的最後一個記錄交換
		swap(L, 0, i);
		// 重新調整爲最大堆
		HeapAdjust(L, 0, i - 1);
	}
}

從代碼中可以看出,堆排序分兩步走,首先是將待排序的序列構造成最大堆,這也是HeapSort()中第一個循環所做的事情,第二個循環也就是第二步,進行堆排序,逐步將每個最大值的根結點和末尾元素進行交換,然後再調整成最大堆,重複執行。

而在第一步中構造最大堆的過程中,是從n2

的位置開始進行構造,這是從下往上、從右到左,將每個非葉結點當作根結點,將其和其子樹調整成最大堆。

接下來就是分享堆排序的效率了。堆排序的運行時間主要是消耗在初始構造堆和在重建堆時的反覆篩選上。

在構建堆的過程中,因爲是從完全二叉樹的最下層最右邊的非葉結點開始構建,將它與其孩子進行比較和若有必要的交換,對每個非葉結點,最多進行兩次比較和互換操作,這裏需要進行這種操作的非葉結點數目是n2

個,所以整個構建堆的時間複雜度是O(n)

在正式排序的時候,第i

取堆頂記錄重建堆需要用O(logi)的時間(完全二叉樹的某個結點到根結點的距離是log2i+1),並且需要取n1次堆頂記錄,因此,重建堆的時間複雜度是O(nlogn)

所以,總體上來說,堆排序的時間複雜度是O(nlogn)

由於堆排序對原始記錄的排序狀態並不敏感,因此它無論最好、最壞和平均時間複雜度都是O(nlogn)

。同樣由於記錄的比較與交換是跳躍式進行,堆排序也不是穩定的排序算法。

另外,由於初始構建堆需要的比較次數較多,因此,它並不適合待排序序列個數較少的情況。

歸併排序

歸併排序(Merging Sort)就是利用歸併的思想實現的排序方法,它的原理是假設初始序列有n

個記錄,則可以看成是n個有序的子序列,每個子序列的長度爲1,然後兩兩合併,得到n2(x表示不小於x的最小整數)個長度爲2或1的有序子序列;再兩兩合併,,如此重複,直至得到一個長度爲n

的有序序列爲止,這種排序方法稱爲2路歸併排序。

下面是介紹實現的代碼。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 歸併排序,使用遞歸
void MergeSort(SqList *L){
	MSort(L->r, L ->r, 0, L->length-1);
}
// 將SR[s..t]歸併排序爲TR1[s..t]
void MSort(int SR[], int TR1[], int s, int t){
	int m;
	int TR2[MAXSIZE];
	if (s == t)
		TR1[s] = SR[s];
	else{
		// 將SR[s..t]平分爲SR[s...m-1]和SR[m...t]
		m = (s + t) / 2+1;
		MSort(SR, TR2, s, m-1);
		MSort(SR, TR2, m, t);
		// 將TR2[s..m-1]和TR2[m..t]歸併到TR1[s..t]
		Merge(TR2, TR1, s, m-1, t);
	}
}
// 將有序的SR[i..m]和SR[m+1..n]歸併爲有序的TR[i..n]
void Merge(int SR[], int TR[], int i, int m, int n){
	int j, k, l;
	for (j = m+1, k = i; i <= m && j <= n; k++){
		// 將SR中記錄由小到大併入TR
		if (SR[i] < SR[j])
			TR[k] = SR[i++];
		else
			TR[k] = SR[j++];
	}
	if (i <= m){
		for (l = 0; l <= m - i; l++)
			// 將剩餘的SR[i..m]複製到TR
			TR[k + l] = SR[i + l];
	}
	if (j <= n){
		for (l = 0; l <= n - j; l++)
			// 將剩餘的SR[j..n-1]複製到TR
			TR[k + l] = SR[j + l];
	}
}

上述代碼是一個遞歸版本的歸併排序實現算法,其中函數MSort()的作用是將待排序序列進行分割,然後Merge()函數會對需要歸併的序列進行排序並兩兩歸併在一起。

歸併排序的時間複雜度是O(nlogn)

,並且無論是最好、最壞還是平均都是同樣的時間性能。另外,在歸併過程中需要與原始記錄序列同樣數量的存儲空間存放歸併結果,並且遞歸時需要深度爲log2n的棧空間,因此空間複雜度是O(n+logn)

另外,歸併排序是使用兩兩比較,不存在跳躍,這在Merge()中的語句if(SR[i]<SR[j])可以看出,所以歸併排序是一個穩定的排序算法。

總體來說,歸併排序是一個比較佔用內存,但效率高且穩定的算法。

下面會介紹一個非遞歸版本的歸併排序算法實現。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 非遞歸版本的歸併排序
void MergeSort2(SqList *L){
	// 申請額外空間
	int* TR = (int *)malloc(L->length * sizeof(int));
	int k = 1;
	while (k < L->length){
		MergePass(L->r, TR, k, L->length);
		// 子序列長度加倍
		k = 2 * k;
		MergePass(TR, L->r, k, L->length);
		k = 2 * k;
	}
}
// 將SR[]中相鄰長度爲s的子序列兩兩歸併到TR[]
void MergePass(int SR[], int TR[], int s, int n){
	int i = 0;
	int j;
	while (i <= n - 2 * s){
		// 兩兩歸併
		Merge(SR, TR, i, i + s - 1, i + 2 * s - 1);
		i = i + 2 * s;
	}
	if (i < n - s + 1)
		// 歸併最後兩個子序列
		Merge(SR, TR, i, i + s - 1, n - 1);
	else{
		// 若最後剩下單個子序列
		for (j = i; j <= n - 1; j++)
			TR[j] = SR[j];
	}
}
// 將有序的SR[i..m]和SR[m+1..n]歸併爲有序的TR[i..n]
void Merge(int SR[], int TR[], int i, int m, int n){
	int j, k, l;
	for (j = m+1, k = i; i <= m && j <= n; k++){
		// 將SR中記錄由小到大併入TR
		if (SR[i] < SR[j])
			TR[k] = SR[i++];
		else
			TR[k] = SR[j++];
	}
	if (i <= m){
		for (l = 0; l <= m - i; l++)
			// 將剩餘的SR[i..m]複製到TR
			TR[k + l] = SR[i + l];
	}
	if (j <= n){
		for (l = 0; l <= n - j; l++)
			// 將剩餘的SR[j..n-1]複製到TR
			TR[k + l] = SR[j + l];
	}
}

非遞歸版本的歸併排序算法避免了遞歸時深度爲log2n

的棧空間,空間複雜度是O(n)

,並且避免遞歸也在時間性能上有一定的提升。應該說,使用歸併排序時,儘量考慮用非遞歸方法。

快速排序

在前面介紹的幾種排序算法,希爾排序相當於直接插入排序的升級,它們屬於插入排序類,而堆排序相當於簡單選擇排序的升級,它們是屬於選擇排序類,而接下來介紹的快速排序就是冒泡排序的升級,它們屬於交換排序類。

快速排序(Quick Sort)的基本思想是:通過一趟排序將待排序記錄分割成獨立的兩部分,其中一部分記錄的關鍵字均比另一部分記錄的關鍵字小,則可分別對這兩部分記錄繼續進行排序,以達到整個序列有序的目的。

下面給出實現的快速排序算法代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 快速排序
void QuickSort(SqList *L){
	QSort(L, 0, L->length - 1);
}
// 對待排序序列L中的子序列L->r[low...high]做快速排序
void QSort(SqList *L, int low, int high){
	int pivot;
	if (low < high){
		// 將L->r[low...high]一分爲二,算出樞軸值pivot
		pivot = Partition(L, low, high);
		// 對低子序列遞歸排序
		QSort(L, low, pivot - 1);
		// 對高子序列遞歸排序
		QSort(L, pivot + 1, high);
	}
}
// 交換待排序序列L中子表的記錄,使樞軸記錄到位,並返回其所在位置
// 並使得其之前位置的值小於它,後面位置的值大於它
int Partition(SqList *L, int low, int high){
	int pivot_key;
	// 初始值設置爲子表的第一個記錄
	pivot_key = L->r[low];
	while (low < high){
		while (low < high && L->r[high] >= pivot_key)
			high--;
		// 將小於樞軸記錄的值交換到低端
		swap(L, low, high);
		while (low < high && L->r[low] <= pivot_key)
			low++;
		// 將大於樞軸記錄的值交換到高端
		swap(L, low, high);
	}
	return low;
}

上述代碼同樣是使用了遞歸,其中Partition()函數要做的就是先選取待排序序列中的一個關鍵字,然後將其放在一個位置,這個位置左邊的值小於它,右邊的值都大於它,這樣的值被稱爲樞軸。

快速排序的時間性能取決於快速排序遞歸的深度。在最優情況下,Partition()每次都劃分得很均勻,如果排序n

個關鍵字,其遞歸樹的深度技術logn+1,即需要遞歸log2n次,其時間複雜度是O(nlogn)。而最壞的情況下,待排序的序列是正序或逆序,得到的遞歸樹是斜樹,最終其時間複雜度是O(n2)

平均情況可以得到時間複雜度是O(nlogn)

,而空間複雜度的平均情況是O(logn)

。但是由於關鍵字的比較和交換是跳躍進行的,所以快速排序也是不穩定排序。

快速排序的優化

快速排序算法是有許多地方可以優化的,下面給出一些優化的方案。

優化選取樞軸

樞軸的值太大或者太小都會影響快速排序的性能,一個改進方法是三數取中法,即取三個關鍵字先進行排序,將中間數作爲樞軸,一般是取左端、右端和中間三個數

需要在Partition()函數中做出下列修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int pivot_key;
	// 使用三數取中法選取樞軸
	int m = low + (high - low) / 2;
	if (L->r[low] > L->r[high])
		// 保證左端最小
		swap(L, low, high);
	if (L->r[m] > L->r[high])
		// 保證中間較小
		swap(L, high, m);
	if (L->r[m] > L->r[low])
		// 保證左端較小
		swap(L, m, low);

	pivot_key = L->r[low];

三數取中對小數組有很大的概率取到一個比較好的樞軸值,但是對於非常大的待排序的序列還是不足以保證得到一個比較好的樞軸值,因此還有一個辦法是九數取中法,它先從數組中分三次取樣,每次去三個數,三個樣品各自取出中數,然後從這三個中數當中再取出一箇中數作爲樞軸。

優化不必要的交換

優化後的代碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pivot_key = L->r[low];
int temp = pivot_key;
while (low < high){
	while (low < high && L->r[high] >= pivot_key)
		high--;
	// 將小於樞軸記錄的值交換到低端
	// swap(L, low, high);
	// 採用替換而不是交換的方式進行操作
	L->r[low] = L->r[high];
	while (low < high && L->r[low] <= pivot_key)
		low++;
	// 將大於樞軸記錄的值交換到高端
	// swap(L, low, high);
	// 採用替換而不是交換的方式進行操作
	L->r[high] = L->r[low];
}
// 將樞軸值替換回L.r[low]
L->r[low] = temp;
return low;

這裏可以減少多次交換數據的操作,性能上可以得到一定的提高。

優化小數組時的排序方案

當數組比較小的時候,快速排序的性能其實還不如直接插入排序(直接插入排序是簡單排序中性能最好的)。其原因是快速排序使用了遞歸操作,在有大量數據排序時,遞歸操作的影響是可以忽略的,但如果只有少數記錄需要排序,這個影響就比較大,所以下面給出改進的代碼。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define MAX_LENGTH_INSERT_SORT 7 
// 對待排序序列L中的子序列L->r[low...high]做快速排序
void QSort(SqList *L, int low, int high){
	int pivot;

	if ((high - low) > MAX_LENGTH_INSERT_SORT){
		// 當high - low 大於常數時用快速排序
		// 將L->r[low...high]一分爲二,算出樞軸值pivot
		pivot = Partition(L, low, high);
		// 對低子序列遞歸排序
		QSort(L, low, pivot - 1);
		// 對高子序列遞歸排序
		QSort(L, pivot + 1, high);
	}
	else{
		// 否則使用直接插入排序
		InsertSort(L);
	}
}

上述代碼是先進行一個判斷,當數組的數量大於一個預設定的常數時,才進行快速排序,否則就進行直接插入排序。這樣可以保證最大化地利用兩種排序的優勢來完成排序工作。

優化遞歸操作

遞歸對性能是有一定影響的,QSort()在其尾部有兩次遞歸操作,如果待排序的序列劃分極端不平衡,遞歸的深度將趨近於n

,而不是平衡時的log2n

,這就不僅僅是速度快慢的問題了。棧的大小是很有限的,每次遞歸調用都會耗費一定的棧空間,函數的參數越多,每次遞歸耗費的空間也越多。因此,如果能減少遞歸,將會大大提高性能。

下面給出對QSort()實施尾遞歸優化的代碼。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 對待排序序列L中的子序列L->r[low...high]做快速排序
void QSort(SqList *L, int low, int high){
	int pivot;

	if ((high - low) > MAX_LENGTH_INSERT_SORT){
		// 當high - low 大於常數時用快速排序
		while (low < high){
			// 將L->r[low...high]一分爲二,算出樞軸值pivot
			pivot = Partition(L, low, high);
			// 對低子序列遞歸排序
			QSort(L, low, pivot - 1);
			// 尾遞歸
			low = pivot + 1;
		}
	}
	else{
		// 否則使用直接插入排序
		InsertSort(L);
	}
}

上述代碼中使用while循環,並且去掉原來的對高子序列進行遞歸,改成代碼low = privot + 1,那麼在進行一次遞歸後,再進行循環,就相當於原來的QSort(L,privot+1,high);,結果相同,但是從遞歸變成了迭代,可以縮減堆棧深度,從而提高了整體性能。

總結

上述總共介紹了7種排序算法,首先是根據排序過程中藉助的主要操作,將內排序分爲:插入排序、交換排序、選擇排序和歸併排序,如下圖所示。

事實上,目前還沒有十全十美的排序算法,都是各有優點和缺點,即使是快速排序算法,也只是整體上性能優越,它也存在排序不穩定、需要大量輔助空間、對少量數據排序無優勢等不足。

下面對這7種算法的各種指標進行對比,如下圖所示:

從算法的簡單性來看,可以分爲兩類:

  • 簡單算法:冒泡、簡單選擇、直接插入。
  • 改進算法:快速、堆、希爾、歸併。

從平均情況看,快速、堆、歸併三種改進算法都優於希爾排序,並遠遠勝過3種簡單算法。

從最好情況看,冒泡和直接插入排序要更好一點,即當待排序序列是基本有序的時候,應該考慮這兩種排序算法,而非4種複雜的改進算法。

從最壞情況看,堆和歸併排序比其他排序算法都要更好。

從空間複雜度看,歸併排序和快速排序都對空間有要求,而其他排序反而都只是O(1)

的複雜度。

從穩定性上看,歸併排序是改進算法中唯一穩定的算法。而不穩定的排序算法有“快些選堆”,即快速、希爾、選擇和堆排序四種算法(書中給出的簡單選擇排序是不穩定的,但是從網上查找資料看到選擇排序是一個不穩定的算法)。

排序算法的總結就到這裏,實際上還是要根據實際問題來選擇適合的排序算法。

全部排序算法的代碼可以查看排序算法實現代碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章