贪心算法题目总结之区间问题

54 阅读3分钟

一、前言

在贪心算法类的题目中,有一类常见的题目,就是给你一个二维数组,数组里面是一个个区间,区间有左右端点,然后去求最长的无重叠区间的数目,或者合并有重叠的区间,或者划分彼此没有重叠的区间,或者稍微变通一下,改成类似于射气球这种题目。这一类题目的做法基本相似,有很多共通之处,下面就来总结这一类题目的做法和思想。

二、经典题目

直接通过题目来看思想

2.2.1 最长数对链

题目链接:646. 最长数对链 - 力扣(LeetCode)

题目要求: 给你一个由 n 个数对组成的数对数组 pairs ,其中 pairs[i] = [left, right] 且 left < right。

现在,我们定义一种 跟随 关系,当且仅当 b < c 时,数对 p2 = [c, d] 才可以跟在 p1 = [a, b] 后面。我们用这种形式来构造 数对链 。

找出并返回能够形成的 最长数对链的长度 。

你不需要用到所有的数对,你可以以任何顺序选择其中的一些数对来构造。

image.png

解题思路:

这种题目肯定都是需要排序的,那么具体要按什么来排序呢?左端点还是右端点,其实都可以,一般来说,我习惯于按照右端点由小到大进行排序。贪心的思想就在于,右边界越小,留给后面的空间就越大,就更容易形成最长数对。

这个题其实是预定会议的一个问题,给你若干时间的会议,然后去预定会议,那么能够预定的最大的会议数量是多少?核心在于我们要找到最大不重叠区间的个数。 如果我们把本题的区间看成是会议,那么按照右端点排序,我们一定能够找到一个最先结束的会议,而这个会议一定是我们需要添加到最终结果的的首个会议。(这个不难贪心得到,因为这样能够给后面预留的时间更长)。 下面看C++代码:

class Solution {
public:
    int findLongestChain(vector<vector<int>>& pairs) {
        sort(pairs.begin(),pairs.end(), [](const vector<int> &a, const vector<int> &b){ return a[1] < b[1]; });//这里的写法是看的题解,按照数组的第二个元素大小排序<就是从小到大排,反之就是从大到小
        int count = 1;
        int right_bound = pairs[0][1];//cur用来记录当前最长数对链的最后面的那个数组的右端的值,下一个要找的数组的左端的值要保证大于这个cur
        for(int i = 1;i < pairs.size();i ++)
        {
            if(pairs[i][0] > right_bound)
            {
                count ++;
                right_bound = pairs[i][1];
            }
        }
        return count;
    }
};

2.2.2 无重叠区间

题目链接: 435. 无重叠区间 - 力扣(LeetCode)

题目要求: 给定一个区间的集合 intervals ,其中 intervals[i] = [starti, endi] 。返回 需要移除区间的最小数量,使剩余区间互不重叠 。

这个题目跟上一个最长数对链的做法和思路几乎是一样的。这个可以根据左端点排序来做也可以右端点排序来做,都可以。

思路一: 按照左端点来排序,从小到大。然后向右遍历,每遇到一个区间就判断是否有重叠,如果有,就需要删除一个区间,然后结果加一。具体删除哪一个,就要选择删除那个右端点大的那一个,因为右端点越大,越可能造成重合,就要删除这个,保留右端点值小的那一个。 代码:

class Solution {
public:
    static bool cmp(vector<int>& a,vector<int>& b)
    {
        return a[0] < b[0];
    }
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        int count = 0;
        sort(intervals.begin(),intervals.end(),cmp);
        int minRight = intervals[0][1];//记录当前无重叠序列的最小右边界的值
        for(int i = 1;i < intervals.size();i ++)
        {
            if(intervals[i][0] < minRight)
            {
                count ++;
                minRight = min(minRight,intervals[i][1]);//取右端点最小的那一个
            }else{
                minRight = intervals[i][1];
            }
        }
        return count;
    }
};

