「算法与数据结构」系列 Day04
写在前面
前三篇讲的冒泡、选择、插入、希尔、快速、归并、堆排序,不管思路差多远,有一件事是共同的:都要靠元素之间两两比较大小来确定谁排在前面。教科书里有个结论------任何基于比较的排序算法,最坏情况都跑不出O(nlogn),这不是经验总结,是能严格证明的数学下界。但计数排序、桶排序、基数排序偏偏能做到O(n)级别,比这个"下界"还快。它们到底是钻了什么空子?这篇就把这个下界怎么来的、这三种排序又是怎么绕开它的讲清楚。
一、是什么:不比较大小,靠别的方式确定位置
计数排序的思路很直接:既然要排序的都是整数,那就直接拿值当数组下标------开一个长度覆盖整个值域的计数数组,统计每个值出现了几次,再对计数数组做前缀和累加,一个值的累加计数就是它在最终结果里应该排到的位置区间。全程没有一次"拿两个元素比大小"。
桶排序先按值的范围把元素分散装进若干个桶,保证前面的桶里装的值一定比后面的桶小,桶与桶之间的顺序天然确定、不需要比较;每个桶内部数据量小,再用任意排序算法(通常是插入排序)把桶内排好,最后按桶的顺序把结果拼接起来。
基数排序处理的是多位数:不去比较两个数整体谁大谁小,而是从个位开始,每一轮只按"当前位的那个数字"(0~9)做一次计数排序,个位排完排十位,十位排完排百位,位数排完排序也就完成了。
二、为什么:O(nlogn)下界怎么来的,这三种又是怎么绕开的
先说这个下界是怎么证明的。任何一个基于比较的排序算法,都可以看成一棵决策树 :每个内部节点是一次"两个元素比大小"的操作,往左走还是往右走取决于比较结果,从根走到叶子的一条路径就对应一种确定的排列顺序。n个元素一共有n!种不同的排列,也就是这棵决策树至少要有n!个叶子节点。而一棵每个节点最多两个分支的二叉树,如果有n!个叶子,树的高度至少是log2(n!)------高度就是最坏情况下需要的比较次数。用斯特林公式展开log2(n!),化简出来正是O(nlogn)。这个结论跟具体用哪种比较排序算法完全无关,是所有"靠比较确定顺序"这条路子的天花板。
计数、桶、基数排序能绕开它,根本原因是它们压根没有用"比较"当作确定顺序的手段:
- 计数排序直接把元素的值当数组下标去归类计数,这是一次O(1)的随机访问,不是比较,所以它的复杂度是O(n+k)(k是值域范围),公式里没有log项。
- 桶排序把比较限制在同一个桶内部的少数元素上,桶和桶之间靠"值域范围"直接确定顺序,不需要比较;只要数据分布够均匀、桶的数量选得合适,每个桶里的元素个数是常数级,桶内排序的总代价也能摊薄到O(n)。
- 基数排序把"一次性比较整个数值大小"拆成了"按位分别做计数排序",每一位都是不含比较的O(n+基数),一共处理d位,总复杂度是O(d(n+基数)),同样没有log项。
代价也很明显:三者都在用额外空间换时间(计数数组、桶数组),而且都要求数据满足一定前提------值域不能太大(否则计数数组本身就撑爆内存)、分布不能太不均匀(否则桶排序退化)、数据得是能拆出固定位数的整数或类似结构(否则基数排序无从下手)。这也是它们没有取代比较排序、只能在特定场景里发挥优势的原因。
三、怎么用:代码实现 + 常见坑
计数排序:values当下标,但值域一大就爆内存
java
public static void countingSort(int[] arr) {
int max = arr[0], min = arr[0];
for (int num : arr) {
max = Math.max(max, num);
min = Math.min(min, num);
}
int range = max - min + 1;
int[] count = new int[range];
for (int num : arr) count[num - min]++;
// 累加计数,count[i]表示 <= (i+min) 的元素一共有多少个
for (int i = 1; i < range; i++) count[i] += count[i - 1];
int[] output = new int[arr.length];
// 从后往前填充,保证相同值元素的相对顺序不变(稳定性)
for (int i = arr.length - 1; i >= 0; i--) {
output[count[arr[i] - min] - 1] = arr[i];
count[arr[i] - min]--;
}
System.arraycopy(output, 0, arr, 0, arr.length);
}
最容易踩的坑是只看元素个数、不看值域范围 :[1, 1000000000]只有2个元素,但值域是十亿级别,计数数组count直接按这个长度分配内存,程序不是变慢而是直接内存溢出。计数排序只适合值域跨度和数据量n差不多是同一量级的场景,而不是元素个数少就一定能用。
第二个坑是从后往前填充output数组这一步不能改成从前往后,否则相同值元素的相对顺序会被反过来,稳定性直接丢失。
桶排序:桶分得不均匀就退化回插入排序的O(n²)
java
public static void bucketSort(int[] arr) {
int max = arr[0], min = arr[0];
for (int num : arr) {
max = Math.max(max, num);
min = Math.min(min, num);
}
int bucketCount = arr.length;
int range = (max - min) / bucketCount + 1;
List<List<Integer>> buckets = new ArrayList<>();
for (int i = 0; i < bucketCount; i++) buckets.add(new ArrayList<>());
for (int num : arr) {
int index = (num - min) / range;
buckets.get(index).add(num);
}
int k = 0;
for (List<Integer> bucket : buckets) {
Collections.sort(bucket); // 桶内元素少,用插入排序等简单排序即可
for (int num : bucket) arr[k++] = num;
}
}
最大的坑是数据分布不均匀导致某个桶塞满了几乎所有元素:桶排序的O(n)是建立在"每个桶里元素个数是常数级"这个假设上的,一旦分布严重倾斜(比如1000个数里999个都挤在同一个区间),这个桶内部还是要跑一次插入排序,整体复杂度退化回O(n²),跟没分桶差不多。所以用桶排序前得对数据分布有基本预期,分布未知或明显不均匀的场景不适合硬上。
基数排序:每一轮必须是稳定排序,顺序不能从高位排到低位
java
public static void radixSort(int[] arr) {
int max = Arrays.stream(arr).max().getAsInt();
for (int exp = 1; max / exp > 0; exp *= 10) {
countingSortByDigit(arr, exp);
}
}
private static void countingSortByDigit(int[] arr, int exp) {
int n = arr.length;
int[] output = new int[n];
int[] count = new int[10];
for (int num : arr) count[(num / exp) % 10]++;
for (int i = 1; i < 10; i++) count[i] += count[i - 1];
for (int i = n - 1; i >= 0; i--) {
int digit = (arr[i] / exp) % 10;
output[count[digit] - 1] = arr[i];
count[digit]--;
}
System.arraycopy(output, 0, arr, 0, n);
}
最容易被忽视的坑是:每一轮按位排序必须是稳定的,而且顺序必须是从最低位排到最高位。原因是低位排序确定的相对顺序,要在高位排序时被完整保留下来------比如个位排完,两个十位相同、个位不同的数已经按个位排好了顺序,接下来按十位排序时,如果这一轮不稳定,这两个数原本靠个位排出来的相对顺序就可能被打乱,最终结果就是错的。这也是为什么基数排序的每一位必须用计数排序(天然稳定)而不能换成不稳定的排序算法。
| 排序 | 时间复杂度 | 额外空间 | 适用前提 |
|---|---|---|---|
| 计数排序 | O(n+k) | O(k) | 值域k和数据量n同量级的整数 |
| 桶排序 | 平均O(n+k) | O(n+桶数) | 数据分布比较均匀,能估计出合理的桶范围 |
| 基数排序 | O(d(n+r)) | O(n+r) | 能拆出固定位数d的整数或定长字符串,r是基数(通常是10) |
四、面试追问
Q1:为什么说所有基于比较的排序算法,最坏情况都逃不开O(nlogn)?
因为任何比较排序都可以建模成一棵决策树:每个内部节点是一次两两比较,从根到叶子的路径对应一种确定的排列结果。n个元素有n!种排列,决策树至少要有n!个叶子;每个节点最多两个分支的二叉树,要容纳n!个叶子,树高至少是log2(n!),用斯特林公式展开后就是O(nlogn)。这是所有比较排序共同的下界,跟具体算法实现无关。
Q2:计数排序为什么能做到O(n+k)而不是O(nlogn)?
因为它没有用比较来确定顺序,而是直接把元素的值当数组下标去归类统计,是一次O(1)的随机访问操作。整个过程只遍历了原数组和计数数组各一遍,复杂度是遍历原数组的O(n)加上遍历计数数组的O(k),公式里不含任何log项。
Q3:桶排序在什么情况下会退化,退化成什么?
当数据分布严重不均匀、大量元素落进同一个桶时会退化。桶排序的效率建立在"每个桶元素个数是常数级"的假设上,一旦某个桶塞进了几乎所有元素,这个桶内部还是要跑一次桶内排序算法(通常是插入排序),最坏情况下退化成O(n²),跟直接对整个数组做插入排序没什么区别。
Q4:基数排序为什么要求每一轮按位排序必须是稳定的,而且要从低位排到高位?
因为低位排序确定的相对顺序需要在后续高位排序时被保留下来。如果某一轮不稳定,两个数在低位上已经排好的相对顺序会被这一轮打乱,最终结果就是错的。从低位到高位排是为了让每一轮都在上一轮已经排好的基础上继续细化,反过来从高位排到低位则没有这个性质,排序结果不正确。
下一篇预告
Day05 二分查找的边界陷阱:为什么写对二分比想象中难。