想象你是仓库管理员,要在堆满数百万个编号箱子的大仓库里,把所有箱子按编号从小到大排好。问题是你的工作台很小,只能同时摆下几十个箱子——你绝不可能把仓库搬空全部堆到工作台上一次性排好,只能搬一批、理一批、放回一批。这,就是外部排序面对的核心困境。计算机里的“大仓库”是磁盘,“工作台”是内存。当数据量远超内存容量时,所有经典内部排序算法(快排、归并)都会失效,因为它们假设数据能全部塞进内存随机访问。外部排序的策略只有八个字:化整为零、逐段归并。先把大文件分批读入内存排序,写回磁盘形成若干有序“归并段”,再通过多路归并把这些段落逐步合并成完整有序序列。整个过程中,磁盘与内存之间的搬运次数(I/O)决定了性能上限,而败者树、替换选择、缓冲区管理等方法,都是在想方设法让“搬运”更省、更快、更聪明。

岗位关联

外部排序是海量数据处理的基石,数据库排序、MapReduce Shuffle、大文件排序都依赖它。掌握多路归并与磁盘I/O优化,是走向大数据工程师与数据库内核开发者的必修课。

目录
  1. 可视化演示
  2. 外部排序概述
  3. 初始归并段
  4. 多路归并
  5. 败者树优化
  6. 替换选择算法
  7. 磁盘IO优化
  8. 并行归并策略
  9. 应用与性能
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

外部排序用于处理无法全部装入内存的海量数据,通过多路归并和败者树优化磁盘I/O。本文讲解外部排序的基本流程与初... 切换模式查看不同维度。

本节配有交互动效(进入视口后自动加载,约 3KB)。

一、外部排序:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现

外部排序概述

你站在巨大的仓库中央,望着满坑满谷的编号箱子,再看看手里那张小小的工作台——一次只能摆下几十个箱子。聪明的你很快想通:既然一口气理不完,那就分批理。先把一角仓库的箱子搬到工作台上排好序,放回原位;再搬下一角……等到每个角落的箱子都各自有序后,再考虑怎么把这些“小有序”合并成“大有序”。至于每一角落搬多少趟、怎么合并最省力气,就是外部排序要回答的问题。

把这个画面搬进计算机:磁盘是那个大仓库,内存是工作台。当待排序数据总量 N 远超内存容量 M 时,快速排序、堆排序这类内部排序算法全部失灵——它们的前提是数据能同时驻留内存。外部排序采用两阶段策略:第一阶段“生成初始归并段”,分块读入数据、段内排序、写回磁盘;第二阶段“多路归并”,把若干有序段逐层合并,直到只剩一段。

两阶段中,磁盘与内存之间的数据搬运量是决定性能的关键。磁盘随机访问比顺序访问慢几个数量级,而内存读写比磁盘快几个数量级,所以外部排序的一切优化都围绕“减少搬运次数、让搬运更有序”。假设初始归并段数为 m,多路归并路数为 k,归并趟数约等于 log 以 k 为底 m 的对数,那么总搬运量可近似表示为:

回到仓库的比喻:如果你把仓库分成 10 个小堆,每次把 2 个小堆合并成 1 个有序大堆,大约需要 4 趟才能把 10 堆合成 1 堆。每趟你都要把所有箱子从仓库搬到工作台再搬回去,这就是"2N"的含义。理解了搬运量,你就抓住了外部排序的命脉。

总I/O量≈2N×(⌈log⁡km⌉+1)\displaystyle \text{总I/O量} \approx 2N \times (\lceil \log_k m \rceil + 1)
数据总量 N 决定了每趟归并至少要读一遍、写一遍,因此总 I/O 量 ≈ 2N × (归并趟数 + 1)。结论很直接:归并趟数越少,磁盘搬运越省。
  1. 段数m内存一次只能容纳 M 个元素,所以初始归并段的数量约等于 N 除以 M
    m≈⌈NM⌉\displaystyle m \approx \left\lceil \frac{N}{M} \right\rceil
  2. 归并趟数每一趟 k 路归并,最多把段数缩小为原来的 1/k,从 m 段并到 1 段所需趟数
    ⌈log⁡km⌉\displaystyle \lceil \log_k m \rceil
  3. 搬运总量生成归并段时读写各一遍(2N),之后每趟归并又读写各一遍(2N)
    2N×(⌈log⁡km⌉+1)\displaystyle 2N \times (\lceil \log_k m \rceil + 1)

