news 2026/9/11 4:18:27

希尔排序原理详解:从插入排序到增量序列,高效实现与工程应用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
希尔排序原理详解:从插入排序到增量序列,高效实现与工程应用

去年在一台资源很紧张的单片机上调试数据采集程序,几万条记录乱序得一塌糊涂,标准库的排序函数又没法直接用。我先写了插入排序,跑一次要等好几秒,完全不能接受。后来想起教科书里那个“比插入排序高级一点点”的希尔排序(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,然后对每组分别做插入排序。比如 gap=5 时,下标 0、5、10 这些元素被分到一组,下标 1、6、11 被分到另一组,每组内部排序时,元素一次最多可以向后移动 5 个位置。

这个过程可以理解为“让元素先跳着走”。就像一个大会议室里座位很乱,如果允许人们一次跨过 5 个座位找位置,肯定比只能挨着座位挪要快得多。

随着排序进行,gap 不断减小,元素能跨越的距离也越来越短。当 gap=1 时,整个数组就只有一组,恰好退化成普通的插入排序,对所有元素做最后一次完整的精排。

2.2 手动跑一遍完整的希尔排序全过程

光说概念容易飘,拿一个具体的例子完整跑一遍。假设数组是:

[49, 38, 65, 97, 76, 13, 27, 49, 55, 04]

数组长度 n=10,按最常用的减半策略,gap 依次取 5、2、1。

第一趟,gap=5

把下标差为 5 的元素分成一组,组内做插入排序:

  • 下标 0 和 5:49 和 13,排序后变成 13、49
  • 下标 1 和 6:38 和 27,排序后变成 27、38
  • 下标 2 和 7:65 和 49,排序后变成 49、65
  • 下标 3 和 8:97 和 55,排序后变成 55、97
  • 下标 4 和 9:76 和 04,排序后变成 04、76

第一趟结束后数组变成:

[13, 27, 49, 55, 04, 49, 38, 65, 97, 76]

注意,下标 2 的位置现在是 49,它来自原数组下标 7;下标 5 的位置是另一个 49,来自原数组下标 0。两个相同元素已经悄悄换了先后顺序,这正是希尔排序不稳定的一个缩影,后面细说。

第二趟,gap=2

重新按下标差 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]

现在能明显看到,较小的数已经集中到了数组前部,较大的数被推到了后部。数据整体上比原始状态“顺”了很多。

第三趟,gap=1

