「算法与数据结构」系列 Day01
写在前面
冒泡、选择、插入、希尔排序,教科书里总是打包放在一起讲,理由也差不多:都是"简单排序",都不用分治、不用额外数据结构,随手就能写出来。但如果只满足于背下"这四个都是O(n²)",会漏掉一个更有意思的问题:希尔排序明明是插入排序的改进版,为什么复杂度分析起来反而更复杂、更暧昧,甚至运气不好还能退化回O(n²)?这篇就从这四个排序的核心动作出发,把这个问题讲清楚。
一、是什么:四个排序在解决同一个问题,动作却完全不同
四者要解决的问题是一样的:给定一个无序数组,原地把它排成有序。但"怎么找到该放的位置"这件事,四种排序给出了四种答案:
- 冒泡排序:相邻元素两两比较,大的一路"冒泡"到末尾,一轮下来至少确定一个元素的最终位置。
- 选择排序:每一轮从未排序区间里选出最小值,直接放到已排序区间的末尾。
- 插入排序:把当前元素在已排序区间里从后往前比较,插入到该待的位置,像打牌摸牌时理牌一样。
- 希尔排序:先按一个较大的间隔(gap)把数组分成若干组,组内做插入排序,再逐步缩小间隔,直到间隔为1退化成一次完整的插入排序。
前三种都是"暴力搬动元素",只是搬动的策略不同;希尔排序则是在插入排序外面套了一层"先粗调、再细调"的分组逻辑。
二、为什么:都绕不开O(n²),希尔排序又是怎么想跳出去的
冒泡、选择、插入排序的时间复杂度天然就是O(n²),原因是三者都要做逐对比较:数组里任意两个元素之间的相对顺序,都得靠某一次具体的比较操作来确定,n个元素两两比较的组合数量级就是O(n²),谁也逃不掉。区别只在于比较之后"做多少事":冒泡是频繁交换相邻元素,选择是先扫一遍再交换一次,插入是移动元素腾位置------但这些差异只影响常数因子,不改变量级。
希尔排序想解决的正是这个问题:插入排序有个特点,如果数组已经接近有序,效率会很高(内层while循环几乎不用移动就退出),但如果数组是逆序的,每插入一个元素都要挪到最前面,退化成最坏情况。希尔排序的思路是先用大间隔做几轮"粗排",让数组快速变得接近有序,等间隔缩小到1时,插入排序面对的已经是一个"局部有序"的数组,效率自然更高。
但这也是它复杂度分析暧昧的原因:希尔排序的时间复杂度直接取决于间隔序列(gap sequence)怎么选 。用最朴素的n/2, n/4, ..., 1(代码里最常见的写法),最坏情况仍然是O(n²);换成更精心设计的序列(比如Hibbard序列),可以做到O(n^1.3)甚至更好。也就是说,希尔排序不是"天生"比前三者快,而是给了一个可以调优的自由度------调不好,照样退化回O(n²)的圈子里。
三、怎么用:代码实现 + 常见坑
插入排序:核心是内层循环的两个条件顺序不能反
java
public static void insertionSort(int[] arr) {
for (int i = 1; i < arr.length; i++) {
int current = arr[i];
int j = i - 1;
// 注意 j >= 0 必须写在前面,短路求值避免数组越界
while (j >= 0 && arr[j] > current) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = current;
}
}
最容易踩的坑是把while (arr[j] > current && j >= 0)两个条件顺序写反------j都可能是-1了还先去访问arr[j],直接数组越界。
冒泡排序:漏掉"提前退出"就是白白多跑的轮数
java
public static void bubbleSort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
boolean swapped = false;
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swapped = true;
}
}
if (!swapped) break; // 这一轮没发生交换,说明已经有序,没必要继续
}
}
不加swapped标记的冒泡排序,即使数组已经有序,也会傻乎乎地跑完所有轮次,白白浪费O(n)次无意义的比较。
选择排序:交换次数少,但天生不稳定
java
public static void selectionSort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
int minIndex = i;
for (int j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[minIndex]) minIndex = j;
}
if (minIndex != i) {
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
}
选择排序每轮只交换一次,交换次数是三者里最少的,但这次"跨区间"的交换会破坏相同值元素原本的相对顺序,比如[5a, 3, 5b, 1]排完可能变成[1, 3, 5b, 5a]------两个5的原始先后关系换了,这就是它不稳定的原因。
希尔排序:把插入排序的移动逻辑套上gap
java
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 temp = arr[i];
int j = i;
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp;
}
}
}
和插入排序几乎一模一样,唯一区别是把相邻比较的步长1换成了gap。这里最容易忽略的坑是希尔排序不稳定:分组比较时,相同值的元素可能被分到不同组,组内排序不会跨组感知彼此的原始顺序,最终顺序就乱了。
这四种排序在Java标准库里也不是纯理论摆设:java.util.DualPivotQuicksort对长度小于47的小数组,会直接切换成插入排序,因为数据量小时O(n²)的常数因子反而比快排的分治开销更划算;Collections.sort背后的TimSort也是用二分插入排序处理长度较短的"run"。选择、冒泡排序则很少出现在生产代码里,更多是教学和面试考察基本功。
| 排序 | 平均复杂度 | 是否稳定 | 是否原地 | 核心动作 |
|---|---|---|---|---|
| 冒泡排序 | O(n²) | 稳定 | 是 | 相邻比较+交换 |
| 选择排序 | O(n²) | 不稳定 | 是 | 选最小值+单次交换 |
| 插入排序 | O(n²) | 稳定 | 是 | 已排序区间内挪位置 |
| 希尔排序 | 依赖gap序列,介于O(n²)和O(n log n)之间 | 不稳定 | 是 | 分组插入排序 |
四、面试追问
Q1:为什么说冒泡、选择、插入排序的复杂度都逃不开O(n²)?
因为三者都要靠逐对比较来确定任意两个元素之间的相对顺序,n个元素两两比较的数量级本身就是O(n²),三者的区别只在比较之后做的搬动动作不同(交换、单次交换、移位),这些差异只影响常数因子,不会改变量级。
Q2:插入排序在什么情况下效率最高,什么情况下最差?
数组已经接近有序时效率最高,因为内层while循环几乎不需要移动元素就能提前退出,接近O(n);数组完全逆序时效率最差,每插入一个元素都要移动到数组最前面,退化成最坏情况O(n²)。希尔排序正是利用了这个特点,先用大间隔让数组变得接近有序,再交给插入排序收尾。
Q3:希尔排序的时间复杂度为什么不能像其他三种一样直接给一个确定的O(n²)?
因为希尔排序的效率直接取决于间隔序列怎么选:用最朴素的n/2, n/4, ..., 1序列,最坏情况仍然是O(n²);换成设计过的序列(比如Hibbard序列)可以做到更好的量级。它不是天生比其他三种快,而是多了一个可以调优、也可能调不好的自由度。
Q4:选择排序和冒泡排序都会做交换,为什么说选择排序的交换次数更少?
冒泡排序每发现一次逆序就交换一次,最坏情况下交换次数和比较次数是同一量级;选择排序则是每一轮内层循环只记录最小值的下标,等这一轮扫描完才交换一次,所以每轮最多交换一次,总交换次数不超过n-1次,明显少于冒泡排序。
下一篇预告
Day02 分治的力量:快速排序为什么平均更快,最坏情况又是怎么产生的。