排序

【知识框架】

在这里插入图片描述

排序概述

一、排序的相关定义

  1. 排序,就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。为了查找方便,通常希望计算机中的表是按关键字有序的。排序的确切定义如下:
    输入: n n n个记录 R 1 , R 2 , . . . , R n R_1,R_2,...,R_n R1,R2,...,Rn,对应的关键字为 k 1 , k 2 , . . . , k n k_1,k_2,...,k_n k1,k2,...,kn
    输出:输入序列的一个重排 R 1 ′ , R 2 ′ , . . . , R n ′ {R_1}^{'},{R_2}^{'},...,{R_n}^{'} R1,R2,...,Rn,使得 k 1 ′ ≤ k 2 ′ ≤ . . . ≤ k n ′ {k_1}^{'}≤{k_2}^{'}≤...≤{k_n}^{'} k1k2...kn(其中“ ≤ ≤ ”可以换成其他比较大小的符号)

  2. 排序的稳定性。假设 k i = k j ( 1 ≤ i ≤ n , 1 ≤ j ≤ n , i ! = j ) k_i=k_j(1≤i≤n,1≤j≤n,i!=j) ki=kj(1in,1jn,i!=j),且在排序前的序列中 R i R_i Ri领先于 R j R_j Rj(即 i < j i<j i<j)。如果排序后 R i R_i Ri仍领先于 R j R_j Rj,则称所用的排序方法是稳定的;反之,若可能使得排序后的序列中 R j R_j Rj领先于 R i R_i Ri,则称所用的排序方法是不稳定的。
    需要注意的是,算法是否具有稳定性并不能衡量一个算法的优劣,它主要是对算法的性质进行描述。如果待排序表中的关键字不允许重复,则排序结果是唯一的,那么选择排序算法时的稳定与否就无关紧要。

  3. 内排序和外排序。内排序是在排序整个过程中,待排序的所有记录全部被放置在内存中。外排序是由于排序的记录个数太多,不能同时放置在内存,整个排序过程需要在内外存之间多次交换数据才能进行。

通常可以将排序算法分为插入排序、交换排序、选择排序、归并排序和基数排序五大类。每种排序算法都有各自的优缺点,适合在不同的环境下使用,就其全面性能而言,很难提出一种被认为 是最好的算法。内部排序算法的性能取决于算法的时间复杂度和空间复杂度,而时间复杂度一般是由比较和移动的次数决定的。

二、排序用到的结构与函数

以下是一个用于排序用的顺序表结构。

#define MAXSIZE 10	//用于要排序数组个数最大值,可根据需要修改
typedef struct{
	int R[MAXSIZE];	//用于存储要排序的数组
	int length;	//用于记录顺序变的长度
}SqList;

另外,由于排序最常用到的操作是数组两元素的交换,所以我们将它写成函数。

