1. 排序算法为什么是Java面试的"必考题"
但凡参加过Java后端岗位面试的人,应该都有同感:排序算法几乎是躲不开的一关。无论是校招还是社招,面试官总喜欢从八大排序里挑一两个,让你讲讲原理、手写实现、分析时间复杂度,再追问一句"那你说说这个排序稳定吗?为什么稳定/不稳定?"
我刚工作那会儿也困惑过,觉得排序这玩意儿不是Collections.sort()一行代码就搞定了吗?为什么还要手写冒泡、快排?后来自己带团队、面别人,才慢慢想明白这件事。
面试官考排序,绝不是在为难你,而是在用最小的成本考察你的几项基本功:
- 代码功底:能不能把逻辑清晰地翻译成代码,边界条件处理得干不干净。
- 算法思维:懂不懂"分治""交换""插入""归并"这些基本思想,而不是只会调API。
- 性能意识:知不知道不同数据规模下该选什么排序,为什么JDK的
Arrays.sort()在不同场景下会用不同的排序策略。 - 语言特性掌握:Java的数组、集合、泛型、比较器,能不能熟练运用。
所以,八大排序不只是一道"面试八股文",它其实是Java基础里最能串联起"数据结构 + 算法 + 语言特性"的一块内容。这篇文章我就把自己这些年对八大排序的理解、手写实现、以及在实际项目中的选型经验,完整地梳理一遍。内容会尽量保持"面试导向 + 实战导向":先讲清楚每个排序的核心思想和实现,再补充那些真正容易踩坑的细节。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞懂两个前置概念:稳定性和时间复杂度
在动手写代码之前,有两件事必须搞清楚,不然后面看排序代码、聊排序优化,都会云里雾里。
2.1 稳定性到底是什么意思
稳定性这个概念,很多初学者会误解成"排序结果是否可靠"。其实它的定义非常具体:
如果待排序序列中存在两个相等的元素,排序前A在B前面,排序后A仍然在B前面,那么这个排序算法就是稳定的。
打个比方,假设你在学生管理系统里先按学号排好序,然后又按成绩排序。如果排序算法是稳定的,那么成绩相同的学生,依然会按照学号从小到大的顺序排列。如果是不稳定的排序,成绩相同的学生可能会"乱序",学号顺序就被打乱了。
八大排序的稳定性情况,我直接整理成一张表,方便记忆:
| 排序算法 | 稳定性 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 |
|---|---|---|---|---|
| 冒泡排序 | 稳定 | 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(nlogn) | O(nlogn) | O(n) |
| 快速排序 | 不稳定 | O(nlogn) | O(n²) | O(logn) |
| 堆排序 | 不稳定 | O(nlogn) | O(nlogn) | O(1) |
| 计数排序 | 稳定 | O(n+k) | O(n+k) | O(k) |
这里有一个很容易记混的点:选择排序为什么不稳定?
举个最简单的例子:
code复制数组:[5, 5, 3]
第一轮选择排序会找到最小的元素3,然后把它和第一个位置的5交换。结果是:
code复制[3, 5, 5]
注意,原本在前面的那个5,被换到了后面,另一个5跑到了前面。两个相等的5的相对位置发生了改变,所以选择排序不稳定。
2.2 时间复杂度不能只看平均情况
面试里经常有人背"快排平均O(nlogn)",但被问到"那快排最坏情况是什么?"就答不上来。实际上,快速排序在最坏情况下(比如每次划分都极端不平衡)会退化成O(n²)。这也是为什么实际工程中,快排的实现会加入"三数取中""随机基准"等优化手段,目的就是尽量避免最坏情况。
理解了这两个前置概念,下面开始逐个拆解八大排序。我不会只贴代码,还会讲清楚"为什么这么写""优化点在哪里""常见的坑是什么",这样你面试被追问时才能接得住。
3. 冒泡排序:最直观的交换排序,但别只会背代码
冒泡排序通常是很多人学的第一个排序算法,因为它最符合直觉:像气泡一样,大的元素逐渐"浮"到数组末尾。
3.1 核心思想与Java实现
核心思想是:每一轮从头开始,两两比较相邻元素,如果前一个比后一个大,就交换。每轮结束后,最大的元素会"冒泡"到数组末尾。重复n-1轮,数组就有序了。
java复制public static void bubbleSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr, j, j + 1);
}
}
}
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
这里有个细节:内层循环的边界是 n - 1 - i。因为每轮结束后,末尾i个元素已经有序,无需再参与比较。这个边界写错,要么出现数组越界,要么做很多无意义的比较。
3.2 冒泡排序的优化:提前退出
实际写代码时,冒泡排序最常见的优化是增加一个"是否发生交换"的标志位。如果某一轮从头到尾都没有发生任何交换,说明数组已经有序,可以直接退出循环。
java复制public static void bubbleSortOptimized(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
boolean swapped = false;
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr, j, j + 1);
swapped = true;
}
}
if (!swapped) {
break;
}
}
}
这个优化在近乎有序的数组中效果非常明显。比如数组已经有序,第一轮扫描后swapped仍然是false,直接退出,时间复杂度从O(n²)降到了O(n)。
不过我得说句实话:冒泡排序在实际项目中基本不会用来排大量数据,它更多是教学演示和面试入门。但有一个场景例外——数据量很小(比如几十个元素)且基本有序时,冒泡排序由于实现简单、常数小,反而可能比快排更快。
4. 选择排序:最简单的"选最小",但有个明显缺陷
选择排序的核心思想是:每一轮从未排序区间中找出最小元素,放到已排序区间的末尾。
4.1 实现与注意点
java复制public static void selectionSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
int minIndex = i;
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
if (minIndex != i) {
swap(arr, i, minIndex);
}
}
}
选择排序的代码比冒泡还简单,但它有个明显的缺陷:无论数组是否有序,比较次数都是固定的 n(n-1)/2 次。也就是说,即使数组已经排好序了,它依然会老老实实地做完全部比较。这一点和冒泡排序的优化版差别很大。
4.2 为什么选择排序不稳定,以及改进思路
前面已经用[5, 5, 3]的例子说明了不稳定性。这里再补充一个改进思路:既然每次找最小元素后会交换,导致不稳定,那能不能改成"找到最小元素后,把前面的元素依次后移,把最小元素插入"?这样虽然变成了一种类似插入排序的操作,但稳定性能得到保证。不过这样做就失去了选择排序"交换次数少"的优势,实际中很少这么干。
选择排序的唯一优势是交换次数最少,只有n-1次。如果交换操作的成本很高(比如元素是巨大的对象,交换是深拷贝),选择排序反而可能是合理的。
5. 插入排序:打扑克牌时你一直在用的算法
插入排序的思路,人人都在生活中用过:打扑克牌时,摸到一张新牌,你会把它插到手里已经排好序的牌堆中合适的位置。这就是插入排序。
5.1 实现与"搬移"思想
java复制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 current = arr[i];
int j = i - 1;
// 从后往前扫描,把比current大的元素往后移
while (j >= 0 && arr[j] > current) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = current;
}
}
这里要注意一个细节:我们不是"交换"元素,而是搬移。先把当前元素current保存下来,然后从后往前,遇到比current大的元素就往右挪一位。最后把current放到空出来的位置。
这个"搬移"比"交换"的效率更高,因为一次交换要三次赋值,而一次搬移只要一次赋值。
5.2 插入排序的实战价值:近乎有序的数据它最快
插入排序在实际项目中其实经常出现,尤其适合数据量小且基本有序的场景。当数组基本有序时,插入排序的时间复杂度可以降到接近O(n)。这也是为什么很多高级排序算法(比如快排、归并)在递归到子数组规模很小时,会转而使用插入排序——这是一种非常经典的优化策略,叫"小区间插入排序"。
JDK的Arrays.sort()在排序小规模数组时,底层使用的就是插入排序或者改进的插入排序(对于原始类型,用的是双轴快速排序;对于对象类型,用的是TimSort,其中就包含了插入排序的思想)。
6. 希尔排序:插入排序的"升级版",理解增量是关键
希尔排序是插入排序的改进版,核心思想是:先让数组局部有序,再逐步减小增量,最后做一次全量的插入排序。因为插入排序在数据"基本有序"时效率非常高,希尔排序就是利用这一点,通过"跳跃式"的插入排序,快速让数据逼近有序状态。
6.1 增量序列的选择
希尔排序的代码并不复杂,但增量序列的选择对性能影响很大。常用的增量序列是gap = n/2,每次减半:
java复制public static void shellSort(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;
while (j - gap >= 0 && arr[j - gap] > current) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = current;
}
}
}
gap就是间隔,arr[i]和arr[i - gap]在同一个"子序列"中。当gap=4时,数组会被分成4个子序列,每个子序列独立做插入排序。随着gap逐步减小到1,最后整个数组做一次标准的插入排序。
6.2 为什么希尔排序不稳定
因为不同子序列之间是独立排序的,相等的元素可能被分到不同的子序列中,它们之间的相对顺序无法保证。举个简单的例子:[5, 3, 5],当gap=2时,索引0和2的5在同一个子序列中,而索引1的3在另一个子序列中。如果发生移动,两个5的相对位置可能就变了。
希尔排序在日常开发中用得不多,但它体现了"化整为零、逐步逼近"的策略思想,而且在中等规模数据(几千到几万个元素)上的表现其实不错,比普通插入排序快很多。
7. 归并排序:典型的"分治"思想,稳定且性能稳定
归并排序是我个人非常喜欢的一个排序算法,因为它完美地诠释了分治思想:把一个大问题拆分成若干个小问题,解决小问题后,再合并结果。
7.1 核心框架:分 + 合
归并排序的流程分两步:
- 分:把数组从中间一分为二,递归地对左右两半排序。
- 合:将两个有序的子数组合并成一个有序数组。
java复制public static void mergeSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
mergeSort(arr, 0, arr.length - 1, new int[arr.length]);
}
private static void mergeSort(int[] arr, int left, int right, int[] temp) {
if (left >= right) {
return;
}
int mid = left + (right - left) / 2;
mergeSort(arr, left, mid, temp);
mergeSort(arr, mid + 1, right, temp);
merge(arr, left, mid, right, temp);
}
private static void merge(int[] arr, int left, int mid, int right, int[] temp) {
int i = left;
int j = mid + 1;
int t = 0;
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[t++] = arr[i++];
} else {
temp[t++] = arr[j++];
}
}
while (i <= mid) {
temp[t++] = arr[i++];
}
while (j <= right) {
temp[t++] = arr[j++];
}
// 把临时数组的结果拷回原数组
t = 0;
while (left <= right) {
arr[left++] = temp[t++];
}
}
这里注意几个细节:
- 计算
mid用的是left + (right - left) / 2,而不是(right + left) / 2,因为后者在极端情况下可能溢出(虽然Java里int相加很难溢出,但这是好习惯)。 - 合并时使用的是
<=而不是<,这样可以保证稳定性——两个相等的元素,左边的先被放入临时数组,相对顺序就不会变。 - 临时数组
temp在递归栈外只创建一次,避免频繁创建数组带来的性能开销。
7.2 归并排序的两个"硬伤":空间复杂度与性能损耗
归并排序的最大缺点是需要O(n)的额外空间。如果数据量非常大(比如几千万个元素),这份额外内存是不可忽视的。
另一个容易被忽略的问题是:递归过程中的函数调用开销和数组拷贝开销。虽然归并排序的时间复杂度是O(nlogn),但它的常数因子比快速排序大。在实际数据量较大时,归并排序通常比快排慢一些。
不过它有一个快排不具备的优势:稳定性。所以需要稳定排序的场景(比如数据是对象、排序结果需要保持某种原有顺序)下,归并排序是首选。
这里有两点非常有意思:
- Java的
Collections.sort()对对象列表的排序用的是TimSort,它的实现是"归并排序 + 插入排序"的一种混合优化。而归并排序是保证稳定性的基础。 - 外部排序(数据量太大,无法一次性载入内存)也离不开归并排序的思想:把大文件切成多块,分别排序写入磁盘,然后多路归并成一个有序文件。
8. 快速排序:实战中最常用的排序,但优化点也最多
快速排序是实际工程中应用最广泛的排序算法。它同样是分治思想,但它不是像归并排序那样"先分后合",而是"先划分,再递归"。
8.1 核心思想:Partition(分区)
快速排序的思路是:在数组中选一个基准值(pivot),通过分区操作,把小于等于基准值的元素放到左边,大于等于基准值的元素放到右边。然后对左右两个子区间递归地做同样的操作。
经过分区后,基准值最终会落在它的"正确位置"上。递归结束后,整个数组就有序了。
java复制public static void quickSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
quickSort(arr, 0, arr.length - 1);
}
private static void quickSort(int[] arr, int left, int right) {
if (left >= right) {
return;
}
int pivotIndex = partition(arr, left, right);
quickSort(arr, left, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, right);
}
private static int partition(int[] arr, int left, int right) {
// 这里先直接取最左边的元素作为基准值
int pivot = arr[left];
int i = left;
int j = right;
while (i < j) {
// 从右往左找第一个小于pivot的元素
while (i < j && arr[j] >= pivot) {
j--;
}
arr[i] = arr[j];
// 从左往右找第一个大于pivot的元素
while (i < j && arr[i] <= pivot) {
i++;
}
arr[j] = arr[i];
}
arr[i] = pivot;
return i;
}
这里使用的是一种常见的"挖坑填数"分区方式,比较好理解:
- 先把
arr[left]作为基准值保存下来,此时left位置就相当于一个"坑"。 - 从右往左找比基准值小的元素,填到坑里,此时
j位置变成新坑。 - 从左往右找比基准值大的元素,填到坑里,此时
i位置变成新坑。 - 重复2、3步,直到
i和j相遇。把基准值填入相遇的位置。
8.2 快排的致命弱点与优化手段
快排最怕的一种情况是:如果基准值选得不好,导致每次分区都极度不平衡,比如数组已经有序,每次取最左边元素作为基准值,那么每次左边区间为空,右边区间是n-1个元素,递归深度直接变成n,时间复杂度退化成O(n²),还可能导致栈溢出。
面试官很喜欢在这里挖坑:如果给你一个有序数组,让你用上面这个快排实现去排序,会发生什么?答案就是:性能严重退化,甚至抛出StackOverflowError。
实际工程中常见的优化手段有:
- 随机基准:不在每次取固定位置,而是随机选一个元素作为基准值,从概率上避免每次都选到最坏情况。
- 三数取中:取
left、mid、right三个位置的中间值作为基准值,比随机基准更稳定。 - 小区间切换插入排序:当子数组长度小于某个阈值(比如10或16)时,改用插入排序,减少递归调用开销。
- 三路快排:将数组分成"小于基准值、等于基准值、大于基准值"三个区间。这种方案非常适合处理包含大量重复元素的数组。
我在自己的项目里,快排的partition往往是这么写的:
java复制private static int partition(int[] arr, int left, int right) {
// 三数取中:left, mid, right 三个位置的中间值放到left
int mid = left + (right - left) / 2;
if (arr[left] > arr[right]) {
swap(arr, left, right);
}
if (arr[mid] > arr[right]) {
swap(arr, mid, right);
}
if (arr[mid] > arr[left]) {
swap(arr, mid, left);
}
int pivot = arr[left];
// ... 后续分区逻辑不变
}
这样能在很大程度上避免有序数组导致的性能退化。
9. 堆排序:借助"堆"这个数据结构实现的选择排序加强版
堆排序的理解门槛比前面几个高一些,因为它需要先理解"堆"这种数据结构。但如果你掌握了它,面试时的"数据结构"维度就有了一个完整的闭环。
9.1 什么是堆,为什么它可以用来排序
堆是一种完全二叉树,Java里的PriorityQueue底层就是用堆实现的。堆排序利用的是最大堆的性质:堆顶永远是整个序列中最大的元素。
于是堆排序的思路变得非常简单直接:
- 把待排序数组构建成一个最大堆。
- 把堆顶元素和堆末尾元素交换,这样最大值就被放到了数组末尾。
- 将剩余的元素重新调整成最大堆。
- 重复2、3步,直到堆中只剩下一个元素。
9.2 Java实现
java复制public static void heapSort(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, i, n);
}
// 2. 依次将堆顶元素与末尾元素交换,并调整堆
for (int i = n - 1; i > 0; i--) {
swap(arr, 0, i);
heapify(arr, 0, i);
}
}
private static void heapify(int[] arr, int i, int length) {
int largest = i;
int leftChild = 2 * i + 1;
int rightChild = 2 * i + 2;
if (leftChild < length && arr[leftChild] > arr[largest]) {
largest = leftChild;
}
if (rightChild < length && arr[rightChild] > arr[largest]) {
largest = rightChild;
}
if (largest != i) {
swap(arr, i, largest);
// 交换后可能破坏子树结构,继续下沉调整
heapify(arr, largest, length);
}
}
这里有两个容易被忽略的细节:
- 最后一个非叶子节点的索引是
n / 2 - 1。因为完全二叉树中,叶子节点占了最后大约一半的位置,从n/2 - 1开始往前遍历,就能覆盖所有非叶子节点。 heapify的递归调用。交换后,largest位置的子树结构可能被破坏,所以需要递归地对以largest为根节点的子树继续调整。
9.3 堆排序的优缺点
堆排序的优势在于:时间复杂度稳定为O(nlogn),且空间复杂度是O(1),不需要额外空间。这一点比归并排序和快排(递归栈空间)都要好。
缺点是:它是不稳定的,而且由于堆顶元素和末尾元素的交换操作,会破坏元素的相对顺序。另外,堆排序的常数因子比快排大,所以实际执行速度通常不如快排。
面试中如果被问到"有没有时间复杂度O(nlogn)且空间复杂度O(1)的排序算法",答案就是堆排序。这也是它在八大排序中不可替代的位置。
10. 计数排序:桶排序思想的特例,数据范围有限时的"作弊器"
八大排序里,前面七个都是基于"比较"的排序,理论下界是O(nlogn)(即最坏情况下比较排序至少要比较这么多次)。但计数排序不是比较排序,它借助的是"桶"的思想,可以达到O(n+k)的时间复杂度,其中k是数据范围。
10.1 适用场景与实现
计数排序的使用限制非常明确:数据必须是非负整数(或者能映射成整数),且数据范围不能太大。如果数据范围有几千万或者几个亿,计数排序就会因为辅助数组过大而不可用。
核心思路:
- 找出待排序数组的最大值
max。 - 创建一个长度为
max + 1的计数数组count。 - 遍历原数组,统计每个元素出现的次数。
- 遍历计数数组,把元素按顺序填回原数组。
java复制public static void countingSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int max = arr[0];
for (int i = 1; i < arr.length; i++) {
if (arr[i] > max) {
max = arr[i];
}
}
int[] count = new int[max + 1];
for (int num : arr) {
count[num]++;
}
int index = 0;
for (int i = 0; i < count.length; i++) {
while (count[i] > 0) {
arr[index++] = i;
count[i]--;
}
}
}
这个实现足够简单,但有一个问题:它不是稳定的。如果只是对纯数字排序,不稳定性无所谓。但如果你排序的是对象,希望保持相同值的相对顺序,就需要对计数排序做"稳定化"处理——使用前缀和数组,从原数组的末尾开始填充结果数组。
java复制public static void countingSortStable(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int max = arr[0];
int 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 的元素个数
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);
}
这里通过"最小值为偏移量"来处理负数场景,通过前缀和加"从后往前遍历"来保证稳定性,是与基础版最大的差别。
10.2 计数排序之外:基数排序与桶排序
虽然八大排序通常只包括计数排序,但面试中经常会顺带问"你还知道哪些线性时间排序?"这时候你可以提一下基数排序(按位依次排序,比如先按个位、再按十位)和桶排序(把元素分到若干个有序桶中,每个桶内部排序后再合并)。它们的核心思想和使用场景,在原理上都和计数排序有强关联。
11. 八大排序的性能对比与面试实战要点
铺垫了这么多,这里做一个系统性的总结。面试中,八大排序的对比题几乎是必考的,但很多人只背了表格,一被追问就露馅。所以我把每一个需要重点讲解的细节也一并列出来。
11.1 快速记忆表与"一句话"考点
| 排序算法 | 一句话考点 |
|---|---|
| 冒泡排序 | 相邻元素两两比较,每轮冒一个最大值;可加flag优化有序数组 |
| 选择排序 | 每轮选最小元素放到前面;不稳定,但交换次数最少 |
| 插入排序 | 像打扑克牌插牌;数据基本有序时效率极高 |
| 希尔排序 | 插入排序的改进版,通过gap分组预排序;不稳定 |
| 归并排序 | 分治思想的代表,稳定但需要O(n)额外空间 |
| 快速排序 | 分治+分区的代表,平均最快但最坏可能O(n²) |
| 堆排序 | 借助最大堆,O(nlogn)且O(1)空间,但不稳定 |
| 计数排序 | 非比较排序,O(n+k),不适合数据范围过大的场景 |
11.2 面试中常见追问及参考答案
这些追问是我在面试别人时最常问的,也是我自己当年被问过的:
问:数组长度很小(比如小于50)时,你会选什么排序?为什么?
答:插入排序或直接调用Arrays.sort()。因为数组规模很小时,O(n²)和O(nlogn)的差距并不明显,而插入排序的常数极小、实现简单,不需要额外的递归栈和空间。
问:数据量巨大,内存无法一次性装下时怎么办?
答:使用外部排序,核心思想是归并。先把大文件切分为多个能装入内存的小块,分别排序后写入磁盘,然后用多路归并的方式合并成有序的大文件。
问:Java的Arrays.sort()底层是如何选择排序算法的?
答:对于基本类型数组,使用双轴快速排序(Dual-Pivot QuickSort),因为它不需要稳定性,且速度更快;对于对象类型数组,使用TimSort(结合了归并排序和插入排序),因为需要保证稳定性。
问:什么情况下快排会退化,如何避免?
答:基准值选择不当时退化,最典型的是有序数组+固定取第一个元素。避免手段包括随机基准、三数取中、小区间切换插入排序等。
11.3 实战中的选型建议
在实际项目中,我个人的选型逻辑大致是这样的:
- 数据量小(<100):直接插入排序,代码简单,性能足够。
- 数据量中等(几百到几万):快速排序,配合三数取中优化。
- 需要稳定排序:归并排序(或直接用
Collections.sort()/Stream.sorted())。 - 内存受限且数据量大:堆排序。
- 数据是整数且范围有限:计数排序,性能远超所有比较排序。
不过大多数时候,Java程序员只要记住一条就够了:不要自己造排序轮子,优先使用Arrays.sort()和Collections.sort(),因为它们已经针对不同场景做了充分优化。手写排序算法更多是为了理解原理、应对面试,以及在极个别特殊场景下做定制优化。
12. 一份完整可运行的Demo与常见坑盘点
最后,我整理了一份完整的Java Demo代码,把八大排序放在同一个类里,便于直接复制到本地运行对比。同时也把我在学习和实际使用中踩过的坑列出来,希望大家少走弯路。
12.1 完整Demo的调用示例
java复制public class SortDemo {
public static void main(String[] args) {
int[] arr1 = {5, 3, 8, 4, 2, 7, 1, 6};
int[] arr2 = arr1.clone();
int[] arr3 = arr1.clone();
int[] arr4 = arr1.clone();
int[] arr5 = arr1.clone();
int[] arr6 = arr1.clone();
int[] arr7 = arr1.clone();
int[] arr8 = arr1.clone();
BubbleSort.bubbleSort(arr1);
SelectionSort.selectionSort(arr2);
InsertionSort.insertionSort(arr3);
ShellSort.shellSort(arr4);
MergeSort.mergeSort(arr5);
QuickSort.quickSort(arr6);
HeapSort.heapSort(arr7);
CountingSort.countingSort(arr8);
System.out.println("冒泡排序: " + Arrays.toString(arr1));
System.out.println("选择排序: " + Arrays.toString(arr2));
System.out.println("插入排序: " + Arrays.toString(arr3));
System.out.println("希尔排序: " + Arrays.toString(arr4));
System.out.println("归并排序: " + Arrays.toString(arr5));
System.out.println("快速排序: " + Arrays.toString(arr6));
System.out.println("堆排序: " + Arrays.toString(arr7));
System.out.println("计数排序: " + Arrays.toString(arr8));
}
}
实际使用时,建议用arr.clone()给每个排序算法准备独立的数组,避免前一个排序修改了数组,影响后一个排序的结果。我在最开始学习时就在这里翻过车——用同一个数组跑了八个排序算法,结果后几个排序输出结果完全一致,还以为自己写错了。
12.2 常见坑盘点
坑1:递归深度过大导致栈溢出。 归并排序和快速排序都依赖递归。当数据量达到百万级别且快排的基准选择不当时,很容易出现StackOverflowError。解决思路有:把快排改成非递归实现(用栈模拟递归),或者增加基准值选择优化。
坑2:排序忘记处理null和空数组。 很多算法实现没有在最外层判空,直接访问arr[0]就会抛NullPointerException或ArrayIndexOutOfBoundsException。虽然看起来是小问题,但实际项目中这种代码很危险。我习惯在排序方法入口统一加上if (arr == null || arr.length < 2)。
坑3:堆排序的heapify边界。 堆排序的难点在于索引计算。左孩子是2*i+1、右孩子是2*i+2,但只有在索引小于堆长度时才有效。很多人写堆排序时忘了加leftChild < length的判断,导致越界访问。
坑4:计数排序的负数场景。 基础版计数排序无法处理负数。如果数组中有负数,要么先做偏移(找到最小值,所有元素减去最小值),要么换用其他排序。我上面的稳定版实现已经处理了这个问题。
坑5:Arrays.sort()和Collections.sort()不要混用。 前者用于基本类型数组,后者用于List集合,虽然都能排序,但底层算法不同(双轴快排 vs TimSort)。如果你把List转成数组再排序,再转回List,代码能跑,但性能往往更差。
坑6:排序稳定性对对象排序很重要。 如果排序的元素是对象,且希望相同属性值的元素保持原有顺序,就不要使用不稳定的排序算法(比如选择排序、快排、堆排序),而应使用归并排序或者Java自带的Collections.sort()。
13. 最后再聊几句我的学习体会
排序算法这块内容,我前前后后接触了快十年,从最初死记硬背代码,到后来理解原理,再到面试别人时通过手写排序来考察候选人的代码风格和边界意识,每个阶段的感受都不一样。
几点真实体会分享给正在学习的朋友:
-
先别追求所有排序一把抓。 先花时间把冒泡、插入、快排、归并这四种吃透,理解"比较""交换""搬移""分治"这些核心思想。其他的排列组合,都是在这些思想之上做变化。
-
手写代码时,边界条件是重中之重。 很多人的排序"差不多能跑",但一遇到数组长度为0、1、2的测试用例就出问题。建议每次写完后,至少用空数组、单元素数组、升序数组、降序数组、含重复元素数组这五类用例自测一遍。
-
排序是理解其他高级算法的基础。 快排的
partition思想是解决"Top K""荷兰国旗问题"的基础;归并排序的分治思想能迁移到"求逆序对""链表排序";堆排序的heapify可以用在"优先级队列""TopK堆"等场景。把排序学扎实,后面的路会顺畅很多。 -
面试时,亲自动手写一遍胜过背诵十遍。 如果条件允许,面试前找一个白板或编辑器,把快排和归并各写三遍。写到能默写、能讲清每一步的用意,面试这块基本就稳了。
最后想说的是,八大排序不管是作为一个面试考点,还是一块算法基石,都值得你花时间认真过一遍。虽然日常开发中我们大概率只会调用现成的排序方法,但当你真正理解了每种排序背后的权衡与取舍,你写代码时对性能、对数据结构的选择,都会比从前更敏锐一些。希望这篇内容能帮你把这块基础打得更扎实。
