排序算法是Java学习路上绕不开的一座山。不管你是在准备校招面试,还是工作了三五年想系统补基础,这玩意儿迟早要面对。网上讲排序的文章多如牛毛,但大多数要么浅尝辄止只贴个代码,要么一上来就甩一堆晦涩的数学推导,看完等于没看。我当初复习Java排序算法时,就是靠一遍遍手写、画图、推导复杂度,才把这块硬骨头啃下来。
这篇文章是我整理的一套学习笔记,覆盖了面试和日常开发中最常碰到的排序算法,包括冒泡、选择、插入、希尔、归并、快排、堆排序,每一块都会讲清楚它的核心思想、Java实现、复杂度推导,以及实际开发中最容易翻车的坑。我会尽可能用大白话解释,让你能真正理解“为什么要这么写”,而不是死记硬背代码。
1. 排序算法全景认知:先搞清楚你学的到底是什么
很多人一上来就背代码,背完就忘,根子在于没建立起整体框架。排序算法不是一堆孤立的知识点,它是一棵有脉络的技能树。在动手写任何算法之前,搞清楚下面几个维度的分类,你后面学起来会顺畅很多。
1.1 排序算法的分类体系与复杂度对照
排序算法可以从三个维度来看:
按是否基于比较来分,有比较排序(冒泡、选择、插入、希尔、归并、快排、堆排序都属于这一类)和非比较排序(计数排序、桶排序、基数排序)。比较排序有个理论下限,时间复杂度不可能低于O(n log n),而非比较排序在某些特定场景下能突破这个限制,做到O(n + k)。
按是否原地排序来分,冒泡、选择、插入、希尔、快排、堆排序都是原地排序,它们借助常数级别的额外空间就能完成排序;归并排序是非原地排序,需要额外O(n)的辅助数组来合并两个有序子序列。
按稳定性来分,排序算法有稳定和不稳定之分。具体哪些稳定、哪些不稳定,这里直接给出一张对照表,建议熟练到能默写:
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 | 排序方式 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 | 原地 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 | 原地 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 | 原地 |
| 希尔排序 | O(n log n) ~ O(n²) | O(n²) | O(1) | 不稳定 | 原地 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 非原地 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 不稳定 | 原地 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 原地 |
这张表是排序算法的“身份证”,面试问到任何一个算法,你先把这个表里的信息报出来,就能让面试官知道你是系统学过而不是背了两段代码。
1.2 为什么排序算法是Java面试的必考点
很多初学者不理解,现在Java生态这么成熟,Arrays.sort()一把梭就完事了,为什么还要自己手写排序算法?
这里我说几句实在话。排序算法是训练算法思维最好的入门材料,它包含了几种最重要的算法思想:暴力枚举、贪心、分治、堆结构应用。更重要的是,面试官可以通过排序算法看出你代码习惯好不好——边界条件处理得是否周全,空间复杂度是否敏感,稳定性是否考虑过,这些在真实项目里都是能拉开差距的能力。
我遇到过一位面试官,他让我写快速排序,我写完后他没看代码逻辑,先问了一个问题:你的partition处理等值元素时,指针是怎么移动的?这个问题直接决定了快排的稳定性以及最坏情况的退化概率。如果平时只是背代码,这种细节一问就露馅。
2. 手撕高频排序算法:从最暴力的到最常用的
这一部分是整篇笔记的核心。我按照从易到难的顺序逐个拆解,每段都附上可以直接拿去用的Java代码,以及实现过程中需要注意的细节。
2.1 冒泡排序:算法界的Hello World
冒泡排序的思路根植于一个朴素的想法:每一轮从头到尾扫描数组,依次比较相邻的两个元素,如果顺序不对就交换。每一轮结束后,最大的元素就会像气泡一样“浮”到数组末尾,因此得名冒泡排序。
public static void bubbleSort(int[] arr) { if (arr == null || arr.length < 2) { return; } int n = arr.length; // 外层控制轮数,一共需要比较 n-1 轮 for (int i = 0; i < n - 1; i++) { // 内层每轮比较 n-1-i 次,因为最后 i 个元素已经有序 for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } }基础版本只能算及格,面试时还应该掌握两种优化手法。第一种是提前终止法:如果某一轮扫描中发现没有任何交换发生,说明数组已经完全有序,可以直接跳出循环。这里我踩过坑,写的时候以为循环条件改一下就行,实际上需要用一个flag标记每一轮是否发生了交换。
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]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; swapped = true; } } if (!swapped) { break; } } }第二种优化是记录最后交换位置,每一轮结束后记录最后一次发生交换的下标,下一轮只需要比较到这个位置即可,因为这个位置之后的元素都已经有序了。这种优化在数组后半部分已经有序的“近乎有序”场景下效果非常显著。
冒泡排序虽然效率不高,但胜在代码简单、逻辑直观,非常适合用来梳理排序算法的基本框架。实际开发中几乎不会用它来排大量数据,但是用它来理解“比较-交换”的基本范式,物超所值。
2.2 选择排序与插入排序:两个值得认真对待的“简单排序”
选择排序的思路同样很朴素:每一轮从未排序区间中找到最小的元素,然后把它放到已排序区间的末尾。重复这个过程,直到所有元素都排好。
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; } } // 如果 minIndex 不是 i,才交换 if (minIndex != i) { int temp = arr[minIndex]; arr[minIndex] = arr[i]; arr[i] = temp; } } }选择排序有一个很反直觉的特点:它的交换次数是最少的。无论数据状况如何,它恰好只交换 n-1 次,所以当交换数据的代价非常高(比如大对象),但比较代价相对低的时候,选择排序会有用武之地。但它不稳定,举个例子,数组[5, 5, 3],第一轮会把第一个5和3交换,导致两个5的相对位置改变。
插入排序是我个人非常偏爱的一个算法。它的思路和整理扑克牌一模一样:把新元素插入到已经有序的牌堆中正确的位置。
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; // 从右往左寻找插入位置 while (j >= 0 && arr[j] > current) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = current; } }插入排序在“近乎有序”的数组上表现极好,时间复杂度可以逼近O(n)。因为它省去了大量无谓的比较——一旦发现当前元素已经比左边所有元素都大,内层循环立即结束。Java的Arrays.sort在处理小规模数组时,就利用了插入排序这一特性。此外,插入排序是稳定的,这一点让它在很多需要保持相对顺序的场景中更受青睐。
2.3 希尔排序:插入排序的“跳级版”
希尔排序的作者是计算机科学界的传奇人物Donald Shell,它的核心思想是让数组中任意间隔为h的元素都是有序的,这个h被称为增量。实现上就是使用不同的增量序列,对数组进行多轮插入排序,每轮逐渐缩小增量,直到增量为1。
public static void shellSort(int[] arr) { if (arr == null || arr.length < 2) { return; } int n = arr.length; int h = n / 2; while (h >= 1) { // 对每个间隔为 h 的子序列做插入排序 for (int i = h; i < n; i++) { int current = arr[i]; int j = i - h; while (j >= 0 && arr[j] > current) { arr[j + h] = arr[j]; j -= h; } arr[j + h] = current; } h /= 2; } }希尔排序的关键在于增量序列的选择。我上面用的n/2每次除以2的序列是最经典的增量序列,实现简单,但不是最优的。Knuth增量序列h = h*3 + 1在大多数情况下表现更好,能显著减少比较和移动的次数。有个经验是,希尔排序的时间复杂度与分析增量序列有关,但一般可以认为在O(n log² n)量级,比纯插入排序快很多。
需要特别说明的是,希尔排序是不稳定的。举一个简单的例子,有两个相同的元素,如果分属不同的子序列,在子序列内部排序时可能发生前后位置的交换。面试时只要记得这个结论即可,不必过度纠结具体推导。
2.4 归并排序:分治思想的完美诠释
归并排序是我认为最容易理解、也最适合拿来讲解“分治”思想的排序算法。它的思路分两步:先分解,把数组从中间切开,递归地对左右两半排序;再合并,把两个有序的子数组合并成一个完整的有序数组。
public static void mergeSort(int[] arr) { if (arr == null || arr.length < 2) { return; } mergeSort(arr, 0, arr.length - 1); } private static void mergeSort(int[] arr, int left, int right) { if (left >= right) { return; } int mid = left + (right - left) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid + 1, right); merge(arr, left, mid, right); } private static void merge(int[] arr, int left, int mid, int right) { int[] temp = new int[right - left + 1]; int i = left, j = mid + 1, k = 0; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) { temp[k++] = arr[i++]; } else { temp[k++] = arr[j++]; } } while (i <= mid) { temp[k++] = arr[i++]; } while (j <= right) { temp[k++] = arr[j++]; } // 拷贝回原数组 System.arraycopy(temp, 0, arr, left, temp.length); }代码里有几个细节值得注意。第一,求中间位置用left + (right - left) / 2而不是(left + right) / 2,这样可以避免 left 和 right 都是很大的整数时相加溢出。第二,合并时使用<=而不是<,这保证了归并排序的稳定性,因为左边子数组的元素在遇到相等元素时会先被放入结果数组。第三,每次递归都创建一个新的临时数组会增加开销,更成熟的实现通常会在外层预分配一个等长的辅助数组,递归过程中反复使用。
归并排序的时间复杂度稳定在O(n log n),无论数据好坏,它的递归树结构决定了每次都是分成两半,递归深度是log n层,每层合并的总操作量是O(n),两者相乘就是O(n log n)。代价是额外O(n)的内存空间,这也是它作为非原地排序算法最大的局限。
归并排序有两个经典应用场景。第一个是链表排序,因为链表的随机访问很差,快排的分区操作根本施展不开,而归并排序只需要顺序访问,配合快慢指针找到链表中间节点,完全可以实现O(n log n)的链表排序。第二个是外部排序,当数据量大到内存装不下,需要把数据切分成多个小文件,分别排序后再进行多路归并,整个思路正是归并排序在工程上的典型应用。
2.5 快速排序:应用最广的排序算法
快速排序是面试考查频率最高的排序算法,没有之一。它的核心是partition操作——选定一个基准元素,把数组中小于基准的元素放到基准左边,大于基准的元素放到基准右边,然后递归地对左右两侧继续做同样的处理。
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[right]; int i = left; for (int j = left; j < right; j++) { if (arr[j] < pivot) { swap(arr, i, j); i++; } } swap(arr, i, right); return i; } private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; }我上面给出的partition实现叫Lomuto分区方案,代码最简洁,初学者最容易理解。面试如果要求写快排,写这个版本通常不会出错。但如果你想让面试官眼前一亮,可以写出Hoare分区方案,它的思想是从左右两端往中间扫描,交换反序的元素对,平均交换次数更少,在工程实现中使用得更广泛。
快排当然不是完美的。它的平均时间复杂度是O(n log n),但如果每次选的基准都是当前区间的最小值或最大值,数组被极端地切分成长度为n-1和0的两部分,递归树就退化成了一条“链”,时间复杂度跟着退化成O(n²)。为了避免这种最坏情况,工程上通常采用两种策略:
- 随机化基准:每次partition前随机选择一个元素交换到末尾,用概率手段让最坏情况几乎不可能发生。
- 三数取中:取区间最左、最中、最右三个元素的中位数作为基准,对“近乎有序”的数组特别有效。
快排虽然排序速度很快,但它是不稳定的。如果面试官问,能不能把快排改成稳定的?答案是可以,但代价是额外空间,实际上工程中没有必要这么做,因为真的需要稳定排序时直接用归并或者插入就行。
2.6 堆排序:利用堆结构实现选择排序
堆排序的思路可以理解为“升级版的选择排序”。选择排序每轮扫描找到最小值,时间复杂度O(n²);堆排序先用O(n)时间把数组构建成一个大顶堆,之后每次取出堆顶元素就能得到当前最大值,调整堆的复杂度是O(log n),整体复杂度控制在O(n log n)。
public static void heapSort(int[] arr) { if (arr == null || arr.length < 2) { return; } int n = arr.length; // 构建大顶堆 for (int i = n / 2 - 1; i >= 0; i--) { heapify(arr, n, i); } // 依次取出堆顶元素 for (int i = n - 1; i > 0; i--) { swap(arr, 0, i); heapify(arr, i, 0); } } 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); } }堆排序有几个容易写错的地方。第一,建堆的起始下标是n / 2 - 1,因为最后一个非叶子节点在这里,从它开始往前逐个堆化才能保证整棵树满足堆的性质。第二,每轮把堆顶元素和数组末尾元素交换后,末尾元素进入堆顶,新的堆顶可能不满足堆的性质,需要从它开始向下堆化,堆化的范围是当前未排序区间的长度,不能把已经“排好”的末尾元素算进去。
堆排序最大的优势是时间复杂度稳定在O(n log n),并且是原地排序,空间复杂度只有O(1),这在内存受限的场景非常宝贵。它的缺点是不稳定,并且由于堆元素在内存中跳来跳去,CPU缓存的命中率不高,实际运行速度往往不如快速排序。
3. 工程实战:Java内置排序到底是怎么工作的
手写了那么多排序,最终要在实际业务里使用Java自带的能力。很多初学者不知道Arrays.sort()背后竟然不是一个固定的排序算法。
3.1 Arrays.sort的“双轨制”运行机制
Arrays.sort(int[])对基本类型数组使用的是双轴快速排序(Dual-Pivot Quicksort),这是对经典快排的优化,它选取两个基准元素将数组分为三段,能有效提升数据量较大时的排序效率。
而Arrays.sort(Object[])使用的是TimSort,这是一种结合了归并排序和插入排序的混合算法。为什么对象数组和基本类型数组要用不同的排序算法?核心原因是稳定性的差异。对基本类型来说,两个相等的int值没有任何区别,稳定性完全没有意义;但对对象来说,可能存在“先按价格排序,再按销量排序”这样的多关键字排序需求,稳定性在业务上有实际价值,所以必须选一个稳定的排序算法。
TimSort利用了现实数据中普遍存在的“一段一段已经有序”的特性,把数据切分成一个个已经有序的run,再把这些run用归并的方式合成一个整体。对近乎有序的数据,TimSort的性能非常恐怖,这也是Java工程师们特别喜欢它的原因。
当数组长度小于某个阈值(JDK中定义为32)时,TimSort不会直接使用归并,而是改用插入排序。因为在小规模数据上,插入排序的常数系数比归并排序小很多,哪怕时间复杂度是O(n²),实际运行反而更快。这种“小数据用插入,大数据用分治”的策略,在地层算法工程中几乎是一个通行的套路。
3.2 Collections.sort与自定义Comparator的坑
Collections.sort(List)或者说List.sort()最终调用的也是Arrays.sort,盲点在于自定义Comparator时很容易踩坑。以我实际改bug的经验,最常见的两个报错是:
- 比较器不满足传递性。例如有一段逻辑,
if (a > b) return 1; else return -1;,缺少了a == b时返回0的情况。这种写法让TimSort在检测到比较结果自相矛盾时直接抛出IllegalArgumentException: Comparison method violates its general contract!。 - 比较器内部使用
hashCode作为排序字段,但多个对象可能拥有相同的hashCode,导致比较结果为0,排列顺序无法确定。
正确写法应当明确区分大于、等于、小于三种情况,并且不要依赖hashCode这类不稳定字段。规范的写法可以用Integer.compare(a, b)或Comparator.comparingInt(...),让JDK自己处理比较细节。
3.3 Stream排序与Lambda表达式的使用场景
如果你在用Java 8或更高版本,Stream排序是写起来最舒服的。实际项目中我常用到两类写法:
// 按年龄升序 list.stream().sorted(Comparator.comparingInt(User::getAge)).collect(Collectors.toList()); // 先按年龄升序,再按姓名降序 list.stream() .sorted(Comparator.comparingInt(User::getAge) .thenComparing(Comparator.comparing(User::getName).reversed())) .collect(Collectors.toList());但并行流排序要谨慎使用。parallelStream().sorted()在数据量不大时,因为线程调度和拆分的开销,可能比串行还慢;数据量非常大时,并行排序确实能利用多核优势,但最好先做性能压测,不要凭感觉。
3.4 工程排序的避坑手册
- 空值处理:默认排序规则遇到null会直接抛
NullPointerException。如果业务允许空值参与排序,必须使用Comparator.nullsFirst(...)或Comparator.nullsLast(...)。 - BigDecimal比较:
BigDecimal的equals方法会同时比较数值和精度,new BigDecimal("1.0")和new BigDecimal("1.00")用equals判断是不相等的,但用compareTo判断是相等的。排序要用compareTo而不是equals。 - 中文字符串排序:直接用
String.compareTo排序得到的是按Unicode码点排列的结果,不是拼音顺序。如果业务要求按拼音排,需要引入Collator类,比如Collator.getInstance(Locale.CHINA)。 - 排序稳定性在分页场景的意义:如果两次查询的排序字段有大量重复值,稳定排序能让相同排序字段的记录保持原来的顺序,这样翻页时不会出现同一记录在每页都出现、或者某条记录被漏掉的诡异现象。
4. 面试现场:排序算法高频问题的作答思路
这一节我把自己在面试中被问过的、以及作为面试官问过别人的排序算法高频问题整理下来,每个问题都给出参考作答方向。
4.1 复杂度推导如何做到脱口而出
很多候选人能背出快排是O(n log n),但一问“为什么”就哑火。其实推导并不难,关键是理解递归树的概念。快排每次partition把问题分成两个子问题,平均情况下子问题规模大约是n/2和n/2,递归树的深度是log n,每一层的总工作量是O(n),所以总复杂度是O(n log n)。最坏情况下两个子问题规模是0和n-1,递归树退化成一条链,深度变成n,每层的工作量加起来就是O(n²)。
归并排序类似,区别是它无论数据怎么分布,都是严格对半分,所以归并排序不存在最坏情况的退化问题。堆排序每轮的堆调整是O(log n),总共n轮,总复杂度是O(n log n),而且建堆那一步可以证明是O(n)而不是O(n log n)。
4.2 稳定性在业务选型中的实际意义
稳定性是指排序后相等元素的相对顺序和排序前保持一致。为什么业务上可能要求稳定性?举一个最常见的例子:商品列表希望先按类别分组,再按销量从高到低排。如果先按销量排序,再按类别做一次稳定排序,得到的结果就是每个类别内部都按销量降序。如果第二次排序是不稳定的,那么同类别内部的销量顺序就会被打乱,效果可想而知。
所以当面试官问“什么时候用归并排序而不是快排”,一个重要答案就是:当业务对稳定性有要求时,选择稳定排序;当内存不足以支撑O(n)额外空间时,放弃归并选择堆排序或者快排。
4.3 手撕排序算法的边界处理能力
手写排序代码时,面试官考察的绝不仅是你能不能跑通,更关注边界条件。以我多年的面试经验,下列代码习惯会明显加分:
- 数组为null或长度为0/1时,直接返回,不做无谓处理。这判断要写在方法体的第一行。
- 交换两个元素时,不引入多余临时变量可以写成
a = a ^ b; b = a ^ b; a = a ^ b;,但我不建议在业务代码里这么干,用临时变量更清晰,也不容易出错。 - 在快排partition中,用
left + (right - left) / 2而不是(left + right) / 2,这是很多面试官看细节的点。 - 循环边界统一采用左闭右闭区间
[left, right]还是左闭右开区间[left, right),想清楚再写,不要混着用。
4.4 遇到“Arrays.sort用的什么排序”怎么回答
这是Java面试八股文中的高频题目。一个八十分水平的回答应该是这样分层的:
- 基本类型数组走的是Dual-Pivot Quicksort,因为基本类型排序不需要稳定性。
- 对象数组走的是TimSort,因为需要稳定性来支持多关键字排序。
- 小规模数组统一走插入排序,因为小规模情况下插入排序的常数优势明显。
- TimSort利用了数据中已有的连续有序片段,对真实世界中近乎有序的数据表现特别好,避免了传统归并排序的冗余比较。
这个回答既覆盖底层实现,又能说明设计权衡,面试官很难再挑出毛病。
5. 排序算法的进阶技巧与避坑指南
单纯会写七种排序只是基本功,真正拉开能力差距的是你能不能把排序思想迁移到复杂问题上。这个部分分享几个我在实际项目和刷题过程中总结出来的进阶技巧。
5.1 如何用排序算法解决TopK问题
TopK问题是最典型的排序衍生问题,比如“从一亿条日志中找出访问量最大的前100个IP”。直接Arrays.sort全量排序,时间复杂度O(n log n),内存占用巨大,显然不是最优方案。
最快的解法是只维护一个大小为K的堆:
public static List<Integer> topK(int[] nums, int k) { if (nums == null || nums.length < k) { return Collections.emptyList(); } // 小顶堆,堆顶始终是最小的元素 PriorityQueue<Integer> minHeap = new PriorityQueue<>(k); for (int num : nums) { if (minHeap.size() < k) { minHeap.offer(num); } else if (num > minHeap.peek()) { minHeap.poll(); minHeap.offer(num); } } return new ArrayList<>(minHeap); }这里的核心思想不是把全部数据排序,而是时刻保留当前最大的K个元素,堆顶是最小的那个,当来了一个更大的元素时把它挤出去。时间复杂度只有O(n log K),当K远小于n时优势非常明显。如果K等于1,就是求最大值,时间复杂度O(n)。这个优化思路在面试里属于高频加分项。
5.2 大数据排序与外部排序的基本思路
当数据量超过内存容量的单机极限,常规排序算法就失灵了,需要用外部排序。思路也很直白:把超大文件切分成多个可以装入内存的小块,每块在内存中排好序后写入临时文件,最后对多个有序的临时文件做多路归并。这个多路归并过程正是归并排序在工程上的延伸。Hadoop的MapReduce、Spark的shuffle阶段,底层都蕴含着类似的思想。
5.3 排序算法的调试与验证技巧
我建议初学者刚开始手写排序时,不要一上来就写一个大的随机数组,这样出错后很难定位。一个比较稳的做法是:
- 先写一个空数组,验证不报错。
- 再写一个只包含一个元素的数组,验证边界逻辑。
- 写一个已经排好序的数组,验证是否有无谓交换。
- 写一个完全逆序的数组,验证最坏情况下的正确性。
- 最后用一个可以重复生成随机数组的测试方法,搭配一个工具方法
Arrays.equals(arr, expected)来对照结果。
我个人的习惯是,每实现一个排序算法,就跑一遍固定的小数组[3, 1, 4, 1, 5, 9, 2, 6],手动算出期望结果,再打印每轮排序过程的中间状态。这样既能验证正确性,也能直观观察每一轮数据如何移动,比干看代码容易理解得多。
6. 排序算法速查与练习建议
最后整理一份速查表,方便你在面试前快速回忆。
| 算法 | 一句话核心 | 稳定性 | 适用场景 |
|---|---|---|---|
| 冒泡排序 | 相邻两两比较,大数后移 | 稳定 | 教学排序、小规模无序数据 |
| 选择排序 | 每轮选择最小值放到正确位置 | 不稳定 | 交换代价极高的场景 |
| 插入排序 | 将新元素插入已排序区间的合适位置 | 稳定 | 近乎有序的数据、小规模数据 |
| 希尔排序 | 插入排序的增量分组优化版 | 不稳定 | 中等规模数据 |
| 归并排序 | 递归对半分,再合并两个有序子序列 | 稳定 | 外部排序、链表排序、稳定排序需求 |
| 快速排序 | 基准元素partition,递归排序 | 不稳定 | 大规模随机数据,工程领域最常用 |
| 堆排序 | 建堆+反复取堆顶 | 不稳定 | 内存受限、求TopK |
关于学习路径,我的经验是:不要试图一天之内把七种排序全部攻克。比较合理的时间安排是,第一天只写冒泡和插入,画出每一轮的数组变化;第二天写归并和快排,重点理解分治思想的递归展开过程;第三天写堆排序和希尔排序,再回头把整张复杂度表默写一遍。学完之后找一道经典题目练手,比如LeetCode的912. 排序数组,要求在不使用内置排序的情况下通过所有测试用例,这比单纯抄十遍代码有用得多。
排序算法学到什么程度算过关?我个人判定标准是:能在10分钟内不出错地写完快速排序和归并排序,能当场推导清楚三种时间复杂度,能说清每种算法的稳定性和适用场景。达到这个标准,不管是面试还是实际开发,排序这块就基本稳了。
最后分享一个临场经验:如果你在面试时突然忘了某段代码细节,不要慌,先从“这个算法的核心思想是什么”讲起,比如快排就是“分区”,归并就是“合并”,通常说着说着代码逻辑就顺出来了。排序算法不是靠背出来的,是靠理解长在脑子里的。多写几遍,写到手比脑子快,你就算真正学透了。