
去年在一台资源很紧张的单片机上调试数据采集程序几万条记录乱序得一塌糊涂标准库的排序函数又没法直接用。我先写了插入排序跑一次要等好几秒完全不能接受。后来想起教科书里那个“比插入排序高级一点点”的希尔排序Shell Sort替换之后整个处理时间掉到几十毫秒。也就是从那次开始我才真正把希尔排序翻来覆去研究了一遍——表面看它只是“插入排序加了个增量”但背后那套分组跳跃的思想比很多外表更花哨的算法都值得琢磨。这篇文章就把我从原理到实现、从增量序列选型到工程场景判断的完整心得写出来适合正在学数据结构的同学、准备面试的求职者以及需要在资源受限环境里手写排序的工程师参考。1. 插入排序在无序数据面前的真实短板1.1 插入排序为什么“每次只能挪一步”要理解希尔排序先得把插入排序的病根看清楚。插入排序的思路很直白把数组分成已排序区和未排序区每次从未排序区取一个元素往前逐个比较找到合适位置插进去。这个过程里新元素每次和前面的元素比较后如果发现位置不对就交换一次一次只能和相邻元素交换。问题就出在这个“相邻交换”上。假设有一个完全倒序的数组比如[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]最小的数字1在最右边它要想到达数组最左端需要一路和前面的元素交换跨越 9 个位置。然后2又要从右侧开始一路交换跨越 8 个位置。这样累加下来整个排序过程的交换次数接近 n(n-1)/2。换句话说插入排序的效率瓶颈不是“比较”本身而是“远距离元素无法快速就位”。每个元素都只能像蜗牛一样一步一步往前挪只要数组规模稍微大一点这种挪动成本就会以平方级的速度膨胀。1.2 逆序对视角插入排序的总移动量计算机算法里有个非常实用的概念叫“逆序对”。定义很简单对于数组中的两个位置 i j如果 a[i] a[j]那这两个元素就构成一个逆序对。可以把它理解成“站错位置的两个人”——本该在前面的排在后面了。插入排序的每次交换恰好只能消除一个逆序对。所以插入排序的总交换次数本质上就等于这个数组的逆序对数量。一个随机打乱的数组逆序对数量期望大约是 n²/4 的量级这就从数学上解释了为什么插入排序在乱序数据下是 O(n²) 的复杂度。这个视角特别重要因为希尔排序的优化思路本质上就是在想办法“一次交换消除多个逆序对”。如果能让元素跨越多个位置移动一次操作就能同时处理掉好几个逆序对总成本自然就降下来了。1.3 一个反直觉的结论数据越乱跳跃式移动收益越大这里有个反直觉的结论数据越乱、规模越大插入排序的改进空间就越大。因为逆序对越多插入排序需要做的交换就越多而跳跃式移动一次能消除的逆序对也越多收益呈放大效应。所以希尔排序的设计逻辑其实很朴素先用较大的步长做几轮“粗调”让元素快速逼近自己最终应该在的区域最后再用步长为 1 的普通插入排序做“精调”。因为前面几轮已经把大量远距离逆序对消掉了最后一轮插入排序面对的是一个接近有序的数组几乎不需要怎么移动。这个思想后来在很多算法里都能看到影子比如快速排序在小区间切到插入排序、归并排序里对短序列用插入排序优化本质都是在利用“插入排序对接近有序的数据非常高效”这个特性。2. 希尔排序的“增量”到底在解决什么2.1 增量分组让元素一次跨越多个位置希尔排序里的“增量”其实就是排序时使用的步长 gap。核心做法是按 gap 把数组拆成若干组组内元素的下标差都是 gap然后对每组分别做插入排序。比如 gap5 时下标 0、5、10 这些元素被分到一组下标 1、6、11 被分到另一组每组内部排序时元素一次最多可以向后移动 5 个位置。这个过程可以理解为“让元素先跳着走”。就像一个大会议室里座位很乱如果允许人们一次跨过 5 个座位找位置肯定比只能挨着座位挪要快得多。随着排序进行gap 不断减小元素能跨越的距离也越来越短。当 gap1 时整个数组就只有一组恰好退化成普通的插入排序对所有元素做最后一次完整的精排。2.2 手动跑一遍完整的希尔排序全过程光说概念容易飘拿一个具体的例子完整跑一遍。假设数组是[49, 38, 65, 97, 76, 13, 27, 49, 55, 04]数组长度 n10按最常用的减半策略gap 依次取 5、2、1。第一趟gap5把下标差为 5 的元素分成一组组内做插入排序下标 0 和 549 和 13排序后变成 13、49下标 1 和 638 和 27排序后变成 27、38下标 2 和 765 和 49排序后变成 49、65下标 3 和 897 和 55排序后变成 55、97下标 4 和 976 和 04排序后变成 04、76第一趟结束后数组变成[13, 27, 49, 55, 04, 49, 38, 65, 97, 76]注意下标 2 的位置现在是 49它来自原数组下标 7下标 5 的位置是另一个 49来自原数组下标 0。两个相同元素已经悄悄换了先后顺序这正是希尔排序不稳定的一个缩影后面细说。第二趟gap2重新按下标差 2 分组。偶数下标 0、2、4、6、8 是一组里面的元素是 13、49、04、38、97组内插入排序后变成 04、13、38、49、97。奇数下标 1、3、5、7、9 是一组元素是 27、55、49、65、76排序后变成 27、49、55、65、76。第二趟结束后数组变成[04, 27, 13, 49, 38, 55, 49, 65, 97, 76]现在能明显看到较小的数已经集中到了数组前部较大的数被推到了后部。数据整体上比原始状态“顺”了很多。第三趟gap1这就是普通的插入排序。对数组[04, 27, 13, 49, 38, 55, 49, 65, 97, 76]从头到尾做一次插入排序04、27 不动13 往前插到 04 和 27 之间49 不动38 插到 27 和 49 之间55 不动第二个 49 不动65、97 不动76 插到 65 和 97 之间最终得到有序数组[04, 13, 27, 38, 49, 49, 55, 65, 76, 97]整个过程可以看到前两趟 gap 较大的排序虽然没让数组完全有序但把绝大多数元素推到了离最终位置不远的地方最后一趟插入排序只做了少量比较和移动就完成了全排。2.3 为什么最后一趟gap1才是整个算法的“保险丝”有人可能会问前面 gap5、gap2 的时候每组内部确实有序了但组和组之间还是乱着的这不等于白排吗并不白排。每一趟都在消解远距离的逆序对gap5 时一个元素从下标 9 挪到下标 4一口气解决了 5 个位置的错位gap2 时又能一次挪 2 个位置。等到 gap1 时剩下需要处理的逆序对已经很少了插入排序需要的工作量大幅降低。但前提是最后一趟 gap 必须等于 1。因为只有步长为 1 时才会对数组中所有相邻元素做一次“全覆盖”的比较和调整才能保证任何位置上的逆序对都不被漏掉。如果增量序列最后不是 1比如直接从 gap2 结束那最终得到的结果最多只能保证“偶数和奇数下标各自有序”整个数组仍然可能是乱的。所以 gap1 这一步是整个算法的“保险丝”缺了它前面做的所有工作都无法形成最终正确的结果。3. 手写实现三循环写法与最容易踩的边界坑3.1 最小可运行的shell_sort代码与逐行解释希尔排序的经典实现非常短三段循环嵌套核心代码不超过十行。我用 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 - gap; while (j 0 arr[j] temp) { arr[j gap] arr[j]; j - gap; } arr[j gap] temp; } } }这段代码有几个关键点需要拆开讲。外层循环控制 gap 从 n/2 开始每次除以 2直到 gap0 结束。这样得到的增量序列就是 n/2、n/4、n/8……最后是 1保证最后一趟一定是普通插入排序。中间层循环i从 gap 开始逐一处理每个元素。为什么不是从 0 开始因为每组内第一个元素没有前驱不需要比较而 i 从 gap 开始恰好可以覆盖所有组的第二个元素后续 i 递增时自然覆盖到每个组。最内层循环做真正的比较和移动j从i - gap开始也就是当前元素在组内前面一个元素的位置。如果前面元素比temp大就把前面元素往后挪gap个位置然后j再往前跳gap个位置继续比较。循环结束时j gap就是temp应该插入的位置。3.2 为什么内层从igap开始而不是从0开始这个问题很多初学者会卡住。如果用传统的“先分组再对每组单独插入排序”的思路代码会多出一层循环而且容易漏组。而这个三循环版本巧妙在i从 gap 到 n-1 逐个扫描时每遇到一个新元素就把它和组内前面的元素做插入排序。因为 i 是递增的所以每个组都会被交替处理到但处理顺序并不影响结果。举个简单例子gap2 时i2 处理的是偶数下标组的前两个元素i3 处理的是奇数下标组的前两个元素i4 处理偶数下标组的第三个元素时前面两个已经有序了所以它只需要在已经有序的小组里往前插入。也就是说这个写法让多个组的插入排序“交错”进行本质上和“按组分别排”的效果完全一样但代码量更少循环边界也更好控制。3.3 越界与空数组我在调试时见到的三个典型错误希尔排序代码虽然短但边界条件很隐蔽。我在实际写和调试时遇到过至少三个典型错误。错误一忘记j 0判断导致数组越界。最内层循环里如果只写while (arr[j] temp)当 j 减到负数时程序会去访问 arr[-gap] 这个不存在的内存地址。轻则读到脏数据重则直接崩溃。j 0这个条件必须出现在arr[j]访问之前顺序不能反。错误二最后把temp放错位置。循环退出后正确写法是arr[j gap] temp因为 j 已经跳过了所有比 temp 大的元素temp 应该放在 j 的下一个同组位置上。如果误写成arr[j] temp则会把数组里某个原有的数据覆盖掉排序结果完全错乱。错误三没有处理 n 1 的情况。当数组为空或只有一个元素时n/2 等于 0外层循环一次都不会执行逻辑上没问题。但有些写法如果先把 gap 初始化为 n再在循环里用gap 1判断就可能出现死循环。稳妥的做法是在函数开头加一句判断if (n 1) return;这几个错误都不难改但如果在嵌入式环境或面试白板上写代码一不留神就会踩中建议写完后逐行检查一遍循环边界。4. 增量序列选型n/2只是入门Knuth序列更实用4.1 不同增量序列为什么性能差异巨大希尔排序有一个让很多人困惑的特点时间复杂度不是固定的而是取决于增量序列的选择。所谓增量序列就是每次排序使用的 gap 按照什么规则递减。最朴素的 n/2、n/4、n/8……是最常见的教学写法代码写起来最简单但它并不是性能最优的选择。原因在于某些数据分布下相邻两趟 gap 之间的排序无法形成足够的“接力”。比如上一趟 gap8 排完下一趟 gap4 排序时原本在远距离上已经被纠正的元素可能又被某个跨组操作打回原形导致最后一趟 gap1 时仍然残留大量逆序对。更专业的说法是增量序列的各项之间如果存在公约数排序过程中就可能反复处理某些已经有序的区间浪费比较次数。最坏情况下使用 n/2 这种简单的减半序列希尔排序的时间复杂度仍然是 O(n²)和插入排序一个级别只是常数小一些。4.2 几种常见增量序列的时间复杂度对照工程上和研究文献里提到的增量序列有不少下面列几种最常见的方便做选型参考。增量序列名称生成方式最坏时间复杂度特点Shell 原始序列n/2, n/4, n/8, ...O(n²)实现最简单适合教学演示Knuth 序列h 3h 1取小于 n 的最大值O(n^(3/2))工程上最常用代码量小Hibbard 序列2^k - 1O(n^(3/2))理论性质好但生成略麻烦Sedgewick 序列多种公式组合O(n^(4/3)) 或更优性能好但序列生成复杂实际使用中Knuth 序列是性价比最高的选择。它的生成规则是 h 从 1 开始不断执行h 3 * h 1得到 1、4、13、40、121、364……然后排序时从不超过 n/3 的最大 h 开始递减时执行h (h - 1) / 3最终回到 1。相比之下n/2 序列代码最简单但在数据规模较大时性能不稳定Sedgewick 序列虽然理论性能更好但生成公式复杂实际收益在大规模数据下才明显中小规模数据上跟 Knuth 序列差距不大没必要为了那点差异增加代码复杂度。4.3 Knuth序列的现场构造方法与代码模板用 Knuth 序列改写希尔排序的代码模板如下void shell_sort_knuth(int arr[], int n) { if (n 1) return; int gap 1; while (gap n / 3) { gap 3 * gap 1; } while (gap 0) { for (int i gap; i n; i) { int temp arr[i]; int j i - gap; while (j 0 arr[j] temp) { arr[j gap] arr[j]; j - gap; } arr[j gap] temp; } gap (gap - 1) / 3; } }这个模板里第一步先用while (gap n / 3)找到不超过 n/3 的最大 Knuth 数作为初始 gap。为什么是 n/3因为下一个 Knuth 数3 * gap 1就会超过 n如果直接用它作为初始 gap第一趟排序时很多组里只有一个元素做了不少无用功浪费效率。实测下来同样的数据Knuth 序列比 n/2 序列通常能快 20% 到 50%尤其在数据规模中等偏大的情况下更明显。我自己后来写代码时基本默认用 Knuth 序列很少再碰 n/2 减半写法。5. 希尔排序为什么不稳定以及工程上什么时候该用它5.1 稳定性翻车现场同值元素为什么会被换位排序算法的稳定性指的是值相同的元素在排序前后是否保持原来的相对顺序。如果保持就是稳定的如果不保持就是不稳定的。插入排序本身是稳定的因为相等元素不会交换位置。但希尔排序在分组跳跃的过程中会破坏这种稳定性。看一个简单的例子数组[5a, 5b, 2]其中 5a 和 5b 是两个值相同但来源不同的元素5a 本来在 5b 前面。取 gap2 排序时下标 0 和下标 2 分到一组也就是 5a 和 2 一组组内排序后 2 到前面5a 被挪到下标 2数组变成[2, 5b, 5a]。此时 5a 已经跑到 5b 后面了。最后一趟 gap1 的插入排序虽然不会交换相等元素但两者的相对顺序在上一趟就已经被破坏无法恢复。这个特性在某些场景下会带来问题。比如先按主键排序再按次键排序时如果主键相同的元素次键原本有序稳定性好的算法可以保持这个次序而不稳定的算法可能把它打乱。所以如果业务上对相同关键字的相对顺序有要求希尔排序就不太合适。5.2 一次粗略的实测对比插入排序 vs 希尔排序 vs 快速排序空口无凭列一组我自己本地跑过的大致数据。环境是普通笔记本Crelease 模式数据为随机生成的整数下面是量级参考不同机器会有差异。数据规模插入排序希尔排序Knuth序列快速排序std::sort10万随机整数约8秒约30毫秒约20毫秒100万随机整数没敢真跑估计十几分钟量级约400毫秒约200毫秒这组数据有几个值得注意的点。插入排序和希尔排序之间的差距是两到三个数量级完全不是“优化了一点”的关系而是彻底换了个量级。希尔排序和快速排序之间的差距则缩小到了 2 倍左右在数据规模适中的情况下这个差距对很多场景来说是可以接受的。但希尔排序有一个额外优势它只需要常数级别的额外空间是真正的原地排序而快速排序虽然也是原地排序为主的算法但递归实现会占用调用栈空间何况标准库的 sort 在数据量大时往往还会切换到堆排序来避免最坏情况底层比我们想象中复杂得多。5.3 工程场景判断内存受限、中等规模、无稳定要求的排序任务如果回到工程视角什么时候应该真的用希尔排序而不是直接调标准库第一内存极度受限的环境比如单片机、嵌入式系统、驱动代码里。标准库排序函数可能不存在或者引入完整运行库的成本太高这时候手写一个几行的希尔排序非常划算。第二数据量不大不小但标准库排序的性能没有明显优势时。比如几万到几十万条记录希尔排序的性能和快速排序在一个量级但代码实现远比快速排序简单不容易写错。第三数据本身接近有序的情况。希尔排序在基本有序的数据上表现非常好因为它前面几趟只需少量调整最后一趟几乎线性完成。快速排序在基本有序的数据上如果没有好的分区策略反而可能退化。第四对排序稳定性没有要求并且希望用原地排序完成时。归并排序虽然稳定但需要 O(n) 的额外空间在内存有限的环境里可能直接不可用。大概可以记成一句话原地、中等规模、无序、不稳定要求四个条件都满足时希尔排序是很好的选择。5.4 面试会追着问的几个问题与应对要点分享几个面试里常围绕希尔排序展开的问题提前准备一下会稳很多。为什么最后一趟 gap 必须是 1因为任何大于 1 的 gap 都只能保证“间隔为 gap 的子序列有序”无法保证整个数组有序。只有 gap1 时才会对全部相邻元素做一次完整的插入排序确保没有漏掉任何逆序对。希尔排序的时间复杂度为什么没有一个固定的精确值因为它的时间复杂度强烈依赖增量序列的选择而对不同增量序列做精确复杂度分析本身是个很麻烦的问题至今没有统一的闭式公式。常见的 O(n^(3/2))、O(n^(4/3)) 都是针对特定序列的上界不能一概而论。为什么工程上默认排序不用希尔排序主要原因是快速排序在大量优化后的平均性能更好而且标准库实现已经把各种边界情况都处理好了。希尔排序虽然代码简单但增量序列选择对性能影响大最坏情况下可能退化到 O(n²)缺乏快速排序那种稳定的性能保证。插入排序本身是稳定的为什么希尔排序不稳定因为希尔排序在 gap1 的排序过程中元素会跨越多个位置移动相同值的元素可能被分到不同组或者被其他元素跨过相对顺序在那一刻就被破坏了后续无法恢复。这些问题并不难关键是理解每个回答背后的原理而不是死记硬背。我个人在实际使用中的一个体会是希尔排序最大的价值不只是“比插入排序快”而是它展示了一种优雅的算法设计思路——先用粗粒度的大步长解决主要矛盾再用细粒度的小步长收尾而不是试图一步到位。这个“由粗到细”的优化思想在很多工程问题里都能迁移使用。如果你也要在受限环境里写排序建议直接采用 Knuth 序列的版本代码不复杂性能也稳妥如果数据规模很大且稳定性有要求那还是老老实实交给标准库更省心。