《数据结构·排序·进阶:希尔、堆、快排核心解析》——为何希尔是插入进阶?堆排序时间复杂度的关键?

目录

前言

希尔排序:插入排序[进阶版]

希尔排序为什么是插入排序的进阶版呢?

希尔排序的思路:

代码实现逻辑:

代码实现

希尔排序的时间复杂度

堆排序:选择排序

堆的性质

堆排序的核心步骤

向上调整法:

向下调整法:

建堆

向上调整法建堆

向下调整法建堆

堆排序思路+代码

堆排序的时间复杂度

快速排序:交换排序

hoare版本

代码实现:

"挖坑"版本

代码实现:

"lomuto前后指针"版本

代码实现:

非递归版本的快速排序

hoare版本非递归

"挖坑"版本

"lomuto前后指针"版本

三数取值

快速排序的局部区间优化

总结

核心逻辑回顾:各算法的"解题思路"

[1. 快速排序:分治思想的极致应用](#1. 快速排序:分治思想的极致应用)

[2. 希尔排序:插入排序的"进阶版"](#2. 希尔排序:插入排序的“进阶版”)

[3. 堆排序:贪心思想与完全二叉树的结合](#3. 堆排序:贪心思想与完全二叉树的结合)

二、性能对比

三、适用场景:不同需求下的"最优选择"

[1. 快速排序:大数据量的首选(工程实践核心)](#1. 快速排序:大数据量的首选(工程实践核心))

[2. 希尔排序:中等数据量的"高效轻量选择"](#2. 希尔排序:中等数据量的“高效轻量选择”)

[3. 堆排序:需要"随时获取极值"的场景](#3. 堆排序:需要“随时获取极值”的场景)

四、学习要点:

五、总结:构建自己的"排序算法工具箱"


前言

如果说冒泡排序、选择排序、插入排序是排序算法的基础的话,那希尔排序、堆排序、归并排序 ,就是真正走进工程应用的排序------它们的时间复杂度都是达到了O(nlogn),解决了O(n²)算法再大规模数据下的性能瓶颈,同时各自的设计思路,也代表了排序算法的三种经典优化方向

下面这个是测试算法排序性能的代码

cpp 复制代码
//交换函数 
void Swap(int* x, int* y)
{
	int tmp = *x;
 	*x = *y;
	*y = tmp;
}
// 测试排序的性能对⽐
void TestOP()
{
	srand((unsigned int)time(0));
    //每个空间10万个数据
	const int N = 100000;
    //给七个算法排序创建存储空间
	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();
	//InsertionSort(a1, N);
	int end1 = clock();
	int begin2 = clock();
	ShellSort(a2, N);
	int end2 = clock();
	int begin3 = clock();
	//SelectionSort(a3, N);
	int end3 = clock();
	int begin4 = clock();
	//HeapSort(a4, N);
	int end4 = clock();
	int begin5 = clock();
	//QuickSort(a5, 0, N - 1);
	int end5 = clock();
	int begin6 = clock();
	//MergeSort(a6, N);
	int end6 = clock();
	int begin7 = clock();
	//BubbleSort(a7, N);
	int end7 = clock();
    //打印每个算法排序执行时间
	printf("InsertionSort:%d\n", end1 - begin1);
	printf("ShellSort:%d\n", end2 - begin2);
	printf("SelectionSort:%d\n", end3 - begin3);
	printf("HeapSort:%d\n", end4 - begin4);
	printf("QuickSort:%d\n", end5 - begin5);
	printf("MergeSort:%d\n", end6 - begin6);
	printf("BubbleSort:%d\n", end7 - begin7);
	free(a1);
	free(a2);
	free(a3);
	free(a4);
	free(a5);
	free(a6);
	free(a7);
}

希尔排序:插入排序[进阶版]

希尔排序为什么是插入排序的进阶版呢?

我们知道插入排序再处理接近有序的数据的时候,时间复杂度可以达到O(n),所以希尔排序正是精准解决了插入排序再大规模无序数据 下的性能短板,通过逐步缩小分组的间隔,让数组快速趋近有序,最终用高效的插入排序收尾

希尔排序的思路:

先分组预排序,在整体插入排序
希尔排序通过设定一个"增量序列",将原始数组按增量划分为多个子数组,对每个子数组分别进行插入排序;随后逐步缩小增量,重复分组与排序操作;当增量缩小至1时,整个数组被划分为元素个数 个子数组,此时进行最后一次插入排序,数组即可完全有序。

这里的"增量"可以理解为子数组中元素的间隔,比如增量为5时,数组中索引指向的下标为0、5、10...的元素构成一个子数组,索引为1、6、11...的元素构成另一个子数组,以此类推。随着增量逐渐减小,子数组的规模逐渐扩大,数组的整体有序性也逐步提升,最终在增量为1时完成收尾,此时的插入排序因为数组已接近有序,效率会非常高。

如果没看懂的话,可以看下这张流程图:

插入排序的衍生就是希尔排序,只是希尔排序在直接进行gap == 1时的操作前,会通过gap = n 来获取gap初始值,在正式预排序的时候gap = gap / 3 + 1 ,来不断的进行预排序,而这个预排序其实就是让原来的插入排序从向前的1个步长的元素开始比较,到现在的gap步长的元素开始比较,每进行一次gap步长的插入排序,gap都会重新赋值(gap = gap / 3 + 1),直到gap等于1的时候(这里gap等于1不是最后一次1步长的插入排序就不进行了,会执行完程序在退出的),

看到这里你在返回去看看上面这个图动手画一下,理解的会更深刻一点

我们可以观察发现,数组每进行一次预排序之后,数组的值都会变的相对有序起来,大的靠右,小的靠左

gap它不是一个固定得值,他是一个变化的值 ,怎么变化的,一般取得的都是初始化gap = n预排序过程中gap = gap / 3 + 1,为什么是除3还要加1?因为每次除3的效率更高,加1是为了保证最后gap是大于0的数,这个后面我会去证明我的观点

代码实现逻辑:

接下来,为了更好的理解什么是希尔排序?什么是预排序?为什么希尔排序的每个预排序就是步长为gap的插入排序?

普通的插入排序(gap == 1)

cpp 复制代码
//插入排序
void InsertionSort(int* a, int n)
{
	//控制轮数
    //这里的n - 1也是因为gap == 1
	for (int i = 0; i < n - 1; i++)
	{
		//取有序区域的尾元素
		int index = i;
        //无序区域的第一个元素
		int tmp = a[i + 1];
		//index一直向前遍历,小于0说明有序区域已经遍历完了
		//如果在有序区间遇到比tmp小的就退出
		while (index >= 0 && a[index] > tmp)
		{
			//如果有序区域内的元素大于tmp,就后移
            //index+1 也是因为gap == 1
			a[index + 1] = a[index];

			//向前遍历
            //gap == 1,所以这里就是--
			index--;
		}
        //index+1 也是因为gap == 1
		a[index + 1] = tmp;
	}
}

插入排序(gap == 2)

cpp 复制代码
//插入排序
void InsertionSort(int* a, int n)
{
	//控制轮数
    //这里的n - 2也是因为gap == 2
	for (int i = 0; i < n - 2; i++)
	{
		//取有序区域的尾元素
		int index = i;
        //无序区域的第一个元素
		int tmp = a[i + 2];
		//index一直向前遍历,小于0说明有序区域已经遍历完了
		//如果在有序区间遇到比tmp小的就退出
		while (index >= 0 && a[index] > tmp)
		{
			//如果有序区域内的元素大于tmp,就后移
            //index+2 也是因为gap == 2
			a[index + 2] = a[index];

			//向前遍历
            //gap == 2
			index -= 2;
		}
        //index+2 也是因为gap == 2
		a[index + 2] = tmp;
	}
}

插入排序(gap == 4)

cpp 复制代码
//插入排序
void InsertionSort(int* a, int n)
{
	//控制轮数
    //这里的n - 4也是因为gap == 4
	for (int i = 0; i < n - 4; i++)
	{
		//取有序区域的尾元素
		int index = i;
        //无序区域的第一个元素
		int tmp = a[i + 4];
		//index一直向前遍历,小于0说明有序区域已经遍历完了
		//如果在有序区间遇到比tmp小的就退出
		while (index >= 0 && a[index] > tmp)
		{
			//如果有序区域内的元素大于tmp,就后移
            //index+4 也是因为gap == 4
			a[index + 4] = a[index];

			//向前遍历
            //gap == 4
			index -= 4;
		}
        //index+4 也是因为gap == 4
		a[index + 4] = tmp;
	}
}

这几张图里,我把不同步长的交换流程都画了出来,刚刚代码里受gap影响的代码如果没看懂的话,可以再好好看看这几张图

刚刚我从插入排序的角度,去剖析希尔排序的预排序原理

你会发现其实所谓的希尔排序就是在原本的普通插入排序(gap == 1)执行之前,对数据进行gap !=1的插入排序,这个过程就是预排序

gap = {n/3+1,(n/3+1)/3+1,((n/3+1)/3+1)/3+1,...,1},直到执行到gap == 1,才开始执行普通的插入排序,而插入排序的gap从等于n/3+1,执行到gap 等于 1的这个过程就是希尔排序

所以,只要我们控制好了**{n/3+1,(n/3+1)/3+1,((n/3+1)/3+1)/3+1,...,1}**这个序列里面的值,那么我们就可以控制希尔排序了

代码实现

cpp 复制代码
//希尔排序
void ShellSort(int* a, int n)
{
    //创建gap,将数组的元素个数赋值给gap
	int gap = n;
    //这里控制的就是{n/3+1,(n/3+1)/3+1,((n/3+1)/3+1)/3+1,...,1}
	while (gap > 1)
	{
        //代码保证了gap会从上面那个序列的第一个值一直递减到1
		gap = gap / 3 + 1;
        //控制每轮比较的次数,n-gap防止越界
		for (int i = 0; i < n - gap; i++)
		{    
            //这下面的逻辑和插入排序的逻辑是一致的
			int index = i;
			int tmp = a[i+gap];
			while (index >= 0 && a[index] > tmp)
			{
				a[index + gap] = a[index];
				index -= gap;
			}
			a[index + gap] = tmp;
		}
	}
}

然后这里可以给大家对比一下为什么说gap = gap / 3 + 1的时间效率比gap = gap / 2的效率高

gap = gap / 3 + 1

gap = gap / 2

cpp 复制代码
//希尔排序
void ShellSort(int* a, int n)
{
	int gap = n;
	while (gap > 0)
	{
		gap = gap / 2;
		for (int i = 0; i < n - gap; i++)
		{
			int index = i;
			int tmp = a[i+gap];
			
			while (index >= 0 && a[index] > tmp)
			{
				a[index + gap] = a[index];
				index -= gap;
			}
			a[index + gap] = tmp;
		}
	}
}

虽然相差不多,但是确实存在性能上面的差异,也说明了gap每次的取值会希尔排序的性能都是有影响的

希尔排序的时间复杂度

希尔排序的时间复杂度没有精确的、被严格证明的结果,它的时间消耗和增量序列的选择强相关,同时也和待排序数组的初始状态有关,以下是详细的说明:

  1. 时间复杂度的范围希尔排序的时间复杂度介于 O(nlog n) 和 O(n^2) 之间,平均时间复杂度约为 O(n^1.3),这个数值是基于大量的实验统计得到的结果,并没有对应的数学证明

  2. 不同增量序列对应的时间复杂度希尔排序的时间复杂度完全由增量序列决定,不同的增量序列,时间复杂度的差异非常大:

    • **希尔增量(原始增量:(gap = n/2、n/4......1)**这是希尔排序最初使用的增量序列,它的最坏时间复杂度为 O(n^2),和普通插入排序的最坏复杂度一致,这种增量的效率比较低,现在已经很少使用

    • **Hibbard 增量(gap = 2^k - 1)**这个增量序列的数值为 1、3、7、15、31......,它的最坏时间复杂度为 O(n^1.5),平均时间复杂度约为 O(n^1.25),效率要比希尔增量高很多

    • Sedgewick 增量这个增量序列是由多个数列组合而成,数值为 1、5、19、41、109......,它的最坏时间复杂度约为 O(n^1.3),是目前已知的、效率比较高的增量序列之一

    • **Knuth 增量(gap = (3^k -1)/2)**这个增量序列的数值为 1、4、13、40......,它的最坏时间复杂度约为 O(n^1.5),实际使用中的效率也很不错

  3. 为什么没有精确的时间复杂度希尔排序的排序过程,是通过多次分组插入排序完成的,分组的方式会随着增量的变化而变化,整个过程的操作次数很难通过数学方式进行精确的推导,所以目前并没有一个被严格证明的精确时间复杂度,只有基于实验的统计结果

  4. 时间复杂度的相关补充

    • 希尔排序的最好时间复杂度是 O(n),当待排序数组本身就是有序的时候,每个分组的插入排序都不需要进行元素的交换,只需要进行遍历,所以时间消耗会很低

    • 希尔排序的时间复杂度和数组的初始状态有关,数组初始越接近有序,希尔排序的时间消耗就越少

但是这里可以给大家一个定论的是,在希尔排序过程中,每一次的排序的时间复杂度是呈低高低走势的,类似下面这个图

希尔排序时间复杂度不好计算,因为 gap 的取值很多,导致很难去计算,因此很多书中给出的希尔排序的时间复杂度都不固定。《数据结构(C语⾔版)》--- 严蔚敏书中给出的时间复杂度为:

堆排序:选择排序

堆排序(Heapsort)是指利⽤堆积树(堆)这种数据结构所设计的⼀种排序算法,它是选择排序的⼀种。它是通过堆来进⾏选择数据。需要注意的是排升序要建⼤堆,排降序建⼩堆

如果二叉树的相关知识点如果忘了可以去看一下我之前的博客里面对于二叉树和堆排序都有详细的讲解,本篇博客主要是讲解各种排序的思路和实现逻辑,排序之间的性能比较

下面这篇博客对于二叉树知识点、堆的知识点、堆排序的知识点都有详细讲解

https://blog.csdn.net/M_L_J/article/details/155879408?spm=1001.2014.3001.5501

堆的性质

堆是一种完全二叉树,同时需要满足堆的性质,分为两种:

  • 大堆:任意一个父节点的值,都大于等于它的 左右子节点的值,堆顶(根节点)是整个堆的最大值
  • 小堆:任意一个父节点的值,都小于等于它的 左右子节点的值,堆顶(根节点)是整个堆的最小值

堆在顺序表数组中定位父节点和左右子节点的公式 :

  • 它的左子节点索引为:2*i + 1

  • 它的右子节点索引为:2*i + 2

  • 它的父节点索引为:(i - 1) / 2

在实现堆排序的时候,我们一般使用大堆来实现升序排序 ,使用小堆来实现降序排序

堆排序的原理:通过堆顶元素与堆尾元素的交换,然后在使用向下调整法重新保证堆的性质

堆排序的核心步骤

堆排序的整体流程分为两个大阶段:构建初始大顶堆 + 堆顶元素与末尾元素交换,重新调整堆

说到建堆、元素交换后维持堆的性质,就离不开向下调整法向上调整法

向上调整法:

使用向上调整法的前提条件

被调整元素的前面部分必须是一个堆

向上调整法的核心思路

向堆中 添加一个元素,并且保证不改变堆的性质

添加的元素放在堆的尾部,然后利用公式定位到该元素的父节点,和父节点比较(大堆:大于父节点就交换 小堆:小于父节点就交换),直到和根节点比较完成,那么该元素就算添加完成

接下来我们看一下向上调整法的代码:

cpp 复制代码
//向上调整法
void HeapUp(int* a,int n)
{
    //拿到尾元素的下标
    int child = n;
    //求出尾元素的父节点下标
	int parent = (n - 1) / 2;
	while (child > 0)
	{
		//大堆
		if (a[parent] < a[child])
		{
			Swap(&a[parent], &a[child]);
		}
        //往树的根节点接着往上比较
		child = parent;
		parent = (child - 1) / 2;
	}
}

向下调整法:

使用向下调整法的前提条件

被调整元素的左右子树必须是一个堆

向下调整法的核心思路

现在有一个数组,我要把它变成堆,用向下调整法来建堆

叶子节点这一层可以被看做是堆,因为它们没有孩子节点,所以我们从叶子节点的父节点开始,向下比较,先在俩个子节点中选出最大的或者最小的,然后和父节点比较(大堆:大于父节点就交换 小堆:小于父节点就交换)比较完以后,parent --,child重新根据父节点赋值,直到叶子child的值大于数组尾元素下标

向下调整法代码:

cpp 复制代码
//向下调整法
//                 目标父节点  数组尾元素的下标
void HeapDown(int* a, int parent_index, int size)
{
	int parent = parent_index;
	int child = 2 * parent + 1;
	while (child <= size)
	{
		//大堆
		if (child + 1 <= size && a[child] > a[child + 1])
		{
			child++;
		}
		if (a[parent] > a[child])
		{
			Swap(&a[parent], &a[child]);
		}
		parent = child;
		child = parent * 2 + 1;
	}
}

建堆

学会了向下调整法和向上调整法,那么建堆就有两种方式来建堆

先说一个结论,向下调整建堆的时间复杂度优于向上调整建堆

向上调整法建堆

先看代码:

cpp 复制代码
//向上调整建堆:
void HeapUpAd(int* a, int n)//n == 尾元素下标
{
	assert(a);
    //思路还是一样的
    //就是把每一次的向上调整用for循环来统一管理
	for (int i = 1; i <= n; i++)
	{
		int child = i;
		int parent = (child-1)/2;
		
		while (child > 0)
		{
			if (a[parent] > a[child])
			{
				Swap(&a[parent], &a[child]);
			}
			child = parent;
			parent = (child - 1) / 2;
		}
	}
}

这种建堆方式有两种使用方式:

  • 边存边建堆
  • 给定一个数组,然后建堆
向下调整法建堆

看代码:

cpp 复制代码
//向下调整建堆
void HeapDownAd(int* a,int size)//size == 尾元素下标
{
    //控制每次向下调整对比的父节点的下标
	for (int i = (size - 1) / 2; i >= 0; i--)
	{
		int parent = i;
		int child = parent * 2 + 1;
		while (child <= size)
		{
            //建大堆
			if (child + 1 <= size && a[child] < a[child + 1])
			{
				child++;
			}
			if (a[child] > a[parent])
			{
				Swap(&a[child], &a[parent]);
			}
			parent = child;
			child = parent * 2 + 1;
		}
	}
}

无论是向上调整还是向下调整,都是利用for来控制每次传入的下标,达到建堆的效果

堆排序思路+代码

堆排序思路:

如果是升序的话,在大堆的基础上,将堆顶的元素和堆尾 的元素互换,然后再用向下调整法对堆顶的元素进行建堆(此时你的向下调整法的结束条件就不是堆尾元素的下标了,而是他的前一个,否则你换过去的最大值就会跑到其他位置去,不光排序完成不了,堆也建不好了)(应为堆顶与堆尾元素的互换,一定会破坏堆的性质),建好堆以后,在用堆顶的元素与堆尾的前一个元素交换,并且向下调整的结束条件要在-1,依次循环,直到堆顶元素和和要交换的堆尾元素的下标一致,退出循环结束运行,此时排序就已经排好了

cpp 复制代码
//堆排序-升序-大堆
void HeapSort(int* a, int n)
{
	for (int i = n; i > 0; i--)
	{
		Swap(&a[0], &a[i]);
		int parent = 0;
		int child = parent * 2 + 1;
		while (child <= i - 1)
		{
			if (child + 1 <= i - 1 && a[child] < a[child+1])
			{
				child++;
			}
			if (a[parent] < a[child])
			{
				Swap(&a[parent], &a[child]);
			}
			parent = child;
			child = parent * 2 + 1;
		}
	}
}

堆排序的时间复杂度

堆排序分为建堆+拆堆、重新建堆

建堆的时间复杂度:

假设有一棵7个元素的满二叉树:

1

/ \

2 3

/ \ / \

4 5 6 7

向下调整法从最后一个非叶子节点开始调整也就是3-->6和7比较一次-->3和他们最小值比较一次,这里就两次了,2节点也是两次,那么1要四次(这里算的是最坏的结果)

2+2+4 = n+1 ,按照大O的核心规则(忽略常数项 忽略低阶项 只看最高阶项

等于O(n)

拆堆的时间复杂度:

假设有一棵7个元素的满二叉树:

1

/ \

2 3

/ \ / \

4 5 6 7

首尾互换

7

/ \

2 3

/ \ / \

4 5 6 1

然后重新开始建堆

2

/ \

5 3

/ \ / \

4 7 6 1

建好以后,7从堆顶到现在这个位置最多比较4次 == log(n+1)+1次

6

/ \

5 3

/ \ / \

4 7 2 1

重新开始建堆

3

/ \

5 6

/ \ / \

4 7 2 1

建好以后,6从堆顶到现在这个位置最多比较了2次 == log(n+1)-1

...

我们计算的就是最坏的情况,移动最多次数就是log(n+1)+1,然后堆顶元素交换要交换n-1次,所以(log(n+1)+1) * (n-1) = nlog(n+1)+n-log(n+1)-1,根据大O的计算规则就是

O(nlogn)

堆排序的时间复杂度:O(n)+O(nlogn) = O(nlogn)

快速排序:交换排序

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

hoare版本

这张图就是hoare快速排序的递归思路,他的每一次递归都会使数组的元素变得相对有序起来,因为最后key指向的数据和left指向的数据交换以后,你会发现左边是小于left指向的值,右边是大于的

代码实现:

cpp 复制代码
//快速排序的逻辑实现部分
int _QuickSort(int* a, int left, int right)
{
	int key = left;
   //左值等于右值就结束
	while (left < right)
	{
        //如果end在往左遍历的时候,已经等于begin那么久不执行循环了
        //主要是处理次有序情况
		while (left < right && a[right] >= a[key])
		{
			right--;
		}

		while (left < right && a[left] <= a[key])
		{
			left++;
		}
		if(left < right)
			Swap(&a[left], &a[right]);
	}
    //交换key指向的数据和begin指向的数据
	Swap(&a[key], &a[left]);
	return left;
}
//这是快速排序的递归函数
void QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}
    //进入快速排序函数,获取划分下标点
    //根据每次返回的下标划分区间
	int key = _QuickSort(a, begin, end);
	QuickSort(a, begin, key - 1);
	QuickSort(a, key + 1, end);
}

这里的right必须先走,因为right不先走的话,最后right和left相遇的地方的值就不一定是小于key下标的值了

"挖坑"版本

思路:

  • 创建左右指针
  • ⾸先从右向左找出⽐基准⼩的数据,找到后⽴即放⼊左边坑中,当前位置变为新的"坑",然后从左向右找出⽐基准⼤的数据,找到后⽴即放⼊右边坑中,当前位置变为新的"坑",结束循环后将最开始存储的分界值放⼊当前的"坑"中,返回当前"坑"下标(即分界值下标)

key还是一样的一开始将a[left]赋值给它,并且还要创建一个变量keyI来保存坑的下标,随后看图里你发现left这个位置的元素是不是空掉了,其实他还在数组,并且还在left这个位置,图中的意思是这个位置的值可以被覆盖了,所以其实某种意思上来说就是被删了嘛

还是right先移动,找到比key小的值,然后把该值直接覆盖给a[keyI]的位置,然后此时的keyI = right,成为新坑,然后left才开始动,找比key小的值,找到以后该值覆盖给a[keyI]的位置,keyI = left,成为新坑

直到left < right这个条件不满足

这个版本的快速排序,在筛选值的过程中并不是和hoare版本的一样,两边找完以后再交换俩边的值,而是先挖一个坑,之后找一个填一个在挖一个坑,直到left和right相遇,把key填到最后一个坑

代码实现:

cpp 复制代码
int _QuickSort(int* a, int left, int right)
{
	int key = a[left];
    //保存坑的下标
	int keyI = left;
	while (left < right)
	{
		while (left < right && a[right] >= key)
		{
			right--;
		}
        //将值放到坑里
		a[keyI] = a[right];
        //保存新坑的位置
		keyI = right;
		while (left < right && a[left] <= key)
		{
			left++;
		}
        //将值放到坑里
		a[keyI] = a[left];
        //保存新坑的位置
		keyI = left;
	}
    //最后将key放到最后的坑中
	a[left] = key;
	return left;
}

void QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}
	int key = _QuickSort(a, begin, end);
	QuickSort(a, begin, key - 1);
	QuickSort(a, key + 1, end);
}

"lomuto前后指针"版本

思路:

创建前后指针,从左往右找⽐基准值⼩的进⾏交换,使得⼩的都排在基准值的左边

先创建一个key值来保存left的下标,接着创建prev接收left,cur来接收left+1,执行逻辑就是cur先走,当a[cur]<a[key]的时候,prve++,然后a[cur] 和a[prve]交换后cur++,如果a[cur]>a[key]那就cur++,最后如果cur>right那就结束循环,最后a[key] 和a[prve]交换

代码实现:

cpp 复制代码
int _QuickSort(int* a, int left, int right)
{
	int key = left;
	int prev = left;
	int cur = left + 1;
    //当cur>right的时候说明,已经遍历完数组了
	while (cur <= right)
	{
		if (a[cur] <= a[key])
		{
			prev++;
            //当prve 和cur下标相等的时候就不交换
			if (prev != cur)
				Swap(&a[prev], &a[cur]);
		}
		//无论a[cur] <= a[key] 还是a[cur] > a[key],cur都要++
		cur++;
	}
    //最后将key指向的值和prve指向的值交换
	Swap(&a[key], &a[prev]);
	return prev;

}
void QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}
	int key = _QuickSort(a, begin, end);
	QuickSort(a, begin, key - 1);
	QuickSort(a, key + 1, end);
}

非递归版本的快速排序

其实现在的编译器对于递归的优化是非常好的,两者不会有太多区别,但是我们还要是要掌握非递归的写法

思路:

我们这里会用到栈的结构,刚开始先存入整个区间的左右边界值(先存右边界,再存左边界)在第一次划分好左右区间的时候,先存入右区间的右边界,在存入右区间的左边界,在存入左区间的右边界,在存入左区间的左边界,每次入栈都要判断左右边界是否越界或者已经相等

栈的特点:后入先出

这里我将我这边用到的关于栈的函数放在下面

cpp 复制代码
//list.h
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<string.h>
#include<stdbool.h>

typedef int LTDataType;
typedef struct ListNode {
	LTDataType* a;//接受数组的首元素地址
	int top;//栈顶
	int size;//当前栈的存储数
	int capacity;//最大存储量

}LN;

//初始化
void LNInit(LN* p);
//扩容
void LNresize(LN* p);
//压栈
void LNPush(LN* p, LTDataType x);
//出栈
LTDataType LNPop(LN* p);
//查看栈顶元素
LTDataType LNTop(LN* p);
//查看栈是否为空
//空 ture
bool LNEmpty(LN* p);
cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS 1

#include"list.h"


//初始化
void LNInit(LN* p)
{
	p->capacity = 4;
	LTDataType* tmp = (LTDataType*)malloc(sizeof(LTDataType) * 4);
	if (tmp == NULL)
	{
		perror("malloc fail");
		exit(-1);
	}
	
	p->a = tmp;
	p->size = 0;
	p->top = -1;

}

//扩容
void LNresize(LN* p)
{
	assert(p);

	if (p->capacity != p->size)
	{
		return;
	}
	LTDataType* tmp = (LTDataType*)realloc(p->a,sizeof(LTDataType) * p->capacity * 2);
	if (tmp == NULL)
	{
		perror("realloc fail");
		exit(-1);
	}
	p->a = tmp;
	p->capacity *= 2;
}

//压栈
void LNPush(LN* p, LTDataType x)
{
	assert(p);

	LNresize(p);
	p->a[++(p->top)] = x;
	p->size++;
}

//出栈(弹出栈顶的值并返回该值)
LTDataType LNPop(LN* p)
{
	assert(p);
	assert(!LNEmpty(p));

	LTDataType tmp = p->a[p->top];
	p->top--;
	p->size--;
	return tmp;
}

//查看栈顶元素
LTDataType LNTop(LN* p)
{
	assert(p);
	assert(!LNEmpty(p));
	return p->a[p->top];
}

//查看栈是否为空
//空 ture
bool LNEmpty(LN* p)
{
	assert(p);

	if (p->top == -1)
	{
		return true;
	}
	else
	{
		return false;
	}
}

然后我这里将三个版本的非递归版本的代码都放在下面:

hoare版本非递归
cpp 复制代码
void QuickSortNonR(int* a, int begin, int end)
{
    //建栈
	LN ln;
	LNInit(&ln);
    //存数组区间
	LNPush(&ln, end);
	LNPush(&ln, begin);
    //栈为空退出
	while (!LNEmpty(&ln))
	{
        //取出栈的区间
		int left = LNPop(&ln);
		int left1 = left;
		int right = LNPop(&ln);
		int right1 = right;
		int keyI = left1;
		while (left1 < right1)
		{
			
			//hoare版本
			while (left1 < right1 && a[right1] >= a[keyI])
			{
				right1--;
			}
			while (left1 < right1 && a[left1] <= a[keyI])
			{
				left1++;
			}
			Swap(&a[left1], &a[right1]);
	    }
		Swap(&a[keyI], &a[left1]);
		keyI = left1;
        //控制入栈的区间是有效的区间
		if (keyI + 1 < right)
		{
			LNPush(&ln, right);
			LNPush(&ln, keyI + 1);
		}
		if (keyI - 1 > left)
		{
			LNPush(&ln, keyI - 1);
			LNPush(&ln, left);
		}
	}
    //释放
	free(ln.a);
	ln.a = NULL;
}
"挖坑"版本
cpp 复制代码
void QuickSortNonR(int* a, int begin, int end)
{
	LN ln;
	LNInit(&ln);
	LNPush(&ln, end);
	LNPush(&ln, begin);
	while (!LNEmpty(&ln))
	{
		int left = LNPop(&ln);
		int left1 = left;
		int right = LNPop(&ln);
		int right1 = right;
		int key = a[left1];
		int keyI = left1;
		while (left1 < right1)
		{
			//挖坑
			while (left1 < right1 && a[right1] >= key)
			{
				right1--;
			}
			a[keyI] = a[right1];
			keyI = right1;
			while (left1 < right1 && a[left1] <= key)
			{
				left1++;
			}
			a[keyI] = a[left1];
			keyI = left1;

		}
		
		Swap(&key, &a[left1]);
		keyI = left1;

		if (keyI + 1 < right)
		{
			LNPush(&ln, right);
			LNPush(&ln, keyI + 1);
		}
		if (keyI - 1 > left)
		{
			LNPush(&ln, keyI - 1);
			LNPush(&ln, left);
		}
	}
	free(ln.a);
	ln.a = NULL;
}
"lomuto前后指针"版本
cpp 复制代码
void QuickSortNonR(int* a, int begin, int end)
{
	LN ln;
	LNInit(&ln);
	LNPush(&ln, end);
	LNPush(&ln, begin);
	while (!LNEmpty(&ln))
	{
		int left = LNPop(&ln);
		int right = LNPop(&ln);
		int keyI = left;
		int prve = left;
		int cur = left + 1;
		while (cur <= right)
		{
			//lomuto双指针版本
			if (a[cur] <= a[keyI])
			{
				prve++;
				Swap(&a[prve], &a[cur]);
			}
			cur++;

		}
		Swap(&a[prve], &a[keyI]);
		keyI = prve;

		if (keyI + 1 < right)
		{
			LNPush(&ln, right);
			LNPush(&ln, keyI + 1);
		}
		if (keyI - 1 > left)
		{
			LNPush(&ln, keyI - 1);
			LNPush(&ln, left);
		}
	}
	free(ln.a);
	ln.a = NULL;
}

这就是非递归版本的快速排序,其实我们只需要掌握一种快速排序就可以,建议大家在"挖坑"和"lomuto"这两个版本选择一个即可

三数取值

cpp 复制代码
//三数取值
int MedianofThree(int* a ,int left, int right)
{
	int mid = left + (right - left) / 2;
	if (a[left] > a[right])
	{
		Swap(&a[left], &a[right]);
	}
	if (a[mid] > a[right])
	{
		Swap(&a[mid], &a[right]);
	}
	if (a[left] > a[mid])
	{
		Swap(&a[mid], &a[left]);
	}
    return mid;

}
int _QuickSort(int* a, int left, int right)
{
    //直接将MedianofThree的返回值赋值给key
	int index = MedianofThree(a,left,right);
    Swap(&a[index],&a[left]);
	int prev = left;
	int cur = left + 1;
    int key = left;
    //当cur>right的时候说明,已经遍历完数组了
	while (cur <= right)
	{
		if (a[cur] <= a[key])
		{
			prev++;
            //当prve 和cur下标相等的时候就不交换
			if (prev != cur)
				Swap(&a[prev], &a[cur]);
		}
		//无论a[cur] <= a[key] 还是a[cur] > a[key],cur都要++
		cur++;
	}
    //最后将key指向的值和prve指向的值交换
	Swap(&a[key], &a[prev]);
	return prev;

}
void QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}
	int key = _QuickSort(a, begin, end);
	QuickSort(a, begin, key - 1);
	QuickSort(a, key + 1, end);
}

