算法 --归并排序

核心思想:分治法

归并排序的本质是分治法 。它的核心逻辑可以用八个字概括:"分而治之,合而治之"。

  1. 分:将一个大数组从中间劈开,分成左右两个子数组。

  2. 治:递归地对左右两个子数组分别进行归并排序。

  3. 合:将两个已经排好序的子数组合并成一个完整的有序数组。

关键前提 :合并操作的前提是,左右两个子数组各自已经是有序的。这个"有序"不是天然存在的,而是通过递归一层层返回时构建出来的。

复杂度分析

指标 复杂度 说明
时间复杂度 O(n log n) 无论最好、最坏、平均情况都是如此
空间复杂度 O(n) 合并时需要额外的辅助数组
稳定性 稳定 相等元素的相对顺序不会改变

为什么是 O(n log n)?

  • 分解的层数是 log n(每次二分)。

  • 每一层的合并操作总共处理 n 个元素。

  • 所以总复杂度 = n × log n。

题目一:排序数组

cpp 复制代码
class Solution {
    vector<int> tmp; // 1. 辅助数组
public:
    vector<int> sortArray(vector<int>& nums) {
        tmp.resize(nums.size()); // 2. 初始化辅助数组大小
        mergeSort(nums, 0, nums.size() - 1); // 3. 调用递归
        return nums;
    }
    
    void mergeSort(vector<int>& nums, int left, int right) {
        if (left >= right) return; // 4. 递归终止:单元素天然有序
        int mid = (left + right) >> 1; // 5. 取中点划分区间
        
        mergeSort(nums, left, mid);      // 6. 递归左半区
        mergeSort(nums, mid + 1, right); // 7. 递归右半区
        
        // 8. 合并逻辑(此时左右两半已经各自有序)
        int cur1 = left, cur2 = mid + 1, i = 0; 
        while (cur1 <= mid && cur2 <= right) {
            // 升序:挑小的放前面
            tmp[i++] = nums[cur1] <= nums[cur2] ? nums[cur1++] : nums[cur2++];
        }
        while (cur1 <= mid) tmp[i++] = nums[cur1++];
        while (cur2 <= right) tmp[i++] = nums[cur2++];
        
        // 9. 还原回原数组(关键:tmp从0开始存,还原要减left)
        for (int j = left; j <= right; j++) {
            nums[j] = tmp[j - left];
        }
    }
};

深度思考:侧重点解析

1. 辅助数组 tmp 的作用

tmp 是数值的中转站。合并时,如果直接改 nums,会覆盖掉还没比较的元素。tmp 从索引 0 开始存放,确保不管 left 多大,都不会越界。

2. 递归如何自底向上?

递归先一路向左走到最底(left >= right 返回),此时单个元素天然有序。返回上一层,合并两个单元素,形成长度为 2 的有序区间。再返回,合并两个长度为 2 的区间,形成长度为 4 的有序区间......每一层返回时,子区间已经排好序,这是归并排序的灵魂。

题目二:交易逆序对的总数

cpp 复制代码
class Solution {
    int tmp[50010]; // 1. 全局静态数组,避免反复创建
public:
    int reversePairs(vector<int>& nums) {
        return mergeSort(nums, 0, nums.size() - 1);
    }
    
    int mergeSort(vector<int>& nums, int left, int right) {
        if (left >= right) return 0; // 2. 单元素无逆序对
        int ret = 0;
        int mid = (left + right) >> 1;
        
        ret += mergeSort(nums, left, mid);      // 3. 递归左半区,累加内部逆序对
        ret += mergeSort(nums, mid + 1, right); // 4. 递归右半区,累加内部逆序对
        
        // 5. 【统计阶段】左右两半各自降序,但边界还没被打破
        int cur1 = left, cur2 = mid + 1;
        while (cur1 <= mid && cur2 <= right) {
            if (nums[cur1] > nums[cur2]) {
                ret += right - cur2 + 1; // 6. 批量统计
                cur1++;
            } else {
                cur2++;
            }
        }
        
        // 7. 【合并阶段】打破边界,排成降序
        cur1 = left, cur2 = mid + 1;
        int i = 0;
        while (cur1 <= mid && cur2 <= right) {
            tmp[i++] = nums[cur1] > nums[cur2] ? nums[cur1++] : nums[cur2++];
        }
        while (cur1 <= mid) tmp[i++] = nums[cur1++];
        while (cur2 <= right) tmp[i++] = nums[cur2++];
        
        for (int j = left; j <= right; j++) {
            nums[j] = tmp[j - left];
        }
        return ret;
    }
};

