排序专题(常见8种)【思路解析和代码实现】【2w字长文】

排序专题(常见8种)

1.排序的概念及其运用

1.1排序的概念

排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。

稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。

内部排序:数据元素全部放在内存中的排序。

外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。

1.2排序运用

排序在很多地方都用的上,可以说是非常常用的一个东西。

比如购物时的价格排序,学校的排序…

image-20240515115838742

1.3 常见的排序算法

image-20240515115940485

// 排序实现的接口
// 插入排序
void InsertSort(int* a, int n);

// 希尔排序
void ShellSort(int* a, int n);

// 选择排序
void SelectSort(int* a, int n);

// 堆排序
void AdjustDwon(int* a, int n, int root);

void HeapSort(int* a, int n);

// 冒泡排序
void BubbleSort(int* a, int n);


// 快速排序递归实现
// 快速排序hoare版本
int PartSort1(int* a, int left, int right);
// 快速排序挖坑法
int PartSort2(int* a, int left, int right);

// 快速排序前后指针法
int PartSort3(int* a, int left, int right);

void QuickSort(int* a, int left, int right);

// 快速排序 非递归实现
void QuickSortNonR(int* a, int left, int right);


// 归并排序递归实现
void MergeSort(int* a, int n);

// 归并排序非递归实现
void MergeSortNonR(int* a, int n);


// 计数排序
void CountSort(int* a, int n);

2.常见排序算法的实现

2.1插入排序

2.1.1直接插入排序

直接插入排序是一种简单的插入排序法,其基本思想是:

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

其实我们在学习排序的时候,我们需要将其拆分成单趟和总体。

  • 每排序一次,做了什么。这是单趟
  • 用多次单趟实现总体排序

我们先来看看直接插入排序的单趟的思想:

  1. 其实就是让一个数往数组这种插入,但是要保持有序。
  2. 怎么保持呢,让数字从数组的最后一个元素依次比较,如果要插入的数字大于比较数字,就直接插入到比较数字后面
  3. 如果如果要插入的数字小于比较数字,那就跟数组的前一个元素去比较,依次比较,直至没有元素,可比较,就说明要插入的数字在这个数组中是最小的,那就插入第一个位置。

我们来看单趟的代码:

int end; // end 代表被比较元素的下标
int tmp = a[end + 1]; // tmp代表 插入的元素,要把tmp往数组中放并保持有序

// 让tmp去和数组中的每个元素对比 tmp大就放在后面,tmp小就继续遍历数组元素去 对比
while (end >= 0) // 只要end下标还在数组范围内,就继续遍历
{
	// 判断end指向的元素 和 tmp谁大谁小
	if (a[end] > tmp)
	{
		// tmp小的话就要让end-- 继续比较
		// 要让a[end]往后挪
		a[end + 1] = a[end];
		end--;
	}
	else
	{
		// tmp大的话,就要在a[end]后面插入
		break;
	}
}
// 走到这里有两种情况,一种 是break出来的 一种是循环结束出来的
// 不管是哪一种情况,都是在a[end]后面插入数据
a[end + 1] = tmp;

既然知道了单趟是如何实现的,我们来分析一下总体该如何实现。

其实就是让每一个后面的元素都往前面去插入,也就是一次单趟。

image-20240515121137933

如图所示,第一次我们让第2个元素往前面插入,然后让第三个元素往前面插入,再让第四个元素往前面插入,… 直到第n次 我们让n-1这个元素往前面插入

总体的代码:

// 插入排序
// 时间复杂度 O(N^2)
// 空间复杂度 O(1)
// 最好的情况是 有序或者接近有序  此时时间复杂度是O(N)
// 最坏的情况是 逆序  此时时间复杂度 O(N^2)
void InsertSort(int* a, int n)// n是数组的元素个数
{
	for (int i = 0; i < n - 1; i++) // i不能是n-1 n-1是最后一个元素下标,但是end代表的是被比较元素的下标
	{
        // 把end+1 插入到[0,end]的有效区间内
		int end = i; // end 代表被比较元素的下标
		int tmp = a[end + 1]; // tmp代表 插入的元素,要把tmp往数组中放并保持有序

		// 让tmp去和数组中的每个元素对比 tmp大就放在后面,tmp小就继续遍历数组元素去 对比
		while (end >= 0) // 只要end下标还在数组范围内,就继续遍历
		{
			// 判断end指向的元素 和 tmp谁大谁小
			if (a[end] > tmp)
			{
				// tmp小的话就要让end-- 继续比较
				// 要让a[end]往后挪
				a[end + 1] = a[end];
				end--;
			}
			else
			{
				// tmp大的话,就要在a[end]后面插入
				break;
			}
		}
		// 走到这里有两种情况,一种 是break出来的 一种是循环结束出来的
		// 不管是哪一种情况,都是在a[end]后面插入数据
		a[end + 1] = tmp;
	}

}

测试代码:

void TestInsertSort()
{
	int a[] = { 9,1,2,5,7,4,8,6,3,5 };
	Print(a, sizeof(a) / sizeof(a[0]));// 9 1 2 5 7 4 8 6 3 5
	InsertSort(a, sizeof(a) / sizeof(a[0]));
	Print(a, sizeof(a) / sizeof(a[0])); // 1 2 3 4 5 5 6 7 8 9

}

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

  1. 元素集合越接近有序,直接插入排序算法的时间效率越高

  2. 时间复杂度:O(N^2)

  3. 空间复杂度:O(1),它是一种稳定的排序算法

  4. 稳定性:稳定

但是直接插入排序也是有缺点的,虽然稳定,但是它并不能判断传进来的数据是否有序,即便有序也会遍历一遍,

  • 最好的情况是 有序或者接近有序 此时时间复杂度是O(N)
  • 最坏的情况是 逆序 此时时间复杂度 O(N^2)

因此针对上述情况,我们需要学习希尔排序

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

希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数gap,把待排序数字分成一个个组,所有距离为gap的分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达gap=1时,所有记录在统一组内排好序

简单来说,希尔排序会做两件事情

  • 预排序 (经过预排序数组会接近有序)
  • 直接插入排序

预排序会做什么呢?

预排序会将原数据,切割成间距为gap的几组数据,然后让这个几组数据内部进行插入排序。

image-20240515205118461

但是gap的选值 是会对排序造成一定影响的。

image-20240515205508338

知道了上面希尔排序的思路后:

我们来编写一下单趟的代码:

	int gap; // 间距
	int end; // end指向一组中的一个数字
	int tmp = a[end + gap];  // tmp表示end所指向这一组的元素的下一个元素
	while (end >= 0)
	{
		// 判断tmp是否比 a[end] 小  
		if (tmp < a[end])
		{
			// 小就要移动
			a[end + gap] = a[end];
			end -= gap;
		}
		else
		{
			// 如果tmp大于等于 a[end]的话,就跳出去
			break;
		}
	}
	// 不管是break出来还是,正常结束遍历出来,end + gap 这个下标位置就是 tmp需要插入的地方
	a[end + gap] = tmp;

知道了单趟,我们就来看看总体。

这里我们常规思想肯定是分别控制每一个组进行排序,但是我们可以进行多组排序。

image-20240515211408314

如图所示,我们让i一直遍历到n-gap的位置。进行多组并排

我们会发现,当gap = 1的时候,就是直接插入排序。

并且我们还有一个问题,当gap比较大的时候,即使进行了多组并排,一次排序下来,仍然不是有序的,还需要再排,也就是缩小gap,然后再进行一次排序。

  • gap越小,越接近直接插入排序,效率越慢。
  • gap越大,越不接近有序。

那gap的取值如何确定呢,到底要取多大呢?一次要缩小多少呢?

image-20240515214028531

注意,这里gap = 5 和gap = 2 的时候都是预排序

我们来看看代码:

