排序算法详解(八大排序算法的实现)


1 排序的概念

相关概念:

排序: 所谓排序,就是使一串记录,按照其中的某个或是某些关键字的大小,递增或递减的排列起来的操作。
稳定性: 假定在待排序的记录序列中,存在多个具有相同关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i] = r[j],且 r[i] 在 r[j] 之前,而在排序后的序列中,r[i] 仍在 r[j] 之前,则称这种排序算法是稳定的,否则称为不稳定的。
内部排序: 数据元素全部放在内存中的排序。
外部排序: 数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。

常见的排序算法:
常见排序算法


2 常见排序算法的实现

2.1 插入排序

2.1.1 直接插入排序

基本思想: 把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。

直接插入排序示意图(升序):

直接插入排序(升序)
如示意图中所示:蓝色表示待排序元素,橙色表示已排序元素,红色表示当前待插入元素,绿色表示当前用于与待插入元素进行比较的元素。起始时,将第一个位置(下标为0)上的元素即当成已排序好的元素,接着将后面的元素依次插入:假设当前待插入元素为arr[i](下标为i),则此时前面的i个元素(arr[0], arr[1], ... , arr[i-1])已经排好序,将arr[i]的排序码依次与arr[i-1], arr[i-2], ... 的排序码进行比较,找到对应位置即将arr[i]插入,原来位置上及其后的元素则顺序后移。(以升序排列为例:如果当前被比较元素的排序码比arr[i]的大,则将该元素后移,否则,将arr[i]插入到该元素后面。)

直接插入排序算法的实现:

//直接插入排序(时间复杂度:O(N*N) (1+2+...+ n-1)
void InsertSort(int* arr, int n) {
	for (int i = 0; i < n - 1; i++) {
		int end = i;  //已排序部分的末位置
		int temp = arr[end + 1]; //记录当前待插入元素
		while (end >= 0) {
			if (temp < arr[end]) {
				arr[end + 1] = arr[end];
				end--;
			}
			else {
				break;
			}
		}
		arr[end + 1] = temp; //将元素插入
	}
}

可以发现:直接插入排序算法的时间效率与待排数据有关。最好情况下,待排序列(n个数据)已经是有序状态,此时,每一次插入都只需要比较一次即可,则总比较次数为n - 1,即时间复杂度为O(N);最坏情况下,待排序列处于逆序状态,此时,每一次插入要进行比较的次数为当前已排序数据数,即认为第一个元素为已排序元素,第二个元素的插入需比较1次,第三个元素的插入需比较2次,……,第n个元素的插入需比较n-1次,则总比较次数为1+2+...+ n-1 = n*(n-1)/2,即时间复杂度为 O ( N 2 ) \pmb{O(N^{2})} O(N2) 。同时,只要控制比较插入的条件,原序列中具有相同排序码的元素的相对位置就不会被改变(以升序为例,控制插入条件为:当待排元素的排序码大于且等于被比较元素的时,才将元素插入到被比较元素后),即可以说该排序算法是稳定的。


📜 直接插入排序的特性总结:

  1. 元素集合越接近有序,直接插入排序算法的时间效率越高
  2. 时间复杂度: O ( N 2 ) \pmb{O(N^{2})} O(N2)
  3. 空间复杂度: O ( 1 ) \pmb{O(1)} O(1)
  4. 稳定性:稳定(是一种稳定的排序算法)

2.1.2 希尔排序(缩小增量排序)

基本思想: 希尔排序法又称缩小增量法。其基本思想是:先选定一个整数(假设为gap,gap不大于待排序数据数n),把待排序文件中的所有记录进行分组,将所有距离为gap的记录分在同一组内,并对每一组内的记录进行插入排序。接着减小gap,重复上述的分组和排序操作。当gap = 1时,所有记录在同一组内排好序。

希尔排序示意图(升序):
希尔排序
如图所示:在第一趟排序时取gap = 5,将距离为5的元素在一组内进行排序,数据共被分为5组,则第一组:9,4;第二组为1,8……以此类推(同色为一组),当最后一组排序完成时,第一趟排序结束;在第二趟排序时取gap = 2, 数据共被分为2组,重复组内插入排序操作;最后取gap = 1,所有元素均在同一组内进行插入排序操作。

可以发现:当gap取值为多少时,数据就可以被分为多少组,且每组有n/gap个数据。在gap逐渐减小的排序过程中,数据也在逐渐接近有序。事实上,希尔排序法是在直接插入排序法基础上优化而来的,希尔排序的每趟排序的组内排序进行的都是插入排序操作,只不过直接插入排序相当于只进行一趟gap = 1的排序操作。由上一小节中的分析可知,插入排序算法在数据越接近有序时,算法的时间效率越高。而希尔排序正是利用这一特性进行优化,从大间距排序逐步到间距为1的排序(直接插入排序),每一趟排序都会使数据变得更加有序,这样,可以有效提升对接近逆序或者逆序的数据进行排列的时间效率。

希尔排序算法的实现:

🍚 根据上述思想,我们可以先实现单趟排序中的单组排序如下(以第二趟排序(gap = 2)中的第一组排序为例):

int gap = 2;
for (int i = 0; i < n - gap; i += gap) {
	int end = i;  //已排序部分的末位置
	int temp = arr[end + gap]; //记录当前待插入元素
	while (end >= 0) {
		if (temp < arr[end]) {
			arr[end + gap] = arr[end];
			end -= gap;
		}
		else {
			break;
		}
	}
	arr[end + gap] = temp; //将元素插入
}

🍚🍚 而每趟需要进行gap组的排序,进而有单趟排序的实现如下:

int gap = 2;
//j控制排序组
for (int j = 0; j < gap; j++) {
	for (int i = j; i < n - gap; i += gap) {
		int end = i;  //已排序部分的末位置
		int temp = arr[end + gap]; //记录当前待插入元素
		while (end >= 0) {
			if (temp < arr[end]) {
				arr[end + gap] = arr[end];
				end -= gap;
			}
			else {
				break;
			}
		}
		arr[end + gap] = temp; //将元素插入
	}
}

到这或许会有人下意识的认为只是单趟排序都已经要三层循环了,这样算法的时间效率真的能提升吗?

其实算法时间复杂度的大小不能表面的从几层循环来判断,还需要根据情况具体分析。在这里,我们可以计算出,每趟排序中单组排序在最坏情况下需要进行比较的次数为1+2+...+ (n/gap - 1) = (n/gap)*(n/gap-1)/2 = (n - gap)*n / (2*gap^2),则每趟排序最坏需要进行比较的次数为(1+2+...+ (n/gap - 1)) * gap = n*(n/gap-1)/2 = (n - gap)*n / (2*gap)

当然,如果觉得这三层循环有些多了,其实单趟排序的算法实现还可以进行简化如下:

int gap = 2;
//相对于上一种写法,这里去掉了最外层循环,同时将原第二层循环中的 i += gap 修改为了 i++
//实现了单趟排序中的gap组数据并行排序
for (int i = 0; i < n - gap; i++) {
	int end = i;  //已排序部分的末位置
	int temp = arr[end + gap]; //记录当前待插入元素
	while (end >= 0) {
		if (temp < arr[end]) {
			arr[end + gap] = arr[end];
			end -= gap;
		}
		else {
			break;
		}
	}
	arr[end + gap] = temp; //将元素插入
}

希尔排序单趟gap组数据并行排序示意图:
希尔排序单趟gap组数据并行排序

🍚🍚🍚 希尔排序算法的整体实现:

了解了希尔排序的单趟排序的实现,接下来只需要再控制间距gap的变化即可完成整个希尔排序。根据希尔排序的基本思想,我们知道,gap应该是逐渐减小至1的,那gap具体是如何减小的呢?初始的gap值又应该取多大合适呢?

通常,我们将gap > 1时的排序都称为预排序gap = 1时的最后一次排序称为直接插入排序。在预排序时(以升序为例):
如果gap取值越大,则排序码较大的数据就会越快跳到后面,排序码较小的数据也会越快的跳到前面,但排序后的数据更不接近有序;如果gap取值越小,数据被排到相应位置的速度就越慢,但排序后的数据会更接近的有序。

常用的gap取值方法有两种:

① gap = gap / 2;
② gap = gap / 3 + 1;

需要注意的是:不能使用gap = gap / 3;的方法进行取值,这样无法保证最后一次gap能取到1。

以下是算法的整体实现:

//希尔排序
//gap > 1 预排序
//gap == 1 直接插入排序
void ShellSort(int* arr, int n) {
	int gap = n;
	while (gap > 1) {
		//gap = gap / 2;
		gap = gap / 3 + 1;
		for (int i = 0; i < n - gap; i++) {
			int end = i;
			int temp = arr[end + gap];
			while (end >= 0) {
				if (temp < arr[end]) {
					arr[end + gap] = arr[end];
					end -= gap;
				}
				else {
					break;
				}
			}
			arr[end + gap] = temp;
		}
	}
}

📜 希尔排序的特性总结:

  1. 希尔排序是对直接插入排序的优化。
  2. 当 gap > 1 时的排序都是预排序,目的是让数组更接近有序。当 gap == 1 时,数据已经接近有序了,这样再进行直接插入排序的速度就会很快。就整体而言,算法可以达到优化的效果。
  3. 希尔排序的时间复杂度不好计算,因为gap的取值方法有很多,导致了这很难去计算,也因此在一些书中给出的希尔排序的时间复杂度是不固定的,大致范围在 O ( N ∗ l o g 2 N ) \pmb{O(N*log_2N)} O(Nlog2N) ~ O ( N 2 ) \pmb{O(N^2)} O(N2)

    《数据结构(C语言版)》 — 严蔚敏
        希尔排序的分析是一个复杂的问题,因为它的时间是所取“增量”序列的函数。有人指出,当增量序列为 d l t a [ k ] = 2 t − k + 1 − 1 \pmb{dlta[k] = 2^{t-k+1} - 1} dlta[k]=2tk+11 时,希尔排序的时间复杂度为 O ( n 3 / 2 ) \pmb{O(n^{3/2})} O(n3/2),其中 t 为排序趟数, 1 ≤ k ≤ t ≤ ⌊ l o g 2 ( n + 1 ) ⌋ \pmb{1 \leq k \leq t \leq \lfloor log_2(n+1) \rfloor} 1ktlog2(n+1)⌋ 。还有人在大量的实验基础上推出:当 n 在某个特定范围内时,希尔排序所需的比较和移动次数约为 n 1.3 \pmb{n^{1.3}} n1.3 ,当 n → ∞ \pmb{n \rightarrow \infty} n ,可减少到 n ( l o g 2 n ) 2 \pmb{n(log_2n)^2} n(log2n)2增量序列可以有各种取法,但需注意:应使增量序列中的值没有除1之外的公因子,并且最后一个增量值必须等于1。

    《数据结构-用面向对象方法与C++描述》 — 殷人昆
        gap的取法有多种。最初shell提出取 g a p = ⌊ n / 2 ⌋ , g a p = ⌊ g a p / 2 ⌋ \pmb{gap = \lfloor n/2 \rfloor , gap = \lfloor gap/2 \rfloor } gap=n/2,gap=gap/2,直到 g a p = 1 \pmb{gap = 1} gap=1,后来Knuth提出取 g a p = ⌊ g a p / 3 ⌋ + 1 \pmb{gap = \lfloor gap/3 \rfloor + 1 } gap=gap/3+1。还有人提出都取奇数为好,也有人提出各 gap 互质为好。无论哪一种主张都没有得到证明。
        对希尔排序的时间复杂度的分析很困难,在特定情况下可以准确的估算关键码的比较次数和对象的移动次数,但想要弄清关键码比较次数和对象移动次数与增量选择之间的依赖关系,并给出完整的数学分析,还没有人能够做到。在Knuth所著的《计算机程序设计技巧》第3卷中,利用大量的实验统计资料得出,当 n 很大时,关键码平均比较次数和对象平均移动次数大约在 n 1.25 \pmb{n^{1.25}} n1.25 1.6 n 1.25 \pmb{1.6n^{1.25}} 1.6n1.25 范围内,这是在利用直接插入排序作为子序列排序方法的情况下得到的。
  4. 稳定性:由于在预排序时间距gap > 1,每次移动数据时都会跨越一部分数据,而不是每次只向相邻的位置移动,所以无法保证原序列中具有相同排序码的元素的相对位置不会因跨越移动而被改变,因而这种排序算法是不稳定的。

