☰
手写排序算法:五类经典排序模板与面试要点详解
2026/10/22 19:28:45 网站建设 项目流程

做面试官或者旁观别人笔试的时候,经常能看到一个很有意思的现象:一提到“排序”,很多人完全不慌,因为第一反应是直接调用内置排序函数;可一旦被要求“手写冒泡排序”或者“解释一下快速排序为什么快”,不少人就开始卡壳。这倒不一定是基础差,而是排序在日常开发里被当成了现成工具,很少有人把它当成训练边界控制和复杂度直觉的教材。

这篇文章就围绕刷题平台上最常被考察的五种经典算法——冒泡排序、选择排序、插入排序、快速排序、计数排序,把它们的核心思想、标准模板、易错点以及刷题场景中的真实用法讲透。不管你是刚准备面试的初学者,还是想系统整理排序模板的进阶玩家,应该都能从中捞到点东西。我会尽量用写业务代码时的大白话来讲,不堆术语,每一步都说明白“为什么这么做”。

1. 刷题场景下的排序算法:手写排序到底在考什么

先说一个很多人没想明白的问题:既然刷题平台允许直接调用sorted()、Arrays.sort(),为什么面试官还是执着于让你手写排序?

我的理解是,排序是少数几种能同时考察三项底层能力的题型。

第一项是对循环边界的敏感度。冒泡排序的内层循环到哪结束?选择排序的最小值下标从哪开始扫?插入排序的 while 条件顺序能不能写反?这些细节看起来小,但真发给候选人手写,十个人里至少有两个人会越界或者多算一轮。边界条件从来不是“细心”两个字能概括的,它背后是对数组索引和循环不变量的真正掌控。

第二项是对稳定性和原地性的理解。面试官很喜欢追问一句:“这个排序稳定吗?为什么?”你要是只背结论“选择排序不稳定”,却说不清楚是因为跨越式交换破坏了相对顺序,那这题基本就被判为死记硬背。

第三项是复杂度直觉。五种算法里,前四种是比较排序,最后一种是计数排序。为什么计数排序能突破 O(n log n) 的下界?因为它不比较。这背后牵扯出的是“比较排序理论下界”和“空间换时间”两个重要思维模型。刷题越到后期你会发现,真正考排序的题目,几乎都不是让你排个序就完事,而是让你用排序思想去解决“求第 K 大”“按频率排序”“统计逆序对”这类变形题。

所以我的建议是:不要嫌弃排序太基础,把五种算法当成五把钥匙。它们各自锁着一个思维训练点——冒泡锁相邻交换,选择锁极值定位,插入锁局部有序,快速锁分治划分,计数锁值域映射。把这五把钥匙都摸熟了,后面做很多题都能顺手开锁。

2. 冒泡与选择排序:边界条件决定成败的基础功

2.1 冒泡排序的正确姿势与常见错法

冒泡排序的思想大概是最容易解释的:从前往后两两比较,如果前一个比后一个大,就交换,这样每一轮都会把当前未排序区域里的最大值“冒”到最后面。

一张 Python 模板如下:

def bubble_sort(nums): n = len(nums) for i in range(n): swapped = False for j in range(0, n - i - 1): if nums[j] > nums[j + 1]: nums[j], nums[j + 1] = nums[j + 1], nums[j] swapped = True if not swapped: break return nums

代码里最容易被写错的地方是内层循环的n - i - 1。为什么不是n - 1?因为每跑完一轮i,末尾的i个位置已经放好了本轮最大的那几个数,它们不需要再参与比较。如果写成n - 1,功能上不错,但会多出大量无效比较。面试时让候选人优化,八成要先指出这一点。

另外一个常被忽略的细节是swapped标记。这个标记的意义在于:如果某轮扫描下来一次交换都没发生,说明数组已经整体有序,直接跳出循环即可。加了这一步后,对于完全有序的数组,冒泡排序只需要一轮扫描,复杂度退化到 O(n)。这个细节如果你能在代码里主动写出来,会是个不错的加分点。

冒泡排序的稳定性和坑在哪里?

  • 稳定性:相等元素不会交换,所以是稳定排序。
  • 空间复杂度:原地交换,O(1)。
  • 时间复杂度:平均和最坏 O(n²)。

