归并排序(MergeSort)完全指南 —— 从原理到非递归实现

博客: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 用 break
  • end2 >= n 裁到 n - 1
  • 拷回 a 时只拷合并过的那段 [i, end2],不是整个数组
  • 比较用 <= 保证稳定性
相关推荐
墨^O^2 小时前
进程与线程的核心区别及 Linux 启动全过程解析
linux·c++·笔记·学习
寒秋花开曾相惜2 小时前
(学习笔记)3.9 异质的数据结构(3.9.1 结构)
c语言·网络·数据结构·数据库·笔记·学习
福楠2 小时前
现代C++ | C++14甜点特性
linux·c语言·开发语言·c++
WBluuue2 小时前
Codeforces Educational 188(ABCDEF)
c++·算法
charlie1145141912 小时前
嵌入式C++教程实战之Linux下的单片机编程:从零搭建 STM32 开发工具链(4)从零构建 STM32 构建系统
linux·开发语言·c++·stm32·单片机·学习·嵌入式
AI成长日志2 小时前
【笔面试算法学习专栏】双指针专题:简单难度三题精讲(167.两数之和II、283.移动零、344.反转字符串)
学习·算法·面试
Book思议-2 小时前
【数据结构】数组与特殊矩阵
数据结构·算法·矩阵
LuminousCPP2 小时前
C语言自定义类型全解析
c语言·笔记·枚举·结构体·联合体
不吃蘑菇!2 小时前
LeetCode Hot 100-1(两数之和)
java·数据结构·算法·leetcode·哈希表