分治算法-归并排序专题:从性能优化到索引数组的突破
分治算法-归并排序专题:从性能优化到索引数组的突破
目录
- 刷题记录
- 刷题过程
- 核心概念
- 我踩的坑
- 题目详解
- 核心收获
刷题记录
- 刷题日期: 2024年10月17日(Day16)
- 完成题量: 4题全部AC
- 学习时长: 约4小时
- 题目难度: 中等1题 + 困难3题
题目 | 难度 | 用时 | 一次AC? |
---|---|---|---|
LeetCode 912 - 排序数组(归并排序) | 中等 | 40分钟 | ❌ 性能优化 |
剑指Offer 51 - 数组中的逆序对 | 困难 | 50分钟 | ❌ cur2初始化错误 |
LeetCode 315 - 计算右侧小于当前元素的个数 | 困难 | 100分钟 | ✅ 引导后一次过 |
LeetCode 493 - 翻转对 | 困难 | 40分钟 | ❌ 混淆统计和归并 |
刷题过程
Day16(10.17):归并排序 + 统计问题
第1题:排序数组(LeetCode 912)
- 写到合并两个有序数组时卡住了
- 不确定用什么数据结构辅助
- 经过引导:用临时数组
tmp
,双指针比较 - 第一版用局部
tmp
:904ms,感觉很慢 - 优化成全局
tmp
:60ms,快了15倍! ⚡ - 关键理解:
resize
的作用,避免频繁内存分配
第2题:数组中的逆序对(剑指Offer 51)⭐⭐
- 一开始不理解为什么暴力会超时
- 计算了一下:50000² / 2 = 12.5亿次,确实会超时
- 然后不理解为什么归并排序能统计逆序对
- 困惑点:
- 排序了数组变了,统计还会正确吗?
- 为什么要递归?
- 什么时候统计?
- 经过一步步引导理解:
- 先统计再排序,count 已经记录了
- 递归是为了让左右有序,才能批量统计
- 在
else
分支统计(左边大于右边时)
- 写代码时出错:
cur2 = right
,应该是cur2 = mid + 1
- 改对后 AC,但理解过程花了很长时间
第3题:计算右侧小于当前元素的个数(LeetCode 315)⭐⭐⭐
- 这题最难!一开始完全不知道怎么做
- 困惑点:
- 返回值怎么处理?数组还是什么?
- 如何记录每个元素的统计结果?
- 统计公式和数组怎么关联?
- 经过苏格拉底式引导,一步步理解:
- 逆序对统计整体,这题统计每个元素
- 归并排序会改变位置,需要追踪
- 用索引数组追踪原始位置!
- 统计时机:左边被取走时
- 统计公式:
cur2 - mid - 1
- 花了100分钟,但最后一次AC!
- 最大收获:索引数组这个技巧!
第4题:翻转对(LeetCode 493)⭐⭐
- 有了前面的基础,这题思路清晰一些
- 一开始想套用逆序对的模板,在归并时统计
- 写成:
if(nums[cur1] <= 2*nums[cur2])
- 结果报错:heap-buffer-overflow(数组越界)
- 还有个错:
while(cur1 <= mid) tmp[k++] = nums[cur2++];
(应该是cur1++) - 经过引导理解:
- 翻转对的条件 ≠ 归并条件
- 不能混在一起
- 必须分两步:先统计,再归并
- 还学到了
2LL
的作用:避免整数溢出 - 改对后 AC
今日难点:
- 归并排序的
resize
性能优化(15倍提升) - 逆序对的"排序后还能统计正确"的理解
- 索引数组的引入和使用(最难但最有价值)
- 翻转对的"分两步"理解
核心概念(我的理解)
什么是归并排序?
通过今天4道题,我的理解是:把数组分成两半,递归排序,然后合并两个有序数组。
三步骤:
- 分(Divide): 找中点,分成两半
- 治(Conquer): 递归排序左右
- 合(Combine): 合并两个有序数组
关键是第3步的"合并",用双指针 + 临时数组。
归并排序 vs 快速排序
昨天学了快速排序,今天学归并排序,对比一下:
对比项 | 快速排序 | 归并排序 |
---|---|---|
分的方式 | 按基准值分三块 | 按中点分两半 |
是否需要合并 | 不需要 | 需要 |
时间复杂度(平均) | O(n log n) | O(n log n) |
时间复杂度(最坏) | O(n²) | O(n log n) |
空间复杂度 | O(log n) | O(n) |
稳定性 | 不稳定 | 稳定 |
适用场景 | 一般排序 | 需要稳定排序、外部排序 |
我的理解:
- 快排:时间换空间(最坏O(n²),但空间小)
- 归并:空间换时间(稳定O(n log n),但空间大)
归并排序的统计问题分类
今天4道题让我发现了一个规律:
题目 | 统计条件 | 归并条件 | 能否合并? | 策略 |
---|---|---|---|---|
逆序对 | nums[i] > nums[j] | nums[cur1] <= nums[cur2] | ✅ 可以 | 1步(归并时统计) |
翻转对 | nums[i] > 2*nums[j] | nums[cur1] <= nums[cur2] | ❌ 不行 | 2步(先统计,再归并) |
右侧更小 | 每个元素各自统计 | nums[cur1] <= nums[cur2] | ❌ 不行 | 需要索引数组 |
判断标准:
- 统计条件 = 归并条件的互补 → 可以在归并时统计
- 统计条件 ≠ 归并条件的互补 → 必须分开处理
索引数组技巧
这是今天最大的收获!
什么时候用?
- 需要在排序的同时追踪元素原始位置
- 需要对每个元素单独统计结果
怎么用?
// 1. 创建索引数组
vector<int> index(n);
for(int i = 0; i < n; i++) index[i] = i;// 2. 排序索引,比较元素
比较:nums[index[cur1]] vs nums[index[cur2]]
交换:交换 index 里的值
统计:count[index[cur1]] += ...// 3. index[i] 永远记录着原始位置
核心理解: 不排序元素本身,而是排序它们的"指针"(索引)
我踩的坑
坑1:局部tmp导致性能差(排序数组)
错误代码:
void mergeSort(vector<int>& nums, int left, int right) {// ...vector<int> tmp; // ❌ 每次递归都创建新数组// 合并...
}
问题: 每次递归都分配内存,频繁分配释放
性能: 904ms
正确做法:
class Solution {
public:vector<int> tmp; // ✅ 全局数组vector<int> sortArray(vector<int>& nums) {tmp.resize(nums.size()); // 只分配一次mergeSort(nums, 0, nums.size() - 1);return nums;}
};
性能: 60ms,快了15倍!
教训: 能复用的资源就复用,避免频繁分配
坑2:cur2初始化错误(逆序对)
错误代码:
int cur1 = left, cur2 = right; // ❌ cur2应该是 mid+1
问题: 跳过了中间元素,导致统计错误
测试用例:
输入:[7, 5, 6, 4]
输出:3
预期:5
正确做法:
int cur1 = left, cur2 = mid + 1; // ✅
教训: 归并排序的右半部分从 mid + 1
开始,不是 right
坑3:索引数组初始思路混乱(右侧更小元素)
初始困惑:
// 我一开始想这样
vector<int> mergeSort(vector<int>& nums, int left, int right) {vector<int> ret{}; // 先统计个数,最后压进去?// ...
}
问题:
- 返回值类型不清楚
- 如何记录每个元素的统计结果不清楚
- 统计公式和数组如何关联不清楚
理解过程(花了100分钟):
- 认识到需要追踪每个元素
- 归并排序会改变位置
- 引入索引数组
- 理解统计时机(左边被取走时)
- 理解统计公式(
cur2 - mid - 1
)
教训: 复杂问题要一步步分解,不要一下想太多
坑4:混淆统计和归并(翻转对)
错误代码:
while(cur1 <= mid && cur2 <= right) {if(nums[cur1] <= 2*nums[cur2]) { // ❌ 条件错了tmp[k++] = nums[cur1++];} else {tmp[k++] = nums[cur2++];count += mid - cur1 + 1;}
}
问题: 用翻转对的条件来决定取谁,导致归并排序错误
运行错误: heap-buffer-overflow
正确做法: 分两步
// Step 1: 统计翻转对
int cur1 = left, cur2 = mid + 1;
while(cur1 <= mid) {while(cur2 <= right && nums[cur1] > 2LL * nums[cur2]) {cur2++;}count += (cur2 - mid - 1);cur1++;
}// Step 2: 正常归并
// ...
教训: 统计条件 ≠ 归并条件时,必须分开处理
坑5:整数溢出(翻转对)
错误代码:
nums[cur1] > 2 * nums[cur2] // ❌ 可能溢出
问题: nums[cur2]
可能是10亿,2 * 10亿 = 20亿
,接近 int
上限
正确做法:
nums[cur1] > 2LL * nums[cur2] // ✅ 用 long long
解释:
2LL
是long long
类型nums[cur2]
会自动提升为long long
- 结果不会溢出
教训: 涉及乘法时要考虑溢出
题目详解
1. 排序数组(LeetCode 912)⭐
题目: 对数组进行升序排列。
思路: 归并排序三步骤
- 分:找中点
mid = (left + right) >> 1
- 治:递归排序左右
- 合:合并两个有序数组
代码:
class Solution {
public:vector<int> tmp; // 全局临时数组vector<int> sortArray(vector<int>& nums) {tmp.resize(nums.size()); // 预分配空间mergeSort(nums, 0, nums.size() - 1);return nums;}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);// 合并两个有序数组int cur1 = left, cur2 = mid + 1;int k = left;while(cur1 <= mid && cur2 <= right) {if(nums[cur1] <= nums[cur2]) {tmp[k++] = nums[cur1++];} else {tmp[k++] = nums[cur2++];}}while(cur1 <= mid) tmp[k++] = nums[cur1++];while(cur2 <= right) tmp[k++] = nums[cur2++];// 拷贝回原数组for(int i = left; i <= right; i++) {nums[i] = tmp[i];}}
};
关键点:
- 用全局
tmp
避免频繁分配 tmp
的索引从left
开始- 拷贝时
nums[i] = tmp[i]
(索引对应)
性能优化: 局部tmp 904ms → 全局tmp 60ms(快15倍)
复杂度:
- 时间:O(n log n)
- 空间:O(n)
2. 数组中的逆序对(剑指Offer 51)⭐⭐
题目: 统计数组中的逆序对数量。
逆序对定义: i < j
且 nums[i] > nums[j]
思路: 归并排序 + 批量统计
- 在合并时,利用有序性批量统计
- 当左边大于右边时,左边剩余的都大于右边
- 公式:
count += (mid - cur1 + 1)
代码:
class Solution {
public:vector<int> tmp;int reversePairs(vector<int>& nums) {tmp.resize(nums.size());return mergeSort(nums, 0, nums.size() - 1);}int mergeSort(vector<int>& nums, int left, int right) {if(left >= right) return 0;int mid = (left + right) >> 1;int count = mergeSort(nums, left, mid) + mergeSort(nums, mid + 1, right);int cur1 = left, cur2 = mid + 1;int k = left;while(cur1 <= mid && cur2 <= right) {if(nums[cur1] <= nums[cur2]) {tmp[k++] = nums[cur1++];} else {tmp[k++] = nums[cur2++];count += (mid - cur1 + 1); // 批量统计}}while(cur1 <= mid) tmp[k++] = nums[cur1++];while(cur2 <= right) tmp[k++] = nums[cur2++];for(int i = left; i <= right; i++) {nums[i] = tmp[i];}return count;}
};
关键理解:
- 为什么排序了还能统计?→ 先统计再排序,count 已记录
- 为什么要递归?→ 让左右有序,才能批量统计
- 为什么
mid - cur1 + 1
?→ 左边剩余的都大于右边当前元素
复杂度: O(n log n)
3. 计算右侧小于当前元素的个数(LeetCode 315)⭐⭐⭐
题目: 返回数组 counts
,counts[i]
是 nums[i]
右侧更小的元素个数。
思路: 归并排序 + 索引数组
- 不直接排序
nums
,而是排序索引数组index
index[i]
记录原始位置- 统计时:
count[index[cur1]] += (cur2 - mid - 1)
代码框架:
class Solution {
public:vector<int> tmp, count, nums;vector<int> countSmaller(vector<int>& nums) {int n = nums.size();this->nums = nums;// 创建索引数组vector<int> index(n);for(int i = 0; i < n; i++) index[i] = i;count.resize(n, 0);tmp.resize(n);mergeSort(index, 0, n - 1);return count;}void mergeSort(vector<int>& index, int left, int right) {if(left >= right) return;int mid = (left + right) >> 1;mergeSort(index, left, mid);mergeSort(index, mid + 1, right);// 合并int cur1 = left, cur2 = mid + 1, k = left;while(cur1 <= mid && cur2 <= right) {if(nums[index[cur1]] <= nums[index[cur2]]) {count[index[cur1]] += (cur2 - mid - 1); // 统计tmp[k++] = index[cur1++];} else {tmp[k++] = index[cur2++];}}while(cur1 <= mid) {count[index[cur1]] += (right - mid);tmp[k++] = index[cur1++];}while(cur2 <= right) tmp[k++] = index[cur2++];for(int i = left; i <= right; i++) index[i] = tmp[i];}
};
关键点:
- 排序
index
,比较nums[index[i]]
- 统计
count[index[cur1]]
(用原始下标) - 左边被取走时统计
理解过程: 花了100分钟,但完全理解了索引数组的作用
复杂度: O(n log n)
4. 翻转对(LeetCode 493)⭐⭐
题目: 统计满足 i < j
且 nums[i] > 2 * nums[j]
的翻转对数量。
思路: 分两步
- Step 1:统计翻转对
- Step 2:正常归并排序
为什么要分两步?
- 统计条件:
nums[i] > 2 * nums[j]
- 归并条件:
nums[i] <= nums[j]
- 两个条件不一样,不能混在一起
代码:
class Solution {
public:vector<int> tmp;int reversePairs(vector<int>& nums) {tmp.resize(nums.size());return mergeSort(nums, 0, nums.size() - 1);}int mergeSort(vector<int>& nums, int left, int right) {if(left >= right) return 0;int mid = (left + right) >> 1;int count = mergeSort(nums, left, mid) + mergeSort(nums, mid + 1, right);// Step 1: 统计翻转对int cur1 = left, cur2 = mid + 1;while(cur1 <= mid) {while(cur2 <= right && nums[cur1] > 2LL * nums[cur2]) {cur2++;}count += (cur2 - mid - 1);cur1++;}// Step 2: 正常归并cur1 = left;cur2 = mid + 1;int k = left;while(cur1 <= mid && cur2 <= right) {if(nums[cur1] <= nums[cur2]) {tmp[k++] = nums[cur1++];} else {tmp[k++] = nums[cur2++];}}while(cur1 <= mid) tmp[k++] = nums[cur1++];while(cur2 <= right) tmp[k++] = nums[cur2++];for(int i = left; i <= right; i++) {nums[i] = tmp[i];}return count;}
};
关键点:
- 分两步:先统计,再归并
- 用
2LL
避免整数溢出 - 不能用统计条件来决定取谁
性能对比:
- 理论:比逆序对慢2倍(常数因子)
- 实际:慢1.3-1.5倍(缓存友好性)
复杂度: O(n log n)
核心收获
1. 归并排序的性能优化
全局tmp vs 局部tmp:
方式 | 时间 | 原因 |
---|---|---|
局部tmp | 904ms | 每次递归都分配内存 |
全局tmp | 60ms | 只分配一次 |
优化效果: 快了15倍!
教训: 能复用的资源就复用
2. 归并排序统计问题的规律
判断标准:
- 统计条件 = 归并条件的互补 → 1步(归并时统计)
- 统计条件 ≠ 归并条件的互补 → 2步(先统计,再归并)
3种情况:
- 逆序对: 条件互补,可以一起
- 翻转对: 条件不同,必须分开
- 右侧更小: 需要追踪每个元素,用索引数组
3. 索引数组的高级技巧
核心思想: 不排序元素本身,而是排序它们的"指针"
适用场景:
- 需要在排序的同时追踪原始位置
- 需要对每个元素单独统计结果
模板:
// 创建索引数组
vector<int> index(n);
for(int i = 0; i < n; i++) index[i] = i;// 排序索引,比较元素
排序:index
比较:nums[index[i]]
统计:count[index[i]]
4. 整数溢出的警惕
问题: 2 * nums[i]
可能溢出 int
范围
解决: 用 2LL
强制转成 long long
2 * nums[cur2] // ❌ 可能溢出
2LL * nums[cur2] // ✅ 不会溢出
易错点总结
易错点 | 错误写法 | 正确写法 | 影响 |
---|---|---|---|
临时数组 | 局部 tmp | 全局 tmp + resize | 性能差15倍 |
tmp索引 | k = 0 | k = left | 索引错位 |
cur2初始化 | cur2 = right | cur2 = mid + 1 | 跳过元素 |
索引数组比较 | index[cur1] | nums[index[cur1]] | 比较错误 |
混淆统计和归并 | 在归并时统计翻转对 | 分两步 | 逻辑错误 |
整数溢出 | 2 * nums[i] | 2LL * nums[i] | 溢出 |
后续计划
今天完成了归并排序的4道题,加上昨天的快速排序4道题,分治算法基本掌握了。
对比:
- 快速排序:三路分区 + 快速选择
- 归并排序:二路分治 + 合并统计
明天准备开始新的专题,期待新的挑战!
学习感悟:
- 今天最大的突破是理解了索引数组这个高级技巧
- 苏格拉底式学习虽然慢(第3题花了100分钟),但理解很深刻
- 性能优化(15倍提升)让我意识到细节的重要性
- 4道题形成了完整的归并排序知识体系
总用时: 约4小时
快排 vs 归并 对比:
对比项 | (快排) | (归并) |
---|---|---|
题目数量 | 4题 | 4题 |
学习时长 | 2.5小时 | 4小时 |
最难的题 | 第K个最大元素 | 右侧更小元素 |
最大收获 | 快速选择 | 索引数组 |