一、合并阶段在忙些什么
你可以把两个有序序列想象成两堆扑克牌,每一堆都从最上面开始,按照从小到大的顺序排好了。现在要合成一堆,仍然保持从小到大。做法很简单:每次看两堆最上面的牌,谁小谁先出来,放到新扑克堆的底部。两个堆中有一个空了,就把另一个剩下的牌全部按顺序挪过来。归并排序里,每一次递归的“归”就是干这件事。
这步操作看起来没什么含量,但它很辛苦。因为排序过程中有大量数据要被搬来搬去。对于100万个整数,光合并阶段就要移动大约2000万次数据(因为每一轮都要遍历全部数据,log2(100万)约20轮,每轮移动100万个数据,总移动约2000万次)。移动就是内存读写。所以合并操作快不快,直接决定整个排序快不快。而内存读写的快慢,很大程度上由CPU的缓存行决定。
二、缓存行是辆“顺风车”
CPU在内存里拿数据,就像你在超市取货架上拿东西。你不能打开每一个商品的包装,而是整个托盘一起拿过来。这个托盘就是缓存行,通常有64字节。当你需要数组里第一个int时,CPU会把从它开始连续64字节的内容全部装进CPU旁边的“小仓库”(高速缓存)。之后你读后面第二个、第三个int,小仓库里都有,直接取就行,根本不用再跑一趟超市。
反过来,如果你访问的地址东一个西一个,每次取到的托盘里只有一个是你想要的,其他都被丢掉,那CPU就得不停往超市跑,浪费的时间和电都是白花的。这叫缓存未命中,代价非常大。所以算法设计者最关心的一个指标,就是“访问内存时,是不是按顺序挨个访问”。合并操作恰好就是这样一种顺序访问模式。
三、合并操作的天然好习惯
我们来看一次具体的合并。假设main数组里有一段连续空间,左半是下标0到4,右半是5到9。合并时,我们需要一个临时数组,从左半的第一个元素开始,挨个取;右半也从第一个元素开始挨个取。把较小的那个写入临时数组的第一个位置、第二个位置……整个过程就像三个队伍同时往前走:左边一列、右边一列、目标数组的写入位置,全部是连贯的。
这种连贯模式对缓存行非常友好。第一次读左半第一个元素时,CPU把0到15的int都拉进缓存,接下来读左半第二、第三、第四个int时,全都命中缓存。右半也一样。写临时数组的时候,写入位置也是顺序的,写缓存行也能高效合并。这就是局部性原理:你刚刚访问过的地址附近,往往马上会被再次访问。合并操作把它们全都照顾到了。
但有些实现浪费了这种天然优势。比如递归版每层都新建临时数组,导致临时内存和原数组不在一个连续区域,缓存行来回切换。再比如合并结束后,再把临时数组的内容复制回原数组,等于数据被白白搬了一次。这些都可以优化掉。
四、优化建议
4.1 用交替缓冲代替频繁分配
递归归并排序经常写成每次合并时分配一个临时数组,合并完再释放。这种分配在规模小时还好,数据一大就会变成灾难。首先内存分配本身有开销,其次新分配的内存可能离原数组非常远,导致缓存全部失效。更聪明的做法是:一次性开两个同样大的数组A和B。第一轮从A里读,把合并结果写到B;第二轮再从B里读,写到A。这样数据始终在A和B之间滚来滚去,两个数组的地址是固定的,缓存能一直保持温热。这个技巧叫乒乓缓冲,也叫双缓冲。
4.2 小数组交给插入排序
归并排序一直划分下去,最后每个子数组只剩一个元素,这是理论上的分治终点。但在递归逻辑里,如果子数组长度小于某个阈值,比如16,就没必要继续递归划分了。因为这个规模下,插入排序的简单循环比递归函数调用更快。插入排序虽然理论复杂度是O(n^2),但它的常数极小,又完全顺序访问。把16个元素排序,它可能只需要几十个周期。而归并排序的递归调用、栈操作、合并准备,反而要几百个周期。所以工业实现普遍喜欢加这个阈值。
4.3 控制分块宽度
自底向上的归并排序,是从宽度1开始,每一轮宽度翻倍。宽度小的时候,上一轮合并出来的结果,下一轮立刻又要被读取。如果使用交替缓冲,这些数据大概率还留在L1缓存里,下一轮就能直接命中。如果你每轮都从很远的地方复制到另一个临时数组,那这些数据就被挤出去了。另外,分块能让循环次数更稳定,方便编译器做循环展开。循环展开可以把多次比较和移动放在一起执行,减少循环判断的浪费,提升CPU流水线利用率。
4.4 用预取指令提前“打招呼”
CPU读内存时有延迟,这个延迟对CPU来说非常漫长,可能达到几百个时钟周期。预取指令可以在你真正需要某个数据之前,提前告诉缓存:“请把这块数据拉进来。”等程序读到那里时,数据已经等在缓存里,延迟就被藏起来了。GCC和Clang提供了__builtin_prefetch,很多高级排序实现都会用。注意预取的距离要合适:太近,等你去读时还没拉回来;太远,可能会把有用的数据挤出缓存。通常预取当前访问位置后面256字节或512字节的数据比较靠谱。
五、代码示例:一次关心缓存的归并排序
技术栈:C++(GCC/Clang编译)。下面这段代码使用了交替缓冲,并且在每个块的规模小于等于16时改用插入排序。合并时加入预取指令,让数据提前进入缓存。这是一个完整的可运行示例。
// 技术栈:C++(需要GCC/Clang,支持__builtin_prefetch)
#include <vector>
#include <algorithm>
// 对 dst[low..high-1] 做直接插入排序
static void insertionSort(int* dst, int low, int high) {
for (int i = low + 1; i < high; ++i) {
int key = dst[i];
int j = i - 1;
// 把 key 往前挪,直到遇到比它小的元素
while (j >= low && dst[j] > key) {
dst[j + 1] = dst[j];
--j;
}
dst[j + 1] = key;
}
}
// 合并 src[left..mid-1] 与 src[mid..right-1],结果写到 dst[left..right-1]
static void mergeWithPrefetch(const int* src, int* dst,
int left, int mid, int right) {
int i = left;
int j = mid;
int k = left;
while (i < mid && j < right) {
// 预取后面约 64 个 int(256字节),提前把数据请进缓存
// 边界情况未完全判断,真正的工程实现需要限制预取范围
__builtin_prefetch(&src[i + 64], 0, 1);
__builtin_prefetch(&src[j + 64], 0, 1);
if (src[i] <= src[j]) {
dst[k++] = src[i++];
} else {
dst[k++] = src[j++];
}
}
while (i < mid) {
__builtin_prefetch(&src[i + 64], 0, 1);
dst[k++] = src[i++];
}
while (j < right) {
__builtin_prefetch(&src[j + 64], 0, 1);
dst[k++] = src[j++];
}
}
// 优化后的归并排序:乒乓缓冲 + 小数组插入排序 + 预取
void optimizedMergeSort(std::vector<int>& data) {
int n = (int)data.size();
if (n < 2) return;
std::vector<int> tmp(n); // 辅助数组
int* src = data.data(); // 当前读的数组
int* dst = tmp.data(); // 当前写的数组
// 块宽度,从1开始,每轮翻倍
for (int width = 1; width < n; width *= 2) {
// 从左到右处理所有相邻块对
for (int left = 0; left < n; left += 2 * width) {
int mid = std::min(left + width, n);
int right = std::min(left + 2 * width, n);
// 如果块很小,直接复制到dst,然后插入排序
if (right - left <= 16) {
for (int t = left; t < right; ++t) dst[t] = src[t];
insertionSort(dst, left, right);
} else {
mergeWithPrefetch(src, dst, left, mid, right);
}
}
// 交换读写角色,下一轮反向归并
std::swap(src, dst);
}
// 如果最终结果不在原数组里,拷回去
if (src != data.data()) {
std::copy(tmp.begin(), tmp.end(), data.begin());
}
}
这段代码的巧妙之处在于,它没有用递归,而是用宽度翻倍的方式,从下往上做归并。这样每次合并的两个块,在内存中总是相邻的,访问起来更连贯。小块的插入排序直接在dst上做,是因为这一轮最终所有块的结果都要写到dst里去,所以干脆先复制过去,再排序,逻辑统一。
mergeWithPrefetch里的预取,是在比较还没有发生时,就把后面要读的元素拉进缓存。这里预取距离是64个int,也就是256字节。在实际项目中,这个值需要根据缓存行大小和处理器预取能力做调优。如果担心越界,可以在预取之前判断i + 64 < mid或者j + 64 < right。
还有一点要说明:代码里的插入排序阈值16不是绝对的。不同CPU上,可能8、32、甚至48效果更好。你可以写一个小测试,比较不同阈值的运行时间,选出最合适的。
六、应用场景与优缺点
应用场景
归并排序特别适合那些要求稳定,或者数据量特别大的场景。比如数据库的排序操作,需要保证相同键的记录保持原有顺序,这就不能随便交换元素。又比如外部排序:当数据远比内存大时,要先把数据分成很多个小文件,每个文件内部排序,然后再多路归并成一个有序文件。这个过程本质上就是不停地在做合并。合并阶段的缓存优化,直接影响磁盘读写的效率,因为缓存命中率高,就能减少不必要的磁盘IO。
另外,在多线程并行排序里,把一个大数组切分成多个段,让不同线程各自排序,最后再归并,这也是归并排序的优势。它天然适合分而治之。在这种情况下,每个线程的合并操作如果能利用CPU缓存局部性,整个程序就能跑得更快。
优点
- 稳定排序,相同值的元素相对位置不会变。
- 最坏时间复杂度就是O(n log n),没有快速排序那样的最坏退化。
- 访问模式非常规则,基本都是顺序读、顺序写,这对CPU缓存、内存页甚至磁盘SSD都很友好。
- 容易分块,支持并行化和外部化。
- 通过乒乓缓冲和预取,性能可以进一步提升。
缺点
- 需要额外O(n)的内存空间,如果数据量极大,可能是个负担。
- 对性能敏感的纯内存排序,快排往往比归并排序更快,因为它不需要额外写那么多数据,原地划分就能完成。
- 对于接近有序的数据,归并排序仍然要做完整的合并和搬移,不像插入排序那样能提前收敛。
- 自底向上的迭代实现虽然避免了递归栈,但是代码复杂度和理解成本比递归版本高一些。
七、注意事项
第一,预取不是免费的。预取指令本身也会占用CPU资源,预取太多反而会拖慢速度。建议在判断合并是性能瓶颈后,再用__builtin_prefetch做实验。第二,小数组阈值要谨慎设置。如果你把阈值设成100,那插入排序的O(n^2)会带来很多无谓比较,结果可能比纯归并还慢。第三,需要留意编译器的优化级别。在-O2或-O3下,编译器可能会自动做循环展开、向量化甚至软件预取,这时手动预取可能会干扰编译器的优化。你可以用性能分析工具对比开关预取前后的差异,如果没差别,删掉手动预取更干净。
还有一个细节:内存对齐。如果数组的起始地址恰好是64字节的倍数,那么缓存行会对得更整齐,访问更高效。在C++中,可以用alignas(64)来对齐一个静态数组,也可以用memalign或posix_memalign分配对齐内存。不过对于std::vector,它的对齐通常只是默认alignof(int),不一定能充分利用缓存行。如果追求极致性能,可以自己写一个对齐分配器。这个例子里没有特意处理,但你应该知道有这一步。
另外,在递归版归并排序中,也可以在进入递归时判断区间长度是否小于阈值,如果小于就直接插入排序,然后返回。这样能减少大约log2(阈值)层递归调用,对栈空间和调用时间都有好处。迭代版没有这个问题,但理解两者的差异,能帮你写出更灵活的代码。
八、总结
归并排序的合并阶段,天生就是按顺序访问内存的,这正好和CPU缓存行的运作方式合拍。我们不需要发明一套高深理论,只要别破坏这种顺序性就行。避免频繁分配临时数组,使用交替缓冲;在小规模时改用插入排序,减少递归开销;用预取指令把数据提前拉到缓存;再加上合理的对齐,这几点足够把一个普通的归并排序改造成对缓存非常友好的高性能版本。以后再看到排序慢,不要总想着减少比较次数,多看看数据是怎么在内存里跑的。理解了缓存行,你才算真正理解了性能优化。
Comments