思路二: 按照右端点来排序,从小到大。然后向右遍历,每遇到一个区间就判断是否有重叠,如果当前元素和前面的元素出现重叠,就需要删除一个区间、结果加一。而删除的这一个就是右端点值较大的那一个,也就是当前元素(因为是按照右端点排序的,所以如果出现重叠,就把当前元素删除,因为当前元素肯定比前面的那个元素的右端点值要大)。思路和最长数对链是一样的.一个是求非重叠区间的最大值,一个是求重叠部分的最小值,思路完全一样。

c++代码:

class Solution {
public:
    static bool cmp(vector<int>& a,vector<int>& b)
    {
        if(a[1] == b[1])
            return a[0] > b[0];
        return a[1] < b[1];
    }
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        int count = 0;
        sort(intervals.begin(),intervals.end(),cmp);
        int minRight = intervals[0][1];
        for(int i = 1;i < intervals.size();i ++)
        {
            if(intervals[i][0] < minRight)
            {
                count ++;
            }else{
                minRight = intervals[i][1];
            }
        }
        return count;
    }
};

2.2.3 用最少数量的箭引爆气球

题目链接: 452. 用最少数量的箭引爆气球 - 力扣(LeetCode)

题目要求: 有一些球形气球贴在一堵用 XY 平面表示的墙面上。墙面上的气球记录在整数数组 points ,其中points[i] = [xstart, xend] 表示水平直径在 xstart 和 xend之间的气球。你不知道气球的确切 y 坐标。

一支弓箭可以沿着 x 轴从不同点 完全垂直 地射出。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstart,xend, 且满足  xstart ≤ x ≤ xend,则该气球会被 引爆 。可以射出的弓箭的数量 没有限制 。 弓箭一旦被射出之后,可以无限地前进。

给你一个数组 points ,返回引爆所有气球所必须射出的最小弓箭数 。

解题思路:

其实这道题完全可以直接用上面那道最长数对链的方式来求解,思路是一模一样的。

那么为什么可以呢,最长数对的个数就等于弓箭个数?

可以这样想,弓箭要射的肯定尽量都是重叠的区间,尽可能重复的越多越好。最长数对链表示的就是这个序列有多少不重叠的区间,那么因为这些区间是不重叠的,那么弓箭数量肯定要大于等于这些区间的数目。

然后,因为除去这些区间外,其他区间肯定都是和它们有重叠的(不重叠的话就是无重叠区间的一员了),而且一个无重叠区间不会出现首尾都重叠的情况(可以好好想想为什么,因为如果是首尾都有重叠的话那么它就不会被选为无重叠区间的一员了),重叠只可能在某一端重叠,因此每一只箭都可以射落其他的重叠区间,所以最长数对链就等于弓箭数。

代码同最长数对链。

当然还有一种写法就是不断贪心更新公共右边界,代码如下

class Solution {
private:
    static bool cmp(const vector<int>& a, const vector<int>& b) {
        return a[0] < b[0];
    }
public:
    int findMinArrowShots(vector<vector<int>>& points) {
        if (points.size() == 0) return 0;
        sort(points.begin(), points.end(), cmp);

        int result = 1; // points 不为空至少需要一支箭
        for (int i = 1; i < points.size(); i++) {
            if (points[i][0] > points[i - 1][1]) {  // 气球i和气球i-1不挨着,注意这里不是>=
                result++; // 需要一支箭
            }
            else {  // 气球i和气球i-1挨着
                points[i][1] = min(points[i - 1][1], points[i][1]); // 更新重叠气球最小右边界
            }
        }
        return result;
    }
};

2.2.4 划分字母区间

题目链接: 763. 划分字母区间 - 力扣(LeetCode)

题目要求: 给你一个字符串 s 。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。

注意,划分结果需要满足:将所有划分结果按顺序连接,得到的字符串仍然是 s 。

