优选算法之 分治-归并排序

目录

一、归并排序

1. 题目链接:912. 排序数组

2. 题目描述:

3. 解法

🌴算法思路:

🌴算法代码:

二、数组中的逆序对

1. 题目链接:LCR 170. 交易逆序对的总数

2. 题目描述:

3. 解法

🌴算法思路:

🌴算法代码:

三、计算右侧小于当前元素的个数

1. 题目链接:315. 计算右侧小于当前元素的个数

2. 题目描述:

3. 解法

🌴算法思路:

🌴算法流程:

🌴算法代码:

四、翻转对

1. 题目链接:493. 翻转对

2. 题目描述:

3. 解法

🌴算法思路:

🌴算法代码:


一、归并排序

1. 题目链接:912. 排序数组

2. 题目描述:

给你一个整数数组 nums,请你将该数组升序排列。

你必须在 不使用任何内置函数 的情况下解决问题,时间复杂度为 O(nlog(n)),并且空间复杂度尽可能小。

示例 1:

输入:nums = [5,2,3,1]
输出:[1,2,3,5]

示例 2:

输入:nums = [5,1,1,2,0,0]
输出:[0,0,1,1,2,5]

提示:

  • 1 <= nums.length <= 5 * 104
  • -5 * 104 <= nums[i] <= 5 * 104

3. 解法

🌴算法思路:

归并排序的流程充分的体现了「分而治之」的思想,大体过程分为两步:

  • 分:将数组⼀分为二为两部分,一直分解到数组的长度为 1 ,使整个数组的排序过程被分为「左半部分排序」 + 「右半部分排序」;
  • 治:将两个较短的「有序数组合并成⼀个长的有序数组」,一直合并到最初的长度。

🌴算法代码:

class Solution 
{
    vector<int> tmp;

public:
    vector<int> sortArray(vector<int>& nums) 
    {
        tmp.resize(nums.size());
        mergeSort(nums, 0, nums.size() - 1);
        return nums;
    }
    void mergeSort(vector<int>& nums, int left, int right) 
    {
        if (left >= right) return;
        
        // 1. 选择中间点划分区间
        int mid = (left + right) >> 1;

        // [left, mid] [mid + 1, right]
        // 2. 把左右区间排序
        mergeSort(nums, left, mid);
        mergeSort(nums, mid + 1, right);

        // 3. 合并两个有序数组
        int cur1 = left, cur2 = mid + 1, i = 0;
        while (cur1 <= mid && cur2 <= right)
            tmp[i++] = nums[cur1] <= nums[cur2] ? nums[cur1++] : nums[cur2++];
        // 处理没有遍历完的数组
        while (cur1 <= mid)
            tmp[i++] = nums[cur1++];
        while (cur2 <= right)
            tmp[i++] = nums[cur2++];
            
        // 4. 还原
        for (int i = left; i <= right; i++)
            nums[i] = tmp[i - left];
    }
};

二、数组中的逆序对

1. 题目链接:LCR 170. 交易逆序对的总数

2. 题目描述:

在股票交易中,如果前一天的股价高于后一天的股价,则可以认为存在一个「交易逆序对」。请设计一个程序,输入一段时间内的股票交易记录 record,返回其中存在的「交易逆序对」总数。

示例 1:

输入:record = [9, 7, 5, 4, 6]
输出:8
解释:交易中的逆序对为 (9, 7), (9, 5), (9, 4), (9, 6), (7, 5), (7, 4), (7, 6), (5, 4)。

限制:

0 <= record.length <= 50000

3. 解法

🌴算法思路:

        用归并排序求逆序数是很经典的方法,主要就是在归并排序的合并过程中统计出逆序对的数量,也就是在合并两个有序序列的过程中,能够快速求出逆序对的数量。

        我们将这个问题分解成几个小问题,逐一破解这道题。


注意:默认都是升序,如果掌握升序的话,降序的归并过程也是可以解决问题的。

问题1:为什么可以利用归并排序?

