
面试季又到了算法题永远是躲不过去的一道坎。上篇我们聊了插入、选择、归并、快排这几个主力选手这篇继续把剩下高频考点一次性讲透。排序算法这块面试官问起来通常不只是让你背代码而是看你能不能把原理说清楚、复杂度算明白、边界情况处理到位。我见过太多人死记硬背几个排序实现结果一问为什么快排最坏情况是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不断缩小再重复这个过程。当gap1时就是标准插入排序但此时数组已经基本有序了插入排序的交换次数会大幅减少。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太大了分组数太多、每组只有一两个元素没有任何意义。第二个是内层循环的起点取igap而不是i0这是为了让每个元素都有机会跟它前面gap位的元素比较。第三个是在while循环里必须写j gap如果写成j 0也是对的因为当j等于gap时j-gap恰好是0但j gap语义更清晰不容易产生数组越界的疑问。关于稳定性希尔排序是不稳定的。跳跃式的比较方式会破坏元素的相对位置。比如[2, 1, 2]这个数组两个2的相对顺序在排序后可能发生变化但因为值相等最后结果看起来还是一样实际追踪元素的原始下标才会发现顺序变了。面试如果问希尔排序稳定吗记住一个结论只要排序过程中存在跳跃式交换不是相邻位置交换就注定不稳定。2.3 面试中希尔排序的常见问法面试官如果问希尔排序通常会搭配这么几个问题为什么希尔排序最后一定要用gap1做一次完整插入排序答案是只有gap1时才能保证所有元素都在正确位置上之前的操作都是把大元素快速挪到后面、小元素快速挪到前面但不能保证全局有序。希尔排序的时间复杂度是多少这个要分情况回答最坏是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]为例n5最后一个非叶子节点是下标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)更快的排序答案就是——计数排序、桶排序、基数排序它们不通过比较来排序而是利用数据本身的特性。计数排序的核心思路是创建一个辅助数组countcount[i]记录原数组中等于i的元素个数然后根据count数组把元素放到正确的位置上。适用条件非常苛刻数据必须是非负整数且数据范围相对集中。以数组[4, 2, 2, 8, 3, 3, 1]为例数据范围是1到8创建一个长度为9的count数组下标0到8遍历原数组count[1]1count[2]2count[3]2count[4]1count[8]1。然后根据count数组累加前缀和得到每个元素在排序后数组中的目标位置。最终输出[1, 2, 2, 3, 3, 4, 8]。计数排序的代码实现有两个陷阱。第一个是下标偏移如果数据最小值为100最大值为200不能直接创建长度为200的数组应该创建长度为200-1001101的数组每个下标代表的值要减去偏移量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 基数排序的逐位比较思想基数排序是个很有意思的算法它的思想跟人的直觉很接近如果要给一堆三位数排序先按个位数分桶再按十位数分桶最后按百位数分桶经过三轮分配后结果自然有序。这个思路叫作LSDLeast 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的十位都是024的十位是245的十位是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)用大小为k1的小顶堆维护窗口依次取出最小元素。这道题考察的是数据有序程度对算法选择的影响。如何用O(1)额外空间合并两个有序数组如果两个数组都存储在第一个数组的末尾预留了空间那么从两个数组的末尾开始往前比较较大的放到末尾位置依次向前填充。不需要额外空间代码也很短。这道题考察的其实是双指针从后往前技巧跟排序本身关系不大但经常跟排序一起出现。如何对自定义对象排序答案取决于语言在JavaScript里传比较函数给sort在Java里实现Comparable接口或者传入Comparator在Python里用key参数。这个问题的考察点是你是否理解排序接口的扩展机制。快排如何保证尽量不退化到O(n²)常见策略随机选基准、三数取中、在规模减小到一定阈值后切换到插入排序。如果面试官进一步问有没有一种排序能在最坏情况下O(n log n)且实际表现接近快排答案就是内省排序introsortC标准库的std::sort和Go的sort包底层都是这么实现的结合快排和堆排序各自的优势。6. 从排序看工程思维复杂度不是全部6.1 数学复杂度与真实性能的差距很多人刚学算法的时候都会陷入一个误区觉得O(n log n)一定比O(n²)快。但实际上复杂度描述的是数据规模趋于无穷大时的增长趋势而真实工程中数据规模往往在某个有限范围此时常数因子、数据局部性、CPU缓存命中率这些不优雅的因素反而起决定性作用。举一个实测的例子对10000个随机整数排序插入排序O(n²)需要大约几千万次比较操作看起来应该比快排O(n log n)慢很多。但如果数组本身已经基本有序逆序数很少插入排序只需要非常少的交换量实际执行时间很可能优于快排。这就是为什么很多快排实现会对小规模子序列回退到插入排序的原因。在现代CPU上数据的访问模式也非常重要。快排和堆排序在做元素交换时快排的访问模式更接近顺序访问因为它对数组进行分段而堆排序的下沉操作访问的是跳跃式的下标2i1、2i2这会导致更多的缓存未命中。数据量越大这个劣势越明显。所以复杂度相同但堆排序实际慢于快排不是一个玄学是有硬件层面的原因的。6.2 为什么面试官不满足于你会写快排我后来参与过一些技术面试发现优秀的候选人和普通候选人之间差距往往不在会不会写代码而在能不能讲清楚为什么。具体到排序面试官通常给一个开放题目请用你最熟练的排序算法把数组排一下——这时大部分候选人直接开始写快排但优秀的候选人会先问数据规模多大内存有没有限制数据大概是什么分布是否需要稳定三个问题问完面试官对这个候选人的评价已经基本固定了。这个先问约束条件再动手的习惯正是工程思维的核心体现。在真实项目里没有人会给一个不确定规模的排序任务盲目上快排。跟运维、数据仓库的同事对接以后你会更深刻地感受到同一个排序任务放在内存里排和放在磁盘上外部排序完全是两个世界的问题。6.3 我的一些个人体会排序算法这杆旗帜立了这么多年在面试中永远是万金油式的存在。它不像动态规划那样需要灵感和技巧储备也不像图论那样需要抽象建模能力但它恰恰最考验基础是否扎实。很多候选人能写复杂题却在排序是稳定的吗这种基础问题上栽跟头说白了还是对原理理解不够深。我个人建议准备排序算法时不要只刷代码。每学一个排序最好亲手推一遍它在一组具体数据上的执行过程把每一轮交换都画出来。这个动作虽然费时间但对建立直觉帮助极大。当年我在准备面试时把八种排序在草稿纸上各推演了三遍之后面试中任何排序问题都没再发过怵。本文最后分享一个实用小技巧面试中写排序代码时先把空函数签名写好然后写主逻辑最后再回头处理边界条件。很多同学一上来就写边界判断逻辑容易乱等主流程通了再补边界永远来得及。祝看到这里的各位都能顺利通关。