// 希尔排序 [时间复杂度:O(N^1.3 ~ N^2)]
void ShellSort(int* a, int n)
{
	// 1. gap > 1 时,相当于 预排序,让数据接近有序
	// 2. 当gap = 1时 相当于直接插入排序

	int gap = n; // 间距

	while (gap > 1)// >1是因为当gap = 2的时候进循环就会变成0 + 1就是1 也就是直接插入排序了。走完这趟肯定有序了
	{
		gap = gap / 3 + 1; // gap / 3到最后肯定会 = 0 
		// + 1 是为了保证gap 最后一次 是 = 1 的 相当于直接插入排序
		// 是为了保证数据是有序的

		for (int i = 0; i < n - gap; i++)// i <  numsSize - gap 实现多组并排
		{
			int end = i; 
			int tmp = a[end + gap]; // tmp表示end所指向这一组的元素的下一个元素
			while (end >= 0)
			{
				// 判断tmp是否比 a[end] 小  
				if (tmp < a[end])
				{
					// 小就要移动
					a[end + gap] = a[end];
					end -= gap;
				}
				else
				{
					// 如果tmp大于等于 a[end]的话,就跳出去
					break;
				}
			}
			// 不管是break出来还是,正常结束遍历出来,end + gap 这个下标位置就是 tmp需要插入的地方
			a[end + gap] = tmp;
		}
		//Print(a, n); // 可以观察每一次预排序后的数据情况 【测试运行时间的时候这里要注释掉】
	}

}

测试代码:

void TestShellSrot()
{
	int a[] = { 9,1,2,5,7,4,8,6,3,5 };
	Print(a, sizeof(a) / sizeof(a[0]));// 9 1 2 5 7 4 8 6 3 5
	ShellSort(a, sizeof(a) / sizeof(a[0]));
    //Print(a, sizeof(a) / sizeof(a[0])); // 1 2 3 4 5 5 6 7 8 9
}

image-20240515220935922

希尔排序的特性总结:

  1. 希尔排序是对直接插入排序的优化。

  2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。

  3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些树中给出的希尔排序的时间复杂度都不固定

  4. 不稳定

2.1.3性能差距

我们说,希尔排序是直接插入排序的优化,那到底优化了多少呢,我们来通过一段代码直观的感受一下。

代码:

void TestOP()
{
	srand((unsigned int)time(NULL));
	const int N = 100000;

	// 给数组创建N个空间
	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();
	InsertSort(a1, N);
	int end1 = clock();

	// 获取希尔排序的运行时间
	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();

	// 打印运行时间
	printf("InsertSort:%d\n", end1 - begin1);
	printf("ShellSort:%d\n", end2 - begin2);


	// 释放掉数组空间
	free(a1);
	free(a2);
}

测试结果:

  • N = 10000时

image-20240516152122262

可以看到希尔排序几乎没有用时就搞定了

  • 当N = 100000时

image-20240516152107922

直接插入用了2958毫秒,希尔排序用了13毫秒,这个性能差距,说明希尔排序的优化是非常巨大的

这里有一道oj题。

排序数组

如果我们用直接插入排序是过不了的,性能不够好,但是我们用希尔排序就可以

2.2选择排序

2.2.1直接选择排序
  • 在元素集合array[i]–array[n-1]中选择关键码最大(小)的数据元素

  • 若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换

  • 在剩余的array[i]–array[n-2](array[i+1]–array[n-1])集合中,重复上述步骤,直到集合剩余1个元素

image-20240516125609600

上面的说法只会选择一个最小的往前面放,但是我们可以多加一个功能

找出最小的和最大的同时往前面和后面放。

其实就是给数组一个begin和end,遍历这个范围内的数,找到最大的和最小的,分别放到begin和end的位置上,然后缩小begin和end的范围,然后继续遍历,继续把最大的和最小的放到begin和end上,循环,直至begin和end相遇,此时数组就是有序的了。

如图所示:

image-20240516125655101

代码如下:

// 选择排序
void SelectSort(int* a, int n)
{
	// 每次遍历数组,将最小的数字和最大的数字,放到数组的begin位置 和 end位置
	int end = n - 1;
	int begin = 0;

	while (begin < end) // 只要begin和 end没有相遇,那就不能确定是有序的
	{
		int mini, maxi;
		mini = maxi = begin;

		for (int i = begin + 1; i <= end; i++)
		{
			// 在begin + 1到 end的范围之间 查找是否有比,a[begin] 更小的数字
			if (a[i] < a[mini])
				mini = i;

			// 在begin + 1到 end的范围之间 查找是否有比,a[begin] 更大的数字
			if (a[i] > a[maxi])
				maxi = i;
		}
		// 经过一次for循环,我们找到了[begin, end]之间,最小的和最大的数的下标
		// 将其放到begin和end的位置上
		Swap(&a[mini], &a[begin]);
		// 如果maxi和begin的位置重叠
		if (maxi == begin)
		{
			// maxi和begin位置重合,那么最大的数最会被换到mini的位置上去
			maxi = mini;  // 因此需要让maxi重新指向最大的数
		}
		Swap(&a[maxi], &a[end]);

		// 交换完就 让begin 和 end缩小范围
		begin++;
		end--;
	}
	
}

但是这里有一个需要注意的问题

如果maxi和begin的位置重叠,我们是不能直接交换他们的。因为mini先和begin交换,此时最大的数,由于跟着begin交换到了mini的位置,maxi指向的不再是最大的数,反而是最小的数,这个时候代码就会出错

image-20240516125922322

因此我们需要处理一下:

		// 如果maxi和begin的位置重叠
		if (maxi == begin)
		{
			// maxi和begin位置重合,那么最大的数最会被换到mini的位置上去
			maxi = mini;  // 因此需要让maxi重新指向最大的数
		}

测试代码:

void TestSelectSrot()
{
	int a[] = { 9,1,2,5,7,4,8,6,3,5 };
	Print(a, sizeof(a) / sizeof(a[0]));// 9 1 2 5 7 4 8 6 3 5
	SelectSort(a, sizeof(a) / sizeof(a[0]));
	Print(a, sizeof(a) / sizeof(a[0])); // 1 2 3 4 5 5 6 7 8 9
}

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

  1. 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用

  2. 时间复杂度:O(N^2)

  3. 空间复杂度:O(1)

  4. 稳定性:不稳定

2.2.1.1性能对比

同样的我们跟前面我们实现的两个排序对比一下效率

代码:

void TestOP()
{
	srand((unsigned int)time(NULL));
	const int N = 100000;

	// 给数组创建N个空间
	int* a1 = (int*)malloc(sizeof(int) * N);
	int* a2 = (int*)malloc(sizeof(int) * N);
	int* a3 = (int*)malloc(sizeof(int) * N);
	
	// 给数组生成随机数,每个数组内的数据都是一样的
	for (int i = 0; i < N; i++)
	{
		a1[i] = rand();
		a2[i] = a1[i];
		a3[i] = a1[i];
	}

	// 获取直接插入排序的运行时间
	int begin1 = clock();
	InsertSort(a1, N);
	int end1 = clock();

	// 获取希尔排序的运行时间
	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();

	// 获取直接选择排序的运行时间
	int begin3 = clock();
	SelectSort(a3, N);
	int end3 = clock();

	// 打印运行时间
	printf("InsertSort:%d\n", end1 - begin1);
	printf("ShellSort:%d\n", end2 - begin2);
	printf("SelectSort:%d\n", end3 - begin3);


	// 释放掉数组空间
	free(a1);
	free(a2);
	free(a3);
}

结果如图所示:

  • 当N = 10000时

image-20240516152207178

这个时候可能还没什么区别,但是直接选择排序的效率是比直接插入排序慢的

  • 当N = 100000时

image-20240516152230348

我们可以看到直接选择排序的效率非常低,因为哪怕一个数组接近有序,也要不断地去遍历不断的去判断。就算直接传一个有序的数组进去,它也要遍历去判断

2.2.2堆排序

堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。

通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。

堆排序也是一种选择排序,但是它的效率就比直接选择排序高很多,原因我们在二叉树的专题中有详细的讲解、

现在我们来看看堆排序该如何实现

这里我们要排升序,那就要建大堆

建堆就要用到堆向下调整算法

具体的实现思想和过程,我们已经在二叉树专题详细解析过了,这里就不分析了