如果我们将数组从中间划分成两个部分,那么我们可以将逆序对产生的方式划分成三组:

  • 逆序对中两个元素:全部从左数组中选择
  • 逆序对中两个元素:全部从右数组中选择
  • 逆序对中两个元素:一个选左数组另一个选右数组

根据排列组合的分类相加原理,三种种情况下产生的逆序对的总和,正好等于总的逆序对数量。

而这个思路正好匹配归并排序的过程:

  • 先排序左数组;
  • 再排序右数组;
  • 左数组和右数组合二为一。

因此,我们可以利用归并排序的过程,先求出左半数组中逆序对的数量,再求出右半数组中逆序对的数量,最后求出⼀个选择左边,另⼀个选择右边情况下逆序对的数量,三者相加即可。

问题2:为什么要这么做?

        在归并排序合并的过程中,我们得到的是两个有序的数组。我们是可以利用数组的有序性,快速统计出逆序对的数量,而不是将所有情况都枚举出来。

核心问题:如何在合并两个有序数组的过程中,统计出逆序对的数量?

合并两个有序序列时求逆序对的方法有两种:

  1. 快速统计出某个数前面有多少个数比它大;
  2. 快速统计出某个数后面有多少个数比它小;

方法一:快速统计出某个数前面有多少个数比它大

通过⼀个示例来演示方法一:

假定已经有两个已经有序的序列以及辅助数组 left = [5, 7, 9] right = [4, 5, 8] help = [ ],通过合并两个有序数组的过程,来求得逆序对的数量:

规定如下定义来叙述过程:

cur1 遍历 left 数组、cur2 遍历 right 数组、ret 记录逆序对的数量。

第一轮循环:

        left[cur1] > right[cur2],由于两个数组都是升序的,那么我们可以断定,此刻 left 数组中[cur1, 2] 区间内的 3 个元素均可与 right[cur2] 的元素构成逆序对,因此可以累加逆序对的数量 ret += 3,并且将 right[cur2] 加入到辅助数组中,cur2++ 遍历下⼀个元素。

        第⼀轮循环结束后:left = [5, 7, 9] right = [x, 5, 8] help = [4] ret = 3 cur1 = 0 cur2 = 1

第二轮循环:

        left[cur1] == right[cur2],因为 right[cur2] 可能与 left 数组中往后的元素构成逆序对,因此我们需要将 left[cur1] 加⼊到辅助数组中去,此时没有产⽣逆序对,不更新 ret。

        第二轮循环结束后:left = [x, 7, 9] right = [x, 5, 8] help = [4, 5] ret = 3 cur1 = 1 cur2 = 1

第三轮循环:

        left[cur1] > right[cur2],与第⼀轮循环相同,此刻 left 数组中[cur1, 2] 区间内的 2 个元素均可与 right[cur2] 的元素构成逆序对,更新 ret 的值为 ret += 2,并且将 right[cur2] 加⼊到辅助数组中去,cur2++ 遍历下⼀个元素。

        第三轮循环结束后:left = [x, 7, 9] right = [x, x, 8] help = [4, 5, 5] ret = 5 cur1 = 1 cur2 = 2

第四轮循环:

        left[cur1] < right[cur2],由于两个数组都是升序的,因此我们可以确定 left[cur1] 比 right 数组中的所有元素都要小。left[cur1] 这个元素是不可能与 right 数组中的元素构成逆序对。因此,大胆的将 left[cur1] 这个元素加入到辅助数组中去,不更细 ret 的值。

        第四轮循环结束后:left = [x, x, 9] right = [x, x, 8] help = [4, 5, 5, 7] ret = 5 cur1 = 2 cur2 = 2

第五轮循环:

        left[cur1] > right[cur2],与第⼀、第三轮循环相同。此时 left 数组内的 1 个元素能与 right[cur2] 构成逆序对,更新 ret 的值,并且将 right[cur2] 加⼊到辅助数组中去。

        第五轮循环结束后:left = [x, x, 9] right = [x, x, x] help = [4, 5, 5, 7, 8] ret = 6 cur1 = 2 cur2 = 2

