尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

置换选择排序:突破内存限制优化外部排序初始归并段生成

置换选择排序:突破内存限制优化外部排序初始归并段生成 提到排序很多人第一反应是快速排序、归并排序、堆排序以及它们在在线判题系统里的复杂度分析。但真正落到生产环境时最让人头疼的排序场景往往不是内存里那几万条记录而是内存根本装不下整份数据。比如一次性要排几十个 GB 的日志文件或者把一个千万级用户的导出表按某个字段排好序再写回磁盘。这时候你会发现平时背得滚瓜烂熟的快排代码完全派不上用场因为问题已经从“怎么比较大小”变成了“怎么在内存和磁盘之间高效倒腾数据”。置换选择排序Replacement Selection Sort正是外部排序里用来生成初始归并段的一种经典算法。它不像快排那样直接把全部数据排成最终顺序但它决定了后续归并的趟数进而决定了整个外排序要读多少次磁盘。理解它不只是为了应付数据结构考试更是为了搞清楚一个很实际的问题当数据大到内存装不下时排序到底应该怎么做。1. 内存装不下时为什么排序的难点完全变了1.1 内排序和外排序的本质差异内排序也就是我们平时写的快排、堆排、归并排序默认有一个很强的假设所有数据都能放进内存。在这个前提下任意两个元素之间的比较、交换代价都很低CPU 可以直接访问。外排序则不同。数据总量远大于内存容量你只能把一部分数据读进内存处理完写回磁盘再读下一批。这个时候真正的瓶颈已经不是比较次数而是磁盘 IO。一次磁盘读写消耗的时间可能比几万次比较还要大。因此外排序的核心优化目标从“少比较”变成了“少读写”。这个转变会带来一系列连锁反应。比如我们平时评估排序算法用的是时间复杂度和空间复杂度但在外排序里更常说的是“数据被读写了多少遍”。一趟完整的读写往往意味着几个小时甚至几天的工程任务。1.2 最朴素的外排序策略切块排序 多路归并如果只给你一个能装 M 条记录的内存缓冲区让你排序 N 条记录最直观的做法是什么切块。把整个大文件切成一个个大小为 M 的小块每块读入内存用普通内排序排好然后写回磁盘。这个写回的有序块就叫初始归并段。等所有块都处理完再通过多路归并把这些有序段逐层合并成一个大段最终得到全局有序的文件。这个方案完全可行数据结构教材里通常也是这么讲的。但如果你真的去实现一遍会发现一个很明显的浪费每块数据在排序时完全没有利用已经存在的“部分有序性”。块和块之间是割裂的所有块的长度都被限制成 M。1.3 普通切块留下的问题初始归并段太短假设总共有 N 条记录内存缓冲区大小是 M普通切块会生成大约 N/M 个初始归并段。后续使用 k 路归并归并的趟数差不多是 log_k(N/M)。初始归并段越短段数就越多需要归并的趟数也就越多。每一趟归并都要把所有数据从头到尾读写一遍。所以如果能想办法让初始归并段更长即使内存还是那么大整体 IO 次数也能明显降下来。这其实就是置换选择排序出现的意义它不再机械地把内存切成一个个独立块而是通过一种“边输出、边读入、边置换”的方式让生成的初始归并段突破内存容量的限制。平均情况下段长可以做到内存容量的大约两倍。两倍听起来不多但它意味着初始归并段的数量可能直接减半后续归并趟数自然减少。2. 置换选择排序的核心机制输出一个读入一个2.1 内存里维护一个最小堆输出一个读一个置换选择排序的基本逻辑并不复杂。内存缓冲区只保留 M 条记录但这个缓冲区不是一次性切死的块而是一个动态变化的集合。第一步从输入文件读入 M 条记录建立一个最小堆。堆顶是当前集合中最小的记录。第二步把堆顶记录输出到当前归并段文件同时记录下这个值记作 last。第三步从输入文件读入下一条记录看它的关键字是否大于等于 last。如果是说明它可以安全地跟在当前归并段后面于是把它插入堆中继续参与当前段的排序。如果不是说明它比刚才输出的值还小如果放进当前段会破坏当前段的有序性所以暂时把它放到另一个区域留给下一个归并段使用。反复执行第二步和第三步直到堆为空。此时当前归并段结束。然后把之前“另放”的那些记录作为下一个归并段的初始内容继续同样的操作。2.2 新记录比刚才输出的小就留给下一段这里最关键的一点是判断“新记录属于哪个归并段”的标准不是“堆里有没有空间”而是“它能不能接在上一个输出值后面”。普通切块排序里一个归并段最多只能有 M 条记录因为内存块的大小是固定的。但置换选择排序不同。只要读入的新记录不小于上一个输出的值它就可以立刻进入当前归并段的候选池继续参与后续选择。所以当前归并段的长度可以远大于 M。当新记录比 last 小时情况就改变了。比如已经输出了 20接着读入一条 15那 15 绝对不能放在当前段里否则当前段的序列就不再有序。它只能被“冻结”起来等当前段结束再作为下一个段的种子。这个操作之所以叫“置换选择排序”是因为每一轮都有一个很明显的“置换”动作从堆顶拿走一个最小值再从输入文件读入一个新值填回内存。整个过程中内存里的记录始终在流动而不是像切块方案那样一批一批孤立地处理。2.3 为什么平均段长能到约 2 倍内存容量数据结构教材和很多外排序资料里都会提到一个结论输入记录关键字随机分布时置换选择排序生成的初始归并段平均长度约为 2M。这个结论不是靠拍脑袋得到的核心原因和“当前段延续概率”有关。当堆顶输出 last 后新读入的记录只要不小于 last就能继续留在当前段。在随机分布条件下约一半的记录满足这个条件所以当前段在输出过程中有很高概率被持续延长。最好的一种情况是输入数据已经接近升序。此时几乎每一条新记录都能继续留在当前段最后可能整个输入文件只生成一个初始归并段。最差的一种情况是输入数据接近逆序。每条新记录都比上一次输出的值小于是当前段无法吸收新记录段长就退化成 M 左右跟普通切块差不多。所以置换选择排序不是任何情况下都能稳定带来两倍收益。它的收益来自“输入数据中有可以利用的有序趋势”。3. 手工模拟一遍M3 时的完整过程3.1 模拟输入与运行规则为了把这段流程讲清楚我用一个很小的例子手工跑一遍。假设内存缓冲区最多只能放 3 条记录也就是 M3。输入序列如下15, 4, 8, 20, 3, 12, 30, 10, 1, 18, 5, 9, 22, 7, 25, 6, 2, 11, 14, 13规则再强调一下每次从堆顶输出最小值 last然后从输入序列读入一个新值。如果新值大于等于 last就把它放进当前段的堆如果小于 last就先放到“后备区”等下一个归并段。3.2 完整过程拆解先读入前 3 条15、4、8最小堆顶是 4。输出 4读入 2020 4入堆。当前堆里有 8、15、20。输出 8读入 33 8放入后备区。当前堆里有 15、20。输出 15读入 1212 15放入后备区。当前堆里有 20。输出 20读入 3030 20入堆。当前堆里有 30。输出 30读入 1010 30放入后备区。堆空了。所以第一个归并段是4, 8, 15, 20, 30。这个段明显超过了 M3达到了 5。后备区里有 3、12、10。把它们转成堆开始生成第二个归并段输出 3读入 11 3放入后备区。堆里有 10、12。输出 10读入 1818 10入堆。堆里有 12、18。输出 12读入 55 12放入后备区。堆里有 18。输出 18读入 2222 18入堆。堆里有 22。输出 22读入 77 22放入后备区。堆空了。第二个归并段是3, 10, 12, 18, 22。后备区里有 1、5、7。继续生成第三个归并段输出 1读入 2525 1入堆。堆里有 5、7、25。输出 5读入 66 5入堆。堆里有 6、7、25。输出 6读入 22 6放入后备区。堆里有 7、25。输出 7读入 1111 7入堆。堆里有 11、25。输出 11读入 1414 11入堆。堆里有 14、25。输出 14读入 1313 14放入后备区。堆里有 25。输出 25输入序列已经读完堆空。第三个归并段是1, 5, 6, 7, 11, 14, 25。后备区里有 2、13。最后后备区转成堆生成第四个归并段输出 2堆里剩 13。输出 13。第四个归并段是2, 13。3.3 从模拟结果能读出什么如果使用普通切块M320 条记录会被切成 7 个长度为 3 的归并段。但使用置换选择排序后同样 M3只生成了 4 个归并段而且段长分别是 5、5、7、2。这 4 个段内部都是非递减的后续多路归并的时候只需要处理 4 路而不需要处理 7 路。段数减少归并趟数就可能减少磁盘读写次数自然下降。不过也要看到归并段长度并不均匀最后一个段只有 2 条而第三个段有 7 条。这种不均匀在实现多路归并时会造成一个影响不能用“每个归并段平均分配内存”的思路做缓冲管理必须让归并算法能感知每个段的剩余长度否则某些缓冲区可能很快见底造成 IO 等待。4. 工程实现里的两个关键选择堆还是败者树4.1 堆的实现最简单但比较次数偏多实现置换选择排序时最容易想到的数据结构就是最小堆。初始时把 M 条记录建成堆每次从堆顶取出最小值然后把新记录插入堆做一个向下调整复杂度是 O(log M)。这个实现非常直观用来做教学和原型验证完全够用。但在真实的外排序场景里M 可能不是 3而是几万甚至几十万。堆的调整虽然也是 O(log M)但每一层都要做比较而且堆调整过程中经常需要交换下标对 CPU 缓存不算友好。更关键的是外排序里选择最小值这件事可能不止做一次。每生成一个归并段每输出一条记录都要做一次选择。累计下来的比较次数会直接影响整个排序流程的 CPU 开销。4.2 败者树如何减少比较次数在外部排序的工程实现里更常见的选择是败者树。败者树的结构也是完全二叉树叶子节点存放参与比较的记录内部节点存放的是每一轮比较中的“败者”根节点则保存最终的“胜者”也就是当前最小的记录。当一个记录被选中并输出后新读入的记录会替换掉这个叶子节点。接下来只需要从这条叶子节点出发沿着路径向上和兄弟节点的胜者依次比较就能找到新的最小值。由于每个内部节点只记录败者比较过程和信息传递都比堆调整更直接路径上的比较次数更少。更重要的是败者树天然适合“多路归并”场景。后续阶段把多个初始归并段合并成一个全局有序文件时也是反复从多个段中选最小值同样可以用败者树。这样一来从生成归并段到最后的归并阶段可以复用同一套设计和代码思想。4.3 归并段为什么常常不等长会影响后续归并从上面的模拟也能看到置换选择排序生成的归并段不等长。这是它的特点也是实现时要注意的地方。普通切块生成的段长度固定后续归并时可以比较均匀地分配缓冲区。但置换选择排序生成的段长度有波动归并时必须实时记录每个段的“剩余记录数”。如果某个段已经全部被归并完算法需要从参与归并的段集合中移除它。否则程序可能试图从一个已经读空的段里继续取数据结果读到随机值或缓冲区脏数据。工程上常用的做法是在归并段的文件尾部写入一个结束标记或者在内存里为每个段维护一个指针和剩余计数。每输出一条记录就把对应段的剩余计数减一减到零就从候选集中剔除。4.4 一个朴素的 Python 模拟下面给出一个简化版 Python 实现用最小堆来演示核心流程适合用来理解算法不适合直接在生产环境处理超大文件。import heapq def replacement_selection(data, m): data list(data) n len(data) idx 0 # 先把内存缓冲区填满 heap [] while idx n and len(heap) m: heapq.heappush(heap, data[idx]) idx 1 runs [] current [] pending [] last_written None while heap or pending: # 当前段结束转到下一个归并段 if not heap: if current: runs.append(current) current [] last_written None heap, pending pending, [] # 如果内存没满且还有输入继续读入 while idx n and len(heap) m: heapq.heappush(heap, data[idx]) idx 1 if not heap: break val heapq.heappop(heap) current.append(val) last_written val # 读入一条新记录判断属于当前段还是下一个段 if idx n: new_val data[idx] idx 1 if new_val last_written: heapq.heappush(heap, new_val) else: pending.append(new_val) if current: runs.append(current) return runs seq [15, 4, 8, 20, 3, 12, 30, 10, 1, 18, 5, 9, 22, 7, 25, 6, 2, 11, 14, 13] for r in replacement_selection(seq, 3): print(r)输出结果[4, 8, 15, 20, 30] [3, 10, 12, 18, 22] [1, 5, 6, 7, 11, 14, 25] [2, 13]这个实现把整个输入序列转成了 list很方便演示。但真实场景里数据源应该是文件流读一条处理一条不能一次性把全部数据都装进内存。代码里的 pending 列表也需要根据内存大小做严谨的上限控制避免出现“堆加后备区超过 M”的情况。5. 什么时候该用什么时候别硬用适用边界与排查思路5.1 适用场景置换选择排序最典型的应用场景是大规模数据外部排序的初始归并段生成阶段。只要你遇到“数据量远超内存但还是必须全部排序”的问题它就是一个值得认真考虑的候选方案。具体来说这些情况很适合几十 GB 甚至 TB 级日志文件需要按时间戳排序。数据库导出文件过大无法用内存排序工具直接处理。需要自己实现一个简化版外排序工具比如在嵌入式环境或内存受限服务器上处理批量数据。教学实验或考研复习需要理解外排序中“归并段”是怎么来的。在这些场景里置换选择排序的价值不是帮你在内存里排得快而是帮你在磁盘上少读几遍数据。5.2 不适用场景如果数据量本身不大能全部放进内存置换选择排序就是一个过度设计。直接快排或者用编程语言自带的排序函数既能得到更好性能代码也更容易维护。另一个不太建议硬用的场景是输入数据基本逆序。这种情况下新读入的记录大概率小于上一次输出值当前归并段很难吸收新记录段长会退化到接近 M。此时置换选择排序相对普通切块没有明显优势但实现复杂度高了不少。如果你的真实需求是稳定排序也要额外小心。置换选择排序本身更关注“段内有序”不保证相等关键字记录的原始相对顺序稳定进入最终结果。是否能在后续归并中保证稳定取决于归并阶段的实现策略。5.3 常见误区很多人初学置换选择排序时会有几个误解。第一个误解是以为每个归并段长度都能达到约 2M。这不是保证值而是随机输入下的平均表现。遇到逆序输入、重复值太多、以及数据分布不均匀的情况段长可能明显偏离这个值。第二个误解是觉得只要把堆建好算法就结束了。实际上置换选择排序只是外排序的第一阶段。它生成的归并段只是局部有序后续必须通过多路归并才能得到全局有序结果。如果只跑完置换选择排序就拿这个结果当最终排序结果数据一定是错的。第三个误解是低估了 IO 优化的重要性。有些人实现外排序时把大量精力放在比较优化上却没有给输入输出加缓冲。结果程序跑起来以后大量时间浪费在频繁的小块磁盘读写上速度远不如一个“算法平庸但 IO 缓冲正确”的版本。5.4 如果排序结果不对按什么顺序排查如果实现完以后发现结果不对我一般会按照下面的顺序排查先看输入。确认输入序列是否被正确读取文件指针、下标、行尾符、编码这些都可能引入错误。再用很小的数据量跑一遍人工检查每个归并段内部是否有序。再看堆的逻辑。确认堆顶是否真的是最小值堆的初始化、插入、删除是否按照最小堆规则实现。一个常见的低级错误是把最大堆逻辑用在了置换选择排序里导致输出的段是递减的。再看“归属判断”。新记录和 last_written 的比较条件是大于等于还是大于必须想清楚。如果允许相等关键字留在当前段就要用 如果不允许则用 。我用的是 意思是可以让相等元素继续延续当前段这样段会更长一些。再看归并段衔接。检查每个段结束时后备区记录有没有完整转成下一段的堆。如果后备区被清空或者被覆盖后续归并段就会丢失数据。最后看归并阶段。如果预处理生成的归并段没问题但最终结果不对问题很可能出现在多路归并时没有正确处理不等长段或者某个归并段提前读完时没有及时把它从选择集合里移除。6. 把它放进完整外排序链路从初始归并段到多路归并6.1 完整流程生成归并段只是第一步一次完整的外部排序通常可以分成两个大阶段。第一阶段用置换选择排序生成尽可能长的初始归并段。第二阶段用多路归并把多个归并段合并成一个全局有序文件。如果合并后的文件仍然太大或者参与归并的路数超过系统限制还需要把当前结果继续作为输入再进行下一轮归并。这个过程会重复进行直到只剩下一个有序段。在这个流程里置换选择排序的任务是把“大文件”变成“一组长度较长的有序文件”。段越长段数越少后续多路归并的趟数就越少。所以评价一个置换选择排序实现好不好不能只看它生成了多少个归并段还要看这些段的长度分布、内存占用以及生成过程中磁盘 IO 的总量。6.2 工程落地时的几个建议如果你打算在一个真实项目里实现外排序我有几个具体建议。第一先用小数据量把整条链路跑通再上大文件。不要一开始就拿几十 GB 的数据来验证算法。先用几千条记录把置换选择排序生成的归并段打印出来人工确认每一段都有序再进入多路归并阶段确认最终结果和普通排序一致。第二内存缓冲区不能全给堆。置换选择排序运行时内存不仅要保存当前堆还要保存后备区记录。如果你的内存上限是 M理论上堆和后备区加起来应该不超过 M。真实实现里最好为输入缓冲区和输出缓冲区分别预留固定大小的空间避免文件读写本身因为缺少缓冲而变慢。第三优先使用文件流而不是一次性把数据读进 list。Python 里可以写一个生成器每次返回一条记录。C 里则用 ifstream 按行读取或按固定长度读取。这样无论数据多大内存占用都稳定在 M 附近。第四关注临时文件的管理。每生成一个归并段就会产生一个临时文件。段数太多时临时文件也会很多要注意文件描述符耗尽的问题。通常会在同一时间只保留必要数量的临时文件归并完成后及时删除。第五把“归并段长度”作为监控指标记录下来。运行结束后统计段数、平均段长、最长段和最短段。如果段长普遍接近 M说明输入数据的有序性利用得不够可以检查是否存在逆序分布如果段长很长说明数据本身有较好的有序趋势或者你的 M 值设置得比较合理。6.3 回到一个更底层的经验置换选择排序真正值得学习的不只是那几行堆操作而是它背后一个更通用的思路当内存不够时不要机械地把数据切碎而是尽量利用数据本身已经存在的有序性让每一段“物尽其用”。这个思路在很多地方都会出现。比如数据库排序算子里已经部分有序的索引片段可以被直接利用再比如在大数据分析里按桶切分数据时如果桶内顺序保留得好后续合并成本会低很多。置换选择排序是这类思想的经典表达也是把“排序”从单机内存问题转化为磁盘 IO 问题的关键一步。很多年前我第一次实现外排序时也犯过“先把所有数据切块排序”的直觉错误。后来自己手工推演了一遍置换选择排序才意识到算法真正的价值不是让你更快地完成一次比较而是让你从更高的维度思考“数据流动”的成本。如果你也想动手实现一个外排序工具我建议先不要急着写归并部分而是先把这个生成归并段的算法吃透用一个小例子把它模拟到完全顺手。这一步想清楚后面的多路归并会顺畅得多。
返回列表