数据结构中的排序秘籍:从基础到进阶的全面解析

引言

在数据结构广阔的领域里,排序是一项基础且核心的操作,它就像是数据世界的 "整理大师",能将杂乱无章的数据变得井然有序 。在日常生活里,排序的概念也随处可见。比如图书馆中,书籍会按照类别、作者或者出版时间等规则排列,方便读者快速找到所需书籍;电商平台上,商品会依据销量、价格或者用户评价进行排序,帮助消费者更高效地筛选商品。​

而在实际应用场景中,排序更是发挥着关键作用。在数据库系统中,对数据进行排序可以加快查询速度,提高数据检索的效率;在搜索引擎中,通过对搜索结果进行排序,能够将最相关、最有价值的信息呈现给用户。可以说,排序是数据处理和分析的基石,它为后续更复杂的数据操作和应用奠定了坚实的基础。接下来,就让我们深入探索排序的世界,领略各种排序算法的魅力与奥秘。

一. 排序的基本概念

1.1 排序的定义

排序,就是将一组杂乱无章的记录序列,按照特定的规则调整为有序序列的操作。这里的 "规则",通常是依据记录中的某个关键字(或字段)的大小关系来确定

1.2 稳定性的概念

在排序的世界里,稳定性是一个重要的概念。简单来讲,稳定排序就是在排序过程中,能够保证那些具有相同关键字的记录,它们的相对次序在排序前后保持不变。例如,有一个序列[2, 3a, 3b, 4, 5],其中3a和3b关键字相同,在经过稳定排序后,这个序列可能变为[2, 3a, 3b, 4, 5],3a仍然在3b之前,这就是稳定排序 。而不稳定排序则可能会改变相同关键字记录的相对次序,同样对于上述序列,经过不稳定排序后,可能得到[2, 3b, 3a, 4, 5],3a和3b的相对位置发生了变化。

为了更直观地理解,以后续将要讲解的冒泡排序和快速排序为例。冒泡排序是稳定排序算法,它在比较相邻元素时,如果两个元素相等,就不会交换它们的位置,所以能保证相同元素的相对顺序不变。而快速排序是不稳定排序算法,在其划分过程中,可能会把相同关键字的元素分在不同的子序列,从而导致它们的相对顺序在排序后发生改变。

1.3 内部排序与外部排序

根据排序过程中数据存储的位置,排序可分为内部排序外部排序 。内部排序是指在排序期间,所有参与排序的数据元素都全部存放在内存中,排序操作直接在内存中完成,无需借助外部存储设备 。像我们后续即将深入探讨的冒泡排序、插入排序、快速排序等,都属于内部排序。​

与之相对的是外部排序,当数据量极其庞大,无法一次性全部装入内存时,就需要借助外部存储设备(如硬盘)来辅助完成排序操作。在排序过程中,数据会不断地在内存和外存之间进行移动。典型的外部排序算法如多路归并排序,它通过多次归并操作,逐步将大量数据排序 。在实际应用中,当处理海量数据时,就需要考虑使用外部排序算法来应对内存不足的问题。而本文,我们主要聚焦于内部排序算法的学习与探讨。

1.4 常见的排序算法

二. 排序算法的实现

2.1 插入排序

基本思想:

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

实际中我们在玩扑克牌是就用到了插入排序的思想

2.1.1 直接插入排序

1. 原理

当插入第 i (i>=1) 个元素时,前面的 array[0],array[1],...,array[i-1] 已经排好序,此时用array[i] 的排序码与 array[i-1],array[i-2],... 的排序码顺序进行比较,找到插入位置 即将 array[i] 插入,原来位置上的元素顺序后移。

简单来说,插入排序就像是玩扑克牌时整理手中牌的过程 。它将数组分为已排序和未排序两部分初始时已排序部分只有一个元素(即数组的第一个元素)然后从第二个元素开始,将未排序部分的元素依次插入到已排序部分的合适位置 ,就像将新拿到的扑克牌插入到手中已整理好的牌的合适位置一样 。具体来说,对于当前要插入的元素,从已排序部分的末尾开始向前比较,如果已排序部分的元素大于要插入的元素,就将该元素向后移动一位,为要插入的元素腾出位置,直到找到合适的位置插入。