2.2 选择排序

基本思想: 每次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。

2.2.1 直接选择排序

直接选择排序示意图:

直接选择排序
如图所示:蓝色部分代表当前待排元素,红色部分代表当前选出的(这里是关键码最小的)元素,绿色部分代表当前遍历到的元素,橙色部分代表已排序好的元素,棕色部分代表当前待排集合中的第一个元素。


🍚 以升序为例,直接选择排序的基本步骤如下:

① 在元素集合 arr[i] - arr[n-1] 中选择关键码最大(小)的数据元素(此时 arr[i] 之前的元素处于已排序好的状态)
② 若选出的元素不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换
③ 在剩余的 arr[i] - arr[n-2](arr[i+1] - arr[n-1]) 元素集合中,重复上述步骤,直到集合中只剩一个元素

算法实现:

void Swap(int* p1, int* p2) {
	int temp = *p1;
	*p1 = *p2;
	*p2 = temp;
}

void SelectSort(int* arr, int n) {
	for (int i = 0; i < n - 1; i++) {
		int min = i;
		for (int j = i + 1; j < n; j++) {
			min = arr[min] < arr[j] ? min : j; //选出关键码最小的元素
		}
		Swap(&arr[i], &arr[min]); //将关键码最小的元素与组中第一个元素交换
	}
}

分析:用该算法对有 n 个数据的序列进行排序,第一趟选择需进行比较的次数为 n-1 ,第二趟选择需进行比较的次数为 n-2,……,最后一趟选择需进行比较的次数为 1 ,则总的比较次数为 n-1 + n-2 + ... + 1 = n*(n-1)/2 ,算法的时间复杂度为 O ( N 2 ) \pmb{O(N^{2})} O(N2)


🍚 此外,既然我们可以在一趟选择中选出关键码最大(最小)的元素,那是不是可以将关键码最小(最大)的元素也选出来呢,于是对于上述直接选择排序算法还可以进行改进如下:

① 在元素集合 arr[i] - arr[n-i-1]同时选出关键码最大和最小的数据元素(此时 arr[i] 之前的元素和 arr[n-i-1] 之后的元素均处于已排序好的状态)
② 将选出的关键码最小的元素与这组元素中的第一个元素交换。这里需要注意的是,因为选出的关键码最大的元素有可能是这组元素中的第一个元素,则在将选出的关键码最小的元素与第一个元素交换后,原来选出的关键码最大的元素可能就不再是在组中第一个位置上了,而被换到了选出的关键码最小的元素的位置上,因此,在第一次交换后,需要将原来选出的关键码最大的元素的位置上的元素与交换后原来选出的关键码最小的元素的位置上的元素进行比较,以更新关键码最大元素的位置(因为在每次选择后,记录的都是相应元素的下标位置),最后再将关键码最大的元素与这组元素中的最后一个元素交换。
③ 在剩余的 arr[i+1] - arr[n-i-2] 元素集合中,重复上述步骤,直到 i+1 ≥ n-i-2

改进后的算法实现如下:

void Swap(int* p1, int* p2) {
	int temp = *p1;
	*p1 = *p2;
	*p2 = temp;
}

void SelectSort(int* arr, int n) {
	int begin = 0; //记录当前排序元素集合的起始位置
	int end = n - 1; //记录当前排序元素集合的末尾位置
	while (begin < end) {
		int min = begin; //最小数的下标
		int max = begin; //最大数的下标
		for (int i = begin + 1; i <= end; i++) {
			min = arr[min] < arr[i] ? min : i;
			max = arr[max] > arr[i] ? max : i;
		}
		Swap(&arr[min], &arr[begin]); //将关键码最小的元素与排序集合中第一个元素交换
		max = arr[max] > arr[min] ? max : min; //更新关键码最大的元素的位置
		Swap(&arr[max], &arr[end]); //将关键码最大的元素与排序集合中最后一个元素交换
		begin++; //更新排序集合起始位置
		end--; //更新排序集合末尾位置
	}
}

分析:用改进后的算法对有 n 个数据的序列进行排序,单考虑选出关键码最小(最大)的元素,第一趟选择需进行比较的次数为 n-1 ,第二趟选择需进行比较的次数为 n-3,……,最后一趟选择需进行比较的次数:n 为偶数时是 1;n 为奇数时是 2,一共需进行的选择趟数: ⌊ n / 2 ⌋ \pmb{\lfloor n/2 \rfloor} n/2,则总的比较次数:n 为偶数: n-1 + n-3 + ... + 1 = n*n/4 ;n 为奇数:n-1 + n-3 + ... + 2 = (n+1)*(n-1)/4 ,算法的时间复杂度为 O ( N 2 ) \pmb{O(N^{2})} O(N2) 。事实上,虽说有一定的改进,但改进前后算法的时间复杂度的量级并没有改变。总的来说,直接选择排序算法的时间效率不高。


📜 直接选择排序的特性总结:

  1. 与直接插入排序算法相比,直接插入排序算法要好一些,直接插入排序的适应性更强,对于有序或局部有序的数据,在时间效率上都能有所提升;而直接选择排序算法在任何情况下(不论数据是有序或无序),其时间复杂度都是 O ( N 2 ) \pmb{O(N^{2})} O(N2)
  2. 时间复杂度: O ( N 2 ) \pmb{O(N^{2})} O(N2)
  3. 空间复杂度: O ( 1 ) \pmb{O(1)} O(1)
  4. 稳定性:由于在每次交换过程中都可能会发生数据跨越,所以无法保证原序列中具有相同关键码的元素的相对位置不会因跨越移动而被改变,因而这种排序算法是不稳定的。
    例如:
    对原序列:4 2 7 3 4 1 进行直接选择排序,此时 arr[0] = arr[4] = 4
    第一趟选出最小的元素 arr[5] = 1,将其与第一个元素 arr[0] = 4 进行交换得到新序列: 1 2 7 3 4 4
    此时,原序列中位于下标 0 处的关键码值为 4 的元素被换到了下标为 5 的位置上(在下标为 4 的关键码值为 4 的元素之后),导致原序列中两个关键码值均为 4 的元素的相对位置发生了改变。即算法不稳定。

2.2.2 堆排序

堆排序是指利用堆积树(堆)这种数据结构所设计的一种排序算法,是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。


🍚这里简要的介绍一下堆的概念,具体实现及其它应用就不在此讲解。

堆是一种二叉树,这里所说的堆与操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。