/*交换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;
}

常见的排序算法

一、冒泡排序(交换排序)

1、算法

冒泡排序的基本思想是:从后往前(或从前往后)两两比较相邻元素的值,若为逆序,则交换它们,直到序列比较完。第一趟冒泡,结果是将最小的元素交换到待排序列的第一个位置(或将最大的元素交换到待排序列的最后一个位置),关键字最小的元素如气泡一般逐渐往上“漂浮”直至“水面”(或关键字最大的元素如石头一般下沉至水底)。下一趟冒泡时,前一趟确定的最小元素不再参与比较,每趟冒泡的结果是把序列中的最小元素(或最大元素)放到了序列的最终位置…这样最多做 n − 1 n-1 n1趟冒泡就能把所有元素排好序。
如下图所示,我们对序列 { 49 , 38 , 65 , 97 , 76 , 13 , 27 , 49 } \{49,38,65,97,76,13,27,49\} {49,38,65,97,76,13,27,49}进行冒泡排序:
在这里插入图片描述
冒泡排序算法的代码如下:

void BubbleSort(SqList *L){
	int i, j;
	bool flag = true;	//表示本趟冒泡是否发生交换的标志
	for(i=0; i< L->length-1; i++){	
		flag = false;	
		for(j=n-1; j>i; j--){	//一趟冒泡过程
			if(L->R[j-1] > L->R[j]){	//若为逆序
				swap(&L, j-1, j);	//交换
				flag = true;
			}
		}
		if(flag == false){
			return;	//本趟遍历后没有发生交换,说明表已经有序
		}
	}
}

2、性能分析

空间效率:仅使用了常数个辅助单元,因而空间复杂度为 O ( 1 ) O(1) O(1)
时间效率:当初始序列有序时,显然第一趟冒泡后flag依然为false (本趟冒泡没有元素交换),从而直接跳出循环,比较次数为 n − 1 n-1 n1,移动次数为 0 0 0,从而最好情况下的时间复杂度为 O ( n ) O(n) O(n);当初始序列为逆序时,需要进行 n − 1 n- 1 n1趟排序,第 i i i趟排序要进行 n − i n -i ni次关键字的比较,而且每次比较后都必须移动元素 3 3 3次来交换元素位置。这种情况下, 比 较 次 数 = ∑ i = 1 n ( n − i ) = n ( n − 1 ) / 2 比较次数=\displaystyle\sum_{i=1}^{n}(n-i)=n(n-1)/2 =i=1n(ni)=n(n1)/2 移 动 次 数 = ∑ i = 1 n 3 ( n − i ) = 3 n ( n − 1 ) / 2 移动次数=\displaystyle\sum_{i=1}^{n}3(n-i)=3n(n-1)/2 =i=1n3(ni)=3n(n1)/2从而,最坏情况下的时间复杂度为 O ( n 2 ) O(n^2) O(n2),其平均时间复杂度也为 O ( n 2 ) O(n^2) O(n2)


二、简单选择排序

简单选择排序法(Simple Selection Sort) 就是通过 n − i n-i ni次关键字间的比较,从 n − i + 1 n-i+1 ni+1个记录中选出关键字最小的记录,并和第 i ( 1 < i < n ) i (1<i<n) i(1<i<n)个记录交换之。

1、算法

void SelectSort(SqList *L){
	int i,j,min;
	for(i=0; i<L->length-1;i++){	//一共进行n-1趟
		min = i;	//记录最小元素位置
		for(j=i+i; j<L->length; j++){
			if(L->R[j] < L->R[min]){	//在R[i...n-1]中选择最小的元素
				min = j;	//更新最小元素位置
			}
		}
		if(min !=i){
			swap(L->R[i], L->R[min]);	//swap函数移动元素3次
		}
	}
}

2、性能分析

空间效率:仅使用常数个辅助单元,故空间效率为 O ( 1 ) O(1) O(1)
时间效率:从上述代码中不难看出,在简单选择排序过程中,元素移动的操作次数很少,不会超过 3 ( n − 1 ) 3(n-1) 3(n1)次,最好的情况是移动 0 0 0次,此时对应的表已经有序;但元素间比较的次数与序列的初始状态无关,始终是 n ( n − 1 ) / 2 n(n- 1)/2 n(n1)/2次,因此时间复杂度始终是 O ( n 2 ) O(n^2) O(n2)
稳定性:在第 i i i趟找到最小元素后,和第 i i i个元素交换,可能会导致第 i i i个元素与其含有相同关键字元素的相对位置发生改变。例如,表 L = { 2 , 2 , 1 } L= \{2,2, 1\} L={2,2,1},经过一趟排序后 L = { 1 , 2 , 2 } L= \{1, 2,2\} L={1,2,2},最终排序序列也是 L = { 1 , 2 , 2 } L=\{1,2,2\} L={1,2,2},显然, 2 2 2 2 2 2的相对次序已发生变化。因此,简单选择排序是一种不稳定的排序方法。


三、直接插入排序

直接插入排序(Straight Insertion Sort)的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。

1、算法

void InsertSort(SqList *L){
	int i,j;
	//依次将R[2]~R[n]插入到前面已排序序列,R[1]为默认排好序的序列,R[0]作为哨兵不存放元素
	for(i=2; i<=L->length; i++){
		//若R[i]关键码小于其前驱,将A[i]插入有序表
		if(L->R[i] < L->R[i-1]){
			L->R[0] = L->R[i];	//复制为哨兵,R[0]不存放元素
			//从后往前查找待插入位置
			for(j=i-1; L->R[0]<L->R[j]; --j){
				L->R[j+1] = L->R[j];	//向后挪位
			}
			L->[j+1] = A[0];	//复制到插入位置
		}
	}
}

假定初始序列为 { 49 , 38 , 65 , 97 , 76 , 13 , 27 , 49 } \{49, 38, 65, 97 ,76, 13, 27, 49\} {49,38,65,97,76,13,27,49},初始时 49 49 49可以视为一个已排好序的子序列,按照上述算法进行直接插入排序的过程如下图所示,括号内是已排好序的子序列。
在这里插入图片描述

2、性能分析

空间效率:仅使用了常数个辅助单元,因而空间复杂度为 O ( 1 ) O(1) O(1)
时间效率:在排序过程中,向有序子表中逐个地插入元素的操作进行了 n − 1 n-1 n1趟,每趟操作都分为比较关键字和移动元素,而比较次数和移动次数取决于待排序表的初始状态。
在最好情况下,表中元素已经有序,此时每插入一个元素,都只需比较一次而不用移动元素,因而时间复杂度为 O ( n ) O(n) O(n)
在最坏情况下,表中元素顺序刚好与排序结果中的元素顺序相反(逆序),总的比较次数达到最大,为 ∑ i = 2 n i \displaystyle\sum_{i=2}^{n}i i=2ni,总的移动次数也达到最大,为 ∑ i = 2 n ( i + 1 ) 。 \displaystyle\sum_{i=2}^{n}(i+1)。 i=2n(i+1)
平均情况下,考虑待排序表中元素是随机的,此时可以取上述最好与最坏情况的平均值作为平均情况下的时间复杂度,总的比较次数与总的移动次数均约为 n 2 / 4 n^2/4 n2/4
因此,直接插入排序算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2)
稳定性:由于每次插入元素时总是从后向前先比较再移动,所以不会出现相同元素相对位置发生变化的情况,即直接插入排序是一个稳定的排序方法。
适用性:直接插入排序算法适用于顺序存储和链式存储的线性表。为链式存储时,可以从前往后查找指定元素的位置。


四、折半插入排序

从直接插入排序算法中,每趟插入的过程中都进行了两项工作:①从前面的有序子表中查找出待插入元素应该被插入的位置;②给插入位置腾出空间,将待插入元素复制到表中的插入位置。注意到在该算法中,总是边比较边移动元素。下面将比较和移动操作分离,即先折半查找出元素的待插入位置,然后统一地移动待插入位置之后的所有元素。

1、算法

void HalfInsertSort(SqList *L){
	int i,j,low,high,mid;
	//依次将R[2]~R[n]插入前面的已排序序列
	for(i=2; i<=L->length;i++){
		L->R[0] = L->R[i];	//将R[i]暂存到R[0]
		low=1; high=i-1;	//设置折半查找的范围
		//折半查找(默认递增有序)
		while(low <= high){
			mid = (low + high) / 2;	//取中间点
			if(L->R[mid] > L->R[0]){
				high = mid-1;	//查找左半子表
			}else{
				low = mid+1;	//查找右半子表
			}
		}
		for(j = i-1; j>=high+1; --j){
			L->R[j+1] = L->R[j];	//统一后移元素,
		}
		L->R[high+1] = L->R[0];	//插入操作
	}
}

2、性能分析

从上述算法中,不难看出折半插入排序仅减少了比较元素的次数,约为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n),该比较次数与待排序表的初始状态无关,仅取决于表中的元素个数 n n n;而元素的移动次数并未改变,它依赖于待排序表的初始状态。因此,折半插入排序的时间复杂度仍为 O ( n 2 ) O(n^2) O(n2),但对于数据量不很大的排序表,折半插入排序往往能表现出很好的性能。折半插入排序是一种稳定的排序方法。


五、希尔排序

希尔排序是对直接插入排序进行改进而得来的,又称缩小增量排序。
希尔排序的基本思想是:先将待排序表分割成若干形如 L [ i , i + d , i + 2 d , . . . , i + k d ] L[i,i+d,i+ 2d,...,i+ kd] L[i,i+d,i+2d,...,i+kd]的“特殊”子表,即把相隔某个“增量”的记录组成一个子表,对各个子表分别进行直接插入排序,当整个表中的元素已呈“基本有序”时,再对全体记录进行一次直接插入排序。
希尔排序的过程如下:先取一个小于 n n n的步长 d 1 d_1 d1,把表中的全部记录分成 d 1 d_1 d1组,所有距离为 d 1 d_1 d1的倍数的记录放在同一组,在各组内进行直接插入排序;然后取第二个步长 d 2 < d 1 d_2<d_1 d2<d1,重复上述过程,直到所取到的 d t = 1 d_t= 1 dt=1,即所有记录已放在同一组中,再进行直接插入排序,由于此时已经具有较好的局部有序性,故可以很快得到最终结果。

1、算法

/*对顺序表L作希尔排序*/
void ShellSold(SqList *L){
	int i,j;
	int increment = L->length;	
	do{
		increment = increment/3 + 1;	//增量序列
		for(i = increment+1; i <= L->length; i++){
			if(L-R[i] < L->R[i-increment]){
				/*需将L->R[i]插入有序增量子表*/
				L->R[0] = L->R[i];	//暂存R[0]
				for(j = i-increment; j>0 && L->R[0]<L->R[j]; j-=increment){
					L->R[j+increment] = L->R[j];	//记录后移,查找插入位置
				}
				L->R[j+increment] = L->R[0];	//插入
			}
		}
	}while(increment > 1);
}

