数据结构初阶(15)排序算法—交换排序(快速排序)(动图演示)

2.3 交换排序

2.3.0 基本思想

交换排序的基本思想:

基本思想

  • 根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置。
    (比较结果→交换位置)

特点

  • 将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。

比 + 换

2.3.2 快速排序

快速排序是Hoare(霍尔)于1962年提出的一种二叉树结构交换排序方法。

快速排序整体的综合性能和使用场景都是比较好的,各种语言的库里面的排序算法的底层一般都是快速排序。

基本逻辑

基本逻辑

  • 任取待排序元素序列中的某元素作为基准值。
  • 利用基准值将待排序集合分割成两子序列:左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值。
  • 然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

特点

  • ++一趟排序,就定位一个元素的最终位置++,并递归处理两侧子数组。
  • 单趟排序的结果:key关键值来到了自己正确的位置,留下了一个左区间,一个右区间

动图演示

利用基准值将待排序集合划分为左右两半部分的常见方式有:

x.1 hoare版本
  • 选一个基准值key------一般是第一个值 / 最后一个值。
  • left找大:从前往后找比基准值大的元素,找到之后停止;
  • right找小:从后往前找比基准值小的元素,找到之后停止;
  • 如果left和right没有相遇:将left停止位置上的元素和right停止位置上的元素进行交换;
  • 循环执行上面3步。
  • 循环结束后将基准值和left位置上的元素进行交换。

快速排序算法的思想,依赖的主要操作还是"交换",所以快速排序所属大类还是------交换排序。

x.2 挖坑法
  • 让begin从前往后找比基准值大的元素,找到之后停止;
  • 将begin位置上的元素覆盖掉end位置上的元素(begin填end的坑,填完之后begin形成新的坑);
  • 让end从后往前找比基准值小的元素,找到之后停止;
  • 将end位置上的元素覆盖掉begin位置上的元素,end形成新坑;
  • 最后用基准值填最后一个坑。

x.3 前后指针版本
  • cur一直向前遍历,只有cur位置上的值比基准值小时,prev向前遍历。
  • 当cur和prev之间有差距,说明两者之间都为比基准值大的元素,交换cur和prev上的元素。
  • 最后将基准值和prev最后停下来位置上的元素进行交换。

算法步骤

核心逻辑

  • 首先设定一个基准值 (通常是第一个数字/最后一个数字),通过该基准值将数组分成左右两部分。
  • 分区(partition):将所有小于基准值的元素移到基准值左边,大于的移到右边。
  • 递归排序:对左右两个子数组递归进行上述过程。

优化方法

  • 采用三数取中法(取左、中、右三个位置的中间值作为基准值)。
  • 如果分区后子数组长度小于阈值(如 10)时,直接改用插入排序,减少了递归的开销

终止条件

  • 当left >= right (起始索引大于等于结束索引,子数据长度 <= 1),终止当前递归。
    (说明该子数组已有序)
  • 所有子数组均完成分区和排序时,整个数组即有序。

代码实现

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

cpp 复制代码
// 假设按照升序对array数组中[left, right)区间中的元素进行排序
void QuickSort(int array[], int left, int right)
{
     if(right - left <= 1)
         return;

     // 按照基准值对array数组的 [left, right)区间中的元素进行划分
     int div = partion(array, left, right);
 
     // 划分成功后以div为边界形成了左右两部分 [left, div) 和 [div+1, right)
     // 递归排左区间------[left, div)
     QuickSort(array, left, div);
 
     // 递归排右区间------[div+1, right)
     QuickSort(array, div+1, right);
}

发现与二叉树前序遍历规则非常像,在写递归框架时可想想二叉树前序遍历规则即可快速写出来。

后续只需分析如何按照基准值来对区间中数据进行划分的方式即可。

x.1 hoare版本
x.1.1 单趟排序

目的:

key左边的值都比key小,key右边的值都比key大



一趟插入排序:把end+1放到最终位置。

一趟选择排序:把当前最大/小值放到最终位置。

一趟冒泡排序:把当前最大值放到最终位置。

一趟快速排序:把key放到最终位置 && 左边比key小、右边比key大。(单趟快速排序的价值)


为什么说快速排序是二叉树结构的交换排序?

原因:因为快速排序的思想------key有序 + key左边有序 + key右边有序 ==> 整体有序

类似二叉树的前序遍历思想------处理根 + 处理左子树 + 处理右子树。

即快速排序的递归实现形式类似于二叉树。

