(叠甲:如有侵权请联系,内容都是自己学习的总结,一定不全面,仅当互相交流(轻点骂)我也只是站在巨人肩膀上的一个小卡拉米,已老实,求放过)
直接插入排序
直接插入排序(Straight Insertion Sort),它是插入排序家族里最直观、最容易理解的算法,逻辑完全贴合我们日常 “理扑克牌” 的行为。
一、核心思想
直接插入排序的核心是“逐步构建有序序列”: 把数组分成两部分:
- 已排序区间:初始只包含数组的第 1 个元素(默认单个元素天然有序)
- 未排序区间:剩下的所有元素
算法做的事情很简单:依次从未排序区间取出一个元素,把它插入到已排序区间的正确位置,让已排序区间始终保持有序,直到所有元素都插入完成。
生活类比:就像打扑克摸牌时,你手里的牌是整齐有序的,每摸到一张新牌,就从右往左比对,把新牌插到合适的位置。
二、算法执行步骤
以数组 {5, 2, 4, 6, 1, 3} 为例,完整走一遍流程:
- 初始状态:已排序区间 =
[5],未排序区间 =[2, 4, 6, 1, 3] - 处理第 2 个元素(2): 从已排序末尾(5)向前比:5 > 2,把 5 向后挪一位; 已遍历完所有有序元素,把 2 插到最前面 → 已排序:
[2, 5] - 处理第 3 个元素(4): 从后往前比:5 > 4 → 5 后移;2 < 4 → 停止; 把 4 插到 2 和 5 中间 → 已排序:
[2, 4, 5] - 处理第 4 个元素(6): 从后往前比:5 < 6 → 不用动,直接放在末尾 → 已排序:
[2, 4, 5, 6] - 处理第 5 个元素(1): 从后往前依次比较:6、5、4、2 都比 1 大,全部后移一位; 遍历到头,把 1 插到最前面 → 已排序:
[1, 2, 4, 5, 6] - 处理第 6 个元素(3): 从后往前比:6、5、4 都比 3 大,依次后移;2 < 3 → 停止; 把 3 插到 2 和 4 中间 → 最终有序:
[1, 2, 3, 4, 5, 6]
三、C 语言代码实现
下面是标准实现:
#include <stdio.h>
// 直接插入排序:arr是待排序数组,n是数组长度
void straightInsertionSort(int arr[], int n)
{
int i, j, temp;
// 从第2个元素(下标1)开始,逐个插入到前面的有序序列
for (i = 1; i < n; i++)
{
temp = arr[i]; // 1. 保存当前待插入的元素(防止被覆盖)
j = i - 1; // 2. 指针指向已排序区间的最后一个元素
// 3. 向前遍历已排序区间:元素大于temp就向后挪位
while (j >= 0 && arr[j] > temp)
{
arr[j + 1] = arr[j]; // 元素后移一位,腾出空位
j--;
}
arr[j + 1] = temp; // 4. 把待插入元素放进正确的空位
}
}
// 测试
int main()
{
int arr[] = {5, 2, 4, 6, 1, 3};
int len = sizeof(arr) / sizeof(arr[0]);
printf("排序前:");
for (int i = 0; i < len; i++)
{
printf("%d ", arr[i]);
}
straightInsertionSort(arr, len);
printf("\n排序后:");
for (int i = 0; i < len; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
四、复杂度与特性
| 维度 | 结论 | 说明 |
|---|---|---|
| 最好时间复杂度 | O(n) | 数组已经完全有序时,每个元素只比较 1 次 |
| 最坏时间复杂度 | O(n²) | 数组完全逆序时,每个元素都要移动到最前面 |
| 平均时间复杂度 | O(n²) | |
| 空间复杂度 | O(1) | 只用到几个临时变量,属于原地排序 |
| 稳定性 | 稳定排序 | 相等的元素不会交换相对顺序 |
五、适用场景
- 数据量较小的排序场景
- 数组基本有序的场景(越有序,效率越高)
- 作为高级排序算法的基础(比如希尔排序就是在它的基础上优化而来)
希尔排序
先回顾直接插入排序的短板:当数据量大、整体逆序程度高时,每个元素都要一步步向前挪动,移动次数非常多,效率很低(最坏 O (n²))。
希尔排序(Shell's Sort)是直接插入排序的优化版本,也叫缩小增量排序,核心思路是:先通过分组让数组达到「基本有序」的状态,再执行一次直接插入排序,大幅减少元素移动的总次数,从而提升效率。
一、核心思想
希尔排序的逻辑可以概括为「分组预排序 → 逐步缩增量 → 最终直接插入」:
- 选定一个增量 gap,把数组中间隔为 gap 的元素分为一组,每组内部执行直接插入排序。
- 不断缩小 gap(通常每次减半),重复分组排序的操作。
- 当 gap = 1 时,整个数组成为一组,此时数组已经基本有序,执行一次直接插入排序即可完成整体排序。
本质:大增量下快速调整元素的宏观位置,小增量下精细调整顺序;最后一步 gap=1 的直接插入排序,因为数组已基本有序,时间复杂度接近最优的 O (n)。
二、算法执行步骤(完整示例)
以数组 {8, 9, 1, 7, 2, 3, 5, 4, 6, 0}(长度 n=10)为例,采用入门最常用的希尔增量序列(初始 gap = n/2,每次整数减半)。
第 1 轮:gap = 5
按间隔 5 分组,共 5 组,每组 2 个元素:
组 0(下标 0、5):8、3
组 1(下标 1、6):9、5
组 2(下标 2、7):1、4
组 3(下标 3、8):7、6
组 4(下标 4、9):2、0
每组分别做直接插入排序后,数组变为:
{3, 5, 1, 6, 0, 8, 9, 4, 7, 2}
第 2 轮:gap = 2
按间隔 2 分组,共 2 组,每组 5 个元素:
偶数组(下标 0,2,4,6,8):3、1、0、9、7
奇数组(下标 1,3,5,7,9):5、6、8、4、2
每组分别做直接插入排序后,数组变为:
{0, 2, 1, 4, 3, 5, 7, 6, 9, 8}
第 3 轮:gap = 1
gap=1 时,整个数组为一组,执行一次直接插入排序。此时数组已经基本有序,仅需少量移动即可完成排序。
最终有序数组:{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
你可以验证:当 gap=1 时,希尔排序的代码逻辑和直接插入排序完全一致。
三、C 语言代码实现
#include <stdio.h>
// 希尔排序:arr为待排序数组,n为数组长度
void shellSort(int arr[], int n)
{
int i, j, temp;
int gap;
// 外层:控制增量gap,初始为n/2,每次减半,直到gap=1
for (gap = n / 2; gap > 0; gap /= 2)
{
// 中层+内层:对每一组执行直接插入排序,步长为gap
for (i = gap; i < n; i++)
{
temp = arr[i]; // 保存当前待插入元素,防止被覆盖
j = i - gap; // 指向同组中前一个元素的下标
// 同组内向前遍历:元素大于temp就向后挪gap位
while (j >= 0 && arr[j] > temp)
{
arr[j + gap] = arr[j];
j -= gap;
}
arr[j + gap] = temp; // 将元素插入正确位置
}
}
}
// 测试用例
int main() {
int arr[] = {8, 9, 1, 7, 2, 3, 5, 4, 6, 0};
int len = sizeof(arr) / sizeof(arr[0]);
printf("排序前:");
for (int i = 0; i < len; i++)
{
printf("%d ", arr[i]);
}
shellSort(arr, len);
printf("\n排序后:");
for (int i = 0; i < len; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
四、复杂度与核心特性
| 维度 | 结论 | 说明 |
|---|---|---|
| 时间复杂度 | 不固定,取决于增量序列 | 原始希尔增量最坏 O (n²),实际平均性能远优于直接插入排序;优化增量(如 Sedgewick)可达 O (n^1.3)~O (n^4/3) |
| 空间复杂度 | O(1) | 原地排序,仅使用少量临时变量 |
| 稳定性 | 不稳定排序 | 分组过程中,相等元素可能被分到不同组,相对顺序会被打乱 |
五、增量序列的补充
希尔排序的性能核心在于增量序列的选择,常见的有三种:
- 希尔增量:gap = n/2, n/4, ..., 1,实现最简单,入门首选
- Knuth 增量:gap = (3^k - 1)/2,反向推导至小于 n,工程中更常用
- Sedgewick 增量:混合增量公式,性能最优,适合大规模数据
七、适用场景
- 中等规模数据排序(几千到几万级别)
- 追求代码简洁、无需额外内存的场景
- 效率显著优于直接插入排序,实现比快速排序、归并排序更简单
选择排序
一、核心思想
选择排序(Selection Sort)和插入排序一样,也把数组分成已排序区间和未排序区间,但操作逻辑完全不同:
- 插入排序:取出未排序的第一个元素,插入到已排序区间的正确位置
- 选择排序:在未排序区间里找到最小(大)的元素,直接和未排序区间的第一个元素交换,放到已排序区间的末尾
简单概括:每一轮选一个最小的,“拎” 到前面排好。
生活类比:军训排队列,教官每次从队伍里找出最矮的同学,站到已排好队伍的最后,重复直到排完。
二、算法执行步骤
我们继续用熟悉的数组 {5, 2, 4, 6, 1, 3} 演示,长度 n=6,总共需要执行 n-1 轮(最后一个元素自然有序)。
初始状态:已排序区间 = 空,未排序区间 = [5, 2, 4, 6, 1, 3]
第 1 轮:遍历整个未排序区间,找到最小值 1(下标 4)
- 和未排序区间第 1 个元素 5(下标 0)交换
- 数组变为:{1, 2, 4, 6, 5, 3},已排序区间 = [1]
第 2 轮:遍历未排序区间 [2, 4, 6, 5, 3],最小值是 2(下标 1)
- 已经在正确位置,无需交换
- 已排序区间 = [1, 2]
第 3 轮:遍历未排序区间 [4, 6, 5, 3],找到最小值 3(下标 5)
- 和未排序第 1 个元素 4(下标 2)交换
- 数组变为:{1, 2, 3, 6, 5, 4},已排序区间 = [1, 2, 3]
第 4 轮:遍历未排序区间 [6, 5, 4],找到最小值 4(下标 5)
- 和未排序第 1 个元素 6(下标 3)交换
- 数组变为:{1, 2, 3, 4, 5, 6},已排序区间 = [1, 2, 3, 4]
第 5 轮:未排序区间只剩 [5, 6],最小值 5 已在正确位置
- 排序完成,最终结果:{1, 2, 3, 4, 5, 6}
三、C 语言代码实现
核心逻辑:外层控制轮次,内层遍历未排序区找最小值下标,最后交换一次。
#include <stdio.h>
// 选择排序:arr为待排序数组,n为数组长度
void selectionSort(int arr[], int n)
{
int i, j, minIndex, temp;
// 外层:共n-1轮,i是未排序区间的起始下标
for (i = 0; i < n - 1; i++)
{
minIndex = i; // 假设未排序区间第一个元素就是最小值
// 内层:遍历未排序区间,找到真正的最小值下标
for (j = i + 1; j < n; j++)
{
if (arr[j] < arr[minIndex])
{
minIndex = j; // 更新最小值下标
}
}
// 找到最小值后,和未排序区间第一个元素交换
if (minIndex != i)
{
temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
}
// 测试用例
int main()
{
int arr[] = {5, 2, 4, 6, 1, 3};
int len = sizeof(arr) / sizeof(arr[0]);
printf("排序前:");
for (int i = 0; i < len; i++)
{
printf("%d ", arr[i]);
}
selectionSort(arr, len);
printf("\n排序后:");
for (int i = 0; i < len; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
关键细节:全程只记录最小值的下标,全部遍历完再做一次交换;不要边比边换,会增加不必要的交换次数。
四、复杂度与核心特性
| 维度 | 结论 | 说明 |
|---|---|---|
| 最好时间复杂度 | O(n²) | 即使数组已经完全有序,也必须遍历每一轮找最小值 |
| 最坏时间复杂度 | O(n²) | 逆序场景下,比较次数和顺序场景完全相同 |
| 平均时间复杂度 | O(n²) | |
| 空间复杂度 | O(1) | 原地排序,仅使用少量临时变量 |
| 稳定性 | 不稳定排序 | 交换操作可能打乱相等元素的相对顺序 |
不稳定举例:数组 {3, 2, 3, 1},第一轮最小值 1 和第一个 3 交换后,两个 3 的前后顺序就颠倒了。
五、和直接插入排序的对比
同为 O (n²) 级别的基础排序,两者有明显差异:
| 对比项 | 直接插入排序 | 选择排序 |
|---|---|---|
| 操作方式 | 逐个插入,元素多次后移 | 找最小值,每轮最多 1 次交换 |
| 元素移动次数 | 较多(逆序时频繁挪位) | 很少(每轮最多 1 次交换) |
| 有序性影响 | 数组越有序效率越高(最好 O (n)) | 有序无序效率几乎无差别 |
| 稳定性 | 稳定 | 不稳定 |
六、适用场景
- 数据量较小的排序场景
- 对元素交换次数敏感的场景(选择排序的交换次数是所有基础排序里最少的)
- 作为进阶算法的基础(堆排序就是在选择排序的思想上优化而来)
堆排序
一、前置知识:什么是堆
堆本质是一棵满足特殊性质的完全二叉树,并且我们通常用数组来存储它,不需要指针。
1. 完全二叉树的数组下标映射
对于长度为 n 的数组,按下标 0 开始存储,对应二叉树的父子节点下标关系是核心公式:
- 节点
i的左孩子下标:2*i + 1 - 节点
i的右孩子下标:2*i + 2 - 节点
i的父节点下标:(i - 1) / 2(整数除法向下取整) - 最后一个非叶子节点下标:
n/2 - 1(叶子节点天然满足堆性质,无需调整)
2. 大顶堆 vs 小顶堆
- 大顶堆:每个节点的值都 ≥ 左右孩子节点的值,堆顶(根节点)是整个堆的最大值
- 小顶堆:每个节点的值都 ≤ 左右孩子节点的值,堆顶是整个堆的最小值
堆排序做升序排序用大顶堆,做降序排序用小顶堆,我们下面以升序 + 大顶堆为例讲解。
二、核心思想
堆排序的逻辑可以拆成两大步:
- 建堆:把无序数组构建成一个大顶堆,此时堆顶就是全局最大值。
- 循环交换 + 调整:
- 把堆顶最大值和堆的最后一个元素交换,最大值就放到了数组末尾(进入已排序区间)
- 把剩下的元素重新调整为大顶堆,得到新的堆顶最大值
- 重复交换 + 调整,直到堆的大小缩小为 1,整个数组完全有序
本质:用堆结构高效 “选出” 当前最大值,替代了选择排序里遍历全数组找最大值的过程。
三、两个核心操作
堆排序的核心是「下沉调整堆」,建堆本质就是从下往上对每个非叶子节点做一次下沉调整。
1. 下沉调整(堆化)
作用:给定一个根节点下标,假设它的左右子树已经是大顶堆,把它向下调整到正确位置,使整个子树成为大顶堆。 步骤:
- 找到当前节点、左孩子、右孩子三者中的最大值下标
- 如果当前节点就是最大值,说明已经符合堆性质,结束
- 如果孩子节点更大,就和当前节点交换
- 交换后继续向下检查被交换的孩子节点,重复上述过程,直到满足堆性质或到达叶子节点
2. 构建初始大顶堆
从最后一个非叶子节点开始,从后往前、从下往上,对每个非叶子节点执行一次下沉调整,最终整个数组就变成了大顶堆。
四、完整执行步骤示例
以数组 {5, 2, 4, 6, 1, 3}(长度 n=6)为例,完整走一遍流程。
阶段 1:构建初始大顶堆
最后一个非叶子节点下标 = 6/2 - 1 = 2
- 调整下标 2(元素 4):只有左孩子下标 5(元素 3),4>3,无需交换。
- 调整下标 1(元素 2):左孩子下标 3(6)、右孩子下标 4(1),最大值是 6;2<6,交换两者。 数组变为:
{5, 6, 4, 2, 1, 3} - 调整下标 0(元素 5):左孩子下标 1(6)、右孩子下标 2(4),最大值是 6;5<6,交换两者。 数组变为:
{6, 5, 4, 2, 1, 3}此时初始大顶堆构建完成,堆顶 6 是全局最大值。
阶段 2:循环交换 + 调整
共执行 n-1 = 5 轮,每轮把当前最大值放到末尾。
-
第 1 轮:堆顶 6 和末尾元素 3(下标 5)交换 →
{3, 5, 4, 2, 1, 6}已排序区间:[6],堆大小缩小为 5,对下标 0 做下沉调整,调整后堆:{5, 2, 4, 3, 1, 6} -
第 2 轮:堆顶 5 和堆末尾元素 1(下标 4)交换 →
{1, 2, 4, 3, 5, 6}已排序区间:[5, 6],堆大小缩小为 4,调整后堆:{4, 3, 1, 2, 5, 6} -
第 3 轮:堆顶 4 和堆末尾元素 2(下标 3)交换 →
{2, 3, 1, 4, 5, 6}已排序区间:[4, 5, 6],堆大小缩小为 3,调整后堆:{3, 2, 1, 4, 5, 6} -
第 4 轮:堆顶 3 和堆末尾元素 1(下标 2)交换 →
{1, 2, 3, 4, 5, 6}已排序区间:[3, 4, 5, 6],堆大小缩小为 2,调整后堆:{2, 1, 3, 4, 5, 6} -
第 5 轮:堆顶 2 和堆末尾元素 1(下标 1)交换 →
{1, 2, 3, 4, 5, 6}堆大小缩小为 1,排序完成。
五、C 语言代码实现
#include <stdio.h>
// 下沉调整:将以root为根的子树调整为大顶堆
// n是当前堆的大小,root是根节点下标
void adjustHeap(int arr[], int n, int root)
{
int largest = root; // 假设根节点是最大值
int left = 2 * root + 1; // 左孩子下标
int right = 2 * root + 2; // 右孩子下标
// 找出根、左孩子、右孩子三者中的最大值下标
if (left < n && arr[left] > arr[largest])
{
largest = left;
}
if (right < n && arr[right] > arr[largest])
{
largest = right;
}
// 如果最大值不是根节点,就交换,然后继续下沉调整被交换的子树
if (largest != root)
{
int temp = arr[root];
arr[root] = arr[largest];
arr[largest] = temp;
// 递归调整被影响的子树
adjustHeap(arr, n, largest);
}
}
// 堆排序主函数
void heapSort(int arr[], int n)
{
// 1. 构建初始大顶堆:从最后一个非叶子节点从后往前调整
for (int i = n / 2 - 1; i >= 0; i--)
{
adjustHeap(arr, n, i);
}
// 2. 循环交换堆顶与末尾元素,再调整堆
for (int i = n - 1; i > 0; i--)
{
// 堆顶(最大值)与当前堆的最后一个元素交换
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
// 对剩下的i个元素,重新从根节点调整为大顶堆
adjustHeap(arr, i, 0);
}
}
// 测试用例
int main() {
int arr[] = {5, 2, 4, 6, 1, 3};
int len = sizeof(arr) / sizeof(arr[0]);
printf("排序前:");
for (int i = 0; i < len; i++)
{
printf("%d ", arr[i]);
}
heapSort(arr, len);
printf("\n排序后:");
for (int i = 0; i < len; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
六、复杂度与核心特性
| 维度 | 结论 | 说明 |
|---|---|---|
| 最好时间复杂度 | O(nlogn) | 无论数组是否有序,都需要完整建堆 + 调整 |
| 最坏时间复杂度 | O(nlogn) | 性能非常稳定,没有最坏退化 |
| 平均时间复杂度 | O(nlogn) | |
| 空间复杂度 | O(1) | 原地排序,仅使用少量临时变量(递归版会有函数调用栈 O (logn),迭代版为纯 O (1)) |
| 稳定性 | 不稳定排序 | 堆顶与末尾元素的交换会打乱相等元素的相对顺序 |
补充:建堆的时间复杂度是 O (n),不是 O (nlogn),因为越上层的节点调整深度越小,总和收敛为线性。
七、适用场景与对比
-
适用场景:
- 数据量较大的排序场景
- 对排序时间稳定性要求高的场景(不会像快排那样最坏退化到 O (n²))
- 经典延伸:Top K 问题(找出数组中第 K 大的数)、优先队列实现
-
和其他 O (nlogn) 排序对比:
- 比归并排序:空间复杂度更优(O (1) vs O (n)),但不稳定
- 比快速排序:最坏时间更优,但平均速度略慢于快排,且不稳定
冒泡排序
一、核心思想
冒泡排序(Bubble Sort)是最经典的交换类入门排序,核心逻辑是通过相邻元素的两两比较与交换,让较大的元素像水中的气泡一样,逐步 “冒” 到数组的末尾。
我们同样可以把数组拆成两个区间:
- 未排序区间:数组前部,尚未排好的部分
- 已排序区间:数组尾部,每一轮都会新增一个当前最大值
每一轮的操作:遍历整个未排序区间,相邻元素两两比较,左大右小就交换;一轮走完后,未排序区间里的最大值就会被交换到未排序区间的末尾,归入已排序区间。
生活类比:就像排队从左到右挨个比身高,高的人往右换位置,一轮下来最高的人就站到了最右边。
二、完整执行步骤
继续用数组 {5, 2, 4, 6, 1, 3}(长度 n=6)演示,共需 n-1 轮。
第 1 轮(确定最大值 6)
遍历下标 0~4,两两比较交换:
- 5 > 2 → 交换 →
[2, 5, 4, 6, 1, 3] - 5 > 4 → 交换 →
[2, 4, 5, 6, 1, 3] - 5 < 6 → 不交换
- 6 > 1 → 交换 →
[2, 4, 5, 1, 6, 3] - 6 > 3 → 交换 →
[2, 4, 5, 1, 3, 6]本轮结束,最大值 6 到达末尾,已排序区间:[6]
第 2 轮(确定次大值 5)
遍历下标 0~3,无需再比较末尾已排序元素:
- 2 < 4 → 不交换
- 4 < 5 → 不交换
- 5 > 1 → 交换 →
[2, 4, 1, 5, 3, 6] - 5 > 3 → 交换 →
[2, 4, 1, 3, 5, 6]本轮结束,已排序区间:[5, 6]
第 3 轮(确定值 4)
遍历下标 0~2:
- 2 < 4 → 不交换
- 4 > 1 → 交换 →
[2, 1, 4, 3, 5, 6] - 4 > 3 → 交换 →
[2, 1, 3, 4, 5, 6]本轮结束,已排序区间:[4, 5, 6]
第 4 轮(确定值 3)
遍历下标 0~1:
- 2 > 1 → 交换 →
[1, 2, 3, 4, 5, 6] - 2 < 3 → 不交换 本轮结束,已排序区间:
[3, 4, 5, 6]
第 5 轮(最后校验)
仅剩余 1 个未排序元素,无需交换,排序完成。
三、C 语言代码实现
1. 基础版
完全对应上述步骤,最直观的原生实现:
#include <stdio.h>
// 基础版冒泡排序
void bubbleSort(int arr[], int n)
{
int i, j, temp;
// 外层:共执行n-1轮
for (i = 0; i < n - 1; i++)
{
// 内层:遍历未排序区间,两两相邻比较
// 末尾i个元素已排好,无需再比较
for (j = 0; j < n - 1 - i; j++)
{
// 左边大于右边则交换
if (arr[j] > arr[j + 1])
{
temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
2. 优化版(提前终止)
这是冒泡排序最经典的优化:如果某一轮遍历中一次交换都没有发生,说明数组已经完全有序,可以直接结束排序,不用跑完剩余轮次。
#include <stdio.h>
// 优化版冒泡排序:增加有序标志位
void bubbleSort(int arr[], int n)
{
int i, j, temp;
for (i = 0; i < n - 1; i++)
{
int swapped = 0; // 标记本轮是否发生过交换
for (j = 0; j < n - 1 - i; j++)
{
if (arr[j] > arr[j + 1])
{
temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swapped = 1; // 记录发生了交换
}
}
// 本轮无交换,数组已完全有序,直接退出
if (swapped == 0)
{
break;
}
}
}
// 测试用例
int main() {
int arr[] = {5, 2, 4, 6, 1, 3};
int len = sizeof(arr) / sizeof(arr[0]);
printf("排序前:");
for (int i = 0; i < len; i++)
{
printf("%d ", arr[i]);
}
bubbleSort(arr, len);
printf("\n排序后:");
for (int i = 0; i < len; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
四、复杂度与核心特性
| 维度 | 结论 | 说明 |
|---|---|---|
| 最好时间复杂度 | O(n) | 优化版下,数组完全有序时,第一轮遍历无交换,直接结束 |
| 最坏时间复杂度 | O(n²) | 数组完全逆序时,每一轮都要完整比较交换 |
| 平均时间复杂度 | O(n²) | |
| 空间复杂度 | O(1) | 原地排序,仅使用少量临时变量 |
| 稳定性 | 稳定排序 | 仅相邻元素交换,相等元素不交换,相对顺序保持不变 |
稳定举例:数组
{3, 2, 3},冒泡排序中两个 3 的前后顺序始终保持不变。
五、三种基础 O (n²) 排序对比
| 对比项 | 直接插入排序 | 选择排序 | 冒泡排序 |
|---|---|---|---|
| 核心操作 | 元素后移 + 插入 | 找最小值 + 单次交换 | 两两比较 + 频繁相邻交换 |
| 元素交换次数 | 中等 | 最少(每轮最多 1 次) | 最多 |
| 最好时间复杂度 | O(n) | O(n²) | O (n)(优化后) |
| 稳定性 | 稳定 | 不稳定 | 稳定 |
| 实现难度 | 中等 | 中等 | 最简单 |
六、适用场景
排序算法入门教学,逻辑最直观易懂
数据量很小的简单排序场景
对排序稳定性有要求、且追求实现成本极低的场景
实际工程中很少直接使用,整体性能不如插入排序,更多作为算法基础
快速排序
Lomuto 分区法(单向快慢指针法)
一、核心思想
快速排序(Quick Sort)是交换排序的分治优化版,也是目前工业界最常用的排序算法之一,核心是「基准划分 + 递归分治」。
和冒泡排序的本质区别:冒泡每轮 O (n) 操作只能确定 1 个最大值的最终位置;快排一次 O (n) 的分区操作,不仅能确定基准元素的最终有序位置,还能把数组拆成 “全小于基准、全大于基准” 两个独立子数组,递归处理后整体有序,效率从 O (n²) 提升到平均 O (nlogn)。
核心逻辑三步走:
- 选基准:从待排序区间选一个元素作为基准值(pivot)
- 分区:重排数组,所有小于基准的元素移到基准左边,所有大于基准的移到右边,基准最终落在它的固定有序位置
- 递归:对基准左右两个子区间,重复 “选基准 - 分区”,直到子区间长度≤1(天然有序)
生活类比:班级排队选一个同学当身高标杆,比他矮的全站左边,比他高的全站右边;再给左边、右边队伍各自选标杆拆分,直到每个人的位置都固定。
二、核心操作:分区(Partition)
分区是快排的灵魂,我们用最易理解的单向扫描分区法(Lomuto 方案)讲解:
- 选待排序区间的最后一个元素作为基准值
- 维护一个「小于基准的区域右边界」指针 i,初始在区间左边界左侧
- 用指针 j 从左到右遍历区间:
- 若当前元素 ≤ 基准值,就把它换到「小于区域」的末尾,i 右移一位
- 若当前元素 > 基准值,跳过不处理
- 遍历结束后,把基准元素换到「小于区域」的下一位,这就是基准的最终位置
三、完整执行示例
沿用熟悉的数组 {5, 2, 4, 6, 1, 3}(长度 n=6),完整走一遍流程。
第 1 次分区:整个数组 [0,5]
- 基准值 pivot = 3(下标 5)
- 初始化 i = -1(小于区域右边界)
- j 从 0 到 4 遍历:
- j=0,元素 5 > 3 → 跳过
- j=1,元素 2 ≤ 3 → i=0,交换下标 0 和 1 → 数组变为
{2, 5, 4, 6, 1, 3} - j=2,元素 4 > 3 → 跳过
- j=3,元素 6 > 3 → 跳过
- j=4,元素 1 ≤ 3 → i=1,交换下标 1 和 4 → 数组变为
{2, 1, 4, 6, 5, 3}
- 遍历结束,交换下标 i+1=2 和基准下标 5 → 数组变为
{2, 1, 3, 6, 5, 4} - 基准 3 固定在下标 2,拆分出左子区间 [0,1]、右子区间 [3,5]
递归处理左子区间 [0,1]:元素 {2,1}
- 基准值 pivot = 1(下标 1)
- i = -1,j=0 遍历:元素 2 > 1 → 跳过
- 交换下标 0 和 1 → 数组变为
{1, 2, 3, 6, 5, 4} - 基准 2 固定在下标 1,左右子区间长度均≤1,左半部分全部有序
递归处理右子区间 [3,5]:元素 {6,5,4}
- 基准值 pivot = 4(下标 5)
- i = 2,j 从 3 到 4 遍历:6>4、5>4 → 全部跳过
- 交换下标 3 和 5 → 数组变为
{1, 2, 3, 4, 5, 6} - 基准 4 固定在下标 3,右子区间 [4,5] 继续递归,最终全部有序
最终排序结果:{1, 2, 3, 4, 5, 6}
四、C 语言代码实现
基础递归版(Lomuto 分区)
#include <stdio.h>
// 分区函数:返回基准元素的最终下标
// arr为数组,low是区间左边界,high是区间右边界
int partition(int arr[], int low, int high)
{
int pivot = arr[high]; // 选区间最后一个元素作为基准
int i = low - 1; // 小于基准区域的右边界
int temp;
// 遍历区间内除基准外的所有元素
for (int j = low; j < high; j++)
{
// 当前元素小于等于基准,归入小于区域
if (arr[j] <= pivot)
{
i++;
// 交换到小于区域的末尾
temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
// 把基准元素放到小于区域的下一位,即最终正确位置
temp = arr[i + 1];
arr[i + 1] = arr[high];
arr[high] = temp;
return i + 1; // 返回基准的最终下标
}
// 快速排序递归主函数
void quickSort(int arr[], int low, int high)
{
// 递归终止条件:区间长度<=1
if (low < high)
{
// 1. 分区,得到基准下标
int pivotIndex = partition(arr, low, high);
// 2. 递归排序左半部分
quickSort(arr, low, pivotIndex - 1);
// 3. 递归排序右半部分
quickSort(arr, pivotIndex + 1, high);
}
}
// 测试用例
int main() {
int arr[] = {5, 2, 4, 6, 1, 3};
int len = sizeof(arr) / sizeof(arr[0]);
printf("排序前:");
for (int i = 0; i < len; i++)
{
printf("%d ", arr[i]);
}
quickSort(arr, 0, len - 1);
printf("\n排序后:");
for (int i = 0; i < len; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
Hoare 霍尔分区法(左右双指针法)
这是快速排序发明者 C.A.R. Hoare 的原始实现,也是工业界最常用的版本,交换次数更少、执行效率更高。
1. 核心思想
选取一个基准值后,用两个指针从两端向中间逼近:
- 右指针从右往左走,找第一个小于基准的元素
- 左指针从左往右走,找第一个大于基准的元素
- 找到后直接交换这一对逆序元素
- 两指针相遇时,把基准值交换到相遇位置,即为基准的最终有序位置
2. 关键规则(极易出错点)
如果选左端点作为基准,必须让右指针先移动。 原因:最终要把基准和相遇点交换,必须保证相遇点的值 ≤ 基准值;右指针先停下的位置一定满足 ≤ 基准,最终相遇位置也必然符合要求。
3. 完整执行示例
以数组 {5, 2, 4, 6, 1, 3},区间 [0,5] 为例:
- 选基准
pivot = arr[0] = 5,左指针left=0,右指针right=5 - 右指针左移:找小于 5 的元素,
arr[5]=3符合,停下 - 左指针右移:找大于 5 的元素,
arr[3]=6符合,停下 - 交换
arr[3]和arr[5]→ 数组变为{5, 2, 4, 3, 1, 6} - 继续移动:右指针左移到
arr[4]=1停下;左指针右移到下标 4 时与右指针相遇 - 交换基准位置
arr[0]和相遇位置arr[4]→ 数组变为{1, 2, 4, 3, 5, 6} - 基准 5 固定在下标 4,拆分出左区间
[0,3]、右区间[5,5]
// Hoare 左右双指针分区法,返回基准最终下标
int hoarePartition(int arr[], int low, int high)
{
int pivot = arr[low]; // 选区间左端点作为基准
int left = low;
int right = high;
while (left < right)
{
// 1. 右指针先动:从右往左找第一个小于基准的元素
while (left < right && arr[right] >= pivot) {
right--;
}
// 2. 左指针后动:从左往右找第一个大于基准的元素
while (left < right && arr[left] <= pivot)
{
left++;
}
// 3. 两指针未相遇,交换逆序元素
if (left < right) {
int temp = arr[left];
arr[left] = arr[right];
arr[right] = temp;
}
}
// 4. 两指针相遇,将基准值放到相遇的最终位置
arr[low] = arr[left];
arr[left] = pivot;
return left;
}
// 快速排序主函数(和之前完全通用,仅替换分区函数即可)
void quickSort(int arr[], int low, int high)
{
if (low < high)
{
int pivotIndex = hoarePartition(arr, low, high);
quickSort(arr, low, pivotIndex - 1); // 递归左区间
quickSort(arr, pivotIndex + 1, high); // 递归右区间
}
}
挖坑法
挖坑法是 Hoare 法的另一种直观表达,全程没有交换操作,只有赋值填坑,新手最容易理解和写对。
1. 核心思想
把基准元素先 “挖出来” 保存,原位置形成一个空坑;然后左右指针交替找元素填坑,每次填坑会产生新的坑位;两指针相遇时,最后一个坑位就是基准的最终位置,把基准填回去即可。
2. 完整执行示例
同样以数组 {5, 2, 4, 6, 1, 3},区间 [0,5] 为例:
- 挖出基准
pivot = arr[0] = 5,下标 0 形成第一个坑 - 右指针左移找小于 5 的元素:找到
arr[5]=3,把 3 填到下标 0 的坑里 → 下标 5 变成新坑 - 左指针右移找大于 5 的元素:找到
arr[3]=6,把 6 填到下标 5 的坑里 → 下标 3 变成新坑 - 右指针继续左移:找到
arr[4]=1,把 1 填到下标 3 的坑里 → 下标 4 变成新坑 - 左指针继续右移,到下标 4 时与右指针相遇,循环结束
- 把基准 5 填到下标 4 的坑里 → 数组变为
{3, 2, 4, 1, 5, 6},基准固定在下标 4
注:分区只保证基准位置正确、左右大小关系成立,不保证左右内部顺序,因此和 Hoare 法的中间结果不同是正常现象,不影响最终排序结果。
// 挖坑法分区,返回基准最终下标
int pitPartition(int arr[], int low, int high)
{
int pivot = arr[low]; // 挖出基准值,low位置形成第一个坑
int left = low;
int right = high;
while (left < right)
{
// 1. 从右往左找小于基准的元素,填左边的坑
while (left < right && arr[right] >= pivot)
{
right--;
}
if (left < right) {
arr[left] = arr[right]; // 填左坑,right位置变为新坑
left++;
}
// 2. 从左往右找大于基准的元素,填右边的坑
while (left < right && arr[left] <= pivot)
{
left++;
}
if (left < right) {
arr[right] = arr[left]; // 填右坑,left位置变为新坑
right--;
}
}
// 3. 两指针相遇,把基准值填到最后的坑位
arr[left] = pivot;
return left;
}
// 快速排序主函数(同样完全通用)
void quickSort(int arr[], int low, int high)
{
if (low < high)
{
int pivotIndex = pitPartition(arr, low, high);
quickSort(arr, low, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, high);
}
}
三种分区方法对比总结
| 分区方法 | 核心思路 | 交换 / 赋值 | 代码难度 | 运行效率 | 适用场景 |
|---|---|---|---|---|---|
| Lomuto 单向快慢指针 | 维护小于区域,逐个向后扩展 | 交换次数最多 | 最低,最易写对 | 最低 | 教学入门、快速实现 |
| Hoare 左右双指针 | 两端向中间逼近,成对交换逆序元素 | 交换次数最少 | 中等,指针顺序易出错 | 最高 | 工业级实现、追求性能 |
| 挖坑法 | 基准挖空,交替填坑 | 无交换,仅赋值 | 中等,逻辑最直观 | 和 Hoare 相当 | 新手理解、面试手写 |
五、复杂度与核心特性
| 维度 | 结论 | 说明 |
|---|---|---|
| 平均时间复杂度 | O(nlogn) | 分区均匀时,递归深度 logn,每层总操作量 O (n) |
| 最好时间复杂度 | O(nlogn) | 每次分区刚好把数组平分 |
| 最坏时间复杂度 | O(n²) | 数组完全有序 / 逆序,且选首尾元素为基准时,分区极不均匀 |
| 平均空间复杂度 | O(logn) | 递归调用栈的深度,平均为 logn |
| 最坏空间复杂度 | O(n) | 最坏分区下,递归深度为 n |
| 稳定性 | 不稳定排序 | 分区交换过程会打乱相等元素的相对顺序 |
关键说明:实际工程中快排几乎不会触发最坏情况,通过优化基准选择可以彻底避免 O (n²) 退化,因此它的实际运行效率通常优于堆排序和归并排序。
六、常见优化方向
- 基准选择优化
- 三数取中法:取区间首、中、尾三个元素的中位数作为基准,避免有序数组下的最坏情况
- 随机基准法:随机选一个元素作为基准,概率上避免最坏情况
- 小区间优化 当子数组长度小于阈值(通常 10~20)时,改用直接插入排序,减少递归开销
- 三路快排 把数组分成「小于、等于、大于」三部分,处理存在大量重复元素的场景,效率大幅提升
- 尾递归优化 优先递归较短的子区间,降低递归栈深度,最坏空间复杂度可优化到 O (logn)
七、适用场景与对比
适用场景
- 通用大规模数据排序,是绝大多数语言内置排序函数的底层实现(如 C 标准库的 qsort)
- 对平均运行速度要求高,可接受不稳定排序的场景
与同复杂度排序对比
| 算法 | 时间复杂度(平均 / 最坏) | 空间复杂度 | 稳定性 | 平均速度 |
|---|---|---|---|---|
| 快速排序 | O(nlogn) / O(n²) | O(logn) | 不稳定 | 最快 |
| 堆排序 | O(nlogn) / O(nlogn) | O(1) | 不稳定 | 中等 |
| 归并排序 | O(nlogn) / O(nlogn) | O(n) | 稳定 | 较快 |
归并排序
一、核心思想
归并排序(Merge Sort)是分治算法的经典实现,核心思路是「先拆分、后合并」:
- 分(拆分):将数组不断对半拆分,直到每个子数组仅含 1 个元素(单个元素天然有序)。
- 治(合并):将两个有序的小数组,按顺序合并成一个更大的有序数组;逐层向上合并,最终得到完整的有序数组。
和快速排序的本质区别:
- 快排:先分区定基准,再递归处理子数组,排序工作在递归前完成
- 归并:先递归拆分子数组,再合并有序子数组,排序工作在递归后完成
生活类比:把一摞试卷先分成两半,再继续拆到每份只有 1 张;然后把两份排好序的试卷按顺序合成一摞,不断合并直到还原整摞。
二、核心操作:合并两个有序数组
合并是归并排序的灵魂:给定两个已经有序的子数组,把它们合并成一个有序数组。 实现逻辑:
- 申请一块临时数组,大小等于两个子数组长度之和
- 用三个指针分别指向左子数组开头、右子数组开头、临时数组开头
- 每次比较左右指针指向的元素,把较小的元素放入临时数组,对应指针后移
- 当其中一个子数组遍历完,把另一个子数组剩余的元素直接全部拷贝到临时数组
- 最后把临时数组的结果拷贝回原数组的对应位置
三、完整执行示例
以数组 {5, 2, 4, 6, 1, 3}(长度 n=6)为例,完整走一遍拆分 + 合并流程。
阶段 1:拆分(自顶向下)
不断对半拆分,直到子数组长度为 1:
原数组:[5, 2, 4, 6, 1, 3]
第1次拆分:左 [5, 2, 4] | 右 [6, 1, 3]
第2次拆分:左 [5,2]、[4] | 右 [6,1]、[3]
第3次拆分:[5]、[2]、[4] | [6]、[1]、[3]
拆分结束,所有子数组均为单个元素,天然有序。
阶段 2:合并(自底向上)
两两合并有序子数组,逐层向上:
- 合并
[5]和[2]→[2, 5];合并[6]和[1]→[1, 6] - 合并
[2, 5]和[4]→[2, 4, 5];合并[1, 6]和[3]→[1, 3, 6] - 合并
[2, 4, 5]和[1, 3, 6]→[1, 2, 3, 4, 5, 6]
最终得到完整有序数组。
四、代码实现
递归版・自顶向下
这是最经典、最易理解的实现,分为 merge 合并函数和 mergeSort 递归主函数两部分。
#include <stdio.h>
#include <stdlib.h>
// 合并函数:将arr[left..mid] 和 arr[mid+1..right] 两个有序子数组合并
void merge(int arr[], int left, int mid, int right, int temp[]) {
int i = left; // 左子数组起始指针
int j = mid + 1; // 右子数组起始指针
int k = left; // 临时数组指针
// 1. 同时遍历两个子数组,取较小的放入临时数组
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[k++] = arr[i++]; // 优先取左边,保证稳定性
} else {
temp[k++] = arr[j++];
}
}
// 2. 处理左子数组剩余元素
while (i <= mid) {
temp[k++] = arr[i++];
}
// 3. 处理右子数组剩余元素
while (j <= right) {
temp[k++] = arr[j++];
}
// 4. 将临时数组的结果拷贝回原数组对应区间
for (i = left; i <= right; i++) {
arr[i] = temp[i];
}
}
// 归并排序递归主函数
void mergeSortRecursive(int arr[], int left, int right, int temp[]) {
// 递归终止条件:子数组长度<=1
if (left < right) {
int mid = left + (right - left) / 2; // 对半拆分,避免溢出
// 先递归拆分排序左右子数组
mergeSortRecursive(arr, left, mid, temp);
mergeSortRecursive(arr, mid + 1, right, temp);
// 再合并两个有序子数组
merge(arr, left, mid, right, temp);
}
}
// 封装后的对外接口:统一申请临时数组,避免递归内频繁申请释放
void mergeSort(int arr[], int n) {
int *temp = (int *)malloc(n * sizeof(int)); // 一次性申请临时数组
mergeSortRecursive(arr, 0, n - 1, temp);
free(temp);
}
// 测试用例
int main() {
int arr[] = {5, 2, 4, 6, 1, 3};
int len = sizeof(arr) / sizeof(arr[0]);
printf("排序前:");
for (int i = 0; i < len; i++) printf("%d ", arr[i]);
mergeSort(arr, len);
printf("\n排序后:");
for (int i = 0; i < len; i++) printf("%d ", arr[i]);
return 0;
}
自底向上归并排序(迭代版)
递归版是「把大数组拆到最小,再逐层合并」,迭代版则反过来:直接从长度为 1 的子数组出发,两两合并,步长不断翻倍,全程没有递归调用,也没有递归栈开销。
1. 核心逻辑
- 初始步长
size = 1,每个元素自身就是一个有序子数组 - 每一轮按步长将数组分组,每组内左右两段有序子数组合并
- 每轮结束后
size *= 2,直到步长 ≥ 数组长度,排序完成
2. 完整 C 语言代码
合并函数 merge 和递归版完全通用,仅主逻辑改为迭代:
#include <stdio.h>
#include <stdlib.h>
// 合并函数:与递归版完全一致
void merge(int arr[], int left, int mid, int right, int temp[])
{
int i = left;
int j = mid + 1;
int k = left;
while (i <= mid && j <= right)
{
if (arr[i] <= arr[j])
{
temp[k++] = arr[i++];
} else
{
temp[k++] = arr[j++];
}
}
while (i <= mid) temp[k++] = arr[i++];
while (j <= right) temp[k++] = arr[j++];
for (i = left; i <= right; i++)
{
arr[i] = temp[i];
}
}
// 自底向上迭代版归并排序
void mergeSortBottomUp(int arr[], int n)
{
int *temp = (int *)malloc(n * sizeof(int));
int size; // 当前每个有序子数组的长度
// 步长从1开始,每次翻倍
for (size = 1; size < n; size *= 2)
{
// 按当前步长遍历所有分组
for (int left = 0; left < n; left += 2 * size)
{
int mid = left + size - 1;
int right = left + 2 * size - 1;
// 右边界超出数组长度时修正
if (right >= n) right = n - 1;
// 左子数组已到末尾,无需合并
if (mid >= n - 1) break;
merge(arr, left, mid, right, temp);
}
}
free(temp);
}
// 测试用例
int main() {
int arr[] = {5, 2, 4, 6, 1, 3};
int len = sizeof(arr) / sizeof(arr[0]);
printf("排序前:");
for (int i = 0; i < len; i++)
{
printf("%d ", arr[i]);
}
mergeSortBottomUp(arr, len);
printf("\n排序后:");
for (int i = 0; i < len; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
五、复杂度与核心特性
| 维度 | 结论 | 说明 |
|---|---|---|
| 最好时间复杂度 | O(nlogn) | 无论数组是否有序,拆分合并的总工作量固定 |
| 最坏时间复杂度 | O(nlogn) | 性能极其稳定,无退化场景 |
| 平均时间复杂度 | O(nlogn) | |
| 空间复杂度 | O(n) | 需要额外临时数组存放合并结果,典型的空间换时间 |
| 稳定性 | 稳定排序 | 合并时相等元素优先取左侧子数组,相对顺序保持不变 |
关键补充:归并排序是所有主流 O (nlogn) 排序中,唯一天然稳定的算法。
六、适用场景与算法对比
1. 适用场景
- 对排序稳定性有要求的场景
- 外部排序:数据量极大、无法全部装入内存时(如磁盘大文件排序),归并排序是最优选择
- 链表排序:无需额外数组空间,仅通过修改指针即可完成合并,效率极高
2. 与同量级排序对比
| 算法 | 平均时间 | 最坏时间 | 空间复杂度 | 稳定性 | 平均速度 |
|---|---|---|---|---|---|
| 快速排序 | O(nlogn) | O(n²) | O(logn) | 不稳定 | 最快 |
| 归并排序 | O(nlogn) | O(nlogn) | O(n) | 稳定 | 较快 |
| 堆排序 | O(nlogn) | O(nlogn) | O(1) | 不稳定 | 中等 |

1282

被折叠的 条评论
为什么被折叠?