三数取值:解决基准值选择不当导致的最坏情况(如有序数组),通过选取区间左、中、右三个位置的中间值作为基准,让分区更均衡,将最坏时间复杂度从 O(n²) 优化为近似 O(nlogn);

例如:这个数据,不用三数取值,keyI最后会指向1这个元素,然后左区间就无效了,只有右区间,但是对于快速排序来说这种情况的效率非常的低

快速排序的局部区间优化

非递归版本:

cpp 复制代码
void QuickSortNonR(int* a, int begin, int end)
{
	LN ln;
	LNInit(&ln);
	LNPush(&ln, end);
	LNPush(&ln, begin);
	while (!LNEmpty(&ln))
	{
		int left = LNPop(&ln);
		int right = LNPop(&ln);
		int index = MedianofThree(a,left,right);
        Swap(&a[index],&a[left]);
        int keyI = left;
		int prve = left;
		int cur = left + 1;
		if (right - left > 10)
		{
			while (cur <= right)
			{
				//lomuto双指针版本
				if (a[cur] <= a[keyI])
				{
					prve++;
					Swap(&a[prve], &a[cur]);
				}
				cur++;

			}
			Swap(&a[prve], &a[keyI]);
			keyI = prve;

			if (keyI + 1 < right)
			{
				LNPush(&ln, right);
				LNPush(&ln, keyI + 1);
			}
			if (keyI - 1 > left)
			{
				LNPush(&ln, keyI - 1);
				LNPush(&ln, left);
			}
		}
		else
		{
            //可以直接调用插入函数,也可以将插入函数直接放在这个快速排序里
			//InsertionSort(a + left, right - left + 1);
            //left是区间的开始,所以数组的开始地址从+left开始,结束就是right
            //比较的次数就是元素个数减一
			for (int i = 0 + left; i < right; i++)
			{
				int index = i;
                int tmp = a[i + 1];
				while (index >= 0)
				{
					if (a[index] > tmp)
					{
						a[index + 1] = a[index];
						index--;
					}
					if (a[index] <= tmp)
					{
						break;
					}
					
				}
				a[index + 1] = tmp;
			}
		}
	}
	free(ln.a);
	ln.a = NULL;
}

