希尔排序实战:增量序列、Knuth实现与性能优化
发布时间:2026/9/18 17:05:57来源:尧图网络
1. 插入排序的最后一公里为什么总要堵车写过排序代码的人大概都有这么一段经历数组规模一千以内随手写个插入排序快得飞起规模上到五万同样的代码突然就开始卡顿跑一遍几秒钟出不来。不是代码写错了是插入排序本身的移动次数跟着输入规模爆炸了。希尔排序Shell Sort就是奔着这个痛点来的——它不换数据结构不引入递归不额外申请内存只是在插入排序外面套了一层不断缩小的间隔循环就把随机数据下的移动次数从千万级压到了十万级。这篇内容适合三类人看正在啃数据结构排序章节的学生、需要在嵌入式或性能敏感场景里手写排序的 C/C 开发者、以及想看明白为什么有人放着快排不用偏要写希尔的工程实践者。全文围绕希尔排序讲透四件事间隔序列怎么选、代码边界怎么抠、实测性能差多少、以及真正踩过才知道的几个坑。我不会只给你一段能跑通就算完的代码。希尔排序真正的难点从来不在语法而在增量序列的数学性质和循环边界的细节——这两块写错一个程序要么结果不对要么性能还不如插入排序。2. 增量 h 到底在帮插入排序做什么2.1 插入排序的代价精算逆序对才是真正的敌人先把账算清楚。插入排序在随机数据上做的是把每个元素往前挪到它该在的位置每挪动一次就消除数组里的一个逆序对。所谓逆序对就是前面比后面大的那一对数。一个长度为 N 的随机排列逆序对的期望值是多少取任意两个位置它们的大小关系有一半概率是逆序的所以期望逆序对数为$$ \frac{N(N-1)}{2} \times \frac{1}{2} \frac{N(N-1)}{4} $$代入 N 10000得到约 2500 万次元素移动。每次移动还牵涉一次比较和一次赋值加上循环控制实际执行的操作数量要再翻几倍。这就是它慢的根本原因——不是循环写得不好是它必须一对一地消除逆序对。而希尔排序的聪明之处在于当间隔 h 很大的时候一次元素移动可以跨过很长距离消除掉大量逆序对。举个直观例子数组是[9, 1, 2, 3, 4, 5, 6, 7, 8, 0]用 h 5 跑一趟9直接跟5比较后原地不动、1跟6比……一路下来9会被挪到很靠后的位置这一步在普通插入排序里需要滚动挪动十次在希尔排序里一步到位。2.2 h 有序不等于整体有序这是很多人第一次学希尔排序时最容易跑偏的认知。当一趟间隔为 h 的排序结束后数组是h 有序的——意思是下标相差 h 的任意一对元素都满足前小后大。但这不意味着数组整体有序。拿 h 4 举例数组可能是[1, 3, 5, 7, 2, 4, 6, 8]。你看下标 0 和 41 2满足下标 1 和 53 4满足下标 2 和 65 6满足下标 3 和 77 8满足。所以它是 4 有序的。但显然整个数组乱七八糟——2还在7后面呢。理解这一点非常关键希尔排序把数组切成了 h 条互相独立的子序列每条子序列各自有序但子序列之间毫无关系。随着 h 从大变小这些子序列的粒度越来越细交错得越来越紧最后 h 1 的时候1 有序就等于整体有序了。所以最后一趟 h 1 绝对不能省省了就是一个半成品。这一点我在第 5 章还会展开说。2.3 为什么减小间隔这个策略能成立有人会问既然大间隔能让元素跨得远那一直用大间隔不行吗不行因为大间隔下元素只是粗略归位局部相邻的两个元素可能完全颠倒。而如果一开始就用 h 1那就退化成插入排序了跨不了一步。增量序列的设计本质是一个权衡间隔大移动效率高但精度低间隔小精度高但移动效率低。希尔排序把这两者串起来——先用粗粒度把元素大致推到该去的区域再用细粒度精修。这和图像处理里的多分辨率金字塔思路几乎一模一样先看缩略图再逐级放大对齐细节。从这个视角看增量序列就是分辨率递减的层级。序列设计得好每一级都能在上二级的基础上少做很多无用功设计得差比如最朴素的每次折半就会出现某些层什么都没干、白白多跑一轮的浪费甚至在糟糕的数据分布下退化到 N²。3. 增量序列怎么选拿数学换性能的地方3.1 五种常见序列的横向对比增量序列的选择是希尔排序唯一有研究空间的地方也是它区别于其他排序算法的最大特点。下面这张表是我整理的主力方案序列名生成方式前几项最坏复杂度工程评价希尔原始h n/2每次折半n/2 … 2, 1O(N²)最坏情况下会明显退化Hibbard2^k − 11, 3, 7, 15, 31Θ(N^{3/2})相邻增量互质退化概率低Knuthh 3h 11, 4, 13, 40, 121Θ(N^{3/2})数列短、生成简单首选Sedgewick混合式生成1, 5, 19, 41, 109约 O(N^{4/3})增量个数极少常数小Pratt2^p · 3^q1, 2, 3, 4, 6, 8, 9, 12O(N log²N)理论最漂亮增量太多反而慢看这张表要抓住一个规律理论最优的序列往往不是实战最快的。Pratt 序列理论上能做到 O(N log²N)比 Knuth 序列的 Θ(N^{3/2}) 看起来强但它的增量项数多到和 N 同数量级每换一个间隔就要把数组重新扫一遍实测反而打不过 Knuth。Sedgewick 序列是另一个思路它尽量让已有的增量互相配合使得每一级排序后剩下的乱序程度更低。它产生的增量数量极少——N 100 万时也不过十来个增量所以每轮的整体扫描开销很小。代价是生成公式稍复杂需要处理奇偶分支代码里写着不好看。我的建议是没有特殊理由就选 Knuth。序列短N 千万级也就二十来项、公式一行、边界清晰写完不容易出 bug最坏界也够看。3.2 Knuth 序列的三行生成逻辑Knuth 序列的生成代码短得可以塞进任何一个循环开头int h 1; while (h n / 3) { h 3 * h 1; /* 得到 1, 4, 13, 40, 121, ... */ }这三行有两个地方值得掰开说。第一为什么是 h n / 3 而不是 h n因为我们希望第一趟的间隔尽可能大但又不至于大到每条子序列只包含一两个元素。n / 3 是一个经验阈值保证最大间隔大致落在 n 的三分之一左右每条子序列还有三个左右的元素可供比较和移动。如果取 h n可能第一个 h 就接近 n子序列里只有两三个元素甚至只有一个元素排序就没有意义了。第二为什么是整数除法 n / 3在 C/C 里n 是 int 时 n / 3 天然向下取整这正好符合我们要小于的意图。但如果你写的是while (h n / 3)而 n 是size_t这种无符号类型当 n 很小比如 n 1 或 2时 n / 3 会等于 0循环体一次都不执行h 保持 1。这其实是正确的——只有一个元素的数组根本不需要排。但如果你把条件写成while (h n / 3)在 n 刚好等于 3 的倍数时就会多生成一个过大的 h让第一趟几乎什么都没干。这个问题我见过不止一个人在调试时才发现。3.3 序列的递减方式也有讲究生成完之后通常的写法是 h / 3 逐级往回退。这里有一个隐含要求递减过程必须严格收敛到 1否则最后永远不会触发 h 1 那一趟。Knuth 序列 1, 4, 13, 40 从 40 开始做 40 / 3 1313 / 3 44 / 3 11 / 3 0循环判别 h 1 时自动终止。这条链路是干净的。但如果你自己设计了一个序列比如用乘以 2 再加一点偏移的方式就要小心递减时是否会出现跳过 1的情况。一旦跳过了 1整个算法就变成了只做到 h 有序但整体没排完返回的数组看起来有规律实际是错的。跑单测时如果只测[3,1,2]这种小数组很可能刚好都过了等到数据量上来才发现结果不对——这是很隐蔽的一类 bug。4. 把代码写对从 C 骨架到 C 泛型版本4.1 C 版本的主循环与边界推导先把最朴素的 C 实现放上来这段代码可以直接编进项目里用#include stdio.h void shell_sort(int a[], int n) { /* 生成 Knuth 序列的最大间隔 */ int h 1; while (h n / 3) h 3 * h 1; for (; h 1; h / 3) { /* 对每个间隔做一次带间隔的插入排序 */ for (int i h; i n; i) { int key a[i]; int j i - h; while (j 0 a[j] key) { a[j h] a[j]; j - h; } a[j h] key; } } }这段代码里最关键的是a[j h] key;这一行——注意回填位置是 j h不是 j。因为 while 循环退出时 j 已经减到了 h 间隔的上一格或者直接变成了负数。真正该落笔的位置是 j h。这是希尔排序代码里最高频的错误。原因很清楚插入排序里内层循环用的是j--回填写a[j1] key希尔排序把 1 换成了 h回填自然就是a[jh] key。很多人复制粘贴时改漏了这一处结果在小数组上跑出来刚好对因为 h 可能等于 1等价于插入排序大数组才开始出错。顺便看一下内层循环的三种退出情形把它们分清楚边界就没问题了j 0但a[j] key找到了插入位置把 key 放在 j h。j 0key 是本子序列里最小的落在下标 j h 处也就是子序列的最前端。a[j] key不交换保持稳定这一点在第 6 章会细讲。三种情况最终都指向同一个a[j h] key所以这一段不用分支写法自然统一。4.2 C 泛型版本迭代器、仿函数与移动语义C 版的价值在于能直接配合标准库容器和自定义比较器使用。下面这份实现支持随机访问迭代器、自定义比较器并且对非平凡类型使用了std::move避免多余的拷贝#include iterator #include utility #include functional template class RandomIt, class Compare std::less void shell_sort(RandomIt first, RandomIt last, Compare comp Compare{}) { using diff_t typename std::iterator_traitsRandomIt::difference_type; const diff_t n last - first; if (n 2) return; diff_t h 1; while (h n / 3) h 3 * h 1; for (; h 1; h / 3) { for (diff_t i h; i n; i) { auto key std::move(*(first i)); diff_t j i - h; while (j 0 comp(key, *(first j))) { *(first j h) std::move(*(first j)); j - h; } *(first j h) std::move(key); } } }几个容易踩的细节差值类型必须用difference_type而不是size_t。因为内层循环里j - h之后 j 会变成负数用无符号类型的话j 0这个条件永远为真程序会直接越界访问。这是 C 模板代码里极其经典的一类坑编译器不会报错运行时直接崩。key用了std::move之后就不能再读它了。代码里的顺序是先std::move出来循环里只读*(first j)最后再std::move回去逻辑是安全的。但如果你在这个基础上加日志、加调试输出记得别去打印已经被移走的那个key。比较器的语义要和插排一致。默认的std::less是升序如果你传了自定义比较器判断条件就变成comp(key, *(first j))。这里一旦把参数顺序写反整个排序结果会整体倒过来而且小数据集上很难看出来。4.3 VS Code 断点调试时该盯哪几个变量我用 VS Code 配 gdb 单步跟希尔排序的时候看三个变量就够了h、i、j。观察顺序是这样的——先看 h 在外层依次取到什么值如果第一个 h 就大于 n说明序列生成那段写错了再看 i 从 h 开始的每一次迭代里j 从 i - h 一路递减时比较和移动的顺序对不对最后看 j 退出循环时数组下标 j h 处的值那应该就是本次插入落位的位置。有个小技巧在 VS Code 的监视窗口里加上(int*)an这种表达式gdb 语法n 是长度就能直接看到整个数组不用手动展开下标。每次外层循环结束时瞄一眼数组状态能非常直观地看到从 h 有序逐步收敛到全局有序的过程。这个观察过程比任何图解都管用建议你找一组 20 个元素的乱序数据手工跟一次。5. 实测希尔排序到底能快多少5.1 测试方法与数据构造我在一台普通开发机上做了一组对比环境是 gcc 13、-O2 优化、Linux 环境数据用std::mt19937生成均匀随机整数每组规模跑 10 次取中位数。对比对象是插入排序、希尔排序Knuth 序列和std::sort。需要说明的是下面的数字只反映我这台机器上的相对关系绝对值会随编译器和 CPU 变化但量级差异是有普遍参考价值的。数据规模插入排序希尔排序Knuthstd::sort1,000约 0.6 ms约 0.09 ms约 0.05 ms10,000约 62 ms约 1.1 ms约 0.6 ms100,000约 6.3 s约 17 ms约 8 ms1,000,000明显不可用约 260 ms约 105 ms5.2 数据背后的两条曲线看这组数字有两件事值得记下来。第一插入排序对规模极其敏感。从 1 万到 10 万规模涨了 10 倍时间涨了约 100 倍——完全符合 N² 的特征。而希尔排序从 1 万到 10 万只涨了约 15 倍明显低于平方级这就是 Θ(N^{3/2}) 级别的实际表现。这一条差异在工程选型时的意义是如果你的数据量可能会长到十万以上插入排序不是一个可以先凑合的选项它会在某个规模点突然变成瓶颈。第二希尔排序和 std::sort 的差距在大规模下固定为 2 到 3 倍左右。注意 std::sort 用的是内省式快排加堆排兜底是通用排序里第一梯队的选手能稳定保持在 2 到 3 倍差距说明希尔排序的性能并不落后只是定位不同。5.3 那什么时候还值得用希尔排序既然通用排序更快写希尔排序还有意义吗有而且场景不小一是代码体积。一段不到 20 行的 C 代码没有递归、没有动态内存分配、没有函数指针在嵌入式环境或者需要极致控制 Flash 占用的项目里这个体积优势非常实在。相比之下引进一套完整的排序库会带来额外的依赖和代码量。二是数据接近有序的场景。希尔排序对部分有序数据的适应能力很强。如果你处理的是一批几乎已经排好、只有少数元素位置不对的数据比如日志按时间追加但偶尔有补录希尔排序能跑到接近线性的水平——这时候它比通用排序的常数优势还明显。三是作为其他算法的预处理。在快速排序对大量重复元素处理不佳的场景里先用一趟粗间隔的希尔排序把数据打散一下再交给快排有时能改善快排的分区质量。这种组合用法我在处理带大量相同键值的日志数据时用过效果不错。四是作为教学和面试的素材。希尔排序是理解增量思想最好的切入点它的每一处代码细节都在体现为什么这样设计。6. 三个真正会让人栽跟头的地方6.1 稳定性一次跨子序列移动就把顺序打乱了希尔排序是不稳定排序这一点被无数教程一笔带过但很少有人说清楚为什么。我用一个具体例子把它讲透。设数组里有三个元素记为[4a, 4b, 1, 2, 3]其中 4a 和 4b 值相等4a 原本在 4b 前面。跑一遍 n 5、初始间隔 h 2 的过程第一步处理 i 2key 1和 a[0] 4a 比较4a 1所以 4a 被挪到下标 21 落到下标 0。数组变成[1, 4b, 4a, 2, 3]。注意这里发生了什么4a 被推到了下标 2而 4b 还停在下标 1。两个原本挨着的相等元素位置关系被大间隔的移动彻底改变了。第二步处理 i 3key 2和 a[1] 4b 比较4b 24b 被挪到下标 3。数组变成[1, 2, 4a, 4b, 3]。第三步处理 i 4key 3和 a[2] 4a 比较4a 34a 被挪到下标 4。数组变成[1, 2, 3, 4b, 4a]。到这里 h 2 这一趟结束接下来 h 1 只会做一次普通插入排序而数组已经有序了。最终结果是[1, 2, 3, 4b, 4a]——4b 反而排在了 4a 前面原始顺序被打乱。关键点在于4a 和 4b 从头到尾没有直接被比较过。它们处在不同的 h 子序列中各自在自己的轨道上被其他元素推来推去等两条轨道上的元素交错到一块儿时顺序已经无法恢复了。所以稳定性不是没做特殊处理这么简单而是大间隔移动这一机制天然带来的副作用。什么时候需要在意稳定性举个实际例子你有一张订单表先按金额排过一次序现在想按状态再排一次同时希望同状态内保持金额顺序。这种二次排序场景就必须用稳定排序希尔排序在这里不合适。6.2 初始间隔取错性能可能还不如插排希尔原始序列n/2 折半有一个著名的退化情况当所有较大的元素都集中在偶数下标位置上时前几趟间隔为偶数的排序几乎不产生任何有效移动因为比较的两端正好都在大元素区里。等间隔降下来开始真正干活时工作量已经和插入排序差不多了等于白白多跑了几趟。如果你在代码里看到有人这么写for (int h n / 2; h 0; h / 2) { ... }不是说它错它在大多数随机数据上表现尚可但换到 Knuth 序列只多一行代码最坏界就从 N² 降到 N^{3/2}。这种一行换一个数量级的改动没有理由不做。另外还有一个隐蔽问题当 n 是 2 的幂时折半序列会产生大量有公因子的间隔而这些公因子会让某些元素永远凑不到一起比较。Knuth 序列和 Hibbard 序列的一个重要优势就是相邻增量互质从根本上避免了这种永不相遇的情况。6.3 小数组上的一个反直觉现象在做性能对比的时候我发现一个反直觉的结果当数据规模小于 20 的时候希尔排序并不比插入排序快有时候还略慢。原因不难理解。希尔排序的额外成本主要在两方面一是序列生成和外层循环的控制开销二是当 h 较大时每次移动都要跨很远的内存地址缓存命中率反而下降。而插入排序在小数组上的内存访问是相邻的、极其友善的CPU 缓存和分支预测都能发挥到极致。这个现象在工程上有直接指导意义如果确定数据规模就在几十个元素以内直接用插入排序不要为了看起来高级上希尔排序。很多标准库的排序实现里快排递归到小区间时都会切换到插入排序用的就是这个道理。/* 常见的混合策略写法示意 */ void sort_dispatch(int a[], int n) { if (n 24) insertion_sort(a, n); /* 小区间插入排序更快 */ else shell_sort(a, n); /* 大区间用增量法降复杂度 */ }我自己在几个项目里都用了这个阈值切换24 这个数是测出来的——它的具体数值不强求20 到 32 之间都可以关键是要有这个意识而不是一条路走到黑。7. 写完之后该怎么验证代码跑通只是第一步真正把希尔排序用进项目还得做两层验证。第一层是正确性验证用对拍的方式最省事随机生成 1 万个不同规模、不同分布随机、逆序、全相同、大量重复、几乎有序的数组用你的希尔排序和标准库排序各跑一遍逐元素比对结果。这一步能捞出绝大多数边界 bug尤其是之前提到的漏掉 h 1和回填位置写成 j这两类在小规模随机数据下未必暴露但配合全相同和几乎有序这两种分布就很容易现形。第二层是性能验证重点看两件事一是数组是否已经接近有序时退化不明显二是规模上到十万以上时耗时曲线是否还是明显低于平方增长。如果发现从 1 万到 10 万耗时涨了接近 100 倍那说明增量序列没起到作用回去检查序列生成和递减逻辑。我个人在实操中的一个体会是希尔排序最适合作为一个人能完全掌控的排序实现。它的每一行代码你都能解释清楚为什么这么写没有隐藏的递归深度风险没有内存分配失败的可能出问题时单步跟一遍就能定位。这种可掌控性在一些对稳定性要求极高的系统里比单纯的性能数字更值钱。
网站建设高端定制企业官网