资讯详情 常考排序算法面试考点:稳定性、选型与手写避坑
📅 2026/10/19 8:56:34
1. 常考排序的真正考法不是默写是写对、说清、选准刷过几轮算法题的人多半都有一种错觉排序嘛无非冒泡、快排、归并背下来就完事了。但等你真正坐到面试官对面或者打开笔试系统准备把std::sort一交了之的时候才会发现常考排序根本不是这么考的。我见过不少人默写快排行云流水被追问“你这个排序稳定吗不稳定在什么场景下会出问题”就直接卡壳也见过有人把堆排序背得滚瓜烂熟问他“Top K问题为什么用堆而不用快排”就开始答非所问。排序题在笔试和面试里的地位很微妙它人人都会调库但真要你手写、说清原理、在资源受限的场景里做出选型判断很多人就露馅了。这篇文章就按“常考”两个字来梳理——不是从《算法导论》第一章开始讲定义而是从应试和实战的角度把面试官最可能追问的考点、手写时最容易翻车的细节、以及工程选择背后的逻辑全部摊开。先给一个整体框架常考排序分两大阵营。一类是基于比较的排序——冒泡、选择、插入、希尔、归并、快排、堆排它们的时间复杂度下界是O(n log n)这个下界不是随便说的而是因为每次比较最多产生两个分支n个元素的排列有n!种可能决策树的高度必然是log₂(n!)也就是O(n log n)。另一类是非比较排序——计数排序、桶排序、基数排序它们不靠两两比较而是利用数据本身的取值或分布特征在特定条件下能做到O(n)。这个分类是理解整张考点地图的钥匙面试官问“有没有比O(n log n)更快的排序”答案绝对不是“没有”而是“有但要看数据长什么样”。另一个容易被忽略但几乎必问的维度是稳定性。很多人只背结论——“快排不稳定、归并稳定、堆排不稳定”——却不知道稳定性到底解决了什么问题。一句话讲透当排序的键有主次之分也就是你先按A字段排完再按B字段排如果第二趟用的是不稳定排序那么第一趟排出来的A字段顺序可能被全部打乱。真实业务里“先按时间排、再按优先级排”这种多关键字排序太常见了所以稳定性从来不是一个理论概念而是一条硬性的正确性要求。这篇文章后面会专门用一节来讲稳定性和选型这里先建立一个直觉常考排序不是让你背十种算法而是考察你“写对、说清、选准”这三件事。2. 三个O(n²)基础排序考试常客细节全在坑里2.1 冒泡排序基础中的基础但很多人没写出最优版冒泡排序的思路一句话就能说清每一轮从头到尾两两比较相邻元素把最大的“冒”到最后面下一轮就不用再管最后一个位置了。大部分人都能写出这个基础版本但注意面试官经常会在你写完后追问一句“如果这组数据本身就是有序的你的算法还能优化吗”这里就涉及冒泡排序在后半段面试里最常见的考点——标志位优化。不加标志位的冒泡排序无论数据是否有序都要跑满n-1轮时间复杂度恒为O(n²)。加上一个swapped标志每轮开始时置False只要这一轮发生过交换就置True某一轮跑完发现一次交换都没发生说明序列已经完全有序直接跳出循环。这样在最好情况数据原本有序下冒泡排序的时间复杂度可以降到O(n)。def bubble_sort(nums): n len(nums) for i in range(n): swapped False for j in range(n - 1 - i): if nums[j] nums[j 1]: nums[j], nums[j 1] nums[j 1], nums[j] swapped True if not swapped: break return nums这个优化在笔试里可能只是锦上添花但在面试口述环节是实打实的加分点。另外冒泡排序还有一个容易说错的性质它的稳定性。因为比较时用的是nums[j] nums[j 1]严格大于才交换所以相等的元素不会发生位置的相对变化冒泡排序是稳定的。这个细节几乎每年都会有人答错记住一个口诀只要交换条件是严格的或相等的元素就不会擦肩而过排序就是稳定的。2.2 选择排序思路最简单却是最“不认输”的一个选择排序的逻辑比冒泡还直观每一轮在未排序区间里找到最小值放到最前面。它的问题也写在名字里——“选择”本身不关心相等元素的顺序。举例来说数组[3a, 2, 3b, 1]第一轮找到最小值1和第一个位置的3a交换3a被挪到后面去了3a和3b的相对顺序就被破坏了。所以选择排序是不稳定的。选择排序的另一个特点经常被拿来出简答题无论数据怎么分布它的比较次数恒定为n(n-1)/2。因为每一轮都要老老实实扫一遍剩余区间找最小值数据有序也好、无序也好都得扫完才知道哪个最小。相比之下优化的冒泡排序在有序数据上是线性的插入排序在近乎有序的数据上甚至接近线性而选择排序是唯一一个“数据分布怎么变都不为所动”的O(n²)排序。这个特点谈不上优点但它很能说明“最好情况、最坏情况、平均情况”这三组概念的区别面试基础题里也常拿它做例子。工程实践中选择排序基本没有用武之地但考试层面它是很好的“对照组”。很多教材把冒泡、选择、插入放在一起讲就是因为这三者的思路分别代表了“交换”“选择”“插入”三种最朴素的排序思想。你只要把选择排序的稳定性记牢——它不稳定——就抓住了它的核心考点。还有一个值得说的点选择排序虽然不稳定但它的交换次数是所有O(n²)排序里最少的每轮只需要一次交换。如果交换的开销远大于比较的开销比如排序的不是数字而是一批体积很大的对象选择排序反而有一定价值。这种“算法优劣要看成本模型”的意识面试官其实很吃这一套。2.3 插入排序小数组和近乎有序数组的真王者插入排序的思路可以想象成打扑克牌时整理手牌从第二张开始依次把每一张牌往前插到合适的位置前面的牌始终是已经排好序的。它和冒泡、选择最大的区别在于插入排序对数据的初始状态极其敏感。如果数据近乎有序比如每个元素距离它最终的位置都不超过几步那么插入排序每次只需移动一两个位置就能落定整体时间复杂度可以退化到接近O(n)。这是它最重要的性质也是后续快排优化里“小规模子数组改插排”的理论依据。反过来说如果数据是逆序的插入排序每次都要把当前元素从最右边一路挪到最左边退化到彻头彻尾的O(n²)。它最好与最坏的差距极大这一点和选择排序形成鲜明对比。def insertion_sort(nums): for i in range(1, len(nums)): key nums[i] j i - 1 while j 0 and nums[j] key: nums[j 1] nums[j] j - 1 nums[j 1] key return nums注意上面代码比较条件用的也是严格大于等于的情况不往前挪所以插入排序是稳定的。面试里问“什么排序在近乎有序的数据上表现最好”标准答案就是插入排序如果追问“为什么”就把“每次只需移动少量位置”这个直觉说出来。另外希尔排序就是插入排序的改进版——先按某个增量分组做插排不断缩小增量最后增量为1时就是一次标准的插入排序。如果你的笔试时间充足把希尔排序作为插入排序的延伸提一嘴会显得你对知识脉络的把握比背代码的候选人高一个层次。3. 三大O(n log n)排序面试必考重点在边界与退化3.1 快速排序从partition到pivot选取的完整避坑快排在常见排序里的地位就像“自我介绍”在面试中的地位一样属于必考题。它的核心思想是分治选一个基准值pivot把小于它的放左边、大于它的放右边然后递归处理左右两个子区间。快排的平均时间复杂度是O(n log n)但这里有一个几乎所有面试官都会追问的陷阱——最坏情况下它会退化到O(n²)。什么时候退化当数据本来就有序或近乎有序而你又选择第一个元素或最后一个元素作为pivot时。因为每次partition都只能分出“一个元素”和“剩下的所有元素”递归深度直接变成n整体复杂度就变成1 2 3 … n O(n²)。这就是为什么工程实现里几乎不会直接取首元素做pivot要么随机选要么用“三数取中”法——取区间的首、中、尾三个位置的中位数来当pivot。随机化快排的期望复杂度是O(n log n)而且不依赖于输入数据的分布这是概率算法在经典算法里的典型应用。手写快排还有一个高频翻车点partition的等号处理。看下面这段填坑法实现它是我在面试辅导里见过出错率最高的代码之一import random def quick_sort(nums, left, right): if left right: return idx random.randint(left, right) nums[left], nums[idx] nums[idx], nums[left] pivot nums[left] i, j left, right while i j: # 这里必须用 和 如果去掉等号遇到大量重复元素时会死循环 while i j and nums[j] pivot: j - 1 nums[i] nums[j] while i j and nums[i] pivot: i 1 nums[j] nums[i] nums[i] pivot quick_sort(nums, left, i - 1) quick_sort(nums, i 1, right)如果两个内部while只写和当nums[j]等于pivot时j不会继续移动而i也不会动外层的while i j就永远跳不出去直接死循环。这个细节在笔试里并不会报错但面试官带你做代码走查时一眼就能看出来。还有一个更常见的边界问题——递归区间pivot最后被放到了nums[i]的位置那么接下来应该递归[left, i-1]和[i1, right]而不是把i本身再排一遍。很多人代码里写成quick_sort(nums, left, i)虽然偶尔也能跑对但实际上多排了一个已经归位的元素在极端情况下会引入隐藏bug。3.2 归并排序空间换时间也是链表的救命稻草归并排序的分治思路和快排正好相反快排是先粗略分区等分区完成后整个数组就变得有序归并是先递归把左右两半各自排成有序序列再通过merge把两个有序序列合并成一个大的有序序列。整个过程严格遵循“分-治-合”三步它的时间复杂度稳定在O(n log n)不管数据长什么样都不会退化这一点优于快排。代价是需要O(n)的额外空间来存合并结果。归并排序最关键的考点有三个。第一它的稳定性合并过程中遇到相等的元素时先取左半边的右半边的相等元素自然排到后面相对顺序不变所以归并排序是稳定的。第二它的空间复杂度递归调用栈的深度是O(log n)但每一层合并都需要临时数组所以整体额外空间是O(n)。有一个变体叫原地归并排序通过旋转区间来省空间但实现复杂度极高常数因子也很大笔试面试基本不会考。第三它在链表排序里几乎是唯一解链表没有随机访问能力堆排依赖下标建堆快排依赖首尾指针遍历而归并排序只需要把链表断开、逐对合并节点完全不需要额外空间。所以面试题“对链表排序”的标准答案就是归并排序时间复杂度O(n log n)空间复杂度O(1)指额外空间。merge操作本身也是一个容易被单独拎出来考的考点。两个有序数组合并成一个有序数组最简单的双指针做法是三个循环两个子数组各用一个指针先循环到其中一个数组耗尽再把剩余的接上。很多人写merge时只记得前两个while循环忘记在最后把剩下的元素复制回去。还有人在浅拷贝数组时犯迷糊——把tmp复制到原数组时索引偏移算错导致数据错位。我的建议是别试图“聪明”地复用原数组空间老老实实复制一份tmpmerge时用tmp做左右两个子数组的数据源把结果写回原数组对应区间这样最不容易出错。具体实现放在第5节一起讲。3.3 堆排序不考则已一考就是heapify细节堆排序在三个O(n log n)排序里出场频率稍低于快排和归并但一旦考到追问的深度往往最刁钻。堆排序分两步第一步把数组建成一个最大堆第二步重复“把堆顶元素和堆尾元素交换堆大小减一然后从堆顶向下调整”。建堆和调整都依赖一个核心子过程——siftDown向下渗透。先澄清一个几乎人人都有的误区建堆不是从第一个元素开始一个个插入而是从最后一个非叶子节点开始从下往上依次做siftDown。最后一个非叶子节点的下标是n // 2 - 1。为什么从下往上因为这样能保证当前节点的左右子树都已经是合法的堆了调整一次就能让以它为根的整个子树成堆。如果从上往下建每插入一个元素都要从底向上渗透时间复杂度是O(n log n)而从下往上建堆的总工作量是O(n)——这个证明比较巧妙简单理解就是下层节点多但下沉距离短上层节点少但下沉距离也短所有节点下沉的层数之和是线性的。def heap_sort(nums): n len(nums) def sift_down(i, size): while 2 * i 1 size: child 2 * i 1 if child 1 size and nums[child 1] nums[child]: child 1 if nums[i] nums[child]: break nums[i], nums[child] nums[child], nums[i] i child # 建堆 for i in range(n // 2 - 1, -1, -1): sift_down(i, n) # 排序 for i in range(n - 1, 0, -1): nums[0], nums[i] nums[i], nums[0] sift_down(0, i)堆排序的考点非常集中第一建堆的复杂度为什么是O(n)而不是O(n log n)第二siftDown循环条件的写法必须是2 * i 1 size判断的是左孩子是否存在而不是i本身小于size第三堆排序不稳定——堆顶元素和堆尾元素交换时完全可能把相同元素的相对顺序打乱。工程上堆排序很少用来做通用排序因为它的缓存局部性比快排差太多常数因子较大但“用堆来维护Top K”这种问题在面试里出现频率极高本质考的就是对堆这个数据结构的掌握而不是堆排序本身。你可以把堆排序理解成堆这种数据结构最完整的应用场景把siftDown写利索了Top K、带权调度、优先级队列这类题基本都能顺手做出来。4. 稳定性与选型题目问“用哪个”答的是工程权衡4.1 为什么稳定性在真实业务里这么重要很多人背熟了“快排不稳定、归并稳定”但真问到稳定性有什么用就只能答出“相等的元素顺序不变”说不出背后的业务场景。举个例子一个交易订单列表你先按订单创建时间排序再按优先级排序。如果你的第二趟排序用的是不稳定的算法那么在优先级相同的一组订单里创建时间较早的那个订单完全可能被排到创建时间较晚的订单后面结果就是用户看到“高优先级订单里时间乱序”——这已经算数据正确性问题了。所以很多数据库的ORDER BY实现、编程语言内置排序函数都会刻意选择稳定排序。Java的对象排序底层用的是经过优化的归并排序TimSort而不是快排重要原因之一就是稳定性。判断一个排序是否稳定的口诀其实很简单冒泡、插入、归并、基数/计数稳定版本是稳定的选择、快排、堆排不稳定。关键在于“是否发生远距离交换”。相邻交换冒泡、插入不会越过相等的元素分治合并时左半边的相等元素先入归并也不会改变相对顺序而选择排序会把远处的元素直接交换到当前位置快排的分区交换同理堆排序的堆顶堆尾交换更是随意这三者天然就会破坏稳定性。记住“相邻交换稳定、跳变交换不稳定”这条规律比死记结论可靠得多。4.2 场景倒推选型表时间优先、空间受限、稳定性优先各自怎么选常考排序选型题在面试里非常高频基本形式是“给我一个场景你选什么排序”或者反过来“这个场景为什么不能用快排”我把一线面试里最常出现的场景和对应选型整理成一张表方便你们对照记忆。场景推荐的排序核心理由数据量很小n 50插入排序常数因子极低实现简单几乎是O(n²)里最实用的一个数据近乎有序插入排序比较和移动都接近线性比O(n log n)的快排更快大规模完全无序数据快速排序平均性能最优随机化pivot可规避最坏情况必须稳定且数据量大归并排序、TimSort稳定且O(n log n)空间换时间内存极其紧张完全不能开额外空间堆排序原地排序额外空间O(1)取前K个最大/最小元素堆维护大小为K的堆复杂度O(n log K)键取值范围很小的整数计数排序下标直接映射能到O(nk)定长数字或字符串基数排序按位排序整体O(d(nk))避免大键比较这这张表不需要死记抓住底层逻辑就能推出来数据量大且不要求稳定快排是最省事的默认选择要求稳定就归并空间受限就堆排数据量小或近乎有序插排实际上能吊打所有进阶排序。特别要说一下“近乎有序”这个场景——在实际生产里大部分数据都是带着某种时间戳写入的天然就有序只不过偶尔插入了一些乱数据。这也是为什么很多语言内置排序会在排序开始前先扫描几轮插入排序而不是一上来就快排或归并。4.3 数据特征决定算法生死近乎有序、大量重复、超大n除了空间和时间数据本身的特征对排序算法的影响同样关键甚至更关键。面试里能把数据特征和算法选择挂钩是拿到“优”档评价的分水岭。近乎有序上面说过插入排序是这类数据的王者。实际工程中的“近乎有序”不是偶然出现的而是数据产生机制决定的。比如日志文件按时间戳写入新数据追加到末尾大部分时间都是有序的偶尔有些乱序记录这种场景用插排非常划算。大量重复元素快排对重复数据极度敏感。如果partition逻辑处理不好所有等于pivot的元素都会集中到一侧递归区间严重失衡退化到O(n²)。解决思路是“三向切分”——把数据分成小于pivot、等于pivot、大于pivot三部分递归时只处理小于和大于两个区间。很多语言的内置排序实现里都内置了三向切分就是因为真实数据里重复值实在太常见。超大n单机内存放不下就涉及外部排序。外部归并排序的核心思路是先读入能装入内存的数据块各自排序后写回磁盘然后通过多路归并把所有分片合并成一个有序文件。这里的“多路归并”和归并排序的merge是同一个思路只是从数组变成了文件流。面试不太会考外部排序的完整实现但“大数据量排序为什么用归并排序而不是快排”这个问题的答案就是“快排依赖随机访问外部场景只能顺序读归并完美适配顺序访问”。数据特征这条路再往下走就会碰到非比较排序。下一节的计数排序、桶排序、基数排序本质都是利用数据特征来绕过比较排序的O(n log n)下界。它们的适用范围比比较排序窄得多但一旦命中性能优势是碾压性的这也正是面试官喜欢用来拉开区分度的知识点。5. 手撕排序的高频翻车点能看懂和能写对是两码事5.1 快排partition的三类错误快排的分区写法有很多流派左右指针法、填坑法、单边扫描法、三向切分法。面试时用一种你最有把握的就好但无论用哪种有三个错误几乎每个人都踩过。第一内层while的等号问题。我在第3章提过如果写的是nums[j] pivot而不是nums[j] pivot当nums[j]恰好等于pivot时j不会推进外层的while i j就卡死了。笔试环境可能不会真的死循环——因为填坑法里pivot已经被拿出来位置空着有时碰巧能跑过去——但逻辑上是错的。写快排之前先在草稿上问自己一句遇到重复元素我这个循环会不会推进第二递归区间边界。pivot归位后的位置是i子区间应该是[left, i-1]和[i1, right]。但如果partition过程做的是“双边扫描”有时pivot最终位置并不是i而是j这时候复制代码就容易张冠李戴。建议统一记住一种和自己的partition匹配的区间写法不要今天用填坑法配(left, i-1)明天用交换法也配(left, i-1)那就必错。第三随机化pivot的交换位置。很多人写了idx random.randint(left, right)但忘了先把nums[idx]和nums[left]交换直接拿nums[left]当pivot等于随机化白写了。这个笔误非常隐蔽因为结果看起来仍然“有时快有时慢”很难排查。我给一个排查快排bug的固定套路用[1]、[2,1]、[3,1,2]、[3,3,3]这四组数据分别测试。第一组检查边界第二组检查两元素交换第三组检查三元素时pivot的选择第四组专门触发重复元素死循环。四组全过快排的基本正确性就有保障了。5.2 归并merge的常见bugmerge的代码结构很固定但它出错的频率一点也不低。最常见的是索引偏移错乱。很多人把临时数组整体复制之后直接用tmp[i]和tmp[j]做比较但i和j的起始下标不是0而是left和mid 1这时必须先做偏移i left; j mid 1然后比较tmp[i]和tmp[j]写回nums[k]。如果一开始就把tmp里的区间切片出来tmp nums[left:right1]那两个指针的起始位置就是0和mid - left 1和原数组下标完全脱钩写回的时候从k left开始。两种方式都能用但要固定一种别混着来。def merge(nums, left, mid, right): tmp nums[left:right 1] i 0 j mid - left 1 k left while i mid - left and j right - left: if tmp[i] tmp[j]: nums[k] tmp[i] i 1 else: nums[k] tmp[j] j 1 k 1 while i mid - left: nums[k] tmp[i] i 1 k 1 while j right - left: nums[k] tmp[j] j 1 k 1上面代码里我刻意加了一处容易写错的点——循环条件里的。很多人写while i mid - left 1也能跑但边界判定多一个加一少一个加一很容易绕晕。更推荐的做法是像上面这样把区间的闭区间右端点直接用mid - left和right - left来表示这样和切片的索引体系天然对齐逻辑上最不容易出错。另外if tmp[i] tmp[j]用小于等于而不是小于是为了保证相等元素优先取左半边的——这就是归并排序稳定性的来源写的时候一定不要改成。5.3 堆排siftDown为什么总是写反堆排序的翻车点非常集中基本全部集中在siftDown这个子函数上。我统计过辅导过的同学里十个写堆排的至少有六个在siftDown上报错而且报错的模式高度一致。第一个错是循环条件写反。正确写法是while 2 * i 1 size意思是“只要当前节点还有左孩子就继续调整”。有人把它误写成while i size那是把“遍历数组”和“向下调整”混为一谈了。siftDown是顺着父子关系往下走的当前节点的下标i并不一定连续递增所以不能用i size来判断。第二个错是找较大孩子的逻辑。正确的顺序是先取左孩子child 2 * i 1然后判断右孩子是否存在以及是否比左孩子大如果右孩子更大把child更新为右孩子的下标。这个逻辑里最容易漏的是child 1 size的判断——只写nums[child 1] nums[child]在右孩子越界时会直接下标报错。还有一种常见反向错误是把nums[child 1] nums[child]写成没问题但有人会写成mor或方向颠倒最后变成“选较小的孩子上升”堆的性质就全乱了。第三个错是跳出条件。必须检查当前父节点是否大于等于较大孩子如果是就直接break因为下面已经不需要再调整了。很多人会忘记这个break继续往下换导致已经成堆的结构被自己破坏。写完后用[4, 10, 3, 5, 1]手动模拟一遍建堆过程比对一下堆顶是不是最大值基本能当场抓住八成错误。6. 非比较排序突破O(n log n)的偏门武器6.1 计数排序与桶排序的边界条件非比较排序绕开了“两两比较”所以不受O(n log n)下界的限制。它们之所以在面试里值得单独梳理不是因为出现的频率有多高而是因为一旦出现就能把“懂算法原理”和“只会背排序模板”的候选人彻底区分开。计数排序是最简单的非比较排序适用条件是数据取值范围很有限比如0到10000之间的一批成绩。思路是先统计每个值出现的次数再做前缀和最后根据前缀和把原数组元素放到输出数组的正确位置上。如果不要求稳定可以只做“次数统计回填”如果要求稳定比如记录里除了键还有别的字段就必须用前缀和从后往前填。上限是k代表取值范围大小空间复杂度是O(k)时间也是O(nk)。面试官常问的一个点是“计数排序为什么不是O(n)”——答案是它的复杂度里还有kk来源于数据范围n很大而k也很大时计数排序就不一定划算了。桶排序的思路是把数据按范围均匀分到若干个桶里桶内各自排序通常是插排最后依次输出。它最适合的数据分布是“均匀分布”比如0到1之间随机生成的一万个浮点数。但如果数据严重集中在某个局部所有元素挤进一两个桶桶内排序的复杂度就会集中爆发整体性能退化成桶内排序的复杂度。所以面试里问“桶排序什么时候失效”标准回答就是“数据分布不均匀时”。6.2 基数排序的实际应用价值基数排序把比较排序里的“整体比较”变成了“逐位比较”借助的仍然是稳定的桶排序或计数排序。以十进制数字为例从最低位开始每一位做一次稳定的计数排序做完整个序列就有序了。它为什么从低位开始因为低位排序后高位排序时稳定性能保证“高位相同时低位已经有序”这正是“先按低位排、再按高位排”这种多关键字排序的思路和我在第5章强调稳定性作用的逻辑完全一致。基数排序的实际应用范围比大多数人想象的要广。定长数字排序、定长字符串排序比如身份证号、电话号码、日期字符串、以及一些对定长键排序的数据库场景基数排序都能做到O(d(nk))其中d是位数。在k不大、位数也固定的情况下它是真的能逼近O(n)的排序方法。不过它也有明显的限制——只能排整数类型或能拆成定长位的东西浮点数、变长字符串、复杂对象都很难套用。面试考到基数排序的概率不高但如果你在产品项目里体现过“用基数排序优化定长键排序”往往会有不错的效果。我个人在实际项目里用过一次基数排序是给一批固定格式的短码做排序几百万条数据用快排大概要一秒多换成基数排序后整体降到零点几秒代价是额外开了一个计数数组。那次之后我对“非比较排序”的印象就不再是教材里的冷门章节了——它不是万能药但数据形状一旦匹配收益是显著的。遇到排序问题别急着套快排先问自己三个问题数据类型是什么数据范围多大稳定性有没有要求想清楚这三件事再决定用哪张底牌这才是常考排序真正想考察的能力。