[剑指Offer]:数组中的逆序对


题目描述

在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。

示例 1:

输入: [7,5,6,4]
输出: 5

题解思路

方法一:暴力解法(超时)

使用两层 for 循环枚举所有的数对,逐一判断是否构成逆序关系。

复杂度分析:

  • 时间复杂度: O ( N 2 ) O(N^2) O(N2),这里 N 是数组的长度;
  • 空间复杂度: O ( 1 ) O(1) O(1)

代码实现:

class Solution {
public:
    int reversePairs(vector<int>& nums) {
        int count = 0;
        for(int i = 0; i < nums.size(); ++i){
            for(int j = i+1; j < nums.size(); ++j){
                if(nums[i] > nums[j]) ++ count;
            }
        }
        return count;
    }
};
方法二:归并排序
  1. 本题其实就是在对一个数组进行归并排序的基础上,增加了一个统计逆序对数目的障眼法,其实还是归并排序。
  2. 如果了解归并排序的话,就会想到我们可以用分治的思想,将给定的 nums 先一分为二,统计左半部分的逆序对数目,再统计右半部分的逆序对数目,以及统计跨越左半部分和右半部分的逆序对数目,然后将三者相加即可。

案例演示:假设 nums = [8,7,6,5,4,3,2,1]

  1. 将 nums 一分为二。则有 nums1 = [8,7,6,5],nums2 = [4,3,2,1]。那么 nums 的逆序对总数就等于 nums1 中的逆序对数目 + nums2 中的逆序对数目 + 跨越 nums1 和 nums2 的逆序对数目。接下来我们只演示 nums1 的后续步骤,因为 nums2 同理。
  2. 将 nums1 再一分为二。则有 nums3 = [8,7],nums4 = [6,5]。那么 nums1 的逆序对总数就等于 nums3 中的逆序对数目 + nums4 中的逆序对数目 + 跨越 nums3 和 nums4 的逆序对数目。接下来我们只演示 nums3 的后续步骤,因为 nums4 同理。
  3. 将 nums3 再一分为二。则有 nums5 = [8],nums6 = [7],而且 nums5 和 nums6 不能接着分了,因为它们分别只剩一个元素,不构成数对。这个时候,对于 nums5 和 nums6 来说,它们各自的逆序对数目都是 0。而对于 nums3 来说,它左半部分的逆序对数目等于 nums5 的逆序对数目(也就是 0);它右半部分的逆序对数目等于 nums6 的逆序对数目(也就是 0);它跨越左半部分和右半部分的逆序对数目是 1(因为 [8,7] 构成了一个逆序对),所以 nums3 的逆序对数目为 0 + 0 + 1 = 1。
  4. 而对于 nums1 来说,nums3 的逆序对数目等于 nums1 左半部分的逆序对数目,也就是我们刚刚求出来的 1。这时候我们还需要去求 nums1 右半部分的逆序对数目(也就是 nums4 的逆序对数目),再求跨越 nums3 和 nums4 的逆序对数目,然后再如法炮制算 nums2,然后才能得到最终 nums 的逆序对数目。

复杂度分析:

  • 时间复杂度:同归并排序 O ( n l o g n ) O(nlogn) O(nlogn)。这里 n 是数组的长度;
  • 空间复杂度:同归并排序 O ( n ) O(n) O(n),因为归并排序需要用到一个临时数组。

代码实现:

class Solution {
public:
    int reversePairs(vector<int>& nums){
        int sz = nums.size();
        // 若不存在数对,直接 return 0
        if (sz < 2) return 0;

        vector<int> tmp(sz);
        return mergeSort(nums, tmp, 0, sz-1);
    }
private:
    int mergeSort(vector<int>& nums, vector<int>& tmp, int l, int r){
        if (l >= r) return 0;   // 递归终止条件是只剩一个元素了(即不存在数对了)

        int mid = l + (r - l) / 2;  // 此算式等同于 (l + r) / 2,是为了避免溢出
        int leftPairs = mergeSort(nums, tmp, l, mid);    // 计算左半部分的逆序对
        int rightPairs = mergeSort(nums, tmp, mid+1, r); // 计算右半部分的逆序对
        
        if(nums[mid] <= nums[mid+1]){
            // 即如果左右都已排好序,而且左边的最大值 <= 右边的最小值,那么就不存在跨越左边和右边的逆序对了
            return leftPairs + rightPairs;
        }
        int crossPairs = mergeAndCount(nums, tmp, l, mid, r);   // 计算跨越左边和右边的逆序对

        return leftPairs + rightPairs + crossPairs;
    }
    
