FEATURED · 精选文章

Dtsort:基于决策树的稳定排序能否超越std::stable_sort?

发布时间 / 2026/9/4 2:59:54
来源 / 创域科博编辑部
栏目 / 资讯中心
Dtsort:基于决策树的稳定排序能否超越std::stable_sort? 排序是入门教程里最常见的练习题但真正进入 C 性能优化后你会发现普通的std::sort和std::stable_sort之间差的不是“一个参数”而是一整套关于内存、比较次数、分支预测和稳定性语义的取舍。最近一段时间一个名为 Dtsort 的稳定排序方案在讨论区里引起了不少关注它的标题很有“挑衅性”一种基于决策树的稳定排序性能上可以打败std::stable_sort。我的判断是这个标题背后的价值不在于“谁更快”这个结论本身而在于它逼着开发者重新思考一个问题稳定排序真的必须付出那么大的额外代价吗std::stable_sort是 C 标准库里的“保底答案”它功能正确稳定性有保证复杂度也符合标准要求但它的实现思路是通用的、为所有输入兜底的方案。而 Dtsort 这类基于决策树的思路提出了另一种可能先根据输入特征做决策再选择合适的排序路径甚至可以在比较过程中“学会”数据的结构从而减少无效比较和无效移动。读完这篇文章你会得到三样东西第一搞清楚稳定排序的语义和成本到底从哪里来第二理解“决策树排序”和传统归并式稳定排序的本质区别第三学会一套可复现的验证方法在 Dtsort 真正进入你的项目之前先判断它是否适合你的数据分布。排序这件事很多时候不是算法不够好而是没有在正确的场景下被验证。1. 这篇文章真正要解决的问题先说说为什么稳定排序值得被专门讨论。在数据库、缓存更新、事件回放、排行榜这类系统里你经常会遇到一个需求先按主键排序当主键相等时希望保留它们在原始数据中的先后顺序。比如一批日志同时到达排序后不能打乱同一用户 ID 下的到达顺序否则后续的状态机回放就可能出现错乱。此时如果只调用std::sort标准并不会保证相等元素的相对位置你依赖的“时间顺序”就可能被破坏。std::stable_sort之所以成为默认选择是因为它在标准层面给出了稳定性的强保证所有被视为等价的元素在排序后保持它们在原序列中的相对顺序。这个保证很值钱但代价同样真实。为了实现稳定它不能再像快速排序那样在数组里随意交换元素因为任意跨越式交换很容易破坏相等元素的初始次序。典型的实现会转向归并排序思想通过“分段有序再合并”的方式保留顺序这往往需要额外的辅助内存或者需要付出更多的比较和移动。Dtsort 之所以值得关注是因为它挑战了“稳定排序必须贵一点”的默认认知。如果它的实现真的能做到对大多数输入比较次数比通用归并更少又能维持相同的稳定性语义那么在高吞吐、大规模排序场景下这种收益会被放大得非常大。但同时必须泼一盆冷水任何排序算法的性能都逃不开输入分布的影响。Dtsort 如果宣称在某种数据分布上击败std::stable_sort这不代表它能在所有数据分布上赢。文章后面我会详细解释为什么必须在自己的业务数据上做基准测试而不是只看标题给出的结论。本文最适合的读者有两类一类是在实际项目中频繁处理大数据量排序、关心 C 性能的工程师另一类是对排序算法理论有兴趣、想理解“决策树”如何从理论分析工具变成可落地排序策略的学习者。如果你只是调用一次sort处理几百个元素那这篇文章的实践建议暂时用不上但理论视角仍然值得保留。2. 稳定排序基础与 std::stable_sort 的工程边界要聊 Dtsort必须先对齐稳定排序的基础概念。在 C 标准中如果比较器comp满足严格弱序那么当comp(a, b)和comp(b, a)都为false时a和b被视为等价。稳定的意思是排序后所有等价的元素它们原来的相对顺序保持不变。听起来很简单但在工程里经常被误解。很多人以为“我在比较器里加上序号字段让比较器变成全序不也等于稳定了吗”严格来说那已经不是在排序后保持原顺序而是把“原顺序”变成第二排序键重新进行了一次二级排序。这两种做法在结果上可能相似但语义不一样对性能的影响也不一样。#include algorithm #include cstdint #include iostream #include vector struct Record { std::uint64_t user_id; std::uint64_t arrive_seq; friend std::ostream operator(std::ostream os, const Record r) { return os {user_id r.user_id , seq r.arrive_seq }; } }; int main() { std::vectorRecord records { {2, 100}, {1, 101}, {2, 102}, {1, 103}, {2, 104}, }; // std::stable_sort按 user_id 排序相同 user_id 保留原顺序 std::stable_sort(records.begin(), records.end(), [](const Record a, const Record b) { return a.user_id b.user_id; }); for (const auto r : records) { std::cout r \n; } return 0; }上面这段代码输出结果中user_id1的两条记录会保持原始的101在103之前的顺序user_id2的三条记录也会保持100, 102, 104的顺序这正是稳定性的意义。再看复杂度。std::stable_sort的复杂度标准不是简单一句话能概括的很多标准库实现会尝试分配一块辅助内存如果内存足够复杂度接近O(n log n)如果内存不足则可能让复杂度退化到O(n log^2 n)这种更差的情况。这也是稳定排序很难做到“无痛”的原因之一。除了内存稳定排序还面临另一个问题移动次数。归并过程需要把元素在两个序列之间来回搬运。如果元素类型很大比如一个包含多个std::string、std::vector的自定义结构体那么排序的主要成本可能根本不是比较而是移动和拷贝。这种情况下Dtsort 如果只是减少了比较次数未必能在总耗时上占据明显优势。对比一下常见的排序工具场景std::sortstd::stable_sort备注不需要稳定性适合可选但可能浪费直接用 sort 即可相同键必须保留原顺序不保证适合核心稳定性场景大量重复键不稳定能保留原序决策树可能减少比较元素拷贝昂贵交换仍然贵归并移动多需要测试移动成本近排序输入通常快通常也快具体取决于实现从这张表可以看到std::stable_sort的“慢”并不是绝对的。它只是在稳定性约束下做了一个安全的选择。想要超越它新算法必须比它更聪明地处理等价元素和输入分布而不是简单地把比较次数压下来。3. 决策树视角下排序算法还能怎么优化很多人会把“决策树排序”理解成一个很奇怪的东西实际上所有基于比较的排序都可以被抽象成决策树。理论计算机科学里有个经典模型每次比较都是一次判断结果要么走左分支要么走右分支最后到达叶子节点时对应一种可能的排列结果。也就是说任何比较排序的本质都是一种决策过程。问题在于传统排序算法在运行前并不了解输入数据它的比较路径是固定写死在算法结构里的比如归并排序总是先分两半再两两比较快速排序总是选一个枢轴再分区。这种固定结构的好处是非常稳定无论输入是什么算法都有最坏情况的理论保障。坏处则是它不会“偷懒”如果输入里有一大片已经有序的数据或者存在大量重复键传统算法可能仍然在无意义地比较。决策树排序的核心思路就是把排序问题当成一个实时决策问题。算法根据已经得到的比较结果决定下一步该比较哪些元素、哪些区间需要继续细化、哪些区间已经可以认定有序。用机器学习的语言来说它是在为当前这批输入“学”一棵比较树而不是把一棵固定的树强加给所有输入。这个思路听起来很自然但工程实现非常难。难点在于第一构建决策结构的开销可能比节省的比较次数还高。如果数据只有几千个元素你花在“分析数据特征”上的时间可能已经足够跑完一次普通排序了。第二决策结果必须保证正确性排序不是分类决策树不能给一个“大概正确”的结果它需要穷尽所有必要的比较关系。第三稳定性是额外约束你不仅要把键值排好还要保证等价元素的原始相对顺序这对分区和合并过程提出了更高要求。所以Dtsort 如果真的是按照决策树思路实现稳定排序那么它真正的难点不是“想出决策树”而是如何让构建决策结构的代价远小于排序本身并且在整个过程中维持稳定的性质。这种算法很适合一类输入键的重复率比较高或者数据存在明显的分段模式。因为这种输入中“哪些元素必须互相比较”其实很有选择性不需要所有元素都参与全局排序。决策树可以优先处理那些不确定的区间而对于确定性很高的区间直接保留原始顺序即可。在大量重复键的场景中这意味着如果一组等价元素在输入中本来就连在一起决策树可能直接跳过内部的排序消耗这比标准归并要快得多。但从另一个角度看如果输入是完全随机的长尾数据每个键几乎都不相同那么任何基于比较的排序都很难创造奇迹。决策树的“偷懒”空间有限因为几乎每一对可能引起顺序变化的关系都必须被确认。此时 Dtsort 的优势就会明显缩小它最多只能做到接近优秀通用排序的水平。4. Dtsort 的核心逻辑与可能的性能来源Dtsort 的项目标题给出了两个关键信息一是基于决策树二是稳定排序。把这两个词放在一起它想要做到的事情就很清楚了用决策树的方式判断“哪些数据还需要继续排序”并且不破坏相等元素的原始顺序。从公开材料有限的条件出发我不会假装能给出它的源码级解读。一个合理的推断是Dtsort 会把整个排序过程拆成两个阶段。第一个阶段是分析输入识别出哪些区间存在逆序、哪些区间近似有序、哪些区域重复键高度集中。第二个阶段是根据第一阶段收集到的决策信息对必要区域进行稳定的细分排序同时保留全局顺序。这个过程很像一个“先探测、再排序”的算法。它和标准归并排序最大的区别在于归并排序总是从“全部元素都是无序的”这个假设出发而 Dtsort 会动态地认为“某些前缀可能已经有序”“某些相等块不需要拆开”。对于大量真实业务数据来说这个假设往往是对的。另一个可能的收益来自分支预测。现代 CPU 的流水线非常依赖分支预测而传统排序里的比较结果几乎无法预测因为输入是乱序的。Dtsort 通过决策树把相似的元素引导到同一分支后后续比较结果会呈现出更高的规律性分支预测失败率会下降这也是实际耗时降低的来源之一。但我们也要冷静看待。决策树方案可能在下面几类场景中处于劣势第一输入规模很小。比如只有几十个元素任何聪明的前期分析都是额外开销简单插入排序或标准 library sort 的小区间优化可能已经足够好。第二数据完全随机且键重复率极低。这种情况下几乎每个元素都需要和其他元素建立正确的顺序关系决策树很难找到可跳过的区间。第三移动成本高于比较成本。如果元素对象非常大那么即使 Dtsort 把比较次数降到最低只要移动次数没有明显下降最终时间仍然可能跑不过经过精心优化的移动策略。所以对 Dtsort 的正确态度是把它当成一个“针对特定数据分布的竞争性方案”而不是“所有稳定排序的终极答案”。它能不能赢取决于你的业务数据是否具备它可以利用的结构。5. 代码实验如何验证 Dtsort 是否真的稳定高效我不能替你做最终结论但可以给你一套完整的验证框架。无论 Dtsort 是作为开源库发布还是以论文代码形式提供你都可以用同样的方式验证它是否真的保持稳定是否在你的数据上比std::stable_sort更快5.1 准备基准测试环境建议在 Linux x86_64 环境上测试使用相同的编译器和优化等级。排序性能对编译选项非常敏感不要用 Debug 模式不要忘记开-O3。g -stdc17 -O3 -DNDEBUG main.cpp -o sort_bench ./sort_bench 1000000如果你的机器上安装了perf还可以顺便统计分支预测失败次数和缓存丢失情况这比只看时间更能说明问题。perf stat -e task-clock,cache-misses,branches,branch-misses ./sort_bench 1000000注意如果运行环境受限无法使用perf跳过即可计时框架已经足够用于初步判断。5.2 编写数据生成与计时框架为了公平验证我们需要用不同分布的数据来测试不能只测随机数。下面代码里包含三种典型分布完全随机、少量重复键、分段有序。// main.cpp #include algorithm #include chrono #include cstdint #include iostream #include random #include string #include vector struct Item { std::uint64_t key; std::uint64_t seq; // 输入时的全局序号用于检测稳定性 }; enum class DistType { kRandom, kFewKeys, kSortedRuns, }; std::vectorItem GenerateData(std::size_t n, DistType dist, std::uint64_t seed) { std::vectorItem data(n); for (std::size_t i 0; i n; i) { data[i].seq i; } std::mt19937_64 rng(seed); switch (dist) { case DistType::kRandom: { for (auto x : data) { x.key rng(); } break; } case DistType::kFewKeys: { for (auto x : data) { x.key rng() % 16; } break; } case DistType::kSortedRuns: { std::uint64_t run_key 0; for (std::size_t i 0; i n; i) { if (i % 1024 0) { run_key rng() % (1ULL 20); } data[i].key run_key; } break; } } return data; } template typename SortFn double TimeOnceMs(std::vectorItem data, SortFn sort_fn) { auto start std::chrono::steady_clock::now(); sort_fn(data.begin(), data.end(), [](const Item a, const Item b) { return a.key b.key; }); auto end std::chrono::steady_clock::now(); return std::chrono::durationdouble, std::milli(end - start).count(); } bool CheckStable(const std::vectorItem data) { for (std::size_t i 1; i data.size(); i) { // 如果 key 相等但 seq 出现倒序说明稳定性被破坏 if (data[i - 1].key data[i].key data[i - 1].seq data[i].seq) { return false; } } return true; } template typename SortFn void TestCase(std::size_t n, std::uint64_t seed, DistType dist, const std::string name, SortFn sort_fn) { std::vectorItem origin GenerateData(n, dist, seed); const int kReps 5; double total_ms 0.0; std::vectorItem copy origin; for (int i 0; i kReps; i) { copy origin; total_ms TimeOnceMs(copy, sort_fn); } double avg_ms total_ms / kReps; std::cout name | avg avg_ms ms | stable (CheckStable(copy) ? yes : no) std::endl; } int main(int argc, char** argv) { std::size_t n 1000000; if (argc 1) { n std::stoull(argv[1]); } std::uint64_t seed 20250416; std::cout n n std::endl; TestCase(n, seed, DistType::kRandom, std::stable_sort random, [](auto first, auto last, auto comp) { std::stable_sort(first, last, comp); }); TestCase(n, seed, DistType::kFewKeys, std::stable_sort few-keys, [](auto first, auto last, auto comp) { std::stable_sort(first, last, comp); }); TestCase(n, seed, DistType::kSortedRuns, std::stable_sort sorted-runs, [](auto first, auto last, auto comp) { std::stable_sort(first, last, comp); }); return 0; }这段代码的目标不是直接跑 Dtsort而是先把std::stable_sort的基线数据记录下来。你会看到它在三种分布下的时间差异尤其是kFewKeys和kSortedRuns这两种分布会体现出稳定排序在重复键和近排序输入上的行为。5.3 接入 Dtsort 候选实现拿到 Dtsort 的头文件或库之后不要修改公共测试代码只需要在main里增加一个新的TestCase调用。接口通常可以设计成标准库风格例如// 接入候选实现只替换排序函数不改其他逻辑 TestCase(n, seed, DistType::kRandom, Dtsort random, [](auto first, auto last, auto comp) { dtsort::stable_sort(first, last, comp); // 示意接口 }); TestCase(n, seed, DistType::kFewKeys, Dtsort few-keys, [](auto first, auto last, auto comp) { dtsort::stable_sort(first, last, comp); });这个过程有一个关键原则比较器必须完全一致测试数据必须相同稳定性检查必须通过。如果 Dtsort 的接口需要额外的参数比如一个输入分布提示或者一个预训练决策模型那么你需要在TestCase外层先完成对应的前置构造再传入排序函数。5.4 如何判断实验结果判断实验结果时第一看稳定性输出凡是在结果里出现stableno的方案不管多快都不能直接采用因为它已经不满足稳定排序的基本语义。第二看不同分布下的耗时差异。如果 Dtsort 只在kFewKeys上快在kRandom上反而慢说明它的优势来自重复键结构而不是通用排序能力。很多真实系统里的数据分布是有规律可循的如果你的业务数据偏重重复键和局部有序它的表现可能比通用基线好很多如果业务数据接近完全随机那就要谨慎。第三看规模变化。只测一个规模没有说服力建议至少测1万、10万、100万、1000万四档。有些算法的优势只在超大数组上体现有些则在小数组上反而被标准库的小数组优化按在地上摩擦。6. 评测稳定排序时常犯的几个错误在讨论 Dtsort 和std::stable_sort谁的性能更好之前先排除测量方法带来的干扰。方向错了结论也容易错。第一个错误是只统计比较次数不统计移动开销。排序时间等于比较时间加移动时间加算法调度时间。如果你的数据是小型整数比较和移动都很便宜决策树节省的比较次数会直接体现为耗时下降但如果数据是一个个重对象移动开销可能占据绝对大头比较次数的减少不一定会形成可见优势。第二个错误是没有重复测试只跑一次就下结论。现代 CPU 有频率动态调整后台进程也会干扰结果。正确做法是至少跑 5 轮去掉最高值和最低值或者取中位数。更严谨的做法是在同一台机器上交替跑两个候选方案避免前一个算法刚跑完带来的缓存升温影响。第三个错误是使用不同分配策略。std::stable_sort在内部可能需要辅助内存如果 Dtsort 的实现在排序前一次性申请一块很大的缓冲区而你的基准测试没有把分配成本纳入统计那这个比较是不公平的。你应该用长时间运行的方式让分配器预热或者明确注解两者使用额外空间的差异。第四个错误是忽略稳定性验证。有些算法为了提速会在比较器相等时做“二次比较”或者直接把元素按原始地址排序从宏观上看结果可能不影响正确性但它未必是严格稳定的。在做替换前稳定性测试必须单独通过。第五个错误是使用过于理想化的输入。比如只测试std::sort最难处理的逆序输入或者只测试全部随机的输入。真实业务数据很可能不是这些形态。应该从自己的生产日志、数据库快照或者消息队列中抽一批真实数据出来测同时构造几种极端数据做压力验证。7. 常见问题与排查思路很多读者在实际验证过程中会遇到类似问题这里整理成表格方便排查。问题现象可能原因排查方式解决方案测试结果每次波动很大CPU 频率、后台任务、未充分预热多轮重复取中位数检查系统负载固定 CPU 频率或增大数据规模传统 std::stable_sort 比 std::sort 慢很多稳定排序需要归并和辅助内存对比两者耗时和内存分配业务不需要稳定性直接用 sortDtsort 在随机数据上反而更慢决策树判断和构建开销大于收益查看各类数据耗时分布只在你能接受的输入分布上使用稳定性检查返回 no比较器没有遵守严格弱序或实现不保证稳定检查比较器是否等价处理相等元素修正比较器或换稳定实现小数据规模下没有提升前期决策开销占主导测试规模梯度观察比例小数组可以保留原有排序算法加入 seq 字段后所有排序都正确但变慢比较器变复杂排序失去比较优势重看比较器逻辑需要二级排序时用 std::tie 明确语义这里要
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