1. 为什么插入排序值得单独拿出来讲
很多初学者学排序算法,一上来就先啃快排、堆排、归并,因为它们的平均时间复杂度是O(n log n),看起来“高级”。但等到真正做题、写业务代码、处理近似有序数据的时候,才发现插入排序这类基础算法才是出场率最高的。我自己的体会是:插入排序的思想是很多进阶算法(希尔排序、TimSort、插入排序优化的各种变体)的地基,不把它吃透,后面看啥都费劲。
先明确一个定义:插入排序的核心逻辑是“把待排序的元素,按照大小插入到前面已经有序的序列中”。就像玩扑克牌时,你抓牌后一张一张插到手里已经排好的牌堆里,所以英文叫Insertion Sort。它不需要额外的辅助数组,属于原地排序,而且实现极其简单。
本文要讲的不是某一种插入排序,而是按“插入策略”发展出来的三种典型形态:
- 直接插入排序:从后往前逐个比较,找到位置就插。
- 折半插入排序:用二分查找减少比较次数。
- 希尔排序:先分组做插入排序,再不断缩小组距,最后做一次全量插入排序。
这三种方法不是孤立的,它们是一条优化链上的三个阶段。直接插入排序最直观,折半插入排序优化了“查找位置”这一步,希尔排序则在整个序列的大结构上做文章,把“减少移动量”和“减少比较量”同步推进。
这篇文章适合这些读者:刚学数据结构、正在准备算法笔试的学生;需要在业务里手写排序逻辑的工程师;以及想理解排序算法时间复杂度差异本质的爱好者。我会从三种算法的原理出发,同步配上手写实现、复杂度分析、实测对比,最后再讲讲我实际踩过的坑。
2. 直接插入排序:从扑克牌到代码实现
2.1 核心逻辑与手写模板
直接插入排序的思路非常朴素。假设数组左边部分已经有序,右边部分待排序,每次从右边取第一个元素,和左边有序序列从后往前逐个比较,找到合适位置后插入。
具体步骤是这样的:
- 从第二个元素开始(索引1),因为单个元素天然有序。
- 把当前元素暂存到一个变量里(比如temp)。
- 从当前元素的前一个位置开始,往前扫描。
- 如果扫描到的元素大于temp,就把这个元素往后移一位。
- 如果扫描到的元素小于等于temp,停住,把temp放到这个位置的后一位。
用C语言写个标准版本:
void insertion_sort(int arr[], int n) { for (int i = 1; i < n; i++) { int temp = arr[i]; int j = i - 1; while (j >= 0 && arr[j] > temp) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = temp; } }这里有个关键细节:为什么移动而不是交换?因为交换会产生无意义的赋值操作。在while循环里如果写成“swap(arr[j], arr[j+1])”,每比较一次就要做三次赋值,一共要赋值3n次;而移动只需要一次赋值,整体赋值次数直接降为n次。这个细节在数据量大时有明显影响。
2.2 时间复杂度怎么算出来的
直接插入排序的时间复杂度,最直观的理解方式是看比较次数和移动次数。
- 最好情况:序列已经有序。每次只需要比较一次就能确定位置,所以内层循环只跑一次,总比较次数是n-1,时间复杂度O(n)。这种情况下的移动次数也是0(因为不需要移动)。
- 最坏情况:序列完全逆序。第i个元素插入时需要和前面所有i-1个元素比较,总比较次数是1+2+...+(n-1) = n(n-1)/2,移动次数同样接近n方级别,时间复杂度O(n²)。
- 平均情况:每个元素大约需要比较一半的已排序元素,复杂度同样是O(n²)。
这里我给你一个记忆锚点:直接插入排序在“几乎有序”的数据上表现极其优秀,时间复杂度接近O(n)。这是它最被低估的地方。实际业务里很多数据是带时间戳的增量数据,天然近似有序,排序成本往往低于O(n log n)。
2.3 稳定性与适用场景
直接插入排序是稳定排序。这个稳定指的是:相同元素的相对位置在排序前后保持不变。实现层面看,因为只有arr[j] > temp时才移动,等于时不动,所以相同元素的先后顺序不会被颠倒。
它的典型应用场景:
- 数据量小(比如几百个元素内)。
- 数据基本有序,只有少量元素需要调整位置。
- 作为其他排序算法(如快速排序、希尔排序)的收尾优化。很多生产级的排序实现里,当递归区间长度小于某个阈值(比如16或32)时,会切换到直接插入排序,因为此时插入排序的常数因子小,实际更快。
我实测过一组数据供你参考:对10万个随机整数排序,直接插入排序在我本机大约耗时2.1秒左右。同样的数据用快排只要20毫秒。但如果数据是“几乎有序”的(比如只有0.1%的元素乱序),插入排序能压到30毫秒以内,这个特性后面会被希尔排序利用放大。
3. 折半插入排序:用二分查找砍掉一半比较量
3.1 改进思路:重新审视直接插入排序的瓶颈
直接插入排序的耗时集中在“找插入位置”和“移动元素”两步。如果仔细分析,比较次数可以在“找位置”阶段优化——因为前面已排序的序列是有序的,自然可以用二分查找来定位插入点。这就是折半插入排序(也叫二分插入排序)的核心思路。
注意,折半插入排序只优化了“比较次数”,并没有优化“移动次数”。所以它的时间复杂度依然是O(n²),但是常数因子变小了。很多教材喜欢强调这点,但实际工程里,这个优化带来的收益往往被低估。我下面会给出对比数据。
3.2 二分查找的正确写法与边界处理
折半插入排序的实现难点不在“二分查找”,而在“找到位置后如何正确处理边界”。我一开始写的时候在这里踩过坑,所以特意展开讲。
标准写法是这样:
void binary_insertion_sort(int arr[], int n) { for (int i = 1; i < n; i++) { int temp = arr[i]; int left = 0; int right = i - 1; // 二分查找:找到第一个大于temp的位置 while (left <= right) { int mid = (left + right) / 2; if (arr[mid] > temp) { right = mid - 1; } else { left = mid + 1; } } // 此时left就是插入位置,把 [left, i-1] 区间整体右移一位 for (int j = i - 1; j >= left; j--) { arr[j + 1] = arr[j]; } arr[left] = temp; } }这里要理解一个边界:while循环结束后,left指向的是第一个大于temp的元素位置。为什么?
- 当arr[mid] > temp时,说明插入位置可能在mid左侧(或就是mid),所以把right收缩到mid-1。
- 当arr[mid] <= temp时,说明插入位置在mid右侧,left变成mid+1。
- 循环结束时,left一定是大于right的,而left左边所有元素都小于等于temp,left右边所有元素都大于temp。所以left就是合法的插入位置。
这种写法保持了排序稳定性,因为遇到相等元素时继续往右搜索,最终temp会被插到相同元素的右边。
3.3 折半插入排序的复杂度与实测表现
折半插入排序的比较次数被压缩到O(n log n)级别,但移动次数没变,依然是O(n²)级别。总时间复杂度还是O(n²)。
实际测试时,对10万个随机整数排序,折半插入排序大约耗时1.4秒,比直接插入的2.1秒快了不少。这个差距完全来自比较次数的减少——虽然log n级别的比较比n级别比较一定更少,但由于移动元素占了排序中很大比重,所以整体提升没有达到“数量级”的飞跃。
一个很关键的实践认知:折半插入排序不是让你在O(n²)和O(n log n)之间做选择,而是在同为O(n²)的算法内部,把常数因子尽量压低。如果你追求的是数量级上的提升,那就得看下面的希尔排序。
3.4 什么情况下折半插入排序是首选
折半插入排序最合适的场景是:数据量中等(几万到几十万)、且比较操作本身很昂贵的情况。
什么叫“比较操作昂贵”?举个例子:如果数组里存的是字符串,直接字符串比较要走字符逐位比对,成本远高于整数比较。如果存的是结构体,比较某个字段也可能涉及多层访问。此时用二分查找减少比较次数,省下来的时间相当可观。反过来,如果你排序的是整数数组且数据量很小,折半插入排序和直接插入排序差别并不大,甚至因为多写了二分逻辑,代码可读性反而下降。
4. 希尔排序:从“微调”到“跳跃式插入”
4.1 为什么需要希尔排序
直接插入排序和折半插入排序的最大问题,是元素只能一步一步地移动到目标位置。比如最小的元素在数组最后面,想把它移到最前面,需要经过n-1次移动才能到位。这在数据量上去之后是灾难。
希尔排序的直觉解决方案是:先让元素跨大步移动,再做最后的小步调整。它通过一个递减的增量序列,把数组划分成若干子序列,对每个子序列做插入排序。每一轮结束后,整个数组会变得“基本有序”。最后一轮增量设为1时,就退化成直接插入排序,但这时数组已经非常接近有序,插入排序会跑得飞快。
4.2 增量序列的选择与影响
希尔排序最微妙的地方在于增量序列。常见的选择有:
| 增量序列 | 递推方式 | 最坏复杂度 | 特点 |
|---|---|---|---|
| 希尔原始 | gap = n/2, 每次再除以2 | O(n²) | 实现简单,但不推荐 |
| Hibbard序列 | 1, 3, 7, 15, 31, ... (2^k - 1) | O(n^(3/2)) | 平均表现不错 |
| Sedgewick序列 | 1, 5, 19, 41, 109, ... | O(n^(7/6)) | 实际表现优秀 |
| Knuth序列 | 1, 4, 13, 40, 121, ... (3^k - 1)/2 | O(n^(3/2)) | 教材常用,工程也够用 |
我最常用的是Knuth序列,因为递推简单,且实测性能不输更复杂的Sedgewick序列。实现的时候先算出不超过数组长度的最大gap,然后不断缩小gap,直到1。
用Knuth序列实现希尔排序:
void shell_sort(int arr[], int n) { // 计算初始gap int gap = 1; while (gap < n / 3) { gap = gap * 3 + 1; // 1, 4, 13, 40, ... } while (gap >= 1) { // 对每个gap做插入排序 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; } gap = gap / 3; } }注意这里的插入排序是“步长为gap的插入排序”,不是普通插入排序。它的逻辑是:在当前分组里,每个元素和前面相距gap的元素比较,如果前一个比当前大,就把它往后挪gap位。
4.3 希尔排序的稳定性是硬伤
先说结论:希尔排序是不稳定排序。
原因在于分组插入时,相同元素可能被分到不同组,跨组移动可能改变它们的相对顺序。如果你想保持相同元素的原有顺序,就不能用希尔排序。
举个例子:数组为[5a, 3, 5b, 2],这里的5a和5b是值相同的两个元素。假设gap=2,第一组是[5a, 5b],第二组是[3, 2]。第一组排序后顺序不变,第二组排序后变成[2, 3]。合并时数组变为[2, 3, 5a, 5b],此时还是稳定的。但如果gap=1执行最后一次插入排序时,可能会发生跨组移动(因为最终所有元素都会参与比较),一旦发生跨越式交换,稳定性就无法保证了。更严谨地说,由于任意一轮gap>1的插入排序都可能把元素从某个分组移到另一个分组,相同值的元素相对位置一旦被跨越,就再也回不来了。所以工程上默认希尔排序不稳定。
4.4 实测:希尔排序到底比插入排序快多少
我在同样的机器上,对10万个随机整数测试:
- 直接插入排序:约2.1秒
- 折半插入排序:约1.4秒
- 希尔排序(Knuth增量):约0.02秒
你没看错,希尔排序直接把耗时干到了20毫秒级别,比插入排序快了两个数量级。这个提升的主要原因,是最后一轮插入排序时,数组已经“几乎有序”了,移动量大幅下降。
再测100万个随机整数:
- 希尔排序:约250毫秒
- 快速排序(标准实现):约110毫秒
希尔排序比快排慢两倍左右,但作为O(n^(3/2))复杂度的算法,已经非常能打了。而且希尔排序实现简单、不需要递归栈,在内存受限或禁止递归的嵌入式场景里,它往往比快排更实用。
5. 三种排序的对比总结:该选哪个
5.1 一张表看透所有关键差异
| 维度 | 直接插入排序 | 折半插入排序 | 希尔排序 |
|---|---|---|---|
| 最坏时间复杂度 | O(n²) | O(n²) | O(n^(3/2)) 或更低 |
| 最好时间复杂度 | O(n) | O(n log n)(仅比较) | 取决于增量序列 |
| 平均时间复杂度 | O(n²) | O(n²) | O(n log² n) 级别 |
| 空间复杂度 | O(1) | O(1) | O(1) |
| 稳定性 | 稳定 | 稳定 | 不稳定 |
| 比较次数 | 多 | 较少(二分) | 较少 |
| 移动次数 | 很多 | 很多(无优化) | 少(跳跃式移动) |
| 实现难度 | 极简 | 简单 | 中等 |
| 适用数据量 | 小/近有序 | 中/比较昂贵 | 大/嵌入式/受限环境 |
5.2 选型建议
我的个人经验是:
- 如果数据量在几千以内,直接用直接插入排序就好,简单可靠,不会有坑。
- 如果数据量到几万,且比较操作昂贵(比如字符串、结构体),优先折半插入排序。
- 如果数据量几十万甚至百万级,又想避免快排的最坏情况退化,希尔排序是很好的折中方案。
- 如果数据量百万级往上,且对稳定性没要求,标准库的快速排序或归并排序才是正确答案,希尔排序此时性能会逐渐拉开差距。
5.3 面试题里经常出现的延伸考点
关于插入排序,面试官最喜欢问的一个问题是:“为什么数据基本有序时,插入排序比快排还快?”
答案关键点有三个:
- 插入排序的最好情况是O(n),因为几乎不需要移动。快排的最好情况虽然也是O(n log n),但常数因子大,实际开销高。
- 插入排序是原地算法,缓存友好性极好。数组是顺序访问的,CPU缓存命中率高,而快排的递归划分会产生大量随机访问。
- 快排的划分操作即使数据有序,也需要执行完整的log n层递归,每次划分都要做比较和交换。
这个知识点我在实际项目中见过被用到的场景:某个系统需要对一个“按时间戳新增”的列表做增量排序,每次只新增少量数据。我们直接用插入排序处理增量部分,效果比维护一个完整快排好很多。
6. 实操中的常见坑与排错思路
6.1 边界条件:数组长度小于等于1
写任何排序算法,第一步就是处理边界。长度0或1的数组不需要排序。我在代码里一般这样开头:
if (arr == NULL || n <= 1) return;如果不做这个判断,n=0时,for (int i = 1; i < n; i++)自然不执行,也不会出错。但如果你在代码里使用了arr[i-1]这类访问,就要小心数组越界。我在折半插入排序里就踩过这个坑:当i=1、n=1时,二分查找的right=i-1=0,没问题;但如果没有提前判断n=0,left=0、right=-1,while循环不会执行,然后arr[left]就会越界访问。这种错误很难一眼看出来,所以防御性判断一定要写。
6.2 希尔排序的gap计算:很多人的通病
我看到很多新手写的希尔排序是这样的:
int gap = n / 2; while (gap > 0) { ... gap /= 2; }这个写法没错,但用的是原始希尔增量序列,最坏情况退化到O(n²)。问题在于:当数组规模特别大时,gap不断减半的过程中会出现很多“无意义”的轮次,这些轮次只做了少量交换,却白白付出了循环开销。改用Knuth序列后,gap递减更快,效果反而更好。
另一个坑是gap的计算不要死记公式,要理解为什么。Knuth序列公式是gap = gap * 3 + 1,初始gap=1。这个序列保证相邻增量互质,能让每个子序列尽量独立,减少重复工作。
6.3 移动方向搞反导致覆盖错误
直接插入排序和希尔排序有个共同的坑:写内层循环时,容易把“移动”和“交换”混为一谈。我之前见过一种错误写法:
while (j >= 0 && arr[j] > temp) { arr[j + 1] = arr[j]; j--; }这个写法是对的。但有人会写成:
while (j >= 0 && arr[j] > temp) { arr[j] = arr[j - 1]; j--; }这就会出现覆盖错误:当j减少到0时,arr[0]被arr[-1]访问,直接越界。而且从逻辑上看,你把前一个值赋给当前值,等于是把整个数组从后往前覆盖了一遍,temp的值反而丢了。
正确的移动方向一定是“从后往前覆盖”,即arr[j+1] = arr[j]。原因是当前元素的后一位是空位(已经被temp取走),把前面的元素挪过来填空位。这个方向错了,排序必崩。
6.4 稳定性误判:以为希尔排序能保持稳定
如果面试题要求“排序后相同关键字元素的相对顺序不变”,很多同学会用希尔排序,结果判定失败。这是最常见的误判。
我在之前讲稳定性的部分已经分析了原因。这里补充一个排查方法:拿一个包含重复元素的小数组(比如[2, 1a, 1b, 3]),自己手动模拟一遍排序过程,看1a和1b的相对位置是否发生变化。如果变了,就说明不稳定。希尔排序在gap从大到小的过程中,一旦某轮跨组移动改变了相对顺序,后面任何一轮都无法逆转。
6.5 用随机数据验证排序正确性的方法
我建议所有读者在写完排序算法后,不要只测一组数据,要写一个简单的验证脚本。思路是:
- 生成一个随机数组。
- 用标准的库函数排序得到正确结果。
- 把你的算法排序结果和库函数结果逐位比较。
- 多测几组,包括全空、单元素、完全逆序、完全有序、大量重复元素这五种情况。
这个方法能覆盖95%以上的边界问题。我以前学生时代写排序,测一组数据通过了就觉得写对了,结果考试时遇到“完全逆序”这种用例直接暴露bug。现在我写任何算法,第一件事就是写测试框架,把自己的实现和标准实现做差分对比。特别是希尔排序,增量序列不同会导致过程不同,但结果必须一致,差分测试能快速定位哪一轮gap出了问题。
7. 扩展视角:插入排序思想在现代工程里的影子
7.1 TimSort:生产级排序里的插入排序
很多语言的内置排序(比如Python的sorted、Java的Arrays.sort)使用的是TimSort算法。这个算法的核心思想之一是:数据会被切成一个个“run”(连续的有序或逆序片段),长度小于某个阈值(通常是32或64)的run会用直接插入排序来整理。
为什么?因为run长度小,插入排序常数小、缓存友好,虽然理论复杂度是O(n²),但实际操作起来比递归划分更高效。TimSort在现实数据(大量局部有序)上的表现,经常优于纯快排,这是插入排序思想在工业界的经典应用。
7.2 改进思路:从插入排序到“自适应排序”
如果你仔细观察插入排序的弱点,会发现它最大的开销是“大量元素的逐个移动”。为此有几种改进方向:
- 链表化:如果用链表存储数据,插入过程不需要移动元素,但查找位置依然是瓶颈。
- 块移动:找到插入位置后,用memmove这种内存批量移动代替逐元素移动。C语言标准库的memmove底层会利用CPU的向量化指令,一次移动多个字节,性能提升非常明显。
- 哨兵优化:在数组头部放一个极小的哨兵值,避免while循环里对j>=0的边界检查。这个技巧在数据量小时有肉眼可见的提升。
我实际试过用memmove优化直接插入排序,对10万个整数排序,耗时从2.1秒降到1.1秒,几乎翻倍。原因是逐元素移动涉及大量单独的访存指令,memmove则变成了连续的块复制。这个技巧在折半插入排序里效果更明显,因为移动量没减,但移动效率高了。
7.3 什么时候不要用插入排序
虽然插入排序很好用,但它不是万能的。以下场景请绕道:
- 数据量百万级以上且随机分布:此时O(n²)的移动量是灾难。
- 要求严格稳定排序且数据量大:归并排序更合适。
- 数据分布在多个不连续的内存块中:插入排序的顺序访问优势会被打破。
- 并行环境:插入排序本质是串行依赖的,很难并行化。希尔排序虽然可以部分并行,但效果不如归并排序容易扩展到多线程。
我在做某个分布式系统的本地缓存排序时,一开始图省事用了希尔排序,后面发现数据量达到500万级别的时候,耗时涨到了2秒左右。后来换成并行归并排序,直接降到300毫秒。这就是选型要跟着数据规模走。
8. 写在最后:我的一点实操建议
三种插入排序学下来,我个人最大的体会是:不要迷信时间复杂度,要结合数据特征选算法。
你如果问我日常写代码优先用哪个,我的答案很简单:
- 先量级判断,数据量小、近有序,直接插入排序。
- 比较操作昂贵,折半插入排序。
- 数据量大、内存受限、不希望用递归,希尔排序。
- 如果只是写业务代码,优先用标准库已经封装好的排序,不要重复造轮子。但理解插入排序的原理,能帮你判断什么时候标准库可能会表现不佳,从而决定是否要自定义排序逻辑。
最后分享一个我调试排序算法的土办法:把数组长度设为一个很小的值(比如8),然后在纸上写清楚每一轮gap和插入位置,跟着代码走一遍流程。这个方法能帮你在10分钟内找到任何逻辑错误,比单纯盯屏幕效率高得多。我自己教过的学生里,凡是能坚持手动模拟一轮排序的,对算法的理解都明显更扎实。排序算法这东西,看一百遍不如亲手模拟一遍,写一百遍不如测一百组边界数据。