实现思路:

  1. 记录key位置,一般最左边或最右边以此作为分区条件,左右两个指针从两端开始往中间走
  2. right先走,找到比key小的值停下,left后走,找到比key大的值停下。
    (left后走,可能找不到比key大的值,就遇到right了)
  3. left停止并且没遇到right,说明left的值比key大和right的值比key小,两个数据交换,大的在右边,小的就在左边
  4. left和right相遇时,需要将key和相遇位置的数据交换
  5. 目的达成,key比左边的值要大,比右边的值要小

代码实现:

cpp 复制代码
void Single_QuickSort(int* a, int left, int right)
{
	int key = a[left];
	while (left < right)
	{
        //升序------所有比较算法都一样,改变"关键比较"位置的大于、小于符号就能改变升降序
        //降序:左边找小、右边找大

		// right先走,找小
		while (a[right] > key)
		{
			--right;
		}

		// left再走,找大
		while (a[left] < key)
		{
			++left;
		}

		Swap(&a[left], &a[right]);
	}

	Swap(&a[left], &key);
}

这个代码的漏洞:

漏洞

  • ① while (a[right] > key)、while (a[left] > key)
    • 情况1------当前的写法,和key相等就会直接停下,当a[left] = a[right] = key,就会交换,而交换完之后,程序就会陷入死循环地进行交换。
    • 修改1------while (a[right] >= key)、while (a[left] >= key)
    • 情况2------当key为最小值,right找比key小就找不到,就会刹不住车。
      直接越界访问。
    • 修改2------while (left < right && a[right] >= key)、......
      (但是这样修改,对于情况2还是不算很好地处理,遇到情况2,right停在最左端,key左边为空,key右边全数组,递归就很不平衡了,效率就会下降,相当于走完这一轮,key没动过)
  • ② Swap(&a[left], &key)
    • 情况1------拿相遇位置(数组内部空间)和key(局部变量空间)进行值交换,而数组内部key值身处的位置却没发生交换。
    • 情况1------所不要直接把key值给key,而要把key值下标给keyi。
    • 修改2------Swap(&a[left], &a[keyi])

修改后的单趟排序:

cpp 复制代码
void Single_QuickSort(int* a, int left, int right)
{
    int begin = left, end = right;

	int keyi = left;
	while (left < right)
	{
        //升序------所有比较算法都一样,改变"关键比较"位置的大于、小于符号就能改变升降序

		// right先走,找小
		while (left < right && a[right] >= a[keyi])  //★走的过程中不能越界left < right
		{
			--right;
		}

		// left再走,找大
		while (left < right && a[left] <= a[keyi])  //★走的过程中不能越界left < right
		{
			++left;
		}

		Swap(&a[left], &a[right]);
	}
	// 此时begin和end在同一位置,需要将其中key和其中一个交换
	// 交换后,左边都比key小,右边都比key大
	Swap(&a[left], &a[keyi]);

	keyi = left;                //keyi保存相遇位置下标

    //左区间:[begin, keyi - 1]
    //右区间:[keyi + 1, end]

    //begin和end找不到了------left和right已经动了
    //所以需要在left、right向中靠拢前先保存它们的起始位置
}

多的两句,为全趟排序做准备。

cpp 复制代码
    int begin = left, end = right;
    keyi = left

注意事项:

1. 为什么是right先走,而不是left先走呢?

  • right先走 + 先停,是找小,而最后相遇的位置一定是right停止的位置。
    • L遇R:因为是left后走去找大,还没找到大就和right相遇,就会来到right停止的位置(比key小)。
    • R遇L:或者right先走找小,还没找到小就和left相遇,直接走到left(已经被交换为小、或者还在key原地)。
  • 这样就可以确保最后相遇的位置是比key小,和key交换后能确保key左边比key小。
  • 如果是 left先走 + 先停,是找大,而最后相遇的位置一定是left停止的位置,因为是right后走去找left停止的位置(比key大),或者left先走直接走到right(已经被交换为大、或者还在最右侧还没开走),那个位置的值大于key,key和left交换后话,key左边的值不全小于key,就出bug了。
  • 因为有条件left<right ,最后一次先走的那个找到想要的值就会停下 ,后走那个绝对不会超过先走的
  • 可以理解为最后一轮走动,先走的停下来的那个位置 ,就是相遇点
  • 右边作key,让left先走去找大,也就可以保证相遇位置比key大,原理同上。
    一般都是最左边、最右边作key------为了保证相遇位置一定在key的右边、左边。
  1. 找值的条件,left < end ,如果没有添加这个条件,就会造成越界,虽然在外循环的条件设置了left < right,但是在内部两个循环left和right在递增过程会越界。
  • 通过下图例子就可以发现,right会越界
  1. 找值的条件,a[right] >= a[keyi],这里必须要**>=,只有>**时,虽然可以解决上面越界问题,但是会造成死循环。
  • 通过下图例子可以看出,如果left和right的值一样,没有加=的情况,就会死循环。


