资讯动态

从冒泡到堆排:比较排序算法全攻略与工程选型指南

发布时间:2026/10/9 14:20:27 来源:尧图企业网站定制
排序算法这四个字几乎每一个计算机科班出身的人都是从《数据结构》课上第一次认识的但能把比较排序这条线真正吃透的人并不多。我在一线写代码这些年面试过不少人也被人面试过一个很深的感受是很多人对冒泡、快排、堆排的理解停留在“背代码”层面一换数据分布就翻车一聊复杂度就含糊。这篇攻略会把比较排序家族从青铜到王者挨个捋一遍讲清楚每个算法的原理、代码、复杂度、稳定性以及真实工程里的选型逻辑不管你是刚准备算法面试的应届生还是在工作中需要高效排序的老手都能从这里拿到一套可以直接“抄作业”的方案。1. 从青铜到王者比较排序的整体设计思路1.1 段位划分与学习路径“从青铜到王者”这个游戏段位用来比喻排序算法学习其实很贴切因为它反映的不是“背几个模板”而是一个从“写得出”到“写得好”再到“懂得选”的能力进阶。我把比较排序的学习路径分成了四层青铜能凭记忆写出冒泡排序、选择排序跑通一组样例数据就觉得自己会了黄金开始关心稳定性、交换次数、最好情况和最坏情况知道冒泡和插入排序的flag优化怎么加铂金到钻石分治思想入场归并排序和快速排序成为常规武器能分析递归栈深度能处理枢轴选择、边界等细节王者堆排序融会贯通能把六种常见比较排序的复杂度、稳定性、空间占用、适用场景在几分钟内横向对比并且面对具体业务场景能立刻选出最合适的排序策略。为什么我要强调这个层次感因为我见过太多人跳过前面直接啃快排结果面试一要求“手写快排并分析为什么最坏是O(n²)”就愣住了或者在公司写代码时拿快排排一个小规模但几乎有序的数组反而跑不过插入排序。每个阶段的算法背后都对应着一组基本功简单排序是练循环边界和稳定性直觉分治排序是练递归和主定理堆排序则是练“用数据结构优化算法”的思维。这些能力是层层递进的跳层级学最后一定得回头补课。1.2 比较排序的统一框架与理论下界整个比较排序家族有一个共同点排序过程只通过两两元素之间的“比较”来决定谁大谁小不利用数据本身的特殊结构。冒泡、选择、插入、希尔、归并、快排、堆排序全是比较排序而计数排序、基数排序、桶排序这类属于非比较排序是下一个话题。既然核心动作是“比较”我们就得思考一个底线问题基于比较的排序最少需要多少次比较才能排完n个元素答案有一个漂亮的证明——决策树模型。n个元素的全排列一共有 n! 种排序算法本质上是在这些排列中确定正确的那一个。每一次比较最多产生“大于/小于等于”两种结果相当于在决策树上走一个分支k次比较最多区分 2^k 种情况。要想区分 n! 种排列就必须满足2^k ≥ n!两边取对数得到k ≥ log₂(n!) ≈ n·log₂n - 1.44n这意味着任何基于比较的排序算法平均时间复杂度的理论下界就是 O(n·log n)。归并排序、堆排序已经是渐进最优了快速排序在随机化之后平均也是 O(n·log n)。这给我们一个重要的认知锚点不要指望能写一个 O(n) 的万能通用比较排序那在理论上就不存在。所谓“王者”并不是突破这个下界而是在下界之内把常数、空间、稳定性这些工程指标玩到极致。2. 青铜到黄金三种O(n²)排序的真功夫2.1 冒泡排序相邻比较的入门课冒泡排序的思路最简单从头开始每一趟把相邻两个元素比较一遍如果前一个比后一个大就交换。一趟下来最大的元素就像气泡一样冒到了数组最后。重复 n-1 趟整个数组有序。void bubble_sort(int a[], int n) { for (int i 0; i n - 1; i) { int swapped 0; for (int j 0; j n - 1 - i; j) { if (a[j] a[j 1]) { int tmp a[j]; a[j] a[j 1]; a[j 1] tmp; swapped 1; } } if (!swapped) break; } }注意这里有个优化点加了swapped标志后一旦某一趟没有发生任何交换说明数组已经有序直接退出这样最好情况完全有序时间复杂度是O(n)这是青铜升黄金的关键一步。很多入门文章只讲外层 n-1 趟不讲这个提前退出的标志其实这个flag思想在工程上很常用判断“数据是否基本有序”时就是靠它。冒泡排序的时间复杂度最好O(n)、平均和最坏O(n²)空间O(1)稳定。它的缺点是交换次数多每次交换都是一次写操作在数组很大的时候缓存不友好。实际工作中我基本不会用它排序但面试时如果被要求“写一个最简单可运行的排序”冒泡是最稳妥的保底方案不容易写错、边界也好控制。一个容易踩的坑是内层循环的j n - 1 - i不写- i结果每趟都把已排好的尾部元素又比较一遍虽然逻辑没错但白白浪费时间跑大数据时就暴露了。另一个更常见的错误是把j 1写成j导致数组越界访问这种bug在在线评测里会直接报Runtime Error。2.2 选择排序用交换次数换比较次数选择排序的思路是第 i 趟在未排序区间[i, n-1]中找出最小值和第 i 个位置交换。外循环走 n-1 趟每一趟只有一次交换最多交换 n-1 次。void selection_sort(int a[], int n) { for (int i 0; i n - 1; i) { int min_idx i; for (int j i 1; j n; j) { if (a[j] a[min_idx]) min_idx j; } if (min_idx ! i) { int tmp a[i]; a[i] a[min_idx]; a[min_idx] tmp; } } }它和冒泡最大的区别是“交换次数少、比较次数固定”。无论数据一开始是什么顺序比较次数总是 n(n-1)/2所以最好、平均、最坏都是O(n²)。正因如此当我处理“元素拷贝开销极高、而比较开销很低”的数据时会选择选择排序而不是冒泡比如按某个只读键排序一个大结构体数组尽量减少交换次数能明显省时间。但选择排序有个致命弱点不稳定。举个例子数组[5, 8, 5, 2]第一趟找到最小值2把它和第一个5交换两个5的相对顺序就被破坏了。如果排序对象是带次要排序字段的记录这个不稳定性可能让结果不符合预期。所以选择排序更偏向教学算法工程上单独出场的机会不多它的“不稳定”倒是很好的面试提问点能讲清楚为什么不稳定才算真正过关。2.3 插入排序打牌理论里的隐藏高手插入排序太像我们现实生活中整理扑克牌的动作了从第二张牌开始把它和前面已经整理好的牌从右往左比较找到位置插进去。在数组实现中“插入”这个动作其实就是把比它大的元素依次往后挪一位。void insertion_sort(int a[], int n) { for (int i 1; i n; i) { int key a[i]; int j i - 1; while (j 0 a[j] key) { a[j 1] a[j]; j--; } a[j 1] key; } }插入排序的平均和最坏复杂度是O(n²)但最好情况是O(n)——完全有序时内层while一次都不走只是遍历一遍。这个特性让它成为所有O(n²)排序里最有实战价值的一个当你处理的数据规模小几十到几百条且可能已经接近有序时插入排序的常数极小甚至能跑赢大部分O(n log n)算法。这也是为什么我在后面讲快排优化时会提到“小区间用插入排序收尾”这是工业界的常规操作。我见过很多初学者把插入排序写成了“反向冒泡”就是在while里每找到一个比key大的元素就立刻交换一次位置这样虽然结果对但每次都写两次存储效率明显下降。正确做法是先把key存下来把比key大的元素整体右移最后再把key放到空出来的位置上。记住一个口诀插入排序先挖坑再平移最后填坑。3. 铂金到钻石希尔、归并与快速排序的精进路3.1 希尔排序给插入排序装上升级引擎希尔排序是插入排序的泛化版本。插入排序每次只交换相邻元素元素要移动很远时每一步只能挪一格效率很低。希尔排序先按一个较大的步长gap把数组分组对每组做插入排序然后逐渐缩小gap直到gap1做最后一次普通插入排序。void shell_sort(int a[], int n) { for (int gap n / 2; gap 0; gap / 2) { for (int i gap; i n; i) { int key a[i]; int j i - gap; while (j 0 a[j] key) { a[j gap] a[j]; j - gap; } a[j gap] key; } } }这里的核心逻辑就是把插入排序里的“步长1”换成“步长gap”。当gap较大时元素一次可以跳跃很长距离很快把“大体有序”的架子搭好gap逐步缩小后数据已经相当接近有序最后一次插入排序会非常快。希尔排序的复杂度分析很复杂取决于gap序列常见的“每次除以2”实现最坏是O(n²)但用Hibbard序列等改进序列可以达到O(n^(3/2))甚至更好。工程上希尔排序很少单独出现但它的思想很重要让数据先宏观有序再微观精排。很多批处理任务里“先粗排再精排”的优化思路就是从这来的。另外希尔排序是“不稳定”的因为gap分组会让相同元素的相对位置变掉这个特征也让它不适合作为对象排序的默认实现。3.2 归并排序稳定且可预测的分治典范归并排序的思路是分治把数组从中间砍成两半分别排序再把两个有序子数组合并成一个。它的递归结构天然适合用主定理分析T(n) 2T(n/2) O(n)解得时间复杂度稳定为O(n log n)。void merge(int a[], int left, int mid, int right) { int len right - left 1; int *tmp (int *)malloc(sizeof(int) * len); int i left, j mid 1, k 0; while (i mid j right) { if (a[i] a[j]) tmp[k] a[i]; else tmp[k] a[j]; } while (i mid) tmp[k] a[i]; while (j right) tmp[k] a[j]; for (int t 0; t len; t) a[left t] tmp[t]; free(tmp); } void merge_sort(int a[], int left, int right) { if (left right) return; int mid left (right - left) / 2; merge_sort(a, left, mid); merge_sort(a, mid 1, right); merge(a, left, mid, right); }归并排序两个重要特性第一稳定第二需要O(n)额外空间。稳定性来自合并时的比较条件a[i] a[j]当左右两个元素相等时先取左边的所以相等元素的原始顺序被保留。这就是为什么大多数编程语言的标准库在排序对象类型要保稳定时选择归并或其变种比如Java的Arrays.sort(Object[])和Python的sorted都走了类似Timsort的归并路线。归并排序的一个高频bug是把mid计算写成(left right) / 2在极端情况下可能整数溢出虽然现在大多数语言里int范围足够大但写left (right - left) / 2是更好的习惯。另一个容易错的地方是合并循环的边界写漏一个while导致剩余元素没复制回去。我建议在本地拿随机数组多跑几轮比对系统自带排序结果能早发现问题。归并排序的应用不止内存排序外部排序数据量大到不能全载入内存时就是它的经典场景把大文件切分成可以载入内存的块每块排序后写回再多路归并。我在处理海量日志时经常用到这个思路所以别看它“只是O(n log n)之一”它在数据工程里的地位是快排替代不了的。3.3 快速排序工程界常青树的三个关键优化快排也是分治但它和归并的区别在于“先partition再递归”选一个枢轴pivot把小于pivot的元素放左边、大于pivot的放右边每次partition之后枢轴就待在最终位置上然后递归处理左右两侧。最简单的Lomuto分区版本如下int partition(int a[], int left, int right) { int pivot a[right]; int i left - 1; for (int j left; j right; j) { if (a[j] pivot) { i; int tmp a[i]; a[i] a[j]; a[j] tmp; } } int tmp a[i 1]; a[i 1] a[right]; a[right] tmp; return i 1; } void quick_sort(int a[], int left, int right) { if (left right) return; int p partition(a, left, right); quick_sort(a, left, p - 1); quick_sort(a, p 1, right); }快排的平均时间复杂度是O(n log n)空间复杂度是递归栈的O(log n)但它不稳定而且最坏能达到O(n²)——当每次选到的pivot恰好是当前区间最小值或最大值时partition几乎不分割递归深度变成O(n)。最典型的触发场景是数组已经有序而你每次都取最后一个元素当pivot。所以工程上的快排从来不会这么“裸奔”至少要做三个优化枢轴选取三数取中left、mid、right三个位置的元素取中位数或者随机选pivot避免有序数组退化为O(n²)小区间插入排序当递归区间长度小于某个阈值比如16时改用插入排序收尾减少大量短递归的函数调用开销尾递归消除/迭代化递归只处理短的半边长的半边用循环处理把递归栈深度压到O(log n)。快排为什么是“工程界常青树”因为它在随机数据上常数极小、缓存友好而且原地排序不额外占内存。我自己的经验是凡是内存紧张、对稳定性没有硬性要求、数据规模又大的场景首选一定是快排家族。C语言库函数qsort、C的std::sort本质都是经过各种优化的快排变体。面试时手写快排一定要先明确“不要求稳定”同时把三数取中或随机化写入代码这样才能证明你理解退化问题的根源。4. 王者段位堆排序与全局视野下的选型智慧4.1 堆排序从完全二叉树到TopK的降维打击堆排序把数组看成一颗完全二叉树利用大顶堆的堆顶永远是最大元素这个性质每次把堆顶换到数组尾部再对剩余部分重新调整堆反复操作就完成了升序排序。void heapify(int a[], int n, int i) { int largest i; int l 2 * i 1; int r 2 * i 2; if (l n a[l] a[largest]) largest l; if (r n a[r] a[largest]) largest r; if (largest ! i) { int tmp a[i]; a[i] a[largest]; a[largest] tmp; heapify(a, n, largest); } } void heap_sort(int a[], int n) { for (int i n / 2 - 1; i 0; i--) heapify(a, n, i); for (int i n - 1; i 0; i--) { int tmp a[0]; a[0] a[i]; a[i] tmp; heapify(a, i, 0); } }建堆为什么要从n/2 - 1开始往前遍历因为n/2及其之后的节点全是叶子节点叶子自身已经是合法堆不需要调整。自底向上的调整可以做到O(n)建堆而不是朴素理解的O(n log n)。这个数学结论有点反直觉我推导过一遍每层节点的下滤代价和它的高度成正比总代价求和会收敛到O(n)。排序阶段的“交换堆顶重新下滤”一共执行n-1次每次下滤O(log n)这一阶段是O(n log n)。所以堆排序整体O(n log n)空间O(1)不稳定。堆排序的常数比快排大主要因为数组访问没有局部性会跳到相隔很远的下标缓存命中率低所以单纯排序时堆排序往往打不过快排。但堆结构本身的价值远超排序本身** TopK 问题和优先队列**是它的主场。我在实际业务里要从一亿条数据里找出最大的100条绝不会全排而是维护一个容量100的小顶堆遍历一遍数据每次遇到比堆顶大的元素就替换并调整时间复杂度O(n log k)k很小的时候相当于O(n)。这份“用堆解决TopK”的思维是从“会写堆排序”到“能把堆用在刀刃上”的王者级跨越。4.2 六种比较排序复杂度与稳定性一览到了这个位置应该把六种比较排序放在一张表里横向对比这是面试手写算法前最好的自查清单也是实际选型时的速查表算法最好平均最坏空间稳定性冒泡排序O(n)O(n²)O(n²)O(1)稳定选择排序O(n²)O(n²)O(n²)O(1)不稳定插入排序O(n)O(n²)O(n²)O(1)稳定希尔排序O(n log n)依gap而定O(n²)O(1)不稳定归并排序O(n log n)O(n log n)O(n log n)O(n)稳定快速排序O(n log n)O(n log n)O(n²)O(log n)不稳定堆排序O(n log n)O(n log n)O(n log n)O(1)不稳定注意两个特殊点快排在最好情况下不一定是O(n log n)如果每次partition都能将区间一分为二递归深度是log n层、每层O(n)所以最好确实是O(n log n)但有些优化版本在“所有元素相等”时会做特殊处理把复杂度压到O(n)。希尔排序的复杂度我只写了“依gap而定”因为它的完整数学分析要用到数论里的知识工程上记住“平均大概在O(n log²n)附近”就够了不需要背精确公式。4.3 语言标准库的排序选型里藏着什么秘密如果只看教科书可能会误以为排序就是“挑一个喜欢的算法硬写”。但真正让我对比较排序产生“全局视野”的是去翻各大语言标准库的排序实现你会发现它们都在做“算法杂交”C 的qsort通常是快排的工程变体配合插入排序处理小区间Cstd::sortintrosort先是快排递归过深时切成堆排序区间小于16时切成插入排序JavaArrays.sort(int[])DualPivotQuicksort双枢轴快排以及为小数组准备的插入排序JavaArrays.sort(Object[])TimSort一种稳定的归并变种特别擅长处理部分有序数据Pythonsorted/list.sort同样基于TimSort。这个事实给我最大的启发是** 工程排序没有银弹只有“根据数据形态组合算法”**。你看到的大规模排序框架无一例外都在做“快排保证平均效率、堆排兜底最坏情况、插入排序收割小规模区间”的组合拳。我们在自己写代码时也应该继承这种思维先评估数据规模、有序程度、稳定性要求、内存限制再决定用哪一种或者哪几种组合。这才是“王者段位”的定义——不是把某个算法背得最熟而是能在正确的时间调用正确的策略。5. 实战中我踩过的坑问题与排查策略5.1 八个高频翻车现场与诊断方法把近年来自测和帮别人review代码时常遇到的排序bug集中盘点一下每个都附上排查思路这些比单纯背原理更有用冒泡越界访问内层循环用了j n-i而不是n-1-i当j1 n时越界。排查方式是打印j的最大值和n的关系或者用ASan编译一次跑测试数据。插入排序写成了冒泡在while循环里每移动一位就交换一次导致频繁写内存。诊断方法很简单对比一下代码里key变量被赋值的次数理想情况下它只赋值两次存和回填。归并排序漏复制剩余元素两个子数组有一个先走完后另一个的剩余元素没写回临时数组或者没复制回原数组。测试用例用[1,3,5,2,4,6]这类左右长度不等的数据立刻就能暴露。快排递归爆栈有序数组固定取末尾作pivot时递归深度变成O(n)数据量大直接段错误。排查时在递归函数开头打印right-left能看到区间长度几乎不缩小。堆排序建堆起点错从n/2而不是n/2-1开始或者从0开始都会导致建完的堆不满足大顶堆性质。验证方式是排序完检查是否有序或者手动画一次堆交换过程。稳定性被“顺手优化”破坏比如在快排里把改成相等元素的相对顺序就可能乱掉。排查时需要构造带下标标记的测试数据排序后检查相同值对应的下标序列是否升序。二分/分治越界写递归排序时quick_sort(a, left, p-1)写成p1或者p会造成死循环或重复处理。最简单的方法是数据量为2时手动模拟一遍。误判复杂度把希尔排序的复杂度背成“稳定O(n log n)”面试就会被追问到哑口无言。我的建议是准备一张手写对照表面试前默写一遍能默写出来才是真掌握。5.2 从排序到面试算法的三个实战建议排序算法是很多高阶算法题的基础零件练排序不能只练“能跑通”。我在带新人时通常会给三个建议第一测试用例要覆盖“数据三态”完全随机、完全有序、全部相同。这三组数据能筛掉大部分边界bug尤其是快排的退化问题和堆排序的相等元素处理问题。再加上一组“几乎有序”比如只有两个元素位置反了这是考察插入排序和TimSort类算法最优场景的典型样本。第二学完排序后立刻去刷三道题巩固第K大元素快选partition思想的变种、TopK堆排思想、合并两个有序数组归并思想的子问题。这三道题做顺了说明你不是背会了排序而是把它拆成了可复用的算法组件。第三面试被问“请选择一个排序算法”时不要一上来就答快排。先反问数据规模、是否内存紧张、是否需要稳定、是否能修改原数组然后给出结论和理由。这种“先问需求再选型”的答题方式本身就是面试官想看到的工程素养。5.3 我总结的一页纸选型心法按我多年的使用体感把“到底该用哪个排序”浓缩成几句话数据量在几百以内直接插入排序代码简单、常数小数据量大、内存宽松、稳定优先归并排序或者交给语言标准库的TimSort数据量大、内存紧张、稳定没有要求快排变体记得随机化pivot或三数取中担心最坏情况退化到O(n²)上std::sort式的内省排序或者直接堆排序只关心最大/最小的TopK个元素别排序用大小为K的堆。最后一句话送给所有正在刷排序的人不要以“会默写”为终点要以“能设计测试、能分析退化、能权衡选型”为终点。当你面对一个真实场景能脱口说出“这里该用归并因为要稳定但它要O(n)空间所以我改成用链表版归并”的时候你自然就是别人眼里的排序王者了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