
聊排序算法的时候很多人都会把希尔排序当成插入排序的一个改进版一笔带过。说实话我本科第一次看到它时也没觉得多了不起——不就是给直接插入排序加了几个间隔吗但后来被一组对比数据打脸之后我才真正意识到希尔排序的价值从来不是给插入排序续命而是它教给你一个问题当一组元素整体上已经部分有序时排序成本能压到多低。希尔排序解决的不是怎么把数组排好而是怎么排得更快。它适合所有想从基础排序过渡到高级排序的开发者学习也适合需要在不引入递归和额外内存的条件下把中等规模数据比如几千到几万元素排序性能再压一压的场景。它不像快速排序那样需要处理递归栈也不像归并排序那样需要 O(n) 的辅助空间实现起来就是两三个循环的事这也就是希尔排序的简单实现这个标题真正想表达的东西。下面我就把这个简单实现拆开来看聊清楚它背后的设计思路、关键参数、实际表现以及那些容易踩的坑。1. 希尔排序的核心思路与设计思想1.1 从插入排序的痛点说起直接插入排序的逻辑很简单把当前元素往前比较找到合适位置插入。但它的致命伤在于每次只能把元素移动一位。比如数组里最小的元素刚好在末尾那它要逐个往前挪 n-1 次才能到位。数据量小的时候体会不明显一旦数组上万这种惯性挪动就会把 O(n²) 的复杂度吃满。希尔排序的出发点和这个痛点直接相关如果我先让间隔大于 1 的元素做几次远距离比较让小数快速跳到前面大数快速甩到后面做完之后再执行一次普通的插入排序这时数组已经基本有序了插入排序的移动成本就会大幅下降。这个思路后来被总结为宏观上的预处理 微调上的插入排序。理解这一层你就能明白希尔排序不是一个噱头而是从数据移动模式上直接动了刀。这里有个容易忽略的点希尔排序里的每个间隔分组并不要求组内完全有序它只要求全局的逆序数明显下降。因为每趟跨间隔交换之后原来相隔很远的逆序对都有机会被拉近甚至消解。这和你手动把数组粗略分几组分别排序再合并效果上是不一样的。分组合并之后仍然存在大量跨组逆序而希尔排序因为每一趟的 gap 都在缩小天然照顾到了多尺度上的逆序问题。1.2 间隔序列希尔排序真正的主角希尔排序的代码结构其实非常固定最核心的变量就是 gap也就是增量或间隔。gap 决定了两两比较的元素之间隔着多少位置。最常用的朴素做法是从 n/2 开始每轮缩小一半直到 gap 1 为止。这个序列简单归简单但不能算最优。为什么选 n/2 递减因为它能保证最后一轮一定是 gap 1也就是退化成一次普通的插入排序而这一轮一定会让数组完全有序。任何合法的希尔排序都必须满足最后一趟 gap 等于 1这个条件否则就无法保证排序完成。但不同间隔序列对性能的影响差别极大。希尔本人的原始序列在最坏情况下是 O(n²) 的如果改用 Hibbard 序列1, 3, 7, 15, 31...即 2^k - 1最坏复杂度可以压到 O(n^(3/2))再进一步使用 Sedgewick 序列某些分析模型下能做到 O(n^(4/3)) 甚至更好。这说明一个很现实的问题希尔排序的简单实现可以很简单但高效实现确实需要认真选间隔。我对初学者的建议是先别急着上 Sedgewick 序列先把 n/2 递减的版本写熟搞懂每个 gap 下代码是怎么走通的之后再换成其他序列对比性能。因为间隔序列的选择只影响速度不改变算法逻辑换序列时你的代码框架几乎不需要动。1.3 稳定性为什么都说希尔排序不稳定排序算法的稳定性指的是值相等的两个元素在排序前后的相对位置是否保持不变。直接插入排序因为是相邻比较遇到相等值就停下所以是稳定的希尔排序则不然因为它在间隔大于 1 时会把相距很远的元素直接交换位置两个相等元素完全可能因为分属不同的跳线分组而被调换顺序。我举个直观的例子。假设数组是 [5a, 3, 5b, 1]其中 5a 和 5b 表示数值相等但来源不同的两个 5。当 gap 2 时5a 和 5b 并不会在同一个分组里比较交换之后它们的先后关系就可能被打破。也就是说无论你用什么间隔序列只要存在间隔大于 1 的交换稳定性就有丧失的风险。理解了这一点应用上的建议也就出来了如果你的业务数据里有关键字段的先后顺序需要保持比如按时间排序后再按价格排序价格相同时希望保留时间的先后那么希尔排序不适合做这种多级排序。此时应该优先考虑稳定的归并排序或者插入排序。我在实际工程里一般只在数据量适中、不需要稳定、内存紧张这三条同时满足时才选希尔排序。2. 简单实现从零手写希尔排序2.1 最简版实现先把最朴素的版本摆出来。我用 Python 写因为它的循环和下标逻辑最直观方便你对照思路def shell_sort(arr): n len(arr) gap n // 2 # 从一半开始缩小 while gap 0: # 对每个间隔分组执行插入排序 for i in range(gap, n): temp arr[i] j i # 往前跳着比较而不是一步一步挪 while j gap and arr[j - gap] temp: arr[j] arr[j - gap] j - gap arr[j] temp gap // 2 # 缩小间隔 return arr复制过去就能跑。你甚至可以把它压成不到十行但我故意把内部变量拆分清楚这样每行在做什么一眼就能看明白。需要注意希尔排序并不是先分组再排序的物理形态而是在同一个数组中用下标跨越 gap 的方式同时处理了所有分组这正是它省内存的原因。这个版本里有两个关键细节值得单独解释第一个是for i in range(gap, n)的起点为什么从 gap 而不是从 0 开始第二个是内层 while 为什么用arr[j - gap]做比较而不是arr[j]。搞清楚这两个地方你才算真正看懂了这段代码。2.2 为什么内层循环长这样先说起点。外层的 i 从 gap 开始是因为每个分组内第一个元素默认已经有序。比如当 gap 3 时下标 0、3、6... 是一个分组其中下标 0 的元素一开始就待在自己最前面的状态等于是单个元素天然有序所以直接从分组第二个元素即 i 3开始往前插入。同理下标 1、4、7... 的分组从 i 4 开始下标 2、5、8... 的分组从 i 5 开始。for i in range(gap, n)正好把这些分组的入口全部覆盖到了既不重复也不遗漏。再说内层 while。它的逻辑和直接插入排序完全一致只是把往前挪一位改成了往前挪 gap 位。temp保存当前要插入的元素j从当前位置开始往前扫描只要发现arr[j - gap]比temp大就把这个较大的值往后挪到j处腾出空位然后j - gap继续往前。循环结束后j指向的位置就是temp应该插入的位置。整个过程就是间隔化的插入排序理解这一点基本上就掌握了希尔排序。你可以想一个细节为什么把arr[j - gap]赋给arr[j]而不是交换两个元素因为交换会产生三次赋值把一个值先存到temp再整体后移每组只需要一次读 写减少了很多重复操作这也是实现里一个不起眼但值得坚持的优化。2.3 用 C 语言再看一遍底层细节Python 版本适合理解思路如果你想看它在内存里有多省还是用 C 语言更直接void shell_sort(int arr[], int n) { for (int gap n / 2; gap 0; gap / 2) { for (int i gap; i n; i) { int temp arr[i]; int j i; while (j gap arr[j - gap] temp) { arr[j] arr[j - gap]; j - gap; } arr[j] temp; } } }这段代码连一个额外的数组都没开全部操作都在原数组上完成空间复杂度是 O(1)。某些嵌入式场景下数据量几千、内存又紧张这种原地排序就很有优势。我实际在低资源环境里跑过希尔排序几万条记录排下来不会有明显的内存波动这在快速排序的递归实现里是做不到的。需要强调C 版本里int j i放在内层 for 里声明每次都会重新初始化这和你把 j 定义在 while 外层效果相同但可以避免上一个循环残留的值干扰后续比较。很多初学者在改写成无符号下标时会把j gap误写成j 0一旦 gap 大于 1 就可能导致越界访问调试起来比较隐蔽这里先打个预防针。2.4 换一种间隔序列看看如果你已经跑通了上面 n/2 递减的版本想试试 Hibbard 序列gap 2^k - 1可以先把所有可能的 gap 预生成到一个列表再按从大到小的顺序套进同一个循环框架。比如对长度为 100 的数组先用 gap 63再 31、15、7、3、1这样排序趟数更多但每趟交换效率也更高。Python 下生成 Hibbard 序列可以这样gaps [] k 1 while (1 k) - 1 len(arr): gaps.append((1 k) - 1) k 1 for gap in reversed(gaps): for i in range(gap, n): temp arr[i] j i while j gap and arr[j - gap] temp: arr[j] arr[j - gap] j - gap arr[j] temp你会发现换了间隔序列后外层 while 变成了遍历 gap 列表内层的插入逻辑一行都不用改。这进一步印证了希尔排序的结构特点真正影响性能的是间隔序列的选择 每趟的移动效率而不是外层循环的写法。我个人的体会是当你意识到这一点时希尔排序就不再是死记硬背的代码了它变成了一类可以自由改装配件的方法。3. 性能分析与实践场景3.1 时间复杂度为什么是玄学教科书一般会写希尔排序的时间复杂度是 O(n log² n)但严格说这取决于你选什么间隔序列。同样长度的数组不同 gap 序列跑出来的比较次数可能相差好几倍。原因在于希尔排序的每一趟都不是独立的上一趟排序留下的部分有序状态会直接影响下一趟比较时发生交换的概率这种级联效应让精确分析变得非常困难。举个例子同样是 n 10000 的随机数组用 n/2 递减的原始序列实测耗时可能比 Hibbard 序列多出近一倍。而如果数组本身已经接近有序这两种序列的差距又会缩小。这也是为什么很多算法教材在分析希尔排序时都说得比较模糊——不是写不出来而是它没有一个和输入规模直接挂钩的稳定公式必须把间隔序列和数据分布两个变量一起考虑。从工程角度看我们不需要纠结它到底是 O(n^(4/3)) 还是 O(n log² n)只需要记住两条希尔排序比直接插入排序在随机数据上快得多比快速排序和归并在百万级数据上慢但在中等规模、资源受限的场景下它可能是最优解。3.2 现场跑一组对比数据光说不练没意思。我在本地用一个 50000 元素的随机整数数组做了几轮对比记录大概如下单位为毫秒不同机器会有差异但比例可以参考算法50000随机数据耗时说明直接插入排序约 820ms最慢移动次数爆炸希尔排序n/2递减约 22ms明显提升接近30倍希尔排序Hibbard序列约 15ms进一步提升快速排序内置排序约 8ms更快但稳定性差/需递归栈这个结果说明了希尔排序简单实现的价值代码量跟插入排序差不多复杂度却接近快排的同一数量级。当然测试数据本来就是随机数如果数据接近有序插入排序反而可能更快这也是另一个容易让人误判的地方——算法的性能永远要结合数据形态来看。如果你自己也想做类似对比我建议用 Python 的random.sample生成不含重复的数据然后对同一个数据副本跑不同算法避免因为无序程度不同而产生偏差。另外一定要用time.perf_counter()计时不要用time.time()后者在 Windows 下的精度不够。3.3 工程里什么时候用它我在实际项目里总结出三个适合用希尔排序的场景。第一个是内存受限的嵌入式环境。比如单片机上要排序几百到几千个传感器读数没有足够堆内存跑递归快排这时候希尔排序的 O(1) 空间优势非常宝贵。第二个是数据量不大、又不想引入额外排序库的场景。比如写一个脚本工具的辅助函数需要排的数组就几千个元素用内置排序接口反而要处理一堆配置不如直接用希尔排序把问题解决掉。第三个是作为近乎有序但又不完全有序的预处理步骤。有些场景下你只需要把数据大致理顺比如做分桶之前先按序扫描一遍希尔排序可以在不需要稳定性的前提下快速收敛逆序对。反过来如果你要排十万以上的数据或者业务要求排序稳定那就老老实实上归并排序或 TimSort 这类工业级方案。希尔排序不是一个万能银弹它的正确打开方式是在合适的规模做合适的事。3.4 间隔序列到底怎么选这个问题如果展开讲能写一整篇论文但落到代码里我的建议非常直接先默认用 n/2 递减明确它已经能解决大多数问题如果对性能有进一步要求再换成 Sedgewick 序列或者 Hibbard 序列。你要知道 Sedgewick 序列的体现形式很多常见的一种是 1, 5, 19, 41, 109, 209, 505, 929...生成规则相对复杂但工程上可以直接预置成表sedgewick_gaps [1, 5, 19, 41, 109, 209, 505, 929, 2161, 3905, 8929, 16001]使用的时候从大到小过滤掉大于 n 的 gap然后逐个套用即可。从我的实测看Sedgewick 序列在中等规模随机数据上的表现往往比 Hibbard 略好但这个差距在 n 10000 以下基本不明显。所以真正的建议是不要为了微小的性能差异去背复杂序列先用简单序列把逻辑跑通再去按需优化这才是简单实现的核心态度。4. 常见问题与调试经验4.1 gap 最后必须归到 1为什么有人会想既然前面已经把数组大致排好了最后一趟非得用 gap 1 吗答案是必须的。因为前面每一趟只是让数据基本有序不同分组之间的元素仍然可能交叉逆序只有 gap 1 时的标准插入排序才能把所有元素彻底归位。如果最后一趟用的是 gap 2那下标奇偶两个大组之间的相对顺序根本无法保证排序结果必然是错的。我在初学时就吃过这个亏为了省一趟循环直接把 gap 从 4 跳到 1 之前少做了一轮结果发现输出总有一两个元素不在正确位置上。后来检查才发现是我把 while 条件写成了gap 1导致最后一趟被跳过了。这个 bug 非常隐蔽因为大部分数组看起来已经挺有序了只有极少数错位暴露问题。所以每次写完希尔排序第一件事就是检查循环终止条件里有没有包含 gap 1 那一趟。4.2 稳定性丢失的直观验证前文说过希尔排序不稳定如果你不信可以做一个简单实验把数组 [2a, 1, 2b] 拿小数据试。当 gap 1 时两个 2 的相对顺序保持不变但一旦 gap 22a 和 2b 可能就不在同一个分组里交换后顺序就可能颠倒。这里有一个常见误解是分组内采用插入排序而插入排序是稳定的所以希尔排序也稳定。这个推论错就错在忽略分组与分组的交叉交换稳定性是全局属性不是局部属性的简单叠加。如果你需要在工程里保证稳定但又不想放弃希尔排序的速度一个变通方案是对相等元素加入原始下标作为副键排序比较时先比主键主键相等再比副键。这样虽然额外开销有限但总感觉是在给数据打补丁。我通常更推荐直接改成稳定的归并排序省去维护副键的麻烦。4.3 下标和边界最容易踩的坑希尔排序的实现里最常见的运行错误是数组越界。原因多出现在内层 while 的比较逻辑上。比如你把j gap写成j 0当 gap 大于 1 时j 回退到小于 gap 的位置后仍然继续访问arr[j - gap]此时下标变成负数程序要么崩溃要么返回错误结果。这种问题在 Python 里不会立刻报错因为负下标会偷偷访问数组尾部更隐蔽。防范办法其实就一条内层 while 的判断条件永远写成j gap不要自己去换算当 j 等于 gap 时还能不能往左比因为 gap 值初始比较大换成j 1会被 gap 的值误导。我还会在测试时专门用长度为 1、2、3 的小数组跑边界用例确保空数组、单个元素数组、两个元素数组都能正常返回而不是报下标错误。4.4 一个真实的排查案例去年我帮同事调试一段数据清洗脚本他跑的希尔排序总是返回部分有序的数组。代码看起来完全正常gap 也按 n/2 递减。后来我在对敲每组数据时发现他的数据里有大量重复的 id而业务要求保持 id 原有的先后顺序。问题到这里才真相大白不是排序逻辑出错而是稳定性丢失导致多条记录顺序错乱。这件事给我的教训是调试排序算法时不要只盯着是否单调递增还要关注相等元素是否保持了原有顺序。稳定性是一个容易被视觉忽略的属性。你在数组值全部唯一时怎么跑都对一旦出现重复值不同算法的差异立刻现出原形。所以自己测试时一定要专门构造一个包含重复值的小数组并把每个元素标上来源序号再去看排序结果否则排查方向很容易跑偏。另外一个真实的坑也值得说不要用arr sorted(arr)当作希尔排序的返回。因为 Python 的sorted会生成新列表你原数组没变调用者拿到的还是乱序数据。我见过一个初学者写的版本函数内部确实调用了希尔排序逻辑但最后一不小心写成了return sorted(arr)结果性能直接变成快排还破坏了原数组引用排查很久才找到。这些都是实现细节里的简单问题但最容易坑到人。最后再分享一个小技巧如果你想用希尔排序去处理字符串数组或者对象数组不必改动核心逻辑只要把内层比较符替换成你自己定义的compare(temp, arr[j - gap])函数即可。排序的本质就是比较-交换通用逻辑根本不用重写。我个人在实际项目中比较满意的用法是把希尔排序封装成一个通用的sort(arr, compare)工具函数用来在嵌入式脚本里对结构体数组做快速预处理代码量小、不占额外空间效果比预想稳定得多。这也是为什么我一直认为理解希尔排序的价值不在于背代码而在于看懂间隔这个维度给排序带来的杠杆作用。