初始归并段

整理仓库的第一步,是先把仓库划分成几个区块。你画好格子线,每个格子的大小正好等于工作台的容量,把每个格子里的箱子搬到工作台上排好序、放回原处。这样,仓库里就出现了若干堆内部有序的“小堆”,每堆大小约等于工作台容量。这些有序小堆,在外部排序中叫做“初始归并段”。

初始归并段生成的关键是等长划分:以内存容量 M 为一把“标准尺”,把输入数据流切成若干长度为 M 的块。对每个块,读入内存后用快速排序或堆排序排好,再整块写回磁盘。一个块对应一个归并段文件。段与段之间互不重叠、各自有序,这正是后续归并的前提——就像你必须先保证每个小堆内部有序,才能谈得上把多个小堆合并成一个大堆。

归并段的数量直接决定后续工作量。设数据总量为 N、内存容量为 M,则段数:

等长划分简单粗暴但有一个缺点:段的数量被内存容量“锁死”。如果内存只有 1GB 而数据有 100GB,就会产生约 100 个归并段,后续归并时要来回搬运很多趟。后文介绍的替换选择算法,正是为了打破这个“每段长度=M”的紧箍咒。

m=⌈NM⌉\displaystyle m = \left\lceil \frac{N}{M} \right\rceil
段数 = 数据总量 ÷ 每段容量(向上取整)。段数越多,后续归并趟数就越多,I/O 搬运也就越频繁。
  1. 总量N所有待排序数据的元素总个数,对应仓库里的箱子总数
    N\displaystyle N
  2. 容量M内存一次最多能容纳的元素个数,对应工作台大小
    M\displaystyle M
  3. 向上取整最后一块不满 M 也必须单独成一个段,因此用向上取整符号
    ⌈NM⌉\displaystyle \left\lceil \frac{N}{M} \right\rceil

多路归并

仓库里已经有十几堆各自有序的箱子,现在要把它们合并成一堆完全有序的箱子。你可以两堆两堆地合,但那样要来回跑很多趟。更聪明的做法是:同时摊开 k 堆箱子,每堆顶部露出最小的那个编号,比较这 k 个编号,取出最小者放到新堆中;然后从它所属的那堆再露出下一个编号,继续比较……这就是“k 路归并”,你也因此理解为什么打扑克牌时大家喜欢把牌按大小排好再一张张比。

k 路归并中,每输出一个元素,都要从 k 个队头中选出最小值。用最朴素的方法——扫描全部 k 个队头——每选一次要比较 k−1 次。数据总量为 N,一趟归并总共要输出 N 个元素,所以一趟的比较次数为 (k−1)×N。而归并的趟数取决于初始归并段数 m 和路数 k:

想象你是扑克裁判,要同时盯 100 堆牌、每轮从 100 个顶牌中找最小——用“肉眼扫描”当然费劲,这正是多路归并的第一重代价。如果路数少,扫起来轻松但趟数多、搬运多;路数多,趟数少了但每轮比较累。要摆脱这个两难,就需要一个“自动选出最小牌”的记分牌:败者树。

趟数=⌈log⁡km⌉\displaystyle \text{趟数} = \lceil \log_k m \rceil
总比较次数≈(k−1)×N×⌈log⁡km⌉\displaystyle \text{总比较次数} \approx (k-1) \times N \times \lceil \log_k m \rceil
归并趟数随 k 增大而减少,但每趟的比较次数却随 k 线性增长。路数 k 不是越大越好,需要权衡——这正是后面败者树登场的理由。
  1. k路归并同时打开 k 个输入归并段,每次比较 k 个队头、输出最小值
    k\displaystyle k
  2. 归并趟数每趟把 m 段合并为 ⌈m/k⌉ 段,从 m 段到 1 段的趟数
    ⌈log⁡km⌉\displaystyle \lceil \log_k m \rceil
  3. 每趟比较量每输出 1 个元素比较 k−1 次,一趟输出 N 个元素
    (k−1)×N\displaystyle (k-1) \times N
  4. 总比较次数趟数 × 每趟比较量,即总比较次数的上界
    (k−1)×N×⌈log⁡km⌉\displaystyle (k-1) \times N \times \lceil \log_k m \rceil

