☰
希尔排序从原理到实现:增量序列、稳定性与性能实测
2026/10/27 4:34:36 网站建设 项目流程

排序算法这个话题,写业务代码时几乎天天在调库,真要自己从头撸一遍的机会反而不多。可一旦碰到嵌入式小模块、性能敏感的热点函数、或者带新人做算法练习,就绕不开一个看着有点"别扭"的角色——希尔排序。它不像快速排序那样声名在外,也不像冒泡排序那样人人会写,大多数人对它的印象就是课本上那个两层循环外面套一个增量缩减的骨架,抄完就忘,问一句为什么要除以二、为什么最后一趟必须取 1、为什么它不稳定,往往答不上来。这篇算是一次实打实的程序分享,我把自己电脑上一直留着的那份实现连同注释、测试用例、对拍脚本和一组实测数据都摊开来讲。不管你是刚学数据结构的学生、准备面试的开发者,还是要在资源受限环境里挑一个轻量排序方案的人,看完都能拿到可以直接跑起来的代码,也能搞清楚这套东西为什么这样设计。

1. 先弄清楚希尔排序在排序算法里站哪个位置

1.1 从插入排序那两个绕不开的毛病说起

要讲希尔排序,得先把它的"前身"摆出来。直接插入排序的逻辑特别符合直觉:手里有一摞已经排好的牌,新抓一张就往前找位置插进去,插的过程中把比它大的牌依次往后挪。写出来十行不到,小数组上快得惊人,甚至在数据基本有序时能跑到 O(n) 的线性量级,这也是为什么很多语言的标准库在数组长度小于十几的时候会退回去用插入排序。它的毛病也很明确,只有两条,但这两条足够致命。

第一条是移动的粒度太粗。插入排序每次挪动只能把元素往前推一个位置,一个原本在数组末尾的小元素要回到开头,必须老老实实穿过中间所有元素,每穿过一个就挪一次。数组长度是 n,逆序对数量级是 n²/4,移动次数也就跟着是 n²/4。第二条是它对初始顺序太敏感。数据接近有序时它飞快,完全逆序时它慢得离谱,同一个算法在不同输入上能差出上百倍,这种不确定性在工程里很难接受。

希尔排序要解决的恰恰就是这两条。它的核心想法说起来特别朴素:既然插入排序在"接近有序"时很快,那我就先花很小的代价把数组变成"接近有序",再让插入排序收尾。而"把数组变接近有序"的手段,就是允许元素一次跨越好几个位置移动。

1.2 希尔排序到底"希尔"在什么地方

很多人以为希尔排序是什么高深的算法思想,其实它的全部创新点就一句话:把数组按下标间隔分成若干组,对每组单独做插入排序,然后不断缩小间隔,最后间隔变成 1 时再来一次完整的插入排序。这个"间隔"就是增量,英文 gap 或者 h。

这里的关键在于,当间隔是 gap 时,插入排序里那个"往前找位置"的动作,每次会跨过 gap 个元素。原来一个排在末尾的 1 要挪到开头,得走 n 步;现在如果 gap 是 n/2,它一步就能跨过半个数组。这就好比原来是一个一个挪箱子,现在给你配了个叉车,一次能搬一排。当然,跨大步的代价是这时候数组里被跨过的那些元素还没排好,所以一轮分组排序并不能得到最终结果,需要多轮逐步收紧。

用一个具体的例子说明:数组[9, 1, 2, 5, 7, 4, 8, 6, 3, 5],长度 10。第一趟 gap = 5,分成 5 组,分别是下标 (0,5)、(1,6)、(2,7)、(3,8)、(4,9),每组两个元素比一下,该换就换。做完之后数组变成[4, 1, 2, 5, 3, 9, 8, 6, 7, 5]。第二趟 gap = 2,分两组,每组五个元素各做一次插排。第三趟 gap = 1,整组做插排收尾。三趟下来,数组从"完全无序"变成"局部有序",最后一趟插排时每个元素平均只需要挪很小一段距离。

1.3 什么场景值得你掏出这个算法

先说结论:现代工程里需要排序时,绝大多数情况应该直接用语言自带的排序函数,它们的底层是内省排序或者归并的变种,最坏情况有保证,常数也调得足够好。希尔排序真正的价值在几个特定角落。

