博客:TomGo
本文涵盖:基础原理、递归版、非递归版、越界问题专项、复杂度分析、面试高频考点
目录
[易错点一:b 存的是值不是下标](#易错点一:b 存的是值不是下标)
[易错点二:拷回 a 时起点是 a[left] 不是 a[0]](#易错点二:拷回 a 时起点是 a[left] 不是 a[0])
[易错点三:malloc 完要 free](#易错点三:malloc 完要 free)
[易错点四:比较用 <= 保证稳定性](#易错点四:比较用 <= 保证稳定性)
一、大白话说原理
归并排序的思想只有一句话:
把数组不断对半切,切到只剩一个元素(一个元素天然有序),然后两两合并,合并时保持有序。
合并的过程就像打扑克理牌:左手一堆有序的牌,右手一堆有序的牌,每次比较两堆最顶上的牌,小的先放下去,最终合成一堆有序的牌。
举个例子,数组 [5, 3, 8, 1, 6, 2, 7, 4]:
第一步:不断切割
[5,3,8,1,6,2,7,4]
/ \
[5,3,8,1] [6,2,7,4]
/ \ / \
[5,3] [8,1] [6,2] [7,4]
/ \ / \ / \ / \
[5][3][8][1][6][2][7][4]
第二步:两两合并(每次合并都保持有序)
[3,5] [1,8] [2,6] [4,7]
[1,3,5,8] [2,4,6,7]
[1,2,3,4,5,6,7,8]
二、递归版实现
cpp
void MergeSort(int* a, int left, int right)
{
// 只剩一个元素,天然有序,直接返回
if (left == right)
return;
int mid = (left + right) / 2;
// 递归排左半边和右半边
MergeSort(a, left, mid);
MergeSort(a, mid + 1, right);
// 此时左右两半都已有序,开始合并
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
// 申请临时数组存合并结果
int* b = (int*)malloc((right - left + 1) * sizeof(int));
int i = 0;
// 两组同时走,小的先放进b
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
b[i++] = a[begin1++];
else
b[i++] = a[begin2++];
}
// 把剩余的直接补进去
while (begin1 <= end1)
b[i++] = a[begin1++];
while (begin2 <= end2)
b[i++] = a[begin2++];
// 拷回原数组(从a[left]开始,不是a[0]!)
for (int j = 0; j < right - left + 1; j++)
a[left + j] = b[j];
free(b);
}
三、递归版易错点
易错点一:b 存的是值不是下标
// 错误:存的是下标,不是值!
b[i] = begin2;
b[i] = begin1;
// 正确:存值
b[i] = a[begin2];
b[i] = a[begin1];
这个错误非常隐蔽,编译不报错,运行结果全错。记住 b 是用来存数据的,不是存下标的。
易错点二:拷回 a 时起点是 a[left] 不是 a[0]
// 错误:从a[0]开始写,把前面已排好的数据覆盖了!
for (int j = 0; j < right - left + 1; j++)
a[j] = b[j];
// 正确:从a[left]开始
for (int j = 0; j < right - left + 1; j++)
a[left + j] = b[j];
归并是对子数组操作的,b 里存的是 [left, right] 这段的合并结果,必须写回到对应位置。
易错点三:malloc 完要 free
每次递归都申请了内存,用完必须释放,否则内存泄漏。
free(b); // 拷回之后立刻free
易错点四:比较用 <= 保证稳定性
// 推荐:<=,相等时取左边,稳定排序
if (a[begin1] <= a[begin2])
b[i++] = a[begin1++];
// 如果写 <,相等时取右边,破坏稳定性
四、非递归版实现
非递归版不用递归切割,直接控制每次合并的区间大小(gap)。
gap=1:每1个一组,相邻两组合并
[5][3] [8][1] [6][2] [7][4]
→ [3,5] [1,8] [2,6] [4,7]
gap=2:每2个一组,相邻两组合并
[3,5][1,8] [2,6][4,7]
→ [1,3,5,8] [2,4,6,7]
gap=4:每4个一组,相邻两组合并
[1,3,5,8][2,4,6,7]
→ [1,2,3,4,5,6,7,8]
cpp
void MergeSortNonR(int* a, int n)
{
int* b = (int*)malloc(n * sizeof(int));
// gap从1开始,每次翻倍(不能从0开始!0*2永远是0)
for (int gap = 1; gap < n; gap *= 2)
{
// i每次跳2*gap,处理下一对区间
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i;
int end1 = i + gap - 1;
int begin2 = i + gap;
int end2 = i + 2 * gap - 1;
// 越界修正(最后一组可能不够,重点!)
if (end1 >= n || begin2 >= n)
break;
if (end2 >= n)
end2 = n - 1;
// 合并 [begin1,end1] 和 [begin2,end2]
int j = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
b[j++] = a[begin1++];
else
b[j++] = a[begin2++];
}
while (begin1 <= end1) b[j++] = a[begin1++];
while (begin2 <= end2) b[j++] = a[begin2++];
// 只拷回合并过的那段,不是全部!
for (int k = i; k <= end2; k++)
a[k] = b[k];
}
}
free(b);
}
五、非递归越界问题专项(重点!)
这是非递归归并最容易出错的地方,很多人在这里翻车。
为什么会越界?
数组长度不一定是 2 的幂次,最后一组凑不够 gap 个元素是常态。
举例:数组有 7 个元素,gap = 2:
下标: 0 1 2 3 4 5 6
分组:[0,1] [2,3] [4,5] [6,?]
↑
下标7越界!
三种越界情况
情况一:end1 越界(第一组本身就超了)
n=5, gap=4, i=0
begin1=0, end1=3, begin2=4, end2=7
end1=3 没越界,但 end2=7 >= n=5
情况二:begin2 越界(第二组根本不存在)
n=5, gap=4, i=0
begin2=4 < 5,存在
下一轮 i=8, begin2=12 >= 5,第二组不存在
情况三:end2 越界(第二组存在但不够长)
n=7, gap=2, i=4
begin2=6, end2=7
end2=7 >= n=7,裁到 end2=6
处理方式
// 情况一二:直接break,这对区间没得合并
if (end1 >= n || begin2 >= n)
break;
// 情况三:裁掉越界部分,正常合并
if (end2 >= n)
end2 = n - 1;
情况一和情况二合并成一行,简洁又清晰。
六、复杂度分析
| 情况 | 时间复杂度 | 说明 |
|---|---|---|
| 最好 | O(n log n) | 每次对半切,合并是O(n) |
| 平均 | O(n log n) | 稳定,不受数据分布影响 |
| 最坏 | O(n log n) | 不会退化,比快排稳 |
| 空间 | O(n) | 需要额外的临时数组 |
稳定性:稳定排序(相等元素相对顺序不变)
归并排序最大的优点是时间复杂度稳定,不管什么数据都是 O(n log n),不像快排遇到有序数组会退化。
缺点是需要 O(n) 的额外空间,快排只需要 O(log n) 的栈空间。
七、和其他排序对比
| 排序算法 | 平均时间 | 最坏时间 | 空间 | 稳定性 | 适用场景 |
|---|---|---|---|---|---|
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 需要稳定排序,链表排序 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 不稳定 | 通用,实际最快 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 空间要求严格 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 | 小数据量,近乎有序 |
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 | 教学用,实际不用 |
归并排序最适合的场景:
- 链表排序(不需要随机访问,天然适合归并)
- 需要稳定排序的场景
- 外部排序(数据量太大放不进内存,分批归并)
八、面试高频考点
Q1:归并排序的时间复杂度为什么是 O(n log n)?
每次对半切,递归深度是 log n 层。每一层合并的总操作数是 O(n)(所有区间加起来刚好覆盖整个数组)。所以总复杂度是 O(n) × O(log n) = O(n log n)。
Q2:归并排序是稳定排序吗?
是稳定排序。合并时遇到相等的元素,我们让左边的先进(a[begin1] <= a[begin2] 取左边),相等元素的相对顺序不变。
Q3:归并排序和快速排序哪个好?
各有优势。归并时间复杂度稳定不退化,是稳定排序;快排平均情况更快(常数系数小),空间占用少。实际工程中 STL 的 std::sort 用的是 Introsort(快排为主,退化时切堆排,小区间用插入排序)。
Q4:归并排序适合链表吗?
非常适合。链表不支持随机访问,快排需要随机访问找 pivot 效率很低。归并只需要顺序遍历,天然契合链表结构,所以链表排序首选归并。
Q5:非递归归并为什么要处理越界?
数组长度不一定是 2 的幂次,最后一组元素数量可能不足 gap 个。不处理的话 end2 会越界访问非法内存,直接崩。三种情况:第一组越界或第二组不存在直接 break,第二组不够长裁到 n-1。
Q6:归并排序的空间复杂度是多少?
递归版:O(n)(临时数组)+ O(log n)(递归栈)= O(n)。 非递归版:O(n)(临时数组),没有递归栈开销。
九、复习检查清单
写完归并排序对照检查:
b[i] = a[begin1]存的是值,不是下标- 拷回时是
a[left + j] = b[j],不是a[j] = b[j] malloc完有没有free- 非递归 gap 从 1 开始,不是 0
- for 第三项是
i += 2 * gap,不是i + 2 * gap - 越界修正三种情况都处理了
end1 >= n || begin2 >= n用 breakend2 >= n裁到n - 1- 拷回 a 时只拷合并过的那段
[i, end2],不是整个数组 - 比较用
<=保证稳定性