深度思考:侧重点解析

1. 为什么统计必须在合并之前?

统计跨越左右两半的逆序对,前提是:左半区元素天然在索引上小于右半区元素 。如果先合并,左右两半的元素混在一起,物理边界消失,你根本不知道 i 和 j 谁在前谁在后。先统计,后合并,是归并排序统计问题的铁律。

2. 降序为什么能批量统计?

此时左半区降序 [7, 5],右半区降序 [6, 2]。

  • cur1 指向 7,cur2 指向 6。7 > 6,成立。因为右半区降序,所以从 cur2 到 right 所有元素都小于 7。可以直接加上 right - cur2 + 1,不需要逐个比较。

  • 如果 nums[cur1] <= nums[cur2],说明右半区当前的元素更大,cur2++ 跳过。

3. 递归如何配合统计?

左递归返回后,左半区内部逆序对已统计完毕且已降序;右递归同理。此时左右两半各自降序,物理边界还在,才能正确统计跨越两半的逆序对。这就是递归"自底向上"和统计"先于合并"的完美配合。

题目三:计算右侧小于当前元素的个数

cpp 复制代码
class Solution {
    vector<int> ret;   // 1. 结果数组
    vector<int> index; // 2. 追踪原始下标的数组
    int tmpNums[500010];
    int tmpIndex[500010];
public:
    vector<int> countSmaller(vector<int>& nums) {
        int n = nums.size();
        ret.resize(n);
        index.resize(n);
        
        for (int i = 0; i < n; i++) {
            index[i] = i; // 3. 初始化:每个元素的原始下标就是自己
        }
        
        mergeSort(nums, 0, n - 1);
        return ret;
    }
    
    void mergeSort(vector<int>& nums, int left, int right) {
        if (left >= right) return;
        int mid = (left + right) >> 1;
        
        mergeSort(nums, left, mid);
        mergeSort(nums, mid + 1, right);
        
        // 4. 【统计 + 合并】升序
        int cur1 = left, cur2 = mid + 1, i = 0;
        while (cur1 <= mid && cur2 <= right) {
            if (nums[cur1] <= nums[cur2]) {
                tmpNums[i] = nums[cur1];
                tmpIndex[i++] = index[cur1++];
            } else {
                // 5. 关键统计:右半区从cur2到right都小于nums[cur1]
                ret[index[cur1]] += right - cur2 + 1; 
                tmpNums[i] = nums[cur1];
                tmpIndex[i++] = index[cur1++];
            }
        }
        while (cur1 <= mid) {
            tmpNums[i] = nums[cur1];
            tmpIndex[i++] = index[cur1++];
        }
        while (cur2 <= right) {
            tmpNums[i] = nums[cur2];
            tmpIndex[i++] = index[cur2++];
        }
        
        // 6. 还原 nums 和 index
        for (int j = left; j <= right; j++) {
            nums[j] = tmpNums[j - left];
            index[j] = tmpIndex[j - left];
        }
    }
};

深度思考:侧重点解析

1. index 数组为什么必须存在?

数组经过排序后,元素的位置被打乱,但题目要求按原顺序输出结果。index 数组就像每个元素脖子上的"狗牌",无论元素怎么移动,狗牌始终跟着它。index[cur1] 就是元素 nums[cur1] 最初在数组中的位置,统计结果直接累加到 ret[index[cur1]]。

2. tmpIndex 为什么也要同步移动?

tmpNums 存的是排序后的数值,tmpIndex 存的是这些数值对应的原始下标。如果只移动 tmpNums 而不移动 tmpIndex,那么数值和它的原始位置就失联了。数值和索引必须作为一对"情侣"同步移动。

3. 为什么这道题用升序而非降序?