void AdjustDwon(int* a, int n, int root) // 向下调整算法 [前提是左右子树都是大堆]
{
	// 找到父节点和孩子节点
	int parent = root;
	int child = parent * 2 + 1; // 默认左孩子

	while (child < n)
	{
		// 判断左孩子 和 右孩子谁最大
		if (child + 1 < n && a[child] < a[child + 1])
		{
			child++; // 右孩子大
		}

		// 此时孩子节点是左右孩子中最大的,让其跟父亲节点去对比
		if (a[parent] < a[child]) // 我们要实现的是大堆,父节点小于孩子节点要交换
		{
			Swap(&a[parent], &a[child]);

			// 交换完 要迭代
			parent = child;
			child = parent * 2 + 1; // 默认左孩子
		}
		else
		{
			// 走到这里 就说明父节点大,那就不用交换了,退出循环
			break;
		}

	}
}

堆排序实现如下:

// 时间复杂度是O(N*logN)
void HeapSort(int* a, int n)
{
	// 由于我们要实现的是升序排序,我们要建大堆
	for (int i = (n-1 - 1) / 2; i >= 0; i--)// (n-1 - 1) / 2是倒数第一个父节点
	{
		AdjustDwon(a, n, i);
	}

	// 走到这里,大堆已经建好了,然后我们就要对大堆进行排序
	
	// 我们知道大堆 堆顶的数据是最大的,因此我们将其放到后面去,然后忽略它(这样还是左右子树还是大堆),然后调用向下调整算法
	// 然后重复,这样循环n次之后,就是升序
	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]); // 让堆顶和最后一个数据交换

		AdjustDwon(a, end, 0);// 让堆顶变成次大的数
		// 这里end是代表这个a的数据个数传进去的,相当于把n - 1这个下标的元素忽略了

		end--; // end始终指向要被交换的位置。
	}

}

测试代码:

void TestHeapSrot()
{
	int a[] = { 9,1,2,5,7,4,8,6,3,5 };
	Print(a, sizeof(a) / sizeof(a[0]));// 9 1 2 5 7 4 8 6 3 5
	HeapSort(a, sizeof(a) / sizeof(a[0]));
	Print(a, sizeof(a) / sizeof(a[0])); // 1 2 3 4 5 5 6 7 8 9
}

image-20240516151720436

堆排序的特性总结:

  1. 堆排序使用堆来选数,效率就高了很多。

  2. 时间复杂度:O(N*logN)

  3. 空间复杂度:O(1)

  4. 稳定性:不稳定

2.2.2.1性能对比
void TestOP()
{
	srand((unsigned int)time(NULL));
	const int N = 10000;

	// 给数组创建N个空间
	int* a1 = (int*)malloc(sizeof(int) * N);
	int* a2 = (int*)malloc(sizeof(int) * N);
	int* a3 = (int*)malloc(sizeof(int) * N);
	int* a4 = (int*)malloc(sizeof(int) * N);
	
	// 给数组生成随机数,每个数组内的数据都是一样的
	for (int i = 0; i < N; i++)
	{
		a1[i] = rand();
		a2[i] = a1[i];
		a3[i] = a1[i];
		a4[i] = a1[i];
	}

	// 获取直接插入排序的运行时间
	int begin1 = clock();
	InsertSort(a1, N);
	int end1 = clock();

	// 获取希尔排序的运行时间
	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();

	// 获取直接选择排序的运行时间
	int begin3 = clock();
	SelectSort(a3, N);
	int end3 = clock();

	// 获取堆排序的运行时间
	int begin4 = clock();
	HeapSort(a4, N);
	int end4 = clock();

	// 打印运行时间
	printf("InsertSort:%d\n", end1 - begin1);
	printf("ShellSort:%d\n", end2 - begin2);
	printf("SelectSort:%d\n", end3 - begin3);
	printf("HeapSort:%d\n", end4 - begin4);


	// 释放掉数组空间
	free(a1);
	free(a2);
	free(a3);
	free(a4);

}
  • 当N = 10000时

image-20240516151951508

  • 当N = 100000时

image-20240516152023013

可以看到堆排序的效率也是非常高的。

跟希尔排序是一个等级的排序

2.3 交换排序

基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置

交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。

2.3.1冒泡排序

冒泡排序比较简单,这里直接放代码:

// 冒泡排序
void BubbleSort(int* a, int n)
{
	int end = n;
	while (end > 0) 
	{
		int flag = 0; // 判断是否有交换
		for (int i = 1; i < end; i++)
		{
			if (a[i - 1] > a[i])
			{
				Swap(&a[i - 1], &a[i]);
				flag = 1; // 有交换就 1
			}
		}
		 // 如果已经有序了就不要再继续冒泡排序了
		if (flag == 0)
		{
			break;
		}
		end--;
	}
}

测试代码:

void TestBubbleSrot()
{
	int a[] = { 9,1,2,5,7,4,8,6,3,5 };
	Print(a, sizeof(a) / sizeof(a[0]));// 9 1 2 5 7 4 8 6 3 5
	BubbleSort(a, sizeof(a) / sizeof(a[0]));
	Print(a, sizeof(a) / sizeof(a[0])); // 1 2 3 4 5 5 6 7 8 9
}

冒泡排序的性能很差。

性能测试的代码这里不放了。我们直接来看结果

  • 当N = 10000时

image-20240516161909242

  • 当N = 100000时

image-20240516162019868

冒泡排序的特性总结:

  1. 冒泡排序是一种非常容易理解的排序

  2. 时间复杂度:O(N^2)

  3. 空间复杂度:O(1)

  4. 稳定性:稳定

2.3.2快速排序

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

同样的,我们可以将快速排序,分成单趟和总体来理解。

在单趟实现的方法中,一共有三种方法。

  1. 左右指针法
  2. 挖坑法
  3. 前后指针法

这里我们先来理解一下单趟——左右指针法

首先,我们先在数据最后面找到一个Key。【最前面也可以】

然后从数据的最前面 begin开始遍历数组,找到一个比Key大的数字,end指向Key 的位置,往前遍历数组,找到一个比Key小的数字,如果各自都找到且没有相遇,那么就交换,然后继续重复,直至begin和end相遇,相遇的时候,就把Key交换到end的位置。

注意:由于我们排的是升序,所以begin找比Key大的,end找比Key小的,如果排的是降序,那么就反过来就好

image-20240517104329336

当begin和end相遇的时候

image-20240517104359001

知道了单趟的过程,和思路,我们来看看代码是如何实现的:

int PartSort(int* a, int begin, int end)// [begin, end] 得是闭区间
{
	int key = a[end]; //选择最右边的数据当做key

	while (begin < end)
	{
		// 由于我们选择最右边数据当做key,我们要让begin先走
		// begin遍历数据,找到比key大的数据为止
		while (begin < end && a[begin] <= key) // begin < end是为了保证begin移动时不会错过end
		{
			begin++;
		}

		// begin找到了,就让end也出发,找到比key小的数据为止
		while (begin < end && a[end] >= end)// begin < end是为了保证end移动时不会错过begn
		{
			end--;
		}

		// 各自都找到了就要交换
		Swap(&a[begin], &a[end]);
	}
}

这里有个需要我们注意的问题:

  • 如果我们选择最右边当做key,那么要让左边的begin先出发找大

这样才能保证begin和end相遇的位置是,比key大的数字。

image-20240517112857549

如果我们不遵守这个规则就会如下图所示:

image-20240517114004812

由于是右边的end先出发,那么相遇的位置,会比key小,这个时候如果去调换位置,反而导致小的,跑到最右边去了

  • 如果我们选择最左边当做key,那么要让右边的end先出发找小

这样才能保证begin和end相遇的位置,是比key小的数字

image-20240517113550958

本质上,key选在左边和右边都是一样的。

并且我们可以发现:

此时,数据被分成了三个部分,key左边一部分,key, 可key的右边一部分。

这个时候我们发现,经过一次单趟,比key小的都在左边,比key大的都在右边,那我们只要让左右两个部分有序,不就可以实现这个数组的有序了吗

要实现左右两边有序,就在调用一次单趟,也就是递归。

一直递归直至有序,左右有序了,本身也有序了。这个思路其实有点像二叉树的前序遍历。

为了讲述这个递归思路,我们先假设一种理想情况——每次key都在中间,均匀划分数据。

image-20240517103545385

根据这个思路,我们来看看总体的代码:

int PartSort(int* a, int begin, int end)// [left, right] 得是闭区间
{
	int keyindex = end; //选择最右边的数据当做key

	while (begin < end)
	{
		// 由于我们选择最右边数据当做key,我们要让begin先走
		// begin遍历数据,找到比key大的数据为止
		while (begin < end && a[begin] <= a[keyindex]) // begin < end是为了保证begin移动时不会错过end
		{
			begin++;
		}

		// begin找到了,就让end也出发,找到比key小的数据为止
		while (begin < end && a[end] >= a[keyindex])// begin < end是为了保证end移动时不会错过begn
		{
			end--;
		}

		// 各自都找到了就要交换
		Swap(&a[begin], &a[end]);
	}

	// 走到这里说明begin和end相遇了,让该位置和key交换数据
	Swap(&a[begin], &a[keyindex]);

	// 要返回begin和end相遇的位置。
	return begin;// end也可以
}

void QuickSort(int* a, int left, int right)
{
	assert(a);
	// 如果左边界都大于等于右边界了,就不需要排下去了。
	if (left >= right)
		return;

	int div = PartSort(a, left, right);
	// 这个时候被分成了  [left, div-1]  div  [div + 1, right] 三个部分
	// 此时div的位置就是正确的位置,是无需改变的
	// 我们只需要将左右两边的部分排成有序的就行了
	QuickSort(a, left, div - 1);
	QuickSort(a, div + 1, right);
}

测试代码:

void TestQuickSort()
{
	int a[] = { 9,1,2,5,7,4,8,6,3,5 };
	Print(a, sizeof(a) / sizeof(a[0]));// 9 1 2 5 7 4 8 6 3 5
	QuickSort(a, 0, sizeof(a) / sizeof(a[0]) - 1); // -1代表最后一个元素的下标
	Print(a, sizeof(a) / sizeof(a[0])); // 1 2 3 4 5 5 6 7 8 9
}

我们通过递归实现了快速排序,那我们来分析一下其时间复杂度。

时间复杂度是在O(N* logN) ~ O(N^2)。

最好的情况是O(N logN),最坏的情况是O(N^2)*

如图所示:

image-20240517130616068

2.3.2.1性能对比

性能对比的代码:

void TestOP()
{
	srand((unsigned int)time(NULL));
	const int N = 10000;

	// 给数组创建N个空间
	int* a1 = (int*)malloc(sizeof(int) * N);
	int* a2 = (int*)malloc(sizeof(int) * N);
	int* a3 = (int*)malloc(sizeof(int) * N);
	int* a4 = (int*)malloc(sizeof(int) * N);
	int* a5 = (int*)malloc(sizeof(int) * N);
	int* a6 = (int*)malloc(sizeof(int) * N);

	
	// 给数组生成随机数,每个数组内的数据都是一样的
	for (int i = 0; i < N; i++)
	{
		a1[i] = rand();
		a2[i] = a1[i];
		a3[i] = a1[i];
		a4[i] = a1[i];
		a5[i] = a1[i];
		a6[i] = a1[i];
	}

	// 获取直接插入排序的运行时间
	int begin1 = clock();
	InsertSort(a1, N);
	int end1 = clock();

	// 获取希尔排序的运行时间
	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();

	// 获取直接选择排序的运行时间
	int begin3 = clock();
	SelectSort(a3, N);
	int end3 = clock();

	// 获取堆排序的运行时间
	int begin4 = clock();
	HeapSort(a4, N);
	int end4 = clock();

	// 获取冒泡排序的运行时间
	int begin5 = clock();
	BubbleSort(a5, N);
	int end5 = clock();

	// 获取快速排序的运行时间
	int begin6 = clock();
	QuickSort(a6, 0, N - 1);
	int end6 = clock();

	// 打印运行时间
	printf("InsertSort:%d\n", end1 - begin1);
	printf("ShellSort:%d\n", end2 - begin2);
	printf("SelectSort:%d\n", end3 - begin3);
	printf("HeapSort:%d\n", end4 - begin4);
	printf("BubbleSort:%d\n", end5 - begin5);
	printf("QuickSort:%d\n", end6 - begin6);

    
	// 释放掉数组空间
	free(a1);
	free(a2);
	free(a3);
	free(a4);
	free(a5);
	free(a6);

}
  • 当N = 10000时

image-20240517133850812

  • 当N = 100000时

image-20240517141537130

我们可以发现,快速排序也是效率非常高的。

但是目前的快速排序是有缺点的!

目前这个快速排序,在面对有序的或者接近有序的数据,时间复杂度接近O(N^2),前面我们也分析过了。这个时候他的效率将会接近直接插入排序和直接选择排序,就不够好了。

我们也可以做个测试

	// 获取快速排序的运行时间
	int begin6 = clock();
	QuickSort(a5, 0, N - 1);
	int end6 = clock();

让快速排序去排a5 也就是已经是有序的数组了。

  • 当N = 10000时

image-20240517144329712

结果是什么都没有,因为此时已经栈溢出了,因为,我们内存中的栈区,容量是比较小的,在Linux课程中会了解到。

为什么会栈溢出呢,因为在数组有序的情况下,我们会创建N个栈帧,直接就溢出了

  • 当N = 3000时

image-20240517145614452

效率和直接插入排序和直接选择排序相当了。

既然如此我们就要想办法解决

解决方法就是 三数取中

image-20240517144213235

2.3.2.1快速排序优化

三数取中:

其实就是把一个数组中,最开始的数,最右边的数,最中间的数,做一个比较,取出中间值,放到key的位置去,从而避免key是最大的或者最小的数

image-20240517153222909

代码如下:

// 三数取中
int GetMidIndex(int* a, int begin, int end)
{
	int mid = (begin + end) / 2;
	if (a[begin] < a[mid])
	{
		if (a[mid] < a[end])
			return mid;
		else if (a[begin] > a[end])
			return begin;
		else // 代表 a[mid] > a[end] 并且 a[begin] < a[end]
			return end;
	}
	else // a[begin] >= a[mid]  等于的话其实 选哪一个都可以
	{
		if (a[mid] > a[end])
			return mid;
		else if (a[begin] < a[end])
			return begin;
		else// a[mid] < a[end] 并且 a[begin] > a[end]
			return end;
	}
}

总代码:

// 三数取中
int GetMidIndex(int* a, int begin, int end)
{
	int mid = (begin + end) / 2;
	if (a[begin] < a[mid])
	{
		if (a[mid] < a[end])
			return mid;
		else if (a[begin] > a[end])
			return begin;
		else // 代表 a[mid] > a[end] 并且 a[begin] < a[end]
			return end;
	}
	else // a[begin] >= a[mid]  等于的话其实 选哪一个都可以
	{
		if (a[mid] > a[end])
			return mid;
		else if (a[begin] < a[end])
			return begin;
		else// a[mid] < a[end] 并且 a[begin] > a[end]
			return end;
	}
}

int PartSort(int* a, int begin, int end) // [begin, end] 得是闭区间
{ 
	// 我们也不知道一开始谁是中间的数,那就去选
	int midindex = GetMidIndex(a, begin, end); // 选出来中间的数
	// 因为我们拿最右边数据当key,所以选出来就和最右边的数交换
	Swap(&a[midindex], &a[end]); // 这样就可以避免key是最大的数

	int keyindex = end; //选择最右边的数据当做key

	while (begin < end)
	{
		// 由于我们选择最右边数据当做key,我们要让begin先走
		// begin遍历数据,找到比key大的数据为止
		while (begin < end && a[begin] <= a[keyindex]) // begin < end是为了保证begin移动时不会错过end
		{
			begin++;
		}

		// begin找到了,就让end也出发,找到比key小的数据为止
		while (begin < end && a[end] >= a[keyindex])// begin < end是为了保证end移动时不会错过begn
		{
			end--;
		}

		// 各自都找到了就要交换
		Swap(&a[begin], &a[end]);
	}

	// 走到这里说明begin和end相遇了,让该位置和key交换数据
	Swap(&a[begin], &a[keyindex]);

	// 要返回begin和end相遇的位置。
	return begin;// end也可以
}