经过一些举例,key的位置是最左边,right就先走,是最右边,left就先走

找值的时候**条件必须有left<end和****=**的情况

x.1.2 全趟排序

递归函数并非是把子区间拷贝到子数组去处理,而是传递下标直接操作原数组的子区间。

单趟完成后,此时数据就分成了两个区间,左边都是比key小,右边都是比key大。

我们利用分治的思想,让左右区间在进行单趟排序,在区间里在选一个key,在进行分区,直到最后左右区间不存在或者只有1个数,因为这种序列也可以认为是有序的,这样排序就完成了。

key排序好后,将两个区间递归进行
这思想类似二叉树前序遍历

  • 根 -> 左子树 -> 右子树
  • 先排好key的位置 -> key的左区间 -> key的右区间
    左区间[left, keyi - 1],右区间[keyi + 1, right]

递归返回条件:

  1. 区间不存在。
  2. 区间只有一个数。

这中序列也是有序的,begin == end或begin > end,因为分区的特性,区间不存在的情况是begin>end。

代码实现。

cpp 复制代码
// hoare(左右指针法)
void QuickSort(int* a, int left, int right)
{
	// 区间只有一个值:left = right
    // 区间不存在:left > right
    // 就是最小子问题
	if (left >= right)
		return;

	int begin = left, end = right;
	int keyi = left;
	
	// 相遇就结束
	while (left < right)
	{
	    // right 找小
		while (left < right && a[right] >= a[keyi])
		{
			right--;
		}

		// left 找大
		while (left < right && a[left] <= a[keyi])
		{
			left++;
		}

		// 交换
		Swap(&a[left], &a[right]);
	}
	// 此时begin和end在同一位置,需要将其中key和其中一个交换
	Swap(&a[left], &a[keyi]);
	keyi = left;	// 更新keyi的位置

	//分治递归(二叉树的核心思想)
	// [begin, keyi-1]   keyi    [keyi+1, end]    ------    递归的时候分出:左区间、右区间
	// 往下传的是下标,而非拷贝一个左子树来处理,处理的都是原树本身
	QuickSort(a, begin, keyi - 1);
	QuickSort(a, keyi + 1, end);
}
//递归返回:1.顺序执行完函数体;2.遇到最小子问题,直接返回

代码测试。

递归展开图。

也可以调试+打断点,观察单趟、全趟排序的过程。

x.2 挖坑法

hoare版本中,为什么左边置为keyi右边就要先走比较难理解,因此挖坑法诞生了。

由国内大佬想出,大致思路和hoare版本差不多,但是更利于理解。

挖坑法是对hoare版本的优化------不是在效率上的优化,是在理解上的优化。

实现思路:

首先明确单趟快排的目的就是为了把key排到正确的位置:

  1. 将第一个数据存放在临时变量key中,形成一个"坑位"。
  2. 开始坑在左边,右边开始找值补坑,right找到比key小的值,放入坑位,right形成新的坑。
    (升序:右找小)
  3. 现在坑在右边,左边开始找值补坑,left找到比key大的值,放入坑位,left形成新的坑。
    (升序:左找大)
  4. 直到left和right相遇就结束,最后把key放到坑位,key就排到正确的位置上。
  5. 以排好的key(下标为pivot)开始左右分区,[begin, pivot - 1] pivot [pivot + 1, end],进行分治递归。

这样右边先走就具有天然性------左边是坑,必然是右边先走去找值填坑。

相遇也必然是相遇在坑上。

代码实现:

cpp 复制代码
void QuickSort_pivot(int* a, int begin, int end)
{
	if (begin >= end)
		return;

	int left = begin, right = end;
	int key = a[left];
	int pivot = left;
	
	while (left < right)
	{
		// 找小于key的数
		while (left < right && a[right] >= key)
		{
			right--;
		}
		// 找到小的数放到左边的坑里,自己形成新的坑位
		a[pivot] = a[right];
		pivot = right;
		
		// 找大于key的数
		while (left < right && a[left] <= key)
		{
			left++;
		}
		// 找到大的数放到右边的坑里,自己形成新的坑位
		a[pivot] = a[left];
		pivot = left;
	}
	// 最后pivot会指向相遇点,相遇点就是key排好的位置
	a[pivot] = key;	

	// [begin, pivot-1] pivot [pivot+1, end]
	QuickSort_pivot(a, begin, pivot - 1);
	QuickSort_pivot(a, pivot + 1, end);
}

两种单趟排序结束后,结果不太相同,有的题目就会考察快排第一趟排序完的结果是什么?