例如,当传入的SqList的的 l e n g t h = 9 length=9 length=9 R [ 10 ] = { 0 , 9 , 1 , 5 , 8 , 3 , 7 , 4 , 6 , 2 } R[10]=\{0,9,1,5,8,3,7,4,6,2\} R[10]={0,9,1,5,8,3,7,4,6,2}。排序的大致过程下图所示。

第一轮increment=4:
在这里插入图片描述
第二轮increment=2:
在这里插入图片描述
第三轮increment=1:
在这里插入图片描述
最终结果:
在这里插入图片描述

2、性能分析

空间效率:仅使用了常数个辅助单元,因而空间复杂度为 O ( 1 ) O(1) O(1)
时间效率:由于希尔排序的时间复杂度依赖于增量序列的函数,这涉及数学上尚未解决的难题,所以其时间复杂度分析比较困难。当 n n n在某个特定范围时,希尔排序的时间复杂度约为 O ( n 3 / 2 ) O(n^{3/2}) O(n3/2)。要好于直接排序的 O ( n 2 ) O(n^2) O(n2)
稳定性:当相同关键字的记录被划分到不同的子表时,可能会改变它们之间的相对次序,因此希尔排序是一种不稳定的排序方法。
适用性:希尔排序算法仅适用于线性表为顺序存储的情况。