void QuickSort(int* a, int left, int right) // [left, right] 得是闭区间
{
	assert(a);
	// 如果左边界都大于等于右边界了,就不需要排下去了。
	if (left >= right)
		return;

	// 当数据个数大于10我们才使用快排
	if ((right - left + 1) > 10)
	{
		int div = PartSort3(a, left, right);
		// 这个时候被分成了  [left, div-1]  div  [div + 1, right] 三个部分
		// 此时div的位置就是正确的位置,是无需改变的
		// 我们只需要将左右两边的部分排成有序的就行了
		QuickSort(a, left, div - 1);
		QuickSort(a, div + 1, right);
	}
	else
	{
		// 如果数据量比较小,快排实现排序的代价就会比较大
		// 不如直接使用直接插入排序,
		// 因为在快排的前提下,当递归到数据量只剩下10的时候,此时的数据已经接近有序了。
		InsertSort(a + left, (right - left) + 1);
	}
	
}

这个时候我们再来看看快速排序应对极端情况下的性能:

  • 当N = 10000时

image-20240517152647618

这个时候快速排序排的是有序数组

可以看到,已经没有问题了,不会在遇到栈溢出和效率低下的问题了。

  • 当N = 100000时

image-20240517153100244

可以看到快速排序效率还是很高的

由于此时最坏的情况不会再出现,我们的快排这个时候的时间复杂度大概就是O(N*log N)

2.3.3快速排序其他思路

前面我们说过

在单趟实现的方法中,一共有三种方法。

  1. 左右指针法
  2. 挖坑法
  3. 前后指针法

我们已经讲过左右指针法了

2.3.3.1挖坑法
  • 我们来看一下挖坑法:

其实挖坑法就是左右指针法的一个变形

  1. 首先我们选出最右边的数据当作Key,那这个数据的位置就可以看作一个坑

image-20240517213539220

  1. 然后从最左边开始找比key大的数字

  2. 找到大,就让这个数字放到坑位,那这个数字原来的位置也可以看作一个坑位

image-20240517213624148

  1. 然后从填过去的位置往左边找小,找到小就往坑位上去填。

image-20240517213659866

  1. 然后再让begin去找大。end找小,直至begin和end相遇。
  2. 相遇了就把Key插到相遇的位置

image-20240517213843824

这样我们就发现key的左边都是比key小的,key的右边都是比key大的

这样就又分成三段了,让左右两段数据,有序就行了。这就和我们的左右指针法很像了。

我们先来实现挖坑法单趟的代码实现:

// 快速排序挖坑法
int PartSort2(int* a, int begin, int end)
{
	// 三数取中
	int mid = GetMidIndex(a, begin, end);
	Swap(&a[mid], &a[end]); // 取出来的中间数 放到要当做key的位置 

	int key = a[end]; // 取最右边的数据当做key,此时end位置就是坑位
	while (begin < end)
	{
		// 先从左边出发,也就是让begin出发,去找比key大的数字
		while (begin < end && a[begin] <= key)
		{
			begin++;
		}

		// 找到了就放在坑位
		a[end] = a[begin]; // 此时begin位置形成了新的坑位

		// 在从右边找比key小的数字,让end出发
		while (begin < end && a[end] >= key)
		{
			end--;
		}

		// 找到了就要放在坑位中
		a[begin] = a[end];
	}

	// 走到这里就是begin 和 end相遇了
	// 相遇了就把key放到相遇的位置
	a[begin] = key;

	// 返回相遇的位置
	return begin;
}

效率和左右指针法是基本没有区别的。

这里就不在对其性能做对比

2.3.3.2前后指针法
  • 我们再来看最后一种思路——前后指针法
  1. 这里我们还是取最右边的数据为key。让cur指向第一个数据,prev先不指向数据。
  2. 然后让cur去数据中找比key小的数据

image-20240518111848728

  1. 如果找到了那就让prev++,指向第一个数据,然后让其交换。
  2. 然后循环往复直到cur指向边界外边,就跳出循环

image-20240518112520027

  1. 此时prev再++ 所指向的位置就是key要插入的位置,让key和此时的prev交换位置

image-20240518112733406

这个时候就成功分成了三个部分,左边比key小,右边比key大。

我们来看代码实现:

// 快速排序前后指针法
int PartSort3(int* a, int begin, int end)
{
	// 三数取中
	int mid = GetMidIndex(a, begin, end);
	Swap(&a[mid], &a[end]); 

	int key = a[end]; // 取最右边的数据当做key
	int cur = begin; // 指向最左边的数据
	int prev = begin - 1; // 先不指向数据
	while (cur < end)
	{
		// 让cur出发,去数据中找小,找到了prev++ 然后交换
		if (a[cur] < key && ++prev != cur) 
			// && ++prev != cur 的意思是 如果prev++ 后 和cur指向同一个地方 交不交换都可以,那干脆就别换了
		{
			Swap(&a[cur], &a[prev]);
		}

		cur++;
	}
	// 退出循环, 此时prev++ 的位置就是key要插入的位置
	prev++;
	Swap(&a[prev], &a[end]); // 交换key和prev的数据
	// 此时key左边是比key小的,右边是比key大的

	// 返回key的位置
	return prev;
}

测试代码和之前一样,三种思路只是在单趟的思路不同,其他地方思路是一样的,比如三数取中,和递归过程。

三种思路的性能都是大差不差的。

并且我们会发现三种思路最终目的都是将数据分成三段

比key小——key——比key大

然后再用递归,将左右两段的数据有序

2.3.4非递归快速排序

我们说过,递归实现的快速排序,如果没有三数取中的优化,那么在数组有序的这种极端情况下,效率是低的,并且会遇到栈溢出的问题。因为内存中的栈区容量很小,而递归又需要创建栈帧,那么就会造成栈溢出问题。

即便有了三数取中的优化,当数据量很大的时候,还是会创建很多栈帧的,还是会有风险造成栈溢出的问题。

因此非递归的快速排序的需求就出现了、

要想实现非递归的快速排序,我们得先知道递归的快速排序中的递归到底起了什么作用。其实递归就是利用栈帧保存数据

举个例子,第一层调用递归时,保存的是第一层的数据,第二层递归,保存的是第二层的数据。

image-20240519105556584

如图所示:

因此非递归的要通过——模拟实现栈

来达到保存数据的目的。

首先我们要先把之前实现过的栈的定义和接口,拿过来。

这里就不贴代码了,需要的可以看这篇博客 栈和队列的定义和实现

我们直接来看非递归快排的代码:

// 快速排序 非递归实现  (借助栈来实现)
// 递归改非递归—— 1,直接改循环(斐波那契数列求解)一些简单的递归才能这样
// 2. 栈模拟存储数据 改非递归 
// 非递归目的 : 1.提高效率(建立栈帧有损耗,但是现代计算机基本不用考虑这个问题了)
//				2.递归的最大缺陷:如果栈帧深度太深,容易导致栈溢出。因为系统内存的栈空间太小了,是M级别的,
//				但是数据结构模拟实现是在堆上的,堆空间是G级别的
void QuickSortNonR(int* a, int left, int right)
{
	Stack st;
	StackInit(&st); // 栈的初始化

	// 栈是后进先出,我们借助栈保存区间
	StackPush(&st, right);
	StackPush(&st, left);


	while (!StackEmpty(&st))
	{
		// 走进来就是 栈还有数据,那就要排序
		// 拿出存储的区间进行快排
		int begin = StackTop(&st); //根据我们的存储顺序 先拿出来的是左边界
		StackPop(&st); // 要弹出栈顶元素才能拿到下一个元素
		int end = StackTop(&st);
		StackPop(&st);

		// 拿到了[begin, end]这个区间,我们进行排序
		int div = PartSort3(a, begin, end);
		//现在是 [begin, div-1] div [div + 1, end]  
		// 我们要对左右两区间进行排序
		// 首先要保存其区间

		// 先放进去右区间,再放左区间,这样排序先排左区间
		if (div + 1 < end) // div + 1 >= end  就说明是无效区间,无需排序了。
		{
			StackPush(&st, end); 
			StackPush(&st, div + 1);
		}

		if (div - 1 > begin)
		{
			StackPush(&st, div - 1);
			StackPush(&st, begin);
		}

	}
    // 注意将栈空间释放
    StackDestory(&st);
}

这里的快排 对区间的调用,也递归的时候是一样的顺序,因为都拥有栈的后进先出的特性。

我们来看看代码性能对比:

  • 当N = 10000时

image-20240519120305128

其实在Release版本下,去调用,快排是基本稳定比希尔排序快的,在debug版本下,快排在递归的时候,会多一些消耗。