那因为有多种单趟快排的方式,所以结果也不只一种结果,所以快排的各个方法我们都需要了解。

x.3 前后指针版本

这个方法不使用左右指针------hoare,而是使用一前一后,两个前后指针。

这个方法理解了,代码会很简单。

实现思路:

  1. 记录key,key一般都是最左边,目的就是为了把key排到正确的位置。
  2. prev和cur起始都在begin的位置,cur先走找小,cur >= key,cur++
  3. 找到小,即cur < key,那就由prev去追cur,追的路上顺便找大;先追上就cur继续走;先找到大就prev和cur交换后,cur再继续走。
    cur < key,prev++,交换prev和cur
    (如果cur是往前走一步就停,那prev往前走一步也恰好追上cur,不用交换;
    如果cur往前走了多步,说明走过了比key大的数,prev往前一步,就是比key大的数,与cur停止位置与key小的数交换;
    即没有遇到比key大的值之前,prev和cur是挨着/紧跟的)
  4. cur越界后,prev的位置就是key应该去的位置。
  5. 以排好的key开始左右分区,[begin, keyi - 1] keyi [keyi + 1, end],进行分治递归。

需要注意排序的思想两指针间隔,间隔的数都是大于key,当cur遇到小的数,prev++就会在间隔的地方(大于key的数位置),然后交换,就是大的数往后推,小的数往前甩。

这个方法的优势是代码写出来简单。劣势就是不如hoare版本好理解。

性能上这3种单趟快排的写法,都没什么区别。

同理,单趟前后指针快排的结果与单趟hoare快排后的结果也会有不同。


代码实现。

cpp 复制代码
void QuickSort_pointer(int* a, int left, int right)
{
	if (left >= right)
		return;
	
	int keyi = left;
	int prev = left;
	int cur = left + 1;

    //cur一直往后走,停下来交换的条件:遇到比key小 && 与prev不紧邻(中间有间隔元素)
	while (cur <= end)
	{	
		// cur一直递增,碰到小于key的值并且prev++不等于自己,就交换------prev++ == cur,会原地交换,没必要
		if (a[cur] < a[keyi] && ++prev != cur)    //相等就没必要交换(使用前置++意在使用++之后的值去比)
			Swap(&a[prev], &a[cur]);
		
		cur++;
	}
	// key交换到排好的位置,同时更新keyi的位置,原先在最左边
	Swap(&a[keyi], &a[prev]);

	keyi = prev;	            //这一步没有,也能正常递归(改变一下递归参数)
                                //但是加上这一步,能让代码更好理解

	// [begin, keyi-1] keyi [keyi+1, end],进行分治
	QuickSort_pointer(a, left, keyi - 1);
	QuickSort_pointer(a, keyi + 1, right);
}

性能分析(hoare)

时间复杂度:

快排的理想情况是每次的key最后恰好在数组的中间位置,这样每次递归分区时都能大概平分左右区间,这样递归结构近似于完全二叉树,递归深度(层数)就是logN。

单趟排序时间复杂度是O(N),每次就排好一个数。

现在左右分区一层的总的单趟排序时间复杂度也是近似O(N),可以认为平均一层O(N/2)。

最后得出快排整体的时间复杂度O ( N ∗ l o g N )。

最坏情况------数组有序。

有序情况下,就会出现下图中的极端情况,每次选key选是最小(最大)的数,就造成了每层只能排好一个数O(N),需要排N层,很明显这是一个等差数列。

不管升序还是降序,排完一层之后,key总是出现在边界(最左边、最右边),就会导致下一层只有1个孩子。

通过公式得出最坏的情况时间复杂度是O(N^2)。

但是这并不是不能解决,有人就针对这个极端情况做出了优化,确保每次在分区都不会取到最小或最大的数------三数取中法优化。

时间复杂度:

  • 时间性能取决于递归的深度。
  • 最好情况 :二叉树几乎平衡时,也就是数组划分的比较均匀(基准值位于中间),递归深度最小,为logN。
    递归过程中需要对i,j下标一起扫描数组,所以总体时间复杂度时O(N*log2N)。
  • 最坏情况:有序 / 接近有序,二叉树极度不平衡 ,整体时间复杂度达到O(N²)。
  • 优化过后:加上随机选key、三数取中选key,几乎不会出现最坏情况。

空间复杂度:

  • 空间性能取决于递归消耗的栈空间
  • 最好情况:已经分析过,需要递归logzN次,空间复杂度为O(logzN)
  • 最坏情况:已经分析过,需要递归N-1次,空间复杂度为O(N)

稳定性:

  • 不稳定

性能测试。