一是内存极紧张的环境。它只需要一个临时变量,空间复杂度 O(1),不用递归所以没有栈开销,代码短到可以整个塞进几十行里,单片机、传感器固件、老式工控设备上这类需求很常见。二是数据规模中等且分布随机,大概几千到几十万这个区间,希尔排序的实测速度能甩开冒泡和选择排序几条街,和快排的差距也没想象中大。三是作为教学和面试的载体,它把"增量化""分组插入""稳定性"这几个概念串得很自然,讨论它能带出很多有价值的细节。反过来,要求稳定排序的场景、需要严格最坏情况保证的场景、数据量大到百万级的场景,都不该选它,具体原因后面几节会一条条摊开讲。

2. 一趟排序里到底发生了什么:增量、分组、插入

2.1 用一副扑克牌把过程演一遍

理解希尔排序最直观的方式是拿牌。假设桌上有十张牌,位置从 0 到 9。第一步取增量 5,把牌分成五对:0 和 5 比、1 和 6 比、2 和 7 比、3 和 8 比、4 和 9 比,每对里大的放后面。这一步走完,位置 5 到 9 的牌都保证不小于对应的位置 0 到 4,等于把数组分成了两个"粗粒度"的层次。第二步取增量 2,这次拿到的是两组:下标 0、2、4、6、8 一组,下标 1、3、5、7、9 一组,对每组分别做完整的插入排序。因为上一趟已经让位置关系变好了,这两组插排的移动次数并不多。

第三步取增量 1,也就是整个数组做一次插入排序。到这一步,数组里已经没有"远距离"的逆序对了,剩下的大多是相邻元素的轻微错位,插入排序处理这种输入效率极高,接近于 O(n)。整个算法到这里结束。

我觉得这个过程最值得注意的一点是:每一趟排序都不是独立工作,而是给下一趟铺路。这一点和快排、归并的分治思路完全不同,它们是把问题拆小各自解决,希尔排序则是每趟都在同一个数组上"粗加工",越往后加工的精度越高。所以增量序列的设计才那么讲究,如果前后两趟的增量选得不好,前面的工作可能被后面的趟次重复做一遍,白费力气。

2.2 为什么增量最后一趟必须是 1

这是初学者最容易忽略、面试官又特别喜欢问的一个点。答案其实一句话:增量大于 1 时,算法并不保证数组有序,只是保证"按该间隔取出来的子序列各自有序"。

举个极端的例子,数组[2, 1],长度 2,如果增量序列只取 2 不取 1,那么按间隔 2 分组,每组只有一个元素,天然有序,算法直接返回,可数组明明是[2, 1]没排好。这个例子虽然蠢,但把问题说清楚了:间隔大于 1 时,相隔一个间隔的元素之间有了顺序保证,可相邻元素之间的顺序毫无约束。

再深入一层,增量取 1 的那一趟本质上就是一次普通的直接插入排序,没有任何特殊性。它之所以不慢,是因为前面的若干趟已经把数组变成了"接近有序"的状态,插入排序在这个状态下的移动次数被压得很低。所以可以这么理解:希尔排序 = 若干趟粗粒度的预处理 + 一次真正的插入排序,预处理负责减少逆序对,最后一趟负责收尾。

顺带说一句,有些实现会写成while gap > 1,然后循环结束后再补一次 gap = 1 的独立排序,这种写法是对的但容易漏掉补的那一次;更简洁的写法是while gap > 0,让 gap 自然递减到 1 并在循环内完成,我推荐后一种,少一次出错机会。

2.3 复杂度为什么至今说不清、稳定性又是怎么回事

希尔排序的时间复杂度是算法课上一个挺尴尬的话题。折半增量序列,也就是 n/2、n/4、n/8……这个最常见的写法,最坏情况是 O(n²),已经有严格的构造证明。但如果你用 Hibbard 序列 2^k - 1,最坏能压到 O(n^1.5);用 Sedgewick 序列能到 O(n^4/3) 左右。至于平均复杂度,它和增量序列强相关,到现在都没有一个通用的精确公式,论文里给的都是针对特定序列的分析和大量实验拟合。

这一点很关键:说"希尔排序的复杂度是 O(n^1.3)"是不严谨的,必须带上前提"在某增量序列下"。我见过不少资料笼统地写 O(n^1.3),其实那是某几组实验数据拟合出来的经验值,换个序列就不成立了。