说实话,刷题时我几乎不会用冒泡排序做答案。它真正的价值在于理解“相邻交换”这个基本动作。比如有一类题:只允许交换相邻元素,问最少交换多少次能把数组排成某种顺序,这个问题的本质就是在求逆序对数量,而逆序对统计的标准做法是归并排序。冒泡排序其实就是“每次只消除一个逆序对”的朴素实现,理解了这层关系,你再去学归并排序的优化逻辑会顺畅得多。

2.2 选择排序的不稳定性与“前 k 轮”思路

选择排序的核心思想更直接:每轮从未排序部分挑出最小值,放到已排序部分的末尾。

def selection_sort(nums): n = len(nums) for i in range(n): min_idx = i for j in range(i + 1, n): if nums[j] < nums[min_idx]: min_idx = j nums[i], nums[min_idx] = nums[min_idx], nums[i] return nums

这个代码比冒泡还好写,但它的经典坑是稳定性。举个例子:[5a, 5b, 1],第一轮找到最小值 1,直接和第一个位置的 5a 交换,结果变成[1, 5b, 5a],两个 5 的相对顺序反了。原因是选择排序做的是一次“跨越式交换”,而不是相邻交换,它可以把一个元素直接扔到很远的位置,所以相同元素的相对顺序没法保证。

这提醒我们一件事:刷题时如果明确要求稳定排序,选项里就不要考虑选择排序,也别考虑快速排序的基础实现,优先考虑冒泡、插入、归并、计数。

选择排序在刷题里的一个变体思路值得知道:如果只要求选出最小(或最大)的前 k 个元素,理论上可以只跑 k 轮选择排序,每轮 O(n),总复杂度 O(nk)。我在一次笔试里就这样干过,题目是“从一百万个整数里选出最小的 100 个”,结果提交超时。事后复盘才意识到,这种场景下正确选择是建一个小顶堆或者直接用快速选择,而不是选择排序跑 100 轮。这个问题也说明一个道理:面试考选择排序,不是让你拿它当最终答案,而是看你知不知道它的复杂度边界在哪里。

3. 插入排序:被低估的工程价值与链表场景

3.1 扑克牌式的逐个插入逻辑

插入排序的思路人人都会:打牌时抓到一张新牌,把它插到手里已经排好序的牌堆中的合适位置。代码写出来也相当精简:

def insertion_sort(nums): n = len(nums) for i in range(1, n): tmp = nums[i] j = i - 1 while j >= 0 and nums[j] > tmp: nums[j + 1] = nums[j] j -= 1 nums[j + 1] = tmp return nums

很多人第一次写插入排序,会把它写成一个for内部套for、每次都交换相邻元素的版本。那种写法也能跑,但效率差一些,而且不够优雅。正确的姿势是先把当前元素tmp存下来,前面的元素逐个往后挪,最后把tmp放到空出来的位置。这种做法把“交换”变成了“覆盖”,数组访问次数少一些,也更贴近工业实现。

这里有个稳定性的关键细节:while 判断条件是nums[j] > tmp,而不是nums[j] >= tmp。如果写成大于等于,遇到相同元素时还会继续往后挪,把后来的元素插到原本相同元素的前面,就破坏了稳定性。正确写法是在相等时停止,让新元素停在相同元素之后,从而保持稳定。

3.2 为什么说它在工程里被低估

插入排序表面上就是个 O(n²) 的排序,但它有两个其他 O(n²) 算法没有的特性。

第一个特性是“自适应”。如果待排序数组基本有序,每次 while 通常只比较一两次就能找到位置,整体复杂度可以降到接近 O(n)。刷题中这个特性通常体现在“动态维护有序序列”的场景:比如你已经维护了一个有序数组,现在频繁地插入一个新值,要求插入后仍然有序。直接用插入排序的一趟逻辑,把新值从数组末尾往前找位置,往往比每次调用sorted()重建整个数组更快。