递归版本的快速排序:

cpp 复制代码
int _QuickSort(int* a, int left, int right)
{
	int index = MedianofThree(a,left,right);
    Swap(&a[index],&a[left]);
    int key = left;
	int prev = left;
	int cur = left + 1;
	while (cur <= right)
	{
		if (a[cur] <= a[key])
		{
			prev++;
			if (a[prev] != a[cur])
				Swap(&a[prev], &a[cur]);
		}
		
		cur++;
	}
	Swap(&a[key], &a[prev]);
	return prev;

}
void QuickSort(int* a, int begin, int end)
{
	if (begin >= end)
	{
		return;
	}
    //如果区间元素个数小于十个就用插入排序
	if (end - begin > 10)
	{
		int key = _QuickSort(a, begin, end);
		QuickSort(a, begin, key - 1);
		QuickSort(a, key + 1, end);
	}
	else
	{
		InsertionSort(a + begin, end - begin + 1);
	}
	
}

总结

核心逻辑回顾:各算法的"解题思路"

每种排序算法的设计初衷,都是为了在特定场景下平衡"时间复杂度"与"空间复杂度",它们的核心逻辑各有侧重,却又相互关联:

1. 快速排序:分治思想的极致应用

快速排序的核心是「分区」------通过选择一个基准值,将数组划分为"小于等于基准"和"大于基准"两部分,再递归处理子区间。为了优化它的性能和稳定性,我们补充了两个关键技巧:

  • 三数取中优化:解决基准值选择不当导致的最坏情况(如有序数组),通过选取区间左、中、右三个位置的中间值作为基准,让分区更均衡,将最坏时间复杂度从 O(n²) 优化为近似 O(nlogn);

  • 非递归实现:用手动栈模拟递归调用栈,存储待处理区间的边界(左、右索引),避免递归深度过大导致的栈溢出问题,同时保留快排的核心分区逻辑,空间复杂度仍可维持在 O(logn)(最优)。

