leetcode中归并排序的应用

本文深入探讨了归并排序在解决逆序对、计算右侧较小数、翻转对及区间和等问题中的应用。通过实例讲解了如何利用归并排序的特性,优化算法复杂度,高效解决这类问题。

(A)逆序对个数:https://leetcode-cn.com/problems/shu-zu-zhong-de-ni-xu-dui-lcof/

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

思路:这一题是典型的归并排序的应用。归并过程中,[left, mid] 和 [mid + 1, right]分别有序。之后将 nums[i] 和 nums[j]逐个比较。

①考虑右半部分的每个元素对应逆序对的数量,即出现 nums[i] > nums[j]的情况,这说明下标 [i, mid] 中的所有数,都可以与下标 j 的数构成逆序对,因此逆序对的数量 += mid - i + 1。对于这一种思路,因为考虑的是[mid+1, right]能构成的逆序对,当退出(i <= mid && j <= right)时,不管是 i <= mid(说明后半部分遍历完),还是 j <= right (说明前半部分所有的数都要比 [j, right] 小), 都不存在另外的逆序对需要计算。

class Solution {
private:
    vector<int> tmp;
    int cnt = 0;
    void _mergeSort(vector<int>& nums, int left, int right)
    {
        if(left >= right) return;
        int mid = left + (right - left) / 2;
        _mergeSort(nums, left, mid);
        _mergeSort(nums, mid + 1, right);
        int i = left, j = mid + 1, k = 0;
        while(i <= mid && j <= right)
        {
            if(nums[i] <= nums[j]) tmp[k++] = nums[i++];
            //当nums[i] > nums[j]时,说明j与[i, mid]所有数构成逆序对
            else
            {
                cnt += mid - i + 1;
                tmp[k++] = nums[j++];
            }
        }
        while(i <= mid) tmp[k++] = nums[i++];
        while(j <= right) tmp[k++] = nums[j++];
        for (int l = left; l <= right; ++l)
        {
            nums[l] = tmp[l - left];
        }
    }
public:
    int reversePairs(vector<int>& nums) {
        tmp.resize(50010);
        _mergeSort(nums, 0, nums.size() - 1);
        return cnt;
    }
};

②也可以考虑左半部分的每个元素对应逆序对的数量,即出现 nums[i] <= nums[j]的情况,这说明 下标 [mid+1, j) 的所有数,都可以与下标 i 的数构成逆序对,因此逆序对的数量 += j - mid - 1;不过这一种思路需要注意的是如果退出(i <= mid && j <= right)时仍然有 i <= right, 说明[i, mid]的所有数都要比后半部分大, 那么对每一个下标[i, mid] 的数,都与[mid+1, right] 中的所有数构成逆序对。

class Solution {
private:
    vector<int> tmp;
    int cnt = 0;
    void _mergeSort(vector<int>& nums, int left, int right)
    {
        if(left >= right) return;
        int mid = left + (right - left) / 2;
        _mergeSort(nums, left, mid);
        _mergeSort(nums, mid + 1, right);
        int i = left, j = mid + 1, k = 0;
        while(i <= mid && j <= right)
        {
            //当 nums[i] <= nums[j]时,说明i与[mid+1, j)的所有数构成逆序对
            if(nums[i] <= nums[j])
            {
                cnt += j - (mid + 1);
                tmp[k++] = nums[i++];
            }
            else tmp[k++] = nums[j++];
        }
        //注意这里需要考虑剩下的逆序对,因为[i, mid]的所有数都可以与[mid+1, right]构成逆序对
        while(i <= mid)
        {
            cnt += j - (mid + 1);
            tmp[k++] = nums[i++];
        }
        while(j <= right) tmp[k++] = nums[j++];
        for (int l = left; l <= right; ++l)
        {
            nums[l] = tmp[l - left];
        }
    }
public:
    int reversePairs(vector<int>& nums) {
        tmp.resize(50010);
        _mergeSort(nums, 0, nums.size() - 1);
        return cnt;
    }
};

要注意求逆序对的问题有时不是很明显。例如,冒泡排序需要交换的次数就是逆序对的个数,因为每次交换最多只能消除一对逆序对,所以最小交换次数就是逆序对的个数。

例如下面这一题:

https://www.nowcoder.com/practice/6ef4d5e5767b470da56e64ee48e0abea?tpId=146&&tqId=33964&rp=1&ru=/ta/exam-cmbxyk&qru=/ta/exam-cmbxyk/question-ranking

(B) https://leetcode-cn.com/problems/count-of-smaller-numbers-after-self/

Leetcode 315. 计算右侧小于当前元素的个数。

给定一个整数数组 nums,按要求返回一个新数组 counts。数组 counts 有该性质: counts[i] 的值是  nums[i] 右侧小于 nums[i] 的元素的数量。

这一题在逆序对问题的基础上增加了下标的对应问题。由于直接归并排序时,元素的下标会改变,这样无法对应到原来的位置。因此,对于这一题应该另外存储元素的下标,利用返回的数组counts,在找到元素可以构成的逆序对数量时,加到counts中它原来的下标位置。

