数据结构——排序算法(插入,希尔,选择,计数,归并,快排,三万字整理汇总)

⭐排序

排序在我们的日常生活当中非常常见,在每个人的电脑中,我们可以按日期,文件大小或名称等给文件按照需求排序;在购物时,我们对于心仪商品的筛选可以按照销量,口碑,评价数量等进行排序;对于外卖或快递等,我们可以按照离自己远近的商家数量,商家口碑或评价高低进行排序。总之,排序随时存在于我们的身边,而各种各样的排序算法数不胜数,有插入排序和选择排序,有交换排序和归并排序,还有外部排序及内部排序等等。本章将介绍排序中的几种经典算法,以基本数据结构和C语言模拟实现。

✨排序算法分类

排序算法的种类很多,而整体根据数据的挪动规则可将排序分为几个大类:

  1. 插入排序——包含直接插入排序,希尔排序
  2. 选择排序——包含选择排序,堆排序
  3. 交换排序——包含冒泡排序,快速排序
  4. 归并排序

本章将按照从上到下的顺序依次介绍。

🌠插入排序

插入排序类似于纸牌游戏中取牌插入归类的过程,比如抽牌阶段由发牌员递牌到自己手中组成了一串随机的不连续牌组,此时我们会从左边开始一张一张向右将牌依次捋顺,保证捋牌的左侧第一张到当前牌都是有序的情况下,再继续向右取出并插入到左边有序的牌组中。这个过程就是插入排序,而插入排序最简单的算法为直接插入排序。

🍧直接插入排序

直接插入排序就是上例卡牌类游戏排序的典型代表,作为模型而提取出来。对于一个数组而言,插入排序的过程就是从数组的最左边开始,一步步将每个数据从左向右依次排为有序,此后每新增一个随机数据,就可以直接穿插入左边的已有序数组中,实现“插入”的效果。对比之前学过的冒泡排序,即将最值数据交换冒到数组最右边的过程,直接插入排序没有出现数之间交换的过程,因为其每个数据的挪动都是直接向右覆盖,新值直接插入覆盖到原数据合适的位置上,故谓之“直接插入排序”。

🎀直接插入排序算法

void InsertSort(int* arr, int sz)
{
	int* cur = arr, * prev = arr;			//定义新插入值的指针和前一个数指针
	while (++cur < &arr[sz])				//cur在数组范围内的整趟循环
	{
		int value = *cur;					//临时存储新插入的值
		while (prev >= arr)					//单趟循环,向前越界时停止
		{
			if (value < *prev)				//如果新插入的值比前一个小(规则可变)
			{
				*(prev-- + 1) = *prev;		//prev解引用后挪覆盖,再自减
			}
			else
			{
				break;						//发现新插入的值比prev大时停止单趟循环
			}
		}
		*(prev + 1) = value;				//将新值插入到prev的后一个数据上,并重置回到cur位置
		prev = cur;
	}
}
  1. 对于一个数组先定义了两个均指向数组首元素地址的指针cur和prev,分别用于控制数组的整趟循环插入遍历和单趟向前插入循环。cur指针向首元素遍历至尾,每遍历得到一个新的元素,如果满足要求就向前对比并插入合适位置,prev指针在cur拿到值后用该值对cur左侧的所有值进行对比。
  2. 以升序一个数组为例,cur每向后拿到一个值,将该值暂时存储起来放到整型变量value中,此后prev指向的值开始与value进行逐个比对,如果发现value值比prev指向的值更小,则说明该更小值value应该存在于更靠前的位置。为了要保持所有数据的相对顺序,则prev每向前挪动一位之前,就需先将其所指向的值往后挪动覆盖一位,目的是为了给value这个更小值腾出一个有效的数据空间,这个过程类似于顺序表的头插。
  3. 使用临时变量value来存储cur指向位置的值是为了防止prev在向后挪动数据的过程中对cur指向数据的覆盖,从而导致如果对cur解引用,拿到的就是被覆盖的值,换句话说,通过指针cur访问到的值是会被随时改变的,而如果将访问到的值使用变量value暂时保存起来,即使指针指向的值被改变,也可以通过value拿到所需值与prev进行对比以及位置找到后的插入。

直接插入排序原理图
在这里插入图片描述

  1. 因为对比过程中数据不断后挪覆盖的原因,value最后的值插入也必须插入到prev+1的位置上,该位置上的值已经通过后挪覆盖备份起来了,而prev此时如果没有越界,其指向的值比value小,则说明value应该插入到prev指向的值之后,也即prev + 1的位置上。
  2. 这里还需注意的两个细节是cur指针指向的数据总是比prev多一个位置,如果两个指针指向同一个位置的同一数据是没有科比性的,所以最开始就让cur前置自增,与prev拉开距离后,一前一后比对才有意义。同时需要注意,因为cur是前置自增,所以每次值成功插入后,prev需要复位到cur的位置上,再让cur自增,继续向后遍历的过程中保持prev与cur的一步距离差。
  3. 整个过程可以发现cur向后遍历一遍,而prev几乎每次插入新值都要向前再次遍历到头。如果是有序或接近有序的最好情况,则prev几乎无需向前遍历对比和挪动数据,只需遍历指针cur一直向后遍历至数组末即可,此时时间复杂度接近O(N);而如果是逆序的最坏情况,则cur每后移一次取到的值,都必须用prev将该value值送到数组首位置,此时时间复杂度为O(N2)。所以直接插入排序平均情况与冒泡算法一致,平均时间复杂度均为O(N2)。
  4. 对比冒泡排序的交换算法,该直接插入排序的稳定性是其优点之一,因为每个数之间的排序并不会改变相同数之间的相对顺序,而冒泡排序算法则相对不稳定。

🌈测试用例——升序和降序

int arr[] = { 3,1,8,9,0,4,7,2,5,10,6 };		//排升序
int arr1[] = { -3,9,-5,6,4,2,10,8,1};		//排降序
int sz = sizeof(arr) / sizeof(arr[0]);
InsertSort(arr, sz);
for (int i = 0; i < sz; i++)
{
    printf("%d ", arr[i]);
}

🌈观察结果

//升序结果
0 1 2 3 4 5 6 7 8 9 10
//降序结果
10 9 8 6 4 2 1 -3 -5

🎃本章中大部分函数将不使用函数指针控制排序升降序规则,所以如果要使该算法支持降序打印,需要手动将value和*prev之间的对比规则由value < *prev改成value > *prev来手动实现。


🍧希尔排序

直接插入排序对有序或接近有序的数据操作时效率很高,可以达到线性排序的效率,即几乎只需遍历一次数据就可以了,而上述分析也提到了对于没有顺序或完全逆序的数据而言,直接插入的效率就非常低了,因为插入排序每次只能将一个数据向前移动到指定位置,而不能与具有二分分治算法思路的快速排序,归并排序等进行比较。

1959年Donald Shell提出了希尔排序,该算法在直接插入排序的基础上划分为了多个分组,并对每个分组分别进行直接插入排序,这个阶段称为全部数据的预排序。预排序完成后的数组中数据已经接近有序甚至有序,此时再调用直接插入排序就可以以极高的效率将数据全部排成顺序了。

🎀希尔排序算法

