归并排序的三重境界

博客标题:一招鲜,吃遍天:归并排序的三重境界

在学习算法的道路上,我们总会遇到一些"瑞士军刀"般的工具,它们看似简单,却蕴含着解决一类问题的通用思想。今天,我们要聊的主角就是这样一个算法------归并排序

很多人对归并排序的印象可能停留在"哦,一个时间复杂度O(N logN)的稳定排序算法"。没错,但如果仅仅如此,就太小看它了。它的真正威力在于其"分而治之 "思想和独特的merge(合并)过程。这个过程天然地为我们提供了一个上帝视角,去处理那些跨越数组左右两部分的元素关系。

今天,我将通过三道经典的编程题,带你一步步领略归并排序的三重境界:从排序 ,到简单计数 ,再到复杂计数


第一重境界:万物之始 ------ 排序数组 (LeetCode 912)

给你一个整数数组 nums,请你将该数组升序排列。

你必须在 不使用任何内置函数 的情况下解决问题,时间复杂度为 O(nlog(n)),并且空间复杂度尽可能小。

示例 1:

输入:nums = [5,2,3,1]

输出:[1,2,3,5]

解释:数组排序后,某些数字的位置没有改变(例如,2 和 3),而其他数字的位置发生了改变(例如,1 和 5)。

示例 2:

输入:nums = [5,1,1,2,0,0]

输出:[0,0,1,1,2,5]

解释:请注意,nums 的值不一定唯一。

问题描述 :给你一个整数数组 nums,请你将该数组升序排列。要求时间复杂度为 O(nlog(n)),空间复杂度尽可能小。

这是归并排序最本源,最核心的应用。它的思想非常纯粹:

  1. 分解 (Divide):不断地把数组一分为二,直到每个子数组只剩一个元素。一个元素的数组天然就是有序的。
  2. 合并 (Merge):将两个已经有序的子数组,合并成一个大的有序数组。

这里的灵魂就在于merge函数。我们用两个指针分别指向两个有序子数组的开头,比较指针所指元素的大小,将较小的那个放入一个临时的辅助数组,然后移动相应的指针。重复这个过程,直到一个子数组被完全遍历,再将另一个子数组剩下的部分直接复制过去。最后,把辅助数组中的有序结果复制回原数组。

【核心代码】

cpp 复制代码
class Solution {
public:
    int help[50008]; // 辅助数组,避免在递归中频繁创建

    vector<int> sortArray(vector<int>& nums) {
        mergeSort(0, nums.size() - 1, nums);
        return nums;
    }

    // 递归分解
    void mergeSort(int l, int r, vector<int>& nums) {
        if (l >= r) return; // 当子数组只有一个或没有元素时,返回
        int m = l + (r - l) / 2; // 防止 l+r 溢出
        mergeSort(l, m, nums);
        mergeSort(m + 1, r, nums);
        merge(l, m, r, nums); // 合并
    }

    // 合并两个有序子数组
    void merge(int l, int m, int r, vector<int>& nums) {
        int a = l, b = m + 1, index = l;
        while (a <= m && b <= r) {
            if (nums[a] <= nums[b]) {
                help[index++] = nums[a++];
            } else {
                help[index++] = nums[b++];
            }
        }
        // 处理剩余元素
        while (a <= m) help[index++] = nums[a++];
        while (b <= r) help[index++] = nums[b++];
        // 复制回原数组
        for (int i = l; i <= r; i++) nums[i] = help[i];
    }
};

境界小结 :这是归并排序的基本功。理解并能熟练写出这个模板,是迈向更高境界的基石。这里的merge过程,只关心元素间的大小关系 ,目的是为了排序


第二重境界:初窥门径 ------ 计算数组的小和

问题描述:对于一个数组中的每个数,求其左侧所有小于或等于它的数的和。整个数组的小和定义为所有数的小和之和。

这个问题要求我们计算一种特定的"贡献"。暴力解法是O(N^2)的,显然不满足要求。这时,归并排序的机会来了。

我们思考一下,在merge过程中,当我们比较左半部分[l...m]nums[a]和右半部分[m+1...r]nums[b]时,我们能获得什么信息?

nums[a] <= nums[b] 时,我们不仅知道 nums[a]nums[b] 小,更重要的是,因为右半部分 [m+1...r] 是有序的,所以我们知道 nums[a] 小于等于 nums[b]nums[b+1]、...、nums[r] 这所有的元素!

这启发了我们:一个数的小和,可以转化为在merge过程中,它作为较小数时,右侧有多少个数比它大(或等于)。

于是,我们可以在merge时"顺便"完成计算:

nums[a] <= nums[b] 时,我们准备将nums[a]放入help数组。此时,nums[a]对整个数组的小和产生了贡献。它的贡献值是 nums[a] 乘以右半部分所有比它大的数的个数,即 r - b + 1

【核心代码】
注:以下代码实现了"小和"的逻辑,与你提供的代码逻辑略有不同,但思想一致,都是在merge过程中完成统计。

cpp 复制代码
#include <iostream>
using namespace std;
int s[100004];
int help[100004];
long long sum = 0;

void merge(int l, int m, int r) {
    int a = l, b = m + 1, index = l;
    while (a <= m && b <= r) {
        if (s[a] <= s[b]) {
            // s[a] 比右边 [b...r] 的所有数都小
            // 这些数都在 s[a] 的右边,所以 s[a] 产生了贡献
            sum += (long long)s[a] * (r - b + 1);
            help[index++] = s[a++];
        } else {
            // s[b] 比 s[a] 小,但我们无法确定 s[b] 和 s[a] 左边数的关系
            // 所以在 s[b] < s[a] 时不计算贡献
            help[index++] = s[b++];
        }
    }
    while (a <= m) help[index++] = s[a++];
    while (b <= r) help[index++] = s[b++];
    for (int i = l; i <= r; i++) s[i] = help[i];
}

