1. 归并排序:不只是“分而治之”那么简单
大家好,我是老张,在算法和数据结构这块摸爬滚打了十几年,带过不少新人。我发现很多朋友一提到归并排序,脑子里蹦出来的就是“分而治之”四个字,然后就开始写递归。这没错,但真要自己动手实现,尤其是想写出一个工业级、能处理各种边界情况、还能用迭代方式跑的版本,坑可就多了去了。今天,我就结合自己踩过的那些坑,把归并排序从递归到迭代的实现细节,特别是那些教科书里一笔带过、但实际开发中能让你调试一晚上的边界处理,掰开揉碎了讲清楚。
归并排序的核心思想确实优雅:先把大数组一分为二,递归地对左右两半排序,最后再把两个有序小数组合并成一个大的有序数组。这个过程很像搭积木,从最小的有序单元(单个元素)开始,一层层向上合并。它的时间复杂度稳定在 O(n log n),并且是稳定排序,这在处理复杂对象(比如先按年龄排、再按姓名排)时非常有用。但它的“阿喀琉斯之踵”也很明显——需要额外的 O(n) 空间。不过,在内存不再是瓶颈的今天,它的稳定性和可预测性让它在很多场景(如大数据外部排序、链表排序)中依然是首选。
2. 递归版本:从“一看就会”到“一写就废”
我们先从最熟悉的递归版本开始。很多教程给的代码看起来简洁明了,但如果你直接抄,大概率会掉进两个经典的大坑:栈溢出和中点计算错误。别急着写代码,我们先在脑子里把递归树画清楚。
2.1 核心逻辑与“后序遍历”思维
归并排序的递归过程,本质上是一棵二叉树的后序遍历。你想想,“后序”是左-右-根,对应到排序里就是:先递归处理左半区间,再递归处理右半区间,最后将左右两个已经排好序的区间合并。这个顺序至关重要,因为合并操作的前提是两个子区间已经各自有序。所以,写递归函数时,你的思维模式应该是:“我相信我的函数能排好左半边,也能排好右半边,我现在只需要专心把这两半有序数组合并起来。”
合并两个有序数组是归并排序的基石操作。你需要三个指针:两个分别指向左右区间的起始位置,一个指向临时数组的当前位置。比较两个指针所指的元素,把小的那个放入临时数组,然后移动相应的指针。这个过程直到其中一个区间被耗尽为止,最后把另一个区间剩余的元素直接拷贝过去。这个逻辑本身不难,难的是如何正确地划分区间并递归调用。
2.2 典型错误与栈溢出陷阱
来看一个我早期写过的、充满bug的版本,也是很多新手容易写出来的样子:
void _MergeSort(int* arr, int* tmp, int begin, int end) {
if (begin >= end) {
return;
}
int mid = (begin + end) / 2; // 问题根源!
// 划分区间为 [begin, mid-1] 和 [mid, end]
_MergeSort(arr, tmp, begin, mid - 1);
_MergeSort(arr, tmp, mid, end);
// ... 合并逻辑
}
这段代码看起来逻辑清晰,但运行起来大概率会栈溢出。问题就出在 int mid = (begin + end) / 2; 这一行,以及随之而来的区间划分 [begin, mid-1] 和 [mid, end]。
我们来模拟一个最简情况:当 begin = 0, end = 1 时(即区间只有两个元素)。计算 mid = (0+1)/2 = 0(整数除法)。那么划分出的两个区间是:[0, -1] 和 [0, 1]。第一个区间 [0, -1] 是无效的,会立即触发 if (begin >= end) return;。但第二个区间 [0, 1] 呢?它和父调用传入的区间 [0, 1]</


1060

被折叠的 条评论
为什么被折叠?