处理剩余元素:

  • 如果是左边出现剩余,说明左边剩下的所有元素都是⽐右边元素⼤的,但是它们都是已经被计算过的(我们以右边的元素为基准的),因此不会产⽣逆序对,仅需归并排序即可。
  • 如果是右边出现剩余,说明右边剩下的元素都是⽐左边⼤的,不符合逆序对的定义,因此也不需要处理,仅需归并排序即可。
  • 整个过程只需将两个数组遍历⼀遍即可,时间复杂度为 O(N)
  • 由上述过程我们可以得出方法⼀统计逆序对的关键点:
  • 合并有序数组的时候,遇到左数组当前元素 > 右数组当前元素时,我们可以通过计算左数组中剩余元素的长度,就可快速求出右数组当前元素前面有多少个数比它⼤,对比解法⼀中⼀个⼀个枚举逆序对效率快了许多。

方法二:快速统计出某个数后面有多少个数比它小

依旧通过一个示例来演示法二:

假定已经有两个已经有序的序列以及辅助数组 left = [5, 7, 9] right = [4, 5, 8] help = [ ],通过合并两个有序数组的过程,来求得逆序对的数量:

规定如下定义来叙述过程:

cur1 遍历 left 数组、cur2 遍历 right 数组、ret 记录逆序对的数量

第一轮循环:

        left[cur1] > right[cur2],先不要着急统计,因为我们要找的是当前元素后⾯有多少比它小的,这里虽然出现了⼀个,但是 right 数组中依旧还可能有其余比它小的。因此此时仅将 right[cur2] 加入到辅助数组中去,并且将 cur2++。

        第一轮循环结束后:left = [5, 7, 9] right = [x, 5, 8] help = [4] ret = 0 cur1 = 0 cur2 = 1

第二轮循环:

        left[cur1] == right[cur2],由于两个数组都是升序,这个时候对于元素 left[cur1] 来说,我们已经可以断定 right 数组中 [0, cur2) 左闭右开区间上的元素都是⽐它⼩的。因此此时可以统计逆序对的数量 ret += cur2 - 0,并且将 left[cur1] 放⼊到辅助数组中去,cur1++ 遍历下⼀个元素。

        第二轮循环结束后:left = [x, 7, 9] right = [x, 5, 8] help = [4, 5] ret = 1 cur1 = 1 cur2 = 1

第三轮循环:

        left[cur1] > right[cur2],与第⼀轮循环相同,直接将 right[cur2] 加⼊到辅助数组中去, cur2++ 遍历下⼀个元素。

        第三轮循环结束后:left = [x, 7, 9] right = [x, x, 8] help = [4, 5, 5] ret = 1 cur1 = 1 cur2 = 2

第四轮循环:

        left[cur1] < right[cur2],由于两个数组都是升序的,这个时候对于元素 left[cur1] 来说,我们依旧已经可以断定 right 数组中 [0, cur2) 左闭右开区间上的元素都是⽐它⼩的。因此此时可以统计逆序对的数量 ret += cur2 - 0,并且将 left[cur1] 放⼊到辅助数组中去,cur1++ 遍历下⼀个元素。

        第四轮循环结束后:left = [9] right = [8] help = [4, 5, 5, 7] ret = 3 cur1 = 2 cur2 = 2

第五轮循环:

        left[cur1] > right[cur2],与第⼀、第三轮循环相同。直接将 right[cur2] 加⼊到辅助数组中去, cur2++ 遍历下⼀个元素。

        第五轮循环结束后:left = [x, x, 9] right = [x, x, x] help = [4, 5, 5, 7, 8] ret = 3 cur1 = 2 cur2 = 2


处理剩余元素:

1)如果是左边出现剩余,说明左边剩下的所有元素都是⽐右边元素⼤的,但是相⽐较于⽅法⼀,逆序对的数量是没有统计过的。因此,我们需要统计 ret 的值:

  • 设左边数组剩余元素的个数为 leave
  • ret += leave * (cur2 - 0)

        对于本题来说,处理剩余元素的时候, left 数组剩余 1 个元素,cur2 - 0 = 3,因此 ret 需要类加上 3,结果为 6。与⽅法⼀求得的结果相同。

