希尔排序实战:增量序列、Knuth实现与性能优化
2026/9/18 17:05:54 网站建设 项目流程

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比较后原地不动、16比……一路下来,9会被挪到很靠后的位置,这一步在普通插入排序里需要滚动挪动十次,在希尔排序里一步到位。

2.2 h 有序不等于整体有序

这是很多人第一次学希尔排序时最容易跑偏的认知。当一趟间隔为 h 的排序结束后,数组是"h 有序"的——意思是下标相差 h 的任意一对元素都满足前小后大。但这不意味着数组整体有序

拿 h = 4 举例,数组可能是[1, 3, 5, 7, 2, 4, 6, 8]。你看下标 0 和 4:1 < 2,满足;下标 1 和 5:3 < 4,满足;下标 2 和 6:5 < 6,满足;下标 3 和 7:7 < 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 = 13,13 / 3 = 4,4 / 3 = 1,1 / 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[j+1] = key;希尔排序把 1 换成了 h,回填自然就是a[j+h] = key。很多人复制粘贴时改漏了这一处,结果在小数组上跑出来刚好对(因为 h 可能等于 1,等价于插入排序),大数组才开始出错。

顺便看一下内层循环的三种退出情形,把它们分清楚,边界就没问题了:

  • j >= 0a[j] <= key:找到了插入位置,把 key 放在 j + h。
  • j < 0:key 是本子序列里最小的,落在下标 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_traits<RandomIt>::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 单步跟希尔排序的时候,看三个变量就够了:hij。观察顺序是这样的——先看 h 在外层依次取到什么值,如果第一个 h 就大于 n,说明序列生成那段写错了;再看 i 从 h 开始的每一次迭代里,j 从 i - h 一路递减时,比较和移动的顺序对不对;最后看 j 退出循环时数组下标 j + h 处的值,那应该就是本次插入落位的位置。

有个小技巧:在 VS Code 的监视窗口里加上(int*)a@n这种表达式(gdb 语法,n 是长度),就能直接看到整个数组,不用手动展开下标。每次外层循环结束时瞄一眼数组状态,能非常直观地看到"从 h 有序逐步收敛到全局有序"的过程。这个观察过程比任何图解都管用,建议你找一组 20 个元素的乱序数据手工跟一次。

5. 实测:希尔排序到底能快多少

5.1 测试方法与数据构造

我在一台普通开发机上做了一组对比,环境是 gcc 13、-O2 优化、Linux 环境,数据用std::mt19937生成均匀随机整数,每组规模跑 10 次取中位数。对比对象是插入排序、希尔排序(Knuth 序列)和std::sort

需要说明的是,下面的数字只反映我这台机器上的相对关系,绝对值会随编译器和 CPU 变化,但量级差异是有普遍参考价值的

数据规模插入排序希尔排序(Knuth)std::sort
1,000约 0.6 ms约 0.09 ms约 0.05 ms
10,000约 62 ms约 1.1 ms约 0.6 ms
100,000约 6.3 s约 17 ms约 8 ms
1,000,000明显不可用约 260 ms约 105 ms

5.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 = 2,key = 1,和 a[0] = 4a 比较,4a > 1,所以 4a 被挪到下标 2,1 落到下标 0。数组变成[1, 4b, 4a, 2, 3]

注意这里发生了什么:4a 被推到了下标 2,而 4b 还停在下标 1。两个原本挨着的相等元素,位置关系被大间隔的移动彻底改变了

第二步处理 i = 3,key = 2,和 a[1] = 4b 比较,4b > 2,4b 被挪到下标 3。数组变成[1, 2, 4a, 4b, 3]

第三步处理 i = 4,key = 3,和 a[2] = 4a 比较,4a > 3,4a 被挪到下标 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 倍,那说明增量序列没起到作用,回去检查序列生成和递减逻辑。

我个人在实操中的一个体会是,希尔排序最适合作为"一个人能完全掌控的排序实现"。它的每一行代码你都能解释清楚为什么这么写,没有隐藏的递归深度风险,没有内存分配失败的可能,出问题时单步跟一遍就能定位。这种可掌控性在一些对稳定性要求极高的系统里,比单纯的性能数字更值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询