第二个特性是“低常数 + 局部性”。在小规模数据上,插入排序的真实运行速度不慢,甚至能打赢部分 O(n log n) 算法。很多标准库的排序实现(比如快速排序的工程优化版)内部都有一个阈值:当待排区间长度小于 16 或 32 时,停止递归,改用插入排序收尾。原因是快排递归调用的函数栈开销在小区间上会淹没排序收益。这个优化你完全可以在自己写快排时应用。

另外,插入排序是链表排序的天然好选择。因为链表不能随机访问,很多基于“选中间元素”的排序实现起来都很费劲,但插入排序本来就只依赖“从当前位置往前找插入点”,而这个操作在链表里也就是几个指针的事。刷题平台上的“链表插入排序”题就是直接把插入排序的逻辑迁移到链表上,理解了数组版本,链表版本只需要把“往前挪”改成“遍历找插入位置”。

我实际写代码时栽过一个小跟头:while 条件里如果先判断nums[j] > tmp再判断j >= 0,数组下标会越界。正确写法必须把j >= 0放在前面,Python 的短路求值会保证 j 为 -1 时不再访问数组。这个排列顺序虽然只需要一秒钟就能改过来,但面试现场紧张时很容易写反,建议背模板时顺便把这一条背进肌肉记忆。

4. 快速排序:partition 是灵魂,快速选择是变身

4.1 经典快排模板与两种分区写法

快排为什么叫“快速”?因为它把一个大数组切成两半,各自递归排序,每一层都做线性扫描,理想情况下总共 O(n log n) 次比较。

写快排之前,必须先写对 partition。我最常用的是 Lomuto 分区,它的逻辑简单且不容易出边界问题:

def partition(nums, left, right): pivot = nums[right] i = left for j in range(left, right): if nums[j] < pivot: nums[i], nums[j] = nums[j], nums[i] i += 1 nums[i], nums[right] = nums[right], nums[i] return i def quick_sort(nums, left, right): if left >= right: return p = partition(nums, left, right) quick_sort(nums, left, p - 1) quick_sort(nums, p + 1, right)

Lomuto 分区的理解方式是:维护一个“小于 pivot 的区域边界指针 i”,每遇到一个比 pivot 小的元素,就把它扔进这个区域,区域右扩一格。把整个区间扫完之后,pivot 本身的位置自然就在 i 上。

另一种更高效的 Hoare 分区则是左右双指针相向移动:左边找一个大于 pivot 的,右边找一个小于 pivot 的,然后交换,直到两个指针相遇。Hoare 的交换次数更少,但边界处理更麻烦,尤其容易在指针相遇时写出死循环。

我自己面试时一般先写 Lomuto,因为正确性优先。等回答完正确性之后,再主动说“如果用 Hoare 分区可以常数更快,但边界情况需要多处理”,这种主动补充分解动作其实更见功底。

4.2 最坏情况从哪来,以及三路快排的思路

快排看起来很完美,但它有一个致命弱点:当数组已经有序或者接近有序、且每次选到的 pivot 都是最大或最小值时,一次分区只能分出一个元素去固定位置,递归深度变成 O(n),整体复杂度退化成 O(n²)。

解决办法常见的就三个:

  1. 随机选 pivot。从概率上避免每次都踩到最坏点,这是最简单有效的办法。
  2. 三数取中。取左、中、右三个位置的中位数作为 pivot,工程实现里很常用,因为能兼顾“数据有序”和“随机性较差”两类场景。
  3. 三路快排。当数组中存在大量重复元素时,普通快排会反复处理重复值。三路快排把区间分成“小于 pivot”“等于 pivot”“大于 pivot”三段,只递归两端,中间全部跳过。这个思路还有一个面试中很著名的应用场景叫“荷兰国旗问题”:数组里只有 0、1、2 三种值,要求一次遍历排好序。用到的就是三路分区的思想。

关于递归栈的空间复杂度,很多人会忽略。快排不是 O(1) 空间,因为递归需要栈空间,平均是 O(log n),最坏退化成 O(n)。面试官问“快速排序空间复杂度是多少”的时候,你答“平均 O(log n)”然后再补一句“最坏 O(n),但随机化可以规避”,就比单背一个 O(log n) 要好得多。

4.3 快速选择:求第 K 大元素的骨灰级套路