败者树优化

多路归并的每一轮,就像一场小型淘汰赛:k 个队头选手同台比大小,胜出者是最小值。朴素扫描相当于每轮重新把所有选手叫上台比一遍。有没有办法让上一轮的比赛信息保留下来,下一轮只让新上场的选手和“历届输家”比?有——这就是败者树。

败者树是一棵完全二叉树,叶子节点对应 k 个归并段的队头,内部节点记录“败者”(比较中较大的那个值)来自哪一路,根节点则记录整场比赛的“冠军”(最小值)来自哪一路。当某一路的最小值被输出后,新元素顶替它的叶子位置,只需沿着从该叶子到根的一条路径,与沿途记录的历史败者逐一比较——每层比较一次,重复的比较被彻底避免。

淘汰赛的妙处在于:老选手之间的胜负关系已经记录在内部节点里,新选手只需要一路“过关斩将”挑战历史败者。每轮选出最小值的时间从不超 O(k) 降为树高:

对比朴素的“每轮全量扫描”,败者树就像考场里保留了一份历次考试的排名表:新同学来挑战,不必和所有人重新比,只要沿着排名依次挑战即可。归并段路数越大、数据量越大,这份“排名表”省下的力气就越可观。

T=O(log⁡2k)\displaystyle T = O(\log_2 k)
败者树把“从 k 个元素中选最小”的代价从 O(k) 压缩到 O(log₂k)。路数 k 越大,优势越明显。
  1. 内部节点每个内部节点存放该子树比较产生的“败者”来自哪一路
    败者段号\displaystyle \text{败者段号}
  2. 树高k 个叶子的完全二叉树,高度为 ⌈log₂k⌉
    h=⌈log⁡2k⌉\displaystyle h = \lceil \log_2 k \rceil
  3. 调整路径某叶子替换新值后,只需沿一条根路径更新祖先
    h=O(log⁡2k)\displaystyle h = O(\log_2 k)

替换选择算法

等长划分生成的归并段,长度被内存容量 M 死死锁住。可你有没有想过:如果整理箱子时,一边整理一边从仓库门口源源不断地补货,是不是能让每个有序小堆比工作台容量更大?这正是替换选择算法的核心思想——它把生成归并段从“分批静态整理”变成“流水线动态整理”。

算法维护一个能容纳 M 个元素的内存工作区。初始时从输入读入 M 个元素;接着反复执行:从工作区中选出一个“大于当前段末尾且最小”的元素,输出到当前归并段;每输出一个元素,立即从输入读入一个新元素补进工作区。如果工作区里所有元素都小于当前段末尾,说明当前段“到头了”,开启一个新的归并段,从头再来。

为什么这样能生成超长归并段?因为只要新补进来的元素恰好比当前段尾大,就能继续“接龙”。对于均匀随机数据,新元素有大约 1/2 的概率比已输出的最大值还大,所以顺串可以不断延续。数学期望上,随机输入下替换选择生成的归并段长度为:

替换选择的最大价值在于:它没有增加任何内存,却让归并段数量几乎减半,归并趟数随之降低。这就像聪明的仓库管理员在传达带旁安排了一个“预备区”——整理到一半时,新来的箱子如果编号够大,就直接排进当前这堆,大大延长了每次整理的成果。

E[L]≈2M\displaystyle E[L] \approx 2M
替换选择把归并段平均长度从 M 提升到 2M,段数量直接减半。若输入本身接近有序,归并段甚至可以长到惊人。
  1. 工作区大小内存工作区最多容纳 M 个元素,即当前可用的“候选池”
    M\displaystyle M
  2. 输出条件从工作区选择“大于当前段尾且最小”的元素输出,保证段内有序
    min⁡{x∣x>当前段尾}\displaystyle \min\{x \mid x > \text{当前段尾}\}
  3. 随机分布期望均匀随机输入下,新元素大于当前段尾的概率约为 1/2,每个元素平均被“接龙”约 2M 次
    E[L]≈2M\displaystyle E[L] \approx 2M

磁盘IO优化