cpp 复制代码
void TestOP()
{
	srand(time(0));//要产生随机需要一个种子,否则随机是写死的伪随机
	const int N = 10000000;
	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);

	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];
	}

	int begin1 = clock();    //系统启动到执行到此的毫秒数
	//InsertSort(a1, N);
	int end1 = clock();      //系统启动到执行到此的毫秒数

	int begin7 = clock();
	//BubbleSort(a7, N);
	int end7 = clock();

	int begin3 = clock();
	//SelectSort(a3, N);
	int end3 = clock();

	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();

	int begin4 = clock();
	HeapSort(a4, N);
	int end4 = clock();

	int begin5 = clock();
	QuickSort(a5, 0, N - 1);
	int end5 = clock();

	//int begin6 = clock();
	//MergeSortNonR(a6, N);
	//int end6 = clock();

	//printf("InsertSort:%d\n", end1 - begin1);
	//printf("BubbleSort:%d\n", end7 - begin7);

	printf("ShellSort:%d\n", end2 - begin2);
	//printf("SelectSort:%d\n", end3 - begin3);
	printf("HeapSort:%d\n", end4 - begin4);
	printf("QuickSort:%d\n", end5 - begin5);
	//printf("MergeSort:%d\n", end6 - begin6);

	free(a1);
	free(a2);
	free(a3);
	free(a4);
	free(a5);
	free(a6);
	free(a7);
}

int main()
{
	//TestInsertSort();
	//TestBubbleSort();
	//TestShellSort();

	//TestSelectSort();
	//TestQuickSort();
	//TestMergeSort();
	//TestCountSort();
	TestOP();


	//MergeSortFile("sort.txt");

	return 0;
}

快速排序虽然和堆排序是同一量级,但是性能总是比堆排序好一些(类似于插入总是比冒泡好一些),因为快速排序的结构具有很强的适应性。

每次key(首元素)都是最小值(数据有序)就是快排最坏的情况,时间复杂度O(N^2)。

在数据是随机的情况下,快排很难出现最坏情况,整个过程可能只有几次这种情况。


测试对有序数组排序------Debug下。

结果:栈溢出。

快排优化(hoare)

优化思路0:排序前先判断有序------>不解决问题,接近有序,快排的速率也慢。

快排的缺陷来自于每次key选择的都是首/尾数据,这在数据有序时就是最值,选完之后无法二分数据,就把自己给坑死了。


y.0 随机选key

优化思路1:随机选key------避免最坏情况出现。

cpp 复制代码
void QuickSort1(int* a, int left, int right)
{
	if (left >= right)
		return;
	int begin = left, end = right;

	//选区间中的随机数作key------不怕有序
	//1.产生随机数
	//int randi = rand();

	//2,使随机数落在[left,right]区间内
	//randi %= (right - left);            //差值
    //randi += left;                      //起点 + 差值

	//合并成两句
    //int randi = rand() % (right - left + 1); 加不加1其实没所谓,反正也不想选到边界去
	int randi = rand() % (right - left);
	randi += left;

	//此时选出的keyi就在任意位置了------>导致单趟的快排就需要重新设计

	//为避免重新设计的麻烦,keyi仍使用左下标,但是修改 左值 为 随机下标 处的值
	Swap(&a[left], &a[randi]);

	int keyi = left;
	while (left < right)
	{

		while (left < right && a[right] >= a[keyi])
		{
			--right;
		}

		while (left < right && a[left] <= a[keyi])
		{
			++left;
		}
		Swap(&a[left], &a[right]);
	}
	Swap(&a[left], &a[keyi]);

	keyi = left;

	QuickSort1(a, begin, keyi - 1);
	QuickSort1(a, keyi + 1, end);
}

总共多了3句代码。


注意:函数名改成QuickSort1,里面的递归调用也要改成QuickSort1,否则还是会栈溢出。


代码测试。

这样快排处理有序数据,不会栈溢出了。但是还是比希尔排序慢。

因为只有快速排序是递归,在debug版本下每次创建函数栈帧都要插入很多调试信息。

Release版本下就会好很多。

或者Debug版本下处理非大量数据也还行。

随机选key·缺陷

  • 虽然说很难会选中最小(大)值,但是也很难选中中位数
    ------>使层高严格接近于logN。
  • 这样就会一边高,一边低,层数就会偏大。
  • 其次就是随机数法有很多不确定性,可能一个乱序数组,随机选出一个最小值或最大值。
y.1 三数取中选key

该优化是针对快排的最坏情况(有序情况),确保在每次在分区里选key的时候,能保证在分区内不会取最或最大的数。

三数取中:最左边和最右边以及中间的数,取这三个数中,值的大小是中间的(不是三个数里最大或最小),比如在有序的情况下中间的值真好能二分区间。