2.3.5快速排序的总结

快速排序的特性总结:

  1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序

  2. 时间复杂度:O(N*logN)

image-20240519120604649

  1. 空间复杂度:O(logN)

看的是它的深度。 因为每次递归都需要创建栈帧。

  1. 稳定性:不稳定

2.4归并排序

2.4.1递归实现归并排序

其实我们前面学习的排序,插入排序,选择排序,交换排序,都是内排序

这现在即将学习的归并排序是外排序

image-20240520111716764

既然要学习一个新排序,我们还是将排序分成单趟和总体

先来看归并排序的单趟

单趟的思想就是之前做过的一道oj题,合并两个有序数组。

分割成两组数据,我们让两个指针分别指向其头部,然后我们开辟一个新的空间。

让两个指针指向的元素去比较,谁小谁就去新的空间,然后指针各自++。

最后新开辟的空间就是我们要排的升序

image-20240520104845665

单趟的代码很简单,这里不展示了。

但是这里有个前提,那就是归并的两段数据必须是有序的,但是实际上给我们的归并的数据,大部分都不是有序的,因此,我们可以选择,不断地拆分,直至拆成有序的为止。

其实也就是递归,将一段数据分成两段,如果不有序,就接着往下拆。

如图所示:

image-20240520110958035

当一段数据被拆到只剩下两个数据的时候,再拆一次,拆出来的两段数据都是只有一个元素的,那肯定就是有序了,这个时候归并排序,返回的就是有序的。

注意:

实际的操作过程中,我们不能频繁的去开辟空间,这样会造成有很多的内存碎片。最好就是只开辟一个临时空间,归并的操作都在这个临时空间进行。

image-20240520112644599

我们来看看代码是如何实现的:

void _MergeSort(int* a, int left, int right, int* tmp)
{
	// 判断传进来的区间是否有效
	if (left >= right)
		return;

	// 将传进来的数据,分割成两个部分
	int mid = (left + right) / 2;
	// [left, mid]  [mid + 1, right]
	// 但是这两个区间不一定有序,因此我们通过递归解决

	_MergeSort(a, left, mid, tmp);
	_MergeSort(a, mid + 1, right, tmp);

	// 归并  
	// 让[left, mid]  [mid + 1, right] 两个区间归并
	int begin1 = left, end1 = mid; // 指向第一个区间的头尾
	int begin2 = mid + 1, end2 = right; // 指向第二个区间的头尾

	int index = left; // index是指向临时空间的下标
	while (begin1 <= end1 && begin2 <= end2) // 越界才会出循环
	{
		// 合并两个有序数组,判断begin1和begin2指向的数据谁大
		if (a[begin1] < a[begin2])
		{
			// 把小的放到临时空间去
			tmp[index] = a[begin1];
			index++;
			begin1++;
		}
		else
		{
			tmp[index] = a[begin2];
			index++;
			begin2++;
		}

	}
	// 要注意走到这里,有可能是两个区间的任意一个区间越界了。
	// 要将另外一个区间的数据全部放进临时空间tmp里
	while (begin1 <= end1) 
	{
		tmp[index] = a[begin1];
		index++;
		begin1++;
	}

	while (begin2 <= end2)
	{
		tmp[index] = a[begin2];
		index++;
		begin2++;
	}

	// 这个时候tmp 里面放的就是 两个区间的数据 的升序
	// 我们要将其数据拷贝到原数组a去
	for (int i = left; i <= right; i++)
	{
		a[i] = tmp[i];
	}


}

// 归并排序递归实现
// 时间复杂度 O(N * logN)
// 空间复杂度 O(N)
void MergeSort(int* a, int n)
{
	assert(a);

	// 创建临时空间
	int* tmp = malloc(sizeof(int) * n);

	_MergeSort(a, 0, n - 1, tmp);
	   
	// 释放空间
	free(tmp);
}

递归过程可以画图去理解。

image-20240520165854746

归并排序的特性总结:

  1. 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。

  2. 时间复杂度:O(N*logN)

  3. 空间复杂度:O(N)

  4. 稳定性:稳定

2.4.2非递归实现归并排序

我们再讲述非递归实现快速排序的时候,有讲过,递归改非递归一般有两种思路:

  • 1.直接改循环
  • 2.通过数据结构栈 来模拟实现

这里我们采用直接改循环的方式来实现非递归

思路就是把一组数据拆分成间隔为gap的多组数据。然后让多组数据进行归并,思路有点像二分。

但是要注意,每个区间的范围是

  • [i, i + gap - 1] [i + gap, i + 2*gap - 1] 闭区间
  • [i, i + gap) [i + gap, i + 2*gap) 开区间

由于我们归并的代码是以闭区间来写的,这里我们采用闭区间

image-20240520174222768

并且我们还要考虑给的数据,是偶数个还是奇数个,偶数个的话,还要考虑 /2之后是否还是偶数的问题

第一种情况:n/2之后 是奇数 导致分组不平均,会让[i, i + gap - 1] [i + gap, i + 2*gap - 1]越界

image-20240520182251624

第二种情况:n直接就是奇数 。[i, i + gap - 1] [i + gap, i + 2*gap - 1]直接越界

image-20240520182629229

因此上述两种情况还要进行处理

我们来看看代码:

// 归并排序非递归实现
void MergeSortNonR(int* a, int n)
{
	assert(a);

	// 创建临时空间
	int* tmp = (int*)malloc(sizeof(int) * n);

	// 将数据分成间隔为gap的组  让每个组去归并
	for (int gap = 1; gap < n; gap *= 2)
	{
		for (int i = 0; i < n; i += 2 * gap) // i += 2 * gap 能保证每次落在新的一组数据
		{
			// 归并  
			// 让[i, i + gap - 1] [i + gap, i + 2*gap - 1]两个区间归并
			int begin1 = i, end1 = i + gap - 1; // 指向第一个区间的头尾
			int begin2 = i + gap, end2 = i + 2 * gap - 1; // 指向第二个区间的头尾

			// 这个时候begin2 或者 end2 是有越界嫌疑的,因此需要进行判断
			
			
			//1.如果给的数据 n / 2是奇数, 那么begin2会越界
			// 也就是合并的时候,只有[i, i + gap - 1], 没有[i + gap, i + 2*gap - 1]
			if (begin2 >= n)
				break; // 退出循环,让下一次循环时, begin2合法,将end2修改为 n -1 那就可以正常归并

			//2. 如果给的数据是奇数个, 那么begin2 会合法,但是end2会越界
			// 也就是合并的时候。第二组[i + gap, i + 2*gap - 1] 只有部分数据
			if (end2 >= n)
				end2 = n - 1;
			

			int index = i; // index是指向临时空间的下标
			while (begin1 <= end1 && begin2 <= end2) // 越界才会出循环
			{
				// 合并两个有序数组,判断begin1和begin2指向的数据谁大
				if (a[begin1] < a[begin2])
				{
					// 把小的放到临时空间去
					tmp[index] = a[begin1];
					index++;
					begin1++;
				}
				else
				{
					tmp[index] = a[begin2];
					index++;
					begin2++;
				}

			}
			// 要注意走到这里,有可能是两个区间的任意一个区间越界了。
			// 要将另外一个区间的数据全部放进临时空间tmp里
			while (begin1 <= end1)
			{
				tmp[index] = a[begin1];
				index++;
				begin1++;
			}

			while (begin2 <= end2)
			{
				tmp[index] = a[begin2];
				index++;
				begin2++;
			}

			// 这个时候tmp 里面放的就是 两个区间的数据 的升序
			// 我们要将其数据拷贝到原数组a去
			for (int j = i; j <= end2; j++)// 要注意是i <= end2  而不是 i <= i + 2*gap - 1
			{
				a[j] = tmp[j];
			}
		}

		Print(a, n); // 打印一次归并的结果

	}
	
	// 释放空间
	free(tmp);
}

测试代码:

void TestMergeSortNonR()
{
	int a[] = { 9,1,2,5,7,4,8,6,3,5 };
	Print(a, sizeof(a) / sizeof(a[0]));// 9 1 2 5 7 4 8 6 3 5
	MergeSortNonR(a, sizeof(a) / sizeof(a[0]));
	//Print(a, sizeof(a) / sizeof(a[0])); // 1 2 3 4 5 5 6 7 8 9
}