因为统计的是"右侧小于当前元素",右半区升序时,一旦 nums[cur1] > nums[cur2],右半区从 cur2 到 right 的所有元素(都是更大的)都满足条件,可以批量加上 right - cur2 + 1。升序配合这个统计逻辑最顺畅。

4. 统计和合并可以同时进行吗?

在这道题里,统计和合并是在同一个 while 循环中完成的 。因为每次比较时,要么移动 cur1,要么移动 cur2,在决定移动谁的同时,统计逻辑也就完成了。

题目四:翻转对

cpp 复制代码
class Solution {
    int tmp[50010];
public:
    int reversePairs(vector<int>& nums) {
        if (nums.empty()) return 0;
        return mergeSort(nums, 0, nums.size() - 1);
    }
    
    int mergeSort(vector<int>& nums, int left, int right) {
        if (left >= right) return 0;
        int ret = 0;
        int mid = (left + right) >> 1;
        
        ret += mergeSort(nums, left, mid);
        ret += mergeSort(nums, mid + 1, right);
        
        // 1. 【统计阶段】(必须独立于合并)
        int cur1 = left, cur2 = mid + 1;
        while (cur1 <= mid) {
            // 2. 对于每个左半区元素,在右半区找满足条件的边界
            while (cur2 <= right && nums[cur1] / 2.0 > nums[cur2]) {
                cur2++;
            }
            // 3. 右半区从mid+1到cur2-1都满足
            ret += (cur2 - (mid + 1));
            cur1++;
        }
        
        // 4. 【合并阶段】降序
        cur1 = left, cur2 = mid + 1;
        int i = 0;
        while (cur1 <= mid && cur2 <= right) {
            tmp[i++] = nums[cur1] > nums[cur2] ? nums[cur1++] : nums[cur2++];
        }
        while (cur1 <= mid) tmp[i++] = nums[cur1++];
        while (cur2 <= right) tmp[i++] = nums[cur2++];
        
        for (int j = left; j <= right; j++) {
            nums[j] = tmp[j - left];
        }
        return ret;
    }
};

深度思考:侧重点解析

1. 为什么统计和合并必须分开?

因为这题的统计条件是 nums[i] > 2 * nums[j],而合并的条件是 nums[i] > nums[j]。两个比较条件不同 !

如果你在同一轮循环里同时统计和合并,指针移动的规则会产生冲突。例如,可能 nums[cur1] > 2 * nums[cur2] 成立,但 nums[cur1] > nums[cur2] 不成立(此时 cur1 应该不动,但合并需要移动指针)。因此,必须先独立统计完,再独立合并。

2. 为什么统计和合并都用降序?

合并排成降序后,数组保持降序,这样下一层递归返回时,统计逻辑依然可以利用"右半区降序"的单调性。统计逻辑和排序逻辑必须保持一致,否则下一层递归就乱了。

3. 统计逻辑的具体运作

cur1 固定遍历左半区。对于每个 cur1,cur2 从 mid+1 开始向右滑动,直到不满足 nums[cur1] > 2 * nums[cur2] 为止。此时 cur2 左侧的所有元素(从 mid+1 到 cur2-1)都满足条件,数量为 cur2 - (mid+1)。

关键点 :cur2 不需要每次重置回 mid+1,因为它随着 cur1 的增加只会向右移动(单调性)。这就是降序的威力------cur1 增大时,nums[cur1] 变小(或不变),需要更大的 cur2 才能不满足条件。

4. 防止溢出的细节

代码中使用了 nums[cur1] / 2.0 > nums[cur2],这是浮点数比较。更稳妥的写法是用 (long long)nums[cur1] > 2LL * nums[cur2],防止 2 * nums[cur2] 发生整型溢出。

四大题目的共通逻辑与区别

题目 辅助数组 递归顺序 统计时机 排序方向 统计逻辑
排序数组 tmp 先左后右 无 升序 无
逆序对 tmp 先左后右 合并前 降序 批量累加 right - cur2 + 1
右侧小于 tmpNums, tmpIndex 先左后右 合并中 升序 ret[index[cur1]] += right - cur2 + 1
翻转对 tmp 先左后右 合并前 降序 固定 cur1,滑窗 cur2,累加 cur2 - (mid+1)

