直接插入排序与希尔排序:原理、复杂度与工程选型实战

发布时间:2026/10/1 17:31:51
直接插入排序与希尔排序:原理、复杂度与工程选型实战
直接插入排序和希尔排序这两兄弟我最早是看严蔚敏那本数据结构入门的说实话当初觉得直接插入排序太“笨”了一个个往前找位置效率看着就着急希尔排序又搞得花里胡哨分来分去的有种不明觉厉的印象。直到后来在真实业务里栽过跟头才重新把这两个算法捡起来仔细研究才发现教科书把他们摆在一起绝不偶然——希尔排序本质就是给直接插入排序做“预排序”而直接插入排序在小规模或近似有序的数据面前能打出堪比快排的performance。这篇文章我不打算讲“八大排序大全”只把这两个钉子户掰开揉碎咱们聊清楚它们的原理、代码、复杂度也会聊聊我在实际排序场景里的选型经验反正归类排序这种事切面就是看数据长什么样。我在项目里接触过不少排序需求报表字段排序、接口返回的列表重排、Excel导出的顺序整理甚至是大型数据迁移前的内存排序方案选型。直接插入排序在数据量小几十到几百条或者数据已经大体有序的场景下那叫一个稳希尔排序更是在中等数据量几百到几万条的随机或半有序数据里靠极简的代码换来了远比冒泡、选择更强的综合表现。这篇文章适合刚学排序算法、准备面试的读者也适合在工作中做小数据量排序优化、想快速选型的同学。十多年经验下来我的体会是别小看任何一个“基础”算法它们的适用边界和工程价值往往比花哨的框架图更有实战意义。1. 从一个真实的排序苦恼说起先讲一个我自己的场景。前两年做某个后台系统有一个列表数据是用户在界面上手动排序的“序号”比如1、2、3……后来增加了批量导入功能导入的Excel里序号经常是乱的比如5、1、9、2导入后需要在内存里对这批数据排好序再合并存量。数据量不大一个部门最多几千条但是因为业务特殊排序后还要做“尽量少调整顺序”这种逻辑。我一开始图省事用了Arrays.sort结果发现Java默认的TimSort虽然稳定但在这种接近有序的序列上虽然也快我却没法完全控制排序过程中的插入点行为我需要一个“能手动控制比较、能逐条感知顺序合理性”的排序方式。这时自然就想到了直接插入排序。另外一个场景是另一种序列。当时另一个同事写的批量更新排序需求在某个维度上有上万个随机数他用了冒泡排序结果一跑就是好几秒那叫一个酸爽。我看了下代码当时就直接改成了希尔排序间隔用Sedgewick序列排序耗时降了至少两个数量级整个操作从秒级变成几十毫秒。那会儿我才真正理解希尔排序“不是插入排序的优化”那么简单——它真的是靠“大步长先粗排、小步长再细排”的思路让数据局部有序的状态以极低成本铺开最后再靠插入排序收尾。有经验之后我总结出一个朴素选型原则如果幂等、稳定、有序度高的中等数据量排序需求直接插入排序永远是首选如果数据量稍大、乱序程度高的内部排序又不想引入复杂实现希尔排序是性价比极高的方案。今天我们就把这两者掰开聊聊看看它们到底怎么工作又该什么时候用。2. 直接插入排序把牌一张张插进手里2.1 核心思路与生活化类比直接插入排序的思路一句话就能讲完把待排序的数组看成两组一组是已经排好的牌一组是还没有处理的牌。每次从没处理的牌堆里拿一张牌从右往左对比已排序牌找到合适的位置插进去。你可以想象打扑克时整理手牌的场景。大多数人拿到扑克牌后不会重新全部排序而是把新摸进的一张牌往手里“穿插”——从右边往左边扫一眼看到比它小的就直接放到那张牌后面。没错这就是直接插入排序。这个行为在计算机里面就是数组元素的搬移你每插一张牌都要把后面所有比它大的牌全部往后挪一格腾出位置。所以直接插入排序的基本步骤是三步把数组第一个元素视作已排好序的区间。从第二个元素开始依次取每个当前元素。在已排序区间里从后往前找到第一个不大于当前元素的位置然后把当前元素插入到它后面。如果位置不空就把比当前元素大的元素逐个后移。你可以发现这个算法是稳定的因为相等元素的插入只会在已有相等元素后面不会越过它。2.2 从零写一个能用的插入排序我用Java给你写一个最直白的版本逻辑和伪代码完全一一对应你们可以直接抄进IDE跑public static void insertionSort(int[] array) { // 从第二个元素开始下标1当作第一张待插入的牌 for (int i 1; i array.length; i) { int current array[i]; // 暂存当前要插入的值 int j i - 1; // j 指向已排序区间的最后一个下标 // 从后往前找插入位置 while (j 0 array[j] current) { array[j 1] array[j]; // 后移一位 j--; } array[j 1] current; // 插入到合适位置 } }这个版本里注意两个细节为什么要暂存current因为数组后移的过程中array[i]这个位置会被覆盖掉所以必须提前保存。循环的判断条件是array[j] current不是。这里直接决定了算法稳定性。只有严格大于才后移那么相等的两个元素原本靠前的那个始终留在前面排序后相对次序不变。说个经验面试或者笔试的时候很多人一紧张会把while循环写成array[j] current结果j i - 1时忘记了数组边界或者漏了j 0这个条件导致数组越界。别问我怎么知道的问就是见过不少。2.3 复杂度看似很弱实则特定场景很能打直接插入排序的时间复杂度分三种情况讨论要清楚最好情况数组已经是升序或你想要的顺序每轮循环只需要比较一次就能发现当前元素已经在正确位置时间复杂度O(n)这个特性让它在近似有序数据上表现极佳。最坏情况数组是完全逆序每插入一个新元素都要把前面所有元素整体后移比较和移动次数都接近n^2/2时间复杂度O(n^2)。平均情况数据随机分布时间复杂度O(n^2)具体来说比较和移动次数大约在n^2/4这个量级。空间复杂度很朴素O(1)只用到常数个临时变量这也是直接插入排序“原地排序”的体现非常适合内存紧张、嵌入式场景。很多人有个误区觉得直接插入排序就是慢不上台面。但我在真实项目中发现一个口诀**“数据量小、近似有序、要求稳定、无需额外空间”**这四条只要满足两三条直接插入排序就是最优解之一。比如在一个长度只有几十的ArrayList里做排序用插入排序代码量最小、稳定性最好、性能也不差。2.4 实操痛点后移搬移的代价比想象中大直接插入排序有个容易被忽略的问题数组挪动元素的代价比比较更大尤其是元素本身是引用类型或复杂对象时后移操作涉及引用赋值虽然每个操作不重但累计次数极多就可能成为瓶颈。举个极端测试对一个长度为5万的数组用直接插入排序如果是纯整数耗时还能接受如果排序的是对象数组且对象比较大比如几十个字段那么引用复制和比较器里的属性访问开销会疯狂放大。我实测过5万个对象的插入排序和希尔排序耗时可差出近一个数量级。所以直接插入排序别硬扛大数据量它和希尔排序本身就是“兄弟搭配”的关系——插入排序做的是“最后一公里”的细活希尔排序负责“宏观打底”。3. 希尔排序给插入排序加速的“跳棋”3.1 为什么插入排序在大数据量下会“塞车”懂了直接插入排序就知道它的问题在于如果数组底部有一个比较小的元素而这个元素又恰好位于数组最后逆序序列里全是这种情况那么这个小元素要一点点挪到前面每挪一次就触发一轮后移效率极低。你可以想象一条堵车的单车道最前面的车开得太慢后面所有车只能一辆辆慢慢跟着整体挪动的路径太长。希尔排序的思想很简单既然插入排序最适合数据大致有序的情况那我能不能先让数据快速变成“大致有序”再做一次整体插入排序怎么快速大致有序呢答案是不再只和相邻元素比较而是“跳”着比较。比如我先让下标间隔为4的元素互相比较并插入排序这样每四个一组内变得有序再缩间隔为2又排一轮最后间隔缩到1整体插入排序。这个过程叫“缩小增量排序”。3.2 关键分组并不是简单的分段这里很多人第一次学希尔排序时会误解成“把数组切成几段每段单独排序”。注意了希尔排序的分组是“间隔分组”不是“连续分组”。举个例子数组长度是10取gap4那么第0、4、8号元素是一组第1、5、9号一组第2、6号一组第3、7号一组。每一组内部做直接插入排序。此时你会发现虽然每一组是在局部有序但跨组的元素距离在宏观上也变近了——这是希尔排序最反直觉也最精妙的地方。每一轮gap排序完成后所有相隔gap的元素之间组内已经有序这时再缩gap继续排。最终gap1的时候整列数据已经相当规整最后一遍直接插入排序几乎线性速度完成。3.3 增量序列的选择别看轻这一步希尔排序的复杂度分析极度依赖增量序列的选择。增量序列选对了性能可以接近O(n log n)选得稀烂甚至可能退化到接近O(n^2)。常见的增量序列有三类我按实战经验介绍一下第一类最初版gap每次折半即gap n/2, n/4, ... 1这也是教科书最常见的写法。实现简单但实际性能一般。为什么因为折半序列有个问题如果gap之间的倍数关系太“整齐”那么不同gap轮次之间可能无法完全消除某些交换对效率受到影响。第二类Hibbard增量序列gap取值1, 3, 7, 15, ... 即2^k - 1理论复杂度是O(n^1.5)。比折半好但毕竟不是最优。第三类Sedgewick增量序列gap 1, 5, 19, 41, 109, ... 或 4^k 3(2^(k-1)) 1*这是Robert Sedgewick在《Algorithms》里推荐的。我实测多次这个序列在真实数据上表现最稳复杂度可到约O(n^1.3)以内而且实现增量序列时配合倒序生成即可。我个人的经验是如果只是面试或者演示用gap n/2的折半就行代码最短如果真正在工程里用建议用Sedgewick序列。后面代码我会给出两种写法你们可以对比跑一跑。3.4 希尔排序的代码实现含两种增量序列下面这个版本用了折半序列最直观public static void shellSortByHalf(int[] arr) { int n arr.length; // 初始gap取长度一半之后每次除以2 for (int gap n / 2; gap 0; gap / 2) { // 从gap开始对每个元素在自己的组内做直接插入排序 for (int i gap; i n; i) { int current arr[i]; int j i; // 组内从后往前比较 while (j - gap 0 arr[j - gap] current) { arr[j] arr[j - gap]; j - gap; } arr[j] current; } } }这里的核心是把直接插入排序里“j i - 1”改成“j i - gap”每一步后移也是“arr[j] arr[j - gap]”。这样同一组内部的排序逻辑不变但是在跨越gap的元素之间“跳跃”比较了。再提供一个用Sedgewick增量序列的版本public static void shellSortBySedgewick(int[] arr) { int n arr.length; // 预先计算Sedgewick增量序列直到超过n int[] gaps new int[20]; int k 0; for (int i 0; ; i) { int gap1 9 * (int)Math.pow(4, i) - 9 * (int)Math.pow(2, i) 1; // 9*4^k - 9*2^k 1 int gap2 (int)Math.pow(4, i) - 3 * (int)Math.pow(2, i) 1; // 4^k - 3*2^k 1 if (gap1 1) gaps[k] gap1; if (gap2 1) gaps[k] gap2; if (gap1 n gap2 n) break; } // 从大到小应用增量 for (int idx k - 1; idx 0; idx--) { int gap gaps[idx]; if (gap n) continue; for (int i gap; i n; i) { int current arr[i]; int j i; while (j - gap 0 arr[j - gap] current) { arr[j] arr[j - gap]; j - gap; } arr[j] current; } } }这个Sedgewick序列版本其实只改了一下gap的产生方式主体逻辑完全一样。你看希尔排序代码量其实很小难在理解分组逻辑和增量序列如何影响性能。3.5 希尔排序的稳定性问题一个容易被问倒的点希尔排序是不稳定的排序算法这是面试常考。为什么不稳定因为在不同的gap轮次中相等的元素可能被分到不同的组里而后一轮的插入排序只关心组内相对位置无法保证在前期轮次里两个相等元素谁在前谁在后。比如在某一轮里两个相等元素因为gap的分组原因一个被排到了前面另一个在后面等下一轮gap更小时插入排序虽然稳定但跨组相对顺序已经发生改变所以整体就“不稳定”了。这个特性在工程上很关键如果对一组有相同关键字的记录要求排序后保持原始顺序比如表格排序还要保留上一级分组顺序那希尔排序就不合适直接用插入排序或归并排序。我在实际项目里遇到过这样的“坑”。某个业务字段有重复值我先用了希尔排序按时间倒序排列结果相同时间的记录顺序被打乱了用户看到列表里同一时间的数据前后顺序随机跳动差点被投诉。后来被迫改回稳定排序。从那以后凡是要求“相同值保持原有顺序”的场景我直接就排除掉希尔排序。4. 实战对比什么时候选哪个才对4.1 直接插入排序 vs 希尔排序的横向对比为了让你一眼看清差别我做了张对比表按几个核心维度列维度直接插入排序希尔排序基本原理每次把元素插入已排序区间分组逐步缩小gap接近全局有序后整体插入排序时间复杂度平均O(n^2)取决于增量序列常见的约O(n^1.3)左右时间复杂度最好O(n)近似有序O(n log n)量级依赖数据环境最坏情况O(n^2)逆序约O(n^2)坏的增量序列空间复杂度O(1)O(1)稳定性稳定不稳定代码复杂度极简略复杂分组插入适合n范围几十到几百近似有序数据几百到几万乱序数据关键技术点边界条件、稳定性增量序列、分组逻辑这张表是选型时的第一参考但在实际工程里数据规模和状态往往混在一起你需要的不是记死这张表而是建立一个“感受尺度”。4.2 我常用的选型流程和判断标准这几年我做排序选型时一般按三步走第一步看数据规模。小于100条几乎任何排序都能秒杀100~1万条是希尔排序的优势区间1万~10万插入排序就不太行了希尔排序还能扛住但更建议考虑快排、归并、堆排序超过10万我基本直接走Arrays.sort或者定制快排。第二步看数据有序度。如果已知数据大体有序比如新数据只是少量新增、旧数据已经排好那么直接插入排序是王。我有一次处理实时日志排序日志时间戳绝大多数就是正序只有少量乱序插入用直接插入排序的表现非常惊艳甚至比泛型自带排序还快。第三步看稳定性要求。稳定性要求高、数据又是乱序那希尔排序直接出局改归并排序或TimSort没有稳定性要求数据又是中等规模乱序希尔排序几乎是性价比最高的。第四步经验补充看实现成本和可维护性。如果项目里定义了一套复杂对象比较器插入排序代码量小、容易阅读排错成本低希尔排序代码也不多但涉及gap序列时不容易一眼看懂。团队里如果都是新手我倾向于先写插入排序因为哪怕性能略差也不容易出错。4.3 结合一个具体的业务案例做推演我给你一个真实可参考的例子。假设有一个订单列表每条订单有“下单时间”字段现在需要把订单按时间从早到晚排序订单数量大概5000条内存不多稳定性没有要求旧数据本身已按加入顺序排列但新数据可能乱。我当时的方案是希尔排序gap用折半序列。为什么不用直接插入排序因为虽然有部分有序但混入的乱序数据数量较多时插入排序最坏情况会明显升温为什么不用快排因为数据量没有大到非快排不可而且快排在内存中递归调用有一定额外开销希尔排序原地排序、代码又短。实测下来5000条订单的排序时间在毫秒级完全满足需求。如果当时强行用直接插入排序最坏情况下可能会有几百毫秒的延迟虽然在单次操作里也不致命但结合后台批量操作场景性能差距就会被放大。顺便提一个技巧如果数据能切片分批希尔排序配合“先对每批做排序、再整体做一次插入排序”的思路会非常稳。这个思路本质上是“归并思维插入排序兜底”我多次在并发批量任务里用过效果很好。5. 实操常见问题与排查经验5.1 直接插入排序的边界条件和越界问题直接插入排序最常见的bug有两类一是j的下标越界二是循环变量写错。用上面那版代码来说最容易出错的是while循环里忘记j 0这个条件或者把array[j] current写成了array[j] current。有个小技巧如果数组下标可能为负最简单的方法是在while循环条件里先写j 0再写array[j] currentJava的短路求值会保证不会越界取到负数下标。我见到很多新手为了省事写成array[j] current j 0这种一是语义不清晰二是容易把j未定义的情况弄混尽量统一为j 0在前。另一个经常被忽略的细节直接插入排序里如果你要排的是降序那么while循环里的比较符号需要变成array[j] current很多人改了一半导致结果奇奇怪怪。比较方向一定写清楚别“顺手”复制升序代码然后反转输出。5.2 希尔排序增量选择错误导致性能异常希尔排序排“乱”了或者排“慢”了第一时间检查增量序列。我遇到过一个情况某同学把gap的初始值设置成了n/2然后每次除以3而不是2结果性能比插入排序还差。为什么因为gap缩小速度太快中间跳过了很多关键间距导致最后一轮gap1时数据仍然很乱几乎退化成直接插入排序的最坏情况了。再一个常见错误是gap乱序。有些人为了“花哨”把增量序列从大到小排列后没有处理好gap和数组长度的关系导致某些gap大于数组长度组内实际上只有一个元素排序等于什么都没做浪费一轮循环。这个问题我写Sedgewick序列版本时特别加了if (gap n) continue;目的就是跳过无意义的超大gap。排查性能异常的另一个思路是在你的代码里加一个计数器统计比较次数和移动次数。我发现很多排序问题只要看一下“移动次数”立刻就能定位是gap序列问题还是逻辑问题。希尔排序里移动次数应该在各个gap轮次间逐步递减如果某一轮移动次数反弹升高多半是gap序列安排有问题。5.3 当数据出现大量重复值时怎么办处理重复值是排序里必须注意的“隐藏暗礁”。直接插入排序因为是稳定排序重复值不会乱处理起来毫无压力但希尔排序不稳定大量重复值会导致相同值的元素相对顺序打乱。如果有大量重复值我的建议是如果排序本身不需要稳定希尔排序照用不影响正确性。如果需要稳定但又想用希尔排序的提速效果可以给比较函数加一个“次关键字”——比如用对象自带的自增序号作为第二排序字段。这样客观上就构造了稳定效果代价是排序过程多比较一次但效果很可靠。如果数据重复度极高比如某一列90%是同一个值任何基于比较的排序都会浪费大量时间在无意义比较上。此时可以换思路用计数排序或者哈希分桶的思想直接绕过比较排序。我在处理Excel报表导出时遇到过一列状态只有0和1的分类排序当时直接用计数排序两遍搞定比任何比较排序都快。这也提醒我们算法选型永远不是只选一种排序算法而是“先看数据形态再决定用什么”。5.4 关于那些热词里的“字符串排序”、“js数组排序”和“mysql排序”热搜词里出现了一堆字符串排序和js数组排序其实它们背后底层还是这些经典排序算法。比如JavaScript里数组的sort方法在不同浏览器和引擎里实现不同V8引擎对长度小于等于10的数组会用直接插入排序超过10会用快速排序而Java的Arrays.sort对Object[]使用TimSort归并排序的优化版对int[]用的是双轴快排。这些底层排序器的设计恰恰验证了在很小的规模上直接插入排序是最优选择之一因为它代码简单、常数因子极小、缓存友好。字符串排序在底层也会频繁用到插入排序的思想——字符串比较本身较重如果数据近似有序插入排序的比较次数少能极大提升效率。我在日志分析场景里试过对一组按时间生成的日志文件名做排序由于文件名前缀日期天然有序直接插入排序明显快过快排。至于MySQL排序它和应用程序内排序是两回事。MySQL的ORDER BY走的是索引或filesortfilesort在内存中会使用快排或堆排序涉及大量数据时还可能使用归并排序落盘。这类底层的排序器实现和程序员手动写的直接插入排序、希尔排序没有直接替换关系但理解插入排序的“近似有序优化”思路能帮你写出更合理的SQL查询条件——比如利用索引让数据库尽量避免大范围filesort。5.5 从调试角度聊点实用技巧不管写哪个排序算法我最推荐的调试手段是写一个随机数组测试工具反复随机生成数据和Arrays.sort的结果做对比。一旦不一致立刻可以缩小到“是稳定性问题”还是“逻辑错误”。如果你发现结果一样但性能大幅下降就用前面说的统计比较次数的方式来看有没有“白折腾”的gap轮次。另一个技巧是拿三个典型的测试数组测边界空数组、只有一个元素、以及逆序数组。空数组和单元素数组跑排序时最容易崩很多人在处理长度为0或1的情况时忘了做防御性判断导致循环越界。虽然教科书上会说排序算法逻辑上天然支持空数组但真实代码里arrays.length 0时for循环不会进入倒是问题不大问题常出在gap初始化和后续访问上。我在代码审查里经常让人加一行“幂等检查”if (arr null || arr.length 2) return; 这不仅是防御性编程也是给排序算法一个明确“不用干活”的信号避免无意义的边界处理。6. 从这两个排序算法里学到的更通用的东西研究了插入排序和希尔排序之后我最大的一个收获是排序算法的本质不是“怎么交换”而是“怎么减少逆序对的数量”。直接插入排序笨就笨在它一次只能交换相邻元素每交换一次只减少一个逆序对所以在逆序度高时效率自然很差。希尔排序之所以快就是因为前期的大gap可以一次性交换较远的元素一次操作能减少多个逆序对相当于“跳着消除逆序”。理解了这个视角你就不会死记复杂度而能想明白为什么希尔排序最坏情况和增量序列强相关——因为增量序列直接决定了每个gap轮次里“逆序对消除的量级”。这个规律在很多场景都适用。比如处理链表排序时插入排序的“移动代价”会变得更高——因为链表不支持随机访问直接插入排序复杂度会蜕化到比较和移动都要遍历链表几乎所有版本都建议用归并排序。再比如处理外部排序时数据不能全进内存靠的是“多路归并”而不是插入排序但每个归并段chunk内部排序仍然常常用直接插入排序做小数组排序。我建议读者在学习时把插入排序和希尔排序作为一个“组合拳”来学不要分开孤立地背。你甚至可以自己设计一个实验先构造一个包含大量逆序对的数组分别跑直接插入排序、折半gap希尔排序、Sedgewick希尔排序记录比较次数和移动次数。做完这个实验你才算真正掌握它们。我自己带新人时也是这么建议的效果比反复刷题好得多。