直接插入排序和希尔排序这两个东西,放在排序算法家族里属于“看着简单、写起来容易翻车”的典型代表。很多人学排序的时候,冒泡和选择排序过一遍就觉得自己懂了,一到插入排序觉得“不就是往前面挪一挪嘛”,结果真上手写代码,边界条件一塌糊涂;再往深了走到希尔排序,增量序列怎么选、为什么它能比直接插入快、分组插入的循环到底怎么写,一堆问号就冒出来了。我当年第一次手写希尔排序的时候,外层循环的步长更新写错了位置,跑出来的结果看着像排好了,实际上部分元素根本没参与比较,调了半天才定位到问题。这篇内容就是把我对这两个算法的理解、实操中踩过的坑、以及面试和工程里真正用得上的细节,完整地摊开讲一遍。不管你是刚接触数据结构的学生,还是工作几年想把基础重新夯实一遍的开发者,都能从里面找到可以直接抄作业的代码模板和避坑清单。
1. 先搞清楚这两个算法到底在解决什么问题
1.1 从“整理扑克牌”说起:插入排序的直觉来源
直接插入排序的核心思想,用生活场景解释最清楚:你手里有一把扑克牌,左手是已经理好的牌,右手是还没理的牌。每次从右手抽一张,插入到左手合适的位置,左手始终保持有序。这个动作你打牌的时候做了无数遍,根本不用想,但翻译成代码就需要把“找位置”和“腾空间”这两件事拆开。
具体到数组上,我们把数组分成两部分:前面一部分是已排序区间,后面一部分是未排序区间。初始时已排序区间只有一个元素(第一个元素天然有序),然后每次取未排序区间的第一个元素,在已排序区间里从后往前扫描,找到合适位置插入进去,同时把比它大的元素统统往后挪一位。这个过程重复到未排序区间为空,整个数组就有序了。
为什么是从后往前扫描而不是从前往后?因为插入的同时需要腾位置。从后往前扫,遇到比当前元素大的就往后挪一格,挪到遇到比它小的或者到头了,空出来的那个位置就是插入点。一趟扫描同时完成了“找位置”和“腾空间”,不需要额外开数组,空间复杂度是 O(1)。如果从前往后找位置,找到之后还得把后面所有元素整体后移,虽然结果一样,但代码写起来别扭,而且容易把下标搞乱。
这里有个很多人忽略的细节:插入排序是稳定排序。所谓稳定,就是值相等的元素在排序后相对顺序不变。从后往前扫描时,我们只在“严格大于”的时候才往后挪,遇到相等就停下来插入,这样相等元素的相对次序就保住了。这个性质在需要多关键字排序的场景里非常有用,比如先按成绩排、再按姓名排,稳定性保证了第二次排序不会打乱第一次的结果。
1.2 希尔排序不是“另一种插入排序”,而是插入排序的加速器
很多人第一次看到希尔排序,会觉得这是不是个完全不同的新算法。其实它的本质还是插入排序,只不过做了一件很聪明的事:先让数组变得“基本有序”,再用直接插入排序收尾。
直接插入排序有个致命弱点:如果数组是逆序的,比如[5,4,3,2,1],每次插入都要把前面所有元素挪一遍,时间复杂度退化到 O(n²)。但如果数组本来就接近有序,插入排序的效率极高,接近 O(n)。希尔排序就是利用了这个特性——它先按一个较大的间隔(增量)把数组分成若干组,对每组做插入排序。这时候元素可以“大步跳跃”地往正确位置靠拢,而不是一步一步挪。随着增量逐渐缩小,数组越来越有序,最后增量变成 1,做一次标准的直接插入排序,此时数组已经基本有序,这一趟几乎不需要挪动多少元素。
打个比方:直接插入排序像是每次只能挪一格搬家,希尔排序像是先允许你跨楼层搬,最后再在一层内微调。跨楼层搬的时候虽然看起来乱,但大件家具很快就到了大致楼层,最后微调的工作量就小得多。
增量的选择是希尔排序的灵魂。最经典的是希尔增量:n/2, n/4, n/8 ... 直到 1。这个序列简单好记,但最坏情况下时间复杂度仍然是 O(n²)。后来有人提出了 Hibbard 增量(1, 3, 7, 15...,即 2^k - 1)、Sedgewick 增量等更优的序列,能把最坏复杂度降到 O(n^1.5) 甚至更好。实际工程中,希尔增量因为实现简单、常数小,在中等规模数据上表现并不差,所以很多教材和面试题默认用它。
1.3 两者的复杂度与适用场景对照
把关键指标摆在一起看,选择就清晰了:
| 对比维度 | 直接插入排序 | 希尔排序 |
|---|---|---|
| 平均时间复杂度 | O(n²) | 取决于增量序列,希尔增量为 O(n^1.5) 左右 |
| 最坏时间复杂度 | O(n²) | 希尔增量下 O(n²),优化增量可改善 |
| 最好时间复杂度 | O(n)(已有序) | O(n log n) 量级 |
| 空间复杂度 | O(1) | O(1) |
| 稳定性 | 稳定 | 不稳定 |
| 适用数据规模 | 小规模(n < 100 左右) | 中等规模(几千到几万) |
| 实现难度 | 极低 | 低,但循环边界易错 |
直接插入排序适合数据量小、或者数据已经基本有序的场景。很多标准库在数组长度小于某个阈值(比如 16 或 32)时,会退化成插入排序,因为小数组上它的常数因子比快排还小。希尔排序则适合中等规模、对稳定性没有要求、又不想引入递归或额外空间的场景,比如嵌入式环境里对一段采集数据做排序。
注意:希尔排序是不稳定排序。分组插入的时候,相等的元素可能被分到不同组,相对顺序无法保证。如果业务要求稳定性,老老实实用插入排序或者归并排序。
2. 直接插入排序的代码骨架与边界陷阱
2.1 一份可以直接背下来的标准实现
先上代码,用 Python 写,因为它的列表操作直观,换成 C/Java 逻辑完全一样:
def insertion_sort(arr): n = len(arr) for i in range(1, n): key = arr[i] j = i - 1 while j >= 0 and arr[j] > key: arr[j + 1] = arr[j] j -= 1 arr[j + 1] = key return arr这段代码短到可以背下来,但每一行都有讲究。i从 1 开始,因为下标 0 的元素天然属于已排序区间。key保存当前要插入的元素,这一步至关重要——如果不保存,后面挪动元素的时候会把arr[i]覆盖掉,数据就丢了。j从i-1开始往前扫,while条件里j >= 0必须放在前面,因为 Python 的and是短路求值,如果先写arr[j] > key,当j变成 -1 时会访问arr[-1],在 Python 里这是最后一个元素,逻辑直接错乱;在 C/Java 里则直接数组越界崩溃。
循环结束后j+1就是插入位置。为什么是j+1而不是j?因为循环退出时,要么j变成了 -1(说明 key 比所有已排序元素都小,应该插到最前面,即下标 0,正好是j+1),要么arr[j] <= key(说明 key 应该插在j的后面,也是j+1)。这个j+1的统一性很多人第一次写会绕进去,建议拿[3,1,2]手动走一遍。
2.2 为什么 while 循环里必须先判断 j 的边界
上面提到的短路求值问题,值得单独拎出来说。我见过不少人在 C 语言里写成:
while (arr[j] > key && j >= 0) { ... }这在j变成 -1 的瞬间就会读取arr[-1],属于未定义行为,可能读到垃圾值,可能直接段错误。正确写法必须把边界判断放前面:
while (j >= 0 && arr[j] > key) { ... }这个顺序问题在 Python 里更隐蔽,因为 Python 的负索引不会报错,arr[-1]会悄悄返回最后一个元素,导致排序结果看起来“差不多对”但个别元素位置错误,排查起来极其痛苦。我建议养成肌肉记忆:凡是往前扫描的循环,边界判断永远写在条件最左边。
2.3 用哨兵优化减少一次比较?多数场景没必要
有些教材会提到“哨兵”技巧:把数组第 0 位留空,存放当前要插入的元素作为哨兵,这样 while 循环里就不用判断j >= 0了,因为哨兵一定会在边界处停下来。这个技巧在 C 语言里能省一次比较,理论上略微提升性能。
但实际工程中我不推荐这么做。原因有三:第一,它要求数组从下标 1 开始存数据,破坏了 0-based 的通用约定,调用方容易踩坑;第二,省下的那次比较在现代 CPU 的分支预测面前收益微乎其微;第三,代码可读性下降,维护成本上升。除非你在写对性能极度敏感的底层库,否则标准写法完全够用。这个技巧知道有这么回事就行,面试时提一嘴能加分,实际项目里别给自己找麻烦。
2.4 插入排序在真实代码里的“隐藏出场”
插入排序最容易被低估的地方,是它在工业级排序库里的角色。很多语言的标准库排序实现是混合策略:大规模数据用快排或归并,但当子数组长度小于某个阈值时,切换到插入排序。原因是插入排序虽然渐近复杂度差,但它的常数因子极小——没有递归调用开销、没有额外内存分配、循环体里就是简单的比较和赋值,在小数组上跑得比快排还快。
以常见的实现为例,阈值通常设在 16 到 32 之间。这个数字不是拍脑袋定的,而是实测出来的平衡点:数组再大一点,O(n²) 的劣势开始显现;再小一点,快排的递归开销占比过高。你在自己写排序工具时也可以借鉴这个思路,比如对一个可能包含大量小分片的列表做排序,先判断长度,小于 20 就直接插入排序,省去递归的麻烦。
3. 希尔排序的增量逻辑与循环嵌套拆解
3.1 从“分组”到“增量缩小”的完整推演
希尔排序的代码比插入排序多一层循环,但很多人卡在“到底哪层循环控制什么”上。我们一步步推。
最外层是增量gap的递减循环,从n//2开始,每次减半,直到gap = 1。这一层决定了“分组的大小”。当gap = 4时,下标 0 和 4 是一组,1 和 5 是一组,以此类推,每组内部做插入排序。
中间层是i从gap遍历到n-1。为什么从gap开始?因为每组的前gap个元素(下标 0 到 gap-1)分别是各组的第一个元素,天然有序,不需要处理。从gap开始,每个元素都属于某个组,需要往它所在组的前面插入。
最内层就是标准的插入排序逻辑,只不过往前扫描的步长是gap而不是 1。j从i-gap开始,每次减gap,比较和挪动都按gap的间隔进行。
def shell_sort(arr): n = len(arr) gap = n // 2 while gap > 0: for i in range(gap, n): key = arr[i] j = i - gap while j >= 0 and arr[j] > key: arr[j + gap] = arr[j] j -= gap arr[j + gap] = key gap //= 2 return arr注意arr[j + gap] = arr[j]和最后的arr[j + gap] = key,这里的+gap对应插入排序里的+1。因为挪动的步长是gap,空出来的位置自然也是j+gap。这个对应关系想通了,希尔排序的代码就是插入排序把所有的1换成gap。
3.2 增量序列的选择:希尔增量不是唯一答案
前面代码用的是希尔增量(n/2 递减),它简单,但有个已知的弱点:当gap较大时,分组里元素少,排序快;但当gap缩小到某些值时,可能出现“前面几轮白干了”的情况——某些元素在大的 gap 下被挪来挪去,到了小的 gap 又被打回原形。最坏情况下复杂度仍是 O(n²)。
如果想追求更好的性能,可以换增量序列。Hibbard 增量是1, 3, 7, 15, ...,通项是2^k - 1,最坏复杂度 O(n^1.5)。Sedgewick 增量是1, 5, 19, 41, 109...,最坏复杂度约 O(n^1.33)。实现上只需要把gap = n//2; gap //= 2换成预定义的序列倒序取用即可:
def shell_sort_hibbard(arr): n = len(arr) gaps = [] k = 1 while (1 << k) - 1 < n: gaps.append((1 << k) - 1) k += 1 for gap in reversed(gaps): for i in range(gap, n): key = arr[i] j = i - gap while j >= 0 and arr[j] > key: arr[j + gap] = arr[j] j -= gap arr[j + gap] = key return arr实际选哪个?我的经验是:数据量在几千以内,希尔增量和 Hibbard 增量实测差距不明显,用希尔增量代码更短;数据量上万且对性能有要求,可以上 Hibbard 或 Sedgewick。但要注意,增量序列的最后一个值必须是 1,否则最后无法保证完全有序——这是硬性约束,忘了就是逻辑错误。
3.3 一个真实踩过的坑:gap 更新位置写错导致“假排序”
我第一次写希尔排序时,把gap //= 2写在了for i循环的内部,结果每处理一个元素 gap 就减半,循环提前结束,数组只排了个大概。更坑的是,测试用例用的是[3,1,2]这种小数组,跑出来居然是对的,直到换成[5,3,8,1,9,2,7,4,6]才暴露问题——部分元素根本没被比较到。
这个坑的教训是:gap 的更新必须在外层 while 循环的末尾,等所有分组都处理完一轮之后才缩小。判断方法很简单,在gap //= 2那行打个断点或者打印一下,看每轮 gap 的值是不是4, 2, 1这样递减,如果看到4, 2, 1, 0中间夹杂着奇怪的数字,就是位置写错了。
还有一个隐蔽的变体错误:while gap > 0写成while gap >= 0,当 gap 变成 0 时,range(0, n)会正常执行,但内层j = i - 0 = i,arr[j+0] = arr[j]自己赋值给自己,然后j -= 0永远不变,直接死循环。这个错误在 Python 里表现为程序卡死,在 C 里也是死循环,排查时容易误以为是数据量太大。记住:gap 的终止条件是严格大于 0。
4. 手写测试用例:怎么验证你的排序真的对了
4.1 别只用“看起来有序”的数组做测试
很多人测试排序算法,随手写个[3,1,2]跑一下,输出[1,2,3]就认为对了。这种测试的覆盖率极低,上面说的 gap 更新位置错误就是被这种用例放过去的。一套靠谱的测试至少覆盖以下几类:
- 空数组和单元素数组:验证边界不崩溃。
- 已经有序的数组:验证不会做多余操作,同时结果不变。
- 完全逆序的数组:验证最坏情况下的正确性。
- 包含重复元素的数组:验证稳定性(针对插入排序)和正确性。
- 随机大数组:用标准库排序结果做对照。
import random def test_sort(sort_func): cases = [ [], [1], [1, 2, 3, 4, 5], [5, 4, 3, 2, 1], [3, 1, 3, 2, 1, 2], [random.randint(-100, 100) for _ in range(1000)], ] for case in cases: expected = sorted(case) result = sort_func(case[:]) # 传副本,避免原地修改影响后续 assert result == expected, f"failed on {case[:20]}..." print("all passed")注意传副本这个细节。插入排序和希尔排序都是原地排序,会修改传入的数组。如果测试时直接传原数组,第一轮跑完数组已经被改了,后面的对照就失去意义。用case[:]复制一份是最省事的做法。
4.2 用“逆序对数量”观察希尔排序的中间过程
想真正理解希尔排序为什么快,可以加一段统计代码,观察每轮 gap 之后数组的“逆序对”数量变化。逆序对就是前面比后面大的元素对,数量越少说明数组越有序。
def count_inversions(arr): cnt = 0 for i in range(len(arr)): for j in range(i + 1, len(arr)): if arr[i] > arr[j]: cnt += 1 return cnt def shell_sort_verbose(arr): n = len(arr) gap = n // 2 while gap > 0: for i in range(gap, n): key = arr[i] j = i - gap while j >= 0 and arr[j] > key: arr[j + gap] = arr[j] j -= gap arr[j + gap] = key print(f"gap={gap}, inversions={count_inversions(arr)}") gap //= 2 return arr拿一个 16 元素的随机数组跑一下,你会看到逆序对数量随着 gap 缩小快速下降,到 gap=1 之前通常已经降到个位数。这个观察比看十遍文字解释都管用,因为它把“基本有序”这个抽象概念变成了具体数字。我在给团队新人讲希尔排序时,都会让他们跑一遍这个统计,跑完基本就没人再问“为什么希尔排序更快”了。
4.3 性能实测:什么规模下希尔排序开始碾压插入排序
理论归理论,实际跑一下更有说服力。下面这段代码对比两者在不同规模下的耗时:
import time def benchmark(n): data = [random.randint(0, 10000) for _ in range(n)] t1 = time.perf_counter() insertion_sort(data[:]) t2 = time.perf_counter() shell_sort(data[:]) t3 = time.perf_counter() print(f"n={n}: insertion={t2-t1:.4f}s, shell={t3-t2:.4f}s") for size in [100, 500, 1000, 5000, 10000]: benchmark(size)实测下来(具体数字因机器而异,但趋势稳定):n=100 时两者差不多,插入排序甚至可能略快,因为希尔排序多了一层循环开销;n=500 时希尔排序开始领先;n=5000 时希尔排序通常快 5 到 10 倍;n=10000 时差距进一步拉大。这个拐点大致在几百的量级,和前面说的“插入排序适合小规模”是吻合的。你可以用自己的机器跑一遍,把拐点记下来,以后选型时心里有数。
5. 面试与工程中的高频追问
5.1 “插入排序稳定,希尔排序不稳定,为什么?”
这是面试里区分“背过”和“真懂”的经典问题。插入排序稳定,因为从后往前扫描时,遇到相等元素就停下,不会把后面的相等元素挪到前面去。希尔排序不稳定,因为分组插入时,相等的两个元素可能被分到不同的组,各自组内排序后,它们的相对位置可能颠倒。
举个具体例子:数组[2a, 2b, 1],其中 2a 和 2b 值相等但 2a 在前。假设 gap=2,分组为[2a, 1]和[2b]。第一组排序后变成[1, 2a],数组变成[1, 2b, 2a]。你看,2a 和 2b 的相对顺序反了。这就是不稳定的根源——分组打破了原本的相邻关系。
回答这个问题时,能举出具体反例比干巴巴说“因为分组”有说服力得多。我建议把这个例子记牢,面试时直接画出来。
5.2 为什么很多库函数不用希尔排序?
标准库的排序实现(比如常见的混合排序)通常选择快排+插入排序,或者归并+插入排序的组合,很少用希尔排序。原因有几个:第一,希尔排序的最坏复杂度依赖增量序列,不如快排的平均 O(n log n) 有保证;第二,希尔排序的缓存局部性不如快排——快排的分区操作访问的是连续内存,希尔排序在大 gap 阶段跳跃访问,缓存命中率低;第三,现代库更看重稳定性和可预测性,希尔排序不稳定且性能对数据分布敏感。
但这不代表希尔排序没用。在嵌入式、单片机、或者不允许递归和额外内存的场景里,希尔排序原地、无递归、代码短的优势就体现出来了。选型从来不是选“最好的”,而是选“最合适的”。
5.3 增量序列能不能随便选?最后一个必须是 1 的原因
前面提过最后增量必须是 1,这里展开说清楚。希尔排序的正确性依赖于:当 gap=1 时,做一次完整的直接插入排序,此时数组必须被完全排好。如果增量序列的最后一个值不是 1,比如用了[4, 2]就结束了,那么 gap=2 的排序只能保证相隔 2 的元素有序,相邻元素之间可能还是乱的,整个数组不保证有序。
数学上的解释是:gap=1 的插入排序是唯一能保证“任意相邻元素都有序”的步骤,而“任意相邻元素有序”等价于“整个数组有序”。所以增量序列可以千变万化,但收尾必须是 1。这个约束在自定义增量序列时千万不能忘,我见过有人用质数序列[7,5,3]做增量,忘了补 1,结果排序结果部分正确,排查了半天。
5.4 实际项目里什么时候会真的手写这两个排序
说实话,业务代码里直接手写排序的场景不多,标准库的sort基本够用。但有两种情况值得自己写:一是数据规模极小且调用频繁,比如每次只排 5 到 10 个元素的配置项,标准库的通用排序有函数调用和类型检查开销,手写插入排序反而更快;二是排序逻辑需要嵌入到更大的算法里,比如某些图算法需要对邻接表按权重排序,数据量小且结构特殊,内联一个插入排序比调库更自然。
另外,面试和算法题里这两个排序的出现频率很高,尤其是“手写希尔排序”经常作为考察循环功底和边界处理的题目。把本文的代码模板和测试用例吃透,这类题基本不会失分。
6. 几个容易忽略的实操细节
6.1 用 C 语言实现时的类型与越界问题
Python 写起来舒服,但很多场景要求 C 实现。C 版本有两个额外注意点:一是key的类型要和数组元素类型一致,如果数组是long long而key声明成int,大数会被截断;二是j的类型要用有符号整数,如果用size_t(无符号),j -= gap在j < gap时会回绕成一个巨大的正数,j >= 0永远成立,直接死循环或者越界。
void insertion_sort(int *arr, int n) { for (int i = 1; i < n; i++) { int key = arr[i]; int j = i - 1; while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; } }j用int而不是size_t,这是 C 语言里处理“可能变成负数”的下标时的通用做法。如果数组长度可能超过int范围(基本不可能,但理论上),才需要考虑long或ptrdiff_t。
6.2 对链表做插入排序的思路差异
数组的插入排序靠“挪动元素”,链表的插入排序靠“改指针”。链表版本反而更自然:维护一个已排序的子链表,每次从未排序部分摘下一个节点,遍历已排序部分找到插入位置,改两个指针就完成了,不需要挪动任何数据。这也是链表在插入场景下比数组有优势的地方。
不过链表插入排序有个坑:找插入位置时,需要记录“前驱节点”,因为单链表只能单向遍历,找到位置后要修改前驱的next。如果插入到头部,前驱为空,需要单独处理。这个边界和数组版本里j+1的统一处理不同,链表版本通常要引入一个哑节点(dummy head)来消除头部特殊判断,代码会干净很多。
6.3 希尔排序在近乎有序数据上的表现反而可能不如插入排序
这个结论有点反直觉,但实测确实如此。如果数据已经基本有序,直接插入排序几乎是 O(n) 一趟扫完;而希尔排序不管数据什么样,都要老老实实跑完所有 gap 轮次,大 gap 阶段的跳跃访问反而做了无用功。所以如果你的业务场景里数据大概率已经有序(比如增量更新的日志、追加写入的时间序列),直接用插入排序,别上希尔排序。
判断方法很简单:跑一次插入排序,统计挪动次数,如果挪动次数远小于 n²/4,说明数据接近有序,插入排序就是最优解。这个“先探测再选算法”的思路,在性能敏感的场景里很实用。
6.4 调试排序代码的三个高效手段
最后分享几个我常用的调试技巧。第一,打印每轮循环后的数组状态,尤其是希尔排序的每个 gap 之后,肉眼观察元素是否在往正确方向靠拢;第二,用极小的数组手动模拟,比如 4 个元素,拿纸笔跟着代码走一遍,比盯着屏幕看有效得多;第三,构造针对性用例,怀疑边界问题时用空数组和单元素,怀疑稳定性时用带标记的重复元素(比如(2,'a')和(2,'b')),怀疑 gap 逻辑时用长度是 2 的幂的数组,这样 gap 序列规整,容易发现异常。
这几个手段组合起来,基本没有调不出来的排序 bug。排序算法的好处是结果可验证——和标准库对比一下就知道对不对,所以调试的核心不是“找正确答案”,而是“定位哪一步偏离了正确轨道”,打印中间状态就是最直接的手段。