void mergeSort(int l, int r) {
    if (l >= r) return;
    int m = l + (r - l) / 2;
    mergeSort(l, m);
    mergeSort(m + 1, r);
    merge(l, m, r);
}

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; i++) cin >> s[i];
    mergeSort(0, n - 1);
    cout << sum << '\n';
}

境界小结 :我们从归并排序中挖掘出了新的价值。merge不再仅仅是为了排序,它成了一个信息处理和统计的平台。通过在比较大小的同时增加一行计算代码,我们巧妙地解决了问题。


第三重境界:登堂入室 ------ 翻转对 (LeetCode 493)

问题描述 :给定一个数组 nums ,如果 i < jnums[i] > 2 * nums[j],我们就将 (i, j) 称作一个重要翻转对。返回重要翻转对的数量。

这个问题是"逆序对"问题的加强版。条件从 nums[i] > nums[j] 变成了 nums[i] > 2 * nums[j]

如果我们还想用第二重境界的方法,在merge排序的同时 进行计数,会遇到一个大麻烦:
merge排序的依据是nums[a] <= nums[b],但计数的依据是 nums[a] > 2 * nums[b]。这两个条件不一致!如果我们按计数条件来移动指针,数组就无法正确排序,那么整个归并排序的根基就动摇了。

怎么办?答案是:解耦!将计数和排序合并分为两步!

merge函数内部,我们利用左右两个子数组已经分别有序的黄金特性,先完成计数,再完成标准的排序合并。

  1. 计数阶段
    • 使用两个指针 iji 遍历左半部分[l...m]j 遍历右半部分[m+1...r]
    • 对于每个 nums[i],我们向右移动 j,直到找到第一个不满足 nums[i] > 2 * nums[j] 的位置。
    • 由于数组的单调性,j 指针无需回退。所以,这一步的时间复杂度是线性的 O(N)。
  2. 排序合并阶段
    • 计数完成后,忘记刚才的 ij
    • 重新用两个指针 ab,从头开始,执行一次标准的归并排序合并操作。

此外,还有一个陷阱:2 * nums[j] 可能会导致整数溢出 。我们需要使用long long来确保计算的正确性。

【核心代码】

cpp 复制代码
class Solution {
public:
    int help[50003];
    int sum = 0;

    int reversePairs(vector<int>& nums) {
        if (nums.empty()) return 0;
        mergeSort(0, nums.size() - 1, nums);
        return sum;
    }

    void mergeSort(int l, int r, vector<int>& nums) {
        if (l >= r) return;
        int m = l + (r - l) / 2;
        mergeSort(l, m, nums);
        mergeSort(m + 1, r, nums);
        merge(l, m, r, nums);
    }

    void merge(int l, int m, int r, vector<int>& nums) {
        // --- 步骤一:先完成计数,不影响排序逻辑 ---
        int j = m + 1;
        for (int i = l; i <= m; i++) {
            // 使用 long long 防止溢出
            while (j <= r && (long long)nums[i] > 2LL * nums[j]) {
                j++;
            }
            sum += j - (m + 1);
        }

        // --- 步骤二:再执行标准的排序合并 ---
        int a = l, b = m + 1;
        int index = l;
        while (a <= m && b <= r) {
            if (nums[a] <= nums[b]) {
                help[index++] = nums[a++];
            } else {
                help[index++] = nums[b++];
            }
        }
        while (a <= m) help[index++] = nums[a++];
        while (b <= r) help[index++] = nums[b++];
        for (int i = l; i <= r; i++) {
            nums[i] = help[i];
        }
    }
};

境界小结 :这是归并排序思想的升华。我们认识到,merge过程提供的"左右子数组均有序 "这个前提,比merge本身的操作更宝贵。我们可以利用这个前提,在排序之前 ,先做一些其他有意义的事情。这种"先利用特性,后恢复结构"的思路,是解决很多复杂分治问题的关键。


总结

我们从一个简单的排序需求出发,一步步深入,最终将归并排序打造成了一个解决复杂计数问题的利器:

  • 境界一 :将merge作为排序工具
  • 境界二 :将merge作为伴随计算的平台,在排序的同时完成简单的计数。
  • 境界三 :将merge前的有序状态 作为独立的计算窗口,实现计数与排序的解耦,解决更复杂的计数问题。

希望这次的旅程能让你对归并排序有一个全新的认识。它不仅仅是一个算法,更是一种强大的思维框架。当你下次遇到涉及"左边/右边"、"之前/之后"的计数问题时,不妨问问自己:归并排序能帮上忙吗?

感谢阅读!

相关推荐
MoRanzhi12032 小时前
2. Pandas 核心数据结构:Series 与 DataFrame
大数据·数据结构·人工智能·python·数据挖掘·数据分析·pandas
力扣蓝精灵2 小时前
今日分享 整数二分
算法
mc23562 小时前
5分钟学会微算法——Brian Kernighan 算法
算法
Excuse_lighttime2 小时前
除自身以外数组的乘积
java·数据结构·算法·leetcode·eclipse·动态规划
fbbqt2 小时前
go语言数据结构与排序算法
数据结构·golang·排序算法
程序员三明治2 小时前
【重学数据结构】队列 Queue
数据结构·后端·算法
Coision.2 小时前
Linux C: 函数
java·c语言·算法
杜小暑2 小时前
数据结构之双向链表
c语言·数据结构·后端·算法·链表·动态内存管理
青瓦梦滋2 小时前
【数据结构】哈希——位图与布隆过滤器
开发语言·数据结构·c++·哈希算法