ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深入理解内部排序与外部排序:九大算法对比及工程实践

深入理解内部排序与外部排序:九大算法对比及工程实践 排序这件事几乎是每个写程序的人迟早都要面对的一道坎。你翻开源码、看框架底层甚至查数据库执行计划到处都能看到排序的影子。但真要说清楚“内部排序”和“外部排序”这两个词的区别很多人又容易卡住——面试的时候能背出八大排序、九大排序的名字可真到实际场景里内存放不下数据该怎么办却往往讲不出个所以然。这篇文章我想换个角度用九大排序算法作为线索把内部排序和外部排序这组概念彻底讲透。你不仅能搞清楚快排、归并、堆排序这些经典算法各自适合干什么还能理解为什么外部排序的核心思路和内部排序完全不同。无论你是正在准备面试的学生还是工作中需要处理海量数据的工程师这篇文章都值得花几分钟读完。1. 内部排序与外部排序的本质差异1.1 问题的起点数据到底能不能全部放进内存内部排序和外部排序的划分标准其实非常朴素排序过程中所有参与排序的数据能否一次性载入内存。如果数据量小小到内存完全可以容纳那么排序操作全部在内存中完成CPU直接访问内存里的数据不需要和磁盘、网络打交道这类排序就叫内部排序。我们平时写的冒泡、快排、归并排序默认情况下都是内部排序。如果数据量大大到内存装不下排序过程中必须把数据一部分一部分地调入内存、处理完再写回磁盘这类排序就叫外部排序。外部排序不仅仅是“数据量大”这么简单它的核心难点在于磁盘的读写速度和内存的访问速度差着好几个数量级你不能像内部排序那样随心所欲地访问数据。这里的判断标准不是“数据占多少字节”而是“能放进内存的数据量占全部数据的比例”。举个直观的例子你机器有16GB内存要对一个20GB的文本文件排序这就必须用外部排序的思路。但如果数据只有8GB理论上内存够用可是操作系统还有其他进程在跑内存不可能全部让给你所以工程上往往也会退而求其次用外部排序的思路来做。1.2 两套逻辑两个维度内部排序和外部排序的差异不是简单地把同一个算法换个地方跑而是整个优化目标都变了。内部排序的目标是减少比较次数和交换次数因为CPU的运算速度很快瓶颈往往是数据搬移的逻辑。所以你会看到快排、堆排序这种精心设计比较策略的算法。外部排序的目标是减少磁盘I/O次数因为一次磁盘寻道的时间可能高达几毫秒而内存排序一千万个整数也只要几百毫秒。在外部排序里计算比较次数反而没那么重要了重要的是怎么让数据在磁盘和内存之间的搬运次数尽可能少。这个区别直接影响算法设计。你不可能在外部排序里用快排那种递归分治的写法因为快排需要随机访问整个数据范围而磁盘上的数据压根不支持这种访问方式。外部排序的经典方案是“归并”因为归并排序天然是顺序访问数据的非常适合磁盘这种顺序读写快、随机读写慢的存储介质。1.3 典型应用场景从面试题到生产环境内部排序的应用场景你每天都在接触搜索引擎对搜索结果按相关性排序、电商系统对商品按价格排序、数据分析中对一批样本做预处理这些数据量通常都在内存容量范围内。外部排序的高频场景则集中在数据库和分布式系统里。比如数据库执行ORDER BY时如果排序的数据量超过sort_buffer_sizeMySQL就会在磁盘上创建临时文件用外部排序的方式处理。再比如Hadoop的Shuffle阶段、Spark的Sort Shuffle本质上都是外部排序的工程实现。你要是做过大数据平台调优对“溢写”、“合并”这些名词肯定不陌生它们背后都是外部排序的机制。2. 九大排序算法全景拆解2.1 九大排序是哪些“九大排序算法”这个说法没有严格统一的标准但业界比较常见的组合是冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序、堆排序、计数排序、基数排序。前七个是非线性时间比较排序后两个是线性时间非比较排序。注意有些教材会把计数排序和桶排序分开算那可能就变成十大排序了。但不管怎么分这九个算法已经涵盖了排序算法的主要设计思想暴力、分治、堆结构、空间换时间、按位处理等等。2.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^1.3)O(n)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 k)O(n k)O(n k)O(k)稳定基数排序O(d(n r))O(d(n r))O(d(n r))O(n r)稳定这里的k是计数排序中数据的取值范围d是最大数字的位数r是基数比如十进制就是10二进制就是2。2.3 各算法的核心思想与适合场景冒泡排序和选择排序是教学意义的算法工程上基本不会用。但冒泡排序有一个特点值得记住如果某一趟没有发生交换说明序列已经有序可以提前终止所以对于近乎有序的数据优化后的冒泡排序其实效率不低。插入排序在工程中的出场率远超你的想象。虽然它的平均复杂度是O(n²)但它的常数因子极小而且对于“基本有序”的数据表现非常好近乎O(n)。很多工业级的快排实现在递归到子数组规模小于一定阈值比如16或32时会切换到插入排序而不是继续递归。希尔排序是插入排序的改进版通过不断缩小间隔来让数据逐步接近有序。它的时间复杂度分析非常复杂至今没有精确的解析解但实测中性能远优于O(n²)算法适用于中等规模数据的排序。归并排序是唯一一个“最坏情况也能保持O(n log n)”的稳定排序算法这一点在外部排序中尤其重要。后面我会详细说外部排序几乎就是归并排序思想在磁盘上的延伸。快速排序是应用最广泛的排序算法。它平均性能极佳但最坏情况会退化到O(n²)。工程上有两个主要规避手段一是随机化选择基准元素二是三数取中从首、中、尾三个位置取中位数作为基准。著名的std::sort就是这么干的它会在快排、堆排、插入排序之间动态切换。堆排序的空间复杂度是O(1)这是它的核心优势。但它有一个致命弱点对缓存极不友好因为堆排序的访问模式是跳跃式的无法利用CPU缓存的顺序预取机制。所以虽然理论上堆排序的时间复杂度和快排一样实际工程中跑起来往往比快排慢不少。计数排序和基数排序是“空间换时间”的典型代表。计数排序要求数据范围有限且已知比如对0到100分的考试成绩排序k值很小效率极高。基数排序则通过逐位处理的方式把排序问题拆解成多轮计数排序适合对整数或定长字符串排序。3. 内部排序的工程落地与选型经验3.1 为什么面试爱考快排和归并面试官爱考快排和归并绝不只是因为这两个算法经典。更重要的原因是它们两个代表了完全不同的两种分治策略。快排是“先分后治”先把数组按照基准元素切分成左右两部分让左边都小于基准、右边都大于基准然后递归处理左右两部分。关键在于partition这一步它决定了元素的最终位置。归并是“先治后分”先把数组对半拆分递归排序左右两半然后合并两个有序数组。合并过程需要额外的辅助数组所以空间复杂度是O(n)。这两种策略直接对应了两种工程场景。快排适合内存排序因为它原地排序、缓存友好归并适合外部排序因为它顺序访问数据、稳定可控。3.2 C实现中需要避开的坑我见过很多人写快排一上来就写最简单的版本结果在工程应用中频繁踩坑。最典型的问题是递归深度。如果输入数据已经有序而你选择的基准恰好是第一个元素那么快排的递归深度会达到n层直接栈溢出。解决办法是前面提到的三数取中或者用随机化选基准。另一个常见问题是小数组递归带来的性能浪费。快排在递归到子数组规模很小时比如元素个数少于16插入排序的性能反而更好因为插入排序对小数组没有递归调用开销而且充分利用了数据局部性。std::sort实际实现里就有这个优化。还有一个细节容易忽略partition过程中元素的交换顺序会影响稳定性。快排天然不稳定如果你在业务代码里需要稳定的排序就不要试图用快排去改直接上归并排序。下面是工程中常见快排写法的关键片段int partition(vectorint arr, int low, int high) { // 三数取中避免最坏情况 int mid low (high - low) / 2; if (arr[mid] arr[low]) swap(arr[mid], arr[low]); if (arr[high] arr[low]) swap(arr[high], arr[low]); if (arr[high] arr[mid]) swap(arr[high], arr[mid]); swap(arr[mid], arr[high]); // 把基准放到最后 int pivot arr[high]; int i low; for (int j low; j high; j) { if (arr[j] pivot) { swap(arr[i], arr[j]); i; } } swap(arr[i], arr[high]); return i; } void quickSort(vectorint arr, int low, int high) { while (low high) { if (high - low 16) { // 小数组用插入排序 insertionSort(arr, low, high); break; } int pi partition(arr, low, high); // 递归处理较短的区间迭代处理较长区间控制递归深度 if (pi - low high - pi) { quickSort(arr, low, pi - 1); low pi 1; } else { quickSort(arr, pi 1, high); high pi - 1; } } }这里有一个优化很多人不知道递归改成尾递归形式只递归较短的那半边长的那半边用循环继续处理。这样可以保证递归深度不超过O(log n)有效避免栈溢出。3.3 RTL实现排序的硬件视角有些做FPGA或ASIC的工程师会遇到“9个值排序算法RTL实现”这种需求本质上是把软件排序算法用硬件描述语言实现。这个场景和软件工程完全不同CPU上跑排序使用ALU和内存而硬件排序通常追求的是“用组合逻辑的并行性换取延迟”。RTL里最常见的排序实现思路是“排序网络”也就是用一系列比较换器comparator组成固定的比较交换序列。比如Batcher归并网络和奇偶归并网络它们的优势在于比较操作是并行执行的和软件排序那种“一次比较一个”完全不同。对于9个数据的排序硬件上可以设计成三层结构先把数据分成多组做并行比较交换再对结果做归并。这就用到归并排序的思想了。如果你只处理固定数量的数据排序网络的资源消耗是可以精确估算的比较器的数量决定了组合逻辑面积。但有一个坑要特别注意排序网络要求所有比较操作同时有效这意味着输入数据必须先全部寄存到位否则时序上会有问题。在FPGA实现时需要加流水线寄存器来切割组合逻辑路径否则时钟频率会被比较链拖垮。4. 外部排序的完整实现思路4.1 外部排序为什么绕不开归并回到开头的问题当数据量超过内存容量时内部排序的算法几乎全部失效。快速排序需要随机访问整个数组范围堆排序需要频繁交换远距离元素这些都和磁盘的物理特性相悖。磁盘的顺序读写速度可以跑到几百MB/s但随机读写一旦遇到寻道操作速度立刻掉到几十KB/s级别。所以外部排序的第一个原则就是尽量顺序读写避免随机访问。归并排序完美符合这个要求。它的核心操作是“把两个有序序列合并成一个有序序列”这个操作只需要顺序扫描两个输入和一个输出完全可以靠顺序I/O完成。也正因如此所有主流的外部排序实现都以归并为核心骨架。4.2 两阶段法先划分归并段再归并经典的外部排序是两阶段法。第一阶段叫做“划分归并段”把大文件切分成若干个能装进内存的小块每个小块在内存中排序后写回磁盘。每个有序的小块就是一个归并段run。假设数据总量是N内存能容纳的数据量是M那么初始归并段的数量大约是N/M。第二阶段叫做“归并阶段”把多个归并段合并成一个更长的归并段。最基础的做法是二路归并也就是每次只合并两个归并段但这样需要循环log2(N/M)趟每趟都要全量读写一遍磁盘I/O开销太大。工程上一般用多路归并一次合并k个归并段这样归并趟数就减少到logk(N/M)。以排序10GB数据、内存可用1GB为例初始归并段数量是10个。如果用二路归并需要4趟合并如果用10路归并一遍就能直接归并完成只需要读写两遍磁盘一遍生成初始归并段一遍做最终归并。差距非常明显。4.3 多路归并的胜负手败者树多路归并听起来简单但实现起来有一个性能陷阱如果每轮合并都要对这k个候选元素做一次完整的比较找出最小值时间复杂度是O(k)整体归并的时间复杂度会变成O(nk)k太大时性能会急剧下降。解决办法是使用败者树。败者树是一棵完全二叉树叶子节点存放k路归并段的当前元素内部节点记录的是“败者”——即两个孩子中较大的那个的索引。树根存放的是全局最小值。每次选出最小值后只需要从对应的叶子节点开始向上调整log2(k)次比较就能得到下一个最小值整体比较次数从O(nk)降到了O(n log k)。具体实现上很多开源项目用的是“置换选择排序”配合败者树这样生成的归并段长度平均可以做到内存容量的2倍进一步减少归并趟数。下面是多路归并中败者树的核心结构示意class LoserTree: def __init__(self, k): self.k k self.leaves [None] * k # 每个归并段的当前元素 self.tree [0] * k # 内部节点记录败者索引 self.tree.append(0) # tree[k] 存放最终胜者 def adjust(self, idx): # idx 是刚刚取出元素的归并段编号 parent (idx self.k) // 2 while parent 0: if self.leaves[idx] self.leaves[self.tree[parent]]: # idx 是败者记录在树中胜者继续向上比较 self.tree[parent], idx idx, self.tree[parent] parent // 2 self.tree[self.k] idx # 最终胜者用败者树实现100路归并非常稳定实测下来比直接线性查找最小值快了接近一个数量级。4.4 外部排序的进阶技巧与参数计算在实际生产环境中外部排序不可能只靠教科书上的两阶段法打天下还需要几个关键技巧。第一个技巧是双缓冲。磁盘I/O是阻塞的如果归并过程中等磁盘把数据读进来再开始做比较CPU就一直在空转。双缓冲的思路是一块缓冲区做归并计算另一块缓冲区同时进行磁盘预读两块轮流切换让CPU和磁盘并行工作。第二个技巧是堆排序在外排序中的应用。虽然归并是骨架但在生成初始归并段时堆结构可以减少比较次数。用堆排序在内存中处理一个数据块时间复杂度是O(n log n)比冒泡快很多也适合内存受限的场景。第三个技巧涉及参数设计。内存分配比例很关键假设你有1GB内存做外部排序通常可以把250MB分给输入缓冲区、250MB分给输出缓冲区剩下500MB作为归并段排序的工作内存。如果你的归并路数更大需要按比例适当缩减缓冲区大小防止内存溢出。这里给一个参数计算的基本方法如果内存限制是M归并路数是k那么输入缓冲区至少需要k个每个大小至少是B字节输出缓冲区至少1个大小至少B字节。工作内存也就是用来排序归并段的至少需要2B字节。所以M的最小值是(k1)B 2B (k3)B。反过来如果你知道M和B就能估算出最大可行的归并路数是M/B - 3。举个例子内存限制1GB磁盘块大小256MB那么归并路数最多就是1GB / 256MB - 3 1这条路走不通。但如果块大小定为64MB归并路数最多就是16 - 3 13基本够用。所以块大小的选择直接决定了你能用多少路归并这个账必须提前算清楚。5. 常见问题与排查心得5.1 外部排序为什么比预想中慢得多如果你自己实现了外部排序跑起来发现速度远低于预期最常见的原因就是随机I/O。很多人以为外部排序只要用了归并就是顺序访问但实际操作中如果归并段的文件描述符管理不当或者磁盘碎片太多操作系统层面还是会频繁触发寻道。排查办法是使用iostat这类工具观察磁盘的读写特性。如果发现每次I/O的数据量远小于设置的缓冲区大小说明随机读的情况很严重。这时候需要检查每个归并段的文件是否连续存储缓冲区是否真的按预期大小读取以及是否存在频繁的fsync调用。还有一个隐蔽的坑是系统页缓存。你读文件的时候操作系统可能会把部分数据缓存在内存里表面上看起来I/O很快但实际上内存已经不够用了。这种问题在数据量刚过内存阈值时特别容易出现——你以为自己在做外部排序其实一半的数据都在系统缓存里性能数据会非常迷惑。5.2 排序结果不稳定排查方向是什么如果业务上需要稳定的排序跑出来的结果却经常变最可能的原因是你用了不稳定的排序算法。这个问题的坑在于很多语言的排序接口并不能保证稳定性。比如C的std::sort是不稳定排序如果你传入的是自定义对象而不是简单元素相等的元素之间顺序不一定能保持。而std::stable_sort则保证稳定使用归并排序实现。Java的Collections.sort在JDK 7以后对对象使用的是TimSort是稳定的但对基本类型用Arrays.sort则是双轴快排不稳定。所以遇到排序结果不稳定的问题第一步不要怀疑算法写错了先确认你用的到底是哪个排序实现。5.3 快排在数据量极小时变慢快排不是万能的。当数据量非常小的时候递归和partition的调用开销远大于直接比较性能反而不如O(n²)的插入排序。这也是std::sort会在小规模数据上切换为插入排序的原因。如果你在写一个通用排序函数建议直接抄这个策略if (high - low 16) { insertionSort(arr, low, high); } else { quickSort(arr, low, high); }这个阈值不是拍脑袋定的。16到32这个范围在大量工程测试中表现最佳小于16切换带来的性能提升已经不大大于32则插入排序的O(n²)劣势逐渐显现。5.4 计数排序和基数排序的内存爆炸问题计数排序的空间复杂度是O(k)如果k很大内存可能直接爆掉。比如给一个大范围的浮点数排序计数排序根本不可行因为浮点数取值空间太大。基数排序则关键在于基数r的选择。用十进制每轮桶的个数是10用二进制每个字节一轮就是256个桶。实际工程里建议每次处理8个bit一个字节这样每轮256个桶桶的数量不大不少缓存友好度也合适。如果你的内存特别紧张可以把基数从256降到16但是轮数会翻倍需要在空间和时间之间权衡。6. 从排序算法到工程思维的迁移写了这么多我想说说排序算法对我的真正影响。很多人觉得排序算法就是面试八股背背复杂度、写写代码就完了。但当你真正在工程中处理过海量数据后会发现排序算法的核心思想已经渗透到了无数系统设计里。外部排序中的多路归并思想和数据库中的B树索引构建、分布式系统中的Shuffle合并本质上是同一个逻辑。快排的partition思想被广泛用在快速选择、TopK问题、分区算法中。而归并排序的稳定特性则成为了很多需要保持原始顺序的系统的不二之选。我个人的经验是学习排序算法不要只盯着代码实现要把每个算法看作一个“解决问题的策略”。冒泡是暴力轮换插入是逐步扩展快排是分而治之归并是合并有序堆是借助数据结构计数和基数则是空间换时间的极致运用。这些策略才是真正可以迁移到各种场景的底层思维。回到内部排序和外部排序的区别。内部排序比拼的是聪明的算法设计外部排序比拼的是聪明的I/O调度。前者是“怎么少做事”后者是“怎么少跑腿”。理解了这层逻辑再回头看排序问题眼界会开阔很多这也是我写这篇文章想传达的核心价值。
返回列表