搬运是外部排序最昂贵的开销。同样搬 100 个箱子,用小车一次推 20 个,比一次抱 2 个来回跑 50 趟省力得多。磁盘 I/O 优化的第一原则就是把“小碎步”变成“大跨步”:以大的数据块为单位批量读写,避免频繁的小块随机访问。一次读 1MB 比读 100 次 10KB 要快得多,因为磁盘顺序读有很高的带宽利用率。

第二板斧是缓冲区管理。归并时,每个输入归并段都要在内存里有自己的缓冲区,专门存放从磁盘预读的一段数据。当缓冲区的数据被消费完,再触发下一次磁盘读。缓冲区太小,磁盘会被频繁“唤醒”;缓冲区太多,又会挤占内存。工程上常用“双缓冲”技巧:为每个输入段准备两个缓冲区,归并进程处理 A 缓冲区时,磁盘异步向 B 缓冲区填充下一批数据——计算与 I/O 重叠进行,磁盘延迟被彻底隐藏。

从 I/O 总量看,优化前后的对比非常直观。设每趟归并读写各 N 个元素,归并趟数为 S:

回到仓库场景:聪明的管理员会先规划好搬运路线,用大推车一次性多装些,还会在门口设两个暂存台——一个台上正在整理,另一个台已经被传送带悄然填满。整理想法与磁盘读写的交错重叠,就是双缓冲的精髓。

总I/O量=2N×(S+1)=2N×(⌈log⁡km⌉+1)\displaystyle \text{总I/O量} = 2N \times (S + 1) = 2N \times \left( \lceil \log_k m \rceil + 1 \right)
总 I/O 量与归并趟数成正比。因此,减少趟数(增大 k、减少段数 m)和让每趟读写更高效(批量、双缓冲)是磁盘优化的两大方向。
  1. 读写各一遍每趟归并要把全部数据读入内存一次、写回磁盘一次,合计 2N 元素
    2N\displaystyle 2N
  2. 趟数加一加上的 1 对应生成初始归并段的那一次读入和写回
    S+1=⌈log⁡km⌉+1\displaystyle S + 1 = \lceil \log_k m \rceil + 1
  3. 总搬运量所有阶段的数据搬运总量,是外部排序优化的核心指标
    2N×(⌈log⁡km⌉+1)\displaystyle 2N \times (\lceil \log_k m \rceil + 1)

并行归并策略

一个人整理整个大仓库太慢了,为什么不叫几个帮手分头行动?并行归并的核心思路同样是“人多力量大”,但难点在于如何分工、如何汇总。常见的并行策略有两种:多磁盘并行——把不同归并段分散到多块磁盘上,让 I/O 请求可以同时在不同磁盘上执行;多线程归并——把归并任务拆成多个子任务,每个线程负责一组归并段的合并,最后再做一次总的归并。

并行不是没有代价的。多个线程同时读写磁盘,会产生 I/O 竞争;多个线程同时占用内存带宽,也会互相干扰。此外,如果各归并子任务的数据量严重不均,有的线程忙死、有的线程空闲,总耗时反而被“短板”拖累。所以负载均衡是并行归并的必修课——尽量让每个归并任务的数据量、I/O 量大致相当。

并行加速比遵循阿姆达尔定律。设可并行部分的占比为 f,并行度为 p,则理论上限:

请一堆帮手看似简单,但帮手越多,碰头商量(串行汇总)的时间也越长。聪明的仓库主管会让每个帮手各管一块区域、互不干扰,最后只做一次汇总——这正是并行归并追求的理想状态。

Smax=1(1−f)+fp\displaystyle S_{\text{max}} = \frac{1}{(1 - f) + \frac{f}{p}}
加速比的上限由串行部分占比决定。即使有 100 个并行线程,若串行部分占 10%,加速比也超不过 10 倍。
  1. 可并行占比f所有归并阶段中能真正并行执行的时间比例
    f\displaystyle f
  2. 串行占比剩下 1−f 的时间必须串行完成,如最后的汇总归并
    1−f\displaystyle 1 - f
  3. 并行加速可并行部分最多提速 p 倍,串行部分不变,得到总时间
    (1−f)+fp\displaystyle (1 - f) + \frac{f}{p}
  4. 加速比原耗时除以并行后耗时的比值,如上式所示
    1(1−f)+fp\displaystyle \frac{1}{(1 - f) + \frac{f}{p}}

应用与性能

