LeetCode 315. 计算右侧小于当前元素的个数
题目描述
给定一个整数数组 nums,返回数组 counts。
其中:
cpp
counts[i]
表示 nums[i] 右侧小于它的元素数量。
例如:
text*斜体样式*
输入:[5, 2, 6, 1]
输出:[2, 1, 1, 0]
小记:
与昨天我所写的题题型很像,都让人容易一眼想到暴力解法,直接套两层循环进去解题,但明显对于hard 难度的题目来说是不现实的,其时间复杂度是 O(N²)。当数组长度达到 10⁵ 时,会超时。今天要锻炼的是对于下标的操控能力.
这道题的核心思想是:
利用归并排序,把逐个比较转化为按段统计。
归并排序中的计数
归并排序会把数组分成左右两个区间。
递归结束后,左右区间都已经分别排好序。这里采用降序归并:
text
左区间:[较大 ... 较小]
右区间:[较大 ... 较小]
设:
cpp
cur1
指向左区间当前元素,
cpp
cur2
指向右区间当前元素。
如果:
cpp
num[cur1] <= num[cur2]
说明右边当前元素更大或者相等,先取右边元素,不进行计数。
如果:
cpp
num[cur1] > num[cur2]
由于右区间是降序排列的,所以从 cur2 到 right 的所有元素都小于 num[cur1]。
因此可以一次性统计:
cpp
right - cur2 + 1
而不需要逐个比较。
为什么需要保存原始下标
归并排序会改变元素在数组中的位置。例如:
text
原数组:[5, 2, 6, 1]
排序后:[6, 5, 2, 1]
但是答案必须记录到元素原来的位置:
text
5 原来的下标是 0
2 原来的下标是 1
6 原来的下标是 2
1 原来的下标是 3
因此使用 index 数组记录原始下标。
初始化时:
cpp
index[i] = i;
表示当前 num[i] 这个元素,原始下标就是 i。
之后每次移动元素时,必须让数值和下标一起移动:
cpp
numtmp[cur] = num[cur1];
indextmp[cur] = index[cur1];
这样:
text
数值 5 和原始下标 0
数值 6 和原始下标 2
始终保持绑定关系。
完整代码
cpp
#include <vector>
using namespace std;
class Solution
{
vector<int> ret;
vector<int> index;
int numtmp[500000];
int indextmp[500000];
public:
vector<int> countSmaller(vector<int>& num)
{
int n = num.size();
ret.assign(n, 0);
index.resize(n);
for (int i = 0; i < n; i++)
{
index[i] = i;
}
mergeSort(num, 0, n - 1);
return ret;
}
void mergeSort(vector<int>& num, int left, int right)
{
if (left >= right)
return;
int mid = left + (right - left) / 2;
mergeSort(num, left, mid);
mergeSort(num, mid + 1, right);
int cur1 = left;
int cur2 = mid + 1;
int cur = left;
while (cur1 <= mid && cur2 <= right)
{
if (num[cur1] <= num[cur2])
{
numtmp[cur] = num[cur2];
indextmp[cur] = index[cur2];
cur++;
cur2++;
}
else
{
ret[index[cur1]] += right - cur2 + 1;
numtmp[cur] = num[cur1];
indextmp[cur] = index[cur1];
cur++;
cur1++;
}
}
while (cur1 <= mid)
{
numtmp[cur] = num[cur1];
indextmp[cur] = index[cur1];
cur++;
cur1++;
}
while (cur2 <= right)
{
numtmp[cur] = num[cur2];
indextmp[cur] = index[cur2];
cur++;
cur2++;
}
for (int p = left; p <= right; p++)
{
num[p] = numtmp[p];
index[p] = indextmp[p];
}
}
};
cur 和 p 为什么直接使用下标
本代码中:
cpp
int cur = left;
所以临时数组使用的是当前区间的原始下标。
例如当前归并区间是:
text
[left, right] = [2, 3]
那么数据会写入:
cpp
numtmp[2]
numtmp[3]
复制时就可以直接使用:
cpp
num[p] = numtmp[p];
如果写成:
cpp
int cur = 0;
如果写成:
那么临时数组会从下标 `0` 开始使用:
numtmp[0]
numtmp[1]
...
此时复制回原数组时,就要通过 p - left 找到临时数组中的对应位置:
cpp
for (int p = left; p <= right; p++)
{
num[p] = numtmp[p - left];
index[p] = indextmp[p - left];
}
本代码采用的是:
cpp
int cur = left;
因此临时数组和原数组使用相同的下标,复制时直接使用 p 即可。
为什么判断条件要使用 <=
代码中的判断条件是:
cpp
if (num[cur1] <= num[cur2])
这里不能写成:
cpp
if (num[cur1] < num[cur2])
因为题目要求的是"严格小于"。
如果左右两个元素相等,它们之间不构成需要统计的关系。
例如:
text
nums = [-1, -1]
两个元素相等,答案应该是:
text
[0, 0]
如果使用 <,相等时会进入 else,从而错误地进行计数。
所以相等时应该优先取右边元素,但不能计数。
为什么答案要写到 ret[index[cur1]]
统计代码是:
cpp
ret[index[cur1]] += right - cur2 + 1;
这里的 cur1 是左区间当前元素的位置。
而:
cpp
index[cur1]
表示这个元素在原数组中的下标。
假设当前排序后的数组是:
text
num : [6, 5, 2, 1]
index : [2, 0, 1, 3]
此时:
text
num[1] = 5
index[1] = 0
说明当前的 5 原来位于下标 0。
如果此时发现 5 右侧有一个更小的元素,就应该更新:
cpp
ret[0]
而不是更新:
cpp
ret[1]
因此必须写成:
cpp
ret[index[cur1]]
不能写成:
cpp
ret[index[cur]]
因为 cur 只是临时数组的写入位置,不一定是左区间当前元素的位置。
以 [5, 2, 6, 1] 为例
原数组为:
text
[5, 2, 6, 1]
经过递归处理后,可以得到两个已经排好序的区间:
text
左区间:[5, 2]
右区间:[6, 1]
对应的原始下标为:
text
左区间下标:[0, 1]
右区间下标:[2, 3]
开始合并。
第一次比较:
text
5 和 6
因为:
text
5 <= 6
所以先取 6,不计数。
第二次比较:
text
5 和 1
因为:
text
5 > 1
右区间当前剩余的元素只有 1,所以:
cpp
right - cur2 + 1 = 1
此时给原下标为 0 的元素增加一次计数:
text
ret[0] += 1
第三次比较:
text
2 和 1
因为:
text
2 > 1
所以:
text
ret[1] += 1
最终得到:
text
ret = [2, 1, 1, 0]
其中:
5比右侧的2、1大,共2个;2比右侧的1大,共1个;6比右侧的1大,共1个;1右侧没有元素。
易错点总结
1. 数值和下标必须一起移动
cpp
numtmp[cur] = num[cur1];
indextmp[cur] = index[cur1];
不能只移动数值,不移动原始下标。
2. 相等时不能计数
cpp
if (num[cur1] <= num[cur2])
必须使用 <=。
3. 计数时使用 cur1
cpp
ret[index[cur1]] += right - cur2 + 1;
不能使用 index[cur]。
4. 临时数组下标要保持统一
如果:
cpp
int cur = left;
复制时使用:
cpp
numtmp[p]
如果:
cpp
int cur = 0;
复制时使用:
cpp
numtmp[p - left]
复杂度分析
归并排序一共有 O(log N) 层,每一层需要遍历当前区间中的元素,因此:
text
时间复杂度:O(N log N)
空间复杂度:O(N)
其中,额外空间主要用于:
cpp
index
numtmp
indextmp
总结
这道题的核心不是直接排序,而是在归并排序的过程中完成统计。
整个过程可以概括为:
text
递归统计左区间内部的答案
+
递归统计右区间内部的答案
+
合并时统计跨左右区间的答案
最关键的地方有两个:
第一,利用有序区间一次性统计多个元素:
cpp
right - cur2 + 1
第二,使用 index 保存原始下标,并让数值和下标一起移动:
cpp
ret[index[cur1]]
这也是这道题和普通归并排序之间最主要的区别。