这就是普通的插入排序。对数组[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 为什么最后一趟gap=1才是整个算法的“保险丝”

有人可能会问:前面 gap=5、gap=2 的时候,每组内部确实有序了,但组和组之间还是乱着的,这不等于白排吗?

并不白排。每一趟都在消解远距离的逆序对:gap=5 时,一个元素从下标 9 挪到下标 4,一口气解决了 5 个位置的错位;gap=2 时又能一次挪 2 个位置。等到 gap=1 时,剩下需要处理的逆序对已经很少了,插入排序需要的工作量大幅降低。

但前提是,最后一趟 gap 必须等于 1。因为只有步长为 1 时,才会对数组中所有相邻元素做一次“全覆盖”的比较和调整,才能保证任何位置上的逆序对都不被漏掉。如果增量序列最后不是 1,比如直接从 gap=2 结束,那最终得到的结果最多只能保证“偶数和奇数下标各自有序”,整个数组仍然可能是乱的。所以 gap=1 这一步是整个算法的“保险丝”,缺了它,前面做的所有工作都无法形成最终正确的结果。

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,直到 gap=0 结束。这样得到的增量序列就是 n/2、n/4、n/8……最后是 1,保证最后一趟一定是普通插入排序。

中间层循环i从 gap 开始,逐一处理每个元素。为什么不是从 0 开始?因为每组内第一个元素没有前驱,不需要比较,而 i 从 gap 开始恰好可以覆盖所有组的第二个元素,后续 i 递增时自然覆盖到每个组。

最内层循环做真正的比较和移动:ji - gap开始,也就是当前元素在组内前面一个元素的位置。如果前面元素比temp大,就把前面元素往后挪gap个位置,然后j再往前跳gap个位置继续比较。循环结束时,j + gap就是temp应该插入的位置。

3.2 为什么内层从i=gap开始而不是从0开始

这个问题很多初学者会卡住。如果用传统的“先分组,再对每组单独插入排序”的思路,代码会多出一层循环,而且容易漏组。而这个三循环版本巧妙在:i从 gap 到 n-1 逐个扫描时,每遇到一个新元素,就把它和组内前面的元素做插入排序。因为 i 是递增的,所以每个组都会被交替处理到,但处理顺序并不影响结果。

举个简单例子,gap=2 时,i=2 处理的是偶数下标组的前两个元素,i=3 处理的是奇数下标组的前两个元素,i=4 处理偶数下标组的第三个元素时,前面两个已经有序了,所以它只需要在已经有序的小组里往前插入。

也就是说,这个写法让多个组的插入排序“交错”进行,本质上和“按组分别排”的效果完全一样,但代码量更少,循环边界也更好控制。

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 之间的排序无法形成足够的“接力”。比如上一趟 gap=8 排完,下一趟 gap=4 排序时,原本在远距离上已经被纠正的元素,可能又被某个跨组操作打回原形,导致最后一趟 gap=1 时仍然残留大量逆序对。

更专业的说法是:增量序列的各项之间如果存在公约数,排序过程中就可能反复处理某些已经有序的区间,浪费比较次数。最坏情况下,使用 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 前面。取 gap=2 排序时,下标 0 和下标 2 分到一组,也就是 5a 和 2 一组,组内排序后 2 到前面,5a 被挪到下标 2,数组变成[2, 5b, 5a]。此时 5a 已经跑到 5b 后面了。最后一趟 gap=1 的插入排序虽然不会交换相等元素,但两者的相对顺序在上一趟就已经被破坏,无法恢复。

这个特性在某些场景下会带来问题。比如先按主键排序,再按次键排序时,如果主键相同的元素次键原本有序,稳定性好的算法可以保持这个次序,而不稳定的算法可能把它打乱。所以如果业务上对相同关键字的相对顺序有要求,希尔排序就不太合适。

5.2 一次粗略的实测对比:插入排序 vs 希尔排序 vs 快速排序

空口无凭,列一组我自己本地跑过的大致数据。环境是普通笔记本,C++,release 模式,数据为随机生成的整数,下面是量级参考,不同机器会有差异。

数据规模插入排序希尔排序(Knuth序列)快速排序(std::sort)
10万随机整数约8秒约30毫秒约20毫秒
100万随机整数没敢真跑,估计十几分钟量级约400毫秒约200毫秒

这组数据有几个值得注意的点。

插入排序和希尔排序之间的差距是两到三个数量级,完全不是“优化了一点”的关系,而是彻底换了个量级。希尔排序和快速排序之间的差距则缩小到了 2 倍左右,在数据规模适中的情况下,这个差距对很多场景来说是可以接受的。但希尔排序有一个额外优势:它只需要常数级别的额外空间,是真正的原地排序;而快速排序虽然也是原地排序为主的算法,但递归实现会占用调用栈空间,何况标准库的 sort 在数据量大时往往还会切换到堆排序来避免最坏情况,底层比我们想象中复杂得多。

5.3 工程场景判断:内存受限、中等规模、无稳定要求的排序任务

如果回到工程视角,什么时候应该真的用希尔排序而不是直接调标准库?

第一,内存极度受限的环境,比如单片机、嵌入式系统、驱动代码里。标准库排序函数可能不存在,或者引入完整运行库的成本太高,这时候手写一个几行的希尔排序非常划算。

第二,数据量不大不小,但标准库排序的性能没有明显优势时。比如几万到几十万条记录,希尔排序的性能和快速排序在一个量级,但代码实现远比快速排序简单,不容易写错。

第三,数据本身接近有序的情况。希尔排序在基本有序的数据上表现非常好,因为它前面几趟只需少量调整,最后一趟几乎线性完成。快速排序在基本有序的数据上如果没有好的分区策略,反而可能退化。

第四,对排序稳定性没有要求,并且希望用原地排序完成时。归并排序虽然稳定,但需要 O(n) 的额外空间,在内存有限的环境里可能直接不可用。

大概可以记成一句话:原地、中等规模、无序、不稳定要求,四个条件都满足时,希尔排序是很好的选择。

5.4 面试会追着问的几个问题与应对要点

分享几个面试里常围绕希尔排序展开的问题,提前准备一下会稳很多。

为什么最后一趟 gap 必须是 1?因为任何大于 1 的 gap 都只能保证“间隔为 gap 的子序列有序”,无法保证整个数组有序。只有 gap=1 时才会对全部相邻元素做一次完整的插入排序,确保没有漏掉任何逆序对。

希尔排序的时间复杂度为什么没有一个固定的精确值?因为它的时间复杂度强烈依赖增量序列的选择,而对不同增量序列做精确复杂度分析本身是个很麻烦的问题,至今没有统一的闭式公式。常见的 O(n^(3/2))、O(n^(4/3)) 都是针对特定序列的上界,不能一概而论。

为什么工程上默认排序不用希尔排序?主要原因是快速排序在大量优化后的平均性能更好,而且标准库实现已经把各种边界情况都处理好了。希尔排序虽然代码简单,但增量序列选择对性能影响大,最坏情况下可能退化到 O(n²),缺乏快速排序那种稳定的性能保证。

插入排序本身是稳定的,为什么希尔排序不稳定?因为希尔排序在 gap>1 的排序过程中,元素会跨越多个位置移动,相同值的元素可能被分到不同组或者被其他元素跨过,相对顺序在那一刻就被破坏了,后续无法恢复。

这些问题并不难,关键是理解每个回答背后的原理,而不是死记硬背。

我个人在实际使用中的一个体会是:希尔排序最大的价值不只是“比插入排序快”,而是它展示了一种优雅的算法设计思路——先用粗粒度的大步长解决主要矛盾,再用细粒度的小步长收尾,而不是试图一步到位。这个“由粗到细”的优化思想,在很多工程问题里都能迁移使用。如果你也要在受限环境里写排序,建议直接采用 Knuth 序列的版本,代码不复杂,性能也稳妥;如果数据规模很大且稳定性有要求,那还是老老实实交给标准库更省心。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 4:17:17

电动汽车充电动态定价策略与主从博弈优化实践

1. 项目背景与核心挑战 在碳中和目标推动下&#xff0c;电动汽车在居民区的渗透率正以每年超过40%的速度增长。去年夏天&#xff0c;我参与某智能小区充电桩改造项目时&#xff0c;亲眼目睹了这样的场景&#xff1a;傍晚6点下班高峰&#xff0c;30辆电动汽车同时接入充电&#…

作者头像 李华
网站建设 2026/9/11 4:17:00

基于MovieLens的协同过滤算法实现:源代码与文档说明

简介&#xff1a;这是一份基于MovieLens数据集的协同过滤推荐算法实现与说明文档&#xff0c;适合推荐系统初学者、计算机相关专业学生用于课程设计或毕业设计。资源包含完整的Python源码与数据文件&#xff1a;3个py脚本分别负责协同过滤主逻辑、数据预处理和配置参数&#xf…

作者头像 李华
网站建设 2026/9/11 4:16:26

大模型流式输出前端实现:ReadableStream与性能优化实战

1. 从“打字机效应”说起&#xff1a;为什么大模型的回答总像在敲键盘&#xff1f;你有没有试过在 ChatGPT 或国内某款主流大模型网页端提问后&#xff0c;盯着输入框下方那行文字——它不是“唰”一下整段弹出来&#xff0c;而是像老式打字机一样&#xff0c;“嗒…嗒…嗒…”…

作者头像 李华
网站建设 2026/9/11 4:13:25

制造企业从传统报表到大数据分析的转型路径

我经常听到制造型企业的管理者说一句话&#xff1a;“报表不是没有&#xff0c;但总觉得差点意思。”工厂里ERP能导出库存报表&#xff0c;MES能拉出产量报表&#xff0c;财务部每个月还能做出一沓经营分析&#xff0c;但真碰上“设备为什么连续两周频繁停机”“这批产品报废率…

作者头像 李华