六、堆排序

堆排序(Heap Sort)是对简单选择排序进行的一种改进。

1、堆的定义

堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大根堆(如下图所示);或者每个结点的值都小于或等于其左右孩子结点的值,称为小根堆。
在这里插入图片描述

2、堆排序

堆排序的思路很简单:首先将存放在 L [ 1... n ] L[1...n] L[1...n]中的 n n n个元素建成初始堆,由于堆本身的特点(以大顶堆为例),堆顶元素就是最大值。输出堆顶元素后,通常将堆底元素送入堆顶,此时根结点已不满足大顶堆的性质,堆被破坏,将堆顶元素向下调整使其继续保持大顶堆的性质,再输出堆顶元素。如此重复,直到堆中仅剩一个元素为止。可见堆排序需要解决两个问题:①如何将无序序列构造成初始堆?②输出堆顶元素后,如何将剩余元素调整成新的堆?

堆排序的关键是构造初始堆 n n n个结点的完全二叉树,最后一个结点是第 ⌊ n / 2 ⌋ ⌊n/2⌋ n/2个结点的孩子。对第 ⌊ n / 2 ⌋ ⌊n/2⌋ n/2个结点为根的子树筛选(对于大根堆,若根结点的关键字小于左右孩子中关键字较大者,则交换),使该子树成为堆。之后向前依次对各结点( ⌊ n / 2 ⌋ − 1 ⌊n/2⌋ -1 n/21~ 1 1 1)为根的子树进行筛选,看该结点值是否大于其左右子结点的值,若不大于,则将左右子结点中的较大值与之交换,交换后可能会破坏下一级的堆,于是继续采用上述方法构造下一级的堆,直到以该结点为根的子树构成堆为止。反复利用上述调整堆的方法建堆,直到根结点。
举例自上往下逐步调整为大根堆,如下图所示。
在这里插入图片描述
继续已上图为例:
输出堆顶元素后,将堆的最后一个元素与堆顶元素交换,此时堆的性质被破坏,需要向下进行筛选。将 09 09 09和左右孩子的较大者 78 78 78交换,交换后破坏了子树的堆,继续对子树向下筛选,将 09 09 09和左右孩子的较大者 65 65 65交换,交换后得到了新堆,调整过程如下图所示。
在这里插入图片描述

