身边不少准备面试的朋友,技术功底其实不差,但一到手撕排序算法就发怵。要么是几种排序的实现细节记混了,要么是被问复杂度推导时卡壳。我做了几年面试官,也参加过不少技术面,最大的感受就是:排序算法这个考点,不是看你背没背过代码,而是看你能不能把它讲清楚、写明白。这篇临时抱佛脚的内容,就是把各种排序算法的核心逻辑、实现要点、面试延伸点全部精简提炼一遍,适合即将参加笔试或技术面、想在最短时间内把排序算法体系梳理清楚的开发者。
这里不会讲太多花哨的推导,重点放在“这道题面试官到底想考什么”和“怎么写才能稳”。每个算法都给出核心思想、手写实现的关键边界点、以及面试经常会追问的细节。先弄清楚排序算法的整体分类是必须的,不然聊归并排序时扯到快排的partition,整个人就乱了。
1. 排序算法的整体脉络与备考思路
1.1 十大排序算法的分类逻辑
排序算法常被分成比较排序和非比较排序两大类。比较排序包括冒泡、选择、插入、希尔、归并、快速、堆排序,它们的共同点是依赖元素之间的比较来决定相对顺序,时间复杂度下界是O(n log n)。非比较排序包括计数、基数、桶排序,这类算法通过额外空间直接在数据分布上做文章,可以突破O(n log n)的限制,但前提是数据有比较强的规律性。
面试中最常问的是快排、归并、堆排这三巨头,这个定位要心里有数。不是因为其它排序不重要,而是这三者覆盖了分治、递归、数据结构三大核心考点。冒泡、选择、插入偶尔会作为热身题或简单笔试题出现,希尔排序和桶排序系列的三种非比较排序则更多出现在进阶追问里,比如让你讲讲“有没有比O(n log n)更快的排序”。
1.2 复杂度、稳定性、空间复杂度速查表
考排序必定要背三个维度:时间复杂度(平均/最坏/最好)、空间复杂度、稳定性。稳定性的含义简单说就是两个相等的元素排序后相对顺序不能被改变。
| 排序算法 | 平均时间 | 最坏时间 | 最好时间 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n^2) | O(n^2) | O(n) | O(1) | 稳定 |
| 选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不稳定 |
| 插入排序 | O(n^2) | O(n^2) | O(n) | O(1) | 稳定 |
| 希尔排序 | O(n^1.3) | O(n^2) | O(n) | O(1) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
| 快速排序 | O(n log n) | O(n^2) | O(n log n) | O(log n) | 不稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 |
| 计数排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 稳定 |
| 基数排序 | O(n*k) | O(n*k) | O(n*k) | O(n+k) | 稳定 |
| 桶排序 | O(n) | O(n^2) | O(n) | O(n) | 稳定 |
这张表建议打印出来反复默写几遍。面试官经常喜欢用“快排最坏情况为什么退化成O(n^2)”或者“堆排序为什么不稳定”这种问题来判断候选人是真理解还是死背书。
1.3 备考的顺序建议
如果只剩三天就要面试,比较推荐这样的复习节奏:第一天把快排、归并、堆排吃透,因为这是最高频考点,三者都要能手写出来。第二天把冒泡、选择、插入和希尔过一遍,理解它们之间的关系,比如插入排序在近乎有序的数据上表现极好,希尔排序是插入排序的改进版。第三天把三种非比较排序的原理理清楚,重点是搞清楚它们各自适用什么场景。
这个顺序背后的逻辑是:先拿核心难点,再用区分度高的场景题去补充。很多人临时抱佛脚会犯一个错误,就是每个算法都浅尝辄止,结果一个都写不完整。我见过太多候选人跟我说“快排我看懂了但写不出来”,问题就出在练习量不够,光看不练等于没看。
2. 高频核心排序:快排、归并、堆排的拆解与手写细节
2.1 快速排序:分治思想的典范
快排的核心只有两步:选取基准pivot,然后partition,把比pivot小的放左边,比pivot大的放右边,再对左右递归下去。
写法上最简单的是用额外数组的版本,但面试时写这种版本基本等于告诉面试官你还没吃透。标准写法是原地partition,用双指针或者单指针的方式,在O(n)时间内完成一趟划分。我常用的写法是取最右边的元素作为pivot,用两个指针i和j,i指向小于pivot区域的末尾,j遍历未划分区域。当发现arr[j] < pivot,就i++然后交换arr[i]和arr[j],最后把pivot和arr[i+1]交换。
边界条件要格外注意:递归的终止条件是left >= right,而不是left > right。原因很简单,当区间只剩一个元素或者空区间时就不需要再递归了。还有一个很多新手会忽略的点,如果pivot选择固定位置,比如总是取最右边,那么在数组已经有序的情况下,每次划分都极度不均匀,递归深度会达到n,导致栈溢出。
def quick_sort(arr, left, right): if left >= right: return pivot = arr[right] i = left - 1 for j in range(left, right): if arr[j] < pivot: i += 1 arr[i], arr[j] = arr[j], arr[i] arr[i + 1], arr[right] = arr[right], arr[i + 1] quick_sort(arr, left, i) quick_sort(arr, i + 2, right)2.2 快速排序的稳定性与优化手段
快排是不稳定的,这个问题很常考。举例说明:数组[5(a), 3, 5(b)],其中5(a)在前,5(b)在后。假如pivot取的是第一个5,partition完成后,两个5的位置其实不会交换,但这个例子无法说明不稳定。换个情况,数组[3, 5(a), 2, 5(b)],pivot选最右边的5(b),partition之后5(b)被换到中间的某个位置,5(a)仍然在左侧,但两者相对顺序被破坏。
如果问快排怎么保证稳定性,常规做法是用额外数组做稳定partition,代价是空间复杂度上升。但一般情况下没有人会追求稳定的快排,直接答“为了稳定性我们会选择归并排序”是比较体面的回答。
快排的优化手段面试里也可能问到。简单说三点:一是随机化pivot选择,避免最坏情况;二是小区间用插入排序,因为递归调用有开销,在小规模数据上插入排序反而更快;三是三路快排,把等于pivot的元素单独放中间,解决大重复数据集上性能退化的问题。
2.3 归并排序:稳定且复杂度恒定
归并排序的核心思想是先递归地把数组分成两半,然后合并两个有序数组。时间复杂度在任何情况下都是O(n log n),这也是它能保持稳定的基础。
合并两个有序数组时的关键操作是:需要额外的辅助数组,空间复杂度为O(n)。有些资料说归并排序空间复杂度是O(log n),那是把递归栈算进去的严格分析,但实际应用中新开辅助数组才是主要的空间消耗,所以面试时直接说O(n)最稳。
归并排序在面试中还有一个超高频的应用场景,就是求逆序对的数量。给定数组,统计i < j且arr[i] > arr[j]的数对个数,归并排序在合并阶段天然就能统计逆序对:当左侧的数大于右侧的数时,说明左侧从该位置到mid的所有数都比右侧那个数大,逆序对数量要加上mid - i + 1。这个点如果问到了,写起来也就是在归并基础上多了几行,却能展示出你对归并过程的理解深度。
def merge_sort(arr): if len(arr) <= 1: return arr mid = len(arr) // 2 left = merge_sort(arr[:mid]) right = merge_sort(arr[mid:]) return merge(left, right) def merge(left, right): result = [] i = j = 0 while i < len(left) and j < len(right): if left[i] <= right[j]: result.append(left[i]) i += 1 else: result.append(right[j]) j += 1 result.extend(left[i:]) result.extend(right[j:]) return result2.4 堆排序:数据结构和排序的结合
堆排序在面试中出现的频率也很高,因为它同时考核了堆这种数据结构。核心思路分两步:先build max heap,把数组调整成大顶堆;然后不断把堆顶元素和当前末尾元素交换,堆的大小减一,再用sift down维护堆性质。
堆排序在实现上最大的坑是索引问题:根节点下标从0开始的话,左孩子是2i+1,右孩子是2i+2,最后一个非叶子节点的下标是n//2 - 1。如果从1开始建堆,左右孩子是2i和2i+1,最后一个非叶子结点是n//2。这个约定必须从一开始就想清楚,不然写出来的代码越调越乱。
堆排序不稳定这个性质也是高频考点,原因很简单:堆顶元素被换到末尾的过程中,会跨过相同值的其它元素。举个例子就明白了,数组[5, 5]建堆之后,堆顶5被换到末尾去,两个5的相对顺序就被颠倒了。
堆排序的代码量不小,而且边界条件多,建议面试前至少独立默写三遍以上。一个常见的错误是忘了在heapify前记录原数组长度,交换后使用错误的范围进行sift down。
def heap_sort(arr): n = len(arr) for i in range(n // 2 - 1, -1, -1): sift_down(arr, i, n) for i in range(n - 1, 0, -1): arr[0], arr[i] = arr[i], arr[0] sift_down(arr, 0, i) def sift_down(arr, i, n): largest = i left = 2 * i + 1 right = 2 * i + 2 if left < n and arr[left] > arr[largest]: largest = left if right < n and arr[right] > arr[largest]: largest = right if largest != i: arr[i], arr[largest] = arr[largest], arr[i] sift_down(arr, largest, n)3. 基础排序与进阶排序:查漏补缺
3.1 冒泡、选择、插入:简单但不可轻视
这三种排序虽然效率一般,但手写代码快,且经常作为热身题。比如面试官可能会让你“写个排序,要求O(1)空间”,其实选插入排序或选择排序都可以,但你要能说出为什么。
冒泡排序尤其重要的是加了一个“是否交换过”的标志位优化:如果一轮遍历中没有发生任何交换,说明已经有序,直接结束。这样排序一个几乎有序的数组时,最好情况可以达到O(n)。
def bubble_sort(arr): n = len(arr) for i in range(n): swapped = False for j in range(n - i - 1): if arr[j] > arr[j + 1]: arr[j], arr[j + 1] = arr[j + 1], arr[j] swapped = True if not swapped: break选择排序好写好记,核心思想是每一轮找到剩余元素中的最小值,和当前位置交换。但它不稳定,原因在于交换可能把相同元素的相对顺序打乱。比如数组[6, 5, 6, 3],第一轮找到3和第一个6交换,两个6的相对顺序就发生变化了。
插入排序的逻辑其实每个人都会,就像摸牌时把新牌插入到已排序的手牌中。它特别适合“几乎有序”的数据,因为大部分时候只需要移动少量元素。如果你面试中被要求实现一个对几乎有序数组排序的最优算法,插入排序就是正确答案之一。还有,在快排的递归中,当子问题规模小于某个阈值时改用插入排序,这个小优化很多面试官都认可。
3.2 希尔排序:插入排序的升级版
希尔排序在面试中通常不会被要求完整实现,但要能说出它的核心思路:先对相隔一定gap的元素做插入排序,逐步缩小gap,直到gap变成1,最后做一次全数组插入排序。
为什么希尔排序能比直接插入排序快?因为它利用了插入排序“在数据几乎有序时效率极高”的特性。通过大步长的分组插入,先把整体杂乱程度降下来,最后的小步长排序成本就低了很多。gap序列的选择会影响复杂度,常用的是每次gap = gap // 2,最终能达到平均O(n^1.3)左右的时间复杂度,这个复杂度不是一个严格推导出来的值,所以面试中只答“低于O(n^2),具体取决于gap序列”就够用。
希尔排序是不稳定的,也不要求掌握具体实现细节,但至少要能手动模拟一遍gap=5,3,1的排序过程。很多面试官会用一个小数组让你手动走一遍,考察你是否真的理解这个“跳跃式插入”是怎么运作的。
3.3 计数排序与桶排序:空间换时间的典型
计数排序的原理非常巧妙:如果数据取值范围有限,直接用数组下标把每个值出现次数记录下来,然后按顺序输出。时间复杂度和数据范围k有关,是O(n+k),空间复杂度也是O(k)。
它的适用条件是数据都是整数且范围不大,不适用于负数或浮点数,如果要处理负数就需要加偏移量。
def counting_sort(arr): if not arr: return [] max_val = max(arr) min_val = min(arr) count = [0] * (max_val - min_val + 1) offset = -min_val for num in arr: count[num + offset] += 1 result = [] for i, c in enumerate(count): result.extend([i - offset] * c) return result桶排序则可以看成计数排序的推广:把数据按范围划分到若干个桶中,每个桶内部用其它排序算法排序,最后把所有桶连接起来。桶排序的时间复杂度取决于桶的数量和桶内数据的分布,如果数据均匀分布且桶数量合适,可以达到O(n)。但最坏情况下所有数据都落在同一个桶里,退化为桶内排序的时间复杂度,也就是O(n^2)。
面试时如果被问到“如何给100万个年龄数据排序”,正确答案不是快排,而是计数排序,因为年龄范围只有0到150,用计数排序的时间复杂度是O(n),会得到加分。这种场景化的问题,考察的正是你对算法适用范围的判断力。
3.4 基数排序:逐位处理的排序思路
基数排序的原理是按元素的各个位依次进行排序。以整数为例,先按个位数排序,再按十位数排序,最后按百位数排序。每一位的排序必须使用稳定排序(常用计数排序),否则高位排序会破坏低位已经排好的顺序。
基数排序的时间复杂度是O(nk),k是数字的位数,空间复杂度是O(n+k)。它适合处理数据位数相对稳定且规模较大的场景。面试中的典型问题是:给10万个手机号排序,如果用快排复杂度是O(n log n),用基数排序则是O(n11),位数固定的话可以看作O(n)。
需要注意的是,基数排序并不只适用于整数,字符串也可以。比如对一批短字符串按字典序排序,按字符串从低位到高位做基数排序是可行的。这个思路稍微了解就好,一般不会要求现场实现。
4. 排序算法面试的高频追问与手撕场景
4.1 手撕代码时的几个致命细节
手写排序时我见过最多的翻车现场,不是思路不懂,而是一些细节上的低级错误。下面这几个点,默写代码前一定要自查:
第一,递归里自己在写边界条件时,索引很容易越界或漏元素。建议每次写递归前先在代码旁边画一条线段,标清left和right分别指向哪。比如归并排序的递归出口是left >= right,很多人在只有一个元素时没有返回,就会无限递归。
第二,交换元素和比较元素混淆。堆排序中heapify是比较和交换的组合,快排中也是同样的逻辑。一定要搞清楚哪些地方是“比较”,哪些地方是“交换”,compare的左右操作数位置错了,排序出来的数组顺序就是反的。
第三,用Python写时要注意切片带来的拷贝开销。很多初学者写归并排序直接用arr[:mid],这在面试中是可以接受的写法,但如果你说完时间复杂度是O(n log n),就要意识到切片让空间复杂度变成了O(n log n),因为每一层递归都拷贝了数组。真正严格的原地归并实现需要用临时数组加下标索引的方式,避免切片拷贝。
def merge_sort_inplace(arr, left, right, temp): if left >= right: return mid = (left + right) // 2 merge_sort_inplace(arr, left, mid, temp) merge_sort_inplace(arr, mid + 1, right, temp) i, j = left, mid + 1 k = left while i <= mid and j <= right: if arr[i] <= arr[j]: temp[k] = arr[i] i += 1 else: temp[k] = arr[j] j += 1 k += 1 while i <= mid: temp[k] = arr[i] i += 1 k += 1 while j <= right: temp[k] = arr[j] j += 1 k += 1 for idx in range(left, right + 1): arr[idx] = temp[idx]这个版本在面试中的加分效果非常明显,因为大多数候选人只会写切片版本,而你能写出下标控制版本,说明对归并过程的理解更扎实。
4.2 排序算法与二分查找的组合
面试题中有一类“排序数组相关查询”的题目,底层离不开排序。经典场景是:一个有序数组,查找某个目标值的位置。快速写出二分查找不是难事,但很多人会卡在边界条件的处理上。
二分查找的核心是维护一个“搜索区间”,并保证循环不变量。常见写法有两种:左闭右闭区间写法[left, right]和左闭右开区间写法[left, right)。两种写法对应的判空逻辑、mid取整方式各有不同,面试时如果混着写,很容易出现死循环或者漏查元素。
排序之后再做二分,这种题型的价值在于提醒我们:排序往往不是终点,而是为后续的高效查询服务的。比如你给数据排好序,然后频繁进行查找、取前K大、去重、合并区间等操作,复杂度会大幅下降。不过临阵磨枪阶段,能把排序本身写对才是第一优先级。
4.3 TopK问题:排序算法的间接应用
TopK(找前K大的元素)是从排序衍生出来的一个经典面试题。常规思路是直接排序然后取前K项,时间复杂度O(n log n)。但实际上面试官更期望你答出“不需要完整排序”的思路。
最高效的解法是快排的partition思路:每轮partition之后,pivot的最终位置pos是确定的。如果pos正好等于K-1,说明前K个元素已经到位;如果pos小于K-1,则只需要在右侧继续找剩下的元素;如果pos大于K-1,则只需要在左侧缩小范围。平均时间复杂度是O(n),最坏情况退化为O(n^2)。
一定要记住:TopK不适合用堆排序的完整版思路来解。真正用堆的解法是维护一个大小为K的小顶堆,遍历数据,只要当前元素大于堆顶就替换并调整,最终堆内K个元素就是前K大。这个解法的时间复杂度是O(n log K),空间复杂度是O(K),在大数据量的场景下非常实用,因为不需要一次性把全部数据加载到内存。
4.4 稳定性在面试题中的实际价值
面试官问稳定性的目的,不只是考察记忆力,而是看你会不会在实际项目中选对排序方法。比较典型的例子是“在按照分数从高到低排序的同时,希望同分的学生按学号排序”,这个问题用稳定排序只需要先按学号排序,再按分数排序,两次排序就能得到正确答案。如果用的是不稳定排序,第二次按分数排的时候会打乱同分学生的学号顺序。
再比如分布式或者数据库场景中,多字段排序通常也是靠稳定排序逐级实现。理解这点以后,你就不会在需要用稳定排序时错选快排了。当被问到“Java或者Python中默认排序是什么”时,Java的Arrays.sort对对象数组使用归并排序(TimSort),对基本类型数组使用双轴快排,Python的sorted用的是Timsort。这些细节如果对方问到,能答出来会显得你平时挺关注语言底层的实现。
5. 快速记忆口诀与临场爆发技巧
5.1 一句口诀串起十大排序
临时抱佛脚最需要的就是高效记忆。我自己的记忆方法是用一句话把十大算法的名字串起来:插冒选,希归快堆,计基桶。然后给每个字配上它的最强特征:
- 插:插入,摸牌,几乎有序时快
- 冒:冒泡,相邻交换,可加标志位
- 选:选择,每轮挑最小,不稳定
- 希:希尔,分组插排,gap递减
- 归:归并,分治合并,稳定O(n log n)
- 快:快排,pivot划分,平均O(n log n)
- 堆:堆排,建堆+反复取顶,O(1)空间
- 计:计数,数组下标统计,适合小范围整数
- 基:基数,按位排序,需要稳定排序辅助
- 桶:桶排,分桶加内排,适合均匀分布
把十个字记住以后,每一类内部再联想它最关键的一句话。面试被问到具体某个算法的时候,就在心里默念“这个名字等于哪条核心特征”,基本不会哑火。
5.2 面试现场的代码节奏把控
手撕代码时很多候选人会在一个细节上纠结太久,导致后面时间不够。我的建议是写代码之前先用一两句话告诉面试官你的整体思路,然后开始写。中间不要完全不说话,也尽量不要每写一行就解释一行。正确的节奏是“写几步,简单说一句在做什么”,这样面试官能感知到你的思路是清晰的。
代码写完之后不要急着说“写完了”,一定要自己在脑内跑一个测试用例,很少有人能一次写对。比如快排跑[5, 2, 9, 1, 5]这种含重复元素的数组,验证一下partition之后pivot的位置是否正确,递归是否能正常结束。我自己的习惯是先测三元素的小数组,再测含重复元素的数组,最后测已经有序的数组。这三类用例覆盖了大部分手写时会犯的错误。
还有一点容易被忽略:问清楚输入格式再动手。数组是int还是float,是否可能为null或空数组,要求升序还是降序,返回新数组还是原地修改。如果题目已经明确说明原地修改,你就不要再开一个新数组返回,否则可能被判不完全符合要求。
5.3 常见排序算法面试题速答清单
下面整理了几个出现频率最高的问题,每个问题建议在脑子里过一遍标准回答的大纲,不需要背逐字稿,但要能把核心要点说全。
“为什么快排比归并排序更常用?”因为快排是原地排序,空间复杂度低(平均O(log n)),而且常数因子小,在大多数情况下比归并排序快。归并排序的优势在于稳定和复杂度恒定,更适合需要稳定性的场景。
“Java里为什么基本类型用快排而对象用归并?”基本类型不需要稳定性,且快排更快;对象排序需要稳定性,所以用归并的变种Timsort。这个问题其实考的是对稳定性和事务性场景的敏感度。
“数组已经有序,快排会怎样?”固定选择最值做pivot时,快排退化到O(n^2),递归深度为n。解决方案是随机选择pivot或三数取中法。
“如果数据量很大,无法全部装入内存怎么排序?”这属于外部排序,思路是先把数据分块,每块内部用快排或归并排好序,再通过多路归并的方式把各块合并成有序序列。提到多路归并时可以用败者树来优化比较次数,能答出这一点会给面试官留下印象。
“排序算法的下界为什么是O(n log n)?”基于比较的排序每两个元素的比较结果只有两种可能,因此决策树的高度至少是log2(n!)级别,而n!大约在n^n量级,所以下界就是O(n log n)。
5.4 复杂参数与边界条件的零失误自查
最后给一个通用的自查清单,写任何排序算法之前先看一眼:
- 递归终止条件是否覆盖了空输入和单元素输入?
- 循环索引是否会出现负数?
- 相等元素在比较时用的是大于还是大于等于?
- 是否考虑了整数溢出的情况(比如mid = (left+right)//2在left+right很大时可能溢出)?
对于归并排序的mid计算,如果题目明确给出int类型的left和right,直接用(left + right) // 2在某些语言里可能溢出,更安全的写法是left + (right - left) // 2。Python里没有溢出问题,但养成这种写法能避免在其它语言里踩坑。快排的partition中,i+1可能等于right或者超出右边界,也需要确保后面的交换不会越界。
这些问题听着琐碎,但在手写代码时就是分水岭。能现场跑通边界条件的候选人,代码功底基本过关;反过来,写完连测都不测就交给面试官的人,即使算法思路之前聊得不错,也会扣分。
6. 一组容易混淆的排序概念强化
6.1 分治、递归与原地排序的关系
归并排序和快排都是分治思想,但有一个关键差异:归并排序的合并阶段需要额外的辅助数组,是“非原地”的;快排的partition阶段通过交换实现原地调整,是“原地”的。堆排序也属于原地排序,因为它在原数组上进行元素交换。
这个差异直接影响空间复杂度,也决定了它们在不同场景下的取舍。如果你在面试中听到“原地排序”这个词,第一时间要意识到它在考空间复杂度。堆排序空间是O(1),快排是O(log n)(递归栈),归并是O(n)。这个结论必须脱口而出。
6.2 最好、最坏、平均情况到底怎么算
很多人会把最好情况和平均情况搞混。最好情况是输入恰好让算法走最短路径,最坏情况是输入让算法走最长路径,平均情况则是所有输入取期望值。
插入排序的最好情况是数组已经有序,每次新元素只需要和前面一个元素比较,总共比较n-1次。快排的最好情况是每次partition都恰好把区间分成两半,此时递归层数是log n,每层总时间复杂度是O(n),所以最好O(n log n)。快排的最坏情况是每次pivot都选到最大或最小值,导致一边为空,递归层数为n,所以最坏O(n^2),堆排序完全不受输入顺序影响,无论什么输入都先建堆再逐一取顶,所以三种情况都是O(n log n)。
面试官问“这个算法的平均复杂度怎么推导”,并不期待你完整算出积分表达式,更常见的是想听你讲清楚“递归式怎么列出来”。比如归并排序的总时间可以写成T(n) = 2T(n/2) + O(n),展开之后到T(1)=O(1),每层总耗时O(n)乘以层数log n,得到O(n log n)。能画出这种递推干系,比“我记得是O(n log n)”要有说服力得多。
6.3 排序稳定性到底在图什么
很多人理解稳定性的意义时,停留在“相等元素顺序不变”这个定义上,却说不清为什么需要它。最简单的例子是表格数据多列排序:表格里已有“姓名”列按字母顺序排好,现在要按“部门”排序。如果使用不稳定排序,排完部门之后,同一个部门内部原来的姓名顺序会乱掉。而稳定排序可以做到两次排序后姓名在部门内仍然有序。
数据库里执行带排序条件的查询时同样依赖稳定性。比如先按create_time排序,再按status分组展示,稳定排序能保证status相同的记录之间保留create_time的先后顺序。面试时能讲出这个应用场景,稳定性的问题基本稳过。
6.4 关于“稳定与不稳定”的快速判断技巧
有没有一个通用的快速判断方法?我自己的经验是:如果排序过程中存在“远距离交换”(不是相邻元素交换),那么很可能不稳定。因为远距离交换可能跨过若干相同值的元素,等于“隔空换位”。快排partition中pivot和某个元素交换,堆排序中堆顶和末尾交换,希尔排序中跨gap交换,这些都是远距离交换,所以不稳定。冒泡、插入、归并只做相邻或局部范围内有序插入,同值元素的相对顺序不会被隔空破坏,所以稳定。归并的合并不算远距离交换,它只是在两个有序序列中按序取出,等值情况下优先取左侧的,所以保持了稳定。
7. 面试冲刺的练习方法与个人经验
7.1 高效练习的六个步骤
如果时间紧张,不需要把每个排序都写到完美。高效的练习步骤我是这样做的,也推荐给身边的朋友:
第一步,把十大排序的名称、复杂度、稳定性写在纸上,默写三遍,确保零遗漏。第二步,先挑快排、归并、堆排三个重点算法,每种用笔纸手动模拟一个8元素数组的完整排序过程,确认自己理解每一步。第三步,在编辑器里独立写出三个重点算法的代码,要求不查资料、不看答案。第四步,写完后用几组特殊数据测试:空数组、单元素、重复元素、倒序数组、已经有序数组。第五步,把冒泡、选择、插入、希尔也各写一遍,重点体会冒泡的标志位优化和插入排序在近有序数组上的效率。第六步,把计数、基数、桶排序的原理用两句话复述出来,能配合手动模拟一个小例子即可。
整个过程如果全速进行,大概需要三四个小时。时间再紧,前四个步骤不能省,尤其是手动模拟快排和归并的思路,对理解边界条件帮助极大。
7.2 我在陪人模拟面试时发现的最高频失误点
不少候选人会在“比较相等元素是否要交换”的问题上栽跟头。快排的partition中,如果条件写成arr[j] <= pivot,那么等于pivot的元素会全部被换到左侧,导致含有大量重复数据时partition两边不平衡甚至死循环。标准的写法是严格小于,等值留给pivot本身以及右侧区域去处理,这样重复数据的分布会更均匀。
另一个高频失误是堆排序的建堆顺序。建堆必须从最后一个非叶子节点开始向前调整,不能从下标0开始向后调整。原因很好理解:sift down需要保证左右子树都已经是合法堆,所以必须自底向上构建;而如果自顶向下构建,调整过程中子树可能还没满足堆性质,结果就不对。
7.3 最后的冲刺心得
排序算法的学习如果只停留在看视频、看文章,效果非常有限。手写代码本身就是最直接的知识压缩方式,写完再主动去思考几个为什么,比多看十个教程都有用。我自己带人复习时,总喜欢问一句“你能不能不用一行代码,就把快排的partition过程跟一个非程序员讲明白”,每当对方卡住,我就知道他还有某些细节没有真正消化。把这个细节补上,才是临时抱佛脚能抱得住的底气。
希望这篇精简整理能在你面试前最后阶段起到定心丸的作用。别贪多,把高频率的三大排序写流畅,把复杂度、稳定性表格记扎实,再带上几个真实场景的解释能力,就已经能在大多数面试里稳定发挥了。