void ShellSort(int* arr, int sz)
{
	int gap = sz;								//定义gap间隔
	while (gap > 1)								//当gap不为0时,执行预排序或直接插入排序
	{
		int* cur = arr, * prev = arr;			//分组执行直接插入排序
		gap = gap / 3 + 1;						//每次将gap间隔缩小为上一次的1/3
		while (cur < &arr[sz - gap])			//控制每个分组依次进行单趟排序的结束条件
		{
			int value = *(cur + gap);			//直接插入排序,cur与prev的增减间隔变为gap
			while (prev >= arr)
			{
				if (value < *prev)				//升序<,降序>
				{
					*(prev + gap) = *prev;
					prev -= gap;
				}
				else
				{
					break;
				}
			}
			*(prev + gap) = value;				//将新数据插入到合适的位置,因为间距为gap,所以为prev + gap上
			cur++;								//每组交替进行单趟插入排序
			prev = cur;
		}
	}
}
  1. 希尔排序将一组数据分为多个分组,每个分组中的前后数据下标间隔为gap,将所有数据分到特定的分组。一组数据中假设gap为4(gap的分组数量不应大于数组总容量sz),则可以将这个数组中的数据分为4组,分组的规则为从数组首元素开始,相邻数据依次为不同的分组,如下标[0], [1], [2], [3]这四个数据分别为分组1,2,3,4,后续数据按照该规则循环进入各个分组即可。

  2. 分组后的数据只应该在本组中进行数据的直接插入排序,即分组1成员间的数据不能与分组2或分组3的数据互相有数据交互,一个分组中的相邻数据之间的距离为gap,如分组1的第一个元素处于下标[0],分组1的第二个元素处于下标[4]的位置。

  3. 因为cur是数组中从前向后整体遍历的指针,所以要实现每个分组之间的插入排序,就只能不同组交替进行部分数据的插入排序,即分组1的[0]与[4]号元素插入排序后,cur指针后挪,开始进行分组2的[1]与[5]号元素排序。待4个分组进行了第一轮部分数据的插排完成后,如果此时cur指针没有越界,则开始进行第二轮从分组1开始的插排。
    在这里插入图片描述

  4. 如上图所示,gap选择为4时将所有分组数据进行插排结束后,每个分组中的数据都相对保持有序了,此时每个分组有序但整体不有序的阶段叫作数组的阶段预排序,此后gap不断缩小,每缩小一次,分组数量就会减少,相应地每个分组数据成员就会增多,且当该次分组的插排结束后,每个分组间的数据都保持相对有序,整体上整个数组的数据从前向后就会越来越有序了。

  5. 随着gap的减小与分组数量的随之减少,一个数组中的数据越来越接近有序,当gap为1时,此时数组中仅有一个分组,将该分组中的所有数据相互间保持有序,就是将整个数组的数据排成顺序,也就是完全的直接插入排序了。因为在gap为1之前进行过的几轮预排序中多少使数据间部分相互有序了,所以结合之前的经验,直接插入排序对一个接近有序或有序的数组调整效率是非常高的,所以最后一轮进行直接插入排序保证整个数组一定有序的时候,排序的难度就没有刚开始完全的乱序数组那么高了,所以速度和效率是很高的。
    在这里插入图片描述

  6. 关于gap的取法,因为因为gap分组排序对于希尔算法来说很重要,当gap越大时,一次性跨越的数组中数据就越多,能更快将处于数组中后方的数据调整到前面,也就能更好地整体调整数据前后的相对位置,使同一个组中的数据保持前后相对有序,但是因为gap越大时一个组中的数据量越少,所以精度较差,整体有序水平不高。gap不断缩小,则每个组中数据越来越多,整趟调整下来后更多的数据就保持相对有序了。官方对gap的取值为初始为sz的一半,后续每次递减为前一次gap值的一半,如一个数组中数据量共有1000个,则首次gap为500,首轮调整完毕后下一次gap为250,再下一次为125,最后一次为1。但实际过程可以自己灵活调整gap,使数据间调整的次数更贴合实际。

🌈测试用例

int arr[] = { 3,1,8,9,0,4,7,2,5,10,6 };		//排降序
int arr1[] = { 100,90,88,77,98,66,55,99,44,33,22,200,10,50,0 };		//排升序
int sz = sizeof(arr) / sizeof(int);
ShellSort(arr, sz);
for (int i = 0; i < sz; i++)
{
    printf("%d ", arr[i]);
}

🌈观察结果

//降序结果
0 1 2 3 4 5 6 7 8 9 10
//升序结果
200 100 99 98 90 88 77 66 55 50 44 33 22 10 0

👑总结

希尔排序是一个不稳定的排序算法,因为虽然其运用到的直接插入排序可以保持整个数据的每组数据间相同数据的相对有序,但在经历多次分组和前后轮次不同组间的数据位置调整后,则可能造成相同数据的前后位置改变。

希尔排序前后共经历两个阶段,预排序和直接插入排序,预排序将不同数据分组,使每组数据前后相对有序。以升序为例,如果gap越大,越大的数将越快到达数组的后半部分,而更小的数则可以更快到达前面,但是整个数组越不接近有序。当gap随着每轮预排序循环每次减半或为前一次的1/3减少到1时,进入直接插入排序将接近有序的数组调整为有序数组,则此时整个数组中数据就有序了。

关于希尔排序的时间复杂度仍是一个难以计算的值,官方给定其介于O(N* logN)与O(N2)之间,即平均时间复杂度为O(N1.3)。


🌠选择排序

选择排序是对数据进行有意义的筛选,一般是取出全部数据的最大或最小值放在整个数组中的特定位置,以达成排序,取大或取小或TopK问题等特殊场景的使用。选择排序有两种比较经典的算法,一个是与冒泡,插排等时间复杂度相当的直接选择排序,其最大特点是方便理解,易于上手,但是是不稳定的算法。另一个是效率很高但也不稳定的堆排序算法,对于堆和堆排序已经在前面的章节重点介绍和论述,本章仅以代码展示与测试一笔带过。

🍧直接选择排序

直接选择排序非常好理解,于冒泡排序算法一样需要对一个数组中的所有数据进行多次遍历,有一个外循环的整体遍历,以及一个内循环的多次搜索最大最小值的单趟遍历,内循环中遍历的主要任务是在一趟遍历中寻找最大值和最小值,找到后将这两个值分别与数组中现有的首尾值交换,向后遍历的过程中重复这一步骤,在一次次遍历后就可以保持最大,次大,最小,次小的值分别出现在数组的首尾两侧并不断向中间缩进,最终达到整个数组数据间的有序。

🎀直接选择排序

void SelectSort(int* arr, int sz)
{
	for (int i = 0; i <= (sz - 1) / 2; i++)			//每次遍历取大取小两头排序,所以整体遍历只需遍历数组容量的一半
	{
		int max = i, min = i;						//定义单次遍历循环存储最大与最小值的临时变量
		for (int j = i + 1; j < sz - i; j++)		//排除掉首尾已经排好序的数据,每次单趟遍历仅对中间数据筛选
		{
			if (arr[min] > arr[j])					//取最小值下标
			{
				min = j;
			}
			if (arr[max] < arr[j])					//取最大值下标
			{
				max = j;
			}
		}
		Swap(&arr[max], &arr[sz - 1 - i]);			//升序算法
		if (min == sz - 1 - i)						//如果min值下标与末下标重合,使用max下标修正min下标
		{
			min = max;
		}
		Swap(&arr[min], &arr[i]);					//修正后再与首位置值进行数据互换
		//Swap(&arr[max], &arr[i]);					//降序算法
		//if (min == i)
		//{
		//	min = max;
		//}
		//Swap(&arr[min], &arr[sz - 1 - i]);
	}
}
  1. 在选择排序算法中有很多细节是值得注意的,首先,与冒泡排序算法不断将最值数据冒到最右侧,后续排序对已经冒泡排序好的数据不再遍历相同,选择排序对一趟排序已经选择出的最大与最小数据与首尾交换后也不再对该对数据进行遍历了。简而言之,选择排序是一个不断向中间缩进排序的算法,对每趟选好的最大和最小数据放入到合适的位置后,就不再重复访问这些数据了,而是向其余的数据再次筛选,重复上一步骤。
    在这里插入图片描述

  2. 由上图可知,整趟排序只需走总数组容量的一半即可,如果该数组容量为奇数,则当标识整趟遍历的首位置i超过中间下标时,整趟遍历结束,数组已经有序。如果该数组容量为偶数,则i超过中间两个元素的第一个元素下标时,遍历结束,数组有序。

  3. 如果遇到最值元素与首尾位置下标值重合,这个时候需要特别注意,如果不修正位置将会造成最值重复交换,导致数据无意义交换的情况,如下图:
    在这里插入图片描述

  4. 在升序的选择排序中,如果选出的最值数据与需要交换的数组范围中的首尾值有重合部分,比如最大值再首部,而最小值在尾部,此时按照正常思路让最大值与尾部交换,最小值再与首部交换,因为数值的交换是通过下标去控制的,所以第一次交换后,此时最小值下标对应的值已经由最大值存储了,如果此时不修正最小值min的指向,再将它认为指向的“最小值”与首部交换的话,会造成数据的复原,即最大值又回到了首部,而最小值也回到了尾部,这种交换是没有意义的。

  5. 所以需要在第一次交换后,第二次交换前做出判断,以上例为例,如果第一次交换后尾部数据被最大值占据,则最小值下标min需要修正其指向,让其指向正确的最小值,而此时最小值正由最大值下标max所指向,所以让min = max,后续的最小值下标min与首部数据交换即可。