3、算法

下面是建立大根堆的算法:

void BuildMaxHeap(ElemType A[], int len){
	for(int i=len/2; i>0; i--){	//从i=[n/2]~1,反复调整堆
		HeadAdjust(A, i, len);
	}
}

函数HeadAdjust将元素k为根的子树进行调整。

/*函数HeadAdjust将元素k为根的子树进行调整*/
void HeadAdjust(ElemType A[], int k, int len){
	A[0] = A[k];	//A[0]暂存子树的根节点
	for(i=2*k; i<=len; i*=2){	//沿key较大的子结点向下筛选
		if(i<len && A[i]<A[i+1]){
			i++;	//取key较大的子节点的下标
		}
		if(A[0] >= A[i]){
			break;	//筛选结束
		}else{
			A[k] = A[i];	//将A[i]调整到双亲结点上
			k = i;	//修改k值,以便继续向下筛选
		}
	}
	A[k] = A[0];	//被筛选结点的值放入最终位置
}

调整的时间与树高有关,为 O ( h ) O(h) O(h)。在建含 n n n个元素的堆时,关键字的比较总次数不超过 4 n 4n 4n,时间复杂度为 O ( n ) O(n) O(n),这说明可以在线性时间内将一个无序数组建成一个堆。
下面是堆排序算法:

void HeapSord(ElemType A[], int len){
	BuildMaxHeap(A, len);	//初始建堆
	for(i = len; i>1; i--){	//n-1趟的交换和建堆过程
		Swap(A, i, 1);	//输出堆顶元素(和堆底元素交换)
		HeapAdjust(A, 1, i-1);	//调整,把剩余的i-1个元素整理成堆
	}
}

同时,堆也支持插入操作。对堆进行插入操作时,先将新结点放在堆的末端,再对这个新结点向上执行调整操作。大根堆的插入操作示例如下图所示。
在这里插入图片描述