此外,我们还补充了"小区间切换插入排序"的优化思路:利用插入排序对小数据量(如≤10个元素)的常数时间优势,替代快排的底层递归,减少递归调用开销,进一步提升实际运行效率。

2. 希尔排序:插入排序的"进阶版"

希尔排序的本质是「分组插入排序」------通过设定逐渐缩小的"增量",将数组划分为多个子序列,对每个子序列执行插入排序;随着增量减小,子序列长度逐渐增大,最终增量为1时,整个数组已基本有序,只需一次常规插入排序即可完成。这种"先宏观有序,再微观调整"的思路,彻底解决了插入排序在大数据量无序数组中"元素移动次数过多"的痛点。

3. 堆排序:贪心思想与完全二叉树的结合

堆排序的核心是「堆结构」------利用完全二叉树的特性,构建大根堆(或小根堆),每次取出堆顶元素(最大值或最小值),再调整堆结构,重复此过程直到数组有序。它的关键是"堆的构建"和"堆的调整"两个操作,本质是通过贪心策略,每次选当前区间的极值元素,逐步完成排序。

性能对比

性能是排序算法的核心评价指标,以下是五种算法的关键性能参数对比(n 为数组长度):

排序算法 平均时间复杂度 最坏时间复杂度 最好时间复杂度 空间复杂度 稳定性
递归快排(未优化) O(nlogn) O(n²) O(nlogn) O(logn)(递归栈) 不稳定
快排(三数取中+非递归) O(nlogn) O(nlogn)(近似) O(nlogn) O(logn)(手动栈) 不稳定
希尔排序 O(n^1.3)(取决于增量) O(n²) O(n) O(1) 不稳定
堆排序 O(nlogn) O(nlogn) O(nlogn) O(1) 不稳定