🌈测试用例

int arr1[] = { -7,7,-10,6,6,2,10,8,1,0,3,-5,5};		//排升序
int arr2[] = { 10,8,6,4,2,1,0 };					//排降序
int arr3[] = { 4,5,1,6,3,9};						//排降序
int sz = sizeof(arr1) / sizeof(int);
SelectSort(arr1, sz);
for (int i = 0; i < sz; i++)
{
    printf("%d ", arr1[i]);
}

🌈观察结果

//升序结果
-10 -7 -5 0 1 2 3 5 6 6 7 8 10
//降序结果
10 8 6 4 2 1 0
9 6 5 4 3 1

总结:

无论在数组处于已经有序,局部有序或乱序的情况下,选择排序都是效率最低的算法,其时间复杂度在何种情况下都是O(N2)。

在数组接近有序的情况下,没有插入排序好,因为仍然需要选出最大和最小,全部遍历完数组。而插入排序在接近有序的情况下,时度为O(N)。即使在乱序的情况下,因为局部可能有序的影响,插入排序仍然比选择排序的效率要高,因为选择排序需要完全遍历数组,以等差数列的时间遍历完并选出最大最小数,而插入排序只需要挪动前面有序部分的比新数据大或小的数,而不需要挪动再往前的数。

选择排序和插入排序两者,仅有在逆序数组的情况下效率一致,都是O(N2)。


🍧堆排序