与逆序对问题不同的是,这里要求的是每个元素右边小于它的数量,因此应当使用逆序对问题的思路②,即对左半部分的每个元素,考察它能组成的逆序对的数量。当 nums[i] <= nums[j] 时, 说明 i 与 [mid + 1,j) 构成逆序对,即nums[i]对应的逆序对数量 += (j - mid - 1)。同时还要注意,当退出循环时如果仍有i <= mid,说明 [i, mid] 的每个元素都与 [mid + 1, right] 中的所有元素构成逆序对,逆序对数量都应当 += (right - mid)。 

class Solution {
private:
    vector<int> counts;
    vector<pair<int,int>> tmp;
    //v:存储元素值和原始位置,(val, index)
    void _mergeSort(vector<pair<int,int>>& v, int left, int right)
    {
        if(left >= right) return;
        int mid = left + (right - left) / 2;
        _mergeSort(v, left, mid);
        _mergeSort(v, mid + 1, right);
        int i = left, j = mid + 1, k = 0;
        tmp.resize(right - left + 1);
        while(i <= mid && j <= right)
        {
            //因为要求的是右边小于本身的数量,因此分别计算左半部分的每个元素对应逆序对的数量。
            if(v[i].first <= v[j].first)
            {
                counts[v[i].second] += j - (mid + 1);
                tmp[k++] = v[i++];
            }
            else tmp[k++] = v[j++];
        }
        //需要考虑剩下的逆序对数量
        while(i <= mid)
        {
            counts[v[i].second] += j - (mid + 1);
            tmp[k++] = v[i++];
        }
        while(j <= right) tmp[k++] = v[j++];
        for (int l = left; l <= right; ++l)
        {
            v[l] = tmp[l-left];
        }
        for (int l = left; l <= right; ++l)
        {
            v[l] = tmp[l - left];
        }
    }
public:
    vector<int> countSmaller(vector<int>& nums) {
        counts.resize(nums.size());
        vector<pair<int,int>> v(nums.size());
        for (int i = 0; i < nums.size(); ++i)
        {
            v[i].first = nums[i];
            v[i].second = i;
        }
        _mergeSort(v, 0, v.size() - 1);
        return counts;
    }
};

(C) https://leetcode-cn.com/problems/reverse-pairs/

493. 翻转对 给定一个数组 nums ,如果 i < j 且 nums[i] > 2*nums[j] 我们就将 (i, j) 称作一个重要翻转对。你需要返回给定数组中的重要翻转对的数量。

思路:

这一题与逆序对问题不同之处在于,如何得到 [left, mid] 和 [mid + 1, right] 中的元素构成的翻转对的数量。主要思想是,对左半部分的每个元素,依次考察它与右半部分的元素能够组成的翻转对数量。可以采用从前向后和从后向前两种方式。