外部排序在海量数据场景无处不在。数据库执行 ORDER BY 时,如果待排序数据超过内存的 sort buffer,就会自动切换到外部排序;MapReduce 的 Shuffle 阶段,Map 端输出的中间结果按 key 分区排序后落盘,Reduce 端拉取后也要做归并排序——外部排序是分布式框架的“隐形基建”;Linux 下对大文本文件执行 sort,底层同样是外部排序的教科书式实现。

从各项指标回看整个过程:初始归并段的生成需要一次全量读取和一次全量写回;每趟归并又各读写一次;多路归并与败者树负责压低比较成本;替换选择负责减少段数;双缓冲和并行负责让 I/O 与计算重叠。综合来看,外部排序处于两股力量的拉扯之中:一边是磁盘搬运,一边是内存比较。

最终的整体性能可以概括为:

仓库整理的大工程终于收尾:比较就像整理动作,败者树让每个动作更利落;I/O 就像搬运,趟数决定要跑多少趟。真正的大数据工程师,既懂得把整理动作做得干脆(算法优化),更懂得把搬运路线规划得经济(I/O 优化)——两者齐头并进,才是外部排序的至高心法。

---

T比较=O(Nlog⁡km⋅k)→败者树O(Nlog⁡2N)\displaystyle T_{\text{比较}} = O(N \log_k m \cdot k) \xrightarrow{\text{败者树}} O(N \log_2 N)
TI/O=O ⁣(N⋅⌈log⁡kNM⌉)\displaystyle T_{\text{I/O}} = O\!\left(N \cdot \lceil \log_k \frac{N}{M} \rceil\right)
比较复杂度被败者树优化到 O(N log₂N) 量级,与最优内部排序同阶;I/O 复杂度由归并趟数决定,这才是外部排序真正需要精打细算的账。
  1. 普通k路比较朴素归并每趟每元素比较 k−1 次,总比较量为 N·logₖm·k
    N⋅⌈log⁡km⌉⋅(k−1)\displaystyle N \cdot \lceil \log_k m \rceil \cdot (k-1)
  2. 败者树压缩每轮选最小从 O(k) 降为 O(log₂k),总比较逼近 N·log₂N
    O(Nlog⁡2N)\displaystyle O(N \log_2 N)
  3. I/O量级每趟读写 2N 元素,趟数为 logₖ(N/M),因此 I/O 量为此表达式
    O ⁣(N⋅⌈log⁡kNM⌉)\displaystyle O\!\left(N \cdot \lceil \log_k \frac{N}{M} \rceil\right)

AI 应用场景

数据库中的 ORDER BY 大表排序

阶段:数据预处理与特征工程

解决的问题:传统方式下,SQL 执行对大表 ORDER BY 时若排序数据超过 sort buffer,会直接溢出到临时文件并产生大量随机磁盘 I/O。外部排序通过分块排序加多路归并,把随机 I/O 转化为顺序读写,让数据库在内存不足时依然能高效完成排序。

具体案例:某电商平台要对 10 亿条订单记录按金额排序。MySQL 的 sort buffer 只有 2MB,若用朴素方式会频繁刷新临时文件、磁盘抖动严重。借助 InnoDB 内部的外部排序实现(分块“快排 + 归并”),配合排序缓冲区优化与双缓冲预读,排序耗时从 40 分钟降到 6 分钟。

大模型训练语料的全局排序去重

阶段:数据清洗与语料构建

解决的问题:千亿级 token 的网页语料需要按 URL 或内容哈希排序去重,但全部载入内存不现实。外部排序的多路归并与替换选择算法,让去重任务可以在磁盘上分块完成,同时通过败者树保持高吞吐的归并比较。

具体案例:某 AI 团队构建中文预训练语料,原始数据 20TB,需按文本哈希排序后做相邻去重。团队用 MapReduce Shuffle 阶段的外部排序(默认 10 路归并)完成全局排序,再对有序序列做一次线性扫描去重,全程磁盘 I/O 控制在每个数据 3 遍以内。

搜索引擎倒排索引的归并构建

阶段:特征索引构建

解决的问题:搜索引擎爬取的海量文档需要构建倒排索引,每个词条对应一个有序文档 ID 列表。不同文档的局部索引要合并成全局索引,本质就是多路归并。败者树能高效地从上百个局部索引中合并出全局有序的 posting list。