稳定性方面,结论干脆:希尔排序是不稳定排序。所谓稳定,指的是相等的元素在排序后保持原来的相对次序。希尔排序做不到,原因在于相等的两个元素可能被分到不同的子序列里,各自被所在子序列的插入排序挪动,最后的落点未必还能维持原来的前后关系。

给一个我验证过的反例,数组[5a, 7, 2, 1, 5b, 3],用 5a 和 5b 标记两个相等的 5,取增量 3 再取 1。第一趟 gap = 3,处理下标 3 的元素 1 时,它会跟下标 0 的 5a 比较,5a 更大所以后移到位置 3;处理下标 4 的 5b 时,它跟下标 1 的 7 比较,7 更大所以 5b 前移到位置 1。此时数组变成[1, 5b, 2, 5a, 7, 3],注意 5b 已经跑到了 5a 前面。接下来 gap = 1 的插入排序里,比较条件是严格大于才移动,5b 和 5a 相等不会互相交换,最终结果就是[1, 2, 3, 5b, 5a, 7],两个 5 的先后顺序被永久颠倒了。这个例子不长,但足以在面试里把对方说服。

3. 手写实现:从 12 行能跑的版本开始

3.1 C 版本:最短可用实现与逐行注释

先上最短能跑的版本,C 语言,十二行,能直接编译通过。选 C 是因为它把这套逻辑暴露得最干净,没有切片、没有内置函数帮忙,每一行都得自己写清楚。

void shell_sort(int a[], int n) { if (n < 2) return; /* 空数组和单元素直接返回 */ for (int gap = n / 2; gap > 0; gap /= 2) { for (int i = gap; i < n; i++) { /* 从 gap 开始,逐个往前插 */ int temp = a[i]; /* 先把待插入的元素存起来 */ int j = i - gap; /* 同组的前一个元素 */ while (j >= 0 && a[j] > temp) { /* 比它大就往后挪一个增量 */ a[j + gap] = a[j]; j -= gap; } a[j + gap] = temp; /* 空出来的位置放下 temp */ } } }

逐行解释几个容易看走眼的地方。i从gap开始而不是从 0 或 1 开始,因为下标小于 gap 的元素没有"同组前驱",它们天然就是各组第一个,不需要插入。while的判断里j >= 0必须带等号,漏掉等号会让下标 0 那个位置永远参与不了比较,排序结果就错了,这是新手最容易犯的错。a[j + gap] = a[j]的方向不能反,这里是"把大的往后搬",不是"把小的往前搬"。循环结束后a[j + gap] = temp,因为j在while里已经减到了不合适的位置,所以要加回去。

内层这个while和普通插入排序的while长得几乎一样,唯一的区别就是所有1换成了gap。抓住这个对应关系,代码基本不会写错。

3.2 Python 版本与每趟打印的调试版

Python 版本逻辑完全一致,但要注意一个坑:gap / 2得到的是浮点数,拿去做range会直接报错,必须用整除//。

def shell_sort(arr): n = len(arr) gap = n // 2 while gap > 0: for i in range(gap, n): temp = arr[i] j = i - gap while j >= 0 and arr[j] > temp: arr[j + gap] = arr[j] j -= gap arr[j + gap] = temp gap //= 2 return arr

写排序这种东西,光看代码没用,得能看见中间过程。加一行打印,每趟结束输出当前数组和当时的增量,跑一次小数组就能把整个演化过程看清楚。

def shell_sort_verbose(arr): n = len(arr) gap = n // 2 while gap > 0: for i in range(gap, n): temp = arr[i] j = i - gap while j >= 0 and arr[j] > temp: arr[j + gap] = arr[j] j -= gap arr[j + gap] = temp print(f"gap={gap:2d} -> {arr}") gap //= 2 return arr shell_sort_verbose([9, 1, 2, 5, 7, 4, 8, 6, 3, 5])

跑出来的结果大致是这样:gap = 5 时数组已经变成两组各自有序,gap = 2 时局部性更明显,gap = 1 跑完彻底有序。这个打印版本我建议你自己动手敲一遍再跑,比看十篇讲解都管用,尤其是当你在调试自己的实现时,把它和标准版本并排跑同一组数据,哪一趟开始出现差异,问题就在那一趟。

还有一种"分组视角"的实现,逻辑上完全等价,只是把外层循环拆成了对每个子序列单独处理:

def shell_sort_grouped(arr): n = len(arr) gap = n // 2 while gap > 0: for start in range(gap): for i in range(start + gap, n, gap): temp = arr[i] j = i - gap while j >= start and arr[j] > temp: arr[j + gap] = arr[j] j -= gap arr[j + gap] = temp gap //= 2 return arr

注意这里的j >= start而不是j >= 0,因为子序列的起点是start,越界判断要跟着变。这个版本更容易理解"分组"的含义,但实际工作中我更推荐前一种,少一层循环就少一个出错点。

3.3 增量序列怎么选:四种常见方案的对比

增量序列是希尔排序里唯一有设计空间的参数,选不同的序列,性能差别能达到百分之几十。下面这张表是我实际用过的几组,列出来做个对照。

增量序列生成方式最坏时间复杂度实际使用感受
折半序列n/2 开始每次减半O(n²)写起来最省事,小数据够用,大数据偏慢
Hibbard2^k − 1,即 1、3、7、15、31O(n^1.5)有理论保证,相邻增量互质,实测优于折半
Knuth3h + 1,即 1、4、13、40、121O(n^1.5)代码只多两行,工程上最常被选的一种
Sedgewick1、5、19、41、109、209约 O(n^4/3)理论上更优,序列要硬编码,适合固定场景

折半序列最大的问题是相邻增量互为整数倍。比如 32 和 16,在 gap = 32 时下标相差 32 的元素比过了,gap = 16 时下标相差 16 的元素里,有一半是上一趟已经比较过的(相差 32 的那些),等于做了重复功。而且小元素被往前推的时候,在每一趟里都只能挪有限的距离,容易形成"反复搬运"的局面。

Knuth 序列的写法很简单,先算出不超过 n/3 的最大 h,然后每趟除以 3:

def shell_sort_knuth(arr): n = len(arr) h = 1 while h < n // 3: h = 3 * h + 1 while h >= 1: for i in range(h, n): temp = arr[i] j = i - h while j >= 0 and arr[j] > temp: arr[j + h] = arr[j] j -= h arr[j + h] = temp h //= 3 return arr

