排序算法这事,很多人觉得面试过了就完了,实际项目中直接调 Array.prototype.sort() 就行。但前阵子我处理一个真实需求时遇到了点状况,几百条数据的列表要频繁排序,数据基本有序但偶尔有新值插入,默认排序表现让我不太满意,于是把插入排序和它的进阶版——希尔排序重新完整过了一遍。这篇文章把这两兄弟一次讲透,适合刚学算法的同学建立直观理解,也适合写业务代码比较多、想系统补一下排序选型的人做参考。
先说结论:插入排序是理解希尔排序的地基,希尔排序则是在插入排序基础上做“分组跳步”的优化版本。两者空间复杂度都是 O(1),代码量都很小,是笔试手写、嵌入式场景、内存受限环境里非常实用的方案。下面我会从插入排序讲到希尔排序,给出手写代码、踩坑记录和实测对比数据,你可以直接打开控制台跟着敲一遍。
1. 插入排序:先搞懂最基础的排序逻辑
1.1 核心思路:像理扑克牌一样排序
想象你手里有一副乱序的扑克牌,从左到右一张一张理。拿到第二张牌的时候,你会和第一张比,比第一张小就放到前面;拿到第三张的时候,会和前面的牌依次比较,找到合适的位置插进去。插入排序的实际画面就是这样,整个过程非常符合人的直觉。
在代码层面,执行逻辑是这样的:数组的第一个元素天然视为“已排序区间”。从第二个元素开始,每轮取出当前元素,和前面已经排好序的元素逐一比较,凡比它大的元素依次向后移动一位,直到找到比它小或者相等的位置,把当前元素插进去。每一轮结束之后,前 i+1 个元素就都是有序的了。
这里有一个新手经常忽略的细节:“后移”和“交换”有本质区别。交换通常需要三次赋值,而后移只需要一次赋值。插入排序内部用的是“后移”,这也是它比很多刚学算法时想象中要快的原因。很多初学者会把插入排序写成“每比较一次就交换一次”,那样不仅代码绕,实际性能也会差不少。
1.2 JavaScript 实现与逐行解析
直接看代码,这是我推荐的写法:
javascript复制function insertionSort(arr) {
for (let i = 1; i < arr.length; i++) {
const current = arr[i];
let j = i - 1;
while (j >= 0 && arr[j] > current) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = current;
}
return arr;
}
这段代码看起来短,里面有三个细节值得停下来看清楚。
第一,为什么要把 arr[i] 先存到 current 里?因为后移元素的过程中,arr[j] 会覆盖到 j+1 的位置,如果不提前保存,当前元素的值会在数组后移过程中被覆盖掉,整个逻辑就乱了。这是所有“位移式”排序的通用套路,希尔排序里还会用到同样的思路。
第二,while 循环的条件是 j >= 0 && arr[j] > current。意思是只要前面的元素比当前元素大,就继续往后移。注意我用的是 > 而不是 >=,这个细节决定了排序是否稳定——用 > 的话,相等元素不会交换位置,所以插入排序是稳定排序。如果改成 >=,相等元素也会被移动,稳定性就丢失了。
第三,最后一步 arr[j + 1] = current。因为 while 退出时 j 已经指向第一个比 current 小的位置,所以当前元素的最终位置是 j+1。很多人第一次写的时候容易写成 arr[j] = current,结果最后一个元素重复或者丢失,这个边界问题需要多跑几个例子才能理解透。
为了加深理解,手动跑一个例子。对 [5, 3, 8, 1, 9, 2] 执行插入排序:
- i=1, current=3:5>3,5后移,3放到位置0 → [3, 5, 8, 1, 9, 2]
- i=2, current=8:5<8,不动 → [3, 5, 8, 1, 9, 2]
- i=3, current=1:8>1,5>1,3>1,全部后移,1放到位置0 → [1, 3, 5, 8, 9, 2]
- i=4, current=9:8<9,不动 → [1, 3, 5, 8, 9, 2]
- i=5, current=2:9>2,8>2,5>2,3>2,1<2,停 → [1, 2, 3, 5, 8, 9]
每一轮之后,前 i 个元素都是局部有序的,这就是插入排序的核心过程。你会注意到,第 3 轮元素 1 一次性越过了三个元素,但它是一步一步比较、移动过来的——一次只能挪一个位置,这也是插入排序在完全逆序数据上慢的根本原因。
1.3 复杂度、稳定性与适用场景
简单汇总一下插入排序的关键指标:
- 最好情况:数组本身已经有序,内层 while 一次都不走,时间复杂度 O(n)
- 最坏情况:数组完全逆序,每个元素都要和前面所有元素比较,时间复杂度 O(n²)
- 平均情况:O(n²)
- 空间复杂度:O(1),原地排序
- 稳定性:稳定
实际项目里插入排序最大的价值在于“近乎有序”的数据。比如一个在线排行榜,大部分时间顺序保持不变,偶尔有人分数更新了。这种数据用插入排序,内层循环基本走不了几次,效率非常高,甚至能接近线性时间。
还有一个容易被忽视的使用场景是 JavaScript 引擎内部:V8 对 Array.prototype.sort() 的实现,在数组长度较短时(早期版本阈值是 10),底层走的就是插入排序。原因是插入排序在数据量小的时候常数极小,没有额外空间开销,递归和分治的启动成本反而不划算。理解了插入排序,你基本就理解了 V8 排序实现的半壁江山。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 希尔排序:为什么把数组分组一下就能快这么多
2.1 插入排序的痛点:一次只能挪一个位置
插入排序慢的根本原因在于,元素一次只能向后移动一个位置。遇到极端情况,比如对 [9, 8, 7, 6, 5, 4, 3, 2, 1] 排序,最后一个元素 1 要比较 8 次、移动 8 次才能到达位置 0。每一个元素都面临着类似的大规模搬移,总操作量接近 n²/2,自然快不起来。
从另一个角度看,插入排序的执行效率和“逆序对”的数量直接相关。逆序对就是一对位置在前但值更大的元素组合,比如 [9, 8] 就是一个逆序对。完全逆序的数组有 n(n-1)/2 个逆序对,插入排序每处理一个逆序对,就要做一次比较和一次后移。如果你能快速减少逆序对的数量,排序时间就会大幅下降。
希尔排序的精髓就在于:通过分组跳跃式的排序,让“大元素快速往后跳、小元素快速往前跳”,一次性消除远距离的逆序对。等逆序对数量大幅下降之后,最后用一遍插入排序收尾,这时候数组已经基本有序,插入排序的优势就能充分发挥出来。
2.2 分组思想:增量从大到小,最后收拢
希尔排序的做法是选定一个增量 gap,把数组按“相隔 gap 的元素”分成一组,对每组分别做插入排序。然后缩小 gap,重复这个过程,直到 gap 等于 1,做最后一次完整的插入排序。
用一个例子说明。对 [9, 8, 7, 6, 5, 4, 3, 2, 1] 排序,n=9,第一轮 gap=4。这时数组被分成四组:
- 位置 0、4、8:9、5、1
- 位置 1、5:8、4
- 位置 2、6:7、3
- 位置 3、7:6、2
对每组分别做插入排序,第一组变成 1、5、9,第二组变成 4、8,第三组变成 3、7,第四组变成 2、6。整个数组变成 [1, 4, 3, 2, 5, 8, 7, 6, 9]。注意看,9 从位置 0 直接跳到了位置 8,1 从位置 8 跳到了位置 0,只经过了一次分组排序,远距离逆序对被大量清除。
第二轮 gap=2,数组被分成两组:
- 位置 0、2、4、6、8:1、3、5、7、9
- 位置 1、3、5、7:4、2、8、6
分别排序后,数组变成 [1, 2, 3, 4, 5, 6, 7, 8, 9]。第三轮 gap=1,做一次标准插入排序,数组已经基本有序,几乎不用移动任何元素,很快收尾。
整个过程的关键就是“插入排序在近乎有序的数组上效率接近 O(n)”。分组排序时,每个小组的规模很小,插入排序在小规模数据上开销不大;更重要的是,每一轮分组排序都在快速降低整个数组的逆序对数量,让最后一轮全量插入排序变得非常轻松。两件事结合起来,整体效率就上去了。
2.3 增量序列怎么选:不是越花哨越好
gap 从多少开始、每次怎么缩小,直接决定希尔排序的表现。理论上,增量序列的选择会影响最坏时间复杂度。目前常见的有这么几种:
- 原始希尔增量:n/2、n/4、n/8……直到 1,每次除以 2 取整。这是最简单好记的写法,也是绝大多数教科书默认方案
- Hibbard 增量:1、3、7、15……即 2^k - 1,理论上界可以做到 O(n^(3/2))
- Sedgewick 增量:1、5、19、41、109……一组混合序列,实际表现很好,理论复杂度大约 O(n^(4/3))
- Knuth 增量:1、4、13、40……即 3h + 1 递推
我个人的观点是,除非你在做算法竞赛或者性能极致敏感的场景,否则直接用简单的 gap = Math.floor(gap / 2) 就够了。原因有二:第一,gap 递减到 1 是必须保证的,任何序列最后都要落到 1 才能保证排序正确;第二,不同增量序列之间的性能差距,在数据量几十万以内时并不明显,而简单序列的代码可读性和维护性要好得多。
还有一个小坑:gap 不能是浮点数。比如 n=5 时 5 / 2 = 2.5,直接拿 2.5 当数组索引会出问题,JS 不会报错但会取到 undefined。所以每次都要用 Math.floor() 或者其他方式取整,这一步漏掉的话,排序结果会非常诡异。
3. 希尔排序的 JS 实现:交换版和位移版怎么选
3.1 版本一:交换式实现,理解成本低但效率差
网上不少教程用“交换”实现希尔排序,思路是对每组做冒泡式的相邻交换。代码长这样:
javascript复制function shellSortBySwap(arr) {
let gap = Math.floor(arr.length / 2);
while (gap > 0) {
for (let i = gap; i < arr.length; i++) {
let j = i;
while (j - gap >= 0 && arr[j - gap] > arr[j]) {
[arr[j - gap], arr[j]] = [arr[j], arr[j - gap]];
j -= gap;
}
}
gap = Math.floor(gap / 2);
}
return arr;
}
这个版本好不好理解?好理解。它直接表达了“每个元素和同组前一个元素比较,如果顺序不对就交换”的直觉。但问题也很明显:解构赋值实现交换,本质是创建临时数组再赋值,比用临时变量交换还要多几层开销;而且每次交换只消除了一个逆序对,和插入排序“后移多位”的思想背道而驰。
我在学习阶段写过很长一段时间的交换版,后来对照性能测试才发现,同样的数据量,交换版比位移版慢 3 到 5 倍。对于教学演示可以,但如果你要在项目里用,我建议直接用位移版。
3.2 版本二:位移式实现,推荐的标准写法
位移版的核心思路和插入排序一脉相承:先暂存当前元素,把前面较大的元素统一向后挪,找到合适位置再一次性插入。代码如下:
javascript复制function shellSort(arr) {
let gap = Math.floor(arr.length / 2);
while (gap > 0) {
for (let i = gap; i < arr.length; i++) {
const current = arr[i];
let j = i - gap;
while (j >= 0 && arr[j] > current) {
arr[j + gap] = arr[j];
j -= gap;
}
arr[j + gap] = current;
}
gap = Math.floor(gap / 2);
}
return arr;
}
这个代码和插入排序放在一起对照看,会发现结构上极其相似,唯一的区别就是把 arr[j] > current 中的比较逻辑、后移的步长,从 1 改成了 gap。内部循环中,arr[j + gap] = arr[j] 是后移操作,步长为 gap;j -= gap 则是跳到同组的上一个元素。
对照插入排序的三点细节,这里同样适用:current 必须先保存,防止后移覆盖;比较用 > 而不是 >=,保证相等的元素不参与交换,减少无谓移动;最后 arr[j + gap] = current 是插入位置。
我推荐大家把插入排序的 1 全部替换成 gap 来理解希尔排序。这样你会发现,希尔排序根本没有“新知识”,它就是插入排序的泛化版本。弄明白这层关系,面试被问到“希尔排序是什么”的时候,你就能从“插入排序的改进”这个角度讲清楚,而不是背一个孤立算法。
3.3 代码验证与性能对比
代码写完不能只看结果对不对,还要验证边界情况。我把两个版本的代码放到 Chrome 控制台里,用随机数组、逆序数组、近乎有序数组分别测了一遍。为了让你也能复现,这里给一个简单的测试模板:
javascript复制function generateRandomArray(n) {
return Array.from({ length: n }, () => Math.floor(Math.random() * n));
}
function generateNearlySortedArray(n) {
const arr = Array.from({ length: n }, (_, i) => i);
for (let i = 0; i < 10; i++) {
const a = Math.floor(Math.random() * n);
const b = Math.floor(Math.random() * n);
[arr[a], arr[b]] = [arr[b], arr[a]];
}
return arr;
}
function testSort(name, fn, arr) {
const clone = [...arr];
const start = performance.now();
fn(clone);
const end = performance.now();
console.log(`${name}: ${(end - start).toFixed(3)}ms`);
}
在我本机(Chrome,8 代 i5)上,n=10000 随机数组,插入排序大约 85ms,交换式希尔排序约 15ms,位移式希尔排序约 4ms。n=100000 随机数组,插入排序已经接近 8 秒,位移式希尔排序仍在 65ms 左右。差距就是这么明显。当然,不同机器差异很大,数值只看量级,不要当精确基准。
再用逆序数组测一下。n=10000 逆序数组,插入排序约 170ms,位移式希尔排序约 7ms。最后用近乎有序数组测,n=10000 且只有 10 个元素位置被随机打乱,插入排序大约 0.2ms,位移式希尔排序约 3ms。这里希尔排序反而慢了,因为额外的分组和前几轮排序对近乎有序的数据没有收益,还多花了遍历开销。这个结果提醒我们:没有万能的排序,选算法必须看数据特征。
4. 实际项目里的排序选型:稳定性和数据规模才是关键
4.1 什么时候该用希尔排序
结合上面的测试数据,我给一个经验性的判断标准:
- 数据量在几百以内:插入排序或者直接调内置
sort()都行,差别微乎其微 - 数据量从几千到几万:希尔排序是一个非常好的中间选择,代码量小、不需要额外空间、实现简单,性能也够用
- 数据量超过十万:希尔排序开始吃力,建议上快排、归并或者堆排序。尤其归并排序稳定且可预测,适合需要保证上限时间的场景
- 内存受限的环境(比如嵌入式设备、硬件资源极少的场景):希尔排序的 O(1) 空间复杂度是很大的优势
- 排序稳定性有硬性要求时:不要用希尔排序,直接用稳定排序算法
在 JavaScript 的业务开发中,绝大多数场景其实用 Array.prototype.sort() 就够了。现代 V8 的排序实现是稳定排序,底层混合了插入排序和归并排序的思路,小数组用插入排序,大数组走归并,性能和稳定性都有保障。手写希尔排序更多出现在笔试、面试、算法课,以及一些特定环境(比如不允许用内置 sort 的在线评测系统)里。
4.2 稳定性问题:希尔排序为什么不稳定
排序算法的稳定性指的是:值相同的元素,排序之后相对顺序是否保持原样。如果保持,就是稳定的;如果不保证,就是不稳定的。
插入排序是稳定的。原因在于它只在 arr[j] > current 时才移动元素,相等的时候不动,所以相同值的元素彼此之间的顺序不会被打乱。
希尔排序不稳定。原因在于分组操作会把原本相邻的元素拆散到不同组里,每组独立做插入排序,跨越了原顺序。用一个反例说明。数组 [4, 3, 3, 2] 中两个 3 分别记为 A3 和 B3,初始顺序是 A3 在前、B3 在后。n=4,第一轮 gap=2:
- 位置 0、2 一组:4、B3,排序后变成 B3、4
- 位置 1、3 一组:A3、2,排序后变成 2、A3
此时数组变成 [B3, 2, 4, A3],A3 已经跑到 B3 后面了。第二轮 gap=1 做插入排序,最终数组是 [2, B3, A3, 4],A3 依然在 B3 之后,和初始顺序相反。所以希尔排序是不稳定排序。
这个知识点在实际业务里的影响是:如果你的数据需要按多个字段排序,比如先按权重降序、再按时间升序,用不稳定排序可能打乱前一个字段已经排好的顺序。这时要么用稳定排序,要么把多字段合并成一个复合比较函数传给排序逻辑。
4.3 实测数据:一个更直观的对比表
我把 n=10000 和 n=100000 的两组实测数据整理成表,方便你参考(同样说明:数值与机器强相关,看量级别抠细节):
| 数据规模 | 数据特征 | 插入排序 | 希尔排序(位移版) | Array.sort() |
|---|---|---|---|---|
| 10000 | 随机 | 约85ms | 约4ms | 约3ms |
| 10000 | 逆序 | 约170ms | 约7ms | 约3ms |
| 10000 | 近乎有序 | 约0.2ms | 约3ms | 约3ms |
| 100000 | 随机 | 约8000ms | 约65ms | 约15ms |
从这张表可以读出几个信息:第一,插入排序在近乎有序的数据上确实强到离谱,这是它的甜区;第二,希尔排序在几万量级的数据上性能非常均衡,既不挑输入也不怕逆序;第三,内置 sort() 在大多数场景下仍是首选,手写算法更多是在面试和特定环境中才需要。
5. 手写希尔排序常见问题与排错经验
5.1 边界条件:空数组、单元素数组和已排序数组
空数组和单元素数组不会进入 for 循环或者只执行一次循环体,希尔排序能直接返回原数组,不需要额外判断。但如果你把 gap 初始值设置成 arr.length 而不是 Math.floor(arr.length / 2),在空数组上就会进入 while 循环但内部 for 不执行,代码能跑但没意义;如果在某些特殊写法下 gap 保持为 1 或 0,可能造成死循环。
我建议排序函数开头加一行防御性代码,虽然不必须,但能让你的函数在异常输入下表现更稳定:
javascript复制function shellSort(arr) {
if (arr.length <= 1) return arr;
// ...
}
5.2 gap 的取值和循环边界是重灾区
gap 选错或者边界没算好,出问题的方式五花八门。最常见的几个坑:
第一,gap 没有取整。n=5 时 gap = 5 / 2 = 2.5,数组索引 2.5 不会报错但取到 undefined,排序结果里会出现 NaN 或者原数组被破坏。每次更新 gap 都要用 Math.floor()。
第二,外层 while 条件写成 gap >= 1。因为 gap 每次除以 2 取整,最后一定会变成 0。如果条件是 gap >= 1,gap=0 时会进入循环,内部 for (let i = 0; i < arr.length; i++) 会无限循环或者做无意义操作,直接卡死。正确写法是 while (gap > 0)。
第三,内层 while 的边界写错。j >= 0 这个条件不能丢,否则访问 arr[-1] 得到 undefined,比较结果恒为 false,代码不会报错但移动逻辑会提前终止,排序结果不对。对比的时候要留意 arr[j] 和 current 的类型,如果数组里混着数字和字符串,比较结果是数字的字典序,和预期不符。
5.3 原数组被直接修改的问题
希尔排序和插入排序都是原地排序,函数内部直接修改了传入的数组。这在 JS 里是个隐蔽的坑,因为数组是引用类型,你在函数里改了它,外面的变量也跟着变。
如果你需要保持原数组不变,调用前先拷贝一份:
javascript复制const sorted = shellSort([...originalArr]);
或者把拷贝逻辑写进函数里:
javascript复制function shellSort(arr) {
const result = [...arr];
// 后面用 result 排序
return result;
}
哪种好取决于你的需求。如果你明确想原地排序,那直接用函数内修改没问题;但如果排序对象是组件的 props 或者全局状态里的数组,直接改就会引发一系列脏数据问题。我在实际项目里踩过这个坑,排序之后 state 被改了,页面表现完全不符合预期,排查半天才发现是排序函数动了原数组。
5.4 排序结果验证的小技巧
写排序算法最容易犯的错是“看着对,细节不对”。我强烈建议写完之后跑一个随机测试,用内置 sort 的结果做对照:
javascript复制function validateSort(sortFn, size = 1000) {
const original = Array.from({ length: size }, () => Math.floor(Math.random() * size));
const expected = [...original].sort((a, b) => a - b);
const actual = [...original];
sortFn(actual);
const isEqual = actual.every((v, i) => v === expected[i]);
console.log(isEqual ? 'Pass' : 'Fail');
}
多跑几组随机数据,再跑几组全相同的数组和空数组,基本能覆盖大部分边界问题。这个模板我用了很久,每次手写排序算法之后都会跑一遍,省了很多调试时间。
写到这里,希尔排序的坑基本都踩了一遍。我个人在实际操作中的体会是:排序算法的正确性判断不能靠“肉眼觉得对了”,要用随机测试加结果对照,一次通过且多次通过的代码才算真的写对。另外,如果你面试时被问到排序,先想想数据特征和稳定性需求,再决定用什么算法,这一点比背下所有排序代码重要得多。最后再分享一个小技巧:把插入排序的 1 改成 gap,就是希尔排序的核心,理解了这层联系,你就同时拿下了两个算法。