image-20240520190837744

image-20240520191645349

每组数据的分组情况如下:

image-20240520191915627

第二三步在归并完前面的数据 到了 35都会退出循环。

第四步,会将12456789 和 35 这两组进行归并

这两句话要结合代码进行理解

我们还要测试一个例子:

void TestMergeSortNonR()
{
	int a[] = { 9,1,2,5,7,4,8,6,3,5,10 };
	Print(a, sizeof(a) / sizeof(a[0]));// 9 1 2 5 7 4 8 6 3 5 10
	MergeSortNonR(a, sizeof(a) / sizeof(a[0]));
	//Print(a, sizeof(a) / sizeof(a[0])); // 1 2 3 4 5 5 6 7 8 9 10
}

image-20240520192254783

具体过程就不解析了。根据代码可以自己调试看看

2.4.3归并排序应用

跟之前堆排序可以解决TopK问题一样,我们来看看归并排序会用来解决什么问题?

image-20240520225512621

前面我们说归并排序是外排序。其实就是将数据分成一个个小段,在内存中进行排序,再拿出内存,在外部,比如文件(磁盘中),进行归并排序。

其实就是数据量太大,无法直接通过内存来排序,我们将其平均切割成100份,1000份等等,比如将其切割成原数据切割成100份,每份拿出来就可以放到内存中去排序,排完之后放到一个个小文件中。

这样就会有100个小文件,这100个小文件里面都是排序好的数据,然后让文件去归并,让上一个文件和下一个文件中的数据去归并,最终实现的就是全部数据都放在一个文件里,并且是有序的。

文件的合并思路如下图所示

image-20240520233956417

文件归并的过程就是在文件中进行的,就是在磁盘上进行的。这就是外排序

image-20240521111447944

具体的归并思路:

就是让两个文件归并后的文件跟下一个文件接着归并。

image-20240521114326528

让file1 指向mfile文件,file2指向下一个文件,继续归并file1 和 file2文件,生成mfile文件

image-20240521114307224

然后就一直循环,直至mfile文件和第 n 个文件归并后。就结束。

代码实现:

void MergeFile(const char* file1, const char* file2, const char* mfile)
{
	// 打开file1 文件
	FILE* fout1 = fopen(file1, "r");
	if (fout1 == NULL)
	{
		perror("MergeFile():fopen:fout1");
		exit(-1);
	}

	// 打开file2文件
	FILE* fout2 = fopen(file2, "r");
	if (fout2 == NULL)
	{
		perror("MergeFile():fopen:fout2");
		exit(-1);
	}

	// 打开mfile文件,准备写入。
	FILE* fin = fopen(mfile, "w");
	if (fin == NULL)
	{
		perror("MergeFile():fopen:fin");
		exit(-1);
	}

	// 将file1 和 file2的数据读出来,归并到mfile文件中
	int num1, num2;
	int ret1 = fscanf(fout1, "%d\n", &num1);
	int ret2 = fscanf(fout2, "%d\n", &num2);

	while (ret1 != EOF && ret2 != EOF) // 通过fscanf的返回值来判断是否有文件读取完毕
	{
		// 判断num1 和 num2 谁大谁小
		if (num1 < num2)
		{
			fprintf(fin, "%d\n", num1); // 小的数据放到fin指向的文件
			ret1 = fscanf(fout1, "%d\n", &num1);// 这一步是为了让文件指针++ 读取下一个数据
		}
		else
		{
			fprintf(fin, "%d\n", num2);
			ret2 = fscanf(fout2, "%d\n", &num2);
		}
	}
	
	// 走到这里,有可能是fout1 或者是 fout2 文件先读取完
	// 要把剩下的进行处理
	while (ret1 != EOF)
	{
		fprintf(fin, "%d\n", num1);
 		ret1 = fscanf(fout1, "%d\n", &num1);
	}

	while (ret2 != EOF)
	{
		fprintf(fin, "%d\n", num2);
		ret2 = fscanf(fout2, "%d\n", &num2);
	}

	// 关闭文件
	fclose(fout1);
	fclose(fout2);
	fclose(fin);
}

void MergeSortFile(const char* file)
{
	// 读取传进来的file文件
	FILE* fout = fopen(file, "r");
	if (fout == NULL)
	{
		perror("fopen:fout");
		exit(-1);
	}

	// 读取文件中的数据 ,将其分割成多组,在内存中进行排序,再放入对应文件中

	int n = 10; // 将文件中的数据切割成10个一组
	int num = 0;
	int* a = (int*)malloc(sizeof(int) * n); // 用于在内存中进行排序的数组
	int i = 0;
	int filei = 1; // 每组数据所存放的文件的下标
	char subfile[20]; // 用于存放各组数据文件的文件

	while (fscanf(fout, "%d\n", &num) != EOF) // 从文件中读取数据,写到num中 [读取失败返回EOF]
	{
		// 将读出来的数据放到一个数组中
		if (i < n - 1) // 只读前n - 1个到数组中 第n个在else中处理
		{
			a[i++] = num;
		}
		else
		{
			// 走进循环,fscanf还是读了一个数据到num上,要记得处理
			a[i] = num;

			// 走到这里,说明已经把文件中的数据全部读取到数组a中
			// 对其进行快排
			QuickSort(a, 0, n - 1);// n - 1是最后一个元素的下标

			// 排序完之后,我们将数据放回到文件中,这里我们选择每一组数据都放进一个文件中
			sprintf(subfile, "sub\\sub_sort%d.txt", filei++); // 创建各个文件的名字
			FILE* fin = fopen(subfile, "w"); // 打开subfile所记载的文件名,如果没有,由于是w形式 会自动创建一个
			if (fin == NULL)
			{
				perror("fopen:fin");
				exit(-1);
			}

			// 往文件里写,我们排好序的数组
			for (int j = 0; j < n; j++)
			{
				fprintf(fin, "%d\n", a[j]);
			}
			fclose(fin);

			i = 0; // 重置i
		}

	}

	// 现在文件中的数据, 被我们分成了一组组个数为n的有序数据,并放在了对应的文件中
	// 我们对这个文件,进行归并排序。  也就是在磁盘上进行文件内数据的归并,是外排序
	char mfile[100] = "12";
	char file1[100] = "sub\\sub_sort1.txt";
	char file2[100];
	char subsortfile[100] = "subsortfile\\12";  // 将归并后的mfile子文件都放到subsortfile文件中

	for (int i = 2; i <= n; i++)
	{
		sprintf(file2, "sub\\sub_sort%d.txt", i);

		// 读取file1 和 file2的数据  归并到subsortfile文件的mfile子文件中
		MergeFile(file1, file2, subsortfile);

		// 更新file1指向的文件名
		strcpy(file1, subsortfile);

		// 更新mfile
		sprintf(mfile, "%s%d", mfile, i + 1);

		// 更新subsortfile的mfile子文件名
		sprintf(subsortfile, "subsortfile\\%s", mfile);
	}

	fclose(fout);
}

测试效果如下:

image-20240521134218393

sub文件中:

image-20240521134237892

subsortfile文件中:

image-20240521134311397

最终的12345678910就是所有文件归并后的结果。

将一开始的Sort文件内的数据,有序的存放在1234568910这个文件中

2.6非比较排序

前面我们学习的排序都是比较排序——通过比较两个数的大小来,实现排序

现在我们来学习一下非比较排序。

2.6.1计数排序

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

操作步骤:

  1. 统计相同元素出现次数

  2. 根据统计的结果将序列回收到原来的序列中

如图所示:

我们通过通过一个数组去记录原数组各个数字出现的次数

image-20240521181718763

然后通过我们的计数的数组,覆盖回原数组。

image-20240521181843215

但是当数据比较大且,范围大的时候,采用绝对映射会导致空间浪费较多,因此我们要先找到原数据的最大最小值,来求计数数组计数的范围,并且在计数的时候采取相对映射。

知道了思路,我们来看看代码如何实现:

// 计数排序
// 时间复杂度:O(N + range)
// 空间复杂度: O(range)
void CountSort(int* a, int n)
{
	assert(a);

	// 先找到我们用于计数的数组的边界。也就是范围
	// 去找数据中的最大最小值
	int min = a[0];
	int max = a[0];
	for (int i = 1; i < n; i++)
	{
		// 找最大值
		if (a[i] > max)
			max = a[i];
		
		// 找最小值
		if (a[i] < min)
			min = a[i];
	}
	int range = max - min + 1; // + 1 是因为要算上0的存在

	// 开辟计数数组
	int* countArr = (int*)malloc(sizeof(int) * range);
	memset(countArr, 0, sizeof(int) * range); // 初始化

	// 统计原数据中各个数字出现的次数
	for (int i = 0; i < n; i++)
	{
		//countArr[a[i]]++; // a[i]是什么,就去countArr数组对应的下标处++ 代表a[i]这个数字出现了一次
		// 这是绝对映射
		// 为了让空间不那么浪费,我们采取相对映射
		countArr[a[i] - min]++;
	}

	// 排序
	int index = 0;
	for (int i = 0; i < range; i++)
	{
		for (int j = countArr[i]; j > 0; j--)
		{
			// countArr下标i的元素是j 就代表i,在原数组共出现了j次
			a[index++] = i + min; // + min 是因为我们再计数的时候,采用的是相对映射
		}
	}

	// 释放空间
	free(countArr);
}

测试代码:

void TestCountSort()
{
	int a[] = { 9,1,2,5,7,4,8,6,3,5 };
	Print(a, sizeof(a) / sizeof(a[0]));// 9 1 2 5 7 4 8 6 3 5
	CountSort(a, sizeof(a) / sizeof(a[0]));
	Print(a, sizeof(a) / sizeof(a[0])); // 1 2 3 4 5 5 6 7 8 9
}

image-20240521194532132

计数排序的效率是非常快的,但是需要非常多的空间。

计数排序的特性总结:

  1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。

  2. 时间复杂度:O(MAX(N,范围))

  3. 空间复杂度:O(范围)

  4. 稳定性:稳定

2.7性能对比

我们来看看我们这篇章所学的所有排序的性能对比。

对比性能的代码。我们采用计算每个排序的运行时间来对比性能

代码如下:

void TestOP()
{
	srand((unsigned int)time(NULL));
	const int N = 10000;

	// 给数组创建N个空间
	int* a1 = (int*)malloc(sizeof(int) * N);
	int* a2 = (int*)malloc(sizeof(int) * N);
	int* a3 = (int*)malloc(sizeof(int) * N);
	int* a4 = (int*)malloc(sizeof(int) * N);
	int* a5 = (int*)malloc(sizeof(int) * N);
	int* a6 = (int*)malloc(sizeof(int) * N);
	int* a7 = (int*)malloc(sizeof(int) * N);
	int* a8 = (int*)malloc(sizeof(int) * N);
	int* a9 = (int*)malloc(sizeof(int) * N);
	int* a10 = (int*)malloc(sizeof(int) * N);

	
	// 给数组生成随机数,每个数组内的数据都是一样的
	for (int i = 0; i < N; i++)
	{
		a1[i] = rand();
		a2[i] = a1[i];
		a3[i] = a1[i];
		a4[i] = a1[i];
		a5[i] = a1[i];
		a6[i] = a1[i];
		a7[i] = a1[i];
		a8[i] = a1[i];
		a9[i] = a1[i];
		a10[i] = a1[i];
	}

	// 获取直接插入排序的运行时间
	int begin1 = clock();
	InsertSort(a1, N);
	int end1 = clock();

	// 获取希尔排序的运行时间
	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();

	// 获取直接选择排序的运行时间
	int begin3 = clock();
	SelectSort(a3, N);
	int end3 = clock();

	// 获取堆排序的运行时间
	int begin4 = clock();
	HeapSort(a4, N);
	int end4 = clock();

	// 获取冒泡排序的运行时间
	int begin5 = clock();
	BubbleSort(a5, N);
	int end5 = clock();

	// 获取快速排序的运行时间
	int begin6 = clock();
	QuickSort(a6, 0, N - 1);
	int end6 = clock();

	// 获取非递归快速排序的运行时间
	int begin7 = clock();
	QuickSortNonR(a7, 0, N - 1);
	int end7 = clock();

	// 获取归并排序(递归)的运行时间
	int begin8 = clock();
	MergeSort(a8, N);
	int end8 = clock();	
	
	// 获取归并排序(非递归)的运行时间
	int begin9 = clock();
	MergeSortNonR(a9, N);
	int end9 = clock();

	// 获取归并排序(非递归)的运行时间
	int begin10 = clock();
	CountSort(a10, N);
	int end10 = clock();

	// 打印运行时间
	printf("InsertSort:%d\n", end1 - begin1);
	printf("ShellSort:%d\n", end2 - begin2);
	printf("SelectSort:%d\n", end3 - begin3);
	printf("HeapSort:%d\n", end4 - begin4);
	printf("BubbleSort:%d\n", end5 - begin5);
	printf("QuickSort:%d\n", end6 - begin6);
	printf("QuickSortNonR:%d\n", end7 - begin7);
	printf("MergeSort:%d\n", end8 - begin8);
	printf("MergeSortNonR:%d\n", end9 - begin9);
	printf("CountSort:%d\n", end10 - begin10);



	// 释放掉数组空间
	free(a1);
	free(a2);
	free(a3);
	free(a4);
	free(a5);
	free(a6);
	free(a7);
	free(a8);
	free(a9);
	free(a10);
}
  • 当N = 10000时

image-20240521195508541

  • 当N = 100000时

image-20240521195949773

  • 当N = 1000000时

这个情况,效率低的排序要的时间太多,不算他们

image-20240521200816101

3.总结

image-20240521203957198

还有一个需要注意的

那就是稳定性

我们说一个排序是否稳定——取决于一段数据中,相同的数是否能在排序完之后其相对顺序不变。

image-20240521211910821

我们学习的排序是否稳定,不能靠背,一定要理解其过程,去判断是否稳定。

  • 冒泡排序——稳定。

只需要让两数相等时不交换就好了。

  • 直接插入排序——稳定

在往前或者往后插入的时候,碰到相等的数不交换就好了。

  • 直接选择排序——不稳定

image-20240521214029639

如图所示,两个5的相对顺序发生了变化

  • 堆排序——不稳定

image-20240521214059239

  • 希尔排序——不稳定

当有数据不在同一组的时候,预排序就会交换其相对顺序

  • 快速排序——不稳定

image-20240521214225192

假如key交换到的位置的左右两边都有相同的数,就不稳定了

  • 归并排序——稳定

在对两个有序数组进行排序的时候,判断到两个数相同的时候,让前面的先到数组中。

image-20240521214339128

我们来看看一些选择题:

1. 快速排序算法是基于( )的一个排序算法。
A分治法
B贪心法
C递归法
D动态规划法

2.对记录(54,38,96,23,15,72,60,45,83)进行从小到大的直接插入排序时,当把第8个记录45插入到有序
表时,为找到插入位置需比较( )次?(采用从后往前比较)
A 3
B 4
C 5
D 6

3.以下排序方式中占用O(n)辅助存储空间的是
A 简单排序
B 快速排序
C 堆排序
D 归并排序

4.下列排序算法中稳定且时间复杂度为O(n2)的是( )
A 快速排序
B 冒泡排序
C 直接选择排序
D 归并排序

5.关于排序,下面说法不正确的是
A 快排时间复杂度为O(N*logN),空间复杂度为O(logN)
B 归并排序是一种稳定的排序,堆排序和快排均不稳定
C 序列基本有序时,快排退化成冒泡排序,直接插入排序最快
D 归并排序空间复杂度为O(N), 堆排序空间复杂度的为O(logN)

6.下列排序法中,最坏情况下时间复杂度最小的是( )
A 堆排序
B 快速排序
C 希尔排序
D 冒泡排序

7.设一组初始记录关键字序列为(65,56,72,99,86,25,34,66),则以第一个关键字65为基准而得到的一趟快速排序结果是()
A 34,56,25,65,86,99,72,66
B 25,34,56,65,99,86,72,66
C 34,56,25,65,66,99,86,72
D 34,56,25,65,99,86,72,66

答案是:

A C D B D A A

第7题采用的是挖坑法的思路进行单趟的排序

可以把左右指针法和前后指针法的排序结果也推出来——画图分析

  • 42
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值