①从前向后遍历的方式,即指针ii从left遍历到mid,指针j 初始i指向mid + 1。对每个 i, 找到第一个不满足 nums[i] > 2 * nums[j]的 j 的位置,那么 i 能构成翻转对的数量是 j - (mid + 1)。同时,由于左半部分和右半部分都有序的性质,可以想到,对于 i+ 1,它对应的不满足 nums[i+1] > 2 * nums[j'] 的 j' 不可能小于 i 对应的 j,因此不必要将 j 重新移到 mid +1 再遍历,只需要在前面的基础上++j即可 

class Solution {
private:
    int cnt = 0;
    vector<int> tmp;
    void _mergeSort(vector<int>& nums, int left, int right)
    {
        if(left >= right) return;
        int mid = left + (right - left) / 2;
        _mergeSort(nums, left, mid);
        _mergeSort(nums, mid+1, right);
        //注意得到[left,mid]和[mid+1, right]能够成翻转对数量的计算方法,从前向后遍历
        int j = mid + 1;
        for (int i = left; i <= mid; ++i)
        {
            while(j <= right && (long long)nums[i] > 2 * (long long)nums[j]) j++;
            cnt += j - mid - 1;
        }
        int k = 0;
        int i = left, j = mid + 1;
        while(i <= mid && j <= right)
        {
            if(nums[i] <= nums[j]) tmp[k++] = nums[i++];
            else tmp[k++] = nums[j++];
        }
        while(i <= mid) tmp[k++] = nums[i++];
        while(j <= right) tmp[k++] = nums[j++];
        for (int l = left; l <= right; ++l)
        {
            nums[l] = tmp[l-left];
        }
    }
public:
    int reversePairs(vector<int>& nums) {
        tmp.resize(50010);
        _mergeSort(nums, 0, nums.size() - 1);
        return cnt;
    }
};

②从后向前遍历的方式,即指针 i 指向 mid, 指针 j 指向 right,比较 i 和 j是否满足翻转对的定义,如果满足,那么i 与 [mid + 1, j]的所有元素都构成翻转对,之后i--;否则 j--。当 i < left 或者 j < mid+1时,说明不可能再有翻转对,遍历结束。

class Solution {
private:
    int cnt = 0;
    vector<int> tmp;
    void _mergeSort(vector<int>& nums, int left, int right)
    {
        if(left >= right) return;
        int mid = left + (right - left) / 2;
        _mergeSort(nums, left, mid);
        _mergeSort(nums, mid+1, right);
        //注意得到[left,mid]和[mid+1, right]能够成翻转对数量的计算方法,从后向前遍历
        int i = mid, j = right;
        while(i >= left && j >= mid + 1)
        {
            if((long long)nums[i] > 2 * (long long)nums[j])
            {
                cnt += j - mid;
                i--;
            }
            else j--;
        }
        int k = 0;
        i = left, j = mid + 1;
        while(i <= mid && j <= right)
        {
            if(nums[i] <= nums[j]) tmp[k++] = nums[i++];
            else tmp[k++] = nums[j++];
        }
        while(i <= mid) tmp[k++] = nums[i++];
        while(j <= right) tmp[k++] = nums[j++];
        for (int l = left; l <= right; ++l)
        {
            nums[l] = tmp[l-left];
        }
    }
public:
    int reversePairs(vector<int>& nums) {
        tmp.resize(50010);
        _mergeSort(nums, 0, nums.size() - 1);
        return cnt;
    }
};

(D) https://leetcode-cn.com/problems/count-of-range-sum/

327. 区间和的个数 

给定一个整数数组 nums,返回区间和在 [lower, upper] 之间的个数,包含 lower 和 upper。
区间和 S(i, j) 表示在 nums 中,位置从 i 到 j 的元素之和,包含 i 和 j (i ≤ j)。

说明:
最直观的算法复杂度是 O(n2) ,请在此基础上优化你的算法。

思路:

这一题初看似乎和归并没有什么关系,首先考虑如何用 O(n^2)的复杂度得到解。

(1) 求满足条件的区间和个数,显然用i,j两个指针遍历所有可能的端点,然后对每个区间分别求和判断,是一种解法,但是每次都要对区间重新求和是重复计算,可以初始化时设置 sum 数组, sum[i]代表从 nums[0]到nums[i-1]的和,sum[0]=0,那么从i到j的区间和就可以表示为sum[j]-sum[i]。复杂度是O(n^2)。

(2) 既然从 sum[j] - sum[i] 中可以得到 [i, j]的区间和,那么如果对sum数组使用归并排序,就可以利用数组有序的性质,减少不必要的[i, j]的判断,同时,因为归并是自底向上返回的,因此计算时左边和右边的相对顺序并没有改变。具体而言,对于[left, mid] 和 [mid + 1, right] ,遍历[left, mid]中的每个元素 i, 考察右半部分能够满足 sum[j] - sum[i] 符合条件的 j 的数量。为了减少不必要的计算,对于j的遍历,可以设置左边界和右边界 rl 和 rr, rl指向第一个满足 sum[j] - sum[i]符合条件的j的位置, rr指向最后一个符合条件的j的位置的下一个元素,这样,对于 i 而言, 对应的 j 的数量是 rr - rl。而因为左半部分和右半部分分别有序, 对于i+1而言, 满足条件的 rl不会比上一个rl小,满足条件的rr也不会比上一个rr小,因此,只需要满足条件时执行 rl++, rr++,而不必从头遍历。

最后,这一题的归并排序函数应当是有返回值的。这是因为,上面所说的计算的结果,是针对排好序的[left, mid] 和 [mid + 1, right] 区间而言的。而排序前的 [left, mid] [mid+1, right]这两个区间的返回值,也应当进行计算,这样就形成了递归。

class Solution {
private:
    int low, up;
    int _mergeSort(vector<int>& sum, int left, int right)
    {
        if(left >= right) return 0;
        int mid = left + (right - left) / 2;
        int l = _mergeSort(sum, left, mid);
        int r = _mergeSort(sum, mid + 1, right);
        int cnt = 0;
        int rl = mid + 1, rr = mid + 1;
        for (int i = left; i <= mid; ++i)
        {
            while(rl <= right && sum[rl] - sum[i] < low) rl++;
            while(rr <= right && sum[rr] - sum[i] <= up) rr++;
            cnt += rr - rl;
        }
        inplace_merge(sum.begin() + left, sum.begin() + mid + 1, sum.begin() + right + 1);
        return l + r + cnt;
    }
public:
    int countRangeSum(vector<int>& nums, int lower, int upper) {
        low = lower;
        up = upper;
        vector<int> sum(nums.size() + 1, 0);
        for (int i = 0; i < nums.size(); ++i)
        {
            sum[i+1] = sum[i] + nums[i];
        }
        return _mergeSort(sum, 0, sum.size() - 1);
    }
};

事实上,这一题也可以直接用前缀和+hash表的方法做,具体做法请参见另一篇博客https://blog.csdn.net/chch1996/article/details/106383874

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值