归并排序(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],不是整个数组
  • 比较用 <= 保证稳定性
相关推荐
三毛的二哥16 小时前
BEV:典型BEV算法总结
人工智能·算法·计算机视觉·3d
南宫萧幕17 小时前
自控PID+MATLAB仿真+混动P0/P1/P2/P3/P4构型
算法·机器学习·matlab·simulink·控制·pid
浪浪小洋18 小时前
c++ qt课设定制
开发语言·c++
charlie11451419118 小时前
嵌入式C++工程实践第16篇:第四次重构 —— LED模板,从通用GPIO到专用抽象
c语言·开发语言·c++·驱动开发·嵌入式硬件·重构
handler0118 小时前
Linux: 基本指令知识点(2)
linux·服务器·c语言·c++·笔记·学习
故事和你9118 小时前
洛谷-数据结构1-4-图的基本应用1
开发语言·数据结构·算法·深度优先·动态规划·图论
我叫黑大帅18 小时前
为什么map查找时间复杂度是O(1)?
后端·算法·面试
炽烈小老头18 小时前
【每天学习一点算法 2026/04/20】除自身以外数组的乘积
学习·算法
skilllite作者19 小时前
AI agent 的 Assistant Auto LLM Routing 规划的思考
网络·人工智能·算法·rust·openclaw·agentskills
破浪前行·吴20 小时前
数据结构概述
数据结构·学习