LeetCode 315. 计算右侧小于当前元素的个数
题目描述


题意分析
为原数组的每个下标
i统计:有多少个j > i满足nums[j] < nums[i]。返回的计数数组必须仍与原下标一一对应,不能按数值排序后输出答案。“更小”是严格小于,相等值不算;右侧多个相同的小值分别占据不同位置,都要分别计数。最后一个位置右侧为空,计数自然为零。
解法:归并排序统计逆序关系
核心思路
[!blue]
直接为每个位置扫描右侧需要平方时间。把数组按原下标分成左右两段后,左段内和右段内的贡献可以递归求出,剩下只需统计“左段元素与右段更小元素”形成的跨段关系。
为了加速跨段比较,让递归返回的两个子段按数值有序。但不能丢掉答案对应的原位置,所以实际排序的是
indexes下标数组,比较时读取nums[indexes[i]],计数始终写入counts[原下标]。每个子段包含的原下标范围不变,因此左段所有元素在原数组中都位于右段元素之前。归并时,用
rightMoved记录本次合并中已先放入结果的右段元素数量。如果当前右值严格小于当前左值,就先取右值并加一;由于左段也有序,它比当前以及后续尚未处理的左值都小。当轮到某个左元素输出时,先前取走的
rightMoved个右元素全部严格更小,而仍未取走的右元素都不小于它,所以这就是该左元素在本次合并中新增的完整贡献。相等时必须先取左侧,避免把相等值记入这个计数;右段耗尽后,剩余左元素也仍需加上累计贡献。同段关系由递归处理,跨段关系只在它们第一次分属左右两半的那次合并中统计,因此每个合法数对恰好计一次。最后将有序下标复制回当前区间,供上一层继续归并,原
nums内容无需修改。
解题步骤
- 初始化
indexes[i] = i、临时下标数组temp和答案数组counts。- 递归处理半开区间
[left, right),长度不超过一时直接返回。- 两侧处理完成后,从各自开头归并,并将本轮
rightMoved初始化为零。- 右侧值严格更小时取右侧并增加计数;否则取左侧,把
rightMoved加到该元素原下标的答案。某侧耗尽时继续按相同规则处理另一侧。- 将
temp的当前区间复制回indexes;全部归并结束后,按原下标返回counts。
代码实现
class Solution {
public List<Integer> countSmaller(int[] nums) {
int n = nums.length;
int[] indexes = new int[n];
int[] temp = new int[n];
int[] counts = new int[n];
for (int i = 0; i < n; i++) {
indexes[i] = i;
}
mergeSort(nums, indexes, temp, counts, 0, n);
List<Integer> answer = new ArrayList<>(n);
for (int count : counts) {
answer.add(count);
}
return answer;
}
private void mergeSort(
int[] nums, int[] indexes, int[] temp, int[] counts, int left, int right) {
if (right - left <= 1) {
return;
}
int mid = left + (right - left) / 2;
mergeSort(nums, indexes, temp, counts, left, mid);
mergeSort(nums, indexes, temp, counts, mid, right);
int i = left;
int j = mid;
int rightMoved = 0;
for (int k = left; k < right; k++) {
if (j == right || (i < mid && nums[indexes[i]] <= nums[indexes[j]])) {
// 右段已经输出的值严格更小,把数量记回当前左元素的原下标。
counts[indexes[i]] += rightMoved;
temp[k] = indexes[i++];
} else {
temp[k] = indexes[j++];
rightMoved++;
}
}
for (int k = left; k < right; k++) {
// 只回写当前区间的有序下标,供上层继续正确归并。
indexes[k] = temp[k];
}
}
}
func countSmaller(nums []int) []int {
n := len(nums)
indexes := make([]int, n)
temp := make([]int, n)
counts := make([]int, n)
for i := range indexes {
indexes[i] = i
}
mergeCount(nums, indexes, temp, counts, 0, n)
return counts
}
func mergeCount(nums, indexes, temp, counts []int, left, right int) {
if right-left <= 1 {
return
}
mid := left + (right-left)/2
mergeCount(nums, indexes, temp, counts, left, mid)
mergeCount(nums, indexes, temp, counts, mid, right)
i, j, rightMoved := left, mid, 0
for k := left; k < right; k++ {
if j == right || (i < mid && nums[indexes[i]] <= nums[indexes[j]]) {
// 右段已经输出的值严格更小,把数量记回当前左元素的原下标。
counts[indexes[i]] += rightMoved
temp[k] = indexes[i]
i++
} else {
temp[k] = indexes[j]
j++
rightMoved++
}
}
// 只回写当前区间的有序下标,供上层继续正确归并。
copy(indexes[left:right], temp[left:right])
}
复杂度分析
- 时间复杂度:$O(n \log n)$。归并排序有 $O(\log n)$ 层,每层合并全部
n个下标。- 空间复杂度:$O(n)$。下标数组、临时数组和答案数组占线性空间,递归栈为 $O(\log n)$。
关键点总结
[!green]
- 必须排序原下标而不是只排序数值,否则无法把计数写回原位置。
rightMoved只统计已经越过当前左元素的右段元素;它们原本在右侧,且值更小,正好是答案贡献。- 相等时先取左段,才能排除相等元素,落实“严格小于”。
- 这与普通逆序对归并的区别在于:普通题只累加总数,本题要把贡献记到每个左元素的原下标。
易错点总结
[!yellow]
- 相等时先取右段,会错误地把相等值记作严格更小,必须优先取左段。
rightMoved是一次合并内的局部计数,每次合并都要从零开始,答案数组则累加各层贡献。- 只排序数值或只累计全局逆序对总数,都无法恢复每个原下标自己的答案。
- 右段耗尽后,剩余左元素仍要加上已经移走的右侧数量,不能只复制它们。
- 合并后要回写有序下标,否则上一层无法利用两侧有序性正确统计。
相似题目
| 题目 | 难度 | 关联与区别 |
|---|---|---|
| 493. 翻转对 | 困难 | 同样在归并两个有序半区时计跨区间数对,原题条件为左值大于右值两倍。 |
| 327. 区间和的个数 | 困难 | 同样利用归并计数消除一层枚举,原题对前缀差的上下界范围计数。 |
转载与许可
许可
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!