注:稳定性指"相同值的元素在排序后相对位置不变"。以上五种算法均为不稳定排序,若需稳定排序,可选择归并排序、冒泡排序等(但时间/空间复杂度之间会互偿)。

10000000个数据下,希尔排序、堆排序、无优化的快速排序递归版本(hoare版本、"挖坑"版本、"lomuto双指针"版本)

10000000个数据下,希尔排序、堆排序、优化的快速排序递归版本(hoare版本、"挖坑"版本、"lomuto双指针"版本)

10000000个数据下,希尔排序、堆排序、无优化的快速排序 非递归版本(hoare版本、"挖坑"版本、"lomuto双指针"版本)

10000000个数据下,希尔排序、堆排序、优化的快速排序 非递归版本(hoare版本、"挖坑"版本、"lomuto双指针"版本)

10000000个有序数据下,希尔排序、堆排序、优化的快速排序 非递归版本(hoare版本、"挖坑"版本、"lomuto双指针"版本)

快速排序的三个版本中挖坑版本是性能比较优秀的,因为他交换的次数最少,但是在优化版本下,三个版本的差异是不太大的

希尔排序排序次有序数据性能是非常不错的

适用场景:不同需求下的"最优选择"

没有绝对"最好"的排序算法,只有最适合场景的算法。结合前面的性能分析,各算法的适用场景可总结为:

1. 快速排序:大数据量的首选(工程实践核心)

无论是递归版还是非递归版,快排的平均时间复杂度都是 O(nlogn),且常数时间开销小(实际运行速度快),适合处理大规模数据(如10万、100万级别的数组)。尤其是经过三数取中和小区间优化后,快排的稳定性和效率进一步提升,是C++ STL中 sort 函数的核心实现之一。

❌ 不适用场景:极度有序的数组(未优化版本)、对稳定性有要求的场景。

2. 希尔排序:中等数据量的"高效轻量选择"

希尔排序的空间复杂度为 O(1)(原地排序),且对中等规模数据(如1万~10万级别)的排序效率优于直接插入排序,适合内存受限、数据量不大的场景(如嵌入式设备)。它的缺点是增量选择没有统一标准(常用的有希尔增量、Hibbard增量等),不同增量对性能影响较大。

3. 堆排序:需要"随时获取极值"的场景

堆排序的时间复杂度稳定为 O(nlogn),且是原地排序,适合对排序稳定性要求不高、但需要保证最坏情况下性能的场景。此外,堆结构本身还可用于"优先队列"(如TopK问题、任务调度),这是快排和希尔排序不具备的额外优势。

❌ 不适用场景:对排序速度要求极高的大规模数据(常数时间开销略大于快排)。

