Java 插入排序:抓牌怎么排,它就怎么排
打麻将抓牌时你怎么理牌?插入排序就是那么干的
引言
插入排序(Insertion Sort)最贴近人类日常行为——手里有一副牌,新抓一张,从后往前找到合适位置插进去。
数据越有序,跑得越快。最好情况 O(n),最差 O(n²)。它不光是希尔排序的基石,更是 JDK Arrays.sort() 的核心组件之一——JDK 对小数组(< 47 个元素)默认就调用插入排序。
这篇文章,我会把插入排序讲透:
- 用 ASCII 图演示”抓牌+理牌”的完整过程
- 推导为什么”搬移元素”比”交换元素”更高效
- 列出 4 个高频易错点(尤其是
while 条件里的 > 号)
- 配套 LeetCode 147(链表插入排序)的工程实战
1. 核心思想:抓牌→理牌→再抓牌
1.1 通俗理解
想象你在打麻将,手里已经有了一堆排好序的牌:
1 2 3 4
| 手里:1万 3万 5万 7万(已排好序) 新抓:4万 操作:从右往左找,4万 应该插到 3万 和 5万 之间 结果:1万 3万 4万 5万 7万
|
1.2 ASCII 图解演示
以数组 [5, 2, 4, 6, 1, 3] 为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| 初始:
第 1 趟(i=1,key=2): 已排序部分: key = 2,j = 0 arr = 5 > 2,5 往后挪 → j = -1,退出循环 arr = 2 结果:
第 2 趟(i=2,key=4): 已排序部分: key = 4,j = 1 arr = 5 > 4,5 往后挪 → arr = 2 < 4,停止 arr = 4 结果:
第 3 趟(i=3,key=6): 已排序部分: key = 6,j = 2 arr = 5 < 6,停止 arr = 6 结果:
第 4 趟(i=4,key=1): 已排序部分: key = 1,j = 3 arr = 6 > 1,挪 → arr = 5 > 1,挪 → arr = 4 > 1,挪 → arr = 2 > 1,挪 → j = -1,退出 arr = 1 结果:
第 5 趟(i=5,key=3): 已排序部分: key = 3,j = 4 arr = 6 > 3,挪 arr = 5 > 3,挪 arr = 4 > 3,挪 arr = 2 < 3,停止 arr = 3 结果:
|
1.3 三个关键观察
- 已排序部分逐步扩大——每趟结束后,前面 i+1 个元素一定有序
- 数据越有序,挪动越少——这就是为什么”基本有序”是插入排序的最佳场景
- 搬移 vs 交换——一次搬移只移动一个元素(3 步赋值),一次交换要 3 步赋值(操作相同),但交换还会多做一次比较
2. 完整 Java 实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| import java.util.Arrays;
public class InsertionSort {
public static void insertionSort(int[] arr) { if (arr == null || arr.length < 2) { return; } int n = arr.length; for (int i = 1; i < n; i++) { int key = arr[i]; int j = i - 1; while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; } }
public static void main(String[] args) { int[] arr = {12, 11, 13, 5, 6}; System.out.println("排序前: " + Arrays.toString(arr)); insertionSort(arr); System.out.println("排序后: " + Arrays.toString(arr)); } }
|
优化空间:上述代码还可以用二分查找找插入位置,减少比较次数(但搬移次数不变),这就是二分插入排序。
3. 复杂度证明
3.1 时间复杂度
最坏情况(O(n²)):数组完全逆序
- 第 1 趟挪 0 次
- 第 2 趟挪 1 次
- …
- 第 n 趟挪 n-1 次
- 总挪动次数 = 0 + 1 + … + (n-1) = n(n-1)/2 = O(n²)
最好情况(O(n)):数组已经有序
- 每趟都比 1 次就停止
- 总比较次数 = n-1 = O(n)
平均情况(O(n²)):
3.2 空间复杂度
3.3 稳定性:稳定
- 比较条件
arr[j] > key(严格大于)
- 相同元素不会挪动
- 稳定排序
4. 四个高频易错点
易错点 1:i 从 0 开始
1 2 3 4 5
| for (int i = 0; i < n; i++) { ... }
for (int i = 1; i < n; i++) { ... }
|
易错点 2:while 条件写成 >=
1 2 3 4 5
| while (j >= 0 && arr[j] >= key) { ... }
while (j >= 0 && arr[j] > key) { ... }
|
易错点 3:忘记 j >= 0 边界检查
1 2 3 4 5 6 7 8 9 10 11
| while (arr[j] > key) { arr[j + 1] = arr[j]; j--; }
while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; }
|
关键:j >= 0 必须在前面,否则 arr[j] 会先越界。
易错点 4:忘记保存 key
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| for (int i = 1; i < n; i++) { int j = i - 1; while (j >= 0 && arr[j] > arr[i]) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = arr[i]; }
int key = arr[i]; while (j >= 0 && arr[j] > key) { ... } arr[j + 1] = key;
|
5. JDK 实战:为什么 Arrays.sort() 要用插入排序?
JDK 的 Arrays.sort() 内部对小数组(< 47 个元素)默认调用插入排序,而不是快排。原因是:
5.1 常数因子分析
对于 n=10 的数组:
- 快排:递归调用 + partition 函数 + 栈帧开销 → 实际执行可能比 O(n²) 还慢
- 插入排序:无递归、无栈帧、CPU 缓存友好
5.2 CPU 缓存命中率
插入排序对连续内存顺序访问,CPU 缓存命中率接近 100%。快排的递归分区会跳跃访问,缓存命中率低。
5.3 工程经验法则
数据量小(n < 50)→ 插入排序**
**数据量大(n > 50)→ 快排/归并
这也是为什么各大排序库(如 Python sorted()、C++ std::sort)都有类似的阈值切换。
6. 适用场景与禁区
6.1 适用场景
- 数据量小(n < 100)
- 数据基本有序:插入排序的最强战场
- 流式数据:新数据到达时插入到有序列表(在线排序)
- 链表排序:搬移操作变成改指针,效率更高
6.2 不适用场景
- 大数据量乱序:O(n²) 太慢
- 数组 + 大数据量:搬移操作开销大
- 生产环境大数据:用 JDK 的 Arrays.sort()
7. 面试常见变形
变形 1:二分插入排序
思路:用二分查找找插入位置,减少比较次数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public static void binaryInsertionSort(int[] arr) { for (int i = 1; i < arr.length; i++) { int key = arr[i]; int left = 0, right = i - 1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] > key) { right = mid - 1; } else { left = mid + 1; } } for (int j = i - 1; j >= left; j--) { arr[j + 1] = arr[j]; } arr[left] = key; } }
|
复杂度:比较 O(n log n),挪动仍是 O(n²)。**整体仍是 O(n²)**,只是减少了比较次数。
变形 2:希尔排序(分组插入排序)
思路:把数组按间隔分组,每组分别插入排序,再缩小间隔
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public static void shellSort(int[] arr) { int n = arr.length; for (int gap = n / 2; gap > 0; gap /= 2) { for (int i = gap; i < n; i++) { int key = arr[i]; int j = i - gap; while (j >= 0 && arr[j] > key) { arr[j + gap] = arr[j]; j -= gap; } arr[j + gap] = key; } } }
|
复杂度:取决于 gap 序列。Knuth 序列(1, 4, 13, 40…)下达到 O(n^1.5)。
变形 3:链表插入排序
思路:改指针而不是搬移元素
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public ListNode insertionSortList(ListNode head) { ListNode dummy = new ListNode(0); ListNode curr = head; while (curr != null) { ListNode next = curr.next; ListNode p = dummy; while (p.next != null && p.next.val < curr.val) { p = p.next; } curr.next = p.next; p.next = curr; curr = next; } return dummy.next; }
|
优点:链表插入是 O(1)(改指针),整体性能优于数组版本。
8. LeetCode 真题实战
题目 1:147. 对链表进行插入排序(中等)
描述:给定单链表的头节点 head,使用插入排序对链表进行排序。
解法:直接套用上面”链表插入排序”变形 3。
复杂度:时间 O(n²),空间 O(1)。
题目 2:912. 排序数组(中等)
插入排序解法:直接套用模板。
结果:n=50000 时超时,但能展示插入排序的边界控制能力。
总结
一句话精髓
插入 = 抓牌 → 从后往前挪 → 找到位置插入。
三个关键洞察
- 数据越有序,跑得越快——最好 O(n),这是它最强的特性
- JDK 小数组默认用它——n < 47 时插入排序实际比快排快
- 稳定性靠
> 保证——用 >= 会变成不稳定排序
下一步行动
- 手写二分插入排序,理解”查找 O(log n) + 挪动 O(n)” 的 trade-off
- 用插入排序解 LeetCode 147(链表版),体会链表插入的优势
- 在 JDK 源码
Arrays.sort() 里搜索 insertionSort,看看真实的工业代码