1. 从面试八股到工程实践:为什么我们需要亲手实现排序算法?
如果你是一名Java开发者,或者正在准备Java相关的面试,那么“排序算法”这个词对你来说一定不陌生。它几乎是所有技术面试的“必考题”,从经典的冒泡、选择,到更高效的快排、归并,再到那些名字听起来就很高深的堆排序、计数排序。网上有无数篇文章、代码片段,甚至很多面试宝典里都直接给出了答案。那么,一个很自然的问题就来了:在IDE和JDK的
Collections.sort()
、
Arrays.sort()
已经如此强大和高效的今天,为什么我们还需要花时间去理解,甚至亲手实现这些排序算法?
这绝不仅仅是为了应付面试。我见过太多开发者,能背出各种排序算法的时间复杂度,但当被问到“为什么快速排序在实际应用中通常比归并排序更快?”或者“在什么场景下,时间复杂度为O(n²)的插入排序会比O(n log n)的算法更合适?”时,却只能语塞。知其然,而不知其所以然,是我们在学习技术时最大的障碍。亲手实现一遍排序算法,就像亲手拆解并组装一台精密的机械钟表。在这个过程中,你会深刻理解“比较”、“交换”、“递归分治”、“原地排序”这些概念的真实含义,你会对算法的时间、空间开销有切肤的体会,更重要的是,你能培养出一种“算法思维”——一种在面对复杂问题时,如何设计高效、优雅解决方案的底层能力。
今天,我们就抛开那些干巴巴的概念罗列,以一名一线Java工程师的视角,深入代码层面,逐一实现10种经典的排序算法。我不会只给你最终代码,而是会带你一起思考:这个算法的核心思想是什么?Java代码如何精准地表达这一思想?在实现过程中有哪些容易踩的“坑”?以及,在真实的工程项目中,它们各自的应用边界在哪里?我们从最简单、最直观的开始,逐步深入到更精巧、更高效的算法。
2. 基础排序三剑客:理解排序的基本操作
在接触更复杂的算法之前,我们必须打好基础。冒泡排序、选择排序和插入排序,被称为简单排序算法,它们的时间复杂度都是O(n²)。虽然效率不高,但它们的实现清晰地揭示了排序中最核心的两种操作: 比较 和 交换(或移动) 。理解它们,是理解所有高级排序算法的基石。
2.1 冒泡排序:最直观的“邻里交换”
冒泡排序的思想如同其名:每一轮遍历,相邻的两个元素比较,如果顺序不对就交换,这样每一轮都会将当前未排序部分的最大(或最小)元素“冒泡”到正确位置。
核心实现与思考:
public class BubbleSort {
public static void sort(int[] arr) {
if (arr == null || arr.length < 2) {
return; // 边界条件处理,好习惯从简单算法开始培养
}
int n = arr.length;
// 外层循环控制轮数,n个元素最多需要n-1轮排序
for (int i = 0; i < n - 1; i++) {
// 一个常见的优化点:记录本轮是否发生交换
boolean swapped = false;
// 内层循环进行相邻比较。注意边界是 `n - 1 - i`,因为末尾i个元素已经有序
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
// 交换arr[j]和arr[j+1]
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swapped = true; // 标记发生了交换
}
}
// 如果本轮没有发生任何交换,说明数组已经有序,可以提前结束
if (!swapped) {
break;
}
}
}
}
注意: 很多初学者在写内层循环的边界条件时,容易写成
j < n - 1,忽略了- i。这会导致即使后面的元素已经有序,程序仍然会进行无意义的比较。虽然不影响结果,但体现了对算法过程理解的细微差距。
为什么它效率低? 冒泡排序的交换操作非常频繁,即使只差一个位置,也可能需要多次交换才能“冒”上去。它的比较和交换次数都是O(n²)级别的。在几乎有序的小规模数据(比如少于50个元素)中,经过优化的冒泡排序可能因为能提前结束而表现尚可,但一旦数据量增大,性能会急剧下降。
2.2 选择排序:每次找到“最小元”
选择排序的思路更符合人类直觉:在未排序序列中找到最小(或最大)元素,存放到排序序列的起始位置,然后从剩余未排序元素中继续寻找最小元素,放到已排序序列的末尾。如此反复,直到所有元素均排序完毕。
核心实现与思考:
public class SelectionSort {
public static void sort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
// 假设当前索引i的元素就是最小值
int minIndex = i;
// 在[i+1, n-1]的区间内寻找真正的最小值索引
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j; // 更新最小值的索引,注意这里只记录索引,不交换
}
}
// 找到本轮最小值后,将其与位置i的元素交换
if (minIndex != i) { // 一个小优化:如果最小值就是自己,则无需交换
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
}
}
选择排序的特点:
它的交换次数很少,最多为
n-1
次。这是它的一个优点,特别是在交换成本很高(比如要排序的不是基本类型,而是大型对象)的场景下。但是,它的比较次数依然是O(n²),并且
它是不稳定的排序算法
。举个例子,序列
[5, 8, 5, 2, 9]
,第一轮会选择
2
和第一个
5
交换,导致两个
5
的相对顺序发生变化。
2.3 插入排序:像理扑克牌一样自然
插入排序是简单排序算法中在工程上最有价值的一个。它的工作方式像我们整理手中的扑克牌:对于未排序的数据,在已排序序列中从后向前扫描,找到相应位置并插入。
核心实现与思考:
public class InsertionSort {
public static void sort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int n = arr.length;
// 从第二个元素开始(索引1),因为第一个元素默认是有序的
for (int i = 1; i < n; i++) {
int current = arr[i]; // 当前待插入的元素
int j = i - 1; // 从当前元素的前一个位置开始比较
// 寻找current的插入位置:将比current大的元素都向后移动一位
while (j >= 0 && arr[j] > current) {
arr[j + 1] = arr[j]; // 向后移动元素
j--;
}
// 循环结束时,j指向的是第一个不大于current的元素,或者-1
// 所以current应该插入到 j+1 的位置
arr[j + 1] = current;
}
}
}
插入排序的威力所在:
- 稳定性 :它是稳定的排序算法,相等元素的相对位置不会改变。
-
对小规模或近乎有序数据极其高效
:当数组基本有序时,内层的
while循环几乎会立刻终止,时间复杂度接近O(n)。因此,它常被用作高级排序算法(如快速排序、归并排序)中,当递归到小规模子数组时的优化手段。 - 原地排序 :只需要常数级别的额外空间。
在实际的JDK实现中,
Arrays.sort()
对于对象数组(使用 TimSort)和基本类型数组(使用 Dual-Pivot QuickSort)的排序实现里,当子数组长度小于某个阈值(通常是47)时,都会转而使用插入排序。
3. 进阶排序:分治思想的典范
当数据量变大时,O(n²)的算法就力不从心了。我们需要借助“分而治之”的思想,将大问题拆解成小问题来解决。归并排序和快速排序是分治策略最著名的两个代表,它们的时间复杂度在平均和最坏情况下都能达到O(n log n)。
3.1 归并排序:稳定的“分工协作”
归并排序的核心思想非常清晰:如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。这是一个典型的递归过程。
实现的关键在于“合并”函数:
public class MergeSort {
// 对外公开的排序方法
public static void sort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int[] temp = new int[arr.length]; // 一次性分配临时数组,避免递归中反复创建
sort(arr, 0, arr.length - 1, temp);
}
// 递归排序函数
private static void sort(int[] arr, int left, int right, int[] temp) {
if (left >= right) {
return; // 递归终止条件:子数组只有一个元素或为空
}
int mid = left + (right - left) / 2; // 防止(left+right)溢出
sort(arr, left, mid, temp); // 排序左半部分
sort(arr, mid + 1, right, temp); // 排序右半部分
merge(arr, left, mid, right, temp); // 合并两个有序子数组
}
// 合并两个有序子数组 arr[left...mid] 和 arr[mid+1...right]
private static void merge(int[] arr, int left, int mid, int right, int[] temp) {
int i = left; // 左子数组起始指针
int j = mid + 1; // 右子数组起始指针
int t = 0; // 临时数组指针
// 1. 依次比较两个子数组的元素,将较小的放入temp
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) { // 注意这里是 <=,保证了排序的稳定性
temp[t++] = arr[i++];
} else {
temp[t++] = arr[j++];
}
}
// 2. 将剩余元素拷贝到temp中
while (i <= mid) {
temp[t++] = arr[i++];
}
while (j <= right) {
temp[t++] = arr[j++];
}
// 3. 将temp中的有序数据拷贝回原数组arr
t = 0;
while (left <= right) {
arr[left++] = temp[t++];
}
}
}
归并排序的深度分析:
-
稳定性
:关键在于合并时,当遇到相等元素,我们优先取左边子数组的元素(
arr[i] <= arr[j]),这保证了相等元素的原始相对顺序,所以归并排序是 稳定的 。 - 时间复杂度 :永远是O(n log n)。无论数据初始状态如何,它都需要进行log n层的分割和每一层n级别的合并操作。
- 空间复杂度 :O(n)。因为合并操作需要额外的临时数组。这也是它最大的缺点,在内存受限的环境下需要谨慎使用。
- 适用场景 :适用于链表排序(因为链表不需要像数组那样移动大量元素来腾出空间),也常用于外部排序(数据量太大,无法全部加载到内存)。
3.2 快速排序:高效的“分区治理”
快速排序,顾名思义,是实践中最快的通用排序算法。它的核心思想是“分区”:选择一个基准元素,通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
经典的 Lomuto 分区方案实现:
public class QuickSort {
public static void sort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
quickSort(arr, 0, arr.length - 1);
}
private static void quickSort(int[] arr, int low, int high) {
if (low < high) {
// partitionIndex 是分区操作后,基准元素所处的正确位置
int partitionIndex = partition(arr, low, high);
// 递归排序基准元素左边的子数组
quickSort(arr, low, partitionIndex - 1);
// 递归排序基准元素右边的子数组
quickSort(arr, partitionIndex + 1, high);
}
}
// Lomuto 分区方案
private static int partition(int[] arr, int low, int high) {
// 选择最右边的元素作为基准
int pivot = arr[high];
// i 指向小于基准的子数组的末尾
int i = low - 1;
for (int j = low; j < high; j++) {
// 如果当前元素小于或等于基准
if (arr[j] <= pivot) {
i++;
// 交换 arr[i] 和 arr[j]
swap(arr, i, j);
}
}
// 将基准元素放到正确的位置(i+1)
swap(arr, i + 1, high);
return i + 1;
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
快速排序的陷阱与优化:
- 基准选择 :上面代码简单选择最后一个元素作为基准,这在数组已经有序或逆序时,会导致分区极度不平衡,递归树退化成链表,时间复杂度恶化到O(n²)。 优化方法 :采用“三数取中法”,即取子数组的头、中、尾三个元素,将其中值作为基准,能有效避免最坏情况。
- 递归深度 :对于小数组,快速排序的递归开销可能比排序本身还大。 优化方法 :和插入排序结合,当子数组长度小于某个阈值(如10)时,改用插入排序。
- 重复元素 :当数组中有大量重复元素时,Lomuto分区方案也会导致不平衡分区。 优化方法 :使用“三路快速排序”,将数组分为“小于基准”、“等于基准”、“大于基准”三部分,能高效处理重复元素。
JDK中
Arrays.sort()
对于基本类型使用的 Dual-Pivot QuickSort(双轴快速排序)就是快速排序的一种高级变体,它选择两个基准元素,将数组分成三段,在实践中比经典单轴快排性能更好。
快速排序 vs 归并排序 :
- 速度 :在大多数情况下,快排的常数因子更小,因此通常比归并排序更快。
- 空间 :快排是原地排序(递归调用栈空间除外,可优化为O(log n)),而归并需要O(n)额外空间。
- 稳定性 :经典快排不是稳定的,而归并排序是稳定的。
- 最坏情况 :快排有O(n²)的最坏情况(可通过优化避免),归并排序最坏也是O(n log n)。
4. 线性时间排序:当数据有特殊限制时
之前讨论的算法都是基于“比较”的排序,它们的时间复杂度下界是O(n log n)。但如果待排序的数据满足某些特定条件,我们可以突破这个下界,达到O(n)的线性时间复杂度。这类算法是非基于比较的排序。
4.1 计数排序:数据范围已知的整数排序
计数排序要求输入的数据必须是有确定范围的整数。它的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。
工作原理 :
-
找出待排序数组中的最大值
max和最小值min。 -
创建一个长度为
max - min + 1的计数数组count,初始化为0。 -
遍历原数组,统计每个元素出现的次数,存入
count数组(count[arr[i] - min]++)。 -
将
count数组顺序求和,此时count[i]表示小于等于i+min的元素个数。 -
反向遍历原数组(为了保持稳定性),根据
count数组将元素放到输出数组的正确位置。
Java实现:
public class CountingSort {
public static void sort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
// 1. 找到数据的范围
int max = arr[0], min = arr[0];
for (int num : arr) {
if (num > max) max = num;
if (num < min) min = num;
}
int range = max - min + 1;
// 2. 创建并填充计数数组
int[] count = new int[range];
for (int num : arr) {
count[num - min]++; // 偏移到0-based索引
}
// 3. 将计数数组转换为位置索引(前缀和)
for (int i = 1; i < range; i++) {
count[i] += count[i - 1];
}
// 4. 创建临时输出数组,并反向遍历原数组填充
int[] output = new int[arr.length];
for (int i = arr.length - 1; i >= 0; i--) {
int num = arr[i];
int pos = count[num - min] - 1; // 计算在输出数组中的正确位置
output[pos] = num;
count[num - min]--; // 该数字的计数减一
}
// 5. 将输出数组拷贝回原数组
System.arraycopy(output, 0, arr, 0, arr.length);
}
}
适用场景与限制
:计数排序在数据范围
range
不大,且远小于数据量
n
时,效率极高(O(n+range))。例如,对百万级考生的百分制成绩进行排序。但如果数据范围很大(比如要对几十亿的ID排序),计数数组会非常庞大,浪费空间,此时就不适用了。
4.2 桶排序:将数据分到有序的桶中
桶排序是计数排序的升级版。它假设输入数据均匀分布,然后将数据分到有限数量的桶里,每个桶再分别排序(可以使用其他排序算法)。最后按顺序把每个桶里的元素列出来。
Java实现思路:
public class BucketSort {
public static void sort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int max = arr[0], min = arr[0];
for (int num : arr) {
if (num > max) max = num;
if (num < min) min = num;
}
// 1. 确定桶的数量和范围。这里简单起见,设桶数量为5。
int bucketNum = 5;
// 计算每个桶的容量范围,注意处理边界
int bucketSize = (int) Math.ceil((double)(max - min + 1) / bucketNum);
List<List<Integer>> buckets = new ArrayList<>(bucketNum);
for (int i = 0; i < bucketNum; i++) {
buckets.add(new ArrayList<>());
}
// 2. 将元素放入对应的桶中
for (int num : arr) {
// 计算元素应该放入哪个桶
int index = (num - min) / bucketSize;
// 防止最大值被算到最后一个桶之外
index = Math.min(index, bucketNum - 1);
buckets.get(index).add(num);
}
// 3. 对每个桶内部进行排序(这里使用JDK的排序,实际可用其他算法)
for (List<Integer> bucket : buckets) {
Collections.sort(bucket); // 对每个桶排序
}
// 4. 将桶中元素依次放回原数组
int idx = 0;
for (List<Integer> bucket : buckets) {
for (int num : bucket) {
arr[idx++] = num;
}
}
}
}
桶排序的性能 :当输入数据均匀分布时,每个桶内的数据量接近,时间复杂度接近O(n)。但如果所有数据都集中在一个桶里,则退化为桶内排序算法(如O(n log n))的复杂度。桶排序非常适合用于处理外部排序问题。
4.3 基数排序:按位比较的整数排序
基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。它有两种方式: 最低位优先 和 最高位优先 。通常使用LSD(Least Significant Digit first)方式。
基数排序的步骤(LSD) :
-
取得数组中的最大数,并取得其位数
maxDigit。 -
从最低位开始,依次进行“分配”和“收集”。
- 分配 :遍历数组,根据当前位的值(0-9),将元素放入对应的10个桶中。
- 收集 :按顺序将10个桶中的元素依次放回原数组。
- 重复步骤2,直到最高位。
Java实现:
public class RadixSort {
public static void sort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
// 1. 找到最大值,确定最大位数
int max = Arrays.stream(arr).max().getAsInt();
int maxDigit = getMaxDigit(max);
// 2. 创建10个桶(0-9)
List<List<Integer>> buckets = new ArrayList<>(10);
for (int i = 0; i < 10; i++) {
buckets.add(new ArrayList<>());
}
int mod = 10, div = 1;
// 3. 对每一位进行排序
for (int i = 0; i < maxDigit; i++, mod *= 10, div *= 10) {
// 分配过程
for (int num : arr) {
int bucketIndex = (num % mod) / div; // 获取当前位的数字
buckets.get(bucketIndex).add(num);
}
// 收集过程
int idx = 0;
for (List<Integer> bucket : buckets) {
for (int num : bucket) {
arr[idx++] = num;
}
bucket.clear(); // 清空桶,用于下一位排序
}
}
}
private static int getMaxDigit(int num) {
if (num == 0) return 1;
int digit = 0;
while (num != 0) {
digit++;
num /= 10;
}
return digit;
}
}
基数排序的要点 :
- 稳定性 :必须使用稳定的排序算法作为子排序(这里的桶收集过程是稳定的),否则高位排序会打乱低位已排好的顺序。
- 适用范围 :只能用于整数或能表示为整数的元素(如字符串可按字符ASCII码排序)。
- 时间复杂度 :O(d*(n+k)),其中d是最大位数,k是基数(这里是10)。当d较小,n较大时,效率很高。
5. 特殊数据结构排序:堆排序与希尔排序
5.1 堆排序:利用堆这种数据结构的排序
堆排序是一种利用“二叉堆”这种数据结构设计的排序算法。它可以看做是选择排序的一种改进。选择排序每轮要遍历所有未排序元素找最值,而堆排序利用堆的特性,可以在O(log n)的时间内找到最值。
算法步骤 :
- 建堆 :将待排序序列构造成一个大顶堆(升序排序用大顶堆)。
-
交换与调整
:将堆顶元素(最大值)与末尾元素交换,此时末尾就是最大值。然后将剩余
n-1个元素重新调整成堆。 - 重复步骤2,直到堆中只剩一个元素。
Java实现:
public class HeapSort {
public static void sort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int n = arr.length;
// 1. 构建初始大顶堆。从最后一个非叶子节点开始向上调整
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 2. 逐个将堆顶元素(最大值)交换到末尾,并重新调整堆
for (int i = n - 1; i > 0; i--) {
// 交换堆顶和当前末尾元素
swap(arr, 0, i);
// 调整剩余元素,使其满足堆的性质。注意堆的大小变为i
heapify(arr, i, 0);
}
}
// 调整以节点i为根的子树为大顶堆。n是堆的当前有效大小。
private static void heapify(int[] arr, int n, int i) {
int largest = i; // 初始化最大值为根节点
int left = 2 * i + 1;
int right = 2 * i + 2;
// 如果左子节点存在且大于根节点
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
// 如果右子节点存在且大于当前最大值
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
// 如果最大值不是根节点
if (largest != i) {
swap(arr, i, largest); // 交换根节点和最大值节点
// 递归调整被交换后的子树
heapify(arr, n, largest);
}
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
堆排序的特点 :
- 时间复杂度 :建堆过程O(n),每次调整堆O(log n),总复杂度O(n log n)。
- 空间复杂度 :O(1),原地排序。
- 稳定性 : 不稳定 。因为在堆的调整过程中,相等的元素可能会被交换到不同位置。
- 应用 :堆排序非常适合在需要实时获取最大/最小元素的场景,例如优先级队列。但在普通排序中,由于其数据访问是跳跃式的(对缓存不友好),实际性能通常不如快速排序。
5.2 希尔排序:插入排序的威力增强版
希尔排序是插入排序的一种高效改进版本,也称为缩小增量排序。它通过将原始列表分割成多个子序列,分别进行插入排序,随着增量逐渐减小,子序列越来越长,最终对整个列表进行一次插入排序。由于插入排序对近乎有序的序列效率很高,希尔排序通过前期的大步长跳跃,使元素快速移动到大致正确的位置,从而在后期获得很高的效率。
Java实现(使用希尔增量序列 n/2, n/4, ...):
public class ShellSort {
public static void sort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int n = arr.length;
// 初始增量(间隔)设为数组长度的一半,并逐步缩小
for (int gap = n / 2; gap > 0; gap /= 2) {
// 从第gap个元素开始,对其所在组进行插入排序
for (int i = gap; i < n; i++) {
int current = arr[i];
int j = i;
// 对以gap为间隔的子序列进行插入排序
while (j >= gap && arr[j - gap] > current) {
arr[j] = arr[j - gap]; // 移动元素
j -= gap;
}
arr[j] = current; // 插入元素
}
}
}
}
希尔排序的奥秘 :
- 增量序列的选择 :增量序列的选择直接影响希尔排序的效率。上面使用的是希尔原始序列(n/2^k),但还有更优的序列如Hibbard序列、Sedgewick序列等,它们能将最坏时间复杂度降到O(n^(4/3))甚至O(n log² n)。
- 不稳定排序 :由于是跳跃式移动元素,希尔排序是 不稳定 的。
- 适用场景 :希尔排序是第一个突破O(n²)的排序算法,代码简单,不需要额外内存,对于中等规模的数据表现不错。在嵌入式系统或内存敏感的环境中仍有应用价值。
6. 算法对比与工程实践选择
纸上得来终觉浅,绝知此事要躬行。我们实现了10种算法,但在真实的Java开发中,我们几乎永远不会自己写这些排序。
Arrays.sort()
和
Collections.sort()
是经过千锤百炼的工业级实现。那么,学习这些算法的意义何在?我的体会是,
知其所以然,方能做出最佳选择
。
下面这个表格总结了这10种算法的核心特性:
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 最好时间复杂度 | 空间复杂度 | 稳定性 | 核心思想 | 适用场景 |
|---|---|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(n) | O(1) | 稳定 | 相邻交换 | 教学、小规模或近乎有序数据 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 | 选择最小元 | 交换成本高、数据量小 |
| 插入排序 | O(n²) | O(n²) | O(n) | O(1) | 稳定 | 构建有序序列 | 小规模、近乎有序数据,作为高级排序的子过程 |
| 希尔排序 | O(n log n) ~ O(n²) | O(n²) | O(n log n) | O(1) | 不稳定 | 缩小增量插入 | 中等规模数据,内存受限环境 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 | 分治、合并 | 链表排序、外部排序、需要稳定性的场景 |
| 快速排序 | O(n log n) | O(n²) | O(n log n) | O(log n) | 不稳定 | 分治、分区 | 通用内部排序,JDK默认实现(基本类型) |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 | 堆数据结构 | 原地排序且对最坏时间复杂度有要求,优先级队列 |
| 计数排序 | O(n + k) | O(n + k) | O(n + k) | O(n + k) | 稳定 | 计数统计 | 数据范围k较小的整数排序(如成绩、年龄) |
| 桶排序 | O(n + k) | O(n²) | O(n) | O(n + k) | 稳定 | 分桶、子排序 | 数据均匀分布,外部排序 |
| 基数排序 | O(d*(n + k)) | O(d*(n + k)) | O(d*(n + k)) | O(n + k) | 稳定 | 按位分配收集 | 整数或定长字符串排序,位数d较小 |
在工程中如何选择?
-
默认选择
:对于Java中的对象数组或集合,直接使用
Collections.sort()或Arrays.sort()。它们会根据数据特征(大小、类型、是否部分有序)智能地选择TimSort(对象)或Dual-Pivot QuickSort(基本类型),并混合使用插入排序等优化,性能远超手写实现。 -
需要稳定性时
:如果排序后相等元素的原始顺序必须保留(例如,先按分数排序,再按交卷时间排序),那么归并排序、计数排序、桶排序、基数排序是稳定选项。
Collections.sort()使用的TimSort也是稳定的。 - 空间受限时 :如果内存非常紧张,堆排序和希尔排序是很好的原地排序选择。快速排序虽然是原地排序,但递归调用栈需要O(log n)的额外空间(可优化为迭代)。
-
数据有特殊特征时
:
- 如果数据是 小范围整数 ,计数排序是王者。
- 如果数据是 多位数整数或定长字符串 ,基数排序效率很高。
- 如果数据 基本有序 ,插入排序或TimSort(它利用了自然有序段)会非常快。
- 如果数据 重复元素很多 ,三路快速排序是更好的选择。
最后,亲手实现这些算法的最大价值,在于当你面对一个看似复杂的业务排序需求时,你能立刻在脑海中勾勒出几种可能的解决方案,并快速评估它们的优劣。比如,需要实时维护一个Top K列表?堆排序的思想可以派上用场。需要对海量日志按时间戳排序?你可能会想到外部排序,其核心就是归并。这种从原理到应用的贯通能力,才是我们学习算法的终极目标。下次面试官再问你排序算法,希望你能抛开八股,和他聊聊在真实场景下你的选型思考。

1051

被折叠的 条评论
为什么被折叠?



