主题
十大经典排序算法对比
1. 为什么需要一页“横向比较”
前面的排序主章和图解页更强调“把单个算法讲透”。但真正到做题、面试、工程选型时,难点往往不是“某个算法会不会写”,而是:
- 为什么这里该选归并,不该选快排。
- 为什么这里宁可用计数排序,也不做比较排序。
- 为什么数据近乎有序时,插入排序反而不差。
- 为什么系统库里经常混合使用多种排序策略。
所以这一页专门解决“横向比较”的问题。
2. 十大经典排序算法总表
| 算法 | 平均复杂度 | 最坏复杂度 | 空间复杂度 | 稳定性 | 原地 | 核心思想 |
|---|---|---|---|---|---|---|
| 冒泡排序 | 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^2) | O(1) | 不稳定 | 是 | 分组插入,逐步缩小间隔 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 否 | 分治拆分,有序合并 |
| 快速排序 | O(n log n) | O(n^2) | 递归栈 | 不稳定 | 近似原地 | 基准划分 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 是 | 维护堆顶最值 |
| 计数排序 | O(n+k) | O(n+k) | O(k) | 稳定 | 否 | 统计频次直接定位 |
| 桶排序 | 平均 O(n+k) | 取决于桶内排序 | 取决于桶数 | 视实现而定 | 否 | 按范围分桶 |
| 基数排序 | O(d(n+r)) | O(d(n+r)) | O(n+r) | 稳定 | 否 | 按位分配,多轮稳定排序 |
说明:
k常表示值域大小或桶数。d表示位数。r表示基数,如十进制排序时的10。
3. 三类最基础的 O(n^2) 算法
3.1 冒泡排序
优点:
- 过程直观。
- 稳定。
- 已经有序时可通过提前退出优化。
缺点:
- 交换次数多。
- 数据量稍大时性能迅速变差。
适用:
- 教学演示。
- 极小规模数据。
- 想强调“局部交换累积成全局有序”的过程时。
3.2 选择排序
优点:
- 思路很干净。
- 交换次数通常少于冒泡。
缺点:
- 不稳定。
- 时间复杂度没有本质优势。
适用:
- 讲清“每轮确定一个最终位置”的思想。
- 小规模教学示例。
3.3 插入排序
优点:
- 稳定。
- 对近乎有序的数据非常友好。
- 小规模排序时常常很有竞争力。
缺点:
- 大规模无序数据下仍然是
O(n^2)。
适用:
- 数据几乎有序。
- 在线插入维护局部顺序。
- 大型混合排序里作为小区间收尾算法。
4. 希尔排序:为什么它常被叫作“改良版插入排序”
希尔排序的核心思想是:
先让相距较远的元素进行比较和插入,使整体更快接近有序;然后逐步缩小间隔,直到最后变成普通插入排序。
go
func ShellSort(nums []int) {
for gap := len(nums) / 2; gap > 0; gap /= 2 {
for i := gap; i < len(nums); i++ {
temp := nums[i]
j := i
for j >= gap && nums[j-gap] > temp {
nums[j] = nums[j-gap]
j -= gap
}
nums[j] = temp
}
}
}它的复杂度强烈依赖步长序列,所以教材里常见“平均复杂度不固定”的说法。要抓住的重点是:它并不是魔法,而是在“先粗调,再细调”。
5. 三类最核心的 O(n log n) 比较排序
5.1 归并排序
优势:
- 稳定。
- 最坏复杂度稳定在
O(n log n)。 - 非常适合链表排序、外部排序和并行拆分。
代价:
- 额外空间通常为
O(n)。
适用:
- 需要稳定性。
- 数据量较大。
- 可接受额外内存。
5.2 快速排序
优势:
- 平均性能极强。
- 缓存局部性通常较好。
- 工程里常常非常快。
代价:
- 最坏可能退化到
O(n^2)。 - 不稳定。
适用:
- 一般内存排序。
- 追求平均速度。
- 可配合随机化或三数取中优化。
5.3 堆排序
优势:
- 最坏复杂度稳定在
O(n log n)。 - 原地排序。
代价:
- 不稳定。
- 常数和缓存表现常不如快排。
适用:
- 需要原地且有稳定上界。
- 更关注理论最坏复杂度。
6. 非比较排序:什么时候可以快过 O(n log n)
很多人以为排序下界永远是 O(n log n)。这是不完整的。O(n log n) 下界针对的是比较排序。如果你能利用数值范围、位数结构或分布信息,就可以跳出比较模型。
6.1 计数排序
适用前提:
- 元素是整数。
- 值域不大。
核心思想:
- 统计每个值出现次数。
- 再按次数回写。
go
func CountingSort(nums []int, maxVal int) []int {
count := make([]int, maxVal+1)
for _, x := range nums {
count[x]++
}
out := make([]int, 0, len(nums))
for v, c := range count {
for ; c > 0; c-- {
out = append(out, v)
}
}
return out
}优势:
- 线性时间。
- 可做稳定版本。
限制:
- 值域太大时空间爆炸。
6.2 桶排序
适用前提:
- 数据分布相对均匀。
- 能按范围自然分桶。
核心思想:
- 先把元素分散到多个桶。
- 各桶内部再排。
- 按桶顺序拼回。
桶排序真正好用的前提不是“名字高级”,而是你对数据分布有把握。
6.3 基数排序
适用前提:
- 键可以分解为多个位。
- 每一位都能稳定地分配排序。
经典场景:
- 固定位数整数。
- 字符串字典序的某些变形。
基数排序最关键的概念不是“按位排”,而是:
每一轮子排序必须稳定,否则前一轮建立的低位顺序会被破坏。
7. 做题和工程里的选型建议
7.1 如果题目说“数组基本有序”
优先想到:
- 插入排序。
- 在更复杂问题里,也要考虑“局部插入代价很小”的特性。
7.2 如果题目强调“稳定排序”
优先想到:
- 插入排序。
- 归并排序。
- 计数排序 / 基数排序的稳定实现。
7.3 如果题目强调“值域很小”
优先想到:
- 计数排序。
- 桶排序。
- 基数排序。
不要机械地上快排。
7.4 如果题目强调“最坏也不能太慢”
优先想到:
- 归并排序。
- 堆排序。
7.5 如果题目强调“内存很紧”
优先想到:
- 堆排序。
- 快速排序的原地版本。
- 插入排序用于小规模。
8. 工业实现为什么常用混合排序
现实语言标准库很少完全只靠某一种排序。常见做法是混合:
- 大区间用快速排序。
- 小区间切换到插入排序。
- 递归深度过深时切换到堆排序。
这就是著名的 introsort 思路。
原因很务实:
- 快排平均快。
- 插入排序收尾小区间很高效。
- 堆排序能兜住最坏情况。
这也说明成熟工程实现关注的是整体表现,而不是迷信单一算法。
9. 最常见的误区
- 把快排当成所有场景下的默认最优。
- 认为
O(n log n)一定比O(n^2)实际更快。 - 忽视稳定性要求。
- 不区分比较排序和非比较排序。
- 只背表,不知道每个算法真正擅长的输入条件。
10. 一张“怎么选”的速查表
| 场景 | 优先考虑 |
|---|---|
| 小规模、近乎有序 | 插入排序 |
| 要稳定,且规模较大 | 归并排序 |
| 平均性能优先 | 快速排序 |
| 要原地且要最坏上界 | 堆排序 |
| 值域小、整数 | 计数排序 |
| 分布均匀、可分桶 | 桶排序 |
| 多位整数或定长键 | 基数排序 |
11. 学完这一页后应达到的标准
你不应只会回答“哪个复杂度最低”,而应能回答:
- 它为什么稳定或不稳定。
- 它为什么需要或不需要额外空间。
- 它是比较排序还是非比较排序。
- 它在哪类输入上会特别好,在哪类输入上会明显吃亏。
这才是真正能把排序算法用起来的状态。