堆排序适合关键字较多的情况(如n>1000)。例如,在1亿个数中选出前100个最大值?首先使用一个大小为100的数组,读入前100个数,建立小顶堆,而后依次读入余下的数,若小于堆顶则舍弃,否则用该数取代堆顶并重新调整堆,待数据读取完毕,堆中100个数即为所求。

4、性能分析

空间效率:仅使用了常数个辅助单元,所以空间复杂度为 O ( 1 ) O(1) O(1)
时间效率:建堆时间为 O ( n ) O(n) O(n),之后有 n − 1 n-1 n1次向下调整操作,每次调整的时间复杂度为 O ( h ) O(h) O(h),故在最好、最坏和平均情况下,堆排序的时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
稳定性:进行筛选时,有可能把后面相同关键字的元素调整到前面,所以堆排序算法是一种不稳定的排序方法。


七、归并排序

1、算法

归并排序与上述基于交换、选择等排序的思想不一样,“归并”的含义是将两个或两个以上的有序表组合成一个新的有序表。假定待排序表含有 n n n个记录,则可将其视为 n n n个有序的子表,每个子表的长度为 1 1 1,然后两两归并,得到 ⌈ n / 2 ⌉ ⌈n/2⌉ n/2个长度为 2 2 2 1 1 1的有序表;继续两两…如此重复,直到合并成一个长度为 n n n的有序表为止,这种排序方法称为 2 2 2路归并排序。
下图所示为 2 2 2路归并排序的一个例子,经过三趟归并后合并成了有序序列。
在这里插入图片描述
Merge()的功能是将前后相邻的两个有序表归并为一个有序表。设两段有序表 A [ l o w . . . m i d ] A[low...mid] A[low...mid] A [ m i d + 1... h i g h ] A [mid+1...high] A[mid+1...high]存放在同一顺序表中的相邻位置,先将它们复制到辅助数组 B B B中。每次从对应 B B B中的两个段取出一个记录进行关键字的比较,将较小者放入 A A A中,当数组 B B B中有一段的下标超出其对应的表长(即该段的所有元素都已复制到 A A A中)时,将另一段中的剩余部分直接复制到 A A A中。算法如下:

ElemType *B = (ElemType *)malloc((n+1)*sizeof(ElemType));	//辅助数组B
void Merge(ElemType A[], int low, int mid, int high){
	//表A的两段A[low...mid]和A[mid+1...high]各自有序,将它们合并成一个有序表
	for(int k=low; k<=high; k++){
		B[k] = A[k];	//将A中所有元素复制到B中
	}
	for(i=low, j=mid+1, k=i; i<=mid && j<=high; k++){
		//从low到mid,j从mid+1到high,k是最终排序数组的下标
		if(B[i] <= B[j]){	//比较B左右两段的元素
			A[k] = B[i++];	//将较小值赋值给A,B左段下标加1,右段不动
		}else{
			A[k] = B[j++];	//将较小值赋值给A,B右段下标加1,左段不动
		}
	}
	while(i <= mid){	
		//若第一个表(左段)未检测完,复制
		A[k++] = B[i++];
	}
	while(j <= high){	
		//若第二个表(右段)未检测完,复制
		A[k++] = B[j++];
	}
}

一趟归并排序的操作是,调用 ⌈ n / 2 h ⌉ ⌈n/2h⌉ n/2h次算法 m e r g e ( ) merge() merge(),将 L [ 1... n ] L[1...n] L[1...n]中前后相邻且长度为 h h h的有序段进行两两归并,得到前后相邻、长度为 2 h 2h 2h的有序段,整个归并排序需要进行 ⌈ l o g 2 n ⌉ ⌈log_2n⌉ log2n趟。递归形式的 2 2 2路归并排序算法是基于分治的,其过程如下。
分解:将含有 n n n个元素的待排序表分成各含 n / 2 n/2 n/2个元素的子表,采用 2 2 2路归并排序算法对两个子表递归地进行排序。
合并:合并两个已排序的子表得到排序结果。