学习要点:

学习排序算法,核心是理解其设计思想,而非死记硬背代码。结合这五种算法,有几个关键要点需要重点掌握:

  1. 分治思想:快排是分治思想的典型应用------将大问题(排序整个数组)拆解为小问题(排序子区间),解决小问题后合并结果。这种思路还可迁移到归并排序、二分查找等算法中;

  2. 递归转非递归:快排的非递归实现告诉我们,任何递归算法都可以通过"手动栈"模拟递归调用栈来实现非递归版本,核心是抓住"递归的状态是什么"(这里是待处理区间的边界);

  3. 算法优化的思路:从快排的三数取中、小区间优化,到希尔排序对插入排序的改进,都体现了"针对算法痛点优化"的思路------先找到算法的性能瓶颈(如快排的基准选择、插入排序的元素移动),再针对性设计解决方案;

  4. 时间复杂度的本质:O(nlogn) 算法(快排、堆排序)之所以优于 O(n²) 算法(插入排序、冒泡排序),核心是"每次操作能减少问题规模的比例"(如快排每次将区间缩小一半,堆排序每次将问题规模缩小为 logn)。

总结:构建自己的"排序算法工具箱"

通过这一系列的学习,我们可以发现:排序算法的设计的核心是"平衡"------平衡时间与空间、平衡平均性能与最坏性能、平衡实现复杂度与运行效率。