快排最精彩的变形,是把“递归排序整个区间”改成“只递归目标所在的区间”。因为在 partition 之后,pivot 的位置 p 已经意味着:在最终排序结果中,它是第 p 个数(从 0 计数)。那我们要找第 k 大的元素,就只需要判断目标索引和 p 的关系,决定往左还是往右。

第 K 大的常见转换是先求 len(nums) - K,也就是“从 0 开始计数的第 len-K 小”。然后套快速选择:

def quick_select(nums, left, right, k): if left == right: return nums[left] p = partition(nums, left, right) if p == k: return nums[p] elif p < k: return quick_select(nums, p + 1, right, k) else: return quick_select(nums, left, p - 1, k)

这里有几个实操细节值得重点标出来:

  • 递归区间是p + 1和p - 1,千万不要包含 p 本身,否则区间永远不变,直接死循环。
  • 快速选择平均复杂度 O(n),但最坏也是 O(n²)。同样用随机 pivot 来规避。
  • 该算法会原地修改数组,因为 partition 本质上在交换元素。如果题目要求不能改动原数组,要么复制一份,要么改用堆。
  • 如果你调用一次 partition 之后发现 p 恰好等于目标索引,立刻返回,不用再排剩下的任何元素,这就是“部分排序”思想的精髓。

刷题时,很多“前 K 大”或“第 K 大”的题目都能用快速选择,尤其是数据规模很大、堆的 O(n log k) 也可能超时的场景。但需要提示的是,快速选择的结果依赖原数组被修改,如果后续还要用原数组,必须提前备份。

5. 计数排序:当值域有限时用空间换时间的非比较选手

5.1 基础版计数排序:统计次数后直接填充

计数排序跳出“比较”的框架,因此能在特定条件下做到 O(n + k),其中 k 是值域大小。核心思想只有一句话:用数组下标当值,下标对应的元素存“这个值出现了多少次”。

def counting_sort(nums): if not nums: return [] max_val = max(nums) count = [0] * (max_val + 1) for x in nums: count[x] += 1 res = [] for v, c in enumerate(count): res.extend([v] * c) return res

用生活化的例子解释就是:一个班同学年龄只有 6 岁到 12 岁,老师不需要两两比较谁大谁小,只需要按年龄报数,统计“7 岁几个人,8 岁几个人”,然后按年龄从小到大把所有同学列出来。

这段基础版代码在刷题里能应对大多数“非负整数、范围不大、不必稳定”的场景。但要注意它的限制条件:

  • 数据必须是非负整数,或者可以映射成非负整数。负数要先平移区间。
  • 值域不能太大。比如max_val到 10⁹,count数组直接爆炸。
  • 基础版不是稳定排序。因为它是按“数值从小到大 + 计数”填充的,完全丢掉了原始顺序。

5.2 稳定版计数排序:前缀和与反向填充

如果题目要求稳定,也就是相同值的元素在输出时保留原始相对顺序,那就需要升级成前缀和版本的计数排序。

def counting_sort_stable(nums): if not nums: return [] max_val = max(nums) count = [0] * (max_val + 1) for x in nums: count[x] += 1 for i in range(1, len(count)): count[i] += count[i - 1] res = [0] * len(nums) for x in reversed(nums): count[x] -= 1 res[count[x]] = x return res

这里的关键是最后一步为什么要反向遍历原数组。前缀和count[x]表示“小于等于 x 的元素总共有多少个”,那 x 在结果数组中的最后一个位置就是count[x] - 1。从原数组末尾开始扫描,每放一个 x,就把count[x]减一,这样遇到第二个相同的 x 时,它会被放到那个位置的前一格,从而保持相对顺序不变。

我在一次刷题中就吃过基础版的亏:题目要求把任务按出现次数从高到低排序,如果次数相同则按原始顺序输出。我第一版直接用了基础版计数排序,结果相同次数的任务顺序全乱了,提交后一脸茫然,半天才反应过来是稳定性的问题。后来加上了前缀和与反向填充,一次通过。

5.3 计数排序在刷题中的高频场景