三数取中:left mid right,选取中等大小的那个作key。

效果

  • 当数据是随机的,保证不会取到最大或最小;
  • 当数据是有序的,恰好能二分中间。------最坏直接变最好

三数取中的代码实现------不太容易实现。

找最大值、最小值都好找,找中间值,需要两两比较。

cpp 复制代码
// 三数取中
int GetMidi(int* a, int left, int right)
{
	//求出中间下标
	int mid = (left + right) / 2;

	//执行程序发现三数取中法的效率不高
	//printf("%d %d %d", left, mid, right);
	//printf("->%d %d %d\n", a[left], a[mid], a[right]);
	//数据打印发现三数取中取中的数不太中:比较接近最小值(最大值)

	//三个数,找中间值------两两比较法(条件假设语句)
	if (a[left] < a[mid])
	{
		if (a[mid] < a[right])
			return mid;

		//若mid为最大值
		//那么left和right中较大的为中间值
		else if (a[left] > a[right])
			return left;
		else
			return right;
	}
	else     // a[left] >= a[mid]
	{
		if (a[mid] > a[right])
			return mid;

		//如果mid为最小值
		//那么left和right中较小的为中间值
		else if (a[left] < a[right])
			return left;
		else
			return right;
	}
	//返回三个下标中的值为中等的那个数的下标
}

注意三数取中需要返回下标,不返回下标的话,左边的值就无法交换到中间值的位置。

如swap(&a[left],&mid),因为mid是数据,无法找到在数据中的下标。

cpp 复制代码
void QuickSort2(int* a, int left, int right)
{
	if (left >= right)
		return;
	int begin = left, end = right;

	// 三数取中
	int midi = GetMidi(a, left, right);
	//printf("%d\n", midi);
	Swap(&a[left], &a[midi]);

	int keyi = left;
	while (left < right)
	{

		while (left < right && a[right] >= a[keyi])
		{
			--right;
		}

		while (left < right && a[left] <= a[keyi])
		{
			++left;
		}
		Swap(&a[left], &a[right]);
	}
	Swap(&a[left], &a[keyi]);

	keyi = left;

	QuickSort2(a, begin, keyi - 1);
	QuickSort2(a, keyi + 1, end);
}

只在单趟基础上,多了2行代码,就能解决快排的最坏情况,所以快排可以不看最坏情况。

cpp 复制代码
	int midIndex = GetMidIndex(a, begin, end);
	Swap(&a[midIndex], &a[begin]);

注意改一下两个递归调用的函数名。


代码测试。

三数取中在有序的情况下,消耗变得极大。

善用打印,辅助调试观察。------100个数据。

开始测试发现和预期不符。


改成。

再测还是不正常------>终端缓冲区高度不够。

调正常后,发现:

原因:堆排序后是降序,用三数取中优化后的快速排序算法,对降序数组排升序,消耗还是会比较大。

解释:选降序数组的中间值key放到begin,然后left找大,right找小,频繁停下来进行数据交换。

(降序转到升序去)

总而言之,堆排序处理随机数组、希尔排序处理随机数组、快速排序处理降序数组,这样的耗时对比已经不公平了。

堆排序对数组的有序程序敏感性不高。(10万数据,Release)

修改堆排序,建大堆。

同样对升序排升序的时间对比,才公平。(10万数据,Release)

同等情况下,三数取中快速排序还是会比堆排序快。

针对升序排升序,随机取key快排,比三数取中快排更慢。


再用相同的样本测试------随机数组。(100万数据,Release)

结论

  • 三数取中快速排序还是快。
  • 三数取中快速排序结果不是预期的比另外的快,原因在于没有针对同一组数据进行测试。

此时快速排序就比较完善了。

继续优化,效果不明显(多少还是有点效果)。

继续优化的方式就是------小区间优化。

y.2 小区间优化

快排是一个递归,递归到一个比较小的子区间时,再递归调用,消耗就相对而言比较大了。

(满二叉树最后一层的递归占了50%、倒数第2层的递归占25%,倒数第3层的递归占12.5%)

可以考虑使用插入排序。

(在最简单的3种排序中,插入优于选择、冒泡)

众所周知,与递归相关的算法,递归的过程类似与二叉树结构,高度是h,一共有个节点,调用就有次。

递归到最后几层数据已经接近有序并且调用次数占了大部分

所以小区间优化的目的是减少递归调用的次数。

以100w个数为例,最后几层的调用次数占了总调用次数的80%左右,所以我们就把他消除掉,我这里设区间数据个数少于10个,就不在递归调用了,转而使用插入排序。

因为最后10个数,经过上面不断递归,几乎接近有序了,插入排序针对与接近有序的情况,时间复杂度是O(N)。