viod MergeSort(ElemType A[], int low, int high){
	if(low < high){
		int mid = (low + high)/2;	//从中间划分两个子序列
		MergeSort(A, low, mid);	//对左侧子序列进行递归排序
		MergeSort(A, mid+1, high);	//对右侧子序列进行递归排序
		Merge(A, low, mid, high);	//归并
	}
}

用图像描述,整个的递归拆分加归并排序的过程大概如下:
在这里插入图片描述

2、性能分析

空间效率: Merge()操作中,辅助空间刚好为 n n n个单元,所以算法的空间复杂度为 O ( n ) O(n) O(n)
时间效率:每趟归并的时间复杂度为 O ( n ) O(n) O(n),共需进行 ⌈ l o g 2 n ⌉ ⌈log_2n⌉ log2n趟归并,所以算法的时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
稳定性:由于Merge()操作不会改变相同关键字记录的相对次序,所以2路归并排序算法是一种稳定的排序方法。


八、快速排序

快速排序算法,被列为20世纪十大算法之一。快速排序是所有内部排序算法中平均性能最优的排序算法。

前面介绍及几种算法中,希尔排序相当于直接插入排序的升级,它们同属于插入排序类,堆排序相当于简单选择排序的升级,它们同属于选择排序类。而快速排序是冒泡排序的升级,它们都属于交换排序类。即它也是通过不断比较和移动交换来实现排序的,只不过它的实现,增大了记录的比较和移动的距离,将关键字较大的记录从前面直接移动到后面,关键字较小的记录从后面直接移动到前面,从而减少了总的比较次数和移动交换次数。

快速排序(Quick Sort) 的基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小, 则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。

1、算法

假设关键字数组为 { 49 , 38 , 65 , 97 , 76 , 13 , 27 } \{49,38,65,97,76,13,27\} {49,38,65,97,76,13,27}。快速排序算法的核心逻辑就是先选取当中的一个关键字,比如选择第一个关键字49,然后想尽办法将它放到一个位置,使得它左边的值都比它小,右边的值比它大,我们将这样的关键字称为枢轴(pivot)

快速排序的基本思想是基于分治法的:在待排序表 L [ 1... n ] L[1...n] L[1...n]中任取一个元素 p i v o t pivot pivot作为枢轴(或基准,通常取首元素),通过一趟排序将待排序表划分为独立的两部分 L [ 1... k − 1 ] L[1...k-1] L[1...k1] L [ k + 1... n ] L[k+1...n] L[k+1...n],使得 L [ 1... k − 1 ] L[1...k-1] L[1...k1]中的所有元素小于 p i v o t pivot pivot L [ k + 1... n ] L[k+1...n] L[k+1...n]中的所有元素大于等于 p i v o t pivot pivot,则 p i v o t pivot pivot放在了其最终位置 L ( k ) L(k) L(k)上,这个过程称为一趟快速排序(或一次划分)。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。
一趟快速排序的过程是一个交替搜索和交换的过程,下面通过实例来介绍,附设两个指针 i i i j j j,初值分别为 l o w low low h i g h high high,取第一个元素 49 49 49为枢轴赋值到变量 p i v o t pivot pivot
指针j从 h i g h high high往前搜索找到第一个小于枢轴的元素 27 27 27,将 27 27 27交换到 i i i所指位置。
在这里插入图片描述
指针 i i i l o w low low往后搜索找到第一个大于枢轴的元素 65 65 65,将 65 65 65交换到 i i i所指位置。
在这里插入图片描述
指针 j j j继续往前搜索找到小于枢轴的元素 13 13 13,将 13 13 13交换到i所指位置。
在这里插入图片描述
指针 i i i继续往后搜索找到大于枢轴的元素 97 97 97,将 97 97 97交换到j所指位置。
在这里插入图片描述
指针j继续往前搜索小于枢轴的元素,直至 i = = j i==j i==j
在这里插入图片描述
此时 i = = j i==j i==j,指针 i i i之前的元素均小于 49 49 49,指针 i i i之后的元素均大于等于 49 49 49,将 49 49 49放在 i i i所指位置即其最终位置,经过一趟划分, 将原序列分割成了前后两个子序列。
在这里插入图片描述
按照同样的方法对各子序列进行快速排序,若待排序列中只有一个元素,显然已有序。
在这里插入图片描述
代码实现大致如下:

void QuickSort(ElemType A[], int low, int high){
	if(low < high){
		//Partition()就是划分操作,将表A[low...high]划分为满足上述条件的两个子表
		int pivotpos = Partition(A, low, high);	//划分
		QuickSort(A, low, pivotpos-1);	//依次对两个子表进行递归操作
		QuickSort(A, pivotpos+1, high);
	}
}

假设每次总以当前表中第一个元素作为枢轴来对表进行划分,则将表中比枢轴大的元素向右移动,将比枢轴小的元素向左移动,使得一趟Partition ()操作后,表中的元素被枢轴值一分为二。代码如下:

int Partition(ElemType A[], int low, int high){
	ElemType pivot = A[low];	//将当前表中第一个元素设为枢轴,对表进行划分
	while(low < high){
		while(low < high && A[high] >= pivot){
			--high;	
		}
		A[low] = A[high]	//将比枢轴小的元素移动到左端
		while(low < high && A[low] <= pivot){
			++low;	
		}
		A[high] = A[low];	//将比枢轴大的元素移动到右端
	}
	A[low] = pivot;	//枢轴元素存放到最终位置
	return low;	//返回存放枢轴的最终位置
}

2、性能分析

空间效率:由于快速排序是递归的,需要借助一个递归工作栈来保存每层递归调用的必要信息,其容量应与递归调用的最大深度一致。最好情况下为
O ( l o g 2 n ) O(log_2n) O(log2n);最坏情况下,因为要进行 n − 1 n-1 n1次递归调用,所以栈的深度为 O ( n ) O(n) O(n);平均情况下,栈的深度为 O ( l o g 2 n ) O(log_2n) O(log2n)
时间效率:快速排序的运行时间与划分是否对称有关,快速排序的最坏情况发生在两个区域分别包含 n − 1 n-1 n1个元素和 0 0 0个元素时,这种最大程度的不对称性若发生在每层递归上,即对应于初始排序表基本有序或基本逆序时,就得到最坏情况下的时间复杂度为 O ( n 2 ) O(n^2) O(n2)
有很多方法可以提高算法的效率:一种方法是尽量选取一个可以将数据中分的枢轴元素,如从序列的头尾及中间选取三个元素,再取这三个元素的中间值作为最终的枢轴元素;或者随机地从当前表中选取枢轴元素,这样做可使得最坏情况在实际排序中几乎不会发生。
在最理想的状态下,即Partition ()可能做到最平衡的划分,得到的两个子问题的大小都不可能大于 n / 2 n/2 n/2,在这种情况下,快速排序的运行速度将大大提升,此时,时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)。 好在快速排序平均情况下的运行时间与其最佳情况下的运行时间很接近,而不是接近其最坏情况下的运行时间。快速排序是所有内部排序算法中平均性能最优的排序算法。
稳定性:在划分算法中,若右端区间有两个关键字相同,且均小于基准值的记录,则在交换到左端区间后,它们的相对位置会发生变化,即快速排序是一种不稳定的排序方法。

各种排序算法的比较

在这里插入图片描述

附录

上文链接

数据结构:查找

专栏

数据结构专栏

参考资料

1、严蔚敏、吴伟民:《数据结构(C语言版)》
2、程杰:《大话数据结构》
3、王道论坛:《数据结构考研复习指导》
4、托马斯·科尔曼等人:《算法导论》

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