2. 代码实现
复制代码
//1)直接插入排序
void InsertSort(int* arr, int n)
{
	//i控制end是有序数列的最后一个
	for (int i = 0; i < n - 1; i++)
	{
		int end = i;
		int tmp = arr[end + 1];//把乱序序列的数据插入到有序序列
		//tmp和有序序列里面的数据一个一个比较
		while (end >= 0)
		{
			if (arr[end] > tmp)
			{
				arr[end + 1] = arr[end];
				end--;
			}
			else {
				break;
			}
		}
		arr[end + 1] = tmp;
	}
}
3. 复杂度分析

元素越接近有序,直接插入排序的时间效率越高

  1. 时间复杂度最好的情况是 当数组已经有序时,每一个元素只需要与已排序部分的最后一个元素比较一次,就可以确定其位置,不需要移动元素因为只需要遍历一次数组,比较​n−1 次;最坏的情况是 数组倒序,对于第i个元素,需要与已排序部分的i个元素进行比较和移动,总的比较和移动次数为1+2+3+⋯+(n−1)=n(n−1)/2,时间复杂度为O(n^2),平均情况下 ,插入排序的时间复杂度也是O(n^2)
  2. 空间复杂度 :插入排序是原地排序算法,只需要常数级别的额外空间,用于临时存储要插入的元素,所以空间复杂度为​O(1)
4. 稳定性分析

插入排序是稳定的排序算法 。在插入过程中,当遇到相等的元素时,新元素会插入到相等元素的后面,不会改变它们的相对顺序。

5. 适用场景

插入排序适用于部分有序的数据 。如果数组中大部分元素已经有序,那么插入排序的效率会相对较高,因为它只需要对少数无序元素进行插入操作 。同时,当数据量较小时,插入排序的简单实现和较低的时间复杂度开销也使其成为一个不错的选择 。比如在一些实时性要求较高但数据量不大的场景中,插入排序可以快速完成排序任务 。

2.1.2 希尔排序

1. 原理

希尔排序法又称缩小增量法 。希尔排序法的基本思想是:先选定一个整数(通常是gap =n/3+1),把待排序文件所有记录分成各组,所有的距离相等的记录分在同一组内,并对每一组内的记录进行排序,然后gap=gap/3+1得到下一个整数,再将数组分成各组,进行插入排序,当gap=1时,就相当于直接插入排序。它是在直接插入排序算法的基础上进行改进而来的,综合来说它的效率是要高于直接插入排序算法的。

希尔排序特性总结:

  1. 希尔排序是对直接插入排序的优化
  2. 当 gap > 1 时都是预排序,目的是让数组更接近有序。当 gap == 1 时,数组已接近有序,这样就会很快。这样整体而言,就会达到优化效果。
2. 代码实现
复制代码
//2)希尔排序
void ShellSort(int* arr, int n)
{
	int gap = n;
	while (gap > 1)//预排序
	{
		gap = gap / 3 + 1;
		//gap == 1 直接插入排序
		for (int i = 0; i < n - gap; i++)
		{
			int end = i;
			int tmp = arr[end + gap];
			while (end >= 0)
			{
				if (arr[end] > tmp)
				{
					arr[end + gap] = arr[end];
					end -= gap;
				}
				else {
					break;
				}
			}
			arr[end + gap] = tmp;
		}
	}
}
3. 复杂度分析
  1. **时间复杂度:**O(n^1.3)(希尔排序的时间复杂度分析比较复杂,这里不再分析)
  2. **空间复杂度:**O(1),属于原地排序算法
4. 稳定性分析

希尔排序是不稳定排序,因为相同的元素可能被分到不同的子序列中,导致相同元素的相对位置可能在排序中发生变化。

5. 适用场景
  • 适用于中等规模的数组排序(规模过大时不如快速排序、归并排序,规模过小时优势不明显)。
  • 数据局部有序性较好的数组效率更高。
  • 由于空间复杂度低(O (1)),适合对内存受限的场景。

