先问你一个问题:如果你面前有一副乱掉的扑克牌,你会怎么整理?绝大多数人的第一反应不是去搞什么复杂的分区策略,而是一张一张拿起来,插到左手边已经排好的牌堆里。这个动作,就是插入排序。而希尔排序,只是在这个动作上做了一点看起来很不起眼的改进,却能把排序效率从O(n²)拉到接近O(n^(4/3))的级别。
这两兄弟,一个是最朴素、最容易被低估的“基础款”,一个是“基础款的暴力优化版”。我很少看到有人把这两个算法放在一起彻底讲透——大家往往把插入排序当作入门知识点匆匆带过,希尔排序则被当成“历史遗留算法”提一嘴就没了。但实际上,插入排序至今还在各大语言的标准库底层里给快速排序“擦屁股”,希尔排序则是在内存受限、不想引入额外空间的场景里依然能打的实力派。
这篇文章我会从最底层的原理讲起,把两个算法的实现细节、边界条件、时间复杂度的真实表现全部拆开,最后附上可运行的完整工程代码和针对不同数据特征的性能实验数据。无论你是刚学算法的学生,还是在工作中想优化一段排序逻辑的开发者,看完都能直接照着用。
1. 排序问题其实就藏在我们手边:三种“整理直觉”对应三种经典算法
1.1 为什么“排好序”这件事如此基础
排序不只是算法课上的第一道坎。数据库的索引要排序,搜索引擎的结果要排序,排行榜要排序,甚至你手机通讯录按拼音排列也是排序。几乎所有复杂系统的底层,都有一层排序逻辑在兜底。这也是为什么排序算法永远是数据结构课程的重头戏——它既是入门门槛,也是衡量一个程序员对“时间与空间权衡”理解程度的标尺。
但有意思的是,排序问题虽然古老,却并不简单。不同的数据规模、数据特征、内存条件、稳定性要求,都会导向完全不同的算法选择。用得最多的通用排序是快速排序和归并排序,但在特定场景下,今天要讲的这两个“小角色”反而更合适。
1.2 三种整理动作,对应三种算法原型
你回忆一下自己在物理世界里“排序”的动作,基本逃不出三种:
- 选择:在一堆东西里反复挑最小的放到最前面。这是选择排序的思路。
- 交换:看见相邻两个顺序不对就换一下,一路冒泡过去。这是冒泡排序。
- 插入:把新拿到的东西插进已经排好的一堆里。这是插入排序。
这三种直觉,插入排序是最符合人类习惯的,因为它本身就利用了“已有的有序部分”。你整理书架时,会把新买的书直接插到正确位置,而不是把整排书全部打乱重排。这个“利用已有有序部分”的思想,就是插入排序的核心,也是后来希尔排序一切改进的起点。
1.3 “局部有序”的思想:一个不断生长的有序前缀
插入排序的思路可以用一句话概括:维护一个有序前缀,把新元素插入到正确位置,让有序前缀不断变长。
假设数组是 [5, 2, 4, 6, 1, 3],你从第二个元素开始处理。第一个元素5天然是一个“只有一个元素的有序区”。然后把2拿起来,在它前面的有序区[5]里找位置,发现2比5小,就把5往后挪一位,把2放到最前面,有序区变成[2, 5]。接着处理4,在[2, 5]里找到位置,插入后变成[2, 4, 5]。如此往复。
这个过程非常自然,就像你在牌桌上边摸牌边理牌。而“把元素往后挪”而不是“直接交换”,是插入排序的一个重要细节——挪动比交换快,因为它减少了一次赋值操作。这个微小的差异在数据量大的时候会体现得非常明显。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 插入排序的实现细节:不只是“从后往前比一比”那么简单
2.1 标准实现与核心变量设计
插入排序的代码很简单,但写得好不好,差别很大。我见过很多人写插入排序时,内层循环里每次都写 arr[j] = arr[j - 1]; j--; 然后又写 arr[j] = key,这没问题,但更高效、更清晰的写法是先找到插入位置再统一移动。下面是我在实际项目里最喜欢用的版本,Python实现:
python复制def insertion_sort(arr):
# 从第二个元素开始,因为第一个元素天然有序
for i in range(1, len(arr)):
key = arr[i] # 当前要插入的元素
j = i - 1 # 从已排序部分的最后一个位置开始往前找
# 在已排序部分从后往前扫描,把比 key 大的元素都往后挪一位
while j >= 0 and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
# 找到正确位置,插入 key
arr[j + 1] = key
return arr
注意几个关键变量:
key:当前要插入的元素。必须先用一个临时变量存下来,因为后续挪动元素时会覆盖arr[i]的位置。j:用来往前扫描的游标。它指向当前正在比较的元素位置。while j >= 0 and arr[j] > key:这