核心心法:

  1. 递归返回时子区间已有序,这是归并排序能够统计的前提。

  2. 统计必须在合并之前(或合并中),因为合并会打破左右两半的物理边界。

  3. 降序用于"左边大于右边",升序用于"右边小于左边",方向选择决定了统计逻辑能否批量处理。

  4. 辅助数组是空间换时间 ,index 数组是追踪元素原始身份的,必须与数值同步移动。

识别信号

情况一:题目明确要求 O(Nlog⁡N)的时间复杂度,且需要排序

这是最基础的信号。

  • 场景:给你一个无序数组,要求你按升序/降序排列。

  • 为什么是归并? 快速排序虽然平均也是 O(Nlog⁡N),但最坏情况下会退化到 O(N2)例如数组已经有序)。归并排序的时间复杂度稳定在 O(Nlog⁡N) ,且是稳定排序(相等元素的相对顺序不变)。如果要求"稳定"或"最坏情况也是 O(Nlog⁡N)",归并排序是首选。

情况二:需要统计"逆序对"或"前面/后面比它大/小的元素"

这是归并排序最经典的进阶应用。

  • 场景:

    • 统计数组中 i < j 且 nums[i] > nums[j] 的对数(逆序对总数)。

    • 统计每个元素右侧有多少个比它小的元素。

    • 统计每个元素左侧有多少个比它大的元素。

  • 为什么是归并? 暴力双重循环是 O(N2),会超时。而归并排序在合并两个有序子数组时,可以天然地利用"左半区索引一定小于右半区索引"以及"两半各自有序"这两个性质,用双指针在 O(N) 时间内统计出跨越两半的逆序对。总时间复杂度降为 O(Nlog⁡N)。

  • 核心信号 :题目中出现"统计 "、"数量 "、"右侧/左侧 "、"大于/小于"等字眼,且数据规模 N较大,暴力解法会超时。

情况三:需要统计满足特定大小关系的"对"(如两倍关系)

这是情况二的变种,但比较条件不再是简单的 > 或 <,而是带系数的比较。

  • 场景 :统计 i < j 且 nums[i] > 2 * nums[j] 的对数。

  • 例子 :493. 翻转对。

  • 为什么是归并? 虽然比较条件变了,但核心逻辑没变:我们仍然在合并两个有序子数组时,利用单调性 (降序或升序)来批量统计。注意,这里统计条件和合并条件不同(一个是 > 2*,一个是 >),所以需要先独立统计,再独立合并。

  • 核心信号 :题目中出现了"满足某种倍数关系 "、"特定条件"的统计需求。

情况四:需要"原地"排序,但空间复杂度要求不严格

  • 场景:题目允许 O(N) 的额外空间。

  • 为什么是归并? 归并排序需要 O(N)的辅助数组(tmp)。如果题目对空间复杂度要求极为苛刻(比如要求 O(1)),那么归并排序就不适合了(除非使用极其复杂的原地归并算法)。

  • 核心信号:题目提示空间复杂度为 O(N) 或没有严格限制。

情况五:链表排序

  • 场景:给你一个链表,要求 O(Nlog⁡N)时间排序。

  • 为什么是归并? 链表不支持随机访问,快速排序需要随机访问,而归并排序只需要顺序访问。因此,链表排序的首选就是归并排序(通常使用自底向上的迭代归并,或者递归归并)。

归并和快排的区别

1. 核心思想:分治的"分"与"合"

两者都用了分治法,但侧重点完全不同:

  • 归并排序(先分后合,重在于"合"):

    • 分:简单粗暴地从中间劈开,递归到只剩一个元素。

    • 合(核心) :在递归返回时,将两个已经有序的子数组合并成一个大的有序数组。它的主要工作量在"合并"这一步。

    • 口诀 :自顶向下拆分,自底向上合并。

  • 快速排序(先治后分,重在于"分"):

    • 分(核心):选择一个基准值,通过"分区"操作,把比基准小的扔左边,比基准大的扔右边。此时基准已经在最终位置上。

    • 治:递归地对左右两个子区间重复上述过程。

    • 合 :快排不需要合并!因为分区完成后,整个数组已经天然有序了。

    • 口诀 :自顶向下分区,原地递归搞定。