具体案例:某搜索团队每天新增 5 亿篇文档,每篇产出一个局部 posting list。索引构建阶段用 64 路归并 + 败者树,同时配合 SSD 多通道并行读,把全局索引构建时间从 3 小时压缩到 25 分钟。

Python 演示

python95 行
# -*- coding: utf-8 -*-# 外部排序演示: 分块排序(生成初始归并段) + 堆式k路归并import osimport randomimport tempfileimport heapq  def generate_data(path, count):    # 生成count个随机整数写入文件, 每行一个    with open(path, 'w') as f:        for _ in range(count):            f.write(str(random.randint(0, 1000000)) + '\n')  def create_runs(input_path, mem_size, run_dir):    # 阶段1: 分块读入内存排序, 写出为多个归并段文件    runs = []    with open(input_path, 'r') as f:        block = []        for line in f:            block.append(int(line))            if len(block) >= mem_size:                block.sort()                run_path = os.path.join(run_dir, 'run_%d.txt' % len(runs))                with open(run_path, 'w') as rf:                    rf.write('\n'.join(map(str, block)) + '\n')                runs.append(run_path)                block = []        if block:            block.sort()            run_path = os.path.join(run_dir, 'run_%d.txt' % len(runs))            with open(run_path, 'w') as rf:                rf.write('\n'.join(map(str, block)) + '\n')            runs.append(run_path)    return runs  def merge_runs(run_paths, output_path):    # 阶段2: 使用最小堆做全部路数的一次归并    heap = []    readers = []    for rid, rp in enumerate(run_paths):        rd = open(rp, 'r')        readers.append(rd)        first = rd.readline()        if first:            heapq.heappush(heap, (int(first), rid))    with open(output_path, 'w') as out:        while heap:            val, rid = heapq.heappop(heap)            out.write(str(val) + '\n')            nxt = readers[rid].readline()            if nxt:                heapq.heappush(heap, (int(nxt), rid))    for rd in readers:        rd.close()  def verify_sorted(path):    # 校验输出文件是否全局有序    prev = -1    with open(path) as f:        for line in f:            v = int(line)            if v < prev:                return False            prev = v    return True  def main():    work_dir = tempfile.mkdtemp(prefix='ext_sort_')    input_path = os.path.join(work_dir, 'input.txt')    output_path = os.path.join(work_dir, 'output.txt')    run_dir = os.path.join(work_dir, 'runs')    os.makedirs(run_dir)     N = 10000      # 总数据量    M = 1000       # 内存块大小(即初始归并段容量)     generate_data(input_path, N)    runs = create_runs(input_path, M, run_dir)    print('初始归并段数量:', len(runs))    for i, rp in enumerate(runs):        size = sum(1 for _ in open(rp))        print('  段 %d 长度: %d' % (i, size))     merge_runs(runs, output_path)    print('输出全局有序:', verify_sorted(output_path))    print('结果文件大小: %d bytes' % os.path.getsize(output_path))  if __name__ == '__main__':    main()
目的:用 1000 大小的内存块对 10000 个数据执行完整外部排序,展示“分块排序 + 多路归并”两阶段流程,并校验结果有序性。
  1. block.sort()每个内存块内的排序,对应内部排序生成有序归并段
  2. heapq.heappush(heap, (int(first), rid))最小堆维护各归并段的当前队头,堆顶永远是最小值
  3. val, rid = heapq.heappop(heap)弹出全局最小值并记录来自哪一段
  4. readers[rid].readline()从刚被取走元素的归并段补读下一个元素进堆
  5. runs = create_runs(...)生成初始归并段,段数约等于 N/M,与正文公式对应

常见误区

误区数据量大不就是文件大嘛,我直接用快速排序读整个文件排序,性能一样的。

正解快速排序的前提是数据全部驻留内存、可以随机访问。当数据超过内存时,快排会触发操作系统的虚拟内存换页,产生灾难性的随机磁盘 I/O。外部排序的本质区别在于:它把“随机访问”彻底改造为“顺序读写”,通过归并策略让磁盘像流水线一样高效运转。

为何常见:初学者在内存中做排序时,比较次数是主要开销;一旦数据落到磁盘,I/O 才是真正的瓶颈。两种场景的“成本模型”完全不同,沿用旧直觉就会踩坑。

