面试季又到了,算法题永远是躲不过去的一道坎。上篇我们聊了插入、选择、归并、快排这几个主力选手,这篇继续把剩下高频考点一次性讲透。排序算法这块,面试官问起来通常不只是让你背代码,而是看你能不能把原理说清楚、复杂度算明白、边界情况处理到位。我见过太多人死记硬背几个排序实现,结果一问"为什么快排最坏情况是O(n²)"就卡壳。这篇下篇,我打算把希尔排序、堆排序、计数排序、桶排序、基数排序这五个全部讲细,顺带把面试里最容易踩坑的变种题目整理出来。
1. 为什么排序算法在面试中如此高频
算法面试挂掉的人里面,十个有八个死在排序上。不是排序本身难,而是它考察的是多维度能力:基础数据结构理解(数组、链表、堆)、递归和分治思想、时间复杂度空间复杂度的权衡能力、以及代码的边界处理能力。面试官问你一个排序题,表面是考排序,实际是在看你这个人怎么思考问题。
以快排为例:正常人回答"选择一个基准值,把小的放左边,大的放右边,然后递归",这只能得个及格分。进阶的候选人会主动说"基准值的选择会影响时间复杂度,最坏情况是数组已经有序时每次选的基准都是最大最小值,时间复杂度退化到O(n²)"。能说出"三数取中法"或者"随机选取基准"的,又会高一个档次。如果还能补充"当序列规模小于一定阈值时,可以采用插入排序作为兜底,因为小规模数据插入排序的实际运行速度优于快排"——这种细节才是在面试官眼里真正体现工程思维的地方。
另外一个现实原因:排序几乎是所有更复杂算法的基础。二分查找要求数据有序,数据的有序性靠排序获得;各种需要取Top K、求中位数、找众数的场景,底层都离不开排序逻辑;在公司的实际业务系统里,数据排序的需求也超级常见。所以面试官爱问排序,本质上是想通过一个常见场景,快速判断你的底层功底。
2. 希尔排序:插入排序的进阶杀手锏
2.1 希尔排序的核心思想与gap序列
希尔排序在面试中的出现频率不算最高,但一旦出现,往往是为了考察你对"插入排序的局限性"有没有深入理解。
插入排序的问题是:如果最小的元素在数组末尾,那它需要一步步挪到开头,这个过程的比较和移动次数非常多。希尔排序的思路很简单:先让数组在大跨度范围内变得基本有序,最后再做一次标准的插入排序。大跨度靠的是"增量gap",每次按gap对元素分组,组内做插入排序,然后不断缩小gap直到1。
举个例子,数组[8, 9, 1, 7, 2, 3, 5, 4, 6, 0],如果gap取5,那么下标0和5一组(8和3),下标1和6一组(9和5),下标2和7一组(1和4),依次类推。每组内做插入排序后,整体序列会变得"粗有序"。gap不断缩小,再重复这个过程。当gap=1时,就是标准插入排序,但此时数组已经基本有序了,插入排序的交换次数会大幅减少。
gap序列的选择对希尔排序的效率影响非常大。常见的方案有:
- 原始Shell序列:gap = n/2, n/4, ..., 1。实现简单,但最坏时间复杂度是O(n²)。
- Hibbard序列:1, 3, 7, 15, 31, ...(2^k - 1),最坏是O(n^1.5)。
- Sedgewick序列:1, 5, 19, 41, 109, ...,最坏情况可以达到O(n^(4/3))。
面试里说清楚这些序列的区别,会显得你查过资料、有深度。我自己的经验是,如果要手写,直接gap = n/2每次减半最稳妥,好记也不容易写错,实战笔试里签名时间有限,不要在这种细节上花太多精力。
2.2 希尔排序的代码实现与复杂度分析
希尔排序的代码实现其实就是在插入排序外面套一层gap循环。每次组内排序用的是"基于gap的插入排序"版本,和标准插入排序的唯一区别就是移动距离从1变成gap。
function shellSort(arr) { let n = arr.length; for (let gap = Math.floor(n / 2); gap >= 1; gap = Math.floor(gap / 2)) { for (let i = gap; i < n; i++) { let temp = arr[i]; let j = i; while (j >= gap && arr[j - gap] > temp) { arr[j] = arr[j - gap]; j -= gap; } arr[j] = temp; } } return arr; }这段代码有几个边界点值得注意。第一个是gap循环的启动值取Math.floor(n / 2),这是为了保证第一轮至少有2个分组,否则gap太大了分组数太多、每组只有一两个元素,没有任何意义。第二个是内层循环的起点取i=gap,而不是i=0,这是为了让每个元素都有机会跟它前面gap位的元素比较。第三个是在while循环里必须写j >= gap,如果写成j > 0也是对的(因为当j等于gap时,j-gap恰好是0),但j >= gap语义更清晰,不容易产生数组越界的疑问。
关于稳定性,希尔排序是不稳定的。跳跃式的比较方式会破坏元素的相对位置。比如[2, 1, 2]这个数组,两个2的相对顺序在排序后可能发生变化,但因为值相等,最后结果看起来还是一样,实际追踪元素的原始下标才会发现顺序变了。面试如果问"希尔排序稳定吗",记住一个结论:只要排序过程中存在跳跃式交换(不是相邻位置交换),就注定不稳定。
2.3 面试中希尔排序的常见问法
面试官如果问希尔排序,通常会搭配这么几个问题:
- "为什么希尔排序最后一定要用gap=1做一次完整插入排序?"答案是:只有gap=1时才能保证所有元素都在正确位置上,之前的操作都是把大元素快速挪到后面、小元素快速挪到前面,但不能保证全局有序。
- "希尔排序的时间复杂度是多少?"这个要分情况回答:最坏是O(n²)(用原始序列),平均大概是O(n^1.3),但是实际测试里效率和gap序列强相关。
- "希尔排序在大数据量下表现如何?"实话实说,数据量到达几十万以上时,希尔排序通常跑不过快速排序或归并排序,因为它的复杂度还是比O(n log n)高。但中小规模数据下它的常数因子很小,表现往往不错。
3. 堆排序:优先队列思维的最直接体现
3.1 堆的结构与构建过程
堆排序在面试中的地位很高,因为它的实现里包含了一个常考的数据结构——堆。面试官问到堆排序,往往还会顺带问"堆是什么""优先队列怎么实现""Top K问题怎么处理"。
堆是一棵完全二叉树,并且满足堆序性质:如果是大顶堆,每个节点的值都大于等于它的左右孩子节点;小顶堆则完全相反。堆排序的思路就是:构建一个大顶堆,堆顶元素是最大值,把它和数组末尾交换,然后对剩下的n-1个元素重新调整堆,再交换,重复n-1次。
构建堆的过程有两种思路。第一种是从底向上的"堆化"过程:从最后一个非叶子节点开始,逐个往前执行下沉操作(sift down)。一个长度为n的数组,最后一个非叶子节点的位置是Math.floor(n / 2) - 1。
以数组[4, 10, 3, 5, 1]为例:n=5,最后一个非叶子节点是下标1(值是10),它已经有孩子(下标3是5)但无需交换;再往前到下标0(值是4),它的孩子是10和3,和较大的孩子10交换后得到[10, 4, 3, 5, 1];继续比较下标1(值4)和它的孩子5,交换得到[10, 5, 3, 4, 1]。此时已经是一棵合法的大顶堆。
这里有一个容易混淆的细节:构建堆的过程,和取出堆顶后的调整堆过程,两者用的虽然都是"下沉操作",但作用范围不同。构建堆时,从后往前处理,每个节点下沉到它的合适位置;取出堆顶后,是把最后一个元素放到堆顶,然后从堆顶往下整体下沉一次。
3.2 堆排序的完整代码实现
function heapSort(arr) { let n = arr.length; // 构建大顶堆 for (let i = Math.floor(n / 2) - 1; i >= 0; i--) { heapify(arr, n, i); } // 逐个取出堆顶元素放到末尾 for (let i = n - 1; i > 0; i--) { [arr[0], arr[i]] = [arr[i], arr[0]]; // 交换堆顶和末尾 heapify(arr, i, 0); // 对新的堆顶执行下沉,范围缩小到i } return arr; } function heapify(arr, n, i) { let largest = i; let left = 2 * i + 1; let 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) { [arr[i], arr[largest]] = [arr[largest], arr[i]]; heapify(arr, n, largest); // 递归下沉被换下去的孩子节点 } }heapify函数是这个算法的核心,它做的事情是:找到当前节点和左右孩子中值最大的那个,如果最大的是孩子节点,就交换,然后递归对孩子继续堆化。这个过程的时间复杂度是O(log n),因为完全二叉树的高度是log n,每次下沉最多走一条从根到叶的路径。
构建堆的时间复杂度是O(n),这点很多人会算错。构建堆遍历了n/2个节点,每个节点最多下沉log n次,但整体算下来不是O(n log n)而是O(n)。原因是越底层的节点数量越多但下沉深度越浅,越上层的节点下沉深度深但数量基数小。具体数学推导用的是求和级数,面试里可以直接回答"构建堆是线性时间O(n),重建堆是O(n log n)",然后简单说明一下即可。
3.3 堆排序与快排、归并的比较场景
很多面试官会让候选人对比快排、归并、堆排序"三巨头"。我在这里把对比逻辑整理成一张表。
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 额外空间 | 稳定性 | 特点 |
|---|---|---|---|---|---|
| 快速排序 | O(n log n) | O(n²) | O(log n)递归栈 | 不稳定 | 实际运行最快,适合大规模乱序数据 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 适合链表排序和外部排序 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 不需要额外空间,适合嵌入式场景 |
从工程角度说,在内存充足、需要稳定排序的场景,优先归并排序;在内存受限且可以容忍不稳定的场景,堆排序是很好的选择;绝大多数通用场景,快排的性能表现最好。面试中如果问"大数据量排序选哪个",一定要问清限制条件:内存多大?数据是数组还是链表?是否要求稳定?需求清晰了再作答,这才是面试官想看到的分析能力。
堆排序有一个很有意思的变种问题:"如何在O(n log k)时间内从一个长度为n的数组中找到最大的k个数?"答案是维护一个大小为k的小顶堆,遍历数组,遇到比堆顶大的元素就替换堆顶并下沉调整,遍历结束后堆里的k个元素就是最大的k个。这在海量数据Top K问题中是非常经典的解法。
4. 线性时间排序三剑客:计数、桶、基数
4.1 计数排序的适用边界与实现要点
前面聊的一堆排序,全都是基于"比较元素大小"的比较排序。理论上已经证明,基于比较的排序算法,时间下限是O(n log n)。那如果面试官问"有没有比O(n log n)更快的排序",答案就是——计数排序、桶排序、基数排序,它们不通过比较来排序,而是利用数据本身的特性。
计数排序的核心思路是:创建一个辅助数组count,count[i]记录原数组中等于i的元素个数,然后根据count数组把元素放到正确的位置上。适用条件非常苛刻:数据必须是非负整数,且数据范围相对集中。
以数组[4, 2, 2, 8, 3, 3, 1]为例,数据范围是1到8,创建一个长度为9的count数组(下标0到8),遍历原数组,count[1]=1,count[2]=2,count[3]=2,count[4]=1,count[8]=1。然后根据count数组累加前缀和,得到每个元素在排序后数组中的目标位置。最终输出[1, 2, 2, 3, 3, 4, 8]。
计数排序的代码实现有两个陷阱。第一个是下标偏移:如果数据最小值为100,最大值为200,不能直接创建长度为200的数组,应该创建长度为200-100+1=101的数组,每个下标代表的值要减去偏移量100。第二个是稳定性处理:为了保持稳定性,需要从后往前遍历原数组填充结果数组,这样才能保证相等元素的相对顺序不变。
function countingSort(arr) { if (arr.length <= 1) return arr; let max = Math.max(...arr); let min = Math.min(...arr); let offset = min; let count = new Array(max - min + 1).fill(0); for (let num of arr) count[num - offset]++; for (let i = 1; i < count.length; i++) count[i] += count[i - 1]; let result = new Array(arr.length); for (let i = arr.length - 1; i >= 0; i--) { let val = arr[i]; result[--count[val - offset]] = val; } return result; }这里从后往前遍历的目的是稳定性。count[val - offset]的值表示在排序结果中,该值应当放到的最后一个位置,先减1再赋值,保证相同值的元素按照索引顺序依次排在前面。这个细节面试官经常让人现场演示,一定要能边写边解释清楚。
4.2 桶排序的设计逻辑与空间考量
桶排序可以看作是计数排序的泛化版本。当数据范围很大、直接建数组不现实时,可以设计若干个"桶",把数据按某种规则分散到不同的桶里,每个桶内部再用另一种算法排序(比如插入排序或者直接递归快排),最后依次合并所有桶。
桶的数量通常取Math.sqrt(n)或者根据数据具体分布定。核心挑战在"数据均匀分布到桶里"这个假设上。如果输入是[1, 2, 3, 10000, 20000, 30000],共6个元素,数据范围跨度有3万。这时如果建3个桶,前两个桶可能各只有1个元素,第三个桶有4个,分布不均会导致某个桶内元素过多、排序时间变长,甚至退化到和普通排序一样。
面试中问桶排序,最容易被考的点是:"桶排序的时间复杂度是什么?"标准回答是:如果数据均匀分布、桶的数量选择合理,每个桶内的元素数量大致是n/k,内部排序复杂度为O((n/k) log(n/k)),k个桶总复杂度接近O(n)。但如果分布极其不均匀,最坏情况是所有元素全部落在同一个桶里,复杂度退化为O(n log n)。所以桶排序不是万能银弹,它的前提是"对数据的分布形态有先验了解"。
4.3 基数排序的逐位比较思想
基数排序是个很有意思的算法,它的思想跟人的直觉很接近:如果要给一堆三位数排序,先按个位数分桶,再按十位数分桶,最后按百位数分桶,经过三轮分配后,结果自然有序。这个思路叫作LSD(Least Significant Digit,最低位优先)排序,还有一个变种是MSD(最高位优先),是从高位往低位处理。
具体过程以数组[170, 45, 75, 90, 802, 24, 2, 66]为例。第一轮按个位数分桶:个位是0的有170和90,个位是2的有802和2,个位是4的有24,以此类推。按桶顺序合并后得到[170, 90, 802, 2, 24, 45, 75, 66]。第二轮按十位数分桶:2和802的十位都是0,24的十位是2,45的十位是4,以此类推。合并后得到[2, 802, 24, 45, 66, 170, 75, 90]。第三轮按百位分桶得到最终有序序列。
这个算法看起来绕,但它有一个非常关键的特性:每一轮的分桶过程必须是稳定排序,否则前一轮保留的顺序会在后一轮被打乱。所以基数排序通常内部用计数排序来实现分桶,保证每轮排序都是稳定的。
基数排序的时间复杂度是O(d × (n + k)),其中d是最大数字的位数,k是基数大小(十进制下是10)。当d是常数时,复杂度就是O(n),属于线性时间排序。它比计数排序的优势在于:不受数据范围限制,哪怕数字有几百位,只要位数有限,一样能排。
4.4 三种线性排序的选择策略
面试官很喜欢这样问:"给你一个场景,你选哪种排序方法?"这里整理了典型的场景对应关系。
| 场景 | 数据特征 | 推荐算法 | 原因 |
|---|---|---|---|
| 范围很小的整数 | 成绩0-100,数量1万 | 计数排序 | 线性时间,代码简单 |
| 范围较大但分布均匀 | 浮点数,范围0-1 | 桶排序 | 桶内排序负载低 |
| n位数固定、多次排序 | 手机号,11位数字 | 基数排序 | 不受范围限制,位数固定 |
| 普通数组排序 | 无特殊约束 | 快排或归并 | 通用性强 |
我的建议是,遇到这类问题先反推三个问题:数据是不是整数?数据范围多大?能不能容忍不稳定?大多数场景下答案自然就出来了。这三个线性排序算法面试难度其实不高,重要的是把"为什么我不能用计数排序处理一个分布范围1到1亿的数组"这种边界问题讲清楚。
5. 排序算法的稳定性与面试"终极拷问"
5.1 稳定性到底在说什么
"稳定性"这个词,考研和学生党的理解跟实际工作要求往往有偏差。稳定性指的是:如果两个元素的值相等,排序后它们的前后相对顺序与排序前保持一致。这跟元素的值本身无关,它关心的是"相等的元素是否保持了原有次序"。
举一个业务场景:一个学生管理系统,先按年级排序,再按班级排序。在第二次排序时,如果排序算法是稳定的,那么班级相同的学生,年级排序的顺序会被保留下来。如果算法不稳定,那第二次排序可能会把之前"年级有序"的顺序打乱,最后结果看起来每个班级内部是无序的。这就是为什么很多系统数据处理的场景要求稳定排序的原因。
各排序算法的稳定性结论是面试高频题,值得反复背诵:
- 稳定:冒泡排序、插入排序、归并排序、计数排序、桶排序、基数排序。
- 不稳定:选择排序、快速排序、希尔排序、堆排序。
选择排序不稳定是最容易让人想不通的。原因是选择排序每次找到剩余元素中的最小值,和当前位置交换时,如果这个最小值后面还有与当前位置相等的元素,就会把相等元素的顺序打乱。简单说:跨位置交换破坏了稳定性,相邻交换大概率能保持稳定。
5.2 面试官的排序"绝杀题"
我把自己这些年遇到的排序相关面试题整理了一些典型的,大家可以对照自测。
"有一个几乎有序(每个元素最多偏离正确位置k个位置)的数组,怎么排序最高效?"答案是使用插入排序或者堆排序,插入排序最多只需要比较k次就能找到插入位置,时间复杂度O(nk);如果使用堆排序,可以做到O(n log k),用大小为k+1的小顶堆维护窗口,依次取出最小元素。这道题考察的是"数据有序程度对算法选择的影响"。
"如何用O(1)额外空间合并两个有序数组?"如果两个数组都存储在第一个数组的末尾预留了空间,那么从两个数组的末尾开始往前比较,较大的放到末尾位置,依次向前填充。不需要额外空间,代码也很短。这道题考察的其实是"双指针从后往前"技巧,跟排序本身关系不大但经常跟排序一起出现。
"如何对自定义对象排序?"答案取决于语言:在JavaScript里传比较函数给sort,在Java里实现Comparable接口或者传入Comparator,在Python里用key参数。这个问题的考察点是"你是否理解排序接口的扩展机制"。
"快排如何保证尽量不退化到O(n²)?"常见策略:随机选基准、三数取中、在规模减小到一定阈值后切换到插入排序。如果面试官进一步问"有没有一种排序能在最坏情况下O(n log n)且实际表现接近快排",答案就是内省排序(introsort),C++标准库的std::sort和Go的sort包底层都是这么实现的,结合快排和堆排序各自的优势。
6. 从排序看工程思维:复杂度不是全部
6.1 数学复杂度与真实性能的差距
很多人刚学算法的时候都会陷入一个误区:觉得O(n log n)一定比O(n²)快。但实际上,复杂度描述的是数据规模趋于无穷大时的增长趋势,而真实工程中数据规模往往在某个有限范围,此时常数因子、数据局部性、CPU缓存命中率这些"不优雅"的因素反而起决定性作用。
举一个实测的例子:对10000个随机整数排序,插入排序O(n²)需要大约几千万次比较操作,看起来应该比快排O(n log n)慢很多。但如果数组本身已经基本有序(逆序数很少),插入排序只需要非常少的交换量,实际执行时间很可能优于快排。这就是为什么很多快排实现会对小规模子序列回退到插入排序的原因。
在现代CPU上,数据的访问模式也非常重要。快排和堆排序在做元素交换时,快排的访问模式更接近顺序访问(因为它对数组进行分段),而堆排序的下沉操作访问的是跳跃式的下标(2i+1、2i+2),这会导致更多的缓存未命中。数据量越大,这个劣势越明显。所以"复杂度相同但堆排序实际慢于快排"不是一个玄学,是有硬件层面的原因的。
6.2 为什么面试官不满足于"你会写快排"
我后来参与过一些技术面试,发现优秀的候选人和普通候选人之间,差距往往不在"会不会写代码",而在"能不能讲清楚为什么"。具体到排序,面试官通常给一个开放题目:"请用你最熟练的排序算法,把数组排一下"——这时大部分候选人直接开始写快排,但优秀的候选人会先问:"数据规模多大?内存有没有限制?数据大概是什么分布?是否需要稳定?"三个问题问完,面试官对这个候选人的评价已经基本固定了。
这个"先问约束条件再动手"的习惯,正是工程思维的核心体现。在真实项目里,没有人会给一个不确定规模的排序任务盲目上快排。跟运维、数据仓库的同事对接以后你会更深刻地感受到,同一个排序任务,放在内存里排和放在磁盘上外部排序,完全是两个世界的问题。
6.3 我的一些个人体会
排序算法这杆旗帜立了这么多年,在面试中永远是万金油式的存在。它不像动态规划那样需要灵感和技巧储备,也不像图论那样需要抽象建模能力,但它恰恰最考验基础是否扎实。很多候选人能写复杂题,却在"排序是稳定的吗"这种基础问题上栽跟头,说白了还是对原理理解不够深。
我个人建议准备排序算法时,不要只刷代码。每学一个排序,最好亲手推一遍它在一组具体数据上的执行过程,把每一轮交换都画出来。这个动作虽然费时间,但对建立直觉帮助极大。当年我在准备面试时,把八种排序在草稿纸上各推演了三遍,之后面试中任何排序问题都没再发过怵。
本文最后分享一个实用小技巧:面试中写排序代码时,先把空函数签名写好,然后写主逻辑,最后再回头处理边界条件。很多同学一上来就写边界判断,逻辑容易乱,等主流程通了再补边界永远来得及。祝看到这里的各位都能顺利通关。