排序算法总结(C++版)
这篇文章把面试里最常考的排序算法做一个系统整理。目标不是只会背结论,而是能在面试里把:
- 核心思想
- 时间复杂度
- 空间复杂度
- 稳定性
- 适用场景
- 手写模板
- 高频追问
说清楚。
一、总览
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 | 特点 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n^2) | O(n^2) | O(1) | 稳定 | 简单,适合入门 |
| 选择排序 | O(n^2) | O(n^2) | O(1) | 不稳定 | 交换次数少 |
| 插入排序 | O(n^2) | O(n^2) | O(1) | 稳定 | 小规模数据很好用 |
| 希尔排序 | 约 O(n^1.3 ~ n^2) | O(n^2) | O(1) | 不稳定 | 插入排序的优化版 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 分治思想,复杂度稳定 |
| 快速排序 | O(n log n) | O(n^2) | O(log n) | 不稳定 | 面试最常考,实际常很快 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 原地排序,复杂度稳定 |
| 计数排序 | O(n + k) | O(n + k) | O(k) | 稳定 | 适合数据范围小的整数 |
| 桶排序 | 近似 O(n) | 取决于分布 | 取决于桶数 | 可稳定 | 适合分布均匀的数据 |
| 基数排序 | O(d(n + k)) | O(d(n + k)) | O(n + k) | 稳定 | 适合位数固定的整数/字符串 |
二、冒泡排序
1. 思想
相邻两个元素两两比较,如果顺序不对就交换。每一轮会把当前最大的元素“冒泡”到最后。
2. 例子
数组:
1 | [5, 2, 3, 1] |
第一轮:
- 5 和 2 比,交换 ->
[2,5,3,1] - 5 和 3 比,交换 ->
[2,3,5,1] - 5 和 1 比,交换 ->
[2,3,1,5]
这一轮结束,最大值 5 到末尾。
3. 代码
1 | void bubbleSort(vector<int>& nums) { |
4. 特点
- 稳定排序
- 空间 O(1)
- 优化后,如果数组本来就有序,可以提前结束
5. 高频追问
为什么稳定?
因为只有前一个元素严格大于后一个元素时才交换,相等元素不会改变相对顺序。
三、选择排序
1. 思想
每一轮从未排序部分中找最小值,放到当前起始位置。
2. 代码
1 | void selectionSort(vector<int>& nums) { |
3. 特点
- 不稳定
- 空间 O(1)
- 交换次数少,但比较次数固定很多
4. 为什么不稳定?
例如:
1 | [5a, 5b, 3] |
第一轮会把 3 交换到最前面,可能变成:
1 | [3, 5b, 5a] |
相等元素 5a 和 5b 的顺序变了,所以不稳定。
四、插入排序
1. 思想
把数组分成“已排序区间”和“未排序区间”,每次从未排序区间拿一个数,插到已排序区间正确位置。
2. 例子
1 | [5, 2, 3, 1] |
- 先认为 5 已有序
- 插入 2 ->
[2,5,3,1] - 插入 3 ->
[2,3,5,1] - 插入 1 ->
[1,2,3,5]
3. 代码
1 | void insertionSort(vector<int>& nums) { |
4. 特点
- 稳定
- 空间 O(1)
- 对“小规模”或“接近有序”的数据非常好
5. 高频追问
为什么很多库排序在小区间会退化成插入排序?
因为插入排序常数小、实现简单,对小数组往往比快排、归并更快。
五、希尔排序
1. 思想
希尔排序本质上是“分组插入排序”。
先按 gap 把相距较远的元素看成一组,分别做插入排序;再不断缩小 gap,最后 gap = 1 时,相当于做一次整体插入排序。
2. 代码
1 | void shellSort(vector<int>& nums) { |
3. 特点
- 不稳定
- 是插入排序的优化版
- 面试频率不如快排、归并、堆排高,但偶尔会问
六、归并排序
1. 思想
归并排序就是:
先递归拆分,再合并两个有序数组。
把数组不断拆成两半,直到每部分只有一个元素;然后再把两个有序子数组合并起来。
2. 代码
1 | void mergeSort(vector<int>& nums, int l, int r) { |
完整调用:
1 | void sortArray(vector<int>& nums) { |
3. 特点
- 时间复杂度稳定 O(n log n)
- 稳定排序
- 空间复杂度 O(n)
4. 高频追问
为什么归并排序是稳定的?
因为合并时如果相等,通常先放左边元素:
1 | if (nums[i] <= nums[j]) |
这样相同元素的相对顺序不会变。
为什么适合链表排序?
链表不适合随机访问,但特别适合从中间断开再合并,所以链表排序经典就是归并排序。
七、快速排序
1. 思想
快速排序就是:
选一个 pivot,把小于等于它的放左边,大于等于它的放右边,然后递归排左右两边。
2. 经典模板
1 | void quickSort(vector<int>& nums, int l, int r) { |
完整调用:
1 | void sortArray(vector<int>& nums) { |
3. 特点
- 平均 O(n log n)
- 最坏 O(n^2)
- 空间复杂度平均 O(log n)
- 不稳定
- 面试最常考
4. 高频追问
为什么先走右边再走左边?
因为 pivot 放在左边,最后要把坑位留给 pivot,常见模板就是先从右往左找小的,再从左往右找大的。
为什么要随机 pivot?
为了避免原数组接近有序时退化成 O(n^2)。
快排为什么平均是 O(n log n)?
每轮 partition 代价是 O(n),平均情况下会比较均匀地分成两部分,所以递归层数平均是 O(log n),总复杂度是 O(n log n)。
八、快速选择(Quick Select)
虽然它不是完整排序,但面试经常和快排一起问。
1. 适用题型
- 第 K 大元素
- 最小的 K 个数
2. 思想
它复用了快排的 partition,但不递归两边,只递归答案所在的一边。
3. 复杂度
- 平均 O(n)
- 最坏 O(n^2)
4. 典型题
- LeetCode 215. 数组中的第 K 个最大元素
九、堆排序
1. 思想
堆排序可以理解成:
先建大根堆,再不断把堆顶最大值交换到数组末尾。
2. 代码
1 | void heapify(vector<int>& nums, int n, int i) { |
3. 特点
- O(n log n)
- 空间 O(1)
- 不稳定
- 原地排序
4. 高频追问
为什么建堆从 n/2 - 1 开始?
因为叶子节点没有孩子,不需要向下调整。最后一个非叶子节点就是 n/2 - 1。
为什么堆排序不稳定?
因为堆顶和末尾交换时,相同元素的相对顺序可能被打乱。
数组中如何表示堆?
对于下标 i:
- 左孩子:
2*i + 1 - 右孩子:
2*i + 2 - 父节点:
(i - 1) / 2
十、计数排序
1. 思想
适用于数据范围不大的整数。统计每个数出现了多少次,然后按次数回填。
2. 代码
1 | vector<int> countingSort(vector<int>& nums) { |
3. 特点
- 时间复杂度 O(n + k)
- 适合整数且范围小
- 不是基于比较的排序
4. 高频追问
为什么计数排序不适合数据范围特别大?
因为需要开一个按值域大小分配的数组,值域太大会造成空间浪费甚至无法接受。
十一、桶排序
1. 思想
把数据分到多个桶里,每个桶内再单独排序,最后按桶顺序拼起来。
2. 特点
- 适合数据分布比较均匀的情况
- 如果分布很差,可能退化
- 常作为思路题,不一定真让你手写完整版
3. 高频追问
桶排序和计数排序区别?
- 计数排序是“每个值对应一个计数位”
- 桶排序是“每个区间对应一个桶”
十二、基数排序
1. 思想
按位排序。常见是从个位开始,到十位、百位……每一位都做一次稳定排序。
2. 特点
- 适合位数固定的整数或字符串
- 依赖稳定排序作为子过程
- 不是基于比较的排序
3. 高频追问
为什么基数排序要求子排序稳定?
因为低位排好的相对顺序,需要在更高位排序时保留下来。
十三、稳定排序与不稳定排序
1. 什么叫稳定?
如果两个元素值相同,排序后它们的相对顺序不变,就叫稳定排序。
例如:
1 | [3a, 1, 3b] |
如果排序后还是:
1 | [1, 3a, 3b] |
那就是稳定。
如果变成:
1 | [1, 3b, 3a] |
那就是不稳定。
2. 常见稳定排序
- 冒泡排序
- 插入排序
- 归并排序
- 计数排序
- 基数排序
3. 常见不稳定排序
- 选择排序
- 快速排序
- 堆排序
- 希尔排序
十四、面试最常考的几个对比
1. 快排 vs 归并
快排
- 平均 O(n log n)
- 最坏 O(n^2)
- 空间较省
- 不稳定
- 实际常很快
归并
- 稳定 O(n log n)
- 需要 O(n) 额外空间
- 稳定排序
- 适合链表排序和外部排序
2. 快排 vs 堆排
快排
- 平均更快
- 常数通常更优
- 最坏会退化
堆排
- 复杂度稳定 O(n log n)
- 原地排序 O(1)
- 但实际常数偏大,通常没快排快
3. 插入排序 vs 冒泡排序
- 都是 O(n^2)
- 但插入排序通常更常用、也更高效一点
- 冒泡排序更多偏教学和基础题
十五、面试中排序题怎么选
1. 如果题目就是“排序数组”
优先考虑:
- 快排
- 归并排序
- 堆排序
2. 如果是链表排序
优先:
- 归并排序
3. 如果是找第 K 大/第 K 小
优先:
- 快速选择
- 堆
4. 如果数据范围特别小,而且是整数
优先考虑:
- 计数排序
- 基数排序
5. 如果数组很小或接近有序
优先考虑:
- 插入排序
十六、面试中常见问题整理
1. 哪些排序是 O(n log n)?
常见比较排序里:
- 归并排序
- 快速排序(平均)
- 堆排序
2. 哪些排序是稳定的?
- 冒泡
- 插入
- 归并
- 计数
- 基数
3. 哪些排序是原地排序?
常见:
- 冒泡
- 选择
- 插入
- 希尔
- 快排(通常算原地)
- 堆排
归并排序一般不算原地,因为要额外数组。
4. 为什么快排平均快?
因为 partition 操作局部性好,常数小,而且不需要像归并那样额外开大数组。
5. 为什么归并适合外部排序?
因为它的“分块 + 合并”过程天然适合磁盘文件分段处理。
6. 堆排序为什么空间复杂度 O(1)?
因为它直接在原数组上建堆和调整,不需要额外的大数组。
十七、最建议背的三个模板
1. 快排
1 | void quickSort(vector<int>& nums, int l, int r) { |
2. 归并
1 | void mergeSort(vector<int>& nums, int l, int r) { |
3. 堆排
1 | void heapify(vector<int>& nums, int n, int i) { |
十八、最后总结
如果从“面试最值得优先掌握”的角度看,建议这样记:
第一优先级
- 快速排序
- 归并排序
- 堆排序
这三个最核心。
第二优先级
- 插入排序
- 冒泡排序
- 选择排序
这几个用于补基础、讲稳定性、讲复杂度。
第三优先级
- 计数排序
- 桶排序
- 基数排序
这几个偏“非比较排序”,常作为扩展和追问。
如果你只能背最少内容,那就至少把下面这些背熟:
- 快排模板 + 为什么随机 pivot
- 归并模板 + 为什么稳定
- 堆排模板 + 为什么从
n/2 - 1开始建堆 - 稳定性表
- 各排序复杂度对比
这样大部分排序相关面试题都能覆盖。
十九、附:一句话背诵版
- 冒泡:相邻比较,大的往后冒,稳定。
- 选择:每轮选最小放前面,不稳定。
- 插入:把当前元素插进前面有序区,稳定。
- 希尔:分组做插入,逐步缩 gap,不稳定。
- 归并:先分再并,复杂度稳定 O(n log n),稳定。
- 快排:选 pivot 分两边,平均 O(n log n),不稳定。
- 堆排:建大根堆,反复取堆顶,O(n log n),不稳定。
- 计数:统计次数后回填,适合小范围整数。
- 桶排:按区间分桶,桶内再排,适合分布均匀。
- 基数:按位稳定排序,适合位数固定的数据。