
写了这么多年C我一直觉得插入排序是个“被低估”的算法。不是因为它的复杂度多漂亮而是因为它足够简单、稳定在小规模数据或近乎有序的数据里表现特别好。直到有一次我在维护一个老项目时发现排序函数成了热点做了很多次测量之后我把目光放到了那个不起眼的memmove上——用它对插入排序做了一次“无侵入式”的优化效果比预期好得多。这篇文章就把这次优化的完整过程讲清楚插入排序的瓶颈到底在哪memmove为什么能提速具体怎么改代码实测数据如何以及我踩过的几个坑。既适合刚接触排序算法的同学搞清楚原理也适合正在做性能优化的老手参考。1. 先看瓶颈教科书插入排序慢在哪1.1 教科书版本的插入排序几乎所有学过数据结构的人写出来的第一版插入排序都是这样的void insertion_sort_naive(int *arr, int n) { 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; } }思路就是打扑克牌时整理手牌每次抽出一张牌从右往左找合适位置然后把比它大的牌统统往右挪一格。逻辑没有任何问题代码也几乎是所有面试题的标准答案。但正是这段“标准答案”在数据量稍微大一点之后性能会迅速崩掉。要理解为什么我们得先看它的复杂度外层循环对每个元素跑一次内层循环在每个元素上可能向前移动多次。最坏情况下输入是逆序数组内层循环加起来要执行 n(n-1)/2 次移动时间复杂度 O(n²)。这个复杂度来自“比较”和“移动”两个动作而大多数人下意识只关注比较次数忽略了移动的代价。1.2 瓶颈分析数据搬移成了隐藏的大头我把这段代码拆开看内层循环每一轮做的三件事判断j 0 arr[j] key这是一个比较执行arr[j 1] arr[j]这是单元素赋值执行j--这是指针/下标更新和跳转。在随机数据的场景下元素的赋值搬移次数和比较次数是同一个数量级都是 O(n²)。单次赋值本身很快但问题在于它被塞进了一个带条件判断的循环里分支是否跳转取决于数据现代 CPU 的分支预测器在这种随机模式下经常猜错一猜错就要回滚流水线、重新执行指令这个代价比赋值本身大得多。我实际用perf stat看过一段 10 万元素的随机数据排序发现内层循环的 branch-misses 事件高得吓人。也就是说你真的不是在“快”地搬数据而是每搬一个元素就被 CPU “罚”一次。这是插入排序在大数据量下性能崩掉的真正原因。另外还有一个容易被忽略的细节这种“一次挪一个元素”的搬移方式对现代 CPU 的向量寄存器、内存带宽都利用不足。你明明可以一次搬 16 字节、32 字节结果程序一次只搬 4 字节。从这个角度看插入排序的核心瓶颈已经不再是“算法复杂度”本身而是“搬移数据的实现方式”太粗糙了。2. memmove 的原理它为什么比循环赋值快2.1 memmove 底层在干嘛memmove是 C 标准库里的内存搬移函数声明在string.h中。它做的事情就是把一段连续内存里的数据搬到另一段连续内存里。函数原型如下void *memmove(void *dest, const void *src, size_t n);很多人觉得memmove就是“比 memcpy 更安全的复制”其实这是把它想简单了。现代 C 标准库的memmove实现是一套根据数据长度、对齐情况、CPU 特性动态选择策略的“多级分发器”。拿 glibc 来说它对memmove内部做了一系列长度分级小尺寸时用简单的 32 位或 64 位字长循环中等尺寸时换成 SSE2 的movupd/movdqu一条指令搬 16 字节甚至 AVX2 的vmovdqu一条指令搬 32 字节超过一定阈值的大块数据在有 ERMSEnhanced REP MOVSB特性的 CPU 上直接交给硬件指令rep movsb处理。也就是说当你写一个 10 万字节的搬移时memmove可能一行汇编就能在几个时钟周期内完成主搬移。相比之下手写循环赋值从比较、跳转、地址计算到单条 store 指令每一步都是开销。这就像用一辆小推车一趟一趟运货物和直接叫一辆大卡车一次运走的区别。2.2 为什么必须是 memmove 而不是 memcpy这是这个优化方案里非常关键的一个点。很多人一看到“搬内存”就顺手写成memcpy结果数据莫名其妙出错。原因在于插入排序的场景中源区间和目标区间是重叠的。举个例子如果要把数组中[pos, i)这 k 个元素整体右移一位目标是放到[pos1, i1)。那么源内存块[pos, i)和目标内存块[pos1, i1)的交叉区域是[pos1, i)重叠长度整整有 k-1 个元素。memcpy的标准语义是不处理重叠的。在重叠的情况下它的行为是未定义的。很多平台上的memcpy为了速度会从前往后复制这样一旦源数据的前半部分被目标数据覆盖后面还没复制的内容就已经被破坏结果就是数组里出现一堆重复或垃圾数据。而memmove是专门为重叠而生的。它的实现会先判断目标地址和源地址的相对位置如果 dest 在 src 前面就从前向后复制如果 dest 在 src 后面就从后向前复制。这样做是为了保证“源数据在被覆盖之前”永远已经完成了读取。在插入排序的场景里dest 在 src 的后面所以memmove会从区间的尾部向头部搬移这正好是安全的。2.3 编译器与 CPU 的协同ERMS 与向量化你可能会想既然 memmove 底层也是按块搬那编译器开启-O3之后我手写的循环赋值不也会自动向量化吗是不是差距就没那么大了实测下来区别仍然明显。一方面编译器自动向量化循环时要处理数据对齐、剩余尾数、可能的别名aliasing问题生成代码往往多加很多判断和回退路径效率不如精心写好的库函数。另一方面memmove走的是 glibc 的 IFUNC 机制运行时会根据当前 CPU 支持的指令集动态选择最优实现。比如支持 AVX-512 的 CPU 上它会用 512 位的搬移指令支持 ERMS 的 CPU 上大块内存直接交给rep movsb硬件指令。这些能力是编译器在你普通的 for 循环里很难自动发挥出来的。rep movsb这种指令的原理也很有意思它在微码层面由硬件完成“读一段、写一段、更新指针、判断结束”的循环意味着循环控制和内存访问都由 CPU 内核直接调度流水线利用率极高尤其适合大块连续内存搬运。这也是为什么用memmove做插入排序的元素搬移能真正把“数据搬移”这个环节的常数因子压到极低。3. 实战改造二分查找 memmove 的插入排序3.1 第一步用二分查找定位插入点经典插入排序找插入点时是从右往左线性比较把“查找位置”和“移动元素”混在一个循环里完成。改用memmove之后我们得把这两个动作拆开先在已排序区间[0, i)中找到 key 应该插入的位置 pos再一次性把[pos, i)整体右移一格最后把 key 写入arr[pos]。查找位置这一步可以顺便用一个优化因为[0, i)本身是有序的完全可以用二分查找把查找的复杂度从 O(n) 降为 O(log n)。这会让整个排序的比较次数大幅下降尤其是对于逆序数据效果非常明显。需要注意的是为了让插入排序保持稳定性相等元素的相对顺序不变我们要找的不是第一个“大于等于”key 的位置而是第一个“严格大于”key 的位置。这样 key 会落在所有相等元素后面顺序不会乱。/* 返回 [0, len) 中第一个大于 key 的位置 */ static int upper_bound_int(const int *arr, int len, int key) { int lo 0, hi len; while (lo hi) { int mid lo (hi - lo) / 2; if (arr[mid] key) { lo mid 1; /* 相等也向右走保证稳定性 */ } else { hi mid; } } return lo; }这段代码有一个小细节建议大家留意int mid lo (hi - lo) / 2;而不是(lo hi) / 2。前者避免了两个 int 相加可能溢出当区间很大时的问题是工程上推荐的习惯写法。3.2 第二步一次 memmove 完成区间搬移找到pos后如果pos i说明 key 已经在该在的位置什么都不用做。否则要把[pos, i)这一整段右移一格正好对应目标地址arr[pos 1]。这一步的搬移长度是(i - pos) * sizeof(int)注意这里乘上元素宽度因为memmove按字节数来算。直接写i - pos会让你只搬了四分之一的数据这也是最常见的低级错误之一。memmove(arr[pos 1], arr[pos], (size_t)(i - pos) * sizeof(int));当dst地址大于src地址时memmove内部会从后往前复制因此这段重叠内存搬移是安全的。整个过程只产生一次函数调用不会有内层循环也不会有分支预测失败的问题。3.3 完整代码与逐行解读把上面两个思路合起来完整的优化版插入排序就是这样#include string.h static int upper_bound_int(const int *arr, int len, int key) { int lo 0, hi len; while (lo hi) { int mid lo (hi - lo) / 2; if (arr[mid] key) { lo mid 1; } else { hi mid; } } return lo; } void insertion_sort_memmove(int *arr, int n) { for (int i 1; i n; i) { int key arr[i]; int pos upper_bound_int(arr, i, key); if (pos i) { continue; } memmove(arr[pos 1], arr[pos], (size_t)(i - pos) * sizeof(int)); arr[pos] key; } }整个过程拆开来看key arr[i]保存当前待插入元素因为后面搬移会覆盖它的旧位置upper_bound_int在有序区间[0, i)里定位插入点memmove把[pos, i)这 k 个元素整体后移一位arr[pos] key把 key 放回正确位置。外层循环从i 1开始每次确保[0, i]有序。复杂度上比较次数变成了 O(n log n)搬移次数还是 O(n²) 量级但每次搬移的常数极小。严格来说这仍然不是 O(n log n) 排序但在中小规模数据和特定数据分布下它的实际表现可以逼近高级排序算法。3.4 边界条件和隐藏陷阱第一千万不能图省事用memcpy替换memmove。前面说过这种情况下源和目的区间重叠memcpy会带来未定义行为严重时直接破坏数据。第二memmove的第三个参数n是字节数不是元素个数。数组类型从int换成double或结构体时记得修改乘的宽度。比如搬结构体数组时用(size_t)(i - pos) * sizeof(MyStruct)这里sizeof算出来的是整个结构体的字节数。第三注意pos i的短路判断。这个判断不只是微优化它能避免memmove在长度为 0 时做一次无意义的调用。虽然标准允许memmove(dst, src, 0)即使在指针无效时也是安全的C 标准规定 n 为 0 时行为安全但部分静态检查工具仍会报警但还是建议显式跳过。第四upper_bound_int里的比较条件是不是。如果误写成返回的就是“第一个大于等于 key”的位置等于把 key 插到了已有相等元素的前面破坏稳定性。4. 实测数据优化到底值不值4.1 测试环境与基准方法为了让数据有说服力我把测试方法也一并说明。我用的是 GCC 9.4编译参数-O2测试机器是一颗 Intel Xeon 的普通服务器 CPU内存为 DDR4 2666MHz。测试时把待排序数组预先生成好分别用insertion_sort_naive和insertion_sort_memmove跑相同的数据副本用clock_gettime(CLOCK_MONOTONIC, ...)计时每组测 3 次取中位数避免偶发抖动影响结果。我准备了三类数据完全随机用rand()生成覆盖[0, 100000)逆序从 n 递减到 1几乎有序先生成一个有序数组再随机挑出 1% 的元素与后面的另一个随机位置交换模拟“大数组里个别元素乱序”的真实场景。4.2 不同数据规模与数据分布的表现先看完全随机数据的表现数据规模 n传统版耗时memmove版耗时相对提升1,0000.00021s0.00019s约 1.1 倍10,0000.079s0.029s约 2.7 倍50,0001.94s0.73s约 2.7 倍100,0008.67s3.21s约 2.7 倍这个结果很典型当 n 较小1000 级别时函数调用、二分查找的开销抵消了搬移效率的提升两者几乎持平一旦 n 达到万元以上memmove版的优势开始显现稳定在 2.62.8 倍左右。这个倍数基本符合预期——插入排序的主要开销在搬移搬移的常数被memmove压低了但算法复杂度没有变所以倍数稳定在一个区间不会像真正的 O(n log n) 算法那样随 n 增长而拉开指数级差距。再看逆序数据有一个值得注意的变化。数据规模 n传统版耗时memmove版耗时相对提升10,0000.115s0.031s约 3.7 倍50,0003.87s1.52s约 2.5 倍逆序时传统版的比较和搬移都拉满而且内层循环的每次判断都是“分支失败”的重灾区。memmove版因为提前用二分把位置算好了比较次数从 O(n²) 降到了 O(n log n)搬移路径简化所以在 1 万元素时提升明显能到 3.7 倍。但数据量大到 5 万以后搬移开销还是占主导所以倍数回落到 2.5 倍左右。最后看看“几乎有序”的数据这个才是插入排序真正的天下数据规模 n传统版耗时memmove版耗时相对提升1,000,0000.183s0.052s约 3.5 倍几乎有序时大多数元素已经在正确位置附近插入排序的移动次数不多但每移动一次涉及的数据量不小。memmove把每一次“长距离搬运”的代价压得非常低加上二分查找几乎一两次就定位成功整体提升反而比随机数据更明显。4.3 为什么有的场景下优化不明显实话实说这个优化在某些场景下并不划算。最典型的是小数组。当 n 100 甚至 n 16 时二分查找的逻辑、函数调用压栈、memmove内部的长度判断每一项开销都可能比“逐元素搬两下”更大。这种情况下老老实实写教科书循环反而是最优解。还有一类场景是元素类型极小且搬移数量极少的“基本有序但 n 很大”的数据。虽然 memmove 版仍然快但传统版的比较次数往往只有 1 到 2 次就退出内层循环搬移开销占比很低二分查找反而增加了一些额外比较。整体耗时差距会缩小甚至在某些微架构上出现小幅回退这也是正常的。优化这件事永远要看数据特征。5. 踩坑记录与使用建议5.1 常见坑参数写反、宽度算错、小数组负优化我在实际测试中真踩过一个让人摸不着头脑的坑把memmove(arr[pos 1], arr[pos], ...)写成了memmove(arr[pos], arr[pos 1], ...)方向反了。表面看起来都是“把一段搬到旁边”但方向弄反之后源数据被覆盖搬出来的像一堆垃圾值数组最后全是错误数据。用memmove的代码里函数调用本身不告诉你参数用反了只能靠数据校验和单步调试发现建议大家写完先跑一组逆序小数据人工验证。第二个高频坑就是宽度问题。memmove的第三个参数是字节和memcpy一样。我不止一次看到有人写memmove(dst, src, i - pos)直接用元素个数当字节数。int 数组算出来只搬了四分之一结果数组后半部分完全没搬排序结果直接乱套。无论数组元素是int、double还是结构体都务必乘上对应的sizeof。第三个问题严格说是策略问题数组太小就别用。我自己定的经验线是元素个数少于 16 时直接用原始循环版本如果元素本身是几十字节的大型结构体即使只有 8 个元素memmove也值得用因为一次能搬一整块结构体比逐字段赋值可靠得多。5.2 一个容易忽略的稳定性问题很多人改造插入排序时会顺手把查找条件写成arr[mid] key觉得“小于就右移大于等于就左移”也能找到位置。这样做确实能找到插入点但返回的是第一个大于等于 key 的位置等于把相等元素挤到后面去了排序就不再稳定。工程上这一点很致命。比如你有一批订单先按时间排好序现在要按金额排序但保持同一金额下时间顺序不变那就必须用稳定排序。插入排序本来最大的卖点就是稳定改造时千万别把这一点弄丢。正确做法就是我在 3.1 节给的版本比较条件是时向右走保证 key 落到所有相等元素之后。5.3 什么情况下该用这招根据 my 实际经验这套“二分定位 memmove 搬移”的插入排序在三种场景下最值得用维护一个持续增长的有序序列比如游戏中排行榜、内存里的时间序日志索引。每次只插入一个新元素序列已经基本有序插入排序配合 memmove 几乎是最合适的方案。对近有序的大数组做增量修正。比如缓存列表里偶尔有几个元素乱了用这个版本把乱序元素重新归位比整体调用快排的成本低得多。嵌入式或底层系统里元素类型不是 int而是较大的结构体。memmove一次搬运连续结构体内存比逐字段复制要快一到两个数量级。反过来如果数据规模很大、又是随机乱序不要指望这个优化拯救 O(n²) 复杂度。n 超过几十万、乱序严重时还是要归并排序或快排。memmove能做的只是把插入排序的“常数”拉低让它能多扛一段而不是改变算法等级。5.4 我的最终建议如果你真要把这个方案用到生产代码里我建议做一个小封装在#include string.h之上写两个函数分别对应小型数据和优化版对外只暴露一个排序入口由 n 的数据规模决定调度。避免在代码库里到处散落“有时候用 memmove 版本、有时候用循环版本”的选择逻辑。另外实测时记得开启优化编译。我之前用-O0对比过结果memmove版快得离谱一度以为是自己发现了什么神奇算法。实际上-O0下手写循环极其低效参考意义不大。真正项目里请用-O2或-O3来评估收益得到的结论才靠谱。从我个人经验来说这类“底层库函数参与算法微调”的思路在内存搬移、字符串处理、网络收包等场景里都是通用的。以后遇到性能热点别急着推翻算法先看看瓶颈是不是“低效的逐元素循环”。往往把一次循环换成memmove或memcpy的批量操作就能得到非常可观的提升。