关于堆的排序和建堆方式已经在前面章节中详细阐述,本章仅作为与各个排序算法间的效率对比而供代码展示和测试。(详情请参考该博客: 数据结构——堆和堆的两大应用(堆排序,TopK问题)_VelvetShiki_Not_VS

🎀建堆函数

void HPCreate(int* arr, int sz, COM rule)
{
	int end = (sz - 2) / 2;						//从最后一个非叶子结点向前依次向下调整,直至调整到头结点
	while (end >= 0)
	{
		AdjustDownward(arr, sz, end, rule);		//向下调整建堆
		end--;
	}
}

其中用到了向下调整算法与标识建堆规则的函数指针

🎀建堆规则

typedef bool(*COM)(int, int);		//比较大小的函数指针,返回真假
bool Ascend(int a, int b)			//用于取更大值给父节点的建大堆函数
{
	return a > b;
}
bool Descend(int a, int b)			//用于取更小值给父节点的建小堆函数
{
	return a < b;
}

🎀向下调整算法

void AdjustDownward(int* arr, int sz, int parent, COM rule)
{
	int child = parent * 2 + 1;			//根据传入的父节点和公式计算左子结点下标
	while (child < sz)
	{
		if (child + 1 < sz && rule(arr[child + 1], arr[child]))		//对比右子结点值是否比左子值更大或更小
		{
			child = child + 1;										//如果条件成立,将待交换下标赋值于右子下标
		}
		if (rule(arr[child], arr[parent]))			//父子值对比,建大堆子比父大交换,建小堆子比父小交换
		{
			Swap(&arr[child], &arr[parent]);
			parent = child;							//子给父,继续向下迭代交换
			child = child * 2 + 1;					//子将自身当做父结点,通过公式向下找到左子结点
		}											//当子下标大于数组容量时停止迭代
		else
			break;
	}
}

建好堆以后,进行堆排序,升序建大堆,降序建小堆,堆排序规则与建堆规则保持一致

void HPSort(int* arr, int sz, COM rule)
{
	int end = sz - 1;						//建堆完成后,定义堆末下标end
	while (end > 0)							//当end向前递减至堆顶下标,排序完成
	{
		Swap(&arr[0], &arr[end]);			//首尾交换,将最值数据沉淀到堆尾
		AdjustDownward(arr, end, 0, rule);	//堆顶数据向下调整保持堆结构,方便后续重复交换与调整
		end--;
	}
}

🌈测试用例

int arr[] = { 6,1,8,10,5,4,7,6,5,10,6,0,0 };	//排升序,建大堆
int arr1[] = { 1,2,3,4,5,6,7,8,9,10 };			//排降序,建小堆
int sz = sizeof(arr) / sizeof(int);
HPCreate(arr, sz, Ascend);			//可将Ascend改为Descend建小堆并排降序
HPSort(arr, sz, Ascend);
for (int i = 0; i < sz; i++)
{
    printf("%d ", arr[i]);
}

🌈观察结果

//升序结果
0 0 1 4 5 5 6 6 6 7 8 10 10
//降序结果
10 9 8 7 6 5 4 3 2 1

🌠交换排序

🍧冒泡排序

冒泡排序是最简单的一种交换排序,也是一种稳定的排序,它能够在不改变相同数据间相对顺序位置的情况下,将最值数据通过相邻数据的对比交换一个个排到数组后方,经过多轮排序后最终使整个数组值保持有序。

  1. 经典的冒泡排序算法描述从一串数据的首元素下标开始,不断向后遍历。以升序为例,如果相邻的两个元素的前一个存在更大值,则将该值与后一个值交换,如果该值仍比其后一个元素大,再进行交换,这样通过层层交换可以把最大值在一遍从头到尾遍历中送到数组的最末端,即将最大值“冒”到了最后;次大数据在下一轮单趟冒泡中也会被“冒”到右侧第二个位置上,这样层层循环,当总循环进行了sz - 1次时,所有数据冒泡完毕,数组有序。

  2. 总循环次数为sz - 1可以理解为共有多少对数据进行了比较,比如10个数据共有9对相互比较,4个数据共需要相互两两对比3次,所以总趟数是由数组中多少对数据决定的。而单趟排序为sz - 1 - i可以理解为每次单趟排序的总对数都比上一轮少一个,因为随着冒泡不断进行,总有最大或最小的数据被冒到了最后方或次后方,这些数据因为已经是最值数据沉淀到了后方,所以已经部分有序而没有必要再与它们进行两两比较了,所以单趟排序总会把上几轮已经冒过的数据排除开来,而仅对前几轮没处理过的前面部分的数据再对比。
    在这里插入图片描述

  3. 在优化版的冒泡排序中我们加入了循环终止遍历标识flag,该变量的目的是如果数组在排序过程中已经有序,则无需再向后进行多余的遍历的多对数据的对比了,所以在一趟遍历结束后,如果这个标识flag没有被置假,则表示数组有序,直接break跳出循环。而如果发生了数值间的交换,则在该趟冒泡排序时就一定会将flag置成false,则不结束整趟循环,下一趟冒泡继续。需要注意的是,flag将会在每趟排序的最开头被初始化为true。

🎀优化版冒泡排序

void BubbleSort(int* arr, int sz)
{
	for (int i = 0; i < sz - 1; i++)				//整趟排序循环,总次数为数组容量-1
	{
		bool flag = true;							//循环终止遍历标识,如果单趟循环下来flag保持为真,则已经有序
		for (int j = 0; j < sz - 1 - i; j++)		//单趟冒泡循环,根据已排好的数据量不断减少次数
		{
			if (arr[j] < arr[j + 1])				//规则可变,">"升序,"<"降序
			{
				Swap(&arr[j], &arr[j + 1]);			//相邻数据交换
				flag = false;						//如果存在数据交换,则当前数组不有序,将flag置反
			}
		}
		if (flag)		//如果单趟有序,则进入该判断,停止后续多余的无意义遍历再对比循环
		{
			break;
		}
	}
}

🌈测试用例

int arr1[] = { -6,7,-15,6,3,2,1,8,1, 0 };		//排降序
int arr2[] = { 10,9,8,5,7,6,4,2,1,0, 3 };		//排升序
int sz = sizeof(arr1) / sizeof(int);
BubbleSort(arr1, sz);
for (int i = 0; i < sz; i++)
{
    printf("%d ", arr1[i]);
}

🌈观察结果

//降序结果
8 7 6 3 2 1 1 0 -6 -15
//升序结果
0 1 2 3 4 5 6 7 8 9 10

👑总结

冒泡排序的时间复杂度为O(N2),在有序或接近有序的情况下与插入排序一样是O(N),且经过标识变量flag优化后效率有所提升,但在乱序或完全逆序的情况下效率仍然低的可怕。虽然它在交换排序算法中与其他神仙算法格格不入,但胜在该算法比较稳定,相同数据间的相对位置不会改,且比较好理解,对初学者而言是一个容易上手,概念相对易懂的好算法了。


🍧快速排序

作为几乎是所有时间复杂度同为O(N* logN)的排序中的最优算法,快速排序在各个领域都应用甚广,其采用的分治思想(Divide & Conquer Method)采纳了二叉树分而治之的思路,也能瞥见前序遍历的方法在里面。快速排序是霍尔Hoare最早于1962年提出来的对其他排序的改进,是一种划分的交换排序,因此最早版本的Hoare快排是其他各种版本快排的基础,而各种快排的整体思路可归结如下:

  1. 选取基准数key作为指针遍历数值参照。
  2. 根据特定规则交换遍历数与key的值。
  3. 以key为分界,将数组划分多个子区间并分治排序。

下面将详细介绍以Hoare版本为基础的快排以及其他优化版本的快排算法。

🥝Hoare版

Hoare版快速排序是所有快排的原始版本,该版本在操作实现上较为容易,但理解较为复杂。其大体思路可归结如下

  1. 在一串乱序数据中选取一个基准数key,一般建议为选取最左侧数或最右侧数。
  2. 如果选择了左侧数,则定义一个指针从右侧开始向左遍历,边遍历边找比key更大(降序)或更小数(升序)。
  3. 右指针找到后固定不动,左指针开始向右遍历,寻找比key更小(降序)或更大(升序)数。
  4. 两者均找到对应值后,左右指针值交换,继续上述步骤,直至相遇停止。
  5. 相遇后将左右指针所指值与key值交换,则key左侧均为比key更大而右侧均更小(降序思路)。
  6. 把key作为分界,以二叉前序遍历思想对每个分区间重复1,2,3,4,5步骤即可将整个数组排序完成。
    在这里插入图片描述

上图以降序的单趟部分排序为例,阐述了left与right下标在数组中的移动以及交换过程,有几个隐藏的细节仍需要注意:

  1. key本身存储的是下标编号,arr[key]为key下标所指向的对应值,keyval存储的是arr[key]值的临时存储,而arr[key]值的变化是不会影响keyval的变化,因为key与arr[key]都会在left和right的遍历过程中发生值的交换,所以需要使用keyval临时存储起来,防止参照对应值的变化。
  2. 如果key的设置默认在最左侧,则最先需要移动的应该是right,而不能让left先向右移动。以上图的降序为例,让right先移动,当right遇到比key更大的值时就停止移动,这样当left向右移动与right相遇时,可以保证相遇位置的值比keyval更大,此时让arr[key]与arr[right]交换,就可以将该更大值移动到左侧,而arr[key]的值则可以换到出现在相遇位置并将key挪动到相遇位置上,此时一定保证key左侧的值一定不小于arr[key],而其右侧值一定不大于arr[key]。

🎀Hoare单趟快排(降序为例)

int HoareSort(int* arr, int left, int right)			//将闭区间待排数组的单趟左右下标传入
{
	int key = left, keyval = arr[key];					//选取最左侧下标为key,将该值存储到keyval中
	while (left < right)								//当左右下标没相遇时,找比keyval更大更小值交换
	{
		while(left < right && arr[right] >= keyval)		//right先向左侧移动
			right--;
		while(left < right && arr[left] <= keyval)		//rihgt找到后,再移动left向右移动
			left++;
		Swap(&arr[left], &arr[right]);					//都找到后,交换两者值
	}
	Swap(&arr[key], &arr[left]);						//当left与right相遇后,交换该值与key对应值
	key = left;											//将相遇下标赋值给key传回
	return key;
}
  1. 在left下标与right下标没相遇之前,right下标不断向左侧移动并找出比keyval更大的值,如果找到就停下,等待与left下标向右侧移动找到的比keyval更小值交换。在这个过程中难免会遇到这样的情况:
    在这里插入图片描述

即右下标right不断向左侧寻找比keyval更大的数,但一直没有找到;或者左下标left向右寻找比keyval更小的值也没有找到导致的一致自增,都会最终造成left > right或者直接越界的情况,所以为了应对所有情况,不仅单趟总循环条件为左下标小于右下标,左右下标分别的自增和自减循环也需要加上left < right的条件,并与自身与keyval比大比小的条件相逻辑与才能确保left不超过右下标及不越界的情况下得到数据间的有效交换。

  1. 上述情况仅对单趟Hoare排序做了说明,可见虽然以key为下标的左右两侧将数据归类,使key的左侧数据都大,右侧数据都小,但此时数组中数据仍然不有序,为了使整个数组都有序,此时将执行二叉分治,对每个子区间都进行单趟排序。
    在这里插入图片描述

  2. 单趟排序选出该趟的key后,以二叉树深度优先前序遍历的思路开始对左右已经归类但仍未有序的子闭区间数组进行多次单趟排序,每进行一轮上述步骤的Hoare排序可以选出一个key,而该key所对应的值一定为最终该数值所在数组中的固定位置不会再次移动了。每选出一个key就可以再将子区间划分为更小的子区间,左子树区间的闭区间范围以上一次递归所传入的[Head, key - 1]为子区间范围,而右子树的闭区间为上一次递归传入的[key + 1, Tail]子区间范围。因为key已经排序到了正确的位置上,所以无需再将其传入子区间范围了。

🎀Hoare递归子排序

void QuickSort(int* arr, int Head, int Tail)	//调用函数时,将闭区间的数组首尾下标传入
{
	if (Head >= Tail)							//递归返回的结束条件是当头下标位于尾下标或超过时,直接返回,排序完成
	{
		return;
	}
	int key = HoareSort(arr, Head, Tail);		//初始进入函数,在整个数组范围内选出key
    QuickSort(arr, Head, key - 1);				//开始左右子树递归排序
    QuickSort(arr, key + 1, Tail);
}
  1. 将Hoare的单趟排序与递归函数分离开的原因是,一方面增加了代码的可读性,一方面在后续的其他优化版本快排中,因为整体大逻辑都引用了递归排序子区间和key的选取和交换,所以只需将各种方法都封装到不同函数中,将递归找key并排序的值返回给主调函数的key值即可。

🌈测试用例

int arr1[] = { 6,1,2,7,9,3,4,5,10,8 };		//降序测试
int arr2[] = { 10,9,8,7,5,6,4,3,2,1 };		//升序测试
int sz = sizeof(arr1) / sizeof(int);
QuickSort(arr1, 0, sz - 1);
for (int i = 0; i < sz; i++)
{
    printf("%d ", arr1[i]);
}

🌈观察结果

//arr1降序结果
10 9 8 7 6 5 4 3 2 1
//arr2升序结果
1 2 3 4 5 6 7 8 9 10

升降序的算法调整只需将arr[right]和arr[left]与keyval的对比规则改变一下即可。

🥝坑位版

快速排序的另一版本我们称之为坑位法,该算法相比于Hoare版本相对更容易理解,也更好地解释了为什么当key选取左侧数据时必须让right先移动,而key选择了最右侧数据时必须让left先移动的原因,在逻辑上更能让人接受。坑位法大整体逻辑与Hoare版类似,只是在些许地方略微有所差异,这次以升序数组为例,如下为坑位法的整体思路:

  1. 选取最左侧数据为坑,下标为pit,将pit所指向值arr[pit]使用临时变量pitval保护起来,防止在后续与其他数交换时将该参照标准修改。
  2. 为了弥补最左侧数据被挖走而留下的坑,需要先让右下标right先行移动,寻找比挖走的数更小的值填补进去。
  3. 当右下标找到对应的值arr[right]比pitval小时,将该值从当前right下标中挖走,补进pit坑里,此时pit坑被填满,但留下了新的坑位right,将right的下标赋值给pit标识当前的新坑位。
  4. 左边的坑弥补完成,右侧新城的新坑需要left向右找比pitval更大的值去弥补右侧的新坑。当left不断自增并发现其对应值arr[left]比pitval大时,将该值从当前left下标中挖走,补进右侧pit坑中,此时pit坑被填满,但留下了新的坑位left,将left的下标赋值给pit标识当前的新坑位。
  5. 如此往复直至左右下标相遇时,将初始的最左侧坑位数据填补进两者共同指向的坑中,则全部坑位填补完成。
  6. 此时pit左侧数据均比arr[pit]小,右侧数据均比arr[pit]大,将该pit返回用作后续递归的子数组排序。

单趟坑位排序原理图
在这里插入图片描述

🎀坑位单趟快排(升序为例)

int PitSort(int* arr, int left, int right)		//传入闭区间首尾下标
{
	int pit = left, pitval = arr[pit];			//选取最左侧数作为坑位,存储该值到pitval中
	while (left < right)						//当左右下标没相遇前,循环继续
	{
		while (left < right && arr[right] >= pitval)right--;	//右下标向左侧找比keyval小
		arr[pit] = arr[right];									//找到就将找到的值放入坑位pit中,此处成为新坑位
		pit = right;
		while (left < right && arr[left] <= pitval)left++;		//左下标向右侧找比keyval大
		arr[pit] = arr[left];									//找到就将找到的值放入坑位pit中,此处成为新坑位
		pit = left;
	}
	arr[pit] = pitval;		//左右下标相遇,此时一定有坑,将存储的最左侧值放入该坑位中,并返回该坑下标
	return pit;
}

🎀坑位法递归调用

void QuickSort(int* arr, int Head, int Tail)
{
	if (Head >= Tail)
	{
		return;
	}
	int pit = PitSort(arr, Head, Tail);			//确定单趟坑位pit
	QuickSort(arr, Head, pit - 1);				//以pit为界,分为左右两组递归进行分组排序
	QuickSort(arr, pit + 1, Tail);
}

坑位法仅在确定key(坑位法中换位了pit,本质上是一样的)的方法与Hoare版的单趟逻辑有所不同,在整体的递归和二叉前序分治排序与Hoare共用同一个思路,即两种方法在本质上是共通的,可以通过函数将单趟排序的算法逻辑封装起来,而被主递归函数调用找key(pit),并后续依次对左右子数组中的数据进行Hoare排序或坑位排序。

🌈测试用例

int arr1[] = { 10,9,8,7,5,6,4,3,2,1 };		//升序测试
int arr2[] = { 6,3,8,7,2,4,1,5,0,10 };		//降序测试
int sz = sizeof(arr) / sizeof(int);
QuickSort(arr, 0, sz - 1);
for (int i = 0; i < sz; i++)
{
    printf("%d ", arr[i]);
}

🌈观察结果

//升序结果
1 2 3 4 5 6 7 8 9 10
//降序结果
10 8 7 6 5 4 3 2 1 0
🥝前后指针版与算法优化

前面两种方法都采取了选取左侧数据key并使left和right左右下标从两侧向中间移动的方法依次使每个key所对应值排好在有序位置上。前后指针法与上两种方法思路上有所差别,它可以通过一次从左向右的数组遍历即可完成单趟数据的排序以及key值的选择。以升序为例,其整体思路如下:

  1. 选取最左侧数据作为key,将arr[key]对应值使用keyval暂时存储起来,这点与其他方法一致。
  2. 定义临时下标/指针cur和prev,前者用于整个遍历数组并寻找比keyval更小的值,prev用于与cur找到的值自增后进行数据交换,让更小的值挪到前方。如果cur遍历到的值不小于keyval,则只进行cur自增,不与prev交换也不使prev移动。
  3. 当cur遍历越界,此时将prev对应值与arr[key]交换,并使该位置成为新的key下标,将key返回便于后续递归和二叉前序子数组排序。

原理图如下:
在这里插入图片描述

  1. 前后指针版本看上去更加容易理解,但其中有很多细节也值得注意。首先是key的取值,如同前两种算法取最左侧数据一样,对于一个乱序数组而言可能还比较好,因为cur的遍历所取到的arr[cur]对比keyval时大时小。如果某种极端情况下,比如将一个有序数组逆序的情况,则cur每走一步都将与自身进行交换,显然这样的交换是没有意义的。只有在cur遍历越界时,prev才能与arr[key]进行一次交换,而新选出的key将处于最右侧,即后续递归仅对左子数组进行递归,这样的结果是数组将不断缩小一个单元,直至仅剩最后一个数据时,递归返回使整个数组有序。

逆序数组单趟原理图
在这里插入图片描述

由图中可以发现,如果对一个降序数组排升序,或逆置一个已经有序的数组,则前后指针的单趟排序会产生较多的无意义数据自身对比,而仅对最后一个数据与key进行数据交换,所导致的后果为时间复杂度为O(N2),因为没有体现分治的思想,而仅对每次递归的一侧子树进行数据排序,前后指针的数组逆序递归图如下:
在这里插入图片描述

此时如果对key的选择稍微优化一下,不总是以最左侧数据作为单趟排序的参照标准,而是选取首,中,尾三个位置下标对应的值所取得的中位数作为keyval,并将该值与最左侧数据交换,此时key再作为最左侧下标,其所对应的值就比较由参考价值了,而不会再极端的逆序情况下一边倒造成仅对一侧子树排序的情况。

🎀三数取中选key

int GetMid(int* arr, int left, int right)
{
	int mid = (left + right) / 2;
	int array[] = { arr[left], arr[mid], arr[right] };
	InsertSort(array, 3);
	if (array[1] == arr[left])
		return left;
	else if (array[1] == arr[mid])
		return mid;
	else
		return right;
}

将key优化为中位数mid效果
在这里插入图片描述

递归解析
在这里插入图片描述

可以看出,采用了三值取中算法优化了key的选择之后,对于极端情况下也能展示出快速排序的分治优势了,如上例将数组逆序,仅需采用一趟排序就可以将数组逆置完毕。

🎀前后指针法(单趟升序)

int PointerSort(int* arr, int left, int right)
{
	int mid = GetMid(arr, left, right);			//首,中,尾三下标对应数值取中选mid
	Swap(&arr[mid], &arr[left]);				//将mid对应值与最左侧数据交换,此时定义key为最左侧数据即为中位数
	int key = left, keyval = arr[key], cur = left + 1, prev = left;		//定义前后指针cur和prev
	while (cur <= right)						//当遍历指针cur没有超过数组闭区间范围时
	{
		while (arr[cur] < keyval && ++prev != cur)	//cur对应数值与keyval比较,若比其小则与自增后的prev值交换
		{
			Swap(&arr[cur], &arr[prev]);
		}
		cur++;
	}
	Swap(&arr[prev], &arr[key]);				//当cur越界,将prev值与arr[key]交换,并使该位置为key的新下标,返回
	key = prev;
	return key;
}

虽然三数取中优化了数组逆序排序的效率,但是明显可以看出,已经有序的数组就没有再向下递归的必要了,因为数组已经有序,再往下递归也是没有意义的。这个时候可以结合其他排序算法来优化剩余部分。

🎀整体递归优化的前后指针算法

void QuickSort(int* arr, int Head, int Tail)
{
	if (Head >= Tail)
	{
		return;
	}
	int key = PointerSort(arr, Head, Tail);
	if (Tail - Head > 15)							//当子数组容量少于15个时,调用直接插入排序来代替递归直接将数组排序
	{
		QuickSort(arr, Head, key - 1);
		QuickSort(arr, key + 1, Tail);
	}
	else
	{
		InsertSort(arr + Head, Tail - Head + 1);	//直接插入排序优化掉多余的递归调用
	}
}

两种排序相结合的方式可以极大减少递归调用的次数,使一些很小数据量的子数组不需要进行额外多余的递归而可以使用直接插入排序对少数据量的较短数组实现直接的排序。

🌈测试用例——10万个随机乱序数使用与不使用三数取中算法排序

//随机生成100000个数
srand(time(0));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
//将这些数据依次等值赋给不同两个数组
for (int i = 0; i < N; ++i)
{
    a1[i] = rand();
    a2[i] = a1[i];
}
//两个数组由两个优化与未优化过的快速排序算法完成排序任务
int begin1 = clock();
QuickSort(a1, 0, N - 1);
int end1 = clock();
int begin2 = clock();
QuickSort1(a2, 0, N - 1);
int end2 = clock();
//观察时间消耗(单位毫秒ms)
printf("QuickSort:%d ms\n", end1 - begin1);
printf("QuickSort1(no mid):%d ms\n", end2 - begin2);

🌈观察结果

//使用了三数取中的快排
QuickSort:23 ms
//没优化三数取中的快排
QuickSort1(no mid):27 ms

如果将数据量放大到100万个乱序数据,继续观察结果

//使用了三数取中的快排
QuickSort:266 ms
//没优化三数取中的快排
QuickSort1(no mid):270 ms

可以看出,对于乱序的数组排序,三数取中对于快排的优化并没有那么差距明显。如果选择另一组数据测试,将一个具有10万个数据的已经有序数组逆序呢?

🌈测试用例2——将具有10万个数据的降序数组排升序

//先使用希尔排序将随机数组排成降序
ShellSort(a1, N);
ShellSort(a2, N);
//再分别对这两个降序数组使用两种快速排序算法比较时间
int begin2 = clock();
QuickSort(a1, 0, N - 1);
int end2 = clock();
int begin4 = clock();
QuickSort1(a2, 0, N - 1);
int end4 = clock();

🌈观察结果

//使用了三数取中的快排
QuickSort:3 ms
//没优化三数取中的快排
QuickSort1(no mid):1189 ms

解释:
在这里插入图片描述

可以看出,三数取中对于一个已经有序的数组排逆序是相当有效的,这种优化极大提高了快速排序算法在极端情况下的优化效率,使得原本在完全逆序的数组中时间复杂度为O(N2)的快速排序算法,经过三数取中选key的优化之后回到了O(N* logN)的时间复杂度,进入了排序的第一梯队。

🌈测试用例3——同时使用了三数取中和直接插排优化的快排与没有任何优化的快排排序10万个数,观察它们的递归调用次数以及排序完成时间消耗。

//优化的快排算法
QuickSort递归次数:19130次
QuickSort时间消耗:19 ms
//无任何优化的快排算法
QuickSort1递归次数:74470QuickSort1(no mid&InsertSort):22 ms

👑总结

经过三数取中和直接插入排序优化过的快排算法虽然在时间上略胜于没有任何优化的快速排序,但是对于大量的连续数而言并没有在时间上拉开很大差距,这也说明快排算法本身已经对数据处理的非常快了,对于100万个有序数组排逆序只需要0.2秒左右,而对于有序的10万个无序数据排顺序仅仅只需0.02秒,足见快速排序无愧"快速"之名。而三数取中的主要优化在于对有序数组排逆序上,可以极大提升快排效率,使整体快排时间复杂度从O(N2)的最坏情况拉回到了平均情况的O(N* logN),并且直接插入排序也能明显减少分治递归调用排序函数的次数,极端情况下可以优化掉接近80%的多余递归调用次数,对于内存空间较小的计算机而言是一个比较友好且效率的算法。

🍧快速排序的非递归实现

快速排序还能使用非递归的手段来实现,这里需要借用到栈的结构和循环。使用栈结构Stack的原因是,递归本质上起始就是函数不断在栈上开辟栈帧,层层向内存的高地址上压入新的递归函数栈帧,并不断调节寄存器变量ebp与esp来调节开辟与销毁的内存空间。当递归调用相同函数时,就是将新的函数压栈;而递归返回的阶段就是弹栈,我们可以手动控制压栈时的子数组范围来达到等效的递归效果。
在这里插入图片描述

🎀非递归的栈实现

void QuickSortStack(int* arr, int Head, int Tail)
{
	ST* Bot = STInit();				//初始化一个栈
	STPush(Bot, Tail);				//首先将待排序的数组首尾下标压栈,且首下标压栈发生在尾下标压栈之后
	STPush(Bot, Head);
	while (!STEmpty(Bot))			//当栈不为空,先将栈顶两个下标依次取出,判断是否是合法区间
	{
		int left = STTop(Bot);
		STPop(Bot);					//取顶的同时必须弹栈,才能取到次栈顶元素
		int right = STTop(Bot);
		STPop(Bot);
		if (left < right)			//如果闭区间范围合法,则进入排序流程
		{
			int key = PointerSort(arr, left, right);	//根据所给区间选key并划分更小闭区间
			STPush(Bot, right);		//将更小的左右子树闭区间下标压栈,重复选key与左右更小子区间排序
			STPush(Bot, key + 1);	//同样的,首下标压栈发生在尾下标压栈之后
			STPush(Bot, key - 1);
			STPush(Bot, left);
		}
		else						//如果区间不合法,比如左右下标相等的单元素情况或右下标越界情况,直接跳过该次排序
		{							//继续下一轮取顶判合法区间并排序
			continue;
		}
	}
	STDestroy(&Bot);				//排序完成后,将栈空间销毁
}

其中关于栈结构的有关描述在前面的章节已经详细描述过,链接在此。在整个过程中,需要注意的细节主要有几个。

  1. 快速排序的递归本质上也是函数栈帧的不断压栈过程,在该非递归循环模拟递归中首先将数组首尾两个下标压入栈中其实就是确定了待排序数组的整个有效区间,并调用单趟排序算法选出合适的key下标,为之后子区间压栈和分治排序做准备。
  2. 下标必须是闭区间的原因是,闭区间能很好的确定排序每个待排序数组的确切范围,如果选择开区间将会使单趟排序参数也连带修改,造成代码可读性较差,不容易理解且逻辑性没有闭区间的数组那么好。
  3. 下标必须从右到左的顺序压栈的原因是,栈的数据结构中数据的压入和弹出遵循先进后出(FILO)规则,秉承前序遍历的排序思路,子数组的排序需要从根数组向左子树不断深入,而不是先从右子树开始排序,而为了从左侧开始,就必须先将右子树下标以从右向左的顺序压栈,这样每两次取顶拿到的下标传参给单趟快排函数才能达到依次从左向右取出的效果。
  4. 无论是采取递归还是非递归栈的快速排序算法,它们的空间复杂度都介于O(N)~O(logN)之间,因为递归或压栈都需要借助内存的辅助空间来帮它们完成函数栈帧的建立或下标的压栈,但由于今天各种计算机的内存已经不像以往捉襟见肘,所以快速排序在各个领域都比较实用。

🌈测试用例

int arr1[] = { 10,9,8,7,5,6,4,3,2,1 };			//排升序
int arr2[] = { 68,15,11,34,66,49,0,17,95,80};	//排降序
int sz = sizeof(arr1) / sizeof(int);
QuickSortStack(arr1, 0, sz - 1);
for (int i = 0; i < sz; i++)
{
    printf("%d ", arr1[i]);
}

🌈观察结果

//升序结果
1 2 3 4 5 6 7 8 9 10
//降序结果
95 80 68 66 49 34 17 15 11 0

🌠归并排序

归并排序的思想是建立在归并算法对于多个有序数据串取大(降序)或取小(升序)放入到额外的一个内存空间中,结合前序遍历的递归思路,先将整个数组拆分成一个一个零散的单个数据,再一点点组成小的有序数据串同时不断递归返回给上一层,这样层层将有序的子数组回馈给上层的更大数组以达到归并操作的前提,就可以对所有数据进行归并操作并排序了。归并排序是二叉分治算法的典型应用,它会先使子序列有序,再使子序列段间有序,最后将两个有序表合并成一个,称为二路归并。

🍧归并的递归实现

归并排序使用二叉树的前序递归与二路归并方法,其排序的大体思路可以细分如下:

  1. 将一个数组根据首尾下标取中间位置mid,分为[0, mid]与[mid + 1, sz - 1]两个大区间,采用递归传参分别进入左子树与右子树并执行前序递归向深处递归。
  2. 因为数组每递归向下一次都要对半拆分为两个数组,当达到同一路径的最下层时,一定为仅有一个元素的叶子结点,单个元素无需排序,而对其上一层包含两个元素的数组开始排序。
  3. 排序使用的方法是递归,即将一个数组的左右对半分为两个数组向下递归返回后,两个数组分别独立而有序,此时定义两个指针分别从左右数组的左侧开始向右遍历,如果是升序就取更小的一个数据存入额外的数组中,注意不能将该数直接覆盖其本身,因为这可能会造成数据的覆盖而丧失排序的意义,所以需要借助额外空间来临时存储,当归并结束后再将归并得到的对应数值更新覆盖回去,则可以得到相对有序的数组。

递归排序原理图
在这里插入图片描述

可以看出,辅助空间是独立于原数组而存在,对该空间的数值归并结果并不会直接影响到原数组内容,而是等到归并结束确认有序后再整体内存拷贝给原数组的对应下标上,并将该结果递归返回给上一层,等待遍历右子树后再进行后续整体的递归。

🎀创建辅助空间

void MergeSort(int* arr, int sz)
{
	int* tmp = (int*)malloc(sz * sizeof(int));		//辅助空间容量应与待排序数组总容量一致
	assert(tmp);
	Subsort(arr, tmp, 0, sz - 1);					//调用递归归并排序函数
	free(tmp);										//排序完成,原数组已由临时空间整体内存拷贝有序,释放临时空间
}

🎀归并排序(升序为例)

void Subsort(int* arr, int* tmp, int Head, int Tail)
{
	if (Head >= Tail)					//当归并区间不合法,递归返回
	{
		return;
	}
	int mid = (Head + Tail) / 2;		//二分选mid,以中间数为界将每个大区间分为左右两个子区间
	int left = Head, leftend = mid, right = mid + 1, rightend = Tail, i = Head;		//定义归并控制变量
	Subsort(arr, tmp, Head, mid);		//按照前序遍历思路先递归至左子树深处
	Subsort(arr, tmp, mid + 1, Tail);	//左子树排序完成后再递归至右子树深处
	while (left <= leftend && right <= rightend)	//归并操作,选更大数给辅助空间,直至其中一边越界
	{
		if (arr[left] <= arr[right])	//如果左数组数值比右数组小,则归并到临时数组tmp中
			tmp[i++] = arr[left++];
		else
			tmp[i++] = arr[right++];	//否则右数组值更小,将右数组值归并到tmp中
	}
	while(left <= leftend)				//将剩余一侧的未归并完全数组全部归并
		tmp[i++] = arr[left++];
	while(right <= rightend)
		tmp[i++] = arr[right++];
	memcpy(arr + Head, tmp + Head, (Tail - Head + 1)*sizeof(int));		//每归并结束一次,整体拷贝回给原数组
}

向递归过程加入如下语句方便监控每次递归和内存拷贝时原数组的实时数据变化

static int k = 1;
printf("第%d次对[%d,%d]区间的归并:", k++, Head, Tail);
for (int j = Head; j <= Tail; j++)
{
    printf("%d  ", arr[j]);
}
puts("\n");

对于上图给出的数据10, 6, 7, 1, 9, 8进行归并的递归排序,可以观察到从左子树到右子树分别进行了如下递归趟数与每次的归并结果:
在这里插入图片描述

🌈测试用例

int arr[] = { 3,1,8,9,0,4,7,2,5,10,6 };		//排升序
int arr1[] = { -3,7,11,24,0,100,66,101,50,30,-9,0 };	//排降序
int sz = sizeof(arr) / sizeof(int);
MergeSort(arr, sz);

🌈观察结果

//升序结果1次对[0,1]区间的归并:1  3				//对0~2上拆成的2个子区间归并(mid为2)2次对[0,2]区间的归并:1  3  83次对[3,4]区间的归并:0  9				//对3~5上拆成的2个子区间归并4次对[3,5]区间的归并:0  4  95次对[0,5]区间的归并:0  1  3  4  8  9	//0~5大区间整体归并6次对[6,7]区间的归并:2  7				//对6~8上拆成的2个子区间归并(mid为8)7次对[6,8]区间的归并:2  5  78次对[9,10]区间的归并:6  10			//对9~10归并9次对[6,10]区间的归并:2  5  6  7  10	//6~10大区间整体归并10次对[0,10]区间的归并:0  1  2  3  4  5  6  7  8  9  10	//最终对0~10整个数组区间整体归并
    
//降序结果1次对[0,1]区间的归并:7  -3			//对0~2上拆成的2个子区间归并(mid为2)2次对[0,2]区间的归并:11  7  -33次对[3,4]区间的归并:24  0			//对3~5上拆成的2个子区间归并4次对[3,5]区间的归并:100  24  05次对[0,5]区间的归并:100  24  11  7  0  -3	//0~5大区间整体归并6次对[6,7]区间的归并:101  66			//对6~8上拆成的2个子区间归并(mid为8)7次对[6,8]区间的归并:101  66  508次对[9,10]区间的归并:30  -9			//对9~11上拆成的2个子区间归并9次对[9,11]区间的归并:30  0  -910次对[6,11]区间的归并:101  66  50  30  0  -9	//6~11大区间整体归并11次对[0,11]区间的归并:101  100  66  50  30  24  11  7  0  0  -3  -9		//最终对0~11整个数组区间整体归并
🍧归并的非递归实现

上述归并的递归实现我们借用了二叉前序遍历的方法,通过不断将一个较大范围的数组拆分成独立的个体数据,并在递归返回时将每个个体归并为小的有序数组,再以此为前提返回给大区间通过归并两个小区间继续调整,这样先拆分,后递归返回,再归并的思路其实不使用二叉分治的递归方法也是有其他路可循的,那就是想办法不断将递归的范围缩小,再一步步扩大,即先小递归在大递归的思想。非递归实现的整体思路如下:

  1. 定义一个gap间隔,初始化从1开始,左右区间每个仅包含一个元素,且间距为1个单位。通过两个数组相互比较归并到临时数组tmp上,最终达成的效果为数据间两两有序,但整体仍无序。
  2. gap每次间隔为上一次的二倍,第二次归并开始,gap为2,此时每个区间包含两个元素,左右区间间距为2,归并后可以形成数据间4个为一组的相对有序状态,整体的局部有序范围变大,但可能整体仍未有序。
  3. 再下一轮递归,gap变为4,此时每个区间包含四个元素,左右区间间隔为4个单位,此时再次归并后,可以形成8个一组的相对有序状态,如果数组此时整体有序,则停止归并。

非递归的归并原理图:
在这里插入图片描述

🎀非递归的循环归并

void NonRecurMergeSort(int* arr, int sz)
{
	int gap = 1;		//初始归并间距为1个单位距离
	int* tmp = (int*)malloc(sz * sizeof(int));	//开辟额外内存辅助空间
	assert(tmp);
	while (gap < sz)	//当归并间距大于数组总容量时,归并结束,此时数组有序
	{
		int left = 0, i = 0;
		while (left < sz)
		{
			int leftend = left + gap - 1, right = left + gap, rightend = right + gap - 1;
			if (leftend >= sz || right >= sz)	//边界处理,如果左末下标越界或右起点下标越界,直接结束循环不归并
			{
				break;
			}
			else if (rightend >= sz)	//如果右末下标越界,则将该下标调整至数组末元素下标,以处理未被归并的末元素
			{
				rightend = sz - 1;
			}
			int Head = left;										//需要更新的实参数组起点
			int size = rightend - left + 1;							//需要更新的实参数组个数
			while (left <= leftend && right <= rightend)			//归并开始
			{
				if (arr[left] <= arr[right])
					tmp[i++] = arr[left++];
				else
					tmp[i++] = arr[right++];
			}
			while (left <= leftend)
				tmp[i++] = arr[left++];
			while (right <= rightend)
				tmp[i++] = arr[right++];							//归并结束
			memcpy(arr + Head, tmp + Head, size * sizeof(int));		//内存拷贝需放在每一小次归并结束的循环中而不能放在一趟循环外,是因为可能会拷贝无效的数据
			left += gap;											//跳跃已经归并好的范围,开始对新数据归并
		}
		gap *= 2;													//将每次归并调整的范围扩大,每次扩大二倍
	}
	free(tmp);
}

🎃其中有诸多细节值得注意

  1. 辅助空间tmp必须额外开辟,且容量须与原数组的总容量相同或更大,防止数据在原数组中归并造成对原数据的覆盖。归并结束后需要释放空间,所以归并排序算法不管是递归还是非递归的空间复杂度都为O(N)。

  2. 初始间距gap为1,因为对于归并而言左右数组至少包含一个数据才可对这两个数据进行归并,以及对包含更多数据的数组归并的前提条件是小区间已经有序,所以gap只能从1开始,之后每次翻倍,才能对更大区间的子有序区间进行更大范围的归并操作。

  3. 归并时尤其需要注意区间下标的越界和不合法问题,left下标每次初始化为0,且left的增长会对leftend和right,rightend产生连带影响,所以需要对leftend, right和righend的区间合法性进行判断:

    ☣️如果leftend或right越界,说明此时仅存在左区间有数据或两个区间均不存在数据需要排序,直接结束本轮递归,开始下一轮递归并重置left为0。

    ☣️如果rightend越界,而right没有越界,说明右区间仍有数据未被归并,但是其中数据量比左区间[left, leftend]少,修正右区间范围将rightend调整到数组范围内,即使rightend = sz - 1,即可保证左右区间具有归并关系并均合法。

  4. 临时数组tmp对原数组的内存拷贝,memcpy应该即使发生在每一次归并结束之后,而不能发生在单趟归并结束之后。因为临时数组没有归并的数据赋值之前,其本身数组中存在的是随机数,如果原数组具有5个数据,而单趟归并仅发生了4个或更少数据的归并赋值,在该趟结束后就会将归并正常的4个数据以及一个无意义的随机数全部拷贝回原数组中,将原数组剩下的一个数进行了无意义的替换,造成错误。

  5. 向上述函数加入便于监测观察的归并提示可以看到每次归并函数具体做了哪些工作。

static int k = 1;
printf("第%d次对gap为%d左区间[%d,%d]和右区间[%d,%d]的所有数据归并:", k++,gap, Head,leftend,Head + gap, rightend);
for (int j = Head; j < Head + size; j++)
{
    printf("%d  ", arr[j]);
}
puts("\n");

对于上图的0,4,2,5,3五个数据的降序测试,加入上述代码监测后有:
在这里插入图片描述

可以发现,每次递归结果与理论预测完全一致。

🌈测试用例

int arr[] = { -7,7,1,14,9,10,-66,101,5,3,-9,0 };	//降序测试
int arr1[] = { 3,1,8,10,5};		//升序测试
int sz = sizeof(arr) / sizeof(int);
int sz1 = sizeof(arr1) / sizeof(int);
NonRecurMergeSort(arr, sz);
NonRecurMergeSort(arr1, sz1);

🌈观察结果

//降序结果1次对gap为1左区间[0,0]和右区间[1,1]的所有数据归并:7  -7				//gap为1,相邻数据两两归并2次对gap为1左区间[2,2]和右区间[3,3]的所有数据归并:14  13次对gap为1左区间[4,4]和右区间[5,5]的所有数据归并:10  94次对gap为1左区间[6,6]和右区间[7,7]的所有数据归并:101  -665次对gap为1左区间[8,8]和右区间[9,9]的所有数据归并:5  36次对gap为1左区间[10,10]和右区间[11,11]的所有数据归并:0  -97次对gap为2左区间[0,1]和右区间[2,3]的所有数据归并:14  7  1  -7		//gap为2,一对一对数据归并8次对gap为2左区间[4,5]和右区间[6,7]的所有数据归并:101  10  9  -669次对gap为2左区间[8,9]和右区间[10,11]的所有数据归并:5  3  0  -910次对gap为4左区间[0,3]和右区间[4,7]的所有数据归并:101  14  10  9  7  1  -7  -66	//gap为4,两对两对归并(right越界)11次对gap为8左区间[0,7]和右区间[8,11]的所有数据归并:101  14  10  9  7  5  3  1  0  -7  -9  -66		//(rightend越界)
    
//升序结果1次对gap为1左区间[0,0]和右区间[1,1]的所有数据归并:1  32次对gap为1左区间[2,2]和右区间[3,3]的所有数据归并:8  10		//(right越界)3次对gap为2左区间[0,1]和右区间[2,3]的所有数据归并:1  3  8  10	//(leftend越界)4次对gap为4左区间[0,3]和右区间[4,4]的所有数据归并:1  3  5  8  10	//(rightend越界)

👑总结

不管是递归还是非递归,归并排序的时间复杂度都为O(N* logN),且它是一种相对稳定的排序算法,因为它严谨且全面地运用到了二叉的分治思想,结合归并方法将多个区间的数据拆分并归并同一,无论在最好的有序或接近有序情况下还是逆序或乱序的情况下,时间复杂度都稳定不变,是一种比较高效且稳定的排序方法。但其弊端在于需要开辟额外的数组空间,如果数据量极大的情况下,因为它的空间复杂度为O(N),所以内存空间的占用还是十分可怕的,因此归并排序不仅适用于常规的数据排序场合中,更适合拥有海量数据的外存储及数据库排序的应用场景。


🌠计数排序

计数排序对于数据量重复出现较多次,数据密度较高的数组是比较适合的一种排序算法,其原理如下。
在这里插入图片描述

🎀计数排序

void CountSort(int* arr, int sz)
{
	int max = arr[0], min = arr[0];		//定义存储待排序数据的最大值和最小值
	for (int i = 0; i < sz; i++)		//同选择排序一样找大和找小
	{
		if (max < arr[i])
			max = arr[i];
		if (min > arr[i])
			min = arr[i];
	}
	int range = max - min + 1;				//根据最大和最小数确定数值范围的总区间
	int* tmp = (int*)malloc(range * sizeof(int));	//开辟这个区间的辅助空间,用于计数
	assert(tmp);
	memset(tmp, 0, range * sizeof(int));	//将该空间所有计数初始化为0次
	for (int i = 0; i < sz; i++)			//统计每个数值在此区间范围内出现的次数
	{
		tmp[arr[i] - min]++;
	}
	int j = 0;
	for (int i = 0; i < range; i++)			//升序算法,从头遍历到尾,当计数不为0时将该数值按序插回原数组
	{
		while (tmp[i]--)
		{
			arr[j++] = i + min;
		}
	}
    //for (int i = range - 1; i >= 0; i--)	//降序算法
	//{
	//	while (tmp[i]--)
	//	{
	//		arr[j++] = i + min;
	//	}
	//}
	free(tmp);								//计数归0,排序结束,释放辅助计数空间
}

🌈测试用例

int arr[] = { 1005, 1003, 1001, 1020, 1015, 1000 };		//升序测试
int arr1[] = { 6,1,8,10,5,4,7,6,5,10,6,0,0 };			//降序测试
int sz = sizeof(arr) / sizeof(int);
CountSort(arr, sz);
for (int i = 0; i < sz; i++)
{
    printf("%d ", arr[i]);
}

🌈观察结果

//升序结果
1000 1001 1003 1005 1015 1020		//映射范围从1000到1020,对应映射值从0到20
//降序结果
10 10 8 7 6 6 6 5 5 4 1 0 0			//映射范围从0到10

👑总结
在这里插入图片描述


⭐后话

  1. 博客项目代码开源,获取地址请点击本链接:CSDN-排序/SortTest · VelvetShiki_Not_VS
  2. 若阅读中存在疑问或不同看法欢迎在博客下方或码云中留下评论。
  3. 欢迎访问我的Gitee码云,如果对您有所帮助还可以一键三连,获取更多学习资料请关注我,您的支持是我分享的动力~
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值