实用指南:归并排序的三重境界

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

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

很多人对归并排序的印象可能停留在“哦,一个时间复杂度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函数。我们用两个指针分别指向两个有序子数组的开头,比较指针所指元素的大小,将较小的那个放入一个临时的辅助数组,然后移动相应的指针。重复这个过程,直到一个子数组被完全遍历,再将另一个子数组剩下的部分直接复制过去。最后,把辅助数组中的有序结果复制回原数组。

【核心代码】

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过程中完成统计。

#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来确保计算的正确性。

【核心代码】

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前的有序状态作为独立的计算窗口,实现计数与排序的解耦,解决更复杂的计数问题。

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

感谢阅读!

posted @ 2025-10-25 15:28  yjbjingcha  阅读(2)  评论(0)    收藏  举报