代码实现。

cpp 复制代码
void QuickSort3(int* a, int left, int right)
{
	// 区间只有一个值或者不存在就是最小子问题------可以不写了
	if (left >= right)
		return;

	//小区间优化
	// 小区间选择走插入,可以减少90%左右的递归
	if (right - left + 1 < 10)                //小区间:这里取差值在10以内的区间为小区间
	{
		//插入排序
		InsertSort(a + left, right - left + 1);
	}

	//如果区间大于10,再去走快排的递归分割
	else
	{
		int begin = left, end = right;

		// 三数取中
		int midi = GetMidi(a, left, right);
		//printf("%d\n", midi);
		Swap(&a[left], &a[midi]);

		int keyi = left;
		while (left < right)
		{

			while (left < right && a[right] >= a[keyi])
			{
				--right;
			}

			while (left < right && a[left] <= a[keyi])
			{
				++left;
			}
			Swap(&a[left], &a[right]);
		}
		Swap(&a[left], &a[keyi]);

		keyi = left;

		QuickSort3(a, begin, keyi - 1);
		QuickSort3(a, keyi + 1, end);
	}
}

加了小区间优化,对递归子问题的判断就可以没必要写了。

注意

cpp 复制代码
InsertSort(a + left, right - left + 1);

插入排序的调用参数应该对应需要排序的区间。

这个优化从效果上来说,并不明显------Release下对递归的优化比较大,编译器对递归专门作了各种优化,函数递归调用的消耗没有想象中那么大。

Debug版本下,能观察到相对更明显的优化,但幅度也不大。


挖坑法加上三数取中和小区间优化代码如下:

cpp 复制代码
void QuickSort_pivot(int* a, int begin, int end)
{
	if (begin >= end)
		return;
	
	// begin和end是闭区间,如[0,9],一共10个数,所以+1,10个数以下就小区间优化
	if (end - begin + 1 > 10)
	{
		// 三数取中优化
		int midIndex = GetMidIndex(a, begin, end);
		Swap(&a[begin], &a[midIndex]);
	
		int left = begin, right = end;
		int key = a[left];
	
		int pivot = left;
		
		
		// 挖坑法
		while (left < right)
		{
			while (left < right && a[right] >= key)
			{
				right--;
			}
			a[pivot] = a[right];
			pivot = right;
	
			while (left < right && a[left] <= key)
			{
				left++;
			}
			a[pivot] = a[left];
			pivot = left;
		}
		a[pivot] = key;
	else
	{
		// 小区间优化
		InsertSort(a + begin, end - begin + 1);
	}
}

教材式的实现。

cpp 复制代码
#define MEX_LENGTH_INSERT_SORT 10    /*数组长度阀值*/

/*对顺序表L中的子序列L.r[low..high]作快速排序*/
void Qsort(SaList &L,int low,int high)
{
    int pivot;
    if((high-low) > MAX_LENGTH_ INSERT_SORT)
    {/*当high-low大于常数时用快速排序*/
        pivot=Partition(L,low,high);    /*将L.r[1ow..high]一分为二,*/
                                        /*并算出枢轴值pivot*/
        Qsort(L,low, pivot-1);          /*对低子表递归排序*/
        Qsort(L, pivot+1, high);        /*对高子表递归排序*/
    }
    else    /* 当high-low小于等于常数时用直接插入排序 */
        InsertSort (L);
}

面试让手撕快排,不用管三数取中和小区间优化。(尽量在10min内撕出来)

尽快把"单趟+递归"的快速排序写出来,交流到的时候再说明可以加上三数取中、小区间优化。

  • 三数取中是针对"数组有序"这种情况快排会变最坏。
  • 小区间优化是避免掉底层的大量递归。

非递归版实现(解决栈溢出)

递归有一个致命的缺陷,不能调用很多次,因为递归是在内存中栈区中建立空间,栈区不像堆区容量大,一般默认是8M,所以调用很多次,没能及时销毁话,栈就会溢出。
(Stack Overflow)。

如图,我们通过递归阶乘,求10000!的结果,从调试得出栈就溢出了,因为需要10000层栈帧,递归深度太深,不能及时销毁,导致栈溢出。

递归改成非递归一般分两种情况

  • 直接改成循环。
  • 借助数据结构的栈或队列,模拟递归在建栈帧的过程。

改非递归先看是否能直接改成循环。

如果递归过程比较复杂就需要借助数据结构的栈或队列来模拟递归调用过程。

递归过程中,每次都会创建临时变量,而数据结构栈和队列的只能使用特定位置的数据 ,想要使用其他位置就必须出掉之前数据,类似销毁栈帧,所以很契合。

我们直接借助的是数据结构的栈来实现,因为数据结构的栈和操作系统内存的栈区,特性都是"后进先出",所以模拟过程能更相似递归调用。

递归和非递归的时间复杂度可能会有所不同,就像斐波那契数列,因此在进行转换时需要仔细分析算法的时间复杂度,并确保转换后的非递归的时间复杂度与原来的递归函数相同或更优。此外,非递归函数可能会占用更多的空间,因为需要使用栈或队列来存储中间结果。

思路:

每次递归调用,核心数据就是排序区间,递归改非递归的核心思路就是:

  • 取栈顶区间,执行单趟排序,右、左子区间入栈。(按类似前序的方式,先处理左区间)
  1. 压栈当前排序区间:先入右下标,再左下标,因为需要先取到左下标。
  2. 取左右下标组成一个区间,单趟排好一个值(key),在利用key分成左区间[left,key-1],右区间[key+1,right]。
  3. 检查左右区间数据个数,如果区间内的数据个数在二个以上,就需要再入栈排序。
  4. 重复2,3步,直到栈为空,数据就都排好了。

由于栈的特性,永远左区间先取出,所以左区间先排好,右区间再排好

在过程上就和递归调用一样。

递归版本:借助操作系统的栈区(空间不大)------8M左右

非递归版本:借助数据结构的栈(在堆区,堆区的内存大小远大于栈区)------2G左右


代码实现。(单趟快排使用前后指针版本)

cpp 复制代码
#include "Stack.h"
// 快排单趟前后指针法------注意这里有返回值
int SingleQuickSort(int* a, int begin, int end)
{
	if (begin >= end)
		return;

	int keyi = begin;
	int prev = begin;
    int cur = begin + 1;

	while (cur <= end)
	{
		if (a[cur] < a[keyi] && ++prev != cur)
		{
			Swap(&a[prev], &a[cur]);
		}
		cur++;
	}
	Swap(&a[keyi], &a[prev]);
	keyi = prev;

	return keyi;
}

void TestQuickSortNonR(int* a, int n)
{
	Stack st;
	StackInit(&st);

	// 先入区间右下标,再入左下标------为了先取左下标
	StackPush(&st, n - 1);	
	StackPush(&st, 0);

    //栈不为空,就继续
	while (!StackEmpty(&st))
	{
		int left = StackTop(&st);        //取区间左下标
		StackPop(&st);
		int right = StackTop(&st);       //取区间右下标
		StackPop(&st);

		// 执行快排单趟,排好一个值key
        // 依靠返回值------分割左右区间
		int keyi = SingleQuickSort(a, left, right);
		
        // [left, keyi - 1] keyi [ keyi + 1, right]

		// key+1小于right,说明至少右区间还有两个数据,还需要对右区间入栈排序
		if (keyi + 1 < right)
		{
			StackPush(&st, right);        //入栈:右区间的右下标
			StackPush(&st, keyi + 1);     //入栈:右区间的左下标
		}
		
		// left小于keyi - 1,说明至少左区间还有两个数据,还需要入栈排序
		if (left < keyi - 1)
		{
			StackPush(&st, keyi - 1);    //入栈:左区间的右下标
			StackPush(&st, left);        //入栈:左区间的左下标
		}
	}

    // 栈为空,排序就完成
	StackDestroy(&st);
}

特性总结

快速排序的特性总结:

  1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
  2. 时间复杂度:O(N*logN)。
  3. 空间复杂度:O(logN)。
  4. 稳定性:不稳定。
  5. 递归版本有缺陷,就是在建立太多栈帧情况,不能及时返回就会栈溢出。
    非递归就能解决该问题,效率上二者差不多。
相关推荐
yi.Ist3 分钟前
图论——Djikstra最短路
数据结构·学习·算法·图论·好难
数据爬坡ing18 分钟前
过程设计工具深度解析-软件工程之详细设计(补充篇)
大数据·数据结构·算法·apache·软件工程·软件构建·设计语言
呼啦啦啦啦啦啦啦啦2 小时前
【Java】HashMap的详细介绍
java·数据结构·哈希表
qq_513970442 小时前
力扣 hot100 Day74
数据结构·算法·leetcode
风铃7772 小时前
c/c++ Socket+共享内存实现本机进程间通信
linux·c语言
Cx330❀3 小时前
【数据结构初阶】--排序(三):冒泡排序、快速排序
c语言·数据结构·经验分享·算法·排序算法
lsnm4 小时前
【LINUX网络】HTTP协议基本结构、搭建自己的HTTP简单服务器
linux·运维·服务器·c语言·网络·c++·http
qiuyunoqy5 小时前
list模拟实现
数据结构·c++·list