误区K 越大归并趟数越少,反正内存里的比较很快,直接开 1000 路归并呗。

正解K 增大会带来三重代价:每轮选最小的比较次数线性增长(朴素方法为 k−1 次/元素);每个输入段都要分配缓冲区,内存开销随 k 线性膨胀;磁盘并发读多个段可能引发 seek 竞争。败者树能压低比较代价到 O(log k),但缓冲区与 I/O 竞争的压力依然在,K 需要在趟数与资源开销之间权衡。

为何常见:只看到“减少趟数 = 减少 I/O”这一半,忽略了“每趟的代价”和“缓冲区的内存预算”另一半,于是陷入极端化的直觉。

误区败者树是比多路归并更高级的算法,和堆排序是竞争关系。

正解败者树并不是归并算法本身,它的角色是“从 K 个队头中快速选出最小值”的数据结构。归并的整体流程(比较队头、输出最小、推进指针)完全不变,败者树只是把“选最小”这一步从 O(k) 优化到 O(log k)。它与堆的功能类似,但败者树内部节点记录败者、重建路径更短,在归并场景中综合表现更好。

为何常见:教材通常把“败者树”作为独立小节讲授,配图又强调树形结构,容易让人误以为它是与归并并列的新算法。

误区替换选择能生成超长归并段,所以任何时候都应该用它替代简单的分块排序。

正解替换选择在随机数据下的期望段长只有 2M,带来的收益是段数减半,但代价是:每个元素都要经过堆的插入与删除(额外的比较);归并段长度不稳定,工程上难以预估;它要求数据流式输入,某些场景下反而破坏批量读取的 I/O 效率。对近似有序的数据它效果惊艳,但对随机数据,收益未必抵得过实现复杂度。

为何常见:教材强调“期望长度 2M”这一漂亮结论,学生容易把理想情况当成普遍事实,忽略了前提是均匀随机分布。

误区磁盘慢就把缓冲区加大,反正内存便宜,越大越快。

正解缓冲区的真正作用是让磁盘读写变成“整块顺序访问”并隐藏延迟,但它不能减少数据的总搬运量(2N × 趟数)。当缓冲区数量超过归并路数需求后,继续增大只会挤占进程可用内存,甚至引起操作系统换页,性能反而下降。减少 I/O 量的根本手段是减少归并趟数,缓冲区只是让每一趟跑得更顺。

为何常见:人们习惯用“缓存越大越快”的直觉理解所有存储优化,忽略了外部排序的 I/O 总量由算法结构决定、缓冲区只是执行细节。

面试题

Q1. 外部排序第一阶段的主要任务是什么?

单选题 · easy

  • A.将所有数据一次性读入内存排序
  • B.分块读入数据,段内排序后写出初始归并段← 正确答案
  • C.直接对磁盘文件做随机读取排序
  • D.用败者树完成最终归并

答案:B。外部排序第一步是“化整为零”:把大文件切成内存容纳得下的块,块内排序后写回磁盘成为初始归并段。A 错在数据量超出内存无法一次读入;C 是灾难性的随机 I/O;D 是归并阶段的手段,不是第一阶段的产出。

Q2. 数据总量为 N,内存容量为 M,等长划分生成的初始归并段数量约为多少?

单选题 · easy

  • A.N
  • B.M
  • C.N/M(向上取整)← 正确答案
  • D.N×M

答案:C。每段大小约等于内存容量 M,段数 = 总数据量 ÷ 每段容量,即 ⌈N/M⌉。A 忽略了分块;B 把内存容量当成段数;D 把除号写成了乘号,数量级完全错误。

Q3. 初始归并段数为 m,采用 k 路归并,需要的归并趟数是多少?

单选题 · mid

  • A.⌈logₖm⌉← 正确答案
  • B.⌈logₘk⌉
  • C.k×m
  • D.⌈m/k⌉

答案:A。每趟 k 路归并最多把段数缩小为原来的 1/k,从 m 段归并到 1 段需要 ⌈logₖm⌉ 趟。B 把底数和真数写反了;C 是相乘不是对数;D 描述的是“每趟产生的段数”而非趟数。

Q4. 关于普通 k 路归并的比较次数,下列说法正确的是?

