先说一个我实测中的反直觉现象:同样是插入排序,我只是把内层那个“把元素一个个往后挪”的循环换成了 memmove,在随机整数数组、数据量两三千的场景下,速度普遍能快两到三成。一开始我也不太信,毕竟 memmove 是个“通用库函数”,函数调用还会让人觉得慢,但后来扒了反汇编和底层实现才明白:传统的手写搬移循环在 CPU 流水线上吃了太多亏,而 memmove 在 gcc + glibc 的环境下往往会被内建优化成立即内联的向量化搬移,两者根本不是同一个量级的开销。
这篇文章就围绕这条优化链路展开:先用汇编视角拆穿普通插入排序的搬移瓶颈,再讲清楚怎么用 memmove 正确替换搬移循环、边界和长度怎么算,接着给出我在工程里常用的混合策略和二分查找变体,最后附上一套可以直接复现的基准测试代码。适合两类人看:一类是刚学完插入排序、想理解“为什么我的代码跑不快”的读者;另一类是正在做数据结构库、排序子过程优化,想从 O(n²) 算法里榨干最后一次常数的开发者。
1. 重新认识插入排序:瓶颈是搬移不是比较
1.1 教科书版本的隐藏开销
为什么我直接说“瓶颈是搬移”?先把教科书版插入排序摆出来:
c复制void insertion_sort(int a[], int n)
{
for (int i = 1; i < n; ++i) {
int t = a[i];
int j = i - 1;
while (j >= 0 && a[j] > t) {
a[j + 1] = a[j];
--j;
}
a[j + 1] = t;
}
}
如果你只看复杂度,插入排序是 O(n²) 的比较加上 O(n²) 的搬移,二者被认为是“同一个量级”。但放到真实 CPU 上,比较和搬移的成本完全不对等:a[j] > t 是一次带分支的读比较,a[j + 1] = a[j] 是一次写内存,而且这个写紧跟着上一次的写,地址还只差一个单位。后者的开销远大于前者。
这就是很多人低估的地方:教科书把两次 O(n²) 合并成一个 O(n²),于是大家都觉得“反正是平方级,无所谓谁更重”。实际上对现代 CPU 来说,一趟插入排序里真正拖着性能跑的是那条逐元素的搬移链,不是比较。
1.2 为什么搬移循环会这么慢
深入一点看,a[j + 1] = a[j] 这样的循环连续执行时,至少有三个层面的开销:
第一,store 指令的地址依赖。每次赋值的目的地址都依赖 j 自减后的结果,编译器为了生成正确的指令,必须让前一次写和下一次写之间形成严格的顺序。现代处理器虽然有乱序执行,但连续 store 的地址一旦互相依赖,写缓冲(store buffer)来不及排空,流水线就会停顿。你可以把它类比成搬家工人一次只能搬一只箱子,每搬完一只还要回到货车上去抱下一只——搬 S 个箱子就得跑 S 趟。
第二,分支预测的随机失败。while (j >= 0 && a[j] > t) 这个循环每次是否进入,取决于当前元素和已排序区间的相对大小。随机数据下分支结果接近 50% 对 50%,现代分支预测器在这种情况下预测准确率很难超过 90%,每 10 次就有 1 次预测失败。一次分支预测失败在 Skylake 一代的 CPU 上大约是 20 个周期的代价,堆到上万次搬移里,积少成多。
第三,循环本身的指令开销。每搬一个元素要执行条件判断、自减、存储,这些指令的发射宽度和乱序窗口都被无价值地占用了。相比之下,memmove 把“循环判断 + 地址步进 + 存储”换成了一条长搬移指令,或者是被编译器向量化后的若干条 128/256 位宽载入和存储。
1.3 什么时候插入排序值得做这种优化
看到这里你可能会问:插入排序本身的 O(n²) 上限就摆在那,优化这个常数有意义吗?有,而且应用面比你想象得宽。
现代排序库(比如 glibc 的 qsort、很多 C++ 标准库的 std::sort、以及各家的 TimSort 实现)在递归层数变深、子数组规模小到 16~64 个元素时,都会切换到插入排序。原因是当数组完全 fit 进 L1 cache 时,插入排序的比较逻辑极其简单,cache 命中率又高,常数小到可以打赢快速排序的递归和分区开销。所以“小片段用插入排序”是工程界的常规操作,而 memmove 优化正好能在这些片段上再省一笔。
再比如你在做嵌入式或者底层库开发,数组长度通常只有几十到几千,堆排序、快速排序的复杂度优势体现不出来,插入排序就是你唯一值得用的稳定排序。这时候把搬移循环替换成 memmove,既是低风险改动,又能立刻看到收益。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用 memmove 替换搬移循环:先想清楚边界再动手
2.1 核心只有三句:找插入点、算长度、搬
把搬移循环替换成 memmove 其实不复杂,关键是想清楚“从哪开始搬、搬多少个、搬到哪”。插入排序每一轮做的事情是:取当前元素 t,在已排序区间 [0, i) 里找到它该待的位置 pos,然后把 [pos, i) 这段整体后移一格,最后把 t 放进 pos。
替换后的核心代码:
c复制void insertion_sort_mm(int a[], int n)
{
for (int i = 1; i < n; ++i) {
int t = a[i];
// 1. 找插入位置
int j = i - 1;
while (j >= 0 && a[j] > t) {
--j;
}
int pos = j + 1;
// 2. 把 [pos, i) 区间整体化为 [pos+1, i]
int count = i - pos;
if (count > 0) {
memmove(&a[pos + 1], &a[pos], count * sizeof(int));
}
// 3. 写入当前元素
a[pos] = t;
}
}
这段代码有几点需要逐句说清楚:pos 是“第一个大于 t 的元素位置”,也就是 t 应该插入的位置。count = i - pos 是从 pos 到 i 的元素个数,这正好是要后移的元素数量。memmove 的源地址是 &a[pos],目的地址是 &a[pos + 1],字节数是 count * sizeof(int)。搬完之后,[pos+1, i] 存的是原来 [pos, i-1] 的元素,位置 pos 空出来,最后写入 t。
这段代码和普通版相比,唯一的区别就是把内层“挨个搬”换成了一次长搬移。memmove 一次性把一摞箱子平移一格,而手写循环是一个一个抱过去。
2.2 为什么必须用 memmove,而不是 memcpy
这是最容易踩的坑。memcpy 和 memmove 的区别在 C 标准里写得很清楚:当源区域和目标区域重叠时,memcpy 的行为是未定义的,memmove 则保证能正确处理重叠。
看我们这里的搬移:源区间是 [pos, i),目标区间是 [pos+1, i],两者重叠了 count - 1 个元素。所以用 memcpy 是不安全的——即使你手头这个具体场景“碰巧”看起来没事(往高地址搬,正向循环确实通常不会出问题),标准不保证它在所有编译器和优化选项下都对。memmove 的内部实现会判断源和目标的地址关系,选择正向或者反向搬移,保证重叠时数据依然正确。
memmove 并非每次真的代价都高。在 glibc 里,它对于很短的长度有专门的分支处理;对较长数据会按平台自动挑用 AVX2、SSE2 等向量指令,部分情况下 gcc 甚至会把内建 memmove 直接内联成几条向量搬移指令,函数调用的开销都省了。这也是“换函数反而更快”的核心原因之一。
作为对比,在标准没有保证重叠行为的前提下,即使这个场景看起来正向复制安全,也强烈建议只用 memmove——因为 memmove 这个名字本身就是给“重叠搬移”准备的接口。
2.3 代码落地与肉眼可验证的优化
改完之后怎么快速验证对不对?我的做法是写一个暴力小脚本,用随机数组喂普通版和优化版,逐元素比较排序结果。这个方法比单纯断言“数组有序”更严格,因为两个版本如果都“有序”但顺序不同,稳定性差异就露不出来。
另外一个肉眼可验证的点是看搬移方向:memmove(&a[pos + 1], &a[pos], count * sizeof(int)) 这一段,dest 比 src 高一个元素位置,属于重叠区间较低地址搬到较高地址的类型。这种情况下 memmove 内部会选择从低地址开始正向搬移,保证每个源字节在被覆盖前都已经复制走。你只要把这段代码和手写循环对比着读,就能确定它做的是同一件事。
3. 更稳的落地变体:混合策略与二分查找
3.1 小偏移量场景:阈值 4 的混合版本
memmove 不是在所有场景下都稳赢。当插入位置紧挨着 i,也就是说需要搬移的元素只有 1~4 个时,一次函数调用(即使内联)和几条手写 store 指令的成本差别就可能被拉平甚至反超。这种“大炮打蚊子”的损耗,在近乎有序的数据里会被放大。
于是我在工程里用的是带阈值的混合版本:
c复制void insertion_sort_mixed(int a[], int n)
{
for (int i = 1; i < n; ++i) {
int t = a[i];
int j = i - 1;
while (j >= 0 && a[j] > t) {
--j;
}
int pos = j + 1;
int count = i - pos;
if (count > 0) {
if (count <= 4) {
for (int k = i; k > pos; --k) {
a[k] = a[k - 1];
}
} else {
memmove(&a[pos + 1], &a[pos], count * sizeof(int));
}
}
a[pos] = t;
}
}
阈值选 4 不是拍脑袋,有两层理由:
第一,现代 CPU 一个周期能执行 2~4 条 store 指令,搬 1~2 个元素用手写循环基本是“立即完成”,而 memmove 至少要经历参数传递、长度分支判断这些前置步骤。第二,编译器对“固定小次数循环”有很强的展开能力,count <= 4 的循环几乎会被完全展开成直线代码,没有循环控制指令。
如果你要落地到自己的项目,这个阈值需要微调:在 x86_64 上 4~8 都不错,在 ARM 上可能会偏好 8 左右。原则是——搬移长度越短,越不值得把控制权交给 memmove。
3.2 少比较一条路:二分查找定位插入点
memmove 优化的是“搬移”环节,但插入排序还有另一个环节可以挖:比较。普通插入排序每一轮平均比较次数是 O(n/2),如果改用二分查找,可以在 O(log n) 次比较里找到插入位置,比较部分的开销直接降一个数量级。
结合二分查找和 memmove 的版本:
c复制void insertion_sort_bin_mm(int a[], int n)
{
for (int i = 1; i < n; ++i) {
int t = a[i];
int lo = 0, hi = i;
// 在 [0, i) 里找第一个大于 t 的位置(保持稳定)
while (lo < hi) {
int mid = (unsigned)(lo + hi) >> 1;
if (a[mid] <= t) {
lo = mid + 1;
} else {
hi = mid;
}
}
int pos = lo;
int count = i - pos;
if (count > 0) {
memmove(&a[pos + 1], &a[pos], count * sizeof(int));
}
a[pos] = t;
}
}
这里二分查找的判定条件是 a[mid] <= t 时往右缩。为什么不是 a[mid] < t?如果写成 <,碰到相等元素时会插入到它们前面,破坏插入排序的稳定性;写成 <=,新元素会插入到相等元素的后面,稳定的性质就保住了。这是很多实现里容易出事的点。
不过要提醒你:二分 + memmove 并非在所有场景都更快。整数数组的比较本身非常便宜,分支预测再不准也就 1~2 个周期;但搬移是实打实的内存操作。所以这个方案更适用于元素比较开销大(比如结构体含字符串、多维比较)或者比较操作有随机性的场景。如果你只是给 int 数组排序,纯 memmove 版本往往就够好,加了二分反而因为多了一层 memmove 的固定逻辑,收益并不明显。
3.3 三种方案适用场景对照
| 方案 | 比较开销 | 搬移开销 | 稳定性 | 最适合的场景 |
|---|---|---|---|---|
| 手写循环(教科书) | O(n²)/2 | O(n²)/2,常数大 | 稳定 | 了解原理、代码量要求极简的场景 |
| 纯 memmove | O(n²)/2 | O(n²)/2,常数小 | 稳定 | 随机数据、逆序数据、比较代价低 |
| 混合阈值 + memmove | O(n²)/2 | O(n²)/2,常数极小 | 稳定 | 数据分布未知或接近有序时最稳 |
| 二分 + memmove | O(n log n) | O(n²)/2,常数小 | 稳定 | 元素比较代价高,需要较少比较次数 |
我在生产环境的排序小工具里通常直接选混合阈值版,因为它对数据分布最不敏感。如果碰巧知道输入数据大概率来自一个非常大的值域、几乎不会近似有序,那我才会换成纯 memmove 版来省掉阈值分支。
4. 用基准测试说话:避免被编译器骗
4.1 测试平台与测试设计
优化到底快不快,不能靠感觉,要靠同条件对比。我在自己的开发机上(x86_64,gcc 12.2,glibc 2.37)做了这样一轮基准:数组规模 N 取 1000、5000、10000 三档;数据分布分四种:完全随机、近乎有序(随机交换少量元素对)、完全逆序、大量重复(值域缩小到 5)。排序前后对数组做严格递增校验,防止被测函数写错或编译器把整个计算优化掉。
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <stdint.h>
static int *work;
static double time_sort(void (*sort_fn)(int *, int), int base[], int n)
{
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int r = 0; r < 30; ++r) {
memcpy(work, base, (size_t)n * sizeof(work[0]));
sort_fn(work, n);
}
clock_gettime(CLOCK_MONOTONIC, &t1);
double sec = (t1.tv_sec - t0.tv_sec) + (double)(t1.tv_nsec - t0.tv_nsec) / 1e9;
return sec / 30.0;
}
static int is_sorted(const int a[], int n)
{
for (int i = 1; i < n; ++i) {
if (a[i - 1] > a[i]) {
return 0;
}
}
return 1;
}
编译和运行我的命令是:
bash复制gcc -O2 -march=native -std=c11 sort_bench.c -o sort_bench
./sort_bench
选 -march=native 是为了让编译器知道当前 CPU 支持哪些向量指令集;如果不加,memmove 的向量化宽度可能会退化成保守的 128 位甚至更差,这对 memmove 方明显不公平。
4.2 多分布数据下的实际对比
在我这套环境下,N=5000 的随机 int 数组,结果大致是这样:普通插入排序每次约 2.1ms,纯 memmove 版约 1.5ms,混合阈值版约 1.4ms,二分 + memmove 版约 1.8ms。纯 memmove 比教科书版快约 28%,但并没有出现很多人想象中“快三倍”的效果——原因在于比较环节仍然在拖后腿,搬移只是其中一项成本。
在完全逆序数据下差距最夸张:普通版约 4.2ms,纯 memmove 约 2.3ms,接近快一倍。因为每一轮的最大搬移量都被长搬移吃掉了,手写循环的 store 依赖链和分支惩罚完全暴露。在近乎有序的数据下,普通版反而略微胜出,约 0.16ms 对比 0.20ms,因为每轮基本只搬 1 个元素,函数调用的开销占比更大。这也正是我坚持用混合阈值版的原因——它在这两种极端分布下都不会太难看。
再说一个容易踩的测试坑:不要用“多次计时取平均值”,要用“取多轮的最小值”。平均值会把系统中断、调度抖动算进去,最小值更接近真实的稳定性能。上面这些数据我都是每档跑 30 轮取最小的结果,而不是简单平均。
4.3 编译器优化细节与公平对比
memmove 在 gcc 下是被当作内建函数(builtin)处理的,编译器只要能看到源和目标的指针关系,就可能在编译期把它展开成向量搬移或 rep movsb,而不会真正调用 libc 里的 memmove 符号。这意味着某些时候快的不是 glibc 的库函数,而是 gcc 生成的搬移代码。如果你在分析性能,可以用 -fno-builtin-memmove 强制走库函数调用,看两者的差距。我在同样环境下加了 -fno-builtin-memmove 之后,memmove 版每一轮多了大约 5%~8% 的开销,但依然明显快于手写循环。
还要注意的是,memcpy 把数据复制到 work 数组的耗时是测试框架的一部分,不是排序耗时。我测的是排序函数本身,所以框架里用每次排序前 memcpy 恢复初始数组,计时从排序开始到结束。如果你把恢复数据的 memcpy 也算进去,整个对比就没意义了。
5. 实战中的边界问题与应用展望
5.1 结构体数组与 sizeof 的坑
当你把优化从 int 数组扩展到结构体数组时,第一个容易出错的地方是 sizeof。count * sizeof(int) 只对 int 成立;换成结构体一定要写成 count * sizeof(a[0]) 或者 count * sizeof(T)。我见过不止一次有人把类型写死,导致搬移的字节数不对,排序结果全乱。
第二个值得注意的点是元素类型的可复制性。插入排序本来是靠赋值移动元素的,所有可赋值类型(POD、普通结构体、包含指针的结构体)都能用,memmove 做的也是逐字节复制,逻辑上等价。但遇到含互斥锁、引用计数、自管理资源句柄的类型,用 memmove 就违反了对象语义,这种场景下不能随便换。排序库里的元素通常要求“可平凡复制”,在这个前提下替换没有风险。
结构体里如果有自引用指针(指向数组内其他元素),memmove 和手写循环搬移的结果一致,因为指针值也是按位搬移的,指向的相对关系不会变。这一点我在实现排序工具时专门用测试用例确认过,可以放心。
5.2 数据分布敏感与阈值调参
memmove 版插入排序对数据分布非常敏感。逆序数据收益最大,随机数据中规中矩,接近有序数据可能退化。所谓“阈值”,本质是在“调用库函数的前置开销”和“手写循环的逐元素开销”之间求一个平衡点。
如果想让阈值可调,我的惯用做法是在工程里定义一个常量:
c复制#ifndef INSERTION_MEMOVE_THRESHOLD
#define INSERTION_MEMOVE_THRESHOLD 8
#endif
然后跑一次小规模参数扫描,观察 count 分布在 1~32 时,每个阈值对应的总耗时。按我的经验,超过 16 之后手写循环的优势基本消失,低于 4 又浪费了长搬移的收益,8 是通用性较好的默认值。
5.3 从插入排序到更大优化策略
最后把视野拉宽一点:memmove 优化插入排序,本质上是“通过降低搬移常数来提高插入排序的实际速度”。如果你的数据规模再大一些,插入排序的 O(n²) 天花板很快会压过头,这时更值得做的是减少搬移次数本身,而不是继续优化单次搬移的速度。
一条自然的升级路线是:先用插入排序处理小片段(含 memmove 优化),再用归并或者快速排序串起整个数组。这也是 Linux 内核里的 list_sort 和 glibc qsort 实际采用的策略组合。另一条路线是用希尔排序的思路,通过增大步长把长距离搬移拆成若干次短距离搬移,摊薄总体搬移成本——不过这时 memmove 的用武之地就仅限于每个步长内部的连续片段了,收益会变弱。
我个人的建议是:如果你的排序子过程刚好只处理几十到几千个元素,memmove 优化是性价比极高的一步;如果你已经在设计更大型的排序框架,那把这个优化当作“小片段底层加速”拼进整体方案,而不是单独指望它解决大数组排序,是更务实的做法。
