【算法专题】归并排序

目录

1. 排序数组

2. 交易逆序对的总数

3. 计算右侧小于当前元素的个数

4. 翻转对

总结


1. 排序数组

912. 排序数组 - 力扣(LeetCode)

        今天我们使用归并排序来对数组进行排序,实际上,归并排序和快速排序是有一定相似之处的,都运用了分而治之的思想提升了排序效率。快速排序的实现思路是每次排序把区间划分为小于基准元素、等于基准元素、大于基准元素三个部分,直至数组整体有序为止;而归并排序的实现思路则是每次排序把区间平均划分为两个部分,分别对这两个部分再次排序,然后把这两个部分合并,重复这个过程直至子数组为一。

        显然合并数组这个操作是需要一个数组进行辅助的,由于归并排序过程中两个相等的元素在数组中的位置不会发生改变,所以这是一个稳定的排序算法,虽然在不要求稳定的情况下,都是快速排序比归并排序更快,但归并排序也有自己的应用场景,这点我们在后面会提到。

       

class Solution {
public:
    vector<int> temp;
    vector<int> sortArray(vector<int>& nums) 
    {
        temp.resize(nums.size());
        mergesort(nums, 0, nums.size() - 1);
        return nums;
    }
    void mergesort(vector<int> &nums, int left, int right)
    {
        if(left >= right) return;
        int mid = (left + right) >> 1;
        mergesort(nums, left, mid);
        mergesort(nums, mid + 1, right);
        int cur1= left, cur2 = mid + 1, i = 0;
        while(cur1 <= mid && cur2 <= right)
        {
            temp[i++] = (nums[cur1] <= nums[cur2]) ? nums[cur1++] : nums[cur2++];
        }
        while(cur1 <= mid) temp[i++] = nums[cur1++];
        while(cur2 <= right) temp[i++] = nums[cur2++];
        for(int j = left; j <= right; j++)
        {
            nums[j] = temp[j - left];
        }
    }
};

2. 交易逆序对的总数

LCR 170. 交易逆序对的总数 - 力扣(LeetCode)

        依据题意,我们需要求出一个数组中的逆序对总数,逆序对的定义是前面的数大于后面的数时,这两个数可以组成逆序对。首先能想到的肯定是暴力枚举,两层for循环列举出所有符合条件的逆序对情况,但既然这是困难题,暴力枚举法肯定是通过不了的,所以我们要想办法对暴力法做出优化。

        首先,如果我们把数组平均分为左右两个部分,那么要查找逆序对的步骤就是在左半部分找逆序对、在右半部分找逆序对、左右部分各取一个数,找逆序对。这样一来,就能找出所有满足条件的逆序对了,这时大家可能就会奇怪了,这不还是相当于枚举吗?确实是这样,但如果我们在找完左半部分逆序对后对左边进行排序、找完右半部分逆序对后对右边进行排序、在找完左右部分的逆序对后对数组整体进行排序,大家可能发现了,这样一来我们就能够用归并排序来对求逆序对的流程进行优化了。

        为什么说排序能够优化查找逆序对的效率呢?我举个例子大家就明白了。

大家可以发现,当nums[cur1] > nums[cur2]时,我们就一次性找到了mid-cur1+1个符合条件的逆序对!和暴力枚举法比起来,大大提升了效率!

class Solution {
public:
    vector<int> temp;
    int reversePairs(vector<int>& record) 
    {
        temp.resize(record.size());
        return mergesort(record, 0, record.size() - 1);
    }
    int mergesort(vector<int> &nums, int left, int right)
    {
        if(left >= right) return 0;
        int ret = 0;
        int mid = (left + right) >> 1;
        ret += mergesort(nums, left, mid);
        ret += mergesort(nums, mid + 1, right);
        int cur1 = left, cur2 = mid + 1, i = 0;
        while(cur1 <= mid && cur2 <= right)
        {
            if(nums[cur1] <= nums[cur2])
            {
                temp[i++] = nums[cur1++];
            }
            else
            {
                ret += mid - cur1 + 1;
                temp[i++] = nums[cur2++];
            }
        }
        while(cur1 <= mid) temp[i++] = nums[cur1++];
        while(cur2 <= right) temp[i++] = nums[cur2++];
        for(int j = left; j <= right; j++)
        {
            nums[j] = temp[j - left];
        }
        return ret;
    }
};