多选题 · mid

  • A.每输出一个元素需要比较 k−1 次← 正确答案
  • B.每输出一个元素需要比较 log₂k 次
  • C.总比较次数 ≈ 趟数 × N × (k−1)← 正确答案
  • D.总比较次数 ≈ 趟数 × N × log₂k

答案:AC。朴素方法每轮扫描全部 k 个队头取最小,输出一个元素比较 k−1 次,乘上趟数 ⌈logₖm⌉ 和元素总数 N,即 C。B 和 D 描述的是败者树或堆优化后的比较逻辑,普通方法达不到这个效率。

Q5. 为什么败者树比胜者树更适合外部归并场景?

单选题 · hard

  • A.胜者树根本无法找到最小值
  • B.败者树替换叶子后只需沿单条路径与历史败者比较,重建路径更短← 正确答案
  • C.败者树不需要分配内部节点内存
  • D.败者树能同时输出多个最小值

答案:B。某路队头被输出后,新元素要替代叶子重新比赛。胜者树需要从叶子到根重跑,且每层的胜者一旦变化还要递归影响;败者树内部节点存的是各路“败者”,新元素只需沿路径逐层挑战历史败者,比较次数和赋值次数都更少,因此更适合高频替换的归并场景。A、C、D 说法均不成立。

Q6. 替换选择算法相比普通分块排序,哪些描述正确?

多选题 · mid

  • A.生成的归并段长度可以超过内存容量 M← 正确答案
  • B.均匀随机数据下期望归并段长度约为 2M← 正确答案
  • C.归并段数量一定比分块排序少
  • D.对近似有序的数据能生成极长归并段← 正确答案

答案:ABD。替换选择每输出一个元素就补入一个新元素,只要新元素大于当前段尾就能延续,所以段长可超过 M,随机数据下期望为 2M,A、B 正确。但段长受数据分布影响,不是必然更少,C 绝对化错误。输入近似有序时“接龙”能持续很久,D 正确。

Q7. 双缓冲技术的核心思想是什么?

单选题 · easy

  • A.用两块内存分别存放不同归并段的数据
  • B.让磁盘预读与内存归并计算重叠进行← 正确答案
  • C.复制两份数据防止丢失
  • D.扩大缓冲区以减少比较次数

答案:B。双缓冲把一个输入缓冲区拆成 A、B 两块,内存处理 A 时,磁盘异步向 B 预读下一批数据。计算与 I/O 并行,磁盘等待被隐藏起来。A 说成了分段装载;C 是备份;D 与比较次数无关。

Q8. 一条数据从生成初始归并段到最终输出文件,经历的磁盘读写次数大约是?

单选题 · mid

  • A.1 次读、1 次写
  • B.生成段写 1 次,之后每趟归并读 1 次、写 1 次← 正确答案
  • C.与归并段数量相等
  • D.完全不需要读写磁盘

答案:B。生成初始归并段要写回磁盘一次,之后每趟归并都要把所有数据读入内存、再写回磁盘。因此读取发生在每趟归并时,写入发生在每趟归并结束时,总次数与“趟数+1”成正比。A 忽略了多趟归并;C、D 没有依据。

Q9. 关于并行归并策略,下列说法正确的是?

多选题 · hard

  • A.多磁盘并行可以把 I/O 请求分散到不同物理磁盘上← 正确答案
  • B.多线程归并受限于磁盘与内存带宽,加速比不是线性的← 正确答案
  • C.只要线程数足够多,归并速度必然线性提升
  • D.负载均衡要求各归并任务的数据量大致相当← 正确答案

答案:ABD。多块磁盘能同时处理 I/O 请求,A 正确;磁盘带宽和内存带宽是硬瓶颈,线程过多后收益递减,B 正确、C 过于绝对;任务数据量不均会造成部分线程空闲、整体被最慢的任务拖累,所以负载均衡很关键,D 正确。

Q10. 使用替换选择算法时,内存可容纳 1000 个元素,输入为均匀随机数据,初始归并段期望数量约为多少?

单选题 · extreme

  • A.N/1000
  • B.N/2000← 正确答案
  • C.N/500
  • D.与 N 无关

答案:B。替换选择在均匀随机输入下期望段长 E[L]≈2M=2000,因此段数期望为 N/2000。A 是普通分块排序的段数(N/M);C 误以为段长减半;D 显然错误,段数随数据总量增长。这题要先掌握“期望 2M”再推一步。