2. 执行流程与递归顺序

  • 归并排序:

    • 递归顺序 :先递归左半区,再递归右半区,最后执行合并。

    • 关键动作 :合并(Merge)需要借助辅助数组 tmp,把两个有序数组合并成一个。

    • 类比:像两个已经排好队的小分队,合并成一个大部队。你只需要不断从两个小分队队首挑出更小的那个,放到新队伍里。

  • 快速排序:

    • 递归顺序 :先执行分区,找到基准的最终位置,然后再递归左半区和右半区。

    • 关键动作 :分区通常使用双指针 ,在原数组上直接交换元素,不需要额外空间。

    • 类比:像按照身高排队。先随便拉一个人出来当基准,比他矮的站左边,比他高的站右边。然后对左右两边的人重复这个动作。

3. 时间复杂度与稳定性

维度 归并排序 快速排序
平均时间复杂度 O(Nlog⁡N) O(Nlog⁡N)
最坏时间复杂度 O(Nlog⁡N)(始终稳定) O(N2)(如数组已有序且选第一个为基准)
稳定性 稳定排序(相等元素相对顺序不变) 不稳定排序(分区交换会打乱相等元素的顺序)
额外空间 O(N) (必须借助 tmp 数组) O(log⁡N) ~ O(N)(递归栈空间,原地分区)
  • 归并的稳定性 :在合并时,如果 nums[cur1] <= nums[cur2],优先取左半区元素,这保证了相等元素的相对顺序不变。

  • 快排的不稳定性:分区时,基准值可能会和远处的元素交换,导致相等元素的相对顺序被打乱。

  • 快排的最坏情况 :如果每次选基准都选到最大或最小值(如数组已经有序,且选第一个元素为基准),分区会极度不平衡,退化成 O(N2)。解决办法是随机选基准 或三数取中。

4. 空间复杂度与辅助数组

  • 归并排序 :必须使用 O(N)的辅助数组 tmp。因为合并操作不能在原数组上原地完成(会覆盖未比较的元素)。这是归并排序最大的"缺点",也是它在某些内存受限场景下不被选用的原因。

  • 快速排序 :原地排序。除了递归调用栈外,不需要额外的数组空间。空间复杂度取决于递归深度,平均 O(log⁡N),最坏 O(N)。这是快排最大的"优点",节省内存。

5. 性能表现与缓存友好度

  • 归并排序 :合并时需要频繁读写 tmp 数组,是非原地 操作。在现代计算机的 CPU 缓存机制下,频繁跳跃访问内存(写 tmp,读 nums)会导致缓存命中率较低 ,实际运行速度通常慢于快排。

  • 快速排序 :分区操作是在原数组上连续访问和交换,缓存命中率极高 。因此,在实际工程中(如 C++ 的 std::sort),快排通常是首选,只有在需要稳定排序时才用归并(如 std::stable_sort)。

相关推荐
Run_Teenage1 小时前
算法:树的重心、树的直径
算法
专注仿真1 小时前
高代价计算后置:探测、交战、毁伤三条链路的统一设计哲学
算法·探测·毁伤
xie0510_1 小时前
Any类简要实现
开发语言·c++·算法
字节跳动的猫1 小时前
LikeShop 种草社区模块二开:笔记发布、话题广场与商品挂载实现
数据结构·数据库
在所不辞兄1 小时前
为什么PINN非常适合求解有限元模型
人工智能·深度学习·神经网络·算法·机器学习
一棵树73512 小时前
数据结构闲聊
数据结构·算法
bro_Java6662 小时前
《二叉树的基础知识、操作方法与代码示例》
java·数据结构·算法·广度优先
PascalXie2 小时前
地面机器人VSLAM技术调研与实现方案参考:算法、硬件平台与工程落地
算法·机器人
aqiu1111114 小时前
【C++算法打怪专栏】8. 字符串转换整数 (atoi)
开发语言·c++·算法