概念:
如果有一个关键码的集合 K = k 0 , k 1 , k 2 , ⋯   , k n − 1 \pmb{K = {k_0, k_1, k2,\cdots,k_{n-1}}} K=k0,k1,k2,,kn1 ,把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足: K i ≤ K 2 ∗ i + 1 \pmb{K_i \leq K_{2*i+1}} KiK2i+1 K i ≤ K 2 ∗ i + 2 \pmb{K_i \leq K_{2*i+2}} KiK2i+2 K i ≥ K 2 ∗ i + 1 \pmb{K_i \geq K_{2*i+1}} KiK2i+1 K i ≥ K 2 ∗ i + 2 \pmb{K_i \geq K_{2*i+2}} KiK2i+2); i = 0 , 1 , 2 ⋯   , \pmb{i = 0,1,2\cdots,} i=0,1,2, 则称为小堆(大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

堆的性质:
①堆中某个节点的值总是不大于或不小于其父节点的值;
②堆总是一颗完全二叉树。

堆的结构:
堆结构


堆中父节点与子节点下标的关系:
左孩子节点下标(奇数): l e f t c h i l d = p a r e n t ∗ 2 + 1 \pmb{leftchild = parent*2 + 1} leftchild=parent2+1
右孩子节点下标(偶数): r i g h t c h i l d = p a r e n t ∗ 2 + 2 \pmb{rightchild = parent*2 + 2} rightchild=parent2+2
父节点下标: p a r e n t = ( c h i l d − 1 ) / 2 \pmb{parent = (child-1) / 2} parent=(child1)/2


🍚 堆排序的实现

以数据(int型数组) a r r [ ] = { 17 , 16 , 3 , 20 , 5 , 4 } \pmb{arr[{\kern 2pt}] = \{17,16,3,20,5,4\}} arr[]={17,16,3,20,5,4} 为例(排升序):

(1)要使用堆将数据升序排列,首先需要建立一个大堆

① 堆的向下调整算法:

当一棵不是堆的完全二叉树的左右子树都是堆时,可以通过从根节点开始的向下调整算法将其调整成为堆:从根节点开始,将根节点与其孩子节点进行比较,如果子节点中的关键码比父节点中的关键码大(小),就将父节点与左右子节点中较大(小)的一个进行交换;接着将被用于交换的较大(小)的那个孩子节点原所在位置上的节点作为父节点继续向下调整(重复上述步骤),直到当前父节点是叶子节点或当前父节点的关键码大于左右子节点的关键码时。下图所示为堆向下调整示意图(以调整成大堆为例)。

堆的向下调整

堆的向下调整算法有一个前提:左右子树必须是一个堆才能调整

堆的向下调整算法的实现:

void Swap(int* p1, int* p2) {
	int temp = *p1;
	*p1 = *p2;
	*p2 = temp;
}

//堆的向下调整(以大堆调整为例)
//参数n为数组长度(树的总节点数),参数parent为需进行向下调整的树的根节点下标
void AdjustDown(int* arr, int n, int parent) {
	int maxChild = parent * 2 + 1; //表示左右子节点中关键码较大的一个节点的下标(初始时为左节点)
	//当当前父节点的(左)子节点的下标超出数组下标范围时,跳出循环,调整结束
	while (maxChild < n) {
		if (maxChild + 1 < n && arr[maxChild + 1] > arr[maxChild]) {
			maxChild++; //如果右子节点的关键码比左子节点的大,更新下标
		}
		//子节点的关键码比父节点的大时交换
		if (arr[maxChild] > arr[parent]) {
			Swap(&arr[parent], &arr[maxChild]);
			parent = maxChild; //更新父节点下标
			maxChild = parent * 2 + 1; //更新子节点下标
		}
		else {
			break; //当前父节点的关键码大于左右子节点的关键码,跳出循环
		}
	}
}

② 建大堆:

根据所给数组( a r r [ ] = { 17 , 16 , 3 , 20 , 5 , 4 } \pmb{arr[{\kern 2pt}] = \{17,16,3,20,5,4\}} arr[]={17,16,3,20,5,4} ),我们可以将其在逻辑上看作一棵完全二叉树(如下图所示),但这还不是一个堆,且根节点的左右子树也不是堆,不能单通过向下调整根节点而得到大堆。因此,我们需要通过堆的向下调整算法从倒数第一个非叶子节点的子树开始调整,一直调整到根节点的树,将这棵完全二叉树调整成一个大堆。这样是为了确保每次所进行调整的树的左右子树都是一个堆。

对于顺序存储的完全二叉树,其倒数第一个非叶子节点是最后一个节点的父节点,假设用于存储的数组的长度为 l e n g t h \pmb{length} length,则这棵完全二叉树的最后一个节点的下标为: l e n g t h − 1 \pmb{length - 1} length1,则其父节点的下标为: ( l e n g t h − 1 − 1 ) / 2 \pmb{(length - 1 - 1) / 2} (length11)/2

如下是建大堆过程示意图:

堆排序(建大堆)

建堆算法实现:

void Swap(int* p1, int* p2) {
	int temp = *p1;
	*p1 = *p2;
	*p2 = temp;
}

//堆的向下调整(以大堆调整为例)
//参数n为数组长度(树的总节点数),参数parent为需进行向下调整的树的根节点下标
void AdjustDown(int* arr, int n, int parent) {
	int maxChild = parent * 2 + 1; //表示左右子节点中关键码较大的一个节点的下标(初始时为左节点)
	//当当前父节点的(左)子节点的下标超出数组下标范围时,跳出循环,调整结束
	while (maxChild < n) {
		if (maxChild + 1 < n && arr[maxChild + 1] > arr[maxChild]) {
			maxChild++; //如果右子节点的关键码比左子节点的大,更新下标
		}
		//子节点的关键码比父节点的大时交换
		if (arr[maxChild] > arr[parent]) {
			Swap(&arr[parent], &arr[maxChild]);
			parent = maxChild; //更新父节点下标
			maxChild = parent * 2 + 1; //更新子节点下标
		}
		else {
			break; //当前父节点的关键码大于左右子节点的关键码,跳出循环
		}
	}
}

//建大堆
void CreateHeap(int* arr, int n) {
	int k = (n - 1 - 1) / 2; //倒数第一个非叶子节点下标
	//当调整完根节点的树后退出循环,大堆建成
	while (k >= 0) {
		AdjustDown(arr, n, k--);
	}
}

建堆的时间复杂度:

因为堆是完全二叉树,而满二叉树也是完全二叉树,因此,此处为了简化,使用满二叉树来分析建堆的时间复杂度(多几个节点不会影响其时间复杂度的量级,且我们关注的也通常是近似值)

满二叉树

如图所示为满二叉树逻辑结构图(假设树的高度为h),根据建堆的基本思想,建堆需要从树的倒数第一个非叶子节点的子树开始进行向下调整,直到调整到根节点的树。对于完全二叉树,倒数第一个非叶子节点即为树的倒数第二层(第 h-1 层)的最后一个节点,则从这个节点的子树开始进行向下调整,最坏的情况下:每次调整都需要将当前子树的根节点调整到树的最后一层,且在同一层的节点所需调整的次数相同。则从第 h-1 层的节点开始向下调整有:

第 h-1 层: 2 h − 2 \pmb{2^{h-2}} 2h2 个节点,需向下移动 1 层
第 h-2 层: 2 h − 3 \pmb{2^{h-3}} 2h3 个节点,需向下移动 2 层
……

第 4 层: 2 3 \pmb{2^{3}} 23 个节点,需向下移动 h-4 层
第 3 层: 2 2 \pmb{2^{2}} 22 个节点,需向下移动 h-3 层
第 2 层: 2 1 \pmb{2^{1}} 21 个节点,需向下移动 h-2 层
第 1 层: 2 0 \pmb{2^{0}} 20 个节点,需向下移动 h-1 层

则需要移动的节点的总的移动步数为:

T ( n ) = 2 h − 2 ∗ 1 + 2 h − 3 ∗ 2 + ⋯ + 2 3 ∗ ( h − 4 ) + 2 2 ∗ ( h − 3 ) + 2 1 ∗ ( h − 2 ) + 2 0 ∗ ( h − 1 ) ① \pmb{T(n) = 2^{h-2} * 1 + 2^{h-3} * 2 + \cdots + 2^3 * (h-4) + 2^2 * (h-3) + 2^1 * (h-2) + 2^0 * (h-1)} {\kern 30pt}① T(n)=2h21+2h32++23(h4)+22(h3)+21(h2)+20(h1)

2 ∗ T ( n ) = 2 h − 1 ∗ 1 + 2 h − 2 ∗ 2 + ⋯ + 2 4 ∗ ( h − 4 ) + 2 3 ∗ ( h − 3 ) + 2 2 ∗ ( h − 2 ) + 2 1 ∗ ( h − 1 ) ② \pmb{2*T(n) = 2^{h-1} * 1 + 2^{h-2} * 2 + \cdots + 2^4 * (h-4) + 2^3 * (h-3) + 2^2 * (h-2) + 2^1 * (h-1)} {\kern 30pt}② 2T(n)=2h11+2h22++24(h4)+23(h3)+22(h2)+21(h1)

由② - ①(错位相减)可得:

T ( n ) = 2 h − 1 + 2 h − 2 + ⋯ + 2 4 + 2 3 + 2 2 + 2 1 − h + 1 \pmb{T(n) = 2^{h-1} + 2^{h-2} + \cdots + 2^4 + 2^3 + 2^2 + 2^1 - h + 1} T(n)=2h1+2h2++24+23+22+21h+1

T ( n ) = 2 0 + 2 1 + 2 2 + 2 3 + 2 4 + ⋯ + 2 h − 2 + 2 h − 1 − h \pmb{T(n) = 2^0 + 2^1 + 2^2 + 2^3 + 2^4 + \cdots + 2^{h-2} + 2^{h-1} - h} T(n)=20+21+22+23+24++2h2+2h1h

T ( n ) = 2 h − 1 − h \pmb{T(n) = 2^h - 1 - h} T(n)=2h1h

树的节点总数 n 为: n = 2 h − 1 \pmb{n = 2^h - 1} {\kern 30pt} n=2h1 即树的高度 h 为: h = l o g 2 ( n + 1 ) \pmb{h = log_2(n + 1)} h=log2(n+1)

则: T ( n ) = n − l o g 2 ( n + 1 ) ≈ n \pmb{T(n) = n - log_2(n + 1) \approx n} T(n)=nlog2(n+1)n

即建堆的时间复杂度为: O ( N ) \pmb{O(N)} O(N)


(2)建成大堆后,接下来就可以进行堆排序了

根据大堆的性质可以知道:大堆的根节点是树中关键码最大的节点。基于此,我们每次将当前大堆的根节点与其最后一个节点进行交换使得关键码最大的节点放在最后(也就是放在数组中的最后一个位置),接着将除当前堆的最后一个节点的其它节点通过根节点的向下调整重新建堆,然后重复上述步骤,直到当前堆中只剩一个节点(根节点),升序排序完成。(简而言之,就是通过向下调整算法建大堆每次选出当前集合中的最大元素并将其放在某尾,直至当前集合中只剩一个元素。)

以下是堆排序过程示意图:

堆排序
堆排序整体算法实现:

void Swap(int* p1, int* p2) {
	int temp = *p1;
	*p1 = *p2;
	*p2 = temp;
}

//堆的向下调整(以大堆调整为例)
//参数n为数组长度(树的总节点数),参数parent为需进行向下调整的树的根节点下标
void AdjustDown(int* arr, int n, int parent) {
	int maxChild = parent * 2 + 1; //表示左右子节点中关键码较大的一个节点的下标(初始时为左节点)
	//当当前父节点的(左)子节点的下标超出数组下标范围时,跳出循环,调整结束
	while (maxChild < n) {
		if (maxChild + 1 < n && arr[maxChild + 1] > arr[maxChild]) {
			maxChild++; //如果右子节点的关键码比左子节点的大,更新下标
		}
		//子节点的关键码比父节点的大时交换
		if (arr[maxChild] > arr[parent]) {
			Swap(&arr[parent], &arr[maxChild]);
			parent = maxChild; //更新父节点下标
			maxChild = parent * 2 + 1; //更新子节点下标
		}
		else {
			break; //当前父节点的关键码大于左右子节点的关键码,跳出循环
		}
	}
}

//堆排序
void CreateHeap(int* arr, int n) {
	int k = (n - 1 - 1) / 2; //倒数第一个非叶子节点下标
	//建大堆
	//当调整完根节点的树后退出循环,大堆建成
	while (k >= 0) {
		AdjustDown(arr, n, k--);
	}
	int i = n - 1; //当前堆中最后一个节点下标
	//当当前堆中只剩一个节点(即最后一个节点的下标为根节点下标 0)时,结束循环,排序完成
	while (i > 0) {
		Swap(&arr[0], &arr[i]); //交换当前堆中的根节点与最后一个节点
		AdjustDown(arr, i--, 0); //从根节点开始向下调整,更新堆
	}
}

堆排序的时间复杂度:

如上我们已经分析出了建堆的时间复杂度,这里我们只需要再分析建好堆后的排序过程的时间复杂度即可。同样,使用满二叉树来分析排序过程的时间复杂度。根据堆排序过程:将当前堆的堆顶与堆尾进行交换,然后将根节点进行向下调整形成新堆,重复步骤直到当前堆中只剩一个节点(根节点)。则最坏情况下:每次交换后,都需要将根节点向下调整到当前树的最后一层,且每层有多少个节点就需进行多少次交换,同层节点交换后根节点向下调整的次数相同。同样假设树的高度为h,则有:

第 h 层: 2 h − 1 \pmb{2^{h-1}} 2h1 个节点,交换后根节点需向下移动 h-1 层
第 h-1 层: 2 h − 2 \pmb{2^{h-2}} 2h2 个节点,交换后根节点需向下移动 h-2 层
……

第 4 层: 2 3 \pmb{2^{3}} 23 个节点,交换后根节点需向下移动 3 层
第 3 层: 2 2 \pmb{2^{2}} 22 个节点,交换后根节点需向下移动 2 层
第 2 层: 2 1 \pmb{2^{1}} 21 个节点,交换后根节点需向下移动 1 层

则建堆后排序过程需要移动的节点的总的移动步数为:

T ( n ) = 2 h − 1 ∗ ( h − 1 ) + 2 h − 2 ∗ ( h − 2 ) + ⋯ + 2 3 ∗ 3 + 2 2 ∗ 2 + 2 1 ∗ 1 ① \pmb{T(n) = 2^{h-1} * (h-1) + 2^{h-2} * (h-2) + \cdots + 2^3 * 3 + 2^2 * 2 + 2^1 * 1} {\kern 30pt}① T(n)=2h1(h1)+2h2(h2)++233+222+211

2 ∗ T ( n ) = 2 h ∗ ( h − 1 ) + 2 h − 1 ∗ ( h − 2 ) + ⋯ + 2 4 ∗ 3 + 2 3 ∗ 2 + 2 2 ∗ 1 ② \pmb{2 * T(n) = 2^{h} * (h-1) + 2^{h-1} * (h-2) + \cdots + 2^4 * 3 + 2^3 * 2 + 2^2 * 1} {\kern 30pt}② 2T(n)=2h(h1)+2h1(h2)++243+232+221

由② - ①(错位相减)可得:

T ( n ) = 2 h ∗ ( h − 1 ) − ( 2 h − 1 + ⋯ + 2 4 + 2 3 + 2 2 + 2 1 ) \pmb{T(n) = 2^{h} * (h-1) - (2^{h-1} + \cdots + 2^4 + 2^3 + 2^2 + 2^1)} T(n)=2h(h1)(2h1++24+23+22+21)

T ( n ) = 2 h ∗ h − ( 2 h + 2 h − 1 + ⋯ + 2 4 + 2 3 + 2 2 + 2 1 ) \pmb{T(n) = 2^{h} * h - (2^h + 2^{h-1} + \cdots + 2^4 + 2^3 + 2^2 + 2^1)} T(n)=2hh(2h+2h1++24+23+22+21)

T ( n ) = 2 h ∗ h − ( 2 1 + 2 2 + 2 3 + 2 4 + ⋯ + 2 h − 1 + 2 h ) \pmb{T(n) = 2^{h} * h - (2^1 + 2^2 + 2^3 + 2^4 + \cdots + 2^{h-1} + 2^h)} T(n)=2hh(21+22+23+24++2h1+2h)

T ( n ) = 2 h ∗ h − ( 2 h + 1 − 2 ) \pmb{T(n) = 2^{h} * h - (2^{h+1} - 2)} T(n)=2hh(2h+12)

T ( n ) = 2 h ∗ h − 2 h + 1 + 2 \pmb{T(n) = 2^{h} * h - 2^{h+1} + 2} T(n)=2hh2h+1+2

树的节点总数 n 为: n = 2 h − 1 \pmb{n = 2^h - 1} {\kern 30pt} n=2h1 即树的高度 h 为: h = l o g 2 ( n + 1 ) \pmb{h = log_2(n + 1)} h=log2(n+1)

则: T ( n ) = ( n − 1 ) ∗ l o g 2 ( n + 1 ) − 2 ∗ n + 4 \pmb{T(n) = (n-1)* log_2(n + 1) - 2*n + 4} T(n)=(n1)log2(n+1)2n+4

再将得出的结果与建堆的总移动步数相加有:

( n − 1 ) ∗ l o g 2 ( n + 1 ) − 2 ∗ n + 4 + n − l o g 2 ( n + 1 ) = ( n − 2 ) ∗ l o g 2 ( n + 1 ) − n + 4 ≈ n ∗ l o g 2 n \pmb{(n-1)* log_2(n + 1) - 2*n + 4 + n - log_2(n + 1) = (n-2)* log_2(n + 1) - n + 4 \approx n * log_2n} (n1)log2(n+1)2n+4+nlog2(n+1)=(n2)log2(n+1)n+4nlog2n

即堆排序的时间复杂度为: O ( N ∗ l o g 2 N ) \pmb{O(N*log_2N)} O(Nlog2N)


📜 堆排序的特性总结:

  1. 使用堆来选数比直接选数效率要高
  2. 时间复杂度: O ( N ∗ l o g 2 N ) \pmb{O(N*log_2N)} O(Nlog2N)
  3. 空间复杂度: O ( 1 ) \pmb{O(1)} O(1)
  4. 稳定性:由于在排序过程中的交换操作会导致数据间的位置跨越,所以无法保证原序列中具有相同排序码的元素的相对位置不会因跨越移动而被改变,因而这种排序算法是不稳定的。

2.3 交换排序

基本思想: 所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置。
交换排序的特点是: 将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。

2.3.1 冒泡排序

冒泡排序的示意图:

冒泡排序

如图所示:蓝色部分代表当前待排元素,绿色部分表示当前比较的两个元素,如果前一个元素的关键码比后一个元素的大,则交换两个元素;橙色部分表示已排好序的元素。

(以升序为例)冒泡排序的基本步骤:

① 假设待排集合中共有 n 个元素,则在元素集合 arr[0] - arr[n-1] 中,从元素 arr[0] 开始,逐次向后进行两两比较:比较 arr[0] 与 arr[1] 的关键码,如果 arr[0] 的关键码大于 arr[1] 的关键码,则交换两个元素的位置,接着比较 arr[1] 与 arr[2] 的关键码……直到后一个元素为当前集合中的最后一个元素 arr[n-1] 。
② 在剩余的 arr[0] - arr[n-2] 元素集合中,重复上述步骤,直到集合中只剩一个元素。

冒泡排序的算法实现:

void BubbleSort(int* arr, int n) {
	int i = 0;
	int j = 0;
	for (i = 0; i < n - 1; i++) {
		for (j = 0; j < n - i - 1; j++) {
			if (arr[j] > arr[j + 1]) {
				Swap(&arr[j], &arr[j + 1]); //如果前一个元素比后一个元素的关键码大则交换两个元素
			}
		}
	}
}

分析:以升序排列为例,每完成一趟冒泡,就会将当前待排集合中最大的元素逐渐交换到当前待排集合中的最后一个位置,假设初始集合中共有 n 个待排元素,则第一趟冒泡需要进行比较的次数为:n-1 次,第二趟:n-2 次,……,最后一趟 1 次,一共需进行 n-1 趟冒泡。总的比较次数为:n-1 + n-2 + … + 2 + 1 = n*(n-1) / 2,即冒泡排序算法的时间复杂度为: O ( N 2 ) \pmb{O(N^2)} O(N2)


此外,可以发现,当数据局部有序或者有序的情况下,可能在排序到中间某一趟时,数据就已经完全有序了,此时其实就可以不必在接着比较下去了,因此,我们可将算法进行如下改进:

void BubbleSort(int* arr, int n) {
	int i = 0;
	int j = 0;
	for (i = 0; i < n - 1; i++) {
		int flag = 0; //记录每趟排序过程中是否发生了交换
		for (j = 0; j < n - i - 1; j++) {
			if (arr[j] > arr[j + 1]) {
				Swap(&arr[j], &arr[j + 1]); //如果前一个元素比后一个元素的关键码大则交换两个元素
				if (!flag) {
					flag = 1; //如果交换了就将标志置1
				}
			}
		}
		// 判断:如果在某趟排序中没有进行过一次交换,则说明数据已经完全有序,此时结束排序
		if (!flag) {
			break;
		}
	}
}

需要说明的是:改进后的算法时间复杂度并没有改变,因为在计算时间复杂度时我们关注的是排序中最坏的情况,若是数据是逆序的,此时算法改进与否其实并无差别,只是改进后的算法如果在数据是有序或者接近有序的情况下会相对有时间效率上的提升。


📜 冒泡排序的特性总结:

  1. 时间复杂度: O ( N 2 ) \pmb{O(N^2)} O(N2)
  2. 空间复杂度: O ( 1 ) \pmb{O(1)} O(1)
  3. 稳定性:由于在冒泡排序过程中元素每次移动总是在相邻的位置上,没有发生元素间的位置跨越,原序列中具有相同排序码的元素的相对位置不会被改变,因此该算法是 稳定 的。

2.3.2 快速排序

快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方式,其基本思想为:任取待排元素序列中的某元素作为基准值,按照该排序码将待排序集合分隔成两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后对左右子序列重复上述过程,直到所有元素都排列在相应位置上为止。

快速排序可以用递归实现,也可以用非递归实现,从思想上看,二者的分别在于采用什么方式实现划分后左右子序列的重复过程排序。快速排序递归实现的主框架与二叉树前序遍历规则很像,而非递归实现需要借助栈或队列来完成。

那么先不考虑是递归实现还是非递归实现,根据快速排序的思想,快速排序的过程就是不断按照当前基准值将当前待排序列划分为左右子序列,直到所有元素都排列在相应位置上的过程,因而我们首先要考虑的是:如何按照基准值划分出左右子序列,或者说,如何实现单趟排序过程。

2.3.2.1 快排单趟排序的实现

(单趟排序)将区间按照基准值划分为左右两部分的常见方式有:

1. Hoare法

Hoare法单趟排序示意图(以升序为例):

Hoare法单趟排序

如图所示:先选取一个基准值,记录其位置为 key (这里是以最左端起始位置的值作为基准值)。此时,一方(R)从右边(末尾)向左(前)找起,找到比 key 位置上的值小的值的位置则停下,接着另一方(L)从左边(起始位置)向右(后)找,找到比 key 位置上的值大的值的位置则停下,交换左右停留位置上的值;从停留位置继续寻找,重复上述过程(每次均是先从右边向左边找),直到两方相遇,将相遇位置上的值与 key 位置上的值交换,同时更新 key 所记录的位置为相遇位置,至此单趟排序完成。

可以发现:单趟排序后,原来 key 位置上的值会被交换到这个值在所有数据完全有序后所在的位置(最终正确位置,也是单趟排序最后两方相遇的位置),且比 key 位置上的值小的值都会在 key 的左边,比 key 位置上的值大的值都会在 key 的右边,即数据经过单趟排序后按照基准值被分为了左右两部分。

此外,我们注意到:在单趟排序的过程中,当以待排序列起始位置(最左边)为 key 时,应当总是让右方(R)先向左边走(找小),再让左方(L)向右边走(找大)。原因是:为了确保两方相遇位置上的值要小于或等于 key 位置上的值。
事实上,两方相遇无非两种情况:一种是 R 是停住的,L 遇到 R ,相遇位置就是 R 停住的位置;一种是 L 是停住的,R 遇到 L,相遇位置就是 L 停住的位置。如果 R 是停住的,由 L 遇到 R ,则 R 位置上的值一定是小于 key 位置上的值的(这也是 R 停住的条件);如果 L 是停住的,R 遇到 L,若相遇之前存在交换操作,则此时 L 位置上的值是经过交换操作后小于 key 位置上的值的值,若相遇之前没经过任何一次交换操作(key 位置之后所有的值都比 key 位置上的值要大,致使 R 一直走到 key 位置与未曾走过一步仍停留在起始位置的 L 相遇才停下),此时 L 位置上的值就是 key 位置上的值。

而如果总是让左方(L)先走,则最终两方相遇位置上的值可能大于 key 位置上的值,这与我们预期的结果相反(如下示例所示)。如果要使结果正确,此时需要在最后判断两方相遇位置上的值是否小于或等于 key 位置上的值,如果是则交换(一般是key 之后的值都小于或等于 key 位置上的值的情况),如果否,则将 key 位置上的值与两方相遇位置的前一个位置上的值交换,同时更新 key 记录的位置。虽然最后也可以得到正确结果,但显然这种方式更繁琐一些。

左方先走情况

总结:在Hoare法单趟排序过程中,一般选取最左边或是最右边位置为 key (当然这不是必须的,只是这样设置更方便进行控制,事实上快速排序并没有规定单趟排序应该如何实现,只指明了单趟排序后应当使基准值左边的值都比基准值小,右边的值都比基准值大)。若以起始位置(左边)为 key,最好让右方先走;若以末尾位置(右边)为 key,最好让左方先走。

Hoare法单趟排序算法实现(以升序为例):

int PartSort1(int* arr, int begin, int end) {
	int key = begin; //以起始位置为key
	int left = begin;
	int right = end;
	while (left < right) {
		//右方先走,找到比arr[key]小的值停下
		if (arr[right] >= arr[key]) {
			right--;
			continue;
		}
		//左方再走,找到比arr[key]大的值停下
		if (arr[left] <= arr[key]) {
			left++;
			continue;
		}
		//如果两方都是停下的,交换两个元素
		Swap(&arr[left], &arr[right]);
	}
	Swap(&arr[left], &arr[key]); //相遇,交换相遇位置上的值与key位置上的值
	key = left; //更新key位置
	return key;
}

2. 挖坑法

挖坑法单趟排序示意图(以升序为例):

挖坑法快排单趟

如图所示,挖坑法的单趟排序也需要先选出一个基准值(通常也是选取第一个值或最后一个值作为基准值),并将基准值存入临时变量key中(与Hoare法中的key不同,Hoare法中key记录的是基准值所在的位置,挖坑法中key记录的是基准值),同时将基准值所在位置视为初始坑位。可以看到,在排序过程中始终会存在一个坑位(某一方所在的位置),每次有值填入坑位后都会形成新的坑位。采用挖坑法的思想进行单趟排序时遵循的是,哪一方所处位置为坑位,就让另一方先走。

挖坑法单趟排序算法实现(以升序为例):

// 快速排序挖坑法
//单趟排序
int PartSort2(int* arr, int begin, int end) {
	int key = arr[begin]; //将第一个数据存放在临时变量key中
	int left = begin;
	int right = end;
	int hole = begin; //初始坑位为起始位置
	while (left < right) {
		//相遇或找到比key小的值停下
		while (left < right && arr[right] >= key) {
			right--;
		}
		arr[hole] = arr[right]; //将找到的值填入坑位
		hole = right; //更新坑位
		
		//相遇或找到比key大的值停下
		while (left < right && arr[left] <= key) {
			left++;
		}
		arr[hole] = arr[left]; //将找到的值填入坑位
		hole = left; //更新坑位
	}
	arr[hole] = key; //将存放好的key值放入坑位
	return hole; //返回坑位
}

3. 前后指针法

前后指针法单趟排序示意图(以升序为例):

前后指针法快排单趟

如图所示:同样,先选取一个基准值,并将基准值所在位置记录在key中。初始设置两个指针,前指针prev用于记录前一个位置,后指针cur用于记录后一个位置,由cur指针先向后寻找,当找到值比key位置上的值小的位置,就让prev后移一位,再交换prev和cur位置上的内容。初始时,prev指向的位置即为基准值所在位置,此时prev位置上的内容与基准值相等,而cur指向的位置在prev指向位置的后一位,此后,只有当cur指向的位置上的内容小于基准值时,prev指针才会向后移动,而同时,cur指针找到的这个比基准值小的值又会被换到prev所指向的位置。可以看到,除初始基准值所在位置外,prev所指向的位置的内容及其走过的位置上的内容永远是小于基准值的,而prev指向位置与cur指向位置之间的数据永远是大于或等于基准值的。最后,当cur指向位置超出序列边界时,即表示当前prev指向位置上的内容即为最后一个小于基准值的数据,此时再将基准值与prev指向位置上的内容交换,并将交换后基准值所在位置更新记录到key中,就可使key左边的值(prev走过路径上的值)都比基准值小,右边的值(prev与cur之间的值)都比基准值大。

前后指针法单趟排序算法实现:

// 前后指针法
int PartSort3(int* arr, int begin, int end) {
	int key = begin; //记录基准值所在位置
	int prev = begin; //记录前一个位置
	int cur = begin + 1; //记录后一个位置
	while (cur <= end) {
		//如果后一个位置的值比基准值小,则先将前一个位置后移一位,再交换前后位置的内容,当前后位置相同时可不做交换操作
		if (arr[cur] < arr[key] && ++prev != cur) {
			Swap(&arr[prev], &arr[cur]);
		}
		cur++;
	}
	Swap(&arr[prev], &arr[key]); //交换前一个位置与key位置上的内容
	key = prev; //更新基准值所在位置
	return key;
}

单趟排序的时间复杂度: 不论是那种排序方法,都会将待排序列中的所有元素遍历一遍,虽然有的算法实现中用了多层循环,但从基本思想去分析算法也可以发现,单趟排序只需要将待排元素遍历一遍即结束循环,因此三种单趟排序算法的时间复杂度都是 O ( N ) \pmb{O(N)} O(N)


2.3.2.2 快速排序的递归实现

快速排序递归实现的主框架:

// 假设按照升序对arr数组中[begin, end]区间中的元素进行排序
void QuickSort(int* arr, int begin, int end)
{
{\kern 16pt} if(begin >= end)
{\kern 16pt} return;

{\kern 16pt} // (单趟排序)按照基准值对arr数组的 [begin, end]区间中的元素进行划分
{\kern 16pt} int key = PartSort(arr, begin, end);

{\kern 16pt} // 划分成功后以key为边界形成了左右两部分 [begin, key - 1] 和 [key+1, end]
{\kern 16pt} // 递归排[begin, key - 1]
{\kern 16pt} QuickSort(arr, begin, key - 1);

{\kern 16pt} // 递归排[key+1, end]
{\kern 16pt} QuickSort(arr, key+1, end);
}

如上,我们通过单趟排序将待排序列按照基准值分为了左右两部分,接着只需要通过对左右两部分序列再递归排序即可。

将单趟排序算法带入主框架有:

//快速排序递归实现
void QuickSort(int* arr, int begin, int end) {
	//当序列为空或者只有一个元素时,则表示序列已经有序,不再递归排序
	if (begin >= end) {
		return;
	}
	else {
		//int key = PartSort1(arr, begin, end); //Hoare法
		//int key = PartSort2(arr, begin, end); //挖坑法
		int key = PartSort3(arr, begin, end); //前后指针法
		QuickSort(arr, begin, key - 1); //左部分序列递归排序
		QuickSort(arr, key + 1, end); //右部分序列递归排序
	}	
}

2.3.2.3 快速排序的非递归实现

快速排序过程其实就是通过单趟排序不断划分区间的过程,从上述递归实现算法中可以看到,要对划分的序列再次进行单趟排序的关键在于把握左右区间的边界,如果能知道各子区间的边界,就可以根据区间边界对相应的区间进行单趟排序。对于递归实现的快速排序,相当于把每个待排子序列边界记录在了每次递归调用的函数。对于非递归实现的快速排序,则可以通过栈或者队列来保存每个待排子序列的区间边界。

快速排序非递归实现过程逻辑示意图(借助栈):

非递归快排
如图所示:红色部分代表当前排序序列,橙色部分代表已排序序列。借助栈实现的快排非递归过程在逻辑上类似于对二叉树的前序遍历过程(深度优先遍历),这与快排递归实现在本质上是相同的。初始时,先将初始序列的左右边界入栈,接着进入循环排序过程,每次出栈一个序列的左右边界,同时根据出栈的边界对相应序列进行单趟排序,每次排序后又会生成新的左右子序列,如果序列中的数据量大于1(如果数据量小于等于1,则表示序列已经有序,不需要再记录其边界进行排序),则将序列的左右边界入栈,根据栈先入后出的性质,应先将右子序列的边界入栈,再将左子序列的边界入栈。当栈空时,表示没有待排序列了,即整个排序完成。

快速排序非递归算法实现(借助栈):

① 栈实现:

Stack.h

#define _CRT_SECURE_NO_WARNINGS 1
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

typedef int SDataType;
//支持动态增长的栈
typedef struct Stack {
	SDataType* data;
	int capacity;
	int top;  //栈顶位置,空栈时为-1
}Stack;

//栈初始化
void StackInit(Stack* stack);

//栈打印
void StackPrint(Stack* stack);

//检查栈的容量并扩容
int CheckStackCapacity(Stack* stack);

//检查栈是否为空,空返回0,否则返回非0
int StackIsEmpty(Stack* stack);

//入栈
void StackPush(Stack* stack, SDataType data);

//出栈
void StackPop(Stack* stack);

//获取栈顶元素
SDataType StackTop(Stack* stack);

//获取栈中有效元素个数
int StackSize(Stack* stack);

//栈销毁
void StackDestroy(Stack* stack);

Stack.c

#define _CRT_SECURE_NO_WARNINGS 1
#include "Stack.h"

//栈初始化
void StackInit(Stack* stack) {
	assert(stack);
	stack->data = NULL;
	stack->capacity = 0;
	stack->top = -1;
}

//栈打印
void StackPrint(Stack* stack) {
	assert(stack);
	int length = stack->top;
	//从栈顶开始打印
	while (length >= 0) {
		printf("[ %d ]\n", stack->data[length]);
		length--;
	}
}

//检查栈的容量并扩容
int CheckStackCapacity(Stack* stack) {
	assert(stack);
	if (stack->top == (stack->capacity) - 1) {
		int newcapacity = stack->capacity == 0 ? 4 : (stack->capacity) * 2;
		SDataType* temp = (SDataType*)realloc(stack->data, newcapacity * sizeof(SDataType));
		if (temp == NULL) {
			perror("CheckStackCapacity_realloc_NULL");
			exit(-1);
		}
		stack->capacity = newcapacity;
		stack->data = temp;
		return 0;
	}
	return 1;
}

//检查栈是否为空,空返回非0,非空返回0
int StackIsEmpty(Stack* stack) {
	assert(stack);
	return stack->top == -1;
}

//入栈
void StackPush(Stack* stack, SDataType data) {
	assert(stack);
	CheckStackCapacity(stack);
	stack->data[++(stack->top)] = data;
}

//出栈
void StackPop(Stack* stack) {
	assert(stack);
	assert(!StackIsEmpty(stack));
	stack->top--;
}

//获取栈顶元素
SDataType StackTop(Stack* stack) {
	assert(stack);
	return stack->data[stack->top];
}

//获取栈中有效元素个数
int StackSize(Stack* stack) {
	assert(stack);
	return stack->top + 1;
}

//栈销毁
void StackDestroy(Stack* stack) {
	assert(stack);
	free(stack->data);
	stack->data = NULL;
	stack->capacity = 0;
	stack->top = -1;
}

② 快排非递归实现:

// 快速排序 (非递归实现)
//栈实现(深度优先遍历)
void QuickSortNonR1(int* arr, int begin, int end) {
	//先创建一个栈再初始化
	Stack stack;
	StackInit(&stack);
	
	//先将初始序列的首尾位置入栈
	StackPush(&stack, begin);
	StackPush(&stack, end);
	
	//当栈为空时,表示没有待排序列了,即排序完成
	while(!StackIsEmpty(&stack)){
		int right = StackTop(&stack); //记录右边界
		StackPop(&stack); //右边界出栈
		int left = StackTop(&stack); //记录左边界
		StackPop(&stack); //左边界出栈
		
		//进行单趟排序
		int key = PartSort1(arr, left, right); //Hoare法
		//int key = PartSort2(arr, left, right); //挖坑法
		//int key = PartSort3(arr, left, right); //前后指针法
		
		//如果划分出的子序列中数据量大于1,则先将右子序列的左右边界入栈,再将左子序列的左右边界入栈
		if (key + 1 < right) {
			StackPush(&stack, key + 1);
			StackPush(&stack, right);
		}
		if (key - 1 > left) {
			StackPush(&stack, left); 
			StackPush(&stack, key - 1);
		}
	}
	StackDestroy(&stack); //栈销毁
}

此外,我们也可借助队列来实现非递归的快速排序,其排序的逻辑过程类似于对二叉树的层序遍历(广度优先遍历)。但通常还是借助栈来实现快速排序的非递归。

快速排序非递归算法实现(借助队列):

① 队列实现:

Queue.h

#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

//链式结构队列
typedef int QDataType;
typedef struct QListNode {
	QDataType data;
	struct QListNode* next;
}QListNode;

//队列结构
typedef struct Queue {
	QListNode* front;
	QListNode* rear;
	int size; //记录队列长度
}Queue;

//创建一个队列结点
QListNode* QListNodeCreate(QDataType data);

//创建一定长度的队列(初始化_空队列则队头队尾指针都为空)
//有需要也可通过数组指针传参创建包含指定元素的队列
void QueueCreate(Queue* queue, int length);

//检查队列是否为空,空返回非0,非空返回0
int QueueIsEmpty(Queue* queue);

//队列打印,从队头开始打印
void QueuePrint(Queue* queue);

//入队
void QueuePush(Queue* queue, QDataType data);

//出队
void QueuePop(Queue* queue);

//获取队头元素
QDataType QueueFront(Queue* queue);

//获取队尾元素
QDataType QueueBack(Queue* queue);

//获取队列长度(有效元素个数)
int QueueSize(Queue* queue);

//队列销毁
void QueueDestroy(Queue* queue);

Queue.c

#define _CRT_SECURE_NO_WARNINGS 1
#include "Queue.h"

//创建一个队列结点
QListNode* QListNodeCreate(QDataType data) {
	QListNode* node = (QListNode*)malloc(sizeof(QListNode));
	if (node == NULL) {
		perror("QListNodeCreate_node_NULL");
		exit(-1);
	}
	node->data = data;
	node->next = NULL;
	return node;
}

//创建一定长度的队列(初始化_空队列则队头队尾指针都为空)
//有需要也可通过数组指针传参创建包含指定元素的队列
void QueueCreate(Queue* queue, int length) {
	assert(queue);
	queue->front = NULL;
	queue->rear = NULL;
	queue->size = length;
	if (length > 0) {
		queue->front = QListNodeCreate(0);
		queue->rear = queue->front;
		for (int i = 1; i < length; i++) {
			QListNode* newnode = QListNodeCreate(i);
			queue->rear->next = newnode;
			queue->rear = newnode;
		}
	}
}

//检查队列是否为空,空返回非0,非空返回0
int QueueIsEmpty(Queue* queue) {
	assert(queue);
	if (queue->front == NULL && queue->rear == NULL) {
		return 1;
	}
	return 0;
}

//队列打印,从队头开始打印
void QueuePrint(Queue* queue) {
	assert(queue);
	assert(!QueueIsEmpty(queue));
	QListNode* cur = queue->front;
	while (cur->next) {
		printf("[ %d | %p ]->", cur->data, cur->next);
		cur = cur->next;
	}
	printf("[ %d | %p ]\n", cur->data, cur->next);
}

//入队
void QueuePush(Queue* queue, QDataType data) {
	assert(queue);
	QListNode* newnode = QListNodeCreate(data);
	//如果队列为空时
	if (QueueIsEmpty(queue)) {
		queue->front = newnode;
		queue->rear = newnode;
	}
	else {
		queue->rear->next = newnode;
		queue->rear = newnode;
	}
	queue->size++;
}

//出队
void QueuePop(Queue* queue) {
	assert(queue);
	assert(!QueueIsEmpty(queue));
	QListNode* newfront = queue->front->next;
	free(queue->front);
	queue->front = newfront;
	//如果出队的是最后一个元素,将队尾指针也置空
	if (queue->front == NULL) {
		queue->rear = NULL;
	}
	queue->size--;
}

//获取队头元素
QDataType QueueFront(Queue* queue) {
	assert(queue);
	assert(!QueueIsEmpty(queue));
	return queue->front->data;
}

//获取队尾元素
QDataType QueueBack(Queue* queue) {
	assert(queue);
	assert(!QueueIsEmpty(queue));
	return queue->rear->data;
}

//获取队列长度(有效元素个数)
int QueueSize(Queue* queue) {
	assert(queue);
	//QListNode* cur = queue->front;
	//int size = 0;
	//while (cur) {
	//	size++;
	//	cur = cur->next;
	//}
	//return size;
	return queue->size;
}

//队列销毁
void QueueDestroy(Queue* queue) {
	assert(queue);
	while (!QueueIsEmpty(queue)) {
		QueuePop(queue);
	}
}

② 快排非递归实现:

// 快速排序 (非递归实现)
//队列实现(广度优先遍历)
void QuickSortNonR2(int* arr, int begin, int end) {
	Queue queue;
	QueueCreate(&queue, 0);
	//先将初始序列左右边界入队
	QueuePush(&queue, begin);
	QueuePush(&queue, end);
	//队列为空则表示无待排序列,排序完成
	while (!QueueIsEmpty(&queue)) {
		int left = QueueFront(&queue); //记录左边界
		QueuePop(&queue); //左边界出队
		int right = QueueFront(&queue); //记录右边界
		QueuePop(&queue); //右边界出队
		//进行单趟排序
		int key = PartSort1(arr, left, right); //Hoare法
		//int key = PartSort1(arr, left, right); //挖坑法
		//int key = PartSort1(arr, left, right); //前后指针法
		//先将左子序列边界入队,再将右子序列边界入队
		if (key - 1 > left) {
			QueuePush(&queue, left);
			QueuePush(&queue, key - 1);
		}
		if (key + 1 < right) {
			QueuePush(&queue, key + 1);
			QueuePush(&queue, right);
		}
	}
	QueueDestroy(&queue); //队列销毁
}

2.3.2.4 快速排序的复杂度分析与优化

快速排序过程的逻辑示意图:

快速排序(时间复杂度分析)

1. 快速排序的复杂度分析:

根据快速排序的基本思想,假设初始待排序列中共有 N 个元素。

① 在理想状态下:
每次选取的基准值(key或者key位置上的值)都是序列在有序状态下的中间值,则每趟排序后都会产生两个长度相等的左右子序列,而每次单趟排序后都会将选取的基准值放到最终正确的位置。如图所示,理想状态下的快速排序过程的逻辑示意图类似于一棵满二叉树,树的深度为 l o g 2 ( N + 1 ) \pmb{log_2(N+1)} log2(N+1) ,每层序列经过单趟排序后都会有一定数量的元素被放到正确位置上。

快速排序的时间复杂度:
分析每层排序需要遍历的元素数(或者说需要进行查找的次数)有:第一层:N,第二层:N-1,第三层:N-3,第四层:N-7,……,最后一层(第 l o g 2 ( N + 1 ) \pmb{log_2(N+1)} log2(N+1) 层): N − ( 2 l o g 2 ( N + 1 ) − 1 − 1 ) = ( N + 1 ) / 2 \pmb{N-(2^{log_2(N+1) - 1} - 1) = (N+1)/2} N(2log2(N+1)11)=(N+1)/2
则总的查找次数为: N + N − 1 + N − 3 + N − 7 + ⋯ + ( N + 1 ) / 2 = ( 3 ∗ N + 1 ) ∗ l o g 2 ( N ) / 2 ≈ N ∗ l o g 2 ( N ) \pmb{N + N-1 + N-3 + N-7 + \cdots + (N+1)/2 = (3*N + 1)*log_2(N) / 2 \approx N*log_2(N)} N+N1+N3+N7++(N+1)/2=(3N+1)log2(N)/2Nlog2(N)即理想状态下快速排序的时间复杂度为: O ( N ∗ l o g 2 ( N ) ) \pmb{O(N*log_2(N))} O(Nlog2(N))

快速排序的空间复杂度:
无论是以递归的方式还是借助栈的非递归的方式实现快速排序,从其基本思想上看执行的都类似于对二叉树的深度优先遍历(前序遍历),如果是递归实现,(仅考虑快排函数栈帧,其余个别函数栈帧的开辟不影响最终结果)则最大开辟栈帧数即为树的深度: l o g 2 ( N + 1 ) \pmb{log_2(N+1)} log2(N+1) ;如果是借助栈非递归实现,则需要开辟的栈的最大容量为: 2 ∗ l o g 2 ( N + 1 ) \pmb{2*log_2(N+1)} 2log2(N+1) (考虑每次需要入栈序列的起始位置和末尾位置两个元素)。即理想状态下快速排序的空间复杂度为: O ( l o g 2 ( N ) ) \pmb{O(log_2(N))} O(log2(N))
此外,如果是借助队列的非递归的实现方式,此时执行的类似于对二叉树的广度优先遍历(层序遍历),则需要开辟的队列的最大容量相当于树最后一层节点数(序列数)的两倍,为: 2 ∗ 2 l o g 2 ( N + 1 ) − 1 = N + 1 \pmb{2*2^{log_2(N+1) - 1} = N+1} 22log2(N+1)1=N+1 ,即空间复杂度为: O ( N ) \pmb{O(N)} O(N) 。显然如果要用非递归方式实现快速排序,借助栈比借助队列在空间复杂度方面要更有优势。


② 在最坏情况下:
每次选取的基准值都是序列中的最大值或最小值(通常是原始序列本身有序的情况),则每趟排序后会产生一个长度为 0 的子序列和一个长度比原序列小 1 的子序列。如图所示,最坏情况下的快速排序过程的逻辑示意图类似于一颗只有右(左)子树的二叉树,此时树的深度为 N \pmb{N} N ,每层序列序列经过单趟排序后都会新增一个元素被放到正确位置上(即接下来的待排序列就会减少一个元素)。

快速排序的时间复杂度:
分析每层需要遍历的元素数(查找次数)有:第一层:N,第二层:N-1,第三层:N-2,第四层:N-3,……,最后一层:1 。
则总的查找次数为: N + N − 1 + N − 2 + N − 3 + ⋯ + 1 = N ∗ ( N + 1 ) / 2 ≈ N 2 \pmb{N + N-1 + N-2 + N-3 + \cdots + 1 = N*(N+1)/2 \approx N^2} N+N1+N2+N3++1=N(N+1)/2N2即最坏情况下快速排序的时间复杂度为: O ( N 2 ) \pmb{O(N^2)} O(N2)

快速排序的空间复杂度:
从上述理想状态下快速排序的空间复杂度分析中也可以了解到,快速排序的空间复杂度与逻辑上二叉树的深度有关,当最坏情况下树的深度为 N 时,递归实现需要开辟的最大栈帧数为:N;非递归实现需要开辟的栈(队列)的最大容量为:2(考虑空序列首尾位置不入栈,每次将一个序列的首尾位置元素出栈后都只有一个子序列的首尾位置元素再入栈;如果是队列,考虑树的每层都只有一个待排序列)。即最坏情况下,递归实现的快速排序的空间复杂度为: O ( N ) \pmb{O(N)} O(N) ;非递归实现的快速排序的空间复杂度为: O ( 1 ) \pmb{O(1)} O(1)


2. 算法优化:

这时可能会有疑问:我们常说的快速排序的时间复杂度不是 O ( N ∗ l o g 2 ( N ) ) \pmb{O(N*log_2(N))} O(Nlog2(N)) 吗?如果按照上述分析的话,时间复杂度一般取最坏的情况,那快排的时间复杂度不就为 O ( N 2 ) \pmb{O(N^2)} O(N2) 了吗?这也是我们接下来要说明的问题。

如果我们能规避最坏情况,使无论原始数据如何,排序都能接近或是保持在理想状态下(在实际中,比起空间复杂度,通常我们更关注时间复杂度,也更关注以减小时间复杂度为目标的算法优化),就可以使快速排序的时间复杂度接近 O ( N ∗ l o g 2 ( N ) ) \pmb{O(N*log_2(N))} O(Nlog2(N))。基于此,我们采取以下方法对上述快速排序算法进行优化:

① 三数取中优化(随机选数优化)

所谓三数取中,不是说选取序列中间的数作为基准值,指的是在序列的某三个数中选取不是最大也不是最小的那个数作为基准值。通常这三个数为:第一个数、序列中间的某个数与最后一个数。 如此,即使初始序列是有序的,也可有效避免每次选取的基准值都是序列中的最大值或最小值,从而使快速排序更接近理想状态。经过三数取中优化后的快速排序几乎不会出现最坏情况,快排的时间复杂度可以认为是 O ( N ∗ l o g 2 ( N ) ) \pmb{O(N*log_2(N))} O(Nlog2(N))

考虑到前面的单趟排序算法中我们都是以第一个数或者最后一个数作为基准值,那如果采用三数取中算法选取基准值的话,之前的算法是不是就不能用了呢?事实上,我们仍然可以继续按照之前的算法选取第一个数或者最后一个数作为基准值,只需要在之前将用三数取中算法得到的基准值与之后将要选作为基准值的第一个数或最后一个数进行交换即可。

三数取中算法实现:

//三数取中(返回既不是最大的数也不是最小的数的位置)
int GetMidIndex(int* arr, int begin, int end) {
	//方式一:选择初始序列中间位置的数作为其中一个数
	//int mid = begin + (end - begin) / 2; 
	
	//方式二:除头尾外,再随机选择初始序列中间任意一个位置上的数作为三数之一
	srand(((unsigned int)(time)));
	int mid = begin + rand() % (end - begin);
	
	//选出三数中不是最大也不是最小的数并返回其下标位置
	if (arr[begin] < arr[mid]) {
		if (arr[mid] < arr[end]) {
			return mid;
		}
		else if (arr[begin] < arr[end]) {
			return end;
		}
		else {
			return begin;
		}
	}
	else {
		if (arr[end] < arr[mid]) {
			return mid;
		}
		else if (arr[begin] < arr[end]) {
			return begin;
		}
		else {
			return end;
		}
	}
}

采用三数取中优化后的单趟排序算法实现(以Hoare版本为例):

//快速排序hoare版本
//单趟排序
int PartSort1(int* arr, int begin, int end) {
	//三数取中优化
	int mid = GetMidIndex(arr, begin, end);
	Swap(&arr[begin], &arr[mid]);
	
	int key = begin; //以起始位置为key
	int left = begin;
	int right = end;
	while (left < right) {
		//右方先走,找到比arr[key]小的值停下
		if (arr[right] >= arr[key]) {
			right--;
			continue;
		}
		//左方再走,找到比arr[key]大的值停下
		if (arr[left] <= arr[key]) {
			left++;
			continue;
		}
		//如果两方都是停下的,交换两个元素
		Swap(&arr[left], &arr[right]);
	}
	Swap(&arr[left], &arr[key]); //相遇,交换相遇位置上的值与key位置上的值
	key = left; //更新key位置
	return key;
}

② 小区间优化

从快速排序的逻辑示意图我们可以直观看出,在采用递归方式实现快速排序算法时,在理想状态下,随着递归的深度越深,每层的待排序列的数量就越多,这也意味着需要递归调用函数的次数就越多,而每次递归调用函数及开辟函数栈帧都会带来一定的消耗。但值得注意的是:当递归深度越深,待排序列数量越多的同时,待排序列的长度也越小,这就说明待排序列中的数据量就越少。事实上,当数据量较小时,快速排序与上述提过的几种排序算法的时间效率并无多大差异,既然如此,为了有效减少快排函数递归调用的次数,我们可以在使用快速排序算法将待排序列分隔成一定长度的小区间序列时,采用直接插入排序的方式对小数据量序列进行排序,即小区间优化。
可以发现:满二叉树的最后一层的节点数相当于总结点数的一半,即从最后一层起,每去掉一层节点,相当于去掉了当前满二叉树总结点数的 50% 的节点。对应到快排函数的递归调用上,也就是说当将最后几层的小区间序列,采用直接插入的方式进行排序,可从很大程度上减少快排函数的递归调用次数。

增加小区间优化的快排算法实现:

//快速排序递归实现
void QuickSort(int* arr, int begin, int end) {
	//当序列为空或者只有一个元素时,则表示序列已经有序,不再递归排序
	if (begin >= end) {
		return;
	}
	//小区间优化(当数据量小于15时,采用直接插入法进行排序)
	else if ((end - begin + 1) < 15) {
		InsertSort(arr + begin, end - begin + 1); //直接插入排序
	}
	else {
		//int key = PartSort1(arr, begin, end); //hoare法
		//int key = PartSort2(arr, begin, end); //挖坑法
		//int key = _PartSort2(arr, begin, end); //挖坑法2
		int key = PartSort3(arr, begin, end); //前后指针法
		QuickSort(arr, begin, key - 1);
		QuickSort(arr, key + 1, end);
	}	
}

③ 基于三路划分法的快速排序

考虑到某些特殊情况:待排序列中的数据存在大量重复或全部重复,此时即使采用了前面的优化方法,快速排序仍处于最坏情况。可以采用三路划分的思想实现快速排序。

三路划分法: 同样,每趟排序先选取一个基准值,按照基准值将序列划分为小于基准值,等于基准值,大于基准值的三部分子序列,对于等于基准值的部分,则不需要在进行排序,只需要再对小于基准值和大于基准值的部分进行排序。如此,即使是存在大量重复数据或者全部数据都是重复的,排序的效率也可有效提升(当数据全部重复时,一趟排序划分出的小于基准值的部分为空序列,大于基准值的部分也为空序列,即只需要一趟排序即可完成整体排序,时间复杂度为 O ( N ) \pmb{O(N)} O(N))。

三路划分法排序示意图:

三路划分(快速排序)

算法实现:

//快速排序三路划分法
//适用于有大量重复数据的情况
void QuickSort2(int* arr, int begin, int end) {
	if (begin < end) {
		//三数取中优化
		int mid = GetMidIndex(arr, begin, end);
		Swap(&arr[begin], &arr[mid]);

		int left = begin, right = end;
		int cur = begin + 1;
		int key = arr[begin]; //设置基准值
		//一趟排序将数组划分成小于key,等于key,大于key的三部分
		//对应得到三个区间[begin, left-1]  [left, right]  [right+1, end]
		//等于key的区间不需要再进行排序
		while (cur <= right) {
			if (arr[cur] < key) {
				Swap(&arr[cur], &arr[left]);
				cur++;
				left++;
			}
			else if (arr[cur] > key) {
				Swap(&arr[cur], &arr[right]);
				right--;
			}
			else {
				cur++;
			}
		}
		QuickSort2(arr, begin, left - 1); //递归调用函数排序小于基准值的部分
		QuickSort2(arr, right + 1, end); //递归调用函数排序大于基准值的部分
	}
}

📜 快速排序的特性总结:

  1. 快速排序整体的综合性能较好,使用场景也较为广泛。
  2. 时间复杂度: O ( N ∗ l o g 2 ( N ) ) \pmb{O(N*log_2(N))} O(Nlog2(N))
  3. 空间复杂度: O ( l o g 2 ( N ) ) \pmb{O(log_2(N))} O(log2(N))
  4. 稳定性:不稳定

2.4 归并排序

基本思想: 归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法的一个非常典型的应用。通过将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路合并。

归并排序过程示意图:

_归并排序

归并排序

如图所示:通过不断将待排序列对半分割成左右两个子序列,直到序列不可再分时,认为序列有序,此时,开始往回合并序列,合并过程是先将两个子序列按顺序从对应位置开始依次插入到一个临时数组中,再将临时数组中合并好的有序数据拷贝回原数组中,如此,直到最后两个子序列合并完,排序完成。注意:我们将原序列分解,但不能直接在原序列中进行合并,因为每次在逐个插入合并时,如果将数据直接插入到原数组中,可能会覆盖之后还没有合并的数据,造成对原序列的破坏。


2.4.1 归并排序的递归实现

归并排序的递归实现:

//归并排序
void _MergeSort(int* arr, int begin, int end, int* temp) {
	//将待排序列对半划分为两个子序列
	int mid = begin + (end - begin) / 2;
	int begin1 = begin, end1 = mid;
	int begin2 = mid + 1, end2 = end;
	//当子序列不可再分时,结束递归
	if (begin >= end) {
		return;
	}
	//递归调用函数对左右两个子序列进行归并排序
	else {
		_MergeSort(arr, begin1, end1, temp);
		_MergeSort(arr, begin2, end2, temp);
	}
	//合并
	int i = begin; //从起始位置开始插入
	while (begin1 <= end1 && begin2 <= end2) {
		if (arr[begin1] <= arr[begin2]) {
			temp[i++] = arr[begin1++];
		}
		else {
			temp[i++] = arr[begin2++];
		}
	}
	while (begin1 <= end1) {
		temp[i++] = arr[begin1++];
	}
	while (begin2 <= end2) {
		temp[i++] = arr[begin2++];
	}

	memcpy(arr + begin, temp + begin, (end - begin + 1) * sizeof(int)); //将合并后的有序序列拷贝回原数组中
}

// 归并排序递归实现
void MergeSort(int* arr, int n) {
	int* temp = (int*)malloc(n * sizeof(int)); //开辟一个临时空间用于子序列的合并
	if (temp == NULL) {
		perror("MrgeSort_malloc_failed");
		exit(-1);
	}

	_MergeSort(arr, 0, n - 1, temp);

	free(temp); //释放临时空间
	temp = NULL;
}

2.4.2 归并排序的非递归实现

归并排序非递归实现示意图:

归并排序(非递归)

如图所示:归并排序的非递归实现通过变量 rangeN 控制每组归并数据个数,每次按照rangeN大小将序列分为几组。初始时,设置rangeN为1,当子序列中数据个数为1时可以认为子序列有序,此时开始按照每组rangeN个数据,从第一组开始向后,每两组间进行合并,直到序列中所有数据合并完成;接着将rangeN乘以2(因为每次归并完成后都会得到长度为 rangeN2 的有序子序列,即下一次归并的每组的数据个数为 rangeN2 ),重复上述过程,直到rangeN大于或等于序列数据个数,此时表示,整个序列已经有序,排序完成。

值得注意的是:不一定每次分组都能正好将序列分完,如下图所示为归并可能出现的分组情况,如果数据不能被完全分组,则可能发生数组越界问题,因此在实现时还需要考虑不同情况的越界问题。当右子序列的右边界越界时,可以通过修改最后一组数据的右边界为序列边界(n-1),来完成最后两组数据(其中一组的数据个数小于rangeN)的归并;当右子序列的左边界越界时,即最后一组序列为两组间归并时的左子序列,而子序列本身已有序,因此可直接结束当层排序;当左子序列的右边界越界时,即最后一组序列为两组间归并时的左子序列的一部分,而子序列本身已有序,子序列的部分同样有序,因此可直接结束当层排序。

归并分组情况

归并排序的非递归实现:

// 归并排序非递归实现
void MergeSortNonR(int* arr, int n) {
	int* temp = (int*)malloc(n * sizeof(int)); //开辟一个临时空间
	if (temp == NULL) {
		perror("MergeSortNonR_malloc_failed");
		exit(-1);
	}
	int rangeN = 1; //归并每组数据个数
	while (rangeN < n) {
		int i = 0;
		for (i = 0; i < n; i += 2*rangeN) {
			int begin1 = i, end1 = i + rangeN - 1; //设置当前进行归并的左子序列的左右边界
			int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1; //设置当前进行归并的右子序列的左右边界
			int j = i;
			//处理越界问题
			if (end1 >= n) {
				break;
			}
			else if (begin2 >= n) {
				break;
			}
			else if (end2 >= n) {
				end2 = n - 1;
			}
			//合并
			while (begin1 <= end1 && begin2 <= end2) {
				if (arr[begin1] <= arr[begin2]) {
					temp[j++] = arr[begin1++];
				}
				else {
					temp[j++] = arr[begin2++];
				}
			}
			while (begin1 <= end1) {
				temp[j++] = arr[begin1++];
			}
			while (begin2 <= end2) {
				temp[j++] = arr[begin2++];
			}
			//每合并完一组数据拷贝一次,只拷贝合并的那部分数据
			memcpy(arr + i, temp + i, (end2 - i + 1) * sizeof(int)); 
		}
		rangeN *= 2; //每次合并后子序列的长度乘以2
	}
	free(temp); //释放临时空间
	temp = NULL;
}

2.4.3 归并排序的复杂度分析

无论是递归实现还是非递归实现的归并排序,其基本思想都是一样,因此我们可以从基本思想来分析归并排序的复杂度。

根据基本思想可以画出归并排序过程的逻辑示意图如下:

_归并排序2

如图所示:

归并排序过程可以被分为两大部分,一个是分解过程,一个是合并过程。分解过程示意图类似于一棵满二叉树,合并过程的逻辑过程类似于一棵倒放的满二叉树。假设待排序列中有 N 个数据,则每个过程的树的深度均为 l o g 2 ( N + 1 ) \pmb{log_2(N+1)} log2(N+1) 。在分解过程中,我们不需要遍历待排序列中的元素,只需要知道当前待排序列的左右边界即可从中间将序列划分成两个子序列,因此可以认为每一次的分解操作的时间复杂度都为 O ( 1 ) \pmb{O(1)} O(1) ,而每一层需要执行分解操作的次数为 2 h − 1 \pmb{2^{h-1}} 2h1 ,其中 h 表示层次,则整个分解过程总的需要执行的分解次数为: 2 0 + 2 1 + ⋯ + 2 l o g 2 ( N + 1 ) = N \pmb{2^0 + 2^1 + \cdots +2^{log_2(N+1)} = N} 20+21++2log2(N+1)=N ,即整个分解过程的时间复杂度为 O ( N ) \pmb{O(N)} O(N) 。而在合并过程中,每次合并操作都需要遍历左右子序列中的元素,则每层的合并操作相当于总的需要遍历整个原序列中的元素,也就是说,每层合并操作的时间复杂度为 O ( N ) \pmb{O(N)} O(N) ,一共有 l o g 2 ( N + 1 ) \pmb{log_2(N+1)} log2(N+1) 层,因此整个合并过程的时间复杂度可以认为是 O ( N ∗ l o g 2 N ) \pmb{O(N*log_2N)} O(Nlog2N) ,取最高量级,则归并排序整体的时间复杂度为: O ( N ∗ l o g 2 N ) \pmb{O(N*log_2N)} O(Nlog2N)

同时,在整个归并排序过程中,只需要额外开辟一个大小与原序列大小相等的临时空间,假设待排序列的长度为 N ,则临时空间的大小也为 N 。但此外如果是采用递归方式实现,则因为递归调用函数还需要额外开辟函数栈帧,整个递归过程类似于二叉树的前序遍历(深度优先遍历),最大需要开辟 l o g 2 ( N + 1 ) \pmb{log_2(N+1)} log2(N+1) (深度)个函数栈帧,即额外开辟的空间大小为 l o g 2 ( N + 1 ) \pmb{log_2(N+1)} log2(N+1) 。因此若采用递归实现,额外开辟的空间大小为 N + l o g 2 ( N + 1 ) ≈ N \pmb{ N+log_2(N+1) \approx N} N+log2(N+1)N 。从量级上考虑,无论那种实现方式,归并排序的空间复杂度都为: O ( N ) \pmb{O(N)} O(N)


📜 归并排序的特性总结:

  1. 归并排序的缺点在于需要O(N)的空间复杂度,因此归并排序更多的是解决在磁盘中的外排序问题。
  2. 时间复杂度: O ( N ∗ l o g 2 N ) \pmb{O(N*log_2N)} O(Nlog2N)
  3. 空间复杂度: O ( N ) \pmb{O(N)} O(N)
  4. 稳定性:稳定

2.5 计数排序(非比较排序)

基本思想: 计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。其基本排序过程:

  1. 统计相同元素出现的次数
  2. 根据统计的结果将序列回收到原来的序列中

计数排序过程示意图:

计数排序

计数排序算法实现:

① 不稳定的排序算法

//计数排序(不稳定排序)
//只适用于整型数据,如果是浮点型或者字符串类型的数据则不适用
void CountSort(int* arr, int n){

	//遍历数组,先找出最大值和最小值
	int max = arr[0], min = arr[0];
	for (int i = 0; i < n; ++i)
	{
		if (arr[i] > max)
			max = arr[i];

		if (arr[i] < min)
			min = arr[i];
	}

	//找到数据的范围
	int range = max - min + 1;
	//开辟数据范围大小的临时计数空间
	int* countArray = (int*)malloc(range * sizeof(int));
	if (countArray == NULL) {
		perror("CountSort_malloc_failed");
		exit(-1);
	}
	//将临时空间数据全初始化为0
	memset(countArray, 0, sizeof(int) * range);

	//存放在相对位置,可以节省空间
	//最小值对应计数在临时空间第一个位置,最大值对应计数在最后一个位置
	for (int i = 0; i < n; ++i)
	{
		countArray[arr[i] - min]++;
	}

	//可能存在重复的数据,有几个存几个
	int index = 0;
	for (int i = 0; i < range; ++i)
	{
		while (countArray[i]--)
		{
			arr[index++] = i + min;
		}
	}

	free(countArray);
	countArray = NULL;
}

② 稳定的排序算法

// 计数排序(稳定排序)
//只适用于整型数据,如果是浮点型或者字符串类型的数据则不适用
void CountSort(int* arr, int n) {

	//遍历数组,先找出最大值和最小值
	int max = arr[0], min = arr[0];
	for (int i = 0; i < n; ++i)
	{
		if (arr[i] > max)
			max = arr[i];

		if (arr[i] < min)
			min = arr[i];
	}

	//找到数据的范围
	int range = max - min + 1;
	//开辟数据范围大小的临时计数空间
	int* countArray = (int*)malloc(range * sizeof(int));
	if (countArray == NULL) {
		perror("CountSort_malloc_failed");
		exit(-1);
	}
	//将临时空间数据全初始化为0
	memset(countArray, 0, sizeof(int) * range);

	//存放在相对位置,可以节省空间
	//最小值对应计数在临时空间第一个位置,最大值对应计数在最后一个位置
	for (int i = 0; i < n; ++i)
	{
		countArray[arr[i] - min]++;
	}

	//修正计数空间的计数值,每一项计数值均为之前所有计数值之和加上该项本身的计数值
	for (int i = 1; i < range; i++) {
		countArray[i] += countArray[i - 1];
	}

	//创建原数组大小的临时空间
	int* temp = (int*)malloc(n * sizeof(int));
	if (temp == NULL) {
		perror("CountSort_malloc_failed");
		exit(-1);
	}

	//排序:倒着遍历原数组,根据修正后的计数数组将数据放到临时数组中的对应位置
	for (int i = n - 1; i >= 0; i--) {
		temp[--countArray[arr[i] - min]] = arr[i];
	}

	//将临时数组中排好序的数据拷贝回原数组中
	memcpy(arr, temp, n * sizeof(int));

	free(countArray);
	free(temp);
	countArray = NULL;
	temp = NULL;

}

📜 计数排序的特性总结(基于稳定排序算法):

  1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
  2. 时间复杂度: O ( M A X ( N , 数据范围 ) ) \pmb{O(MAX(N,数据范围))} O(MAX(N,数据范围))
  3. 空间复杂度: O ( M A X ( N , 数据范围 ) ) \pmb{O(MAX(N,数据范围))} O(MAX(N,数据范围))
  4. 稳定性:稳定

3 常见排序算法的特性总结

以下对上述几种排序算法的特性进行总结:

排序方法平均情况最好情况最坏情况辅助空间稳定性
冒泡排序 O ( N 2 ) \pmb{O(N^2)} O(N2) O ( N ) \pmb{O(N)} O(N) O ( N 2 ) \pmb{O(N^2)} O(N2) O ( 1 ) \pmb{O(1)} O(1)稳定
直接选择排序 O ( N 2 ) \pmb{O(N^2)} O(N2) O ( N 2 ) \pmb{O(N^2)} O(N2) O ( N 2 ) \pmb{O(N^2)} O(N2) O ( 1 ) \pmb{O(1)} O(1)不稳定
直接插入排序 O ( N 2 ) \pmb{O(N^2)} O(N2) O ( N ) \pmb{O(N)} O(N) O ( N 2 ) \pmb{O(N^2)} O(N2) O ( 1 ) \pmb{O(1)} O(1)稳定
希尔排序 O ( N 1.3 ) \pmb{O(N^{1.3})} O(N1.3) O ( N 1.3 ) \pmb{O(N^{1.3})} O(N1.3) O ( N 2 ) \pmb{O(N^2)} O(N2) O ( 1 ) \pmb{O(1)} O(1)不稳定
堆排序 O ( N ∗ l o g 2 N ) \pmb{O(N*log_2N)} O(Nlog2N) O ( N ∗ l o g 2 N ) \pmb{O(N*log_2N)} O(Nlog2N) O ( N ∗ l o g 2 N ) \pmb{O(N*log_2N)} O(Nlog2N) O ( 1 ) \pmb{O(1)} O(1)不稳定
归并排序 O ( N ∗ l o g 2 N ) \pmb{O(N*log_2N)} O(Nlog2N) O ( N ∗ l o g 2 N ) \pmb{O(N*log_2N)} O(Nlog2N) O ( N ∗ l o g 2 N ) \pmb{O(N*log_2N)} O(Nlog2N) O ( N ) \pmb{O(N)} O(N)稳定
快速排序 O ( N ∗ l o g 2 N ) \pmb{O(N*log_2N)} O(Nlog2N) O ( N ∗ l o g 2 N ) \pmb{O(N*log_2N)} O(Nlog2N) O ( N 2 ) \pmb{O(N^2)} O(N2) O ( l o g 2 N ) \pmb{O(log_2N)} O(log2N) ~ O ( N ) \pmb{O(N)} O(N)不稳定

以上是我对几种常见的排序算法的一些学习记录总结,如有错误,希望大家帮忙指正,也欢迎大家给予建议和讨论,谢谢!

  • 20
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 19
    评论
Scikit-learn(sklearn)是一个常用的机器学习库,它提供了多种算法和工具来简化机器学习任务的实现。在sklearn中,多层感知器算法(Multilayer Perceptron)是一种基于人工神经网络的分类方法。 多层感知器(MLP)是一种前向人工神经网络,由多个神经元层组成,每层之间都是全连接的。MLP可以用于分类和回归任务,并且在许多实际应用中表现出色。 在sklearn中,使用MLP算法可以通过MLPClassifier(用于分类任务)和MLPRegressor(用于回归任务)这两个类来实现。你可以通过设置不同的参数来调整模型的性能和行为,例如隐藏层的数量和大小、激活函数、优化算法等。 下面是一个使用sklearn中MLPClassifier的示例代码: ```python from sklearn.neural_network import MLPClassifier from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split # 创建示例数据 X, y = make_classification(n_samples=100, random_state=1) # 划分训练集和测试集 X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y, random_state=1) # 创建多层感知器模型 model = MLPClassifier(hidden_layer_sizes=(10, 10), activation='relu', solver='adam', random_state=1) # 拟合模型 model.fit(X_train, y_train) # 在测试集上进行预测 y_pred = model.predict(X_test) ``` 这里的示例代码演示了如何使用sklearn中的MLPClassifier来构建一个多层感知器模型,并在训练集上进行拟合,然后在测试集上进行预测。你可以根据实际情况调整模型的参数和数据预处理方法来获得更好的性能和准确度。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 19
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

大米饭_Mirai

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值