插入排序是我工作之后仍然会主动手写的少数排序算法之一。很多教程把它放在排序章节的第一页,于是新人容易误以为它只是用来当教学引子,实际项目中没什么存在感。恰恰相反,凡是处理几十个元素、或者数据集已经大致有序的场景,插入排序的真实速度往往比快速排序和归并排序更快,原因后面会讲。这篇文章就聊聊用C++写插入排序时那些值得注意的细节,包括三种代码形态、复杂度推导、工程优化和调试翻车记录。无论你是在准备算法面试,还是想在业务代码里提升一点排序效率,都应该能从这里拿到可以直接复用的方案。
1. 从摸牌整牌的逻辑讲起:插入排序到底在排序什么
1.1 把“已排序前缀”想象成一手有序的扑克牌
很多人打牌时有这种习惯:新摸到一张牌,不会把所有牌重新理一遍,而是直接把它插到手里已经排好序的牌中间。插入排序就是把这个小动作程序化。数组从下标 1 开始处理,把当前元素 key 暂存到临时变量里,然后回头和前面的元素逐一比较。只要前面的元素比 key 大,就往后挪一位;直到遇到的元素不再比 key 大,或者已经挪到数组开头,就把 key 放到空出来的那个位置。
这个过程中,数组天然被分成了两个区域:左边是已经整理好的“有序前缀”,右边是还没处理的“无序后缀”。每一轮从无序后缀中取第一个元素,插入到左边合适的位置后,有序前缀就多了一个元素。这个描述听起来很简单,但它是理解插入排序一切特性的起点:迭代次数、移动次数、稳定性、甚至是后续的二分插入优化,都是从“已排序前缀”这个事实衍生出来的。
这里要单独强调一句:插入排序是原地排序,只需要一个临时变量级别的额外空间;同时它是稳定的,因为内层循环的条件是“严格大于”才移动,相等的元素不会互相越过。稳定性和原地性这两点,在真实工程中往往比很多人想象的更重要。
1.2 循环不变量:为什么每一轮结束都是有序的
计算机科学里有个术语叫循环不变量(loop invariant),听起来高大上,其实一句话就能说明白:在每次循环开始之前,某个性质一定是成立的。插入排序的循环不变量是“在进入第 i 轮迭代之前,数组的前 i 个元素已经是排好序的”。
初始时 i = 1,前 1 个元素天然有序,这没什么好争辩的。接下来第 i 轮会把第 i 个元素插到前面的正确位置,于是前 i+1 个元素有序。这样一轮推一轮,整个过程结束时前 n 个元素有序,也就是整个数组有序。这就是为什么插入排序一定能正确排序,不需要额外怀疑。
循环不变量不仅是用来证明算法正确的,调试时也特别好用。我经常在代码里临时加一个断言:每一轮结束后检查 arr[0] 到 arr[i] 是否有序。一旦不满足,说明问题出在上一轮循环里,比盯着变量在纸上推演直观得多。这种“用不变量的眼光看代码”的习惯,对写各类算法都是通用的。
2. C++手写插入排序:三种最常见的实现形态
2.1 数组版本:教科书代码的逐行拆解
先给最经典的数组版本,这也是面试时最容易手写出来的版本。
void insertionSort(int arr[], int n) { if (n <= 1) return; for (int i = 1; i < n; ++i) { int key = arr[i]; int j = i - 1; while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; --j; } arr[j + 1] = key; } }逐行看几个关键点。第一,for 循环从 i = 1 而不是 0 开始,因为第 0 个元素天然有序,不需要“把第 0 个元素插入到自己前面”。第二,key 必须先用临时变量保存,因为后面往前挪元素时会覆盖 arr[i] 的位置,如果不保存,原值就丢了。第三,while 的条件判断顺序是j >= 0 && arr[j] > key,这个顺序千万不能写反。一旦写反成arr[j] > key && j >= 0,当 j 变成 -1 时会先访问 arr[-1],这是未定义行为,程序不一定会立刻崩,但解释器/编译器完全有理由给你一堆莫名其妙的结果。
循环结束后,j 停在不满足条件的位置:要么 j 已经小于 0,说明 key 应该放在数组开头;要么 arr[j] <= key,说明 key 应该放在 j 后面。无论哪种情况,插入位置都是 j + 1,所以统一执行arr[j + 1] = key。这个设计的巧妙之处在于,它免去了在循环里写额外判断的麻烦。
2.2 链表版本:把数组思维切换成指针思维
链表上做插入排序是很多面试官喜欢考的一道题,因为它能考察你是否真的理解了数据结构的差异。链表不支持随机访问,不能像数组那样“每次 O(1) 往前挪一个位置”,但它的插入操作天然是 O(1):只要知道前驱节点,改变 next 指针就能完成插入。
思路和数组版本不太一样。数组是在同一个数组内维护有序前缀,链表更常见的做法是“拆开重插”:维护一个新的有序链表头节点,遍历旧链表,依次取出当前节点 cur,在新链表中找到第一个比 cur 大的位置,把 cur 插过去。因为旧链表的节点不断减少,新链表不断增长,整个过程结束时新链表就是完整的有序链表。
struct ListNode { int val; ListNode* next; ListNode(int x) : val(x), next(nullptr) {} }; ListNode* insertionSortList(ListNode* head) { ListNode dummy(0); // 哨兵节点,简化头插 ListNode* cur = head; while (cur != nullptr) { ListNode* nxt = cur->next; // 先保存下一个节点 ListNode* prev = &dummy; while (prev->next != nullptr && prev->next->val < cur->val) { prev = prev->next; } cur->next = prev->next; prev->next = cur; cur = nxt; } return dummy.next; }这段代码里有几个容易踩的坑。第一,从旧链表取出 cur 后一定要先保存cur->next,否则插入新链表时修改了 cur->next,就找不到旧链表的下一节点了。第二,引入哨兵节点 dummy 能很大程度避免头插时的特殊判断,让所有插入操作统一成“在 prev 后面插入”。第三,内层查找用的是prev->next->val < cur->val,这意味着相等元素会插在已有相等元素的后面,所以这个实现也是稳定的。如果你把<改成<=,稳定性就破坏了,这在面试里经常被追问。
链表插入排序的时间复杂度同样是 O(n²),但相比数组版常数更大,因为每次插入前都要从头部重新扫描新链表。工程上对链表排序一般更推荐归并排序,不过链表插入排序的代码短、好解释,在面试和教学场景下仍然很有价值。
2.3 泛型版本:用模板把排序做进标准库风格
既然是 C++,只写一个 int 数组版本总觉得不够正宗。日常工程里我们更多面对的是std::vector、std::array或者原始数组,这三种容器共享随机访问迭代器。写一个模板函数,让它可以作用于任意随机访问迭代器,这样几乎处处都能用。
#include <algorithm> #include <iterator> template <typename RandomIt> void insertionSort(RandomIt first, RandomIt last) { if (first == last) return; for (RandomIt i = first + 1; i != last; ++i) { auto key = *i; RandomIt j = i; while (j != first && *(j - 1) > key) { *j = *(j - 1); --j; } *j = key; } }这个版本最明显的好处是支持任意可随机访问的容器类型,比如sortVector、sortArray都能调用。但要注意,它只能用于随机访问迭代器,因为代码里用到了first + 1和*(j - 1)。std::list的迭代器不支持这类操作,如果想让链表也用同一个模板,要么退回到 2.2 的手写链表版本,要么把比较和移动逻辑改成std::prev/std::next的反复调用,但那样效率会很差,因为每步移动都会变成线性时间。
从工程角度看,我的建议是:如果只是原地排序一个std::vector,直接用这个模板版;如果排序的是链表,单独写链表版;如果排序的是简单 POD 类型,可以不用std::move,直接拷贝就行。int、float、指针这些类型拷贝成本极低,用auto key = *i很自然。如果是 string 这样拷贝成本较高的类型,可以进一步考虑移动语义,但需要谨慎处理后续元素移动后的状态,这一块放到第 4 部分细说。
3. 复杂度与实测表现:O(n²)为什么还能排得飞快
3.1 最好、最坏、平均复杂度,一步一步推出来
插入排序的复杂度分析是所有排序算法里最容易理解的一个。
最好情况是输入已经升序。这时每一轮的 while 循环刚执行第一次比较就发现arr[j] <= key,于是立刻退出。每轮只比较 1 次,一共 n-1 轮,总比较次数是 n-1,时间复杂度 O(n)。注意移动次数也是 0,因为元素本来就在正确位置。
最坏情况是输入完全逆序。第 i 轮需要把 key 从位置 i 一路挪到位置 0,比较次数和移动次数都接近 i。把所有轮次加起来,约等于 1 + 2 + ... + (n-1) = n(n-1)/2,所以时间复杂度是 O(n²)。
平均情况在随机数据下,第 i 轮大约要向前移动一半的元素,也就是 i/2 次,总次数依然约 n(n-1)/4,复杂度仍然是 O(n²)。但如果数据里的逆序对数量很少,比如 100 个数据里只有 3 处乱序,插入排序的实际运行时间会更接近 O(n),因为每个乱序元素只需要移动很短的距离。
这个“运行时间与逆序对数量高度相关”的特性,是插入排序区别于选择排序和冒泡排序的核心卖点。它让插入排序在“几乎有序”的数据集上拥有天然优势,而这种数据集在实际业务里非常常见。
3.2 空间与缓存:O(1)空间背后的隐形优势
插入排序的空间复杂度是 O(1),不需要额外数组,不需要递归调用栈,这一点在嵌入式、内存受限场景下很有价值。但比空间优势更隐蔽的是它的缓存局部性。
现代 CPU 从内存读数据时,不是只读一个字节,而是一次读入一个缓存行(比如 64 字节)。插入排序在搬移元素时,访问的是数组中相邻的位置:前一个元素挪到后一个位置,接着再往前一个元素挪过来,整个过程对内存的访问模式是顺序的。顺序访问正是 CPU 缓存最喜欢的模式,预取器能提前把后面的数据加载到缓存里。
反观选择排序,每一轮要扫描整个未排序区间来找最小值,扫描范围是跳跃的、范围也不断变化,缓存命中率远不如插入排序。快速排序在分区时也有随机访问和递归跳跃,小规模数据下这些访问模式会让 CPU 频繁缓存失效。所以当 n 很小(比如几十个元素),或数组近乎有序时,插入排序的真实运行速度往往能把时间复杂度更优的算法按在地上摩擦。理论复杂度和实际性能从来不是一回事。
3.3 和主流排序同框对比:什么场景才轮到插入排序
把插入排序和其他排序放在同一张表里看,能更直观地理解它的定位。
| 算法 | 最好 | 平均 | 最坏 | 额外空间 | 稳定性 | 典型适用场景 |
|---|---|---|---|---|---|---|
| 插入排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 | 小规模、几乎有序、流式插入 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 | 减少交换次数、教学演示 |
| 冒泡排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 | 教学演示、简单实现 |
| 快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) | 不稳定 | 大规模乱序、通用排序 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 | 大规模、需要稳定 |
从表格可以总结出插入排序的三个甜点场景:数据量很小、数据已经接近有序、需要保持稳定且数据量不大。开发者聊排序时总倾向于“无脑 std::sort”,但其实在几十个元素的 vector 里,手写插入排序很可能会比 std::sort 更快。尤其是当你需要反复对一个小集合做增量排序时,插入排序的优势会被放大到非常明显。
4. 工程化改造:从旋转移动到二分查找,再到混合排序底座
4.1 用“移动”替代“交换”,减少无意义的赋值
很多新手写插入排序时会误写成“通过不断交换把 key 往左送”。比如:
for (int j = i; j > 0 && arr[j - 1] > arr[j]; --j) { std::swap(arr[j - 1], arr[j]); }这段代码逻辑上没有错误,但它把插入排序最精华的地方给丢了:每次交换需要三次赋值,而插入排序的标准写法只需要一次向前搬移。在基础版本里,key 已经保存在临时变量中,内层循环把每个较大的元素向右移一位,本质上是数组元素的一次覆盖赋值;最后再把 key 写回空位。总赋值次数大约是“移动次数 + 2”,远远低于交换写法的“3 × 移动次数”。
C++ 里这一点格外重要。如果数组元素是 int 或指针,三次赋值和一次赋值的差距可能只是几个时钟周期,但如果是 string 或自定义对象,赋值可能涉及拷贝构造函数、深拷贝、堆内存分配,能差出好几个数量级。所以在泛型实现中,我倾向于把 key 声明为auto key = *i,这样编译器会按容器元素类型正确拷贝或移动。如果元素类型支持移动语义,也可以显式auto key = std::move(*i),但要意识到移动后*i里的值可能被掏空,所以后续逻辑必须保证不再使用*i的旧值,只把它当作可以被覆盖的“空槽”。对这个话题,我的经验是:先写朴素的拷贝版本,用性能分析工具确认拷贝确实是瓶颈后,再优化成移动版本,不要一上来就把代码复杂化。
4.2 二分插入排序:减少比较次数,移动照旧
既然左边的“已排序前缀”是有序的,那么找插入位置这一步就不必从左往右线性扫描,而可以用二分查找。这就是二分插入排序(binary insertion sort)的核心思路。
void binaryInsertionSort(int arr[], int n) { for (int i = 1; i < n; ++i) { int key = arr[i]; int low = 0; int high = i; // 在 arr[0..i-1] 中找到第一个 > key 的位置 low while (low < high) { int mid = low + (high - low) / 2; if (arr[mid] > key) { high = mid; } else { low = mid + 1; } } // 从 low 到 i-1 的元素整体后移一位 for (int j = i; j > low; --j) { arr[j] = arr[j - 1]; } arr[low] = key; } }比较次数从 O(i) 降到了 O(log i),但如果元素移动成本很高,整体复杂度仍然是 O(n²),因为移动次数没有变。这个优化典型的收益场景是:元素比较代价极高而移动代价相对低,比如数组里存的是大字符串或者带复杂比较逻辑的对象,减少比较次数能省下大量时间。
这里有个容易在面试里说错的细节:二分插入排序还是稳定的吗?答案是可以做到稳定,只要在二分查找时把相等元素归入“向右继续找”的一侧。上面的代码里,当arr[mid] == key时走low = mid + 1,最后找到的位置是第一个大于 key 的元素之后,也就是所有相等元素之后。最终 key 被插入到相等元素后面,相对顺序没有被破坏,因此稳定。如果你把判断改成arr[mid] >= key时让 high 往左收,那相等元素会被插到前面,稳定性就丢了。
4.3 插入排序凭什么成为快排和归并排序的加速器
很多标准库的排序实现并不是只用一种算法,而是做“混合排序”。快速排序在递归划分时,如果发现当前区间长度很小,就停下来改用插入排序完成这个小段的排序。归并排序也经常这样做:当区间长度小于某个阈值时,直接调用插入排序。
原因我之前已经埋下伏笔:小规模数组排序时,递归调用、函数压栈、分区操作的开销可能比插入排序的 O(n²) 移动成本还要高。插入排序循环简单、顺序访问、没有额外栈开销,对小数组来说常数极小。业界常用的阈值在 16 到 32 之间,不同 CPU、不同编译优化级别下最优值会有差异,所以实际工程中应该做一次小实验来定。
如果你自己实现快速排序,可以这样加一个“哨兵优化”:
void quickSort(int arr[], int left, int right) { if (right - left + 1 <= 16) { insertionSort(arr + left, right - left + 1); return; } int pivot = partition(arr, left, right); quickSort(arr, left, pivot - 1); quickSort(arr, pivot + 1, right); }在快速排序主体框架不变的情况下,加上这个长度判断,排序性能可以明显提升。我当年在某个小型排序组件的实现中做过对比:纯快速排序排序 1000 个随机整数约耗时 X,混合插入排序后耗时明显下降,数据规模越小,收益越明显。插入排序作为“超级底层算法”的角色,是它工程价值中最容易被低估的一面。
5. 我在C++里踩过的插入排序的坑
5.1 边界条件翻车实录:越界与重复元素
插入排序的代码量很少,但越简单的代码越容易在边界上出问题。最常见的翻车点有三个。
第一个是 while 条件的顺序写反。正确写法必须把j >= 0放在前面,让短路求值保护数组访问。我见过太多新手把arr[j] > key写在前面,结果 j 减到 -1 时还在访问arr[-1]。有些编译器下程序会恰好没有崩溃,但结果完全随机,耗时好几个小时才排查出来。
第二个是循环结束后忘记给arr[j + 1] = key赋值。如果漏掉这句,数组会少一个元素,或者出现两个重复元素。完整的插入排序本质上是一个“取出 key,把一串元素右移,再把 key 塞回去”的过程,少了最后一步等于把 key 从数组中掏走了。很多教材为了简洁会把arr[j + 1] = key放在循环后,但初学者容易只盯着 while 而去掉这行,调试时最好用std::is_sorted配合多组随机数据验证。
第三个是使用int i与无符号 size_t 混用。std::vector<int>::size()返回的是无符号类型,如果写成for (int i = 1; i < vec.size(); ++i),编译器会警告有符号和无符号比较。更要命的是,如果内层循环用for (int j = i - 1; j >= 0; --j),j 是 int 时没问题,但一旦你图省事把 j 也声明成size_t,j-- 到 0 之后会下溢成一个大数,循环根本停不下来。解决方法是全部用带符号索引,或者干脆用 2.3 节的迭代器版本,从根源上避开索引类型问题。
5.2 理论复杂度与真实的运行曲线
理论复杂度容易让人产生“O(n²) 一定很慢”的错觉,但真实世界的性能曲线没有这么简单。我在实际项目里优化过一个接口,排序区间通常只有 20~50 个元素,数据基本有序,偶尔插入几条新消息。最初用的是std::sort,后来改成手写插入排序,接口延迟显著降低。原因很简单:数据几乎有序的时候,插入排序每个元素只需要比较一两次,总操作次数接近线性,而std::sort还要承担递归带来的函数调用开销。
反过来,我曾经也犯过一个错误:清理一批上万条原始数据时,用插入排序硬排,结果等了十几秒才完成。换成std::sort后毫秒级结束。这给我的教训是:在决定用哪种排序前,先量一下数据规模和逆序度。如果你没法确认数据是否近乎有序,那就先用标准库的std::sort,它内部有混合优化策略;只有当场景明确“小”或“近有序”时,插入排序才值得手写。
5.3 面试官最爱追问的几个插入排序问题
在面试场景中,插入排序几乎一定被问到,而且追问角度非常固定。我总结几个高频问题,方便你快速自检。
- 为什么插入排序比选择排序好?同样是最坏 O(n²),选择排序无论输入是什么情况都做同样多的比较,插入排序在最好情况和近乎有序时能降到 O(n);而且选择排序通常不稳地,插入排序稳定。
- 插入排序是稳定的吗?为什么?稳定。内层只移动严格大于 key 的元素,等于 key 的元素不会相对移动。
- 链表上如何实现插入排序?核心是维护一个有序新链表,依次从旧链表取节点插入,重点考察指针顺序是否丢失。
- 二分插入排序能把复杂度降到 O(n log n) 吗?不能。比较次数降低到 O(n log n),但移动次数仍然是 O(n²),所以总体渐进复杂度不变。
- 当 n 等于 10000 且数组近乎有序时,你会选什么排序?这时应该考虑插入排序,因为逆序对很少,实际运行时间接近 O(n)。如果无法保证近乎有序,则用快速排序或归并排序。
这些问题本身不难,但很容易在细节上翻车。尤其是“稳定、链表、二分插入”这三个关键词,面试官喜欢融合起来追问,建议自己写一遍代码再复述思路。
6. 实操心法:在什么时候放心使用插入排序
6.1 我的选择清单:规模、有序度、稳定性
这些年我做技术决策时,总结了一张非常简单的“插入排序使用清单”。数据量小于 50,且不涉及频繁扩容:直接手写插入排序,它大概率比std::sort更快;数据量小于 1000,但已知数据几乎有序,也就是逆序对很少,插入排序也是不错的选择;如果数据量已经上万,那我不会碰手写插入排序,除非场景特殊到数据流式增量且每次只插入一条。
稳定性也是一个重要考虑点。如果排序前后需要保留相同 key 元素的相对顺序,插入排序天然稳定,成本又低,很适合。大规模且要求稳定的场景,当然归并排序更合适,但归并排序需要额外空间,如果你的内存受限,那小规模插入排序是更现实的选择。
还有一条原则:不要迷信复杂度。我经常看到有人对 100 个元素的数据说“插入排序是 O(n²),所以慢”,这是没必要的。复杂度描述的是趋势,不是某个特定数据量下的绝对速度。真要在工程中做排序热点优化,正确做法是先写一个朴素插入排序,再写一个标准库排序,用同一份数据跑基准测试,以测试结果说话。
6.2 从插入排序延伸出去:希尔排序和排序框架
插入排序最成功的延伸之一是希尔排序(Shell Sort)。它先把数组按照某个增量分组,在每一组内做插入排序;然后逐步缩小增量,直到增量为 1 时做最后一次普通插入排序。插入排序的特性是“对近乎有序的数据快”,希尔排序通过分组让数据在宏观上逐渐变得有序,从而大幅减少最后的逆序对数量,整体复杂度可以提升到接近 O(n log n)。理解了插入排序,看希尔排序会非常轻松。
另一个重要的延伸是各类混合排序。像 TimSort 这类算法,会把数组先切分成若干“run”(连续有序段),然后用插入排序来扩展这些 run,最后归并这些 run。C++ 标准库里的std::sort既然是内省排序,内部也常常使用插入排序处理小片段。所以哪怕你已经在用标准库,插入排序的思想仍然在暗处起关键作用。
我这几年做性能调优,最深的感受是:别因为算法课的渐进复杂度而死记硬背。插入排序常被低估,可是当排序区间只有十几个元素,它就是比快排快。所以我现在写排序相关代码,遇到小型切片和近乎有序的流式数据都会直接使用插入排序,而不是自作聪明地把它替换成std::sort。最后分享一个调试小技巧:写完插入排序后,用std::is_sorted配合随机测试跑上万组数据,把边界条件(空数组、单元素、逆序、大量重复)都测一遍,比起逐行断点效率高得多。