3. 计算右侧小于当前元素的个数

315. 计算右侧小于当前元素的个数 - 力扣(LeetCode)

        不难发现,本道题目和上一道交易逆序对的总数的处理方法是非常相似的,区别在于,本题统计的是数组每个元素的右侧小于该元素的数量。则如果我们直接使用归并排序来处理,是会出现问题的,因为计算右侧小于当前元素的个数需要根据数组元素的初始下标来记录出现个数,而排序后,数组nums的顺序发生变化,我们就不能直接得到初始下标了。

        我们可以这样解决:维护一个记录nums数组初始下标的数组index,当我们对nums进行排序时,同步对index数组做相应的处理,这样一来,即便我们对nums进行排序,还是能通过index数组来找到数组元素的初始下标。

        还有一点值得一提的是,本题我们的排序应该选择降序排列而非升序排列,这是因为上一题我们求逆序对的总数实际上是通过计算左侧大于当前元素的个数来得到的,而本题要求的是右侧小于当前元素的个数,所以应该让数组降序排列。可以像上一道题一样,画图辅助理解:

        目前为止,本题所有的算法原理讲解完毕,大家可以先试着自己编写一下代码,锻炼一下自己的代码能力,这样才能够最好的提升。

class Solution {
public:
    vector<int> temp1, temp2;
    vector<int> index, ret;

    vector<int> countSmaller(vector<int>& nums) {
        int n = nums.size();
        temp1.resize(n);
        temp2.resize(n);
        index.resize(n);
        ret.resize(n, 0);

        for (int j = 0; j < n; j++)
            index[j] = j;

        mergesort(nums, 0, n - 1);
        return ret;
    }

    void mergesort(vector<int>& nums, int left, int right) {
        if (left >= right) return;

        int mid = (left + right) / 2;
        mergesort(nums, left, mid);
        mergesort(nums, mid + 1, right);

        int cur1 = left, cur2 = mid + 1, i = 0;

        while (cur1 <= mid && cur2 <= right) {
            if (nums[cur1] <= nums[cur2]) {
                temp1[i] = nums[cur1];
                temp2[i++] = index[cur1++];
            } else {
                ret[index[cur1]] += (right - cur2 + 1); // Update ret[index[cur1]]
                temp1[i] = nums[cur2];
                temp2[i++] = index[cur2++];
            }
        }

        while (cur1 <= mid) {
            temp1[i] = nums[cur1];
            temp2[i++] = index[cur1++];
        }

        while (cur2 <= right) {
            temp1[i] = nums[cur2];
            temp2[i++] = index[cur2++];
        }

        for (int j = left; j <= right; j++) {
            nums[j] = temp1[j - left];
            index[j] = temp2[j - left];
        }
    }
};

4. 翻转对

493. 翻转对 - 力扣(LeetCode)

        通过题目描述,大家都能发现本题和第二题也是挺相似的,不过本题要求的是满足i<j且nums[i]>2*nums[j]的(i,j)个数,这个比较条件并不像第二题一样和归并排序的排序过程完美重合,但是通过和第二题相似的思路,我们利用归并排序处理过的两个数组来求翻转对还是可以大大优化效率,所以求翻转对的操作应该是要放在左排序、右排序之后,在合并两个数组之前的。

        另外,int类型是有大小范围限制的,本题给的数据比较大,会出现溢出的情况,所以我们的判断不直接使用nums[i]>2*nums[j],而是使用nums[i]/2.0>nums[j]。

class Solution {
public:
    vector<int> temp;
    int reversePairs(vector<int>& nums) 
    {
        temp.resize(nums.size());
        return mergesort(nums, 0, nums.size() - 1);
    }
    int mergesort(vector<int> &nums, int left, int right)
    {
        if(left >= right) return 0;
        int ret = 0;
        int mid = (left + right) >> 1;
        ret += mergesort(nums, left, mid);
        ret += mergesort(nums, mid + 1, right);
        int cur1 = left, cur2 = mid + 1, i = 0;
        while(cur1 <= mid && cur2 <= right)
        {
            if(nums[cur1] / 2.0 <= nums[cur2]) cur2++;
            else
            {
                ret += right - cur2 + 1;
                cur1++;
            }
        }
        cur1 = left, cur2 = mid + 1;
        while(cur1 <= mid && cur2 <= right)
        {
            temp[i++] = nums[cur1] <= nums[cur2] ? nums[cur2++] : nums[cur1++]; 
        }
        while(cur1 <= mid) temp[i++] = nums[cur1++];
        while(cur2 <= right) temp[i++] = nums[cur2++];
        for(int j = left; j <= right; j++)
            nums[j] = temp[j - left];
        return ret;
    }
};

5. 合并K个升序链表

23. 合并 K 个升序链表 - 力扣(LeetCode)

        题目的要求是对K个升序的链表进行合并,合并两个升序链表怎么做大家肯定不陌生,所以暴力解法自然就是是每次合并一个链表,直到所有链表都合并完毕为止,假设链表平均长度为n,那时间复杂度就是O(n*K^2),如此高的时间复杂度肯定是通过不了困难题的,所以我们试着找到更优秀的解法。相信大家都能发现,对链表的合并与归并排序是非常类似的,所以我们可以使用类似归并排序的方式,以分治的思想来实现这K个链表的合并。

        接着我们来分析一下为什么分治的方法比暴力算法优秀:

如上图所示,假设我们有K个链表,每个链表的平均长度是n,则由于归并的性质,每个节点只需要合并logK次,那么时间复杂度就优化到了O(n*K*logK),远比暴力解法更优秀。

        当我们使用分治来解决本题时,可以发现代码和归并排序非常相似,区别在于归并排序是对整型数组进行排序,而本题是对链表数组进行排序。

class Solution {
public:
    ListNode *mergeTwoLists(ListNode *l1, ListNode *l2)
    {
        if(!l1) return l2;
        if(!l2) return l1;
        ListNode *head = new ListNode(0), *prev = head;
        ListNode *cur1 = l1, *cur2 = l2;
        while(cur1 && cur2)
        {
            if(cur1->val <= cur2->val) 
            {
                prev->next = cur1;
                cur1 = cur1->next;
            }
            else 
            {
                prev->next = cur2;
                cur2 = cur2->next;
            }
            prev = prev->next;            
        }
        if(cur1) prev->next = cur1;
        else prev->next = cur2;
        prev = head->next;
        delete head;
        return prev;
    }
    ListNode *merge(vector<ListNode*>& lists, int left, int right)
    {
        if(left > right) return nullptr;
        if(left == right) return lists[left];
        // 找到中间节点
        int mid = (left + right) >> 1;
        // 将链表数组的左右部分分别进行合并
        ListNode *l1 = merge(lists, left, mid);
        ListNode *l2 = merge(lists, mid + 1, right);
        // 合并左右两张链表
        return mergeTwoLists(l1, l2);
    }
    ListNode* mergeKLists(vector<ListNode*>& lists) 
    {
        return merge(lists, 0, lists.size() - 1);
    }
};

        

总结

        本篇文章从归并排序开始,带着大家使用分治算法解决了几道算法题,通过这几道题目的练习,我们学习到了包括但不限于:

1. 分治算法的思想:将问题分解为小的子问题,递归解决子问题,然后将结果合并来解决原始问题。

2. 归并排序的实现:数组的划分、递归排序和合并操作。

3. 归并排序算法的稳定性,即排序过程中相等元素的相对位置不会发生变化。

4. 通过分治和排序对暴力枚举算法进行优化。

        大家可以收藏本文,以后再碰到分治算法题可以再回过头看看这篇文章,相信会有不一样的理解。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值