力扣每日一题:计算右侧小于当前元素的个数(hard)

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]

由于右区间是降序排列的,所以从 cur2right 的所有元素都小于 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];
        }
    }
};

curp 为什么直接使用下标

本代码中:

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]]

这也是这道题和普通归并排序之间最主要的区别。

相关推荐
鹿角片ljp1 小时前
LeetCode 142:环形链表 II |HashSet 保底解 + Floyd 快慢指针找环入口
算法·leetcode·链表
Fairy要carry1 小时前
初创实习-IMU模型
人工智能·算法·机器学习
a187927218311 小时前
【算法】回溯算法(一):从一道 IP 题到万能模板
算法·leetcode·go·回溯·leetcode93·ip复原·算法讲解
鹿角片ljp1 小时前
LeetCode 19:删除链表的倒数第 N 个节点复盘| dummy + 快慢指针
算法·leetcode·链表
玖玥拾1 小时前
LeetCode 290 单词规律
算法·leetcode·哈希算法·散列表
liliangcsdn2 小时前
多空价差收益序列汇总统计的分析和代码示例
人工智能·算法·机器学习
啥都想学点的研究生2 小时前
一篇文章讲清楚:Adaboost算法与GBDT
人工智能·算法
旖旎夜光2 小时前
LeetCode 525:连续数组(前缀和) —— 题解
数据结构·c++·算法·leetcode·前缀和
zander2582 小时前
LeetCode 32. 最长有效括号
算法