核心思想:分治法
归并排序的本质是分治法 。它的核心逻辑可以用八个字概括:"分而治之,合而治之"。
-
分:将一个大数组从中间劈开,分成左右两个子数组。
-
治:递归地对左右两个子数组分别进行归并排序。
-
合:将两个已经排好序的子数组合并成一个完整的有序数组。
关键前提 :合并操作的前提是,左右两个子数组各自已经是有序的。这个"有序"不是天然存在的,而是通过递归一层层返回时构建出来的。
复杂度分析
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | 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) |
核心心法:
-
递归返回时子区间已有序,这是归并排序能够统计的前提。
-
统计必须在合并之前(或合并中),因为合并会打破左右两半的物理边界。
-
降序用于"左边大于右边",升序用于"右边小于左边",方向选择决定了统计逻辑能否批量处理。
-
辅助数组是空间换时间 ,
index数组是追踪元素原始身份的,必须与数值同步移动。
识别信号
情况一:题目明确要求 O(NlogN)的时间复杂度,且需要排序
这是最基础的信号。
-
场景:给你一个无序数组,要求你按升序/降序排列。
-
为什么是归并? 快速排序虽然平均也是 O(NlogN),但最坏情况下会退化到 O(N2)例如数组已经有序)。归并排序的时间复杂度稳定在 O(NlogN) ,且是稳定排序(相等元素的相对顺序不变)。如果要求"稳定"或"最坏情况也是 O(NlogN)",归并排序是首选。
情况二:需要统计"逆序对"或"前面/后面比它大/小的元素"
这是归并排序最经典的进阶应用。
-
场景:
-
统计数组中
i < j且nums[i] > nums[j]的对数(逆序对总数)。 -
统计每个元素右侧有多少个比它小的元素。
-
统计每个元素左侧有多少个比它大的元素。
-
-
为什么是归并? 暴力双重循环是 O(N2),会超时。而归并排序在合并两个有序子数组时,可以天然地利用"左半区索引一定小于右半区索引"以及"两半各自有序"这两个性质,用双指针在 O(N) 时间内统计出跨越两半的逆序对。总时间复杂度降为 O(NlogN)。
-
核心信号 :题目中出现"统计 "、"数量 "、"右侧/左侧 "、"大于/小于"等字眼,且数据规模 N较大,暴力解法会超时。
情况三:需要统计满足特定大小关系的"对"(如两倍关系)
这是情况二的变种,但比较条件不再是简单的 > 或 <,而是带系数的比较。
-
场景 :统计
i < j且nums[i] > 2 * nums[j]的对数。 -
例子 :493. 翻转对。
-
为什么是归并? 虽然比较条件变了,但核心逻辑没变:我们仍然在合并两个有序子数组时,利用单调性 (降序或升序)来批量统计。注意,这里统计条件和合并条件不同(一个是
> 2*,一个是>),所以需要先独立统计,再独立合并。 -
核心信号 :题目中出现了"满足某种倍数关系 "、"特定条件"的统计需求。
情况四:需要"原地"排序,但空间复杂度要求不严格
-
场景:题目允许 O(N) 的额外空间。
-
为什么是归并? 归并排序需要 O(N)的辅助数组(
tmp)。如果题目对空间复杂度要求极为苛刻(比如要求 O(1)),那么归并排序就不适合了(除非使用极其复杂的原地归并算法)。 -
核心信号:题目提示空间复杂度为 O(N) 或没有严格限制。
情况五:链表排序
-
场景:给你一个链表,要求 O(NlogN)时间排序。
-
为什么是归并? 链表不支持随机访问,快速排序需要随机访问,而归并排序只需要顺序访问。因此,链表排序的首选就是归并排序(通常使用自底向上的迭代归并,或者递归归并)。
归并和快排的区别
1. 核心思想:分治的"分"与"合"
两者都用了分治法,但侧重点完全不同:
-
归并排序(先分后合,重在于"合"):
-
分:简单粗暴地从中间劈开,递归到只剩一个元素。
-
合(核心) :在递归返回时,将两个已经有序的子数组合并成一个大的有序数组。它的主要工作量在"合并"这一步。
-
口诀 :自顶向下拆分,自底向上合并。
-
-
快速排序(先治后分,重在于"分"):
-
分(核心):选择一个基准值,通过"分区"操作,把比基准小的扔左边,比基准大的扔右边。此时基准已经在最终位置上。
-
治:递归地对左右两个子区间重复上述过程。
-
合 :快排不需要合并!因为分区完成后,整个数组已经天然有序了。
-
口诀 :自顶向下分区,原地递归搞定。
-
2. 执行流程与递归顺序
-
归并排序:
-
递归顺序 :先递归左半区,再递归右半区,最后执行合并。
-
关键动作 :合并(Merge)需要借助辅助数组
tmp,把两个有序数组合并成一个。 -
类比:像两个已经排好队的小分队,合并成一个大部队。你只需要不断从两个小分队队首挑出更小的那个,放到新队伍里。
-
-
快速排序:
-
递归顺序 :先执行分区,找到基准的最终位置,然后再递归左半区和右半区。
-
关键动作 :分区通常使用双指针 ,在原数组上直接交换元素,不需要额外空间。
-
类比:像按照身高排队。先随便拉一个人出来当基准,比他矮的站左边,比他高的站右边。然后对左右两边的人重复这个动作。
-
3. 时间复杂度与稳定性
| 维度 | 归并排序 | 快速排序 |
|---|---|---|
| 平均时间复杂度 | O(NlogN) | O(NlogN) |
| 最坏时间复杂度 | O(NlogN)(始终稳定) | O(N2)(如数组已有序且选第一个为基准) |
| 稳定性 | 稳定排序(相等元素相对顺序不变) | 不稳定排序(分区交换会打乱相等元素的顺序) |
| 额外空间 | O(N) (必须借助 tmp 数组) |
O(logN) ~ O(N)(递归栈空间,原地分区) |
-
归并的稳定性 :在合并时,如果
nums[cur1] <= nums[cur2],优先取左半区元素,这保证了相等元素的相对顺序不变。 -
快排的不稳定性:分区时,基准值可能会和远处的元素交换,导致相等元素的相对顺序被打乱。
-
快排的最坏情况 :如果每次选基准都选到最大或最小值(如数组已经有序,且选第一个元素为基准),分区会极度不平衡,退化成 O(N2)。解决办法是随机选基准 或三数取中。
4. 空间复杂度与辅助数组
-
归并排序 :必须使用 O(N)的辅助数组
tmp。因为合并操作不能在原数组上原地完成(会覆盖未比较的元素)。这是归并排序最大的"缺点",也是它在某些内存受限场景下不被选用的原因。 -
快速排序 :原地排序。除了递归调用栈外,不需要额外的数组空间。空间复杂度取决于递归深度,平均 O(logN),最坏 O(N)。这是快排最大的"优点",节省内存。
5. 性能表现与缓存友好度
-
归并排序 :合并时需要频繁读写
tmp数组,是非原地 操作。在现代计算机的 CPU 缓存机制下,频繁跳跃访问内存(写tmp,读nums)会导致缓存命中率较低 ,实际运行速度通常慢于快排。 -
快速排序 :分区操作是在原数组上连续访问和交换,缓存命中率极高 。因此,在实际工程中(如 C++ 的
std::sort),快排通常是首选,只有在需要稳定排序时才用归并(如std::stable_sort)。