计数排序最常见的刷题场景有这么几类:

  1. 字符串和字母排序。比如只包含小写字母的字符串,开 26 个大小的桶,O(n) 就能排完。
  2. 按频率排序。先统计每个元素出现次数,再把出现次数作为“值”去排序。很多“按频率升序/降序输出”的题目,本质就是建一个频次数组,然后对频次值做计数排序。
  3. 年龄、分数、星级这类小范围数值排序。

还要提醒一点:别把计数排序和桶排序、基数排序混为一谈。计数排序是每个值一个桶;桶排序是把值域分成若干区间,区间内再做其他排序;基数排序是按数字的位进行多轮排序。三者各有各的适用前提,但评论区里最常见的误区就是管桶排序叫计数排序。

6. 五种算法横向对比与刷题时的决策思路

6.1 一张表说明全部关键属性

排序算法平均时间复杂度最坏时间复杂度额外空间稳定性是否原地
冒泡排序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(log n)不稳定是
计数排序O(n + k)O(n + k)O(k)可稳定实现否

这张表不是拿来背的,面试官大概率会追问“为什么快速排序平均 O(n log n)”“为什么计数排序能 O(n)”这类问题。你要能做到每一条都能展开说明。

6.2 刷题时看到题目条件怎么选排序

拿到一道题,先别急着写排序代码,先看数据规模和数据特征。

  • 如果 n 很小(比如小于 50),并且需要稳定排序,直接插入排序最简单。
  • 如果只要求选出最大或最小的某个值,别用完整排序,用快速选择或堆更合适。
  • 如果数据是有限范围的整数(比如年龄、分数),直接用计数排序把复杂度压到 O(n)。
  • 如果数组很大,且对稳定性没有要求,快速排序配上随机 pivot 是最经典的选择。
  • 如果数组基本有序,插入排序的实战表现会出奇地好,虽然理论复杂度还是 O(n²)。

我也总结过一个简单口诀:范围小用计数,基本有序用插入,第 K 个用快选,稳定性要求高就别碰选择和快排。

6.3 把模板刻进肌肉记忆的练习方法

关于模板的背诵,我见过很多错误的练习方式:拿那五种排序每天默写二十遍,但只是默写代码,不理解变量含义。这样一旦题目变形就僵住了。我更推荐“三步练习法”:

第一步,先看懂模板。每行代码都要能回答“这个变量的作用是什么”“这个循环为什么是这个边界”。

第二步,改一个细节跑一遍。比如把插入排序的>改成>=,观察结果变化,用肉眼验证稳定性理论。

第三步,做两道变形题。比如分别用快速选择和堆排序做同一道“求第 K 大元素”题,对比两种解法在性能上的差异。

我在刷题平台上见过很多人直接用内置sorted()就把题过了,这当然没错,但如果你准备的是面试,一定要自己手写过至少一遍基础模板。面试官看的是你的思考过程,而不是最终结果。

6.4 排序思想延伸出去的经典变形题

排序从来不是终点,它在刷题里更像一把万能扳手。真正的高频考点往往是它的变形:

  • 求逆序对数量:核心是归并排序,冒泡排序的朴素解释就是每次交换消除一个逆序对。
  • 求第 K 大 / 前 K 小:核心是快速选择或堆。
  • 荷兰国旗问题:三路快排的直接应用。
  • 按奇偶排序:双指针,不需要完整排序。
  • 按字母频率排序:先统计,再用桶或计数排序。

这些变形题看起来五花八门,但内核都指向同一个能力:理解排序不是“排完就完”,而是“排完之后每个位置都有明确语义”。一旦你有了这种视角,很多题就不会只停留在调用sorted()的水平上。

最后分享一点个人体会。我早期刷题时特别喜欢追求代码简短,能用一行sorted()解决的绝不写第二行。后来慢慢发现,真正让我在面试里稳住阵脚的,反而是那些看起来最基础的排序模板。它们逼着我把循环边界、稳定性、空间复杂度这些概念反复打磨到直觉层面,再遇到复杂变形题时,就不容易慌。建议你也别嫌这五类排序简单,抽一个下午,把它们一个个手写跑通,再试着回答一遍“为什么这样写不会越界”,那感觉和单纯看文章是完全不同的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询