2.2 选择排序

基本思想:

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

2.2.1 直接选择排序

1. 原理
  1. 在元素集合 array[i] -- array[n-1] 中选择关键码最大(小)的数据元素
  2. 若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换
  3. 在剩余的 array[i] -- array[n-2](array[i+1] -- array[n-1]) 集合中,重复上述步骤,直到集合剩余1个元素

简单来说,就是将数组分为已排序和未排序两部分。在每一轮中,从未排序的部分中选择最小(或最大)的元素,然后将其与未排序部分的第一个(最后一个)元素交换位置,这样每一轮都能确定一个元素的最终位置,直到整个数组都被排序。

2. 代码实现
复制代码
//直接选择排序
void SelectSort(int* arr, int n)
{
	int begin = 0;
	int end = n - 1;
	while (begin < end)
	{
		int mini = begin;
		int maxi = begin;
		for (int i = begin; i <= end; i++)
		{
			if (arr[i] < arr[mini])
			{
				mini = i;
			}
			if (arr[i] > arr[maxi])
			{
				maxi = i;
			}
		}
		Swap(&arr[mini], &arr[begin]);
		Swap(&arr[maxi], &arr[end]);
		//先交换最小值与begin,要考虑最大值在begin的情况
		//先交换最大值与end,要考虑最小值在end的情况
		if (maxi == begin)
		{
			maxi = mini;
		}
		begin++;
		end--;
	}
}
3. 复杂度分析

**时间复杂度:**O(n^2)

**空间复杂度:**O(1),属于原地排序算法

4. 稳定性分析

选择排序是不稳定的排序算法,比如,当找到的最大元素与 end 所在位置的元素相同是,交换后相同元素的相对位置就发生了变化。

5. 适用场景

由于选择排序的时间复杂度较高,不适用于大规模数据的高效排序 。但在一些对内存开销敏感,而对排序效率要求不高的场景下,它仍然可以发挥作用 。比如当内存资源非常有限,只能使用少量额外空间时,选择排序的原地排序特性使其成为一个可选方案;或者在一些简单的应用场景中,数据量较小且对排序时间没有严格要求时,也可以使用选择排序。

2.2.2 堆排序

1. 原理

堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。每次从堆顶取出最大(大顶堆)或最小(小顶堆)元素,将其与堆的最后一个元素交换位置,然后对除最后一个元素外的剩余元素重新调整堆,使其仍然满足堆的性质 。不断重复这个过程,直到堆中只剩下一个元素,此时数组就有序了。

2. 代码实现
复制代码
//2)堆排序(要用到向上调整算法或向下调整算法)
void HeapSort(int* arr, int n)
{
    //小堆:降序
    //大堆:升序

	//向下调整算法建堆(n)
2^h-1=n    h=log2(n+1)  所以时间复杂度为logn
	//越往上走,节点个数越多,调整次数越少
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
	    AdjustDown(arr, i, n);
	}

    //向上调整算法建堆(n*logn)
    //越往下走,节点个数越多,调整次数越多
    //for (int i = 1; i < n; i++)
    //{
	//    AdjustUp(arr, i);
    //}

	//n*logn
	int end = n - 1;
	while (end > 0)
	{
		Swap(&arr[0], &arr[end]);
        //交换后,必须使用向下调整算法保持堆的有序性
		AdjustDown(arr, 0, end);
		end--;
	}
}

