目录
[1.1 什么是排序](#1.1 什么是排序)
[1.2 排序的稳定性](#1.2 排序的稳定性)
[1.3 排序算法的分类](#1.3 排序算法的分类)
[1.4 算法复杂度总结表](#1.4 算法复杂度总结表)
[2.1 直接插入排序](#2.1 直接插入排序)
[2.1.1 概念](#2.1.1 概念)
[2.1.2 代码实现](#2.1.2 代码实现)
[2.1.3 总结](#2.1.3 总结)
[2.2 希尔排序](#2.2 希尔排序)
[2.2.1 概念](#2.2.1 概念)
[2.2.2 代码实现](#2.2.2 代码实现)
[2.2.3 gap 序列的选择](#2.2.3 gap 序列的选择)
[2.2.4 总结](#2.2.4 总结)
[3.1 直接选择排序](#3.1 直接选择排序)
[3.1.1 概念](#3.1.1 概念)
[3.1.2 代码实现](#3.1.2 代码实现)
[3.1.3 总结](#3.1.3 总结)
[3.2 堆排序](#3.2 堆排序)
[3.2.1 概念](#3.2.1 概念)
[3.2.2 代码实现](#3.2.2 代码实现)
[3.2.3 总结](#3.2.3 总结)
[4.1 冒泡排序](#4.1 冒泡排序)
[4.1.1 概念](#4.1.1 概念)
[4.1.2 代码实现](#4.1.2 代码实现)
[4.1.3 特性总结](#4.1.3 特性总结)
[4.2 快速排序](#4.2 快速排序)
[4.2.1 概念](#4.2.1 概念)
[4.2.2 三数取中法](#4.2.2 三数取中法)
[4.2.2.1 为什么需要三数取中?](#4.2.2.1 为什么需要三数取中?)
[4.2.2.2 三数取中法的原理](#4.2.2.2 三数取中法的原理)
[4.2.2.3 三数取中的效果](#4.2.2.3 三数取中的效果)
[4.2.2.4 代码实现](#4.2.2.4 代码实现)
[4.2.3 Hoare版本](#4.2.3 Hoare版本)
[4.2.3.1 代码实现](#4.2.3.1 代码实现)
[4.2.4 挖坑法](#4.2.4 挖坑法)
[4.2.4.1 代码实现](#4.2.4.1 代码实现)
[4.2.5 前后指针法](#4.2.5 前后指针法)
[4.2.5.1 代码实现](#4.2.5.1 代码实现)
[4.2.5 快速排序非递归实现](#4.2.5 快速排序非递归实现)
[4.2.5.1 代码实现:](#4.2.5.1 代码实现:)
[5.1 概念](#5.1 概念)
[5.2 归并排序递归实现](#5.2 归并排序递归实现)
[5.2.1 代码实现](#5.2.1 代码实现)
[5.3 归并排序非递归实现](#5.3 归并排序非递归实现)
[5.3.1 为什么需要非递归?](#5.3.1 为什么需要非递归?)
[5.3.2 核心思路](#5.3.2 核心思路)
[5.3.3 代码实现](#5.3.3 代码实现)
[6.1 什么是非比较排序](#6.1 什么是非比较排序)
[6.2 计数排序的概念](#6.2 计数排序的概念)
[6.3 代码实现](#6.3 代码实现)
[6.4 计数排序的优缺点](#6.4 计数排序的优缺点)
[8.1 测试代码](#8.1 测试代码)
[8.2 运行结果截图](#8.2 运行结果截图)
(本文所用语言均为C语言)
一、排序算法概述
1.1 什么是排序
**排序(Sorting)**是指将一组数据按照某种特定的顺序(如升序或降序)重新排列的过程。排序是计算机科学中最基础、最常用的算法之一,几乎所有的数据处理系统都离不开排序操作。
例如 :将数组 [5, 2, 8, 1, 9] 按升序排列为 [1, 2, 5, 8, 9]。
1.2 排序的稳定性
稳定性 是指排序算法在排序过程中,相同元素之间的相对顺序是否保持不变。
稳定排序 :相等元素的相对位置在排序前后不变。
例如:
[(2,a), (1,b), (2,c)]按数值排序后为[(1,b), (2,a), (2,c)],两个2的相对顺序不变。不稳定排序 :相等元素的相对位置可能发生改变。
例如:
[(2,a), (1,b), (2,c)]排序后可能变为[(1,b), (2,c), (2,a)]。
常见排序稳定性:
| 排序算法 | 稳定性 |
|---|---|
| 插入排序 | 稳定 |
| 希尔排序 | 不稳定 |
| 选择排序 | 不稳定 |
| 堆排序 | 不稳定 |
| 冒泡排序 | 稳定 |
| 快速排序 | 不稳定 |
| 归并排序 | 稳定 |
| 计数排序 | 稳定 |
1.3 排序算法的分类
排序算法大致可分为两类:
-
比较排序:通过比较元素之间的大小关系来确定顺序,如插入排序、选择排序、交换排序、归并排序等。时间复杂度下界为 O(n*log n)。
-
非比较排序:不通过比较元素大小,而是利用元素本身的特性(如取值范围)来排序,如计数排序。时间复杂度可达到 O(n)。
1.4 算法复杂度总结表
| 排序算法 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|---|
| 直接插入排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
| 希尔排序 | O(n) | O(n^1.3) | O(n²) | O(1) | 不稳定 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 |
| 冒泡排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
| 快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
| 计数排序 | O(n+range) | O(n+range) | O(n+range) | O(range) | 稳定 |
二、插入排序
2.1 直接插入排序
2.1.1 概念
将数组分为已排序区间 和未排序区间。初始时,已排序区间只有一个元素(数组的第一个元素)。每次从未排序区间取出一个元素,与已排序区间的元素从后往前比较,找到合适的位置插入,直到所有元素都插入到已排序区间。
生活类比:整理扑克牌时,每次摸到一张新牌,将其插入到手中已排好序的牌中合适的位置。
就像这样:

2.1.2 代码实现
cpp
void InsertSort(int* a, int n)
{
for (int i = 1; i < n; i++) // 从第二个元素开始遍历
{
int end = i - 1; // end 指向当前已排序区间的最后一个元素
int tmp = a[i]; // tmp 保存当前待插入的元素值
// 从后往前遍历已排序区间,寻找 tmp 的正确插入位置
while (end >= 0)
{
if (tmp < a[end]) // 如果 tmp 比当前元素小
{
a[end + 1] = a[end]; // 将当前元素向后移动一位
--end; // 继续向前比较
}
else
{
break; // 找到合适位置,跳出循环
}
}
a[end + 1] = tmp; // 将 tmp 插入到正确位置
}
}
2.1.3 总结
| 特性 | 说明 |
|---|---|
| 最好时间复杂度 | O(n)(数组已有序,只需遍历一次) |
| 最坏时间复杂度 | O(n²)(数组逆序) |
| 平均时间复杂度 | O(n²) |
| 空间复杂度 | O(1)(原地排序,不需要额外空间) |
| 稳定性 | 稳定(相等元素不交换,相对顺序不变) |
| 适用场景 | 数据量较小(n < 1000),或数组接近有序时效率最高 |
2.2 希尔排序
2.2.1 概念
希尔排序是直接插入排序的一种改进版本,也称为缩小增量排序。
核心思想是:先将整个数组按**一定间隔(gap)**分成若干子序列,分别对每个子序列进行插入排序;然后逐步缩小间隔,重复上述操作;当间隔缩小到 1 时,对全体元素进行一次插入排序。
为什么希尔排序更快?
- 插入排序在数组接近有序时效率很高(接近 O(n))。
- 希尔排序通过预排序,让数组逐渐变得接近有序。
- 较大的 gap 可以让元素快速移动到接近其最终位置,减少了后续插入排序的移动次数。
生活类比:整理书籍时,先按大类分(如文学、科学、历史),再在每个大类中按小类整理,最后整体微调。
就像这样:

2.2.2 代码实现
cpp
void ShellSort(int* a, int n)
int gap = n; // gap 表示间隔,初始为数组长度
while (gap > 1) // 当 gap > 1 时持续进行预排序
{
gap = gap / 3 + 1; // 动态调整 gap 值(+1 是保证最后一次 gap = 1)
// 对每个子序列进行插入排序
// i 从 0 到 n-gap-1,交替处理不同的子序列
for (int i = 0; i < n - gap; i++)
{
int end = i; // end 指向当前子序列的已排序区间的最后一个元素
int tmp = a[i + gap]; // tmp 是当前待插入的元素
// 在当前子序列中从后往前比较
while (end >= 0)
{
if (a[end] > tmp) // 如果前一个元素比 tmp 大
{
a[end + gap] = a[end]; // 将前一个元素向后移动 gap 步
end -= gap; // 继续向前比较
}
else
{
break; // 找到合适位置,跳出循环
}
}
a[end + gap] = tmp; // 将 tmp 插入到正确位置
}
}
}
2.2.3 gap 序列的选择
希尔排序的性能与 gap 序列的选择密切相关:
gap = gap / 2:简单常用,但效率一般。
gap = gap / 3 + 1:性能更优。(记住这个最好就行了,不要过多研究)
最优 gap 序列:目前尚无数学上的最优解,Knuth、Hibbard 等学者提出过多种序列。
关键点 :必须保证 gap 最终等于 1,否则数组无法完全有序。
2.2.4 总结
| 特性 | 说明 |
|---|---|
| 最好时间复杂度 | O(n)(数组已有序) |
| 平均时间复杂度 | O(n^1.3)(介于 O(n) 和 O(n²) 之间) |
| 最坏时间复杂度 | O(n²)(某些特定 gap 序列下) |
| 空间复杂度 | O(1)(原地排序) |
| 稳定性 | 不稳定(分组排序时可能改变相同元素的相对顺序) |
| 适用场景 | 数据量较大时,比直接插入排序快得多,但仍不如快速排序 |
三、选择排序
3.1 直接选择排序
3.1.1 概念
将数组分为已排序区间 (左侧)和未排序区间 (右侧)。每次从未排序区间中找出最小值 ,将其与未排序区间的第一个元素交换,从而将已排序区间向右扩展一位。
生活类比:在操场排队时,每次从剩余未排好的同学中找出最矮的,让他站到队伍的最前面。
就像这样:

3.1.2 代码实现
Swap函数代码:
cpp
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
直接选择排序代码:
cpp
void SelectSort(int* a, int n)
{
int left = 0; // left 指向未排序区间的第一个元素
int right = n - 1; // right 指向未排序区间的最后一个元素
while (left < right) // 当区间内还有未排序元素时
{
int minPos = left; // 假设最小值在 left 位置
int maxPos = right; // 假设最大值在 right 位置
// 遍历未排序区间 [left, right],同时找出最小值和最大值
for (int i = left; i <= right; i++)
{
if (a[i] < a[minPos]) // 如果找到更小的值
{
minPos = i; // 更新最小值下标
}
if (a[i] > a[maxPos]) // 如果找到更大的值
{
maxPos = i; // 更新最大值下标
}
}
// 将最小值交换到未排序区间的开头(left 位置)
Swap(&a[minPos], &a[left]);
// 注意:如果最大值原本在 left 位置,但刚刚被换到了 minPos 位置
// 例如:a = [9, 1, 5],left=0,right=2,minPos=1,maxPos=0
// 交换 a[1] 和 a[0] 后,最大值 9 被移动到了下标 1
// 所以需要将 maxPos 更新为 minPos
if (left == maxPos)
{
maxPos = minPos;
}
// 将最大值交换到未排序区间的末尾(right 位置)
Swap(&a[maxPos], &a[right]);
//然后缩小范围,继续排序
left++;
right--;
}
}
3.1.3 总结
| 特性 | 说明 |
|---|---|
| 最好时间复杂度 | O(n²) |
| 最坏时间复杂度 | O(n²) |
| 平均时间复杂度 | O(n²) |
| 空间复杂度 | O(1)(原地排序,不需要额外空间) |
| 稳定性 | 不稳定(交换操作可能改变相同元素的相对顺序) |
| 适用场景 | 数据量很小(n < 100),或对稳定性无要求且不在意效率的场景 |
| 优点 | 实现简单,理解直观,不占用额外内存。 |
| 缺点 | 效率低,无论数据是否有序,都要进行完整的遍历比较。 |
3.2 堆排序
3.2.1 概念
堆排序是一种利用 堆数据结构的选择排序算法。它利用堆的特性(大堆堆顶为最大值,小堆堆顶为最小值)进行高效的选择操作。
核心步骤:
建堆:将待排序数组构建成一个大堆(升序)或小堆(降序)。
排序:每次将堆顶元素(最大值或最小值)与堆的最后一个元素交换,然后将剩余元素重新调整为堆,重复此操作直到所有元素有序。
为什么升序建大堆?
升序排序需要每次将当前最大值放到数组末尾。大堆的堆顶恰好是最大值,交换堆顶和末尾元素后,剩余部分可以快速通过向下调整恢复成大堆,从而高效地依次选出剩余最大值。如果建小堆,堆顶是最小值,只能依次选最小值放到数组开头,这会破坏数组后续元素的原有结构,增加复杂度和时间开销。
如果还是不懂,我的这篇博客里面有更详细的解释:数据结构--堆-CSDN博客
3.2.2 代码实现
cpp
//向下调整
//n 是元素个数,不是下标
void AdjustDown(int* a, int n, int parent)
{
//看不懂的话,可以去看我之前写得 堆 的博客
//这里不再做过多解释
int child = 2 * parent + 1;
while (child < n)
{
if (child + 1 < n && a[child] < a[child + 1])
{
child++;
}
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
void HeapSort(int* a, int n)
{
// 第一阶段:建大堆
// (n - 1 - 1) / 2 是最后一个非叶子节点的下标
// 从最后一个非叶子节点开始,从后往前依次向下调整
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i); // 向下调整,确保每个子树都是大堆
}
// 第二阶段:排序
int end = n - 1; // end 指向堆的最后一个有效元素
while (end > 0)
{
Swap(&a[0], &a[end]); // 将堆顶(最大值)与末尾元素交换
AdjustDown(a, end, 0); // 对堆顶向下调整,恢复大堆结构
end--; // 将已排好的最大值从堆中排除
}
}
3.2.3 总结
| 特性 | 说明 |
|---|---|
| 最好时间复杂度 | O(n log n) |
| 最坏时间复杂度 | O(n log n) |
| 平均时间复杂度 | O(n log n) |
| 空间复杂度 | O(1)(原地排序,不需要额外空间) |
| 稳定性 | 不稳定(交换操作可能改变相同元素的相对顺序) |
| 优点 | 效率高,不受数据初始顺序影响,适合大数据量排序 |
| 缺点 | 实现较复杂,不稳定,不适合对稳定性有要求的场景 |
四、交换排序
4.1 冒泡排序
4.1.1 概念
冒泡排序通过相邻元素的比较和交换,将较大的元素逐渐"冒泡"到数组的末尾。每轮遍历都会将当前未排序区间中的最大元素移动到正确位置。
生活类比:气泡在水中上升时,越大的气泡越容易浮到水面。
就像这样:

4.1.2 代码实现
cpp
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n; i++) // i 表示已排序的元素个数
{
int k = 0; // k 用于标记本轮是否发生交换
// 每轮比较相邻元素,将最大值冒泡到末尾
// n - i - 1:已排序的元素在末尾,不需要再比较
for (int j = 0; j < n - i - 1; j++)
{
if (a[j] > a[j + 1]) // 如果前一个比后一个大(逆序)
{
Swap(&a[j], &a[j + 1]); // 交换相邻元素
k = 1; // 标记本轮发生了交换
}
}
if (k == 0) // 如果本轮没有发生交换
break; // 说明数组已经有序,提前退出
}
}
4.1.3 特性总结
| 特性 | 说明 |
|---|---|
| 最好时间复杂度 | O(n)(数组已有序,只需遍历一次) |
| 最坏时间复杂度 | O(n²)(数组逆序) |
| 平均时间复杂度 | O(n²) |
| 空间复杂度 | O(1)(原地排序,不需要额外空间) |
| 稳定性 | 稳定(相邻元素交换,相等元素不交换,相对顺序不变) |
| 适用场景 | 数据量较小,或用于教学演示排序原理 |
4.2 快速排序
4.2.1 概念
快速排序是由 Hoare 提出的分治算法 ,也是目前应用最广泛的排序算法之一。
核心思想:
选基准值(key):从待排序区间中选取一个元素作为基准值。
分区(Partition):将小于基准值的元素放到其左侧,大于基准值的元素放到其右侧,基准值最终位于正确位置。
递归排序:对基准值左侧和右侧的子区间分别递归执行上述操作。
生活类比:整理书架时,先选一本书作为基准,将书名小于它的放在左边,大于它的放在右边,然后对左右两个子书架分别重复此操作。
再介绍快排之前,我先介绍一个方法,这个方法叫做:三数取中法
4.2.2 三数取中法
4.2.2.1 为什么需要三数取中?
快速排序的性能高度依赖于基准值(key)的选择。如果基准值选得合理,每次分区都能将数组均匀地分成左右两部分,递归深度为O(log n),时间复杂度为O(n log n)。
但如果基准值选得不合理 (比如每次都选到最小值或最大值),快速排序就会退化为冒泡排序,时间复杂度变为 O(n²)。
什么情况下会选到不合理的基准值?
数组已经有序 (升序或降序):如果固定选
left位置作为基准值,那么每次基准值都是当前区间的最小值(升序)或最大值(降序),导致分区极不平衡。数组接近有序:同样会出现类似问题。
4.2.2.2 三数取中法的原理
三数取中法 是从区间的 left**、** mid**(中间位置)、** right 三个位置中,选出值大小位于中间的元素作为基准值。
核心思想 :极大降低概率,避免 基准值选到极端值(最小值或最大值),从而保证基准值相对接近中位数,使分区更加平衡。
示例:
数组:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 下标:left=0(下标对应的值为1),right=9(下标对应的值为10),mid=4(下标对应的值为5) 三数取中选出中间值为 5(位于 mid 位置) 基准值为 5,左右分区相对均衡
如果还是看不懂,可以看看下面的代码实现,就方便理解了。
4.2.2.3 三数取中的效果
| 场景 | 未优化(固定取 left) | 三数取中优化 |
|---|---|---|
| 数组已有序(升序) | O(n²)(退化为冒泡) | O(n log n) |
| 数组已有序(降序) | O(n²)(退化为冒泡) | O(n log n) |
| 数组接近有序 | O(n²) | O(n log n) |
| 数组随机分布 | O(n log n) | O(n log n) |
结论:三数取中法有效避免了快速排序的最坏情况,使算法在各种数据分布下都能保持 O(n log n) 的高效性能。
4.2.2.4 代码实现
cpp
// 三数取中,返回三个位置中(也就是left,mid,right),
// 这三个下标所对应的值处于中间的那个位置的下标
int GetMidNumi(int* a, int left, int right)
{
int mid = (left + right) / 2; // 计算中间位置的下标
// 情况1:a[left] < a[mid]
if (a[left] < a[mid])
{
//当 a[mid] < a[right],也就是 a[mid] 是中间值
//下面也是同理,比较的就是这三个下标的所对应的值
//看谁对应的值是第二大的(也就是这三个数的中间)
//就返回这个值的下标来作为基准值
if (a[mid] < a[right])
return mid;
else if (a[left] > a[right])
return left; // a[left] 是中值
else
return right; // a[right] 是中值
}
else // 情况2:a[left] >= a[mid]
{
if (a[mid] > a[right])
return mid; // a[mid] 是中值
else if (a[left] < a[right])
return left; // a[left] 是中值
else
return right; // a[right] 是中值
}
}
4.2.3 Hoare版本
如图(这里为了方便大家理解,这张图的基准值(key)默认取最左边的,到代码实现部分的时候,我们就采用 三数取中 方法来取基准值(key)):
4.2.3.1 代码实现
cpp
//对着我发的图片看代码,更容易理解
int PartSort1(int* a, int left, int right)
{
// 三数取中:从 left、mid、right 中选中间值作为基准
int midi = GetMidNumi(a, left, right);
if (midi != left)
//将基准值交换到 left 位置是因为快排默认是以最左边取基准值的
//所以我们只需要将我们找好的基准值与最左边的值换一下就好了。
Swap(&a[midi], &a[left]);
int keyi = left; // keyi 记录基准值的位置
// 循环条件:left < right,左右指针相遇时停止
while (left < right)
{
// 从右向左找比基准值小的元素(必须右指针先走,保证最后相遇位置小于基准值)
while (left < right && a[right] >= a[keyi])
--right;
// 从左向右找比基准值大的元素
while (left < right && a[left] <= a[keyi])
++left;
// 交换左右指针指向的元素(将小值换到左边,大值换到右边)
Swap(&a[left], &a[right]);
}
// 最后将基准值与 left 位置交换就好了
Swap(&a[keyi], &a[left]);
keyi = left; // 更新基准值的位置
return keyi;
}
void QuickSort(int* a, int left, int right)
{
if (left >= right) // 递归终止条件:区间内元素个数 <= 1
return;
int keyi = PartSort1(a, left, right); // 分区,得到基准值的最终位置
// 递归排序左子区间 [left, keyi-1]
QuickSort(a, left, keyi - 1);
// 递归排序右子区间 [keyi+1, right]
QuickSort(a, keyi + 1, right);
}
4.2.4 挖坑法
如图(这里为了方便大家理解,这张图的基准值(key)默认取最左边的,到代码实现部分的时候,我们就采用 三数取中 方法来取基准值(key)):
4.2.4.1 代码实现
cpp
//对着我发的图片看代码,更容易理解
int PartSort2(int* a, int left, int right)
{
// 三数取中:从 left、mid、right 中选中间值作为基准
int midi = GetMidNumi(a, left, right);
if (midi != left)
Swap(&a[midi], &a[left]); // 将基准值交换到 left 位置
int hole = a[left]; // 取 left 位置的值为基准值,同时该位置形成坑
while (left < right) // 当 left 和 right 相遇时,停止
{
// 从右向左找比基准值小的元素
while (left < right && a[right] >= hole)
right--;
// 将找到的小值填入 left 位置的坑,此时 right 位置成为新坑
a[left] = a[right];
// 从左向右找比基准值大的元素
while (left < right && a[left] <= hole)
left++;
// 将找到的大值填入 right 位置的坑,此时 left 位置成为新坑
a[right] = a[left];
}
// 将基准值填入最后的坑位(left == right)
a[left] = hole;
return left; // 返回基准值的最终位置
}
void QuickSort(int* a, int left, int right)
{
if (left >= right) // 递归终止条件:区间内元素个数 <= 1
return;
int keyi = PartSort2(a, left, right); // 分区,得到基准值的最终位置
// 递归排序左子区间 [left, keyi-1]
QuickSort(a, left, keyi - 1);
// 递归排序右子区间 [keyi+1, right]
QuickSort(a, keyi + 1, right);
}
4.2.5 前后指针法
如图(这里为了方便大家理解,这张图的基准值(key)默认取最左边的,到代码实现部分的时候,我们就采用 三数取中 方法来取基准值(key)):
4.2.5.1 代码实现
cpp
//对着我发的图片看代码,更容易理解
int PartSort3(int* a, int left, int right)
{
// 三数取中:从 left、mid、right 中选中间值作为基准
int midi = GetMidNumi(a, left, right);
if (midi != left)
Swap(&a[midi], &a[left]); // 将基准值交换到 left 位置
int keyi = left; // keyi 记录基准值的位置
int cur = left + 1; // cur 是前指针(快指针),从基准值的下一个位置开始
int prev = left; // prev 是后指针(慢指针)
// cur 从 left+1 遍历到 right
while (cur <= right)
{
// 如果 cur 指向的元素小于基准值
// 先让 prev 自增,然后判断 prev 和 cur 是否指向同一位置
// 如果不同,则交换 prev 和 cur 指向的元素
// 如果相同,说明 prev 和 cur 之间没有大于基准值的元素,无需交换
// 如果第一个条件 a[cur] < a[keyi] 不满足的话
// 那这个条件判断据就会直接结束,++prev不会被执行。
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[prev], &a[cur]); // 将小值换到前面,大值换到后面
}
++cur; // cur 继续向后遍历
}
// 循环结束后,prev 指向最后一个小于基准值的元素
// 将基准值与 prev 交换,基准值归位
Swap(&a[prev], &a[keyi]);
keyi = prev; // 更新基准值的位置
return keyi;
}
void QuickSort(int* a, int left, int right)
{
if (left >= right) // 递归终止条件:区间内元素个数 <= 1
return;
int keyi = PartSort3(a, left, right); // 分区,得到基准值的最终位置
// 递归排序左子区间 [left, keyi-1]
QuickSort(a, left, keyi - 1);
// 递归排序右子区间 [keyi+1, right]
QuickSort(a, keyi + 1, right);
}
4.2.5 快速排序非递归实现
这里栈的代码,大家可以看我这篇博客,里面写的有:数据结构--栈与队列-CSDN博客
4.2.5.1 代码实现:
cpp
void QuickSortNonR(int* a, 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);
// 这里调取了 快慢指针 的方法,来获取key值和交换数值
int keyi = PartSort3(a, begin, end);
// 下一步就相当于分区。
// 就类似递归里面的做法:
// 如果右子区间存在,将右子区间入栈
// 类似这一步 QuickSort(a, keyi + 1, right);
if (keyi + 1 < end)
{
STPush(&st, end);
STPush(&st, keyi + 1);
}
// 如果左子区间存在,将左子区间入栈
// 类似这一步 QuickSort(a, left, keyi - 1);
if (begin < keyi - 1)
{
STPush(&st, keyi - 1);
STPush(&st, begin);
}
}
STDestroy(&st); // 销毁栈
}
五、归并排序
5.1 概念
归并排序(Merge Sort)是建立在归并操作 上的一种分治算法,由冯·诺依曼于1945年提出。
核心思想:
分解 :将待排序数组从中间分成两个子数组,递归地对每个子数组进行归并排序,直到每个子数组只有一个元素。
合并:将两个有序子数组合并成一个有序数组。
生活类比:有两个已排好序的扑克牌堆,每次从两堆的顶部各取一张,比较大小后将较小的放入新堆,直到所有牌合并完毕。
就像这样:

注意 :归并排序需要额外空间来存放合并后的有序数据,空间复杂度为 O(n)。
5.2 归并排序递归实现
5.2.1 代码实现
cpp
void _MergeSort(int* a, int begin, int end, int* tmp)
{
if (begin >= end) // 终止条件:区间只有一个元素或为空
return;
// 计算中间位置,将区间分成 [begin, mid] 和 [mid+1, end]
int mid = (begin + end) / 2;
// 递归分解左子区间 [begin, mid]
_MergeSort(a, begin, mid, tmp);
// 递归分解右子区间 [mid+1, end]
_MergeSort(a, mid + 1, end, tmp);
// 合并两个有序子区间 [begin, mid] 和 [mid+1, end]
int begin1 = begin, end1 = mid; // 左子区间的边界
int begin2 = mid + 1, end2 = end; // 右子区间的边界
int i = begin; // tmp 数组的起始位置
// 将两个有序子区间中的元素按顺序放入 tmp 数组
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[i++] = a[begin1++]; // 左区间元素更小,放入 tmp
}
else
{
tmp[i++] = a[begin2++]; // 右区间元素更小或相等,放入 tmp
}
}
// 如果左子区间还有剩余元素,全部放入 tmp
while (begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
// 如果右子区间还有剩余元素,全部放入 tmp
while (begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
// 将 tmp 中已排好序的数据拷贝回原数组 a 的 [begin, end] 区间
// 为啥要 + begin
// 因为 tmp 数组是全局临时空间,每次合并时数据存放的起始位置不是固定从 0 开始的,
// 而是从当前子区间的左边界 begin开始的,这样才能保证合并后的数据存放在 tmp 中
// 与 a 中相对应的位置,便于最后统一拷贝回原数组。
// 实在想不明白就记住,你的数组名代表数组首元素的地址,
//而从 begin 位置开始就存放值
//所以你就要 数组名加上begin 来指向存值的地方。
memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
//(end - begin + 1) 这个你就类比成 0 到 9有多少个元素,就理解为啥要 +1 了
//0 - 9 + 1 = 10
}
// 函数功能:归并排序主函数
void MergeSort(int* a, int n)
{
// 申请临时空间用于存放合并后的数据
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc error");
return;
}
// 调用归并排序子函数
_MergeSort(a, 0, n - 1, tmp);
free(tmp); // 释放临时空间
}
5.3 归并排序非递归实现
5.3.1 为什么需要非递归?
递归实现的归并排序在处理大数据量时,递归调用栈可能溢出。非递归实现通过迭代方式模拟归并过程,用循环控制子区间的合并,避免了递归调用的开销和栈溢出风险。
5.3.2 核心思路
初始时
gap = 1,将数组分成若干个长度为gap的子区间。将相邻的两个子区间两两合并(归并)。
每次合并完后
gap *= 2,继续两两合并,直到gap >= n。
注意:需要处理边界情况(区间越界),确保合并操作不越界。
5.3.3 代码实现
cpp
void MergeSortNonR(int* a, int n)
{
// 申请临时空间用于存放合并后的数据
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc error");
return;
}
int gap = 1; // gap 表示每个子区间的长度,先初始为 1
while (gap < n) // 当 gap < n 时持续合并,看图就知道了
{
// 每次合并两个相邻子区间:[begin1, end1] 和 [begin2, end2]
for (int i = 0; i < n; i += 2 * gap)
{
// 计算两个子区间的边界,两个子区间各自的内部中间隔了 gap 个值
int begin1 = i; // 左子区间起点
int end1 = i + gap - 1; // 左子区间终点
int begin2 = i + gap; // 右子区间起点
int end2 = i + 2 * gap - 1; // 右子区间终点
// 边界处理:如果右子区间不存在(begin2 >= n),跳出合并
if (end1 >= n || begin2 >= n)
{
break;
}
// 边界处理:如果右子区间终点越界,修正为 n-1
if (end2 >= n)
{
end2 = n - 1;
}
int j = i; // tmp 数组的起始位置
// 将两个有序子区间中的元素按顺序放入 tmp 数组
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[j++] = a[begin1++]; // 左区间元素更小,放入 tmp
}
else
{
tmp[j++] = a[begin2++]; // 右区间元素更小或相等,放入 tmp
}
}
// 如果左子区间还有剩余元素,全部放入 tmp
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
// 如果右子区间还有剩余元素,全部放入 tmp
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
// 将 tmp 中已排好序的数据拷贝回原数组 a 的 [i, end2] 区间
memcpy(a + i, tmp + i, sizeof(int) * (end2 - i + 1));
}
gap *= 2; // 增加 gap 值,进行下一轮合并
}
free(tmp); // 释放临时空间
}
六、非比较排序
6.1 什么是非比较排序
前面介绍的所有排序算法(插入排序、选择排序、交换排序、归并排序等)都属于比较排序,它们通过比较元素之间的大小关系来确定顺序,时间复杂度下界为 O(n log n)。
非比较排序 不通过元素之间的比较来确定顺序,而是利用元素本身的特性(如取值范围、位数等)直接进行排序,时间复杂度可达到 O(n)。
常见的非比较排序有:
计数排序(Count Sort)
桶排序(Bucket Sort)
基数排序(Radix Sort)
本博客重点介绍计数排序,因为它实现简单且效率极高。
6.2 计数排序的概念
计数排序 的核心思想是:统计每个值出现的次数,然后根据次数将元素依次放回原数组。
具体步骤:
统计范围 :找出待排序数组中的最大值 max 和最小值
min,确定数据范围range = max - min + 1。计数 :创建一个大小为
range的计数数组countA,遍历原数组,统计每个值出现的次数(countA[a[i] - min]++)。排序:遍历计数数组,根据每个值出现的次数,将对应的值依次放回原数组。
生活类比:统计全班同学的成绩分布,先数出每个分数有多少人,然后按分数从低到高依次把同学排好。
就像这样:

6.3 代码实现
cpp
void CountSort(int* a, int n)
{
// 1. 找出数组中的最大值和最小值
int max = a[0], min = a[0];
for (int i = 1; i < n; ++i)
{
if (a[i] > max)
{
max = a[i]; // 更新最大值
}
if (a[i] < min)
{
min = a[i]; // 更新最小值
}
}
// 2. 计算数据范围并申请计数数组
int range = max - min + 1; // 数据的取值范围大小
int* countA = (int*)malloc(sizeof(int) * range);
if (countA == NULL)
{
perror("malloc fail");
return;
}
// 3. 将计数数组全部初始化为 0
memset(countA, 0, sizeof(int) * range);
// 4. 统计每个值出现的次数
// 将 a[i] 映射到 countA 中的下标:a[i] - min
for (int i = 0; i < n; i++)
{
countA[a[i] - min]++; // 对应的计数加 1
}
// 5. 根据计数结果,将元素依次放回原数组
int j = 0; // j 指向原数组的当前位置
for (int i = 0; i < range; i++)
{
// 当 countA[i] 不为 0 时,循环放入对应的值
while (countA[i]--) // 每放入一个,计数减 1
{
a[j++] = i + min; // 将值放回原数组(还原真实值)
}
}
free(countA); // 释放计数数组
}
6.4 计数排序的优缺点
优点
效率极高:时间复杂度为 O(n + range),当 range 较小时接近 O(n)。
实现简单:代码逻辑清晰,容易理解。
稳定性:可以实现稳定排序。
缺点
空间消耗大 :当数据范围
range很大时,需要大量额外空间。数据类型受限:只能用于整数排序,无法直接用于浮点数或字符串。
数据分布敏感 :如果数据分布非常分散(如
[0, 1000000]),效率反而很低。
七、测试用例与运行结果
8.1 测试代码
数组打印代码:
cpp
void PrintArray(int* a, int n)
{
for (int i = 0; i < n; i++)
{
printf("%d ", a[i]);
}
}
测试代码:
cpp
#include"Sort.h"
int main()
{
// 需要测试哪一个,就把其它的给注释掉就好了
int array[10] = { 5,3,1,0,5,2,23,14,17,12};
int n = sizeof(array) / sizeof(array[0]);
InsertSort(array, n);
ShellSort(array, n);
SelectSort(array, n);
BubbleSort(array, n);
HeapSort(array, n);
QuickSort(array, 0, n - 1);
QuickSortNonR(array, 0, n - 1);
MergeSort(array, n);
MergeSortNonR(array, n);
CountSort(array, n);
PrintArray(array, n);
return 0;
}
8.2 运行结果截图
测试结果均为图片展示:



