排序算法
排序算法总结
一、概念回顾
稳定性
排序算法的稳定性是指:同样大小的元素在排序之后不会改变原始的相对次序。
- 稳定性对基础类型对象来说毫无意义
- 稳定性对非基础类型对象有意义,可以保留之前的相对次序
1. 比较排序(基于元素两两比较,时间复杂度下界 O(n log n))
1. 冒泡排序
- 核心思想:重复遍历数组,依次比较相邻元素,若顺序错误则交换。每一轮把当前未排序部分的最大值”冒泡”到末尾。
- 稳定性:稳定
2. 选择排序
- 核心思想:将数组分为已排序和未排序两部分。每一轮从未排序部分中选出最小元素,与未排序部分的首元素交换位置。
- 稳定性:不稳定
3. 插入排序
- 核心思想:将数组分为已排序和未排序两部分。每一轮将未排序部分的第一个元素,插入到已排序部分的正确位置。
- 稳定性:稳定
4. 希尔排序
- 核心思想:插入排序的改进版。先将数组按间隔(gap)分组,对每组分别进行插入排序;逐步缩小间隔,最终间隔为 1 时完成全局插入排序。
- 稳定性:不稳定
5. 归并排序
- 核心思想:分治思想。将数组递归地对半拆分,直到每个子数组只有一个元素;再将两个有序子数组合并成一个更大的有序数组。
- 稳定性:稳定
6. 快速排序
- 核心思想:分治思想。选取一个基准(pivot)元素,将数组划分为小于基准、等于基准、大于基准三部分;对左右两部分递归执行相同操作。
- 稳定性:不稳定
7. 堆排序
- 核心思想:利用完全二叉堆结构。先将数组构建成最大堆,然后不断将堆顶(最大值)与末尾元素交换,缩小堆的范围并重新调整为最大堆。
- 稳定性:不稳定
2. 非比较排序(不做两两比较,时间复杂度可突破 O(n log n))
1. 计数排序
- 核心思想:遍历数组统计每个元素出现的次数,利用数组下标(天然有序性)直接算出每个元素在结果数组中的位置。
- 适用前提:元素是整数且取值范围在可接受范围内
- 稳定性:稳定
2. 桶排序
- 核心思想:将数组元素按值的范围分配到若干个”桶”中;对每个桶内部分别排序(可使用任意排序算法);最后依次将各桶的元素取出拼接。
- 稳定性:取决于桶内排序算法,通常稳定
3. 基数排序
- 核心思想:多轮按”位”排序。从低位到高位(或高位到低位),每一轮基于当前位的值将元素分配到 0~9 的桶中,再依次取出形成新序列;重复直至所有位处理完毕。
- 稳定性:稳定(要求每一轮使用稳定的桶内分配方式)
3. 常见排序算法对比总结
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 |
| 希尔排序 | O(n^1.3) | O(n²) | O(1) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 不稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 |
| 计数排序 | O(n + k) | O(n + k) | O(n + k) | 稳定 |
| 桶排序 | O(n + k) | O(n²) | O(n + k) | 视情况 |
| 基数排序 | O(d × (n + k)) | O(d × (n + k)) | O(n + k) | 稳定 |
注:n 为元素数量,k 为取值范围大小,d 为最大位数
4. Java 中常见集合的排序方式
默认排序方向
- 所有内置排序方法默认都是升序(从小到大)
- 降序可通过
Comparator.reverseOrder()或自定义比较器实现 - 基本类型数组没有直接降序方法,需转为包装类型数组或手动反转
数组排序 Arrays.sort()
- 基本类型数组(int[]、long[] 等):底层使用
DualPivotQuicksort(双轴快速排序),不稳定,默认升序 - 对象数组(Integer[]、String[] 等):底层使用
TimSort(归并排序 + 插入排序的混合算法),稳定,默认升序 - 可传入
Comparator自定义排序规则
List 排序 Collections.sort() / list.sort()
Collections.sort(list):本质调用list.sort(null),默认升序list.sort(comparator)(Java 8+):底层将 List 转为数组后调用Arrays.sort(),即 TimSort,稳定- 可传入
Comparator自定义排序规则
Stream 排序 stream.sorted()(Java 8+)
stream.sorted():按元素自然顺序(升序)排序(元素需实现Comparable)stream.sorted(comparator):传入Comparator自定义排序规则- 底层仍是 TimSort,稳定
- 排序后需通过
collect()收集为新集合,不影响原集合
自动有序集合
| 集合类 | 底层结构 | 默认排序方向 | 排序方式 |
|---|---|---|---|
TreeSet |
红黑树 | 升序 | 元素实现 Comparable 或构造时传入 Comparator |
TreeMap |
红黑树 | 升序 | Key 实现 Comparable 或构造时传入 Comparator |
PriorityQueue |
二叉堆 | 升序(小顶堆) | 默认自然顺序,可传入 Comparator |
- 插入元素时自动维护有序性,无需额外调用排序方法
Comparable vs Comparator
| 对比项 | Comparable | Comparator |
|---|---|---|
| 所属包 | java.lang |
java.util |
| 方法 | compareTo(T o) |
compare(T o1, T o2) |
| 使用方式 | 元素内部实现(自然排序) | 外部传入(定制排序) |
| 排序调用 | Collections.sort(list) / list.sort(null) |
list.sort(comparator) |
| 能否多种排序 | 只能有一种(类内部定义) | 可以有多种(外部灵活传入) |
Comparator 构造方式
以下以 Person(name, age) 类为例,按年龄升序排序:
方式一:匿名内部类(Java 7 及以前)
1 | list.sort(new Comparator<Person>() { |
方式二:Lambda 表达式(Java 8+)
1 | list.sort((o1, o2) -> o1.age - o2.age); |
方式三:方法引用(Java 8+,需类有对应方法)
1 | list.sort(Person::compareByAge); |
方式四:Comparator 静态工厂方法(Java 8+,推荐)
1 | list.sort(Comparator.comparingInt(p -> p.age)); // 按 age 升序 |
降序排序的三种写法
1 | list.sort(Comparator.reverseOrder()); // 方式一:reverseOrder() |
关键设计总结
| 场景 | 使用算法 | 稳定性 | 默认方向 |
|---|---|---|---|
Arrays.sort(基本类型[]) |
DualPivotQuicksort | 不稳定 | 升序 |
Arrays.sort(对象[]) / List.sort() |
TimSort | 稳定 | 升序 |
Stream.sorted() |
TimSort | 稳定 | 升序 |
为什么基本类型和对象类型用不同算法?
基本类型不需要稳定性(值相等无意义),双轴快排平均性能更优、空间复杂度 O(1)。
对象类型需要稳定性(保留相等元素的相对顺序),且 TimSort 在部分有序场景下性能更优。
5. 算法题中何时使用集合排序?
一句话原则
当排序能让问题的约束条件变得简单时,先排序! 排序是算法题中性价比最高的预处理手段之一,一次 O(n log n) 的排序,可能换来后续 O(n) 就能解决核心问题。
✅ 应该使用排序的典型场景
1. 去重问题
排序后相同元素相邻,通过 nums[i] != nums[i-1] 即可跳过重复,避免了 Set 的额外空间开销。
典型题:LeetCode 31.下一个排列、LeetCode 217.存在重复元素
2. 双指针 / 滑动窗口前提
双指针法的核心前提就是数组有序,否则指针移动没有方向依据。排序 + 双指针是解决”找两数/三数组合”类问题的黄金组合。
典型题:LeetCode 15.三数之和、LeetCode 11.盛最多水的容器、LeetCode 16.最接近的三数之和
3. 区间问题
按左端点或右端点排序后,区间关系变得有规律,只需一次遍历即可完成合并、插入或判断重叠。
典型题:LeetCode 56.合并区间、LeetCode 57.插入区间、LeetCode 435.无重叠区间
4. 分组 / 聚合处理
排序后相同或相似的元素聚集在一起,可以一边遍历一边统计或处理,不需要额外的 HashMap。
典型题:LeetCode 49.字母异位词分组、LeetCode 128.最长连续序列
5. 找中位数
排序后直接通过下标访问即可。但如果只需要 Top-K 不需要全排序,优先用堆(O(n log k))更优。
典型题:LeetCode 4.寻找两个正序数组的中位数
6. 贪心策略前置
很多贪心算法需要先排序才能确定”贪心顺序”——按什么顺序处理局部最优。
典型题:LeetCode 455.分发饼干、LeetCode 435.无重叠区间、LeetCode 1005.K次取反后最大化的数组和
❌ 不应该全排序的场景
| 场景 | 原因 | 更优方案 |
|---|---|---|
| 只需要 Top-K 而不需要全排序 | 全排序 O(n log n) 做了无用功 | 堆(优先队列),O(n log k) |
| 要求 O(n) 线性时间 | 排序下界 O(n log n) 不满足 | 计数排序、桶排序、快速选择 |
| 流式数据(数据量极大) | 无法一次性全部加载到内存 | 外部排序 / 堆维护 Top-K |
| 两数之和(返回下标) | 排序会打乱原始下标 | HashMap 空间换时间,O(n) |
Java 算法题实战技巧
int[] 降序排序
基本类型数组没有直接降序方法,常见做法:
1 | // 方式一:转为 Integer[](会装箱,大数据量有性能损耗) |
对 List 排序
1 | Collections.sort(list); // 升序 |
自定义对象排序
1 | // 按身高升序,身高相同按体重降序(LeetCode 406.根据身高重建队列) |
Comparator 中避免整数溢出
直接用 a - b 比较可能导致 int 溢出(如 Integer.MAX_VALUE - (-1)),推荐:
1 | // 安全写法一:用 Integer.compare |