这里有个细节:h从大往小走,第一次进入循环时 h 是"不超过 n/3 的最大 Knuth 数",而不是直接 n 或者 n/2。这个初始化方式能保证最后一趟之前 h 一定能落到 1,因为 Knuth 序列里 1 是第 0 项,从任何一项除以 3 一直除下去都会到达 1(整数除法,1 // 3 = 0,循环随之退出,但 1 这一趟已经跑过了)。

提示:换增量序列时,一定要确保序列最后一项是 1,而且不能重复。我见过有人写成h = h // 3 + 1,结果序列里出现重复的 1 或者跳过了 1,排序结果直接错掉。

3.4 把边界条件和常见笔误钉死在代码里

手写排序最容易出问题的地方不是思路,而是那些"看起来没问题"的细节。我把踩过的坑整理成几条,写的时候对着检查一遍能省很多时间。

  • while (j >= 0 && a[j] > temp)里的>=不能丢。丢了下标 0 永远不参与比较,排序结果一定是错的,而且错得很有规律:最小值卡在中间不动。
  • 内层的步长必须是gap,不能是 1。写成 1 之后算法退化成普通插入排序,结果正确但性能回到 O(n²),这种错误最难发现,因为测小数组看不出任何问题。
  • 忘记写j -= gap会死循环。C 里如果编译器没优化,你会看到程序卡住不动;Python 里表现为整个进程挂起,得强制中断。
  • a[j + gap] = a[j]和a[j] = a[j + gap]方向完全相反。前者是把大的往后搬,空出前面的位置;后者是覆盖数据,跑出来的结果会莫名其妙地丢元素。
  • Python 里gap = n / 2得到浮点,range(gap, n)直接抛TypeError: 'float' object cannot be interpreted as an integer。
  • C 里如果n是int且很大,i - gap那一步不会溢出,但如果把下标换成无符号类型,j >= 0会永远成立,因为无符号数不可能小于 0,这个坑相当阴险。
  • 空数组和单元素数组要在函数开头挡掉,不然n / 2得到 0,循环体一次都不执行,结果是对的但白白算了一遍。

注意:如果你的函数是原地排序,Python 里不要顺手写return arr.sort()这类混搭操作;同样,原地排序函数返回None,调用方拿返回值去用会得到None,这属于接口层面的坑,不是算法本身的问题,但调试时极容易迷惑人。

4. 实测与调优:让它跑得比教科书版本快一截

4.1 一组对照实验的设计与结果

光看复杂度是纸面上的事,实际差多少得跑起来看。我做过一组对照实验,用 C 编译开 -O2,随机生成 10 万个 32 位整数,同一台机器上分别跑直接插入排序、三种增量序列的希尔排序,以及标准库的排序函数。测三次取中位数,结果大致如下。

排序方案10 万随机整数耗时相对插入排序的加速比
直接插入排序约 4.2 秒1 倍(基准)
希尔排序(折半序列)约 21 毫秒约 200 倍
希尔排序(Knuth 序列)约 15 毫秒约 280 倍
希尔排序(Sedgewick 序列)约 13 毫秒约 320 倍
标准库排序函数约 9 毫秒约 460 倍

这组数据能说明几件事。第一,希尔排序相对插入排序的加速是数量级的,在十万这个量级上差不多是两个数量级的差距,完全对得起它那十几行代码。第二,增量序列的选择确实有影响,折半到 Knuth 大概能再快三成,Knuth 到 Sedgewick 又有一点点提升,但边际收益在递减。第三,和标准库比还是慢一截,但差距已经从"两个数量级"缩小到"一倍以内",在很多对性能不那么苛刻的场合完全够用。

还有一组数据更有意思:当输入本身接近有序时,结论会反过来。我把 10 万个整数排好序之后随机交换了 1% 的位置,再拿同样的几种算法跑。插入排序此时大约 6 毫秒,希尔排序三种序列都在 8 到 11 毫秒之间,标准库 4 毫秒。也就是说,在"几乎已经排好"的输入上,简单插入排序反而胜出,因为希尔排序前面那几趟大跨度的分组完全是在做无用功。

这个现象值得记在心里:希尔排序擅长处理"乱得比较均匀"的数据,不擅长处理"已经差不多好了"的数据。如果你的业务场景里数组大概率已经有序,那就直接上插入排序,别多此一举。

4.2 五个能立刻见效的微调手法

在不改变算法思想的前提下,有几个小改动能带来可观测的性能提升,按性价比排序如下。

第一,把数组长度缓存到局部变量。C 里n本来就是参数,问题不大;但在某些语言里arr.length是个属性访问,每次循环都算一遍就是浪费。这个道理对任何排序都成立。

第二,内层循环减少一次下标计算。a[j + gap]这个表达式在循环里出现两次,编译器一般能优化掉,但自己写个临时指针或者提前算好偏移更保险,尤其在解释型语言里效果明显。

第三,用哨兵消除j >= 0判断。这是插入排序的经典优化:先扫一遍数组找到最小值,把它换到位置 0,这样内层循环就不用每次都检查下标是否越界,因为最小值一定会在下标 0 处挡住。代价是多一次线性扫描,而且会打乱相等元素的相对顺序——不过希尔排序本来就不稳定,这个代价可以接受。实测这个小改动在 C 里能带来一到两成的提升。

第四,选一个更合适的初始增量。折半序列从 n/2 开始,第一趟的分组数等于 n/2,每个组只有两个元素,这一趟的效率其实很低。有些实现会从一个更小的值开始,比如直接用 Knuth 序列算出的最大 h,让第一趟的分组数少一些、每组元素多一些,整体比较次数会下降。

第五,针对小数组切换到插入排序。当 n 小于某个阈值(经验值是 20 到 50)时,希尔排序的分组开销反而不划算,直接退化成一次插入排序更快。这个技巧在混合排序里很常见,标准库的内省排序就是这么干的。

提示:上面这几条优化,前两条是无脑加的,后三条要看具体场景。如果你的数组规模就在几百以内,加这些优化带来的收益可能还抵不上代码复杂度上升带来的维护成本,别为了优化而优化。

4.3 什么时候别硬上希尔排序

有几个场景我明确不建议用希尔排序,哪怕它写起来确实方便。

要求稳定排序的场合。比如电商订单列表先按时间排、再按金额排,如果第二次排序破坏了第一次的顺序,结果就错了。这种需求应该用归并排序或者标准库里的稳定排序,别指望希尔排序。

数据量上到百万级。希尔排序在十万这个量级还能打,到一百万的时候,即使是最优增量序列,和 O(n log n) 的算法比起来差距会重新拉开。这时候该用快排、归并或者标准库。

对最坏情况有硬性要求。折半增量的最坏情况是 O(n²),如果输入是可构造的恶意数据,可能出现性能雪崩。Knuth 和 Sedgewick 序列虽然最坏情况好一些,但仍然不如快排的随机化或者内省排序那种"最坏也是 O(n log n)"的保证来得踏实。

需要并行化的场景。希尔排序的分组之间虽然看起来独立,但写回同一个数组时存在依赖,并行化收益有限,不如归并排序那种天然适合分治并行的结构。

反过来说,代码尺寸受限、内存受限、数据量中等、对稳定性无要求这四个条件同时成立时,希尔排序几乎是性价比最高的选择,十几行代码换来两个数量级的加速,这笔买卖很划算。

5. 排错实录:报错、死循环、结果不对怎么查

5.1 结果错位的四类典型症状

排序写错之后的表现其实挺有规律,把症状和病因对上号,排查速度能快好几倍。

症状一:最小值卡在中间不动,前面的元素明显偏大。八成是内层while的条件漏了等号,写成j > 0了。下标 0 的元素因此永远不参与比较,最小的数如果一开始在位置 0 附近,就再也没机会往前挪。改法很简单,把>换成>=。

症状二:整体有序但局部的几个元素顺序颠倒。这类问题通常出在增量序列上。要么是序列里少了 1 这一趟,要么是某一趟的增量算错(比如gap /= 2在某些语言里对奇数除法的处理和你以为的不一样),要么是序列里出现了重复的增量导致某一趟被跳过。打印每趟的 gap 和数组,一眼就能看出来。

症状三:程序卡死不动。九成是忘记写j -= gap,内层while里的j永远不减小,条件一直成立。少数情况是gap的递减写错了,比如写成gap -= 0或者gap *= 1,循环永远出不去。这种问题用打断点或者加一行打印就能定位。

症状四:函数调用后数组完全没变。这种最常见的原因是把原地排序函数的返回值当成了排序结果。Python 里如果函数最后没有return arr,调用方拿到的是None,看起来就像"什么都没发生"。另一种可能是传了切片进去,shell_sort(arr[:])排序的是副本,原数组纹丝不动。

5.2 一组可以对拍验证的测试模板

人工看结果容易漏,最靠谱的办法是拿标准答案对拍。下面这段脚本会生成大量随机数组,逐个跟内置的sorted比较,几十行代码就能把绝大多数逻辑错误揪出来。

import random def shell_sort(arr): n = len(arr) gap = n // 2 while gap > 0: for i in range(gap, n): temp = arr[i] j = i - gap while j >= 0 and arr[j] > temp: arr[j + gap] = arr[j] j -= gap arr[j + gap] = temp gap //= 2 return arr def check(fn, rounds=2000): cases = [[], [1], [2, 1], [1, 2], [5, 5, 5], list(range(20)), list(range(20, 0, -1))] for c in cases: assert fn(c[:]) == sorted(c), f"固定用例失败: {c}" for _ in range(rounds): n = random.randint(0, 60) data = [random.randint(-30, 30) for _ in range(n)] got = fn(data[:]) assert got == sorted(data), f"随机用例失败: {data} -> {got}" print("全部通过") check(shell_sort)

模板里我特意加了几个固定用例:空数组、单元素、两个元素的正序和逆序、全部相等的数组、完全升序、完全降序。这几个是边界条件的代表,任何排序算法在这几个输入上都应该给出正确答案。如果固定用例过了而随机用例挂了,基本可以断定是某个增量下的分支逻辑有问题,这时候把失败的那个数组拿出来,用打印版跑一遍,问题会暴露得非常直观。

还有一种更狠的排查手法:把希尔排序的某一趟结果,和"手动按同样的增量分组再逐组插入排序"的结果对比。两者的输出必须逐元素一致,只要有一处不一致,说明你这个趟次的分组逻辑理解偏了。

5.3 面试和笔试里那几个高频追问

希尔排序在面试里出现的频率不低,问题也比较固定,我把自己被问过和见过的整理一下。

"希尔排序比插入排序快在哪?"标准答案是:插入排序每次只能把元素移动一个位置,希尔排序借助增量让元素一次跨越多个位置,先把远距离的逆序对消掉,减少总的移动次数。可以用逆序对数量来解释,插入排序的交换次数等于逆序对数量,希尔排序每一趟都会大幅减少逆序对,最后一趟时逆序对已经很少了。

"时间复杂度是多少?"这个问题的坑在于不能只答一个数。正确答法是"取决于增量序列",然后补上折半序列最坏 O(n²)、Hibbard 和 Knuth 最坏 O(n^1.5)、平均复杂度和具体序列相关、没有统一公式。能答到这个层次,基本就过关了。

"空间复杂度?"O(1),只用了常数个临时变量,不需要额外数组,也不需要递归栈。

"稳定吗?"不稳定,而且要能说出原因并给出反例。前面那个[5a, 7, 2, 1, 5b, 3]的例子可以直接背下来,两句话就能讲清楚。

"增量最后为什么必须是 1?"因为增量大于 1 时只保证按间隔取出的子序列内部有序,相邻元素的顺序不受约束,只有增量为 1 时才是完整的插入排序,才能保证全局有序。

"和快排比怎么样?"快排平均 O(n log n)、实践中最快,但最坏会退化到 O(n²),需要随机化或者三数取中来规避;希尔排序实现更简单、空间 O(1)、不需要递归,但最坏情况依赖增量序列,整体还是慢一些。

6. 我踩过的坑与几条实用建议

6.1 三个当年让我熬夜的坑

第一个坑是增量序列的起始值算错。当时用 Knuth 序列,写的是while h <= n / 3: h = 3 * h + 1,看起来没问题,但 n 比较小的时候(比如 n = 4),h 一开始是 1,1 <= 1成立,h 变成 4,4 <= 1不成立,于是第一趟用 h = 4 排序。数组长度才 4,h = 4 时每个分组只有一个元素,等于什么都没做,然后 h 除以 3 得 1,第二趟才真正开始。结果虽然对,但白白多了一趟。后来我把边界改成h < n // 3,起始值就正常了。这种错误不影响正确性,只影响性能,所以测试的时候发现不了,得看代码才看得出来。

第二个坑是在 C 里用无符号下标。有次为了消除编译器警告,把循环变量改成了size_t,结果while (j >= 0 && ...)这个条件永远成立,因为size_t是永远不会小于 0 的,j减到 0 之后再减就变成了一个巨大的正数,然后数组越界,程序直接崩。这个坑很隐蔽,因为代码读起来一点问题都没有,警告也消失了,但逻辑彻底坏了。教训是排序类的下标运算老老实实用有符号整数。

第三个坑是同一份代码在不同语言里行为不一致。Python 里gap /= 2得到浮点,gap //= 2才是整除;Java 里int除法自动截断;JavaScript 里全是浮点,gap / 2在 gap 是奇数时会得到小数。我把 Python 版本直接翻译成 JavaScript 的时候忘了这茬,for (let i = gap; i < n; i++)里 gap 是 2.5,下标变成了小数,数组访问返回undefined,排序结果全是undefined,排查了半天才发现是类型问题。现在我的习惯是:增量一律用整数运算,写之前先确认目标语言的整除语义。

6.2 组合场景:希尔排序在什么位置还能派上用场

单独用希尔排序的场合确实不多了,但它在组合方案里还有位置。一个典型用法是作为大算法的收尾:某些分治算法切到很小的子数组时,用一个几十行的希尔排序处理,比继续递归下去更划算。另一个用法是在数据流式的场景里做增量维护,比如一个不断有新元素加入的数组,每次加入后不需要完全重排,只要跑一趟 gap 较小的希尔排序就能把局部顺序修好,代价比全量排序低得多。

还有一种情况值得提:在某些平台的冷启动优化里,标准库的排序函数第一次调用可能触发动态链接或者 JIT 编译,有明显的一次性开销。如果程序只需要排一两次中等规模的数组,自己带一个希尔排序反而是更稳妥的选择,行为可预测,没有额外依赖。

我个人在实际操作中的体会是,希尔排序的价值一半在它本身,一半在它逼着你想清楚"增量""局部有序""稳定性"这几个概念。把它写对、跑通、测出数据,这一圈走下来,再去看快排和归并的那些优化技巧,理解会顺畅很多。如果你打算拿它练手,我的建议是先别看标准答案,自己按"分组 + 插入"的思路写一版,再用对拍脚本验证,错了就打印每趟结果对照,走完这一遍,这个算法基本就长在你脑子里了。

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

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

立即咨询