在实际开发中,我们无需重复造轮子,但需要清楚每种算法的优劣,根据场景选择合适的工具:

  • 大规模数据排序:优先选优化后的快速排序;

  • 内存受限、中等数据量:选希尔排序;

  • 需要随时获取极值、要求稳定性能:选堆排序;

  • 小数据量、接近有序:选插入排序(或快排的小区间优化)。

相关推荐
睡醒了叭2 小时前
图像分割-传统算法-聚类算法
opencv·算法·计算机视觉·聚类
这周也會开心2 小时前
Java面试题2-集合+数据结构
java·开发语言·数据结构
子枫秋月2 小时前
模拟C++string实现
数据结构·c++·算法
~央千澈~2 小时前
人工智能AI算法推荐之番茄算法推荐证实其算法推荐规则技术解析·卓伊凡
人工智能·算法·机器学习
羚羊角uou2 小时前
【数据结构】常见排序
数据结构·算法·排序算法
ha_lydms2 小时前
2、Spark 函数_a/b/c
大数据·c语言·hive·spark·时序数据库·dataworks·数据开发
无限进步_2 小时前
C++ STL容器适配器深度解析:stack、queue与priority_queue
开发语言·c++·ide·windows·算法·github·visual studio
byzh_rc2 小时前
[算法设计与分析-从入门到入土] 分治法
算法
拉拉拉拉拉拉拉马2 小时前
感知机(Perceptron)算法详解
人工智能·python·深度学习·算法·机器学习