力扣每日一题:计算右侧小于当前元素的个数(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]]

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

相关推荐
倒头就睡的小比特2 天前
算法竞赛C++常用的STL
c++·算法
小羊没烦恼!2 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
猎头南楼2 天前
知识社区推荐系统实践:新用户冷启动与长短期兴趣建模的挑战 资深推荐算法工程师
人工智能·深度学习·算法·机器学习
旖旎夜光2 天前
力控面试题 01.01: 判定字符是否唯一(位运算) —— 题解
c++·学习·算法·leetcode·力控
wzdark2 天前
大规模并行计算中的负载均衡算法研究4
算法
Because_of_Her12 天前
并查集-听课笔记
笔记·算法·并查集
码流子2 天前
高速公路安全监测实践:碰撞监测预警+物联网底座,从感知到处置的闭环
大数据·人工智能·物联网·算法·架构
another heaven2 天前
【算法/C++ MD5算法能否逆解码?原理、C++实现与同类哈希算法对比】
c++·算法·哈希算法
wzdark2 天前
从算法设计模式看编程思维的抽象能力4
算法
2601_962218612 天前
万象生鲜系统称重自动多退少补算法解决生鲜非标品痛点
大数据·数据库·人工智能·python·算法