    // 本函数的前提条件是:左半部分和右半部分都是已经按升序排好序了的
    int mergeAndCount(vector<int>& nums, vector<int>& tmp, int l, int mid, int r){
        // 因为本函数是从左右部分都只有一个元素的情况开始运行的(自底向上),所以是可以保证前提条件的
        for(int i = l; i <= r; ++i){
            tmp[i] = nums[i];   // 先填充 tmp 辅助数组
        }
        int i = l, j = mid+1;   // i 和 j 分别是左半部分和右半部分的起点
        int count = 0;  // count 用来统计逆序对数量

        for(int k = l; k <= r; ++k){
            if(i == mid + 1){
                // 假如 i 已经越过左边的边界,直接填充右半部分进 nums;
                nums[k] = tmp[j];
                ++j;
            }
            else if(j == r + 1){
                // 假如 j 已经越过右边的边界,直接填充左半部分进 nums
                nums[k] = tmp[i];
                ++i;
            }
            else if(tmp[i] <= tmp[j]){
                // 假如左边小于等于右边,那就不是逆序对,不用修改 count
                nums[k] = tmp[i];
                ++i;
            }
            else if(tmp[i] > tmp[j]) {
                // 假如左边大于右边,是逆序对,count += 当前左边 [i, mid] 的所有元素
                // 因为假如左边是 [7,8],右边是[5,6],然后 i 指向 7,j 指向 5
                // 那么 5 和 7、8 都构成了逆序对,也就是此时有两对新的逆序对
                // 所以可以总结出规律:count += mid - i + 1
                nums[k] = nums[j];
                count += mid - i + 1;
                ++j;
            }
        }
        return count;
    }
};
方法三:散化树状数组(未掌握)

预备知识

「树状数组」是一种可以动态维护序列前缀和的数据结构,它的功能是:

  • 单点更新 update(i, v): 把序列 i 位置的数加上一个值 v,这题 v=1
  • 区间查询 query(i): 查询序列 [1⋯i] 区间的区间和,即 i 位置的前缀和

修改和查询的时间代价都是 O(logn),其中 n 为需要维护前缀和的序列的长度。

思路

记题目给定的序列为 a,我们规定 ai 的取值集合为 a 的「值域」。我们用桶来表示值域中的每一个数,桶中记录这些数字出现的次数。假设 a={5,5,2,3,6},那么遍历这个序列得到的桶是这样的:

index  ->  1 2 3 4 5 6 7 8 9
value  ->  0 1 1 0 2 1 0 0 0

我们可以看出它第 i−1 位的前缀和表示「有多少个数比 i 小」。那么我们可以从后往前遍历序列 a,记当前遍历到的元素为 ai ,我们把 ai 对应的桶的值自增 1,把 i−1 位置的前缀和加入到答案中算贡献。为什么这么做是对的呢,因为我们在循环的过程中,我们把原序列分成了两部分,后半部部分已经遍历过(已入桶),前半部分是待遍历的(未入桶),那么我们求到的 i−1 位置的前缀和就是「已入桶」的元素中比 ai 大的元素的总和,而这些元素在原序列中排在 ai 的后面,但它们本应该排在 ai 的前面,这样就形成了逆序对。

我们显然可以用数组来实现这个桶,可问题是如果 ai 中有很大的元素,比如 109 ,我们就要开一个大小为 109 的桶,内存中是存不下的。这个桶数组中很多位置是 0,有效位置是稀疏的,我们要想一个办法让有效的位置全聚集到一起,减少无效位置的出现,这个时候我们就需要用到一个方法——离散化。

离散化一个序列的前提是我们只关心这个序列里面元素的相对大小,而不关心绝对大小(即只关心元素在序列中的排名);离散化的目的是让原来分布零散的值聚集到一起,减少空间浪费。那么如何获得元素排名呢,我们可以对原序列排序后去重,对于每一个 ai 通过二分查找的方式计算排名作为离散化之后的值。当然这里也可以不去重,不影响排名。

分析复杂度:

  • 时间复杂度:离散化的过程中使用了时间代价为 O(nlogn) 的排序,单次二分的时间代价为 O(logn),一共有 n 次,总时间代价为 O(nlogn);循环执行 n 次,每次进行 O(logn) 的修改和 O(logn) 的查找,总时间代价为 O(nlogn)。故渐进时间复杂度为 O(nlogn)。
  • 空间复杂度:树状数组需要使用长度为 n 的数组作为辅助空间,故渐进空间复杂度为 O(n)。

代码实现:

class BIT {
private:
    vector<int> tree;
    int n;

public:
    BIT(int _n): n(_n), tree(_n + 1) {}

    static int lowbit(int x) {
        return x & (-x);
    }

    int query(int x) {
        int ret = 0;
        while (x) {
            ret += tree[x];
            x -= lowbit(x);
        }
        return ret;
    }

    void update(int x) {
        while (x <= n) {
            ++tree[x];
            x += lowbit(x);
        }
    }
};

class Solution {
public:
    int reversePairs(vector<int>& nums) {
        int n = nums.size();
        vector<int> tmp = nums;
        // 离散化
        sort(tmp.begin(), tmp.end());
        for (int& num: nums) {
            num = lower_bound(tmp.begin(), tmp.end(), num) - tmp.begin() + 1;
        }
        // 树状数组统计逆序对
        BIT bit(n);
        int ans = 0;
        for (int i = n - 1; i >= 0; --i) {
            ans += bit.query(nums[i] - 1);
            bit.update(nums[i]);
        }
        return ans;
    }
};

如有帮助到您,可以多多点赞、评论鼓励哟~~~

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值