//交换
void Swap(int* x, int* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}
//向上调整算法(log n)
void AdjustUp(int* arr, int child)
{
	int parent = (child - 1) / 2;
	while (child > 0)
	{
		if (arr[child] > arr[parent])
		{
			Swap(&arr[child], &arr[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else {
			break;
		}
	}
}
//向下调整算法(log n)
void AdjustDown(int* arr, int parent, int n)
{
	int child = parent * 2 + 1;
	while (child < n)
	{
		if (child + 1 < n && arr[child] < arr[child + 1])
		{
			child++;
		}
		if (arr[child] > arr[parent])
		{
			Swap(&arr[child], &arr[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else {
			break;
		}
	}
}
3. 复杂度分析

时间复杂度:

  • 向上调整算法:就是子节点(只有一个节点)向上调整的次数,节点向上调整了几层,while就循环了几次,最坏的情况是要调整到根节点,即为二叉树的高度(根节点高度为0),所以求时间复杂度就是求二叉树的高度,已知 n = 2^h-1,所以 h = log2(n+1),所以时间复杂度为O(logn)
  • 向下调整算法: O(logn)(推理方式同上)
  • 向上调整算法建堆:O(nlogn) 越往下走,节点个数越多,调整次数越多
  • 向下调整算法建堆:O(n) 越往下走,节点个数越多,调整次数越少

分析向上调整算法建堆和向下调整算法建堆的时间复杂度

  • 要计算时间复杂度即为计算节点调整的总次数。一层一层的计算,从上到下,每层的节点个数不断增加,当使用向上调整算法建堆时,节点个数不断增加,而且调整的次数也不断增加,简而言之,节点的个数越多,调整的次数越多;当使用向下调整算法建堆时,从上到下,节点的个数不断增加,而调整的次数不断减少,简而言之,节点个数越多,调整次数越少,所向下调整算法的时间复杂度更低(这里只是讲解思想,具体计算可以自行尝试)。综上,使用堆排序时,用向下调整算法建堆最好。

空间复杂度:O(1),属于原地排序算法

4. 稳定性分析

堆排序是不稳定排序

  • 原因:在调整堆的过程中,相同元素的相对位置可能被破坏。
  • 示例:数组 [2, 2, 1],构建大顶堆后为 [2, 2, 1],第一次交换堆顶(第一个 2)与末尾(1),得到 [1, 2, 2],破坏了原相对顺序。
5. 适用场景
  • 适用于大规模数据排序(时间复杂度稳定为 O (n log n),不受数据分布影响)。
  • 适合对空间复杂度要求严格的场景(原地排序,O (1) 空间)。
  • 常用于实现优先队列(如 Top K 问题:通过堆快速获取前 K 个最大 / 小元素)。

2.3 交换排序

基本思想:

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

2.3.1 冒泡排序

1. 原理

冒泡排序是一种简单直观的交换排序算法 。它如同水中的气泡,大的气泡会逐渐 "浮" 到水面,而在冒泡排序中,较大的元素会通过不断比较相邻元素并交换位置,逐步 "冒泡" 到数组的末尾

从数组的第一个元素开始,依次比较相邻的两个元素 ,如果前一个元素大于后一个元素(假设是升序排序),就交换它们的位置。这样,每一轮比较都会将当前未排序部分的最大元素 "冒泡" 到当前未排序部分的末尾。然后进行下一轮比较,此时由于上一轮已经将一个最大元素放到了末尾,所以下一轮比较的范围就可以减少一个元素 。不断重复这个过程,直到没有任何一对相邻元素需要交换,这就意味着数组已经完全有序。

2. 代码实现

复制代码
//1)冒泡排序
void BubbleSort(int* arr, int n)
{
	for (int i = 0; i < n - 1; i++)
	{
		int exchange = 0;
		for (int j = 0; j < n - 1 - i; j++)
		{
			if (arr[j] > arr[j + 1])
			{
				exchange = 1;
				Swap(&arr[j], &arr[j + 1]);
			}
		}
		if (exchange == 0)
		{
			break;
		}
	}
}
3. 复杂度分析

时间复杂度:最好的情况:O(n);最坏的情况:O(n^2);平均情况:O(n^2)

空间复杂度:冒泡排序是原地排序算法,它只需要常数级别的额外空间,用于临时存储交换的元素,所以空间复杂度为​O(1)

4. 稳定性分析

冒泡排序是稳定的排序算法 。这是因为在比较相邻元素时,如果两个元素相等,冒泡排序不会交换它们的位置,从而保证了相等元素的相对顺序在排序前后保持不变。

5. 适用场景

由于冒泡排序的时间复杂度较高,在大规模数据排序时效率较低,但它在一些特定场景下仍有应用价值 。比如当数据规模较小,此时​O(n^2)的时间复杂度影响不大,而且其实现简单,代码量少;或者当数据基本有序时,冒泡排序可以很快完成排序,因为只需要少量的比较和交换操作。

2.3.2 快速排序

1. 原理

快速排序是一种基于分治思想的排序算法,它的核心步骤如下:​

  • 选择基准元素:从待排序数组中选择一个元素作为基准(pivot) 。基准元素的选择有多种方法,常见的是选择数组的第一个元素。
  • 划分操作:找基准值(有多个方法),通过一趟排序将待排序数组分割成两个子序列,其中左子序列的所有元素都小于等于基准元素,右子序列的所有元素都大于等于基准元素 。
  • 递归排序:分别对基准元素左右两边的子数组进行递归快速排序,直到子数组的长度为 1 或 0,此时整个数组就有序了 。
2. 代码实现
复制代码
//快速排序
void QuickSort(int* arr, int left, int right)
{
	if (left >= right)
	{
		return;
	}
	//找基准值(有多个方法)
    //_QuickSort⽤于按照基准值将区间[left,right]中的元素进⾏划分 
	int keyi = _QuickSort(arr, left, right);
	//左序列[left,keyi-1]
	//右序列[keyi+1,right]
	QuickSort(arr, left, keyi - 1);
	QuickSort(arr, keyi + 1, right);
}

找基准值的函数方法:

1)hoare 版本

思路

  • 创建左右指针,确定基准值

  • 从右向左找比基准值大的数据,从左向右找比基准值小的数据,左右指针交换进入下次循环

    //hoare版本
    int _QuickSort(int* arr, int left, int right)
    {
    int keyi = left;
    left++;
    //left找比基准值大的,right找比基准值小的
    //虽然有两个while循环,但是一个从左往右,一个从右往左,只循环了一遍
    while (left <= right)
    //1)如果left==right时,相遇值要比基准值要大,right要继续--
    //2)如果left==right时,相遇值要比基准值要小,right不用动
    //这也是为什么left==right不跳出循环的原因
    {
    //right:从右往左找比基准值小的,小的往左放
    while (left <= right && arr[right] > arr[keyi]) //找到的其实是小于等于key的值
    //arr[right]和arr[keyi]不能是>=,必须是>
    //因为当数组值全都为一个相同的值时,加=,基准值每次都是数组下标为0的数,时间复杂度会变为O(n^2)
    {
    right--;
    }
    //left:从左往右找比基准值大的,大的往左放
    while (left <= right && arr[left] < arr[keyi]) //找到的其实是大于等于key的值
    {
    left++;
    }
    //left和right交换
    if (left <= right)
    //left和right相等时也要进入该循环,因为交换之后才会++、--避免死循环
    {
    //3)如果left==right时,相遇值等于基准值,left和right交换之后一定要++、--,否则会死循环
    Swap(&arr[left++], &arr[right--]);
    }
    }
    //right位置就是基准值的位置
    Swap(&arr[keyi], &arr[right]);
    return right;
    }

为什么跳出循环后right位置的值一定不大于key?

当 left > right 时,即 right 走到 left 的左侧,而 left 扫过的数据一定不大于key,因此 right 此时指向的数据一定不大于 key。
基准值的选取有什么要求吗?left 和 right 谁先走是否会影响结果?

基准值为 left 时,left 和 right 谁先走不影响

基准值为 right 时,right 要先走,并且 keyi 与 left 交换,最后return left

2)挖坑法

思路

  • 创建左右指针,选取第一个元素所在位置为坑位(基准值),并将它所在位置的值保存起来

  • 从右向左找比基准值小的数据,然后将该数据放到坑位中,该数据所在位置成为新的坑位

  • 从左向右找比基准值大的数据,然后将该数据放到坑位中,该数据所在位置成为新的坑位

  • 循环结束后再将第一个坑位的值(基准值)放到坑位中,并返回坑位下标

    //找基准值(挖坑法)
    int _QuickSort2(int* arr, int left, int right)
    {
    //不能先让left++,left要始终指向最左侧
    int hole = left;
    int key = arr[hole];
    while (left < right)
    {
    while (left < right && arr[right] > key)
    {
    right--;
    }
    arr[hole] = arr[right];
    hole = right;
    while (left < right && arr[left] < key)
    {
    left++;
    }
    arr[hole] = arr[left];
    hole = left;
    }
    arr[hole] = key;
    return hole;
    }

3)lomuto 双指针法

思路

  • 创建前后指针 prev 和 cur

  • cur从左往右找比基准值小的数据

  • cur指向的数据比基准值小,++pre,如果++prev后不等于 cur,cur和prev交换位置,如果++prev 后等于cur,prev 与 cur 不用交换位置,然后cur++接着遍历

  • cur指向的数据不比基准值小,cur++接着遍历

    //找基准值(lumoto双指针法)
    int _QuickSort3(int* arr, int left,int right)
    {
    //cur从左往右找比基准值小的数据
    //1)cur指向的数据比基准值小,pre++,cur和prev交换位置,cur++
    //2)cur指向的数据不比基准值小,cur++
    int prev = left;
    int cur = prev + 1;
    int keyi = left;
    while (cur <= right)
    {
    //cur谁和基准值比较
    if (arr[cur] < arr[keyi] && ++prev != cur)
    {
    Swap(&arr[cur], &arr[prev]);
    }
    ++cur;
    }
    Swap(&arr[keyi], &arr[prev]);
    return prev;
    }

3. 复杂度分析

1.时间复杂度:O(n*logn)

递归算法时间复杂度 == 单次递归时间复杂度 * 递归次数,递归次数就是二叉树的高度/深度,可以看成是一个满二叉树,n=2^h-1,所以 h=log2(n+1)。最坏的情况,如果基准值找的不好(每次基准值都在最左边,递归次数为n次)/数组有序,时间复杂度为O(n^2)。

2.空间复杂度:O(logn)

  • 基本空间消耗:快速排序本身只需要一个固定大小的临时变量用于交换元素,这部分空间消耗是 O (1) 的常数级。
  • 递归调用栈的空间 :快速排序是递归算法,其主要空间消耗来自递归调用时栈帧的开销。每次递归调用都会在栈上保存参数、返回地址等信息。最优情况 :当每次划分都能将数组均匀地分成两部分时,递归深度为 log₂n,此时空间复杂度为 O (log n)。最坏情况:当数组已经有序或接近有序时,每次划分只能将数组分成一个元素和其余元素两部分,递归深度达到 n,此时空间复杂度为 O (n)
4. 稳定性分析

快速排序是不稳定的排序算法 。在分区过程中,相同的元素可能会交换位置,使得相等元素的相对顺序可能发生改变。

5. 适用场景

快速排序适用于大数据量且对效率有较高要求的场景。在大多数情况下性能优越,所以在数据库索引构建、大规模数据处理等场景中被广泛应用 。例如,在数据库中对大量数据进行排序以加快查询速度时,快速排序可以高效地完成任务 。同时,因为它是原地排序,不需要大量额外空间,在内存资源有限的情况下也能发挥很好的作用 。

2.3.3 快速排序(非递归版本)------借助数据结构栈

1. 原理

非递归快速排序与递归版本的核心逻辑一致,都基于 "分治" 思想:

  1. 选择基准:从当前数组中选择一个元素作为基准(pivot)。
  2. 分区操作:将数组划分为两部分,左半部分元素均小于等于基准,右半部分元素均大于基准。
  3. 栈存范围:将分区后的左右子数组范围(起始和结束索引)压入栈中。
  4. 循环处理:不断从栈中弹出子数组范围,重复 "选择基准 - 分区" 操作,直至栈为空(所有子数组均已排序)。
2. 代码实现
复制代码
//快速排序(非递归版本-----栈)
void QuickSortNonR(int* arr, int left, int right)
{
	ST st;
	STInit(&st);
	STPush(&st, right);
	STPush(&st, left);
	while (!STEmpty(&st))
	{
		//取栈顶两次
		int begin = STTop(&st);
		STPop(&st);
		int end = STTop(&st);
		STPop(&st);
		//[begin,end]-----找基准值
		int keyi = begin;
		int prev = begin, cur = begin + 1;
		while (cur <= end)
		{
			if (arr[cur] < arr[keyi] && ++prev != cur)
			{
				Swap(&arr[prev], &arr[cur]);
			}
			++cur;
		}
		Swap(&arr[prev], &arr[keyi]);
		keyi = prev;
		//begin keyi end
		//左序列[begin,keyi-1]
		//右序列[keyi+1,end]
		if (keyi + 1 < end)
		{
			STPush(&st, end);
			STPush(&st, keyi + 1);
		}
		if (begin < keyi - 1)
		{
			STPush(&st, keyi - 1);
			STPush(&st, begin);
		}
	}
	STDestory(&st);
}
3. 复杂度分析

时间复杂度:平均情况O(n log n),最坏情况(数组有序且选最左为基准)O(n²)(可通过随机选择基准优化至期望O(n log n))。

空间复杂度:栈的空间开销最坏O(n)(如有序数组),平均O(log n)

4. 稳定性分析

因交换操作可能改变相同元素的相对顺序,属于不稳定排序

5. 适用场景

适合处理大规模数据,尤其当递归深度受限(如编程语言递归栈较小时)。非递归快速排序通过栈模拟递归过程,保留了快速排序的高效性,同时避免了递归可能带来的问题。核心是用栈存储子数组范围,通过循环分区实现排序,是实际开发中处理大数据排序的常用方案。

2.4 归并排序

1. 原理

归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法 (Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。归并排序核心步骤:

2. 代码实现
复制代码
void _MergeSort(int* arr, int left, int right, int* tmp)
{
	if (left >= right)
	{
		return;
	}
	int mid = (left + right) / 2;
	//分解
	_MergeSort(arr, left, mid, tmp);
	_MergeSort(arr, mid + 1, right, tmp);
	//合并
	int begin1 = left, begin2 = mid + 1;
	int end1 = mid, end2 = right;
	int index = begin1;
	while (begin1 <= end1 && begin2 <= end2)
	{
		if (arr[begin1] < arr[begin2])
		{
			tmp[index++] = arr[begin1++];
		}
		if (arr[begin2] < arr[begin1])
		{
			tmp[index++] = arr[begin2++];
		}
	}
	while (begin1 <= end1)
	{
		tmp[index++] = arr[begin1++];
	}
	while (begin2 <= end2)
	{
		tmp[index++] = arr[begin2++];
	}
	//tmp中的数据放到arr数组中
	for (int i = left; i <= right; i++)
	{
		arr[i] = tmp[i];
	}
}

//归并排序
void MergeSort(int* arr, int n)
{
	int* tmp = (int*)malloc(sizeof(int) * n);
	//[0,n-1]
	_MergeSort(arr, 0, n - 1, tmp);
	free(tmp);
}
3. 复杂度分析

**时间复杂度:**O(nlogn)

无论原始数组是否有序,拆分阶段需O(log n)次递归,合并阶段每次需O(n)时间,总时间复杂度为 O(n log n)(稳定)。

空间复杂度: 需要一个与原数组长度相同的临时数组,空间复杂度为 O(n)(非原地排序)。

4. 稳定性分析

合并时若两元素相等,优先取左数组元素,因此是 稳定排序。

5. 适用场景

适合处理大量数据(如百万级元素),但因需额外空间,不适合内存受限的场景。

2.5 计数排序------非比较排序

1. 原理

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

  • 确定范围:找出待排序数组中的最大值和最小值,确定计数范围(即需要统计的数值区间)。
  • 统计次数:创建一个计数数组,记录每个元素在原始数组中出现的次数。
  • 计算位置:将计数数组转换为 "前缀和数组",即每个位置的值表示该元素及之前所有元素的总出现次数,以此确定每个元素在结果数组中的最终位置。
  • 构建结果:根据前缀和数组,将原始元素放入结果数组的对应位置,完成排序。
2. 代码实现
复制代码
//非比较排序------计数排序
void CountSort(int* arr, int n)
{
	//找最大值和最小值
	int max = arr[0];
	int min = arr[0];
	for (int i = 1; i < n; i++)
	{
		if (arr[i] < min)
		{
			min = arr[i];
		}
		if (arr[i] > max)
		{
			max = arr[i];
		}
	}
	//count数组大小
	int range = max - min + 1;
	int* count = (int*)malloc(sizeof(int) * (range));
	if (count == NULL)
	{
		perror("mallox fail!");
		exit(1);
	}
	memset(count, 0, sizeof(int) * (range));
	//
	for (int i = 0; i < n; i++)
	{
		count[arr[i] - min]++;
	}
	int index = 0;
	for (int i = 0; i < range; i++)
	{
		while (count[i]--)
		{
			arr[index++] = min + i;
		}
	}
}
3. 复杂度分析

**时间复杂度:**O(n)

确定范围、统计次数、计算前缀和、构建结果均为O(n + k)n为元素个数,k为值范围max-min+1)。当k较小时(如k≈n),时间复杂度接近O(n),效率远高于比较型排序(如快排O(n log n))。

空间复杂度: 需要计数数组和结果数组,空间复杂度为O(n + k)

4. 稳定性分析

从原始数组末尾向前遍历并放置元素,相同元素的相对顺序不变,因此是稳定排序

5. 适用场景
  • 元素为整数(或可映射为整数的离散值);
  • 值范围k较小(如k <= 10^6),否则空间开销过大;
  • 适合批量处理重复值较多的数据(如统计排序成绩、用户年龄等)。

三. 排序算法复杂度及稳定性比较分析

结语

排序算法作为数据结构领域的基石,在计算机科学的各个角落都有着举足轻重的地位。从简单直观的冒泡排序、选择排序和插入排序,到基于分治思想的快速排序、归并排序,再到利用数据特性的堆排序以及非比较类的计数排序、桶排序和基数排序,每一种算法都有其独特的原理、特性和适用场景 。​

在实际应用中,我们需要根据数据的规模、特点、稳定性要求以及空间复杂度等多方面因素,综合权衡选择最合适的排序算法 。对于初学者来说,深入理解每种排序算法的原理和实现,通过大量的代码实践来掌握它们,是提升编程能力和算法思维的重要途径 。希望大家在后续的学习和工作中,能够灵活运用排序算法,解决实际问题,在数据的海洋中畅游 。

如有不足或改进之处,欢迎大家在评论区积极讨论,后续我也会持续更新数据结构相关的知识。文章制作不易,如果文章对你有帮助,就点赞收藏关注支持一下作者吧,让我们一起努力,共同进步!

相关推荐
纪元A梦2 小时前
贪心算法应用:推荐冷启动问题详解
算法·贪心算法
听风说雨的人儿2 小时前
腾讯面试题之编辑距离
算法
Lululaurel3 小时前
机器学习系统框架:核心分类、算法与应用全景解析
人工智能·算法·机器学习·ai·分类
愚润求学3 小时前
【贪心算法】day8
c++·算法·leetcode·贪心算法
递归尽头是星辰3 小时前
双指针与滑动窗口算法精讲:从原理到高频面试题实战
算法·双指针·滑动窗口·子串/子数组问题
夜猫逐梦3 小时前
【Lua】Windows 下编写 C 扩展模块:VS 编译与 Lua 调用全流程
c语言·windows·lua
_OP_CHEN4 小时前
数据结构(C语言篇):(十三)堆的应用
c语言·数据结构·二叉树·学习笔记·堆排序··top-k问题
听情歌落俗4 小时前
MATLAB3-1变量-台大郭彦甫
开发语言·笔记·算法·matlab·矩阵
量子炒饭大师4 小时前
收集飞花令碎片——C语言关键字typedef
c语言·c++·算法