2)如果是右边出现剩余,说明右边剩下的元素都是⽐左边⼤的,不符合逆序对的定义,因此也不需要处理,仅需归并排序即可。

  • 整个过程只需将两个数组遍历⼀遍即可,时间复杂度依旧为 O(N)
  • 由上述过程我们可以得出方法二统计逆序对的关键点:
  • 合并有序数组的时候,遇到左数组当前元素 <= 右数组当前元素时,我们可以通过计算右数组已经遍历过的元素的⻓度,快速求出左数组当前元素后面有多少个数⽐它⼤
  • 但是需要注意的是,在处理剩余元素的时候,方法二还需要统计逆序对的数量。

🌴算法代码:

class Solution 
{
    int nums[50010];
public:
    int reversePairs(vector<int>& record) 
    {
        return mergeSort(record, 0, record.size() - 1);
    }

    int mergeSort(vector<int>& record, int left, int right)
    {
        if(left >= right) return 0;

        int ret = 0;
        //1.找中间点,将数组分成两部分
        int mid = (left + right) >> 1;

        //2.左边的个数 + 排序 + 右边的个数 + 排序
        ret += mergeSort(record, left, mid);
        ret += mergeSort(record, mid + 1, right);

        //3.一左一右的个数
        int cur1 = left, cur2 = mid + 1, i = 0;
        while(cur1 <= mid && cur2 <= right)
        {
            if(record[cur1] <= record[cur2])
            {
                nums[i++] = record[cur1++];
            }
            else
            {
                ret += mid - cur1 + 1;
                nums[i++] = record[cur2++];
            }
        }
        while(cur1 <= mid) nums[i++] = record[cur1++];
        while(cur2 <= right) nums[i++] = record[cur2++];
        for(int j = left; j <= right; j++)
        {
            record[j] = nums[j - left];
        }
        return ret;
    }
};

三、计算右侧小于当前元素的个数

1. 题目链接:315. 计算右侧小于当前元素的个数

2. 题目描述:

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

示例 1:

输入:nums = [5,2,6,1]
输出:[2,1,1,0] 
解释:

5 的右侧有 2 个更小的元素 (2 和 1)
2 的右侧仅有 1 个更小的元素 (1)
6 的右侧有 1 个更小的元素 (1)
1 的右侧有 0 个更小的元素

示例 2:

输入:nums = [-1]
输出:[0]

示例 3:

输入:nums = [-1,-1]
输出:[0,0]

提示:

  • 1 <= nums.length <= 105
  • -104 <= nums[i] <= 104

3. 解法

🌴算法思路:

        这一道题的解法与 求数组中的逆序对 的解法是类似的,但是这⼀道题要求的不是求总的个数,而是要返回⼀个数组,记录每⼀个元素的右边有多少个元素比自己小。

        但是在我们归并排序的过程中,元素的下标是会跟着变化的,因此我们需要⼀个辅助数组,来将数组元素和对应的下标绑定在⼀起归并,也就是再归并元素的时候,顺势将下标也转移到对应的位置上。

        由于我们要快速统计出某⼀个元素后⾯有多少个⽐它⼩的,因此我们可以利⽤求逆序对的第二种方法。

🌴算法流程:

创建两个全局的数组:

  • vector<int> index:记录下标
  • vector<int> ret:记录结果
  • index ⽤来与原数组中对应位置的元素绑定,ret ⽤来记录每个位置统计出来的逆序对的个数。

countSmaller() 主函数:

a. 计算 nums 数组的⼤小为 n

b. 初始化定义的两个全局的数组;

  • 为两个数组开辟大小为 n 的空间
  • index 初始化为数组下标;
  • ret 初始化为 0

c. 调用 mergeSort() 函数,并且返回 ret 结果数组。

void mergeSort( vector<int>& nums, int left, int right ) 函数:

  • 函数设计:通过修改全局的数组 ret, 统计出每⼀个位置对应的逆序对的数量,并且排序;
  • 无需返回值,因为直接对全局变量修改,当函数结束的时候,全局变量已经被修改成最后的结果。

mergeSort() 函数流程:

a. 定义递归出⼝:left >= right 时,直接返回;

b. 划分区间:根据中点 mid,将区间划分为 [left, mid] 和 [mid + 1, right];

c. 统计左右两个区间逆序对的数量:

  • 统计左边区间 [left, mid] 中每个元素对应的逆序对的数量到 ret 数组中,并排序;
  • 统计右边区间 [mid + 1, right] 中每个元素对应的逆序对的数量到 ret 数组中,并排序。

d. 合并左右两个有序区间,并且统计出逆序对的数量:

i. 创建两个大小为 right - left + 1 大小的辅助数组:

  • numsTmp: 排序用的辅助数组;
  • indexTmp:处理下标用的辅助数组。

ii. 初始化遍历数组的指针:cur1 = left(遍历左半部分数组)cur2 = mid + 1(遍历右半边数组)dest = 0(遍历辅助数组)curRet(记录合并时产⽣的逆序对的数量);

iii. 循环合并区间:

当 nums[cur1] <= nums[cur2] 时:

  • 说明此时 [mid + 1, cur2) 之间的元素都是⼩于 nums[cur1] 的,需要累加到 ret 数组的 indext[cur1] 位置上(因为 index 存储的是元素对应位置在原数组中的下标)
  • 归并排序:不仅要将数据放在对应的位置上,也要将数据对应的坐标也放在对应的位置上,使数据与原始的下标绑定在⼀起移动。

当 nums[cur1] > nums[cur2] 时,无需统计,直接归并,注意 index 也要跟着归并。

iv. 处理归并排序中剩余的元素;

  • 当左边有剩余的时候,还需要统计逆序对的数量;
  • 当右边还有剩余的时候,⽆需统计,直接归并。

v. 将辅助数组的内容替换到原数组中去;

🌴算法代码:

class Solution 
{
    vector<int> ret;
    vector<int> index; // 记录 nums 中当前元素的原始下标
    int tmpNums[500010];
    int tmpIndex[500010];

public:
    vector<int> countSmaller(vector<int>& nums) 
    {
        int n = nums.size();
        ret.resize(n);
        index.resize(n);
        // 初始化⼀下 index 数组
        for (int i = 0; i < n; i++)
            index[i] = i;
        mergeSort(nums, 0, n - 1);
        return ret;
    }
    void mergeSort(vector<int>& nums, int left, int right) 
    {
        if (left >= right)
            return;
        // 1. 根据中间元素,划分区间
        int mid = (left + right) >> 1;
        // [left, mid] [mid + 1, right]
        // 2. 先处理左右两部分
        mergeSort(nums, left, mid);
        mergeSort(nums, mid + 1, right);
        // 3. 处理⼀左⼀右的情况
        int cur1 = left, cur2 = mid + 1, i = 0;
        while (cur1 <= mid && cur2 <= right) // 降序
        {
            if (nums[cur1] <= nums[cur2]) 
            {
                tmpNums[i] = nums[cur2];
                tmpIndex[i++] = index[cur2++];
            } 
            else 
            {
                ret[index[cur1]] += right - cur2 + 1; // 重点
                tmpNums[i] = nums[cur1];
                tmpIndex[i++] = index[cur1++];
            }
        }
        // 4. 处理剩下的排序过程
        while (cur1 <= mid) 
        {
            tmpNums[i] = nums[cur1];
            tmpIndex[i++] = index[cur1++];
        }
        while (cur2 <= right) 
        {
            tmpNums[i] = nums[cur2];
            tmpIndex[i++] = index[cur2++];
        }
        for (int j = left; j <= right; j++) 
        {
            nums[j] = tmpNums[j - left];
            index[j] = tmpIndex[j - left];
        }
    }
};

四、翻转对

1. 题目链接:493. 翻转对

2. 题目描述:

给定一个数组 nums ,如果 i < j 且 nums[i] > 2*nums[j] 我们就将 (i, j) 称作一个重要翻转对

你需要返回给定数组中的重要翻转对的数量。

示例 1:

输入: [1,3,2,3,1]
输出: 2

示例 2:

输入: [2,4,3,5,1]
输出: 3

注意:

  1. 给定数组的长度不会超过50000
  2. 输入数组中的所有数字都在32位整数的表示范围内。

3. 解法

🌴算法思路:

题目解析:

        翻转对和逆序对的定义大同小异,逆序对是前面的数要大于后面的数。而翻转对是前面的一个数要大于后面某个数的两倍。因此,我们依旧可以⽤归并排序的思想来解决这个问题。


        大思路与求逆序对的思路⼀样,就是利用归并排序的思想,将求整个数组的翻转对的数量,转换成三部分:左半区间翻转对的数量,右半区间翻转对的数量,⼀左⼀右选择时翻转对的数量。重点就是在合并区间过程中,如何计算出翻转对的数量。

        与上个问题不同的是,上⼀道题我们可以⼀边合并⼀遍计算,但是这道题要求的是左边元素大于右边元素的两倍,如果我们直接合并的话,是无法快速计算出翻转对的数量的。

        例如 left = [4, 5, 6] right = [3, 4, 5] 时,如果是归并排序的话,我们需要计算 left 数组中有多少个能与 3 组成翻转对。但是我们要遍历到最后⼀个元素 6 才能确定,时间复杂度较⾼。

        因此我们需要在归并排序之前完成翻转对的统计。

        下面依旧以⼀个示例来模仿两个有序序列如何快速求出翻转对的过程:假定已经有两个已经有序的序列 left = [4, 5, 6] right = [1, 2, 3] 。 用两个指针 cur1 cur2 遍历两个数组。

  • 对于任意给定的 left[cur1] 而言,我们不断地向右移动 cur2,直到 left[cur1] <= 2 *right[cur2]。此时对于 right 数组而言,cur2 之前的元素全部都可以与 left[cur1] 构成翻转对。
  • 随后,我们再将 cur1 向右移动⼀个单位,此时 cur2 指针并不需要回退(因为 left 数组是升序的)依旧往右移动直到 left[cur1] <= 2 * right[cur2]。不断重复这样的过程,就能够求出所有左右端点分别位于两个子数组的翻转对数目。

        由于两个指针最后都是不回退的的扫描到数组的结尾,因此两个有序序列求出翻转对的时间复杂度是 O(N)

        综上所述,我们可以利⽤归并排序的过程,将求⼀个数组的翻转对转换成求 左数组的翻转对数量 + 右数组中翻转对的数量 + 左右数组合并时翻转对的数量。

🌴算法代码:

class Solution 
{
    int tmp[50010];

public:
    int reversePairs(vector<int>& nums) 
    {
        return mergeSort(nums, 0, nums.size() - 1);
    }
    int mergeSort(vector<int>& nums, int left, int right) 
    {
        if (left >= right)
            return 0;
        int ret = 0;
        // 1. 先根据中间元素划分区间
        int mid = (left + right) >> 1;
        // [left, mid] [mid + 1, right]
        // 2. 先计算左右两侧的翻转对
        ret += mergeSort(nums, left, mid);
        ret += mergeSort(nums, mid + 1, right);
        // 3. 先计算翻转对的数量
        int cur1 = left, cur2 = mid + 1, i = left;
        while (cur1 <= mid) // 降序的情况
        {
            while (cur2 <= right && nums[cur2] >= nums[cur1] / 2.0)
                cur2++;
            if (cur2 > right)
                break;
            ret += right - cur2 + 1;
            cur1++;
        }
        // 4. 合并两个有序数组
        cur1 = left, cur2 = mid + 1;
        while (cur1 <= mid && cur2 <= right)
            tmp[i++] = nums[cur1] <= nums[cur2] ? nums[cur2++] : nums[cur1++];
        while (cur1 <= mid)
            tmp[i++] = nums[cur1++];
        while (cur2 <= right)
            tmp[i++] = nums[cur2++];
        for (int j = left; j <= right; j++)
            nums[j] = tmp[j];
        return ret;
    }
};
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

南风与鱼

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值