返回一个表示每个字符串片段的长度的列表。

这个题可以这样解:先遍历一遍,把所有字母的起始位置和结束位置都记录下来,然后剩下的就跟射气球和无重叠区间的做法差不多了。找出无重叠的区间,然后记录下长度。

C++代码如下:

class Solution {
public:
    static bool cmp(vector<int>& a,vector<int>& b)
    {
        return a[0] < b[0];
    }
    vector<int> partitionLabels(string s) {
        unordered_map<char,int> left;
        unordered_map<char,int> right;//分别记录一个字母出现的左端点和右端点
        for(int i = 0;i < s.size();i ++)
        {
            if(left.find(s[i]) == left.end())
            {
                left[s[i]] = i;
            }
            right[s[i]] = i;
        }
        vector<vector<int>> vec;
        for(auto& i:left)
        {
            vec.push_back(vector<int>{left[i.first],right[i.first]});
        }
        sort(vec.begin(),vec.end(),cmp);//按照左端点的值从小到大排序
        vector<int>res;
        int right_bound = vec[0][1];
        int left_bound = vec[0][0];//其实就是0
        for(int i = 1;i < vec.size();i ++)
        {
            if(vec[i][0] < right_bound)//出现了重叠部分,就更新当前序列的右端点的值
            {
                right_bound = max(vec[i][1],right_bound);
            }else{
                res.push_back(right_bound - left_bound + 1);
                left_bound = vec[i][0];
                right_bound = vec[i][1];
            }
            if(i == vec.size() - 1)//处理一下遍历到最右端的情况,遍历到最右端的时候,要单独做一次。
            //因为后面没有元素的左端点大于当前的右端点了,如果继续遍历下去,会丢失当前序列的长度
            {
                res.push_back(right_bound - left_bound + 1);
            }
        }
        return res;
    }
};

以上解法的时间复杂度是nlgn,因为有一个排序。事实上还有一种n时间复杂度的贪心解法,也是先遍历一次,但是只需要记录每个字符出现的末尾位置就好了。

然后再遍历字符串,不断更新当前已经遍历过的所有字符出现的最大右边界,当遍历的位置等于最大有边界的时候,说明就找到一个无重叠区间的末尾了。

C++代码:

class Solution {
public:
    vector<int> partitionLabels(string S) {
        int hash[27] = {0}; // i为字符,hash[i]为字符出现的最后位置
        for (int i = 0; i < S.size(); i++) { // 统计每一个字符最后出现的位置
            hash[S[i] - 'a'] = i;
        }
        vector<int> result;
        int left = 0;
        int right = 0;
        for (int i = 0; i < S.size(); i++) {
            right = max(right, hash[S[i] - 'a']); // 找到字符出现的最远边界
            if (i == right) {
                result.push_back(right - left + 1);
                left = i + 1;
            }
        }
        return result;
    }
};

2.2.5 合并区间

题目链接: 56. 合并区间 - 力扣(LeetCode)

题目要求: 以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。

这个题目跟上一道题划分字母区间思路和做法一模一样,代码如下:

class Solution {
public:
    static bool cmp(vector<int>& a,vector<int>& b)
    {
        return a[0] < b[0];
    }
    vector<vector<int>> merge(vector<vector<int>>& intervals) {
        if(intervals.size() == 1)
            return intervals;
        vector<vector<int>>res;
        sort(intervals.begin(),intervals.end(),cmp);
        int left = intervals[0][0];
        int right = intervals[0][1];
        for(int i = 1;i < intervals.size();i ++)
        {
            if(intervals[i][0] > right)
            {
                res.push_back(vector<int>{left,right});
                left = intervals[i][0];
                right = intervals[i][1];
            }else{
                right = max(intervals[i][1],right);
            }
            
            if(i == intervals.size() - 1)
            {
                res.push_back(vector<int>{left,right});
            }
        }
        return res;
    }
};