不想争论“面试是不是背题”,但在前端岗里,排序算法依然是出现频率极高的基础考点。更现实的是,数组原生 sort 在多数场景下都够用,可一旦遇到特殊数据分布、需要稳定排序、或者要处理的是对象数组的多个排序维度,你还是要能亲手写出稳定可控的排序逻辑。这篇文章围绕 插入排序 和 希尔排序 展开,用 JavaScript 从零实现,讲清边界、复杂度、坑点,以及在实际项目里怎么选型。无论你是准备面试、补算法基础,还是在真实业务里要做排序性能优化,都可以直接参考里面的代码和思路。
1. 为什么还要手写排序算法:从 JS 内置排序说起
先别急着否定手写排序的意义。V8 引擎的 Array.prototype.sort 已经很强,不同浏览器里分别采用过快速排序和 TimSort,新版 V8 更是用了改进后的 TimSort。TimSort 的厉害之处在于能利用数据中已经有序的片段,对于近乎有序的数据集,复杂度接近 O(n)。但引擎排序依赖的是你传入的比较回调,每次比较都有函数调用开销,并且你无法控制比较过程中产生的临时状态。
举个例子:一个对象数组里多个字段需要按不同优先级排序,你想实现“总分降序,相同时按时间升序,再相同时按姓名拼音降序”,这种多级排序需求用原生 sort 也能做,但可读性一般。更重要的是,原生 sort 对小数组成绩不错,但大数组上如果你传入一个很烂的比较器,比如 return a[0] - b[0] 这种针对字符串的写法,性能会很难看。再比如某些场景下你需要保持“相同 key 的元素原始相对位置不变”,那就要用稳定排序。而插入排序天然稳定,希尔排序则不稳定,这些都是用引擎自带方法时感知不到,但一旦数据量和业务复杂度上来,就会踩坑的真实问题。
理解插入排序和希尔排序,还能顺带建立起一个重要的直觉:排序算法不是孤立的四个字,而是“比较、交换、利用已有顺序”三件事的组合拳。插入排序的核心是“把新元素插入到已排序区间的合适位置”,希尔排序的核心是“先让数组整体变得大致有序,再做最后的精确排序”。这两种思想在算法题里到处都能用上,比如“链表插入排序”“对近乎有序的数组排序”等等。
这篇文章适合三类读者:一是刚学算法想彻底弄懂这两种排序的前端新手;二是在项目里需要手写排序逻辑但没时间系统梳理的开发者;三是准备面试想快速回滚知识点的同学。后面的代码我都会用可运行的 JavaScript 示例,配合复杂度解析和踩坑提醒,保证你上机就能跑、跑完能复述原理。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 插入排序:最像人类理牌的基础算法
2.1 算法思路与图解式讲解
插入排序之所以最好理解,是因为它几乎就是人类整理扑克牌的动作。初始时,左手为空,牌桌上一堆牌。你一张一张拿起牌,从右到左比较手中已有的牌,找到合适的位置插进去。放到数组里就是:
- 把数组看成两部分:左侧已经排好序,右侧是待排序区域。
- 初始时左侧只有第一个元素,因为它自己天然有序。
- 每次取右侧第一个未排序元素,记为
current,然后从当前有序区间的末尾开始,从右往左扫描,凡是比current大的元素都往后挪一位,直到找到一个不大于current的位置,把current插进去。
举个具体例子,数组 [5, 2, 4, 6, 1, 3]:
- 第一轮:左侧
[5],取2。5 > 2,把 5 挪到下标 1,再把 2 放到下标 0,结果[2, 5, 4, 6, 1, 3]。 - 第二轮:左侧
[2, 5],取4。5 > 4,把 5 挪到下标 2,然后2 <= 4,停止,把 4 放到下标 1,结果[2, 4, 5, 6, 1, 3]。 - 第三轮:取
6,发现左侧最后一个元素 5 已经不大于 6,所以原地不动。 - 第四轮:取
1,需要把左侧所有比 1 大的元素全部后移,得到[1, 2, 4, 5, 6, 3]。 - 第五轮:取
3,把 4、5、6 逐个后移,最终得到[1, 2, 3, 4, 5, 6]。
这个过程中,“比较”和“移动”是同步发生的。移动代表把元素复制到下一个位置,最终空出来的位置再放 current。从交换的角度看,插入排序做的是“整块移位”,不是相邻交换,这也是它比冒泡排序通常更快的原因。
2.2 JS 实现:基础版与优化版
先写一个最直白的版本:
javascript复制function insertionSort(arr) {
const n = arr.length;
for (let i = 1; i < n; 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;
}
这里有两个关键细节:
- 循环从
1开始,因为下标 0 的元素已经天然有序。 - 内层
while条件必须写成j >= 0 && arr[j] > current,顺序不能反。如果写成arr[j] > current && j >= 0,当j变成-1时,arr[-1]是undefined,比较结果是false,看似也能退出,但这是碰巧没事,在严格模式或Proxy包装数组时很可能产生不可预期行为。养成先判断下标的习惯,可以避免很多低级崩溃。
这段代码里,当 arr[j] > current 时,我们把 arr[j] 向右移一位。这里不是交换,所以如果数组里有两个相等的元素,后面那个不会被移到前面,因此插入排序是稳定的。不过上面这个写法中,> 而不是 >= 的判断是稳定性的关键。如果你把条件改成 arr[j] >= current,相等的元素也会被移动,排序完成后相等元素的相对顺序可能反过来,稳定性就被破坏了。很多资料里说“插入排序稳定”,指的是“相等时插入到相等元素之后”的实现方式,不是自动稳定。
把上面的基础版已经可以应付面试口述。但在工程里,我有时会用一种“减少赋值次数”的优化版本。基础版里,每比较一次就移动一次元素,也就是对数组执行一次写操作。虽然对现代 JS 引擎来说这不是瓶颈,但在超大数据量场景下,少写几次内存还是能提升一些性能。优化思路是:先只比较,不移动,记录最终插入位置,找到后再一次性写入。
javascript复制function insertionSortOptimized(arr) {
const n = arr.length;
for (let i = 1; i < n; i++) {
const current = arr[i];
let j = i - 1;
// 找位置:只比较,先不移动
while (j >= 0 && arr[j] > current) {
j--;
}
// 位置是 j+1,如果 j+1 不是 i,就需要把 [j+1, i-1] 整体右移
if (j + 1 !== i) {
for (let k = i; k > j + 1; k--) {
arr[k] = arr[k - 1];
}
arr[j + 1] = current;
}
}
return arr;
}
这个版本在“数据几乎有序”的场景下非常快,因为找位置时经常一次循环就结束。但要注意,它实际上保留了“寻找位置”和“移位”两个阶段,逻辑比基础版复杂一些。日常写算法题,基础版足够了;在生产环境里,我更倾向于直接用原生 sort 或者拍平数据后用 TimSort,但如果需要特定稳定性和可控性,我会用这个优化版。
还有一类写法是“从后往前冒泡式地交换”,类似:
javascript复制for (let i = 1; i < n; i++) {
for (let j = i; j > 0 && arr[j - 1] > arr[j]; j--) {
[arr[j], arr[j - 1]] = [arr[j - 1], arr[j]];
}
}
这种写法最容易理解,但每交换一次数组需要三次赋值,性能比基础版差。面试时如果让手写,我不会选择这种,因为看起来像冒泡。要清楚:冒泡是相邻比较然后交换相邻元素,插入排序是取一个元素往前找位置,两者过程有相似性,但本质不同。
2.3 复杂度分析与适用场景
插入排序的时间复杂度分三种:
- 最坏情况:数组完全逆序。每插入一个元素,平均要移动前面所有元素,总比较次数约为
1 + 2 + ... + (n-1) = n(n-1)/2,移动次数也类似,所以复杂度是 O(n²)。 - 最好情况:数组已经有序。每次只需比较一次,移动零次,总比较次数是
n-1,复杂度 O(n)。 - 平均情况:数组中随机排列,复杂度 O(n²)。
空间复杂度是 O(1),因为只在原数组上操作,只使用了常数个额外变量。
适用场景是数据规模小(几百上千以内)或者数据基本有序。比如日志数据按时间戳插入排序,如果新来的日志时间戳总是比最大值差不多或更大,插入排序表现很好。在实际项目中,一些需要实时维护有序数组的场景,比如排行榜、消息列表,如果插入频率高且列表本身不长,插入排序甚至比常规排序更合适,因为它可以利用“几乎有序”这个特点。
插入排序在 JS 运行时的实际表现还有一个隐藏优势:对 CPU 缓存友好。因为它访问的内存是连续的(数组下标连续递增),现代 CPU 的缓存预取机制能有效加速。这是理论复杂度看不到的,但在大数据量下实测会有体感差异。
2.4 常见理解误区:边界、稳定性与“哨兵”技巧
误区一:内层循环 j >= 0 每次都要判断,能不能用“哨兵”优化?
所谓“哨兵”,是在数组下标 0 之前放一个足够小的值,这样当 current 找不到比它小的位置时,可以直接落在哨兵之后,省去每次判断 j >= 0。在 C 语言里,这需要额外申请一个数组并把数据右移一位。在 JS 里也可以实现,比如:
javascript复制function insertionSortWithSentry(arr) {
const n = arr.length;
// 在索引0处插入一个负无穷哨兵,不可行,因为数组头部需要额外空间
}
但 JS 数组本身是动态的,在数组头部插入元素代价极大。除非你用 Float64Array 且自己能控制内存布局,否则哨兵优化在 JS 中得不偿失。这一点和 C 语言不同,别硬套。
误区二:插入排序“稳定”就是无论如何都稳定。
如上所述,代码里比较条件用了 > 则稳定,用了 >= 则不稳定。所以如果你需要在排序后保持相等元素的原始顺序,请特意检查自己的实现。
误区三:for 循环里把 arr[j] 与 current 比较,写成 < 也可以,那是降序。很多初学者在写升序时容易把条件搞反。我的记忆技巧是:升序时,想插入的元素如果小于前面的元素,前面的元素就得往后挪,所以条件是 前一个元素 > 当前元素。
3. 希尔排序:插入排序的升级打怪版
3.1 为什么需要希尔排序:逆序对的痛点
插入排序的问题在于,如果一个很小的元素出现在数组末尾,它需要一步一步地往前挪,每个大于它的元素都得往后移动一次。比如数组 [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],最后那个 0 要经过 9 次移动才能到正确位置。整个过程很低效。
希尔排序的想法是:能不能先让数组“整体上”大致有序,从而大幅减少后续插入排序的移动次数?答案是可以的。它通过引入一个间隔 gap,把原数组按间隔划分成若干个子序列,对每个子序列分别做插入排序。然后缩小 gap,重复这个过程,直到 gap = 1 时执行最后一次普通插入排序。
举个例子,数组 [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],取 gap = 4。按间隔 4 分,可以看成四个子序列:
- 下标 0,4,8:9, 5, 1
- 下标 1,5,9:8, 4, 0
- 下标 2,6:7, 3
- 下标 3,7:6, 2
对每个子序列做插入排序,得到:
- 下标 0,4,8:1, 5, 9
- 下标 1,5,9:0, 4, 8
- 下标 2,6:3, 7
- 下标 3,7:2, 6
根据下标把排序结果放回原数组,得到 [1, 0, 3, 2, 5, 4, 7, 6, 9, 8]。可以看到,大的 9、8 已经跑到了后面,小的 1、0 到了前面,虽然还不太有序,但逆序对已经少了。再取 gap = 2 和 gap = 1 继续排序,最终能快速完成。
注意,希尔排序每次并不是独立地对“相同间隔”的子序列排序,而是所有间隔固定的元素一起做插入排序,但可以理解为逐个处理子序列。最终 gap=1 时,就是对整个数组做普通插入排序,但由于前面已经把步长拉大,数据基本有序,所以 gap=1 的一次扫描速度极快。
3.2 增量序列的演进:为什么不能一直用固定 gap
希尔排序的核心是选择“增量序列”,也就是一系列不断缩小的 gap。最早希尔本人建议选择 n/2, n/4, ..., 1,也就是每次把 gap 除以 2。这个序列简单,易于实现。但它存在一种最坏情况:如果数据恰好被安排得很“整齐”,某些大元素和小元素之间的交换并没有被大步长有效处理,复杂度可能会退化到 O(n²)。
后来研究提出了很多改进的增量序列:
- Hibbard 增量序列:
1, 3, 7, 15, 31, ...,即2^k - 1。最坏时间复杂度约 O(n^(3/2))。 - Sedgewick 增量序列:
1, 5, 19, 41, 109, ...,可以按公式4^k + 3 * 2^(k-1) + 1之类的生成(不同文献有不同定义),性能更好,实际工程常用。 - Knuth 增量序列:
1, 4, 13, 40, 121, ...,即(3^k - 1) / 2。这是很多人教科书里见过的序列,也比较好实现。
增量序列的选择会影响希尔排序的时间复杂度。理论界至今仍未找到一个公认“最优”的通用序列,实际表现和具体数据有关。不过对普通开发来说,用 Knuth 序列能很好地平衡代码简洁度和性能。
我建议在面试中如果被要求实现希尔排序,可以先写出最基础的“gap = 2 倍分”版本,说明思路,然后简单提一句“实际可以用 Knuth 序列优化”。这样既体现你懂原理,也展示你有工程考量。
3.3 JS 实现:基于希尔增量的标准版
先实现一个最简单、最经典的“gap 折半”版本:
javascript复制function shellSort(arr) {
const n = arr.length;
// 初始 gap 为 n/2,之后每次除以2取整
for (let gap = Math.floor(n / 2); gap > 0; gap = Math.floor(gap / 2)) {
// 对每个间隔为 gap 的子序列做插入排序
for (let i = gap; i < n; i++) {
const current = arr[i];
let j = i;
while (j - gap >= 0 && arr[j - gap] > current) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = current;
}
}
return arr;
}
这个代码和插入排序非常像,区别在于内层循环移动的步长是 gap 而不是 1。外层 for 循环控制 gap 从 n/2 一直降到 1。
它的时间复杂度最坏是 O(n²),但绝大多数情况下比普通插入排序快很多。用上面的逆序数组 [9, 8, ..., 0] 来测试,插入排序会做 45 次交换,而希尔排序大约只要十几次,肉眼可见的差别。
再实现一个基于 Knuth 增量序列的版本。Knuth 序列生成方式一般是:h = 1; h = 3*h + 1,重复直到 h 不小于数组长度。然后从最大的 h 开始递减:
javascript复制function shellSortKnuth(arr) {
const n = arr.length;
let gap = 1;
while (gap < n) {
gap = 3 * gap + 1; // 1, 4, 13, 40, ...
}
while (gap > 0) {
for (let i = gap; i < n; i++) {
const current = arr[i];
let j = i;
while (j - gap >= 0 && arr[j - gap] > current) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = current;
}
gap = Math.floor(gap / 3);
}
return arr;
}
注意,Knuth 序列是逆着用的。先找到第一个不小于 n 的 gap,然后每次除以 3 取整,最终会到 1。这样做的好处是步长递减平滑,比除以 2 的序列在实际中更快。
实现希尔排序时有一个很容易写错的地方:内层循环的起点不是子数组的第一个元素,而是 i = gap,并且 i 要从 gap 逐渐到 n-1。为什么不直接对每个子序列单独循环?因为 i 从 gap 到 n-1 的遍历方式对齐了“逐个插入到前面已排序子序列”的逻辑,不会漏掉任何一个元素。也可以把 for 循环写成:
javascript复制for (let i = 0; i < n; i++) {
// 只对 i >= gap 的元素进行插入
if (i < gap) continue;
...
}
但那样多一次判断,不好看。
从性能角度,Knuth 版比折半版要快一些,尤其在数据规模上万之后,差距会明显。不过若面试只是考原理,折半版已经足够交差。下面我们做一个简单的性能验证。
3.4 不稳定性分析与时间复杂度的迷思
希尔排序是不稳定的排序。原因很简单:在按 gap 划分子序列时,两个相等元素可能被分到不同的子序列里。当 gap 大于 1 时,一个子序列的排序可能会让某个相等元素越过另一个相等元素,破坏相对顺序。即便在 gap=1 的最后一轮排序中,插入排序本身是稳定的,但之前的移动已经改变了相对位置。因此,任何需要稳定排序的场景都应该避免希尔排序。
关于希尔排序的时间复杂度,很多初学者背成“O(n^(3/2))”,这个说法不准确。准确地说,希尔排序的时间复杂度取决于增量序列:
- 最坏情况:使用
gap = n/2折半序列时,最坏 O(n²)。 - 使用 Hibbard 序列时,最坏约 O(n^(3/2))。
- 使用 Sedgewick 序列时,最坏约 O(n^(4/3))。
平均情况更难研究,有些序列在特定数据分布下能达到近似 O(n log n) 的效果。所以在面试里,如果面试官追问,你可以回答“复杂度取决于增量序列,通常优于 O(n²),但最坏仍然是二次级别;没有统一的 O(n log n) 保证”。
另外要注意:希尔排序的“差距”不只是性能,还有代码可读性。它的思维是“分治思想 + 插入排序”,但不同于归并或快排的递归分治,它是就地完成、利用间隔跳转。理解它,有助于理解为什么很多算法优化是“预排序 + 精确排序”的组合套路。
4. 手写排序的工程化思考:什么时候自己写,什么时候用内置
4.1 性能对比实测方法与结论
到了工程层面,不要迷信“手写排序一定比原生快”。我们来做个简单测试。用不同排序算法处理一个长度为 100000 的随机整数数组,记录耗时(在浏览器或 Node.js 环境):
javascript复制const arr = Array.from({ length: 100000 }, () => Math.floor(Math.random() * 1000000));
const arrForInsert = [ ...arr ];
const arrForShell = [ ...arr ];
const arrForNative = [ ...arr ];
console.time('insertionSort');
insertionSort(arrForInsert);
console.timeEnd('insertionSort');
console.time('shellSort');
shellSort(arrForShell);
console.timeEnd('shellSort');
console.time('native sort');
arrForNative.sort((a, b) => a - b);
console.timeEnd('native sort');
实测结果通常是:插入排序最慢(因为 O(n²) 在 10 万数据上已经比较痛苦),希尔排序能在一秒内完成,原生 sort 则是其中最快的之一。原生 sort 用了高度优化的 TimSort,它在随机、有序、部分有序数据上都有非常优秀的表现,普通开发者很难徒手超越它。
但“内置 sort 最快”不等于“永远用内置 sort”。有以下例外:
- 当你有大量重复键需要多级排序,并且希望用稳定的手写逻辑保存某些中间状态时,自定义排序更有掌控感。
- 数据量很小(几十个)且需要频繁插入到有序数组时,手写插入排序比每次调用 sort 更高效。
- 你要处理的是链表、树、流式数据等非数组结构,原生 sort 不可用。
所以工程上的结论是:默认用原生 sort,除非业务有明确理由需要手写复杂排序逻辑或在受限数据结构中使用。
4.2 对 JS 引擎回调开销的认识
原生 sort 的性能优势有一部分来自内部优化,但关键在于它接收一个比较函数。比较函数是 JS 函数,每次调用都经过 V8 的解释器和优化器,开销不可忽视。TimSort 之所以快,不只是因为它是 O(n log n),更因为它能减少比较次数,从而减少调用 JS 回调的次数。而插入排序对于近乎有序的数据只需 O(n) 次比较,所以回调开销很小,这也是它在这种场景下可能“看似”比原生 sort 快的原因。
不过如果你拿一个 100 万元素的随机数组来测,手写希尔排序和原生 sort 的差距会很明显。原生 sort 利用二分插入、合并等技巧,在大随机数据上的比较次数更少,回调调用也更少。
如果你想针对某个特定业务场景优化排序,可以考虑两个方向:
- 将对象数组维护成多个索引数组,排序时只排索引数组,避免大对象被频繁移动。
- 如果排序键可以转换为数值,就把比较函数写得更简单。例如把
{ id, score }提取成id * 1000000 + score这样的可比较数值,再用原生 sort 排数值数组,速度更快。
4.3 需要稳定排序时怎么办
ES2019 之后,规范要求 Array.prototype.sort 必须是稳定的。也就是说,现代 JS 原生的 sort 已经是稳定排序,不需要再担心相等元素被打乱。这个特性在 Chorme、Firefox、Safari 和 Node.js 中都支持。
但如果你写的是通用库,需要兼容老环境,就不能依赖原生稳定。此时如果有稳定需求,可以自己实现一个“稳定排序”。插入排序稳定、归并排序稳定,希尔排序和快速排序(如果基准选择不当)通常不稳定。所以手写排序的场景中,稳定排序请优先考虑归并排序或插入排序。
需要注意的另一个点是,稳定排序和实现方式有关。比如快速排序如果采用“尾递归 + 随机基准 + 相同元素回退”等技巧也可以做到稳定,但那样实现复杂度很高。工程上想简单,直接调用原生 sort 是最稳的。
5. 实战:在真实项目中实现一个带排序的列表页
5.1 场景描述与需求拆解
假设你正在做一个数据表格页面,需要展示一组用户记录,每行数据包含:name(姓名)、score(分数)、createdAt(创建时间)。需求是:
- 表格默认按分数降序排列;
- 如果分数相同,按创建时间升序排列;
- 支持用户点击列头切换排序字段;
- 每次排序操作至少要支持 10000 条数据的响应式刷新,不能卡死页面。
第一条肉眼可看,第二条是典型的多关键字稳定排序。这里如果用原生 sort,因为它是稳定的,很容易实现:先按“创建时间”升序排一次,再按“分数”降序排一次。由于稳定排序不会改变前一轮排序的相等元素顺序,最终在分数相等时,会保持创建时间升序。这个技巧很实用。
但如果我们强制要求自己实现排序,该怎么做?可以用插入排序做一次练习,不过数据量大时插入排序会卡。更合理的是写一个通用的稳定排序函数,比如归并排序,或者基于插入排序在数据量小时用、数据量大时用归并。
这里我们结合主题,分别演示插入排序和希尔排序在实战中的简化应用。
5.2 在实现中应用插入排序:小规模多字段排序
对于小规模数据(比如 100 条以内),插入排序足够快,而且它天然稳定,非常适合多关键字排序。我们可以直接写一个“稳定稳定再稳定”的插入排序:
javascript复制function stableInsertionSort(arr, compareFn) {
const n = arr.length;
for (let i = 1; i < n; i++) {
const current = arr[i];
let j = i - 1;
// 注意:compareFn 返回负数表示 current 应小于 arr[j]
// 只有 compareFn(current, arr[j]) < 0 才需要移动 arr[j]
while (j >= 0 && compareFn(current, arr[j]) < 0) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = current;
}
return arr;
}
然后定义比较器:
javascript复制const byScoreDescAndCreatedAsc = (a, b) => {
if (a.score !== b.score) {
return b.score - a.score; // 分数降序
}
return a.createdAt - b.createdAt; // 时间升序
};
stableInsertionSort(users, byScoreDescAndCreatedAsc);
由于插入排序是稳定的,当分数相同时,不会交换顺序,因此前一轮按时间升序排好的顺序会被保留。但这要求先按时间升序排序一次,再执行上面的排序。如果你只有一个排序器,可以这样:
- 先按
createdAt升序调用stableInsertionSort(users, (a, b) => a.createdAt - b.createdAt)。 - 再按
score降序调用stableInsertionSort(users, byScoreDescAndCreatedAsc)。
因为第二次排序是稳定的,分数相同的元素保留第一次的时间顺序。这样多关键字排序就干净利落地完成了。
5.3 在实现中应用希尔排序:大批量预排序
如果数据量上万,手写稳定排序可以直接用原生 sort 替代。但倘若你因为学习目的,非要在大数组上使用手写排序,那么希尔排序可以做“预排序 + 最后一步插入排序”的优化。设计思路是:
- 先用希尔排序的变体把数组按目标比较器粗略排一遍,得到一个“基本有序”的数组;
- 接着用插入排序收尾,插入排序在基本有序的数组上几乎是 O(n)。
但这种做法无法保证整体的稳定性,所以不适合多字段排序。另一种做法是仅用希尔排序做单字段排序,比如对一组成绩数值排序,稳定无关紧要时可以用。
为了演示,我们写一个基于比较器的希尔排序:
javascript复制function shellSortWithComparator(arr, compareFn) {
const n = arr.length;
let gap = 1;
while (gap < n) {
gap = 3 * gap + 1;
}
while (gap > 0) {
for (let i = gap; i < n; i++) {
const current = arr[i];
let j = i;
while (j - gap >= 0 && compareFn(current, arr[j - gap]) < 0) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = current;
}
gap = Math.floor(gap / 3);
}
return arr;
}
如果用户点击“按分数降序”,但不需要稳定排序(分数相等时顺序无所谓),可以直接:
javascript复制shellSortWithComparator(users, (a, b) => b.score - a.score);
对比原生 sort,这个希尔排序在 10000 条数据上的耗时不会太难看,但依然没有原生快。所以它在实战中更适合“展示实现细节”或“在旧环境里没有稳定 sort 时做单 key 排序”的场景。
5.4 排序结果验证与测试
无论使用哪种排序,上线前都要验证结果。一个常见误区是只打印排序后的数组,肉眼觉得“差不多”。更好的做法是编写断言函数:
javascript复制function assertSorted(arr, compareFn) {
for (let i = 0; i < arr.length - 1; i++) {
const c = compareFn(arr[i], arr[i + 1]);
if (c > 0) {
console.error(`排序失败:下标 ${i} 和 ${i + 1} 顺序错误`);
return false;
}
}
console.log('排序验证通过');
return true;
}
注意 compareFn 的定义要一致:返回正数表示第一个参数应该排在第二个参数之后,返回 0 表示相等,返回负数表示应该在前。验证时如果 c > 0,说明相邻元素顺序反了。
稳定性测试则是在对象数组上增加一个序号字段,排序后检查“分数相等时,序号是否递增”:
javascript复制const testData = [
{ score: 90, id: 1 },
{ score: 80, id: 2 },
{ score: 90, id: 3 },
{ score: 80, id: 4 },
];
const sorted = stableInsertionSort([...testData], (a, b) => b.score - a.score);
// 期望:分数 90 的 id 序列为 1,3;分数 80 的 id 序列为 2,4
如果排序后分数 90 的两条顺序变成了 3,1,说明排序不稳定。用这个测试可以快速验证你写的插入排序是否保持了稳定性。
6. 常见问题与调试技巧实录
6.1 调试代码时的常见报错与坑点
这里整理一份我在带新人和自己写码时遇到的典型问题速查表。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 排序结果部分错乱 | 循环起始下标写错,比如 i = 0 导致第一个元素也被当作待插入元素 |
插入排序从 i = 1 开始;希尔排序内层从 i = gap 开始 |
数组元素丢失或出现 undefined |
内层移动元素时越界,j >= gap 条件漏写 |
移动前先判断下标,如 while (j - gap >= 0 && ...) |
| 运行时栈溢出(大数组) | 错误使用递归实现排序 | 插入排序和希尔排序都是迭代,检查是否把 sort 写成递归了 |
| 排序后数组没有变化 | 比较器符号写反,或者误用 current = arr[i] 但忘记赋值回去 |
单步调试第一个元素,确认赋值语句执行了 |
| 希尔排序和预期不一致 | gap 递减逻辑错误,比如 gap /= 2 得到小数而不是取整 |
使用 Math.floor(gap / 2) 或 Math.floor(gap / 3) |
| 使用稳定排序但顺序仍被打乱 | 比较器中相等时返回非 0,或者手写排序用了不稳定的实现 | 确保相等时返回 0,且实现确实采用“相等不移动”的写法 |
我自己最容易犯的错误是在希尔排序中把内层 while 写成 j >= 0,但步长是 gap,导致移动时直接用 arr[j - gap],当 j - gap 为负数时访问到非法下标。虽然 JS 会把它变成 undefined,比较时可能误判,但结果完全不对。所以记住两条铁律:
- 所有数组下标的边界判断必须包含步长,也就是写
j - gap >= 0,而不是j >= 0。 current的值在进入内层循环前一定要保存,不然移动元素时会把它覆盖。
6.2 性能问题排查:如何判断排序是不是卡顿元凶
如果页面里排序导致卡顿,不要一拍脑袋就换算法,先用工具定位。方法是在执行排序前后打上标记:
javascript复制console.time('sort');
arr.sort((a, b) => a.score - b.score);
console.timeEnd('sort');
不同数据量下看耗时,通常规律是:
- 几千条以内:任何排序都很快,手写插入排序也不会卡。
- 几万条:原生 sort 和希尔排序都能接受,但插入排序开始变慢。
- 十几万条以上:原生 sort 稳定高效,手写归并也可以,希尔排序可能还能跑但性能不如 TimSort。
如果排序本身不是瓶颈,真正的问题可能是“排序后触发了大规模 DOM 重绘”。这时候优化顺序是:
- 减少 DOM 节点数量,使用虚拟滚动;
- 使用缓存,避免每次数据变更都全量排序;
- 使用
requestAnimationFrame将排序和渲染分离。
如果确实需要排序大量数据,可以考虑利用 Web Worker 在后台线程排序,避免阻塞主线程。不过要注意 Web Worker 中无法直接操作 DOM,需要通过 postMessage 传递数据。
6.3 从算法到代码:几个必背的要点
最后梳理几个我每次讲解这两类排序时都会强调的要点,你可以当作复习卡片。
- 插入排序的“移动”发生在“比较”之后,移动是后移一位,不是交换。
- 插入排序的稳定条件是“仅当严格大于时移动”,相等时不移动。
- 希尔排序的“gap”把元素分成多条逻辑链,gap=1 时就是普通插入排序。
- 希尔排序的时间复杂度和增量序列强相关,不要背死一个 O(n^(3/2))。
- 工程上优先使用原生
sort,手写排序用于学习、特殊数据结构和稳定多级排序场景。 - 在任何排序代码里,先确认数组是否为空和长度为 1,这两种情况应该立即返回,否则可能白白多跑几次循环。
这些点不算难,但缺一不可。算法实现和工程落地之间的差距,往往就藏在“相等时怎么办”“边界怎么判断”这样的细节里。
最后分享一个我自己的经验:在项目里写排序,别急着选最复杂的算法。先把数据特征搞清楚。如果数据是“近实时增长的”,插入排序比大多数 O(n log n) 排序更合适,因为每次新数据到来时,只需把新元素插入到已有有序序列里,成本极低。如果数据是“海量、乱序、一次性加载”,直接原生 sort。只有当你需要深入理解排序行为、或者处于不能使用原生 sort 的环境(比如某些嵌入式 JS 运行时),才真正需要手写复杂排序。在这条路上,插入排序是理解排序算法的地基,希尔排序则是从 O(n²) 走向 O(n log n) 的重要桥梁。把这篇文章里的实现跑一遍、调试一遍,你就不会再对排序产生“背模板”的焦虑了。
