算法刷题总结 (三) 贪心算法

一、理解贪心算法

在这里插入图片描述

1.1、贪心算法的概念

贪心算法是遵循在每个阶段做出局部最优选择从而解决启发式(近似最优解)问题的任何算法。

因为贪心策略在很多情况下不会产生最优解,可能大部分是近似最优解,也有小部分可能是最糟糕的结果。但对某些特殊问题,采用贪心可以取到最好的效果,即可以从局部最优可以推导到全局最优。

我们分别来看看两个例子:


例子1 (局部最优到全局最优):

某货币有1, 5, 10 ,20的面值大小,现在要找给客户36面值大小的货币,要求找给的货币数量最少

这里使用贪心算法,如下图,从最大的币值20开始 (因为20可以代替2张10,4张5或20张1),不断地找回20,直到不够该币值时,往小的币值10,5,1依次遍历。

这里每一次都取的是最优值,最后的结果也达到了最优。
在这里插入图片描述

但局部最优一定能推导到全局最优吗?看下一个例子:

例子2 (局部最优不能到全局最优):
请添加图片描述
为了达到最大和,在每一步,贪心算法都会选择看起来是最优的直接选择,所以它会在第二步选择 12 而不是 3,并且不会达到包含 99 的最佳解决方案。

我们可以做出目前看起来最好的任何选择,即局部最优选择,然后解决以后出现的子问题。贪心算法做出的选择可能取决于到目前为止所做的选择,但不取决于未来的选择或子问题的所有解决方案。它迭代地做出一个又一个贪婪的选择,将每个给定的问题减少为一个较小的问题。

换句话说,贪心算法永远不会重新考虑它的选择,它是不可更改的,即我们无法在执行过程中的任何后续点更改决定。这是与动态规划的主要区别,动态规划是详尽的并且保证能找到解决方案。在每个阶段之后,动态规划都会根据前一阶段做出的所有决策做出下一步决策,并且可能会重新考虑前一阶段解决方案的算法路径。

贪心算法是独立每步最大,而动态规划是每一步之间状态关联最大,是个重叠子问题,这会在下一篇文章中讲解。


1.2、何时使用贪心

当遇到一道题,如何判别是否适合使用贪心算法?如何看出能从局部最优推导到全局最优?

贪心无套路,也没有框架之类的,需要多看多练培养感觉才能想到贪心的思路。

一般而言,就靠自己去手动模拟,即单纯的过程模拟,如果模拟可行,就试一试贪心策略,如果找出局部最优并可以推出全局最优,就是贪心,如果局部最优都没找出来,就不是贪心,可能是单纯的模拟;如果连模拟都不行,可能需要动态规划

刷题或者面试的时候,手动模拟一下感觉可以局部最优推出整体最优,而且想不到反例,那么就试一试贪心。

如果使用贪心算法,对于推导时,举例子得出的结论靠谱与否不确定,想要严格的数学证明。一般数学证明有如下两种方法:1. 数学归纳法,2. 反证法
但面试中基本不会让面试者现场证明贪心的合理性,代码写出来跑过测试用例即可,或者自己能自圆其说理由就行了。并且,贪心有时候就是常识性的推导,所以自然而然会认为本应该就这么做。


1.3、贪心算法的做题步骤

贪心算法一般分为如下四步:

  1. 将问题分解为若干个子问题
  2. 找出适合的贪心策略
  3. 求解每一个子问题的最优解
  4. 将局部最优解堆叠成全局最优解

这是一个细分,真正做题的时候很难分出这么详细的解题步骤,可能就是因为贪心的题目往往还和其他方面的知识混在一起。


二、经典题型

2.1、简单题目

2.1.1、455.分发饼干

力扣题目链接

在这里插入图片描述

这里的局部最优就是大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩。

贪心算法1(以小孩的胃口为主体):
先满足胃口大的孩子,需要排序后逆序

class Solution:
    def findContentChildren(self, g: List[int], s: List[int]) -> int:
        # 两个指针分别遍历两个list
        i, j = 0, 0
        # 存储结果
        count = 0
        # 排序,从胃口大的孩子开始满足
        g.sort(reverse=True)
        s.sort(reverse=True)

        # 从大遍历孩子和饼干
        # 孩子胃口过大,则换下一个胃口小的孩子
        while i<len(g) and j<len(s):
            # 匹配成功,都移到下一个匹配项
            if g[i]<=s[j]:
                count+=1
                i+=1
                j+=1
            # 匹配不成功,原因只有孩子胃口过大,因为饼干大于或等于胃口都满足匹配
            # 那么换下一个孩子
            else:
                i+=1
        return count

贪心算法2(以饼干为主体):
排序后,饼干从小的开始分发,不满足则往后换大的

class Solution:
    def findContentChildren(self, g: List[int], s: List[int]) -> int:
        # 两个指针分别遍历两个list
        i, j = 0, 0
        # 存储结果
        count = 0
        # 排序,从小饼干开始分发
        g.sort()
        s.sort()

        # 从小遍历孩子和饼干
        # 饼干过小,换一个大一些的饼干
        while i<len(g) and j<len(s):
            # 匹配成功,都移到下一个匹配项
            if g[i]<=s[j]:
                count+=1
                i+=1
                j+=1
            # 匹配不成功,原因只有饼干太小
            # 那么换下一个饼干
            else:
                j+=1
        return count

在这里插入图片描述


2.1.2、1005.K次取反后最大化的数组和

力扣题目链接

贪心的思路,局部最优:让绝对值大的负数变为正数,当前数值达到最大,整体最优:整个数组和达到最大。局部最优可以推出全局最优。

那么如果将负数都转变为正数了,K依然大于0,此时的问题是一个有序正整数序列,如何转变K次正负,让 数组和 达到最大。

那么又是一个贪心:局部最优:只找数值最小的正整数进行反转,当前数值可以达到最大(例如正整数数组{5, 3, 1},反转1 得到-1 比 反转5得到的-5 大多了),全局最优:整个 数组和 达到最大。

这里注意有两次贪婪算法。

贪心算法1(一次排序,绝对值排序):
解题步骤为:
第一步:将数组按照绝对值大小从大到小排序,注意要按照绝对值的大小
第二步:从前向后遍历,遇到负数将其变为正数,同时K–
第三步:如果K还大于0,那么反复转变数值最小的元素,将K用完
第四步:求和

class Solution:
    def largestSumAfterKNegations(self, A: List[int], K: int) -> int:
        A = sorted(A, key=abs, reverse=True) # 将A按绝对值从大到小排列
        for i in range(len(A)):
            if K > 0 and A[i] < 0:
                A[i] *= -1
                K -= 1
        if K > 0:
            A[-1] *= (-1)**K #取A最后一个数只需要写-1
        return sum(A)

贪心算法2(两次排序,直接排序):

class Solution:
    def largestSumAfterKNegations(self, nums: List[int], k: int) -> int:
        nums.sort()
        

        count=0
        # 第一次贪婪,将负数变为正数,遍历列表一次
        while count<k and count<len(nums):
            # 负的变为正的
            if nums[count]<0:
                nums[count] = -nums[count]
                # 改变的次数计数
                count+=1
            # 排序后,遇到正的,退出,整个列表都为正的
            else:
                break

        # 第二次贪婪,偶数改变次数,则不变
        # 奇数改变次数,只变化最小值
        if (k-count)%2!=0:
            nums.sort()
            nums[0] = -nums[0]
            
        return sum(nums)

2.1.3、860.柠檬水找零

力扣题目链接

贪心算法:
仔细分析可知,这题只需要维护三种金额的数量,5,10和20就行,并且这题的判断条件非常少,有如下三种情况:
情况一:账单是5,直接收下。
情况二:账单是10,消耗一个5,增加一个10
情况三:账单是20,优先消耗一个10和一个5,如果不够,再消耗三个5

情况一,情况二,都是固定策略,不用具体分析了,而唯一不确定的就是情况三。情况三这里使用了贪心算法,优先使用大的币值10,因为5更加万能,如果对于交付20过多使用5找零,那么对于10的找零5就会相应的减少,同时存了很多10而用不掉。

class Solution:
    def lemonadeChange(self, bills: List[int]) -> bool:
        res = [0,0,0]

        for i in bills:
        	# 情况一
            if i == 5:
            	# 存钱
                res[0]+=1
            # 情况二
            elif i ==10:
            	# 存钱
                res[1]+=1
                # 判断钱是否够找零
                if res[0]<0:
                    return False
                # 找钱
                res[0]-=1
            # 情况三
            else:
                res[2]+=1
                # 先找大零钱,判断1
                if res[1]>=1 and res[0]>=1:
                    res[0]-=1
                    res[1]-=1
                # 大零钱不够,用小零钱,判断2
                elif res[0]>=3:
                    res[0]-=3
                # 都不够,无法找零
                else:
                    return False
        return True

注意: 这里其实可以去掉20的存取,因为不会用到。


2.2、中等题目1 - 序列问题

2.2.1、376. 摆动序列

力扣题目链接

改题使用贪心算法,相邻元素相减,一旦符号与前一个不同,则为摆动子序列

贪心算法:

class Solution:
    def wiggleMaxLength(self, nums: List[int]) -> int:
        # 单个元素也是摇摆序列,提前+1
        res= 1
        # 定义符号
        pre_sign = 0

        # 这里从1开始,每次跟前面的元素比较
        for i in range(1, len(nums)):
            # 这里符号0直接被考虑进去,相当于起始点,即一个元素
			# [0,0,2,1]第二个零当做起始点

            # 正向摆动,但前一个摆动符号不为正
            if nums[i]-nums[i-1]>0 and pre_sign<=0:
                pre_sign = 1
                res+=1
            # 负向摆动,但前一个摆动符号不为负
            elif nums[i]-nums[i-1]<0 and pre_sign>=0:
                pre_sign=-1
                res+=1
            
        return res

在这里插入图片描述

这道题,如果用贪心算法,有些找规律的意味在里面。

我们可以从上图看到,理论上,原本应该是1-17-5-15-5-16-8,即取低谷与峰值为判断条件才是合理的,然而对于像5-10-13-15-10-5这个从低谷到峰值中间都有值,从贪心的角度,我们直接判断,取第二个10当做峰值,同样达到效果,编程也更加方便。

动态规划:
子序列相关题目理论上可以使用动态规划算法,但是理解有些复杂,并且算法复杂度较高。目前先不做展开,后续补充。


2.2.2、738.单调递增的数字

力扣题目链接

贪心算法:

class Solution:
    def monotoneIncreasingDigits(self, n: int) -> int:
        tmp = list(str(n))
        
        for i in range(len(tmp)-1,0,-1):
            if int(tmp[i])<int(tmp[i-1]):
            	# 前一个值退一位
                tmp[i-1] = str(int(tmp[i-1])-1)
                # 后面的值全部变成9
                tmp[i:] = '9'*(len(tmp)-i)
        
        return int(''.join(tmp))

时间复杂度: O ( n ) O(n) O(n),n 为数字长度
空间复杂度: O ( n ) O(n) O(n)


2.3、中等题目2 - 股票问题

2.3.1、122. 买卖股票的最佳时机 II

力扣题目链接

普通解法:
不去深入理解题目含义,照着题目步骤做

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        # 最终值
        res = 0
        # 记录涨的股票的起始和终止位置
        start, end = 0,0
        # 遍历完成退出
        while end<len(prices):
            # 遍历到结尾,或者下一个为跌则赶紧提前卖出,求和
            if end == len(prices)-1 or prices[end]>prices[end+1]:
                # start==end时未买入,这里求买入区间的和
                if start!=end:
                    res += prices[end]-prices[start]
                # 买区间求完,换下一股
                end+=1
                start = end
            # 当涨的时候,继续等待,直到跌
            else:
                end += 1
        return res

贪心算法:
贪心算法又开始找规律,明白利润是可以分解的,就相对简单一些。如何理解?

假如第0天买入,第3天卖出,那么利润为:prices[3] - prices[0]。
相当于(prices[3] - prices[2]) + (prices[2] - prices[1]) + (prices[1] - prices[0])。

此时就是把利润分解为每天为单位的维度,而不是从0天到第3天整体去考虑!

那么根据prices可以得到每天的利润序列:(prices[i] - prices[i - 1])…(prices[1] - prices[0])。
在这里插入图片描述
这里注意,第一天是没有利润的,利润是两天相比较而产生的的。

从图中可知,只需收集每天的正利润就可以,收集正利润的区间,就是股票买卖的区间,而我们只需要关注最终利润,不需要记录区间。那么只收集正利润就是贪心算法所贪的地方。

局部最优:收集每天的正利润,全局最优:求得最大利润。

既然如此,写起来就非常简单了,

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        # 最终值
        res = 0
        for i in range(1, len(prices)):
        	# 利润值为正的累加起来
            if prices[i]>prices[i-1]:
                res+=prices[i]-prices[i-1]
        return res

时间复杂度: O ( n ) O(n) O(n)
空间复杂度: O ( 1 ) O(1) O(1)

动态规划:

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        # 创建dp table
        # 含义第N天的最大利润
        dp = [0] * (len(prices)+1)
        # 初始化,第0天和第一天没有利润,为0,当然这里多余
        dp[0] = dp[1] = 0
        # 从第二天开始产生正负利润
        for i in range(2, len(prices)+1):
            # 当前第N天的利润等于,max(之前的利润,今天的利润+之前的利润)
            # 这里取最大值,若今天产生负利润,则不买,只取之前的利润
            dp[i] = max(dp[i-1], prices[i-1]-prices[i-2]+dp[i-1])
        return dp[-1]
"""
结果样例:
 1 2 3 4 5 6 7 (days)
[  7,1,5,3,6,4]
[0,0,0,4,4,7,7]
"""

时间复杂度: O ( n ) O(n) O(n)
空间复杂度: O ( n ) O(n) O(n)


2.3.2、714. 买卖股票的最佳时机含手续费

力扣题目链接

贪心算法:
此题无非就是要找到两个点,买入日期,和卖出日期。

买入日期:其实很好想,遇到更低点就记录一下。
卖出日期:这个就不好算了,但也没有必要算出准确的卖出日期,只要当前价格大于(最低价格+手续费),就可以收获利润,至于准确的卖出日期,就是连续收获利润区间里的最后一天(并不需要计算是具体哪一天)。

有三种情况:

情况一:收获利润的这一天并不是收获利润区间里的最后一天(不是真正的卖出,相当于持有股票),所以后面要继续收获利润。
情况二:前一天是收获利润区间里的最后一天(相当于真正的卖出了),今天要重新记录最小价格了。
情况三:不作操作,保持原有状态(买入,卖出,不买不卖)

class Solution:
    def maxProfit(self, prices: List[int], fee: int) -> int:
        res = 0
        minPrice = prices[0]
        for i in range(1, len(prices)):
            # 情况二:相当于买入
            if prices[i]<minPrice:
                minPrice = prices[i]
            # 情况三:保持原有状态(因为此时买则不便宜,卖则亏本)
            elif prices[i]>=minPrice and prices[i]<=minPrice+fee:
                continue
            else:
                # 计算利润,可能有多次计算利润,最后一次计算利润才是真正意义的卖出
                res += prices[i]-minPrice-fee
                # 让minPrice = prices[i] - fee
                # 这样在明天收获利润的时候,不会多减一次手续费
                # 通过降低minPrice,使得隐式的加上了fee,如果连续获取利润的话
                minPrice = prices[i]-fee
        return res

时间复杂度:O(n)
空间复杂度:O(1)

动态规划:

可见上一篇动态规划系列文章


2.4、中等题目3 - 两个维度权衡问题

2.4.1、135. 分发糖果

力扣题目链接

这题的关键是,遇到连续的重复得分,应该如何去赋予糖果呢?这里看几个例子,试着自己手推,找找规律,注意,相邻元素相同时,糖果数是可以任意的。

因为,每个小孩至少一个糖果,则初始化,每个小孩糖果数量都为1
# 1
# 得分
1, 2, 7, 3, 2
# 糖果
1, 2, 3, 2, 1
每个元素与前面相比,大就+1,小就-1

# 2
# 得分
1, 2, 7, 7, 7, 3, 2
# 糖果
1, 2, 3, 1, 3, 2, 1
中间重复的只需要发1个糖果,也就是不变就行,初始化就是1

# 3 
# 得分
1, 2, 7, 7, 7, 3
# 糖果
1, 2, 3, 1, 2, 1
注意,重复的7后面少了一个元素,7分发糖果从3变为2
# 得分
1, 2, 7, 7, 7, 4, 3, 2
# 糖果
1, 2, 3, 1, 4, 3, 2, 1
这里,可以总结,7后面有多少个不同元素,值就为多少
如果编程的话,需要统计7之后的元素集合的个数,赋予给7,复杂度明显很高,不是个很好的选择
那么,可以这样想,最后一个7之后的所有元素,倒着[2,3,4,7]进行上面的操作
或者从list右侧开始与后一个元素比较,大则+1,小则-1,相等则不变

从上面的规律可以看出,这是个两个维度的问题,需要两次贪心算法,正向和反向。这里举个例子:

得分:
[1,2,7,7,7,3]
正向糖果:从第二个小孩开始,比左边大,则+1,其他情况不做任何处理
(因为相当于只做递增部分,后续反向会处理递减部分)
[1,2,3,1,1,1]
反向糖果:要么把得分list和糖果list都翻转进行处理,这样很麻烦。
这里可以倒着,从倒数第二个开始,和右边的比,大则+1,其余情况不不做任何和处理
[1,2,3,1,2,1]
可以看到,两个方向互不干扰,只会处理自己的那一条序列。
结果也是正确的,试试其他例子,结果也同样准确,这就是编程思路。

贪心算法:

class Solution:
    def candy(self, ratings: List[int]) -> int:
        # 初始化,每个小孩一个糖果
        tmp = [1]*len(ratings)
        # 正向分发糖果,与左边小孩比较,大则+1,否则无操作
        for i in range(1, len(ratings)):
            if ratings[i]>ratings[i-1]:
                tmp[i]=tmp[i-1]+1
        # 反向分发糖果,反向与右侧孩子比较,大则取,自身与+1的最大值,否则无操作
        # 取最大值,因为同一个孩子,多的糖果可以兼容少的糖果,相反则不成立
        for i in range(len(ratings)-2,-1,-1):
            if ratings[i]>ratings[i+1]:
                tmp[i]=max(tmp[i], tmp[i+1]+1)
        return sum(tmp)

正向:
在这里插入图片描述
反向:
在这里插入图片描述
这题如果在考虑局部的时候想两边兼顾,就会顾此失彼。


2.4.2、406.根据身高重建队列

力扣题目链接

贪心算法:
局部最优:优先按身高高的people的k来插入。插入操作过后的people满足队列属性

全局最优:最后都做完插入操作,整个队列满足题目队列属性

class Solution:
    def reconstructQueue(self, people: List[List[int]]) -> List[List[int]]:
        # 先按照h维度的身高顺序从高到低排序。确定第一个维度
        # lambda返回的是一个元组:当-x[0](维度h)相同时,再根据x[1](维度k)从小到大排序
        people.sort(key=lambda x:(-x[0],x[1]))
        que = []
        # 根据每个元素的第二个维度k,贪心算法,进行插入
        # people已经排序过了:同一高度时k值小的排前面。
        for p in people:
            que.insert(p[1], p)
        return que

首先按身高排序,身高相同的,k大的排后面
在这里插入图片描述


2.5、困难题目1 - 区间问题

当我们处理区间问题时,选择按照左端点还是右端点排序取决于问题的具体目标和限制条件。

  • 区间调度问题中,我们的目标是选择尽可能多的不重叠区间。为了达到这个目标,我们应该优先选择右端点较小的区间,因为这样可以为后面的区间留出更多的空间。因此,我们需要按照区间的右端点排序。

  • 区间合并问题中,我们需要将所有重叠的区间合并成一个区间。为了解决这个问题,我们可以按照区间的左端点排序,然后遍历所有区间,如果当前区间与上一个合并的区间重叠,则将它们合并;否则,将当前区间添加到结果中。

总之,在解决区间问题时,我们应该根据问题的具体目标和限制条件选择合适的排序方法。

什么是区间调度问题?
区间调度问题是一类经典的算法问题。它的目标是在给定的区间集合中,选择尽可能多的不重叠区间。

例如,假设我们有一个会议室,现在有若干个会议需要在这个会议室举行。每个会议都有一个开始时间和结束时间。由于会议室只能同时容纳一个会议,因此我们需要安排会议,使得尽可能多的会议能够顺利举行。

这个问题就可以看作是一个区间调度问题。每个会议都可以看作是一个区间,开始时间和结束时间分别对应区间的左端点和右端点。我们的目标是选择尽可能多的不重叠区间,即安排尽可能多的不冲突的会议。

区间调度问题通常可以使用贪心算法来解决。具体方法是按照区间的右端点排序,然后遍历所有区间,如果当前区间与上一个选择的区间不重叠,则选择当前区间。


2.5.1、55. 跳跃游戏

力扣题目链接

贪心算法:

class Solution:
    def canJump(self, nums: List[int]) -> bool:
        cover = 0
        # 长度为1则直接返回True
        if len(nums)==1:
            return True
        # 从0开始遍历,保证i在cover的范围内
        i = 0
        while i<=cover:
        	# 不断更新范围,只要有只值就往前跳,扩大cover
        	# 如果cover不再扩大,i会逐渐逼近cover临界值
            cover = max(i+nums[i], cover)
            # 范围扩张到列表最后一个值的位置,或者大于它
            if cover>=len(nums)-1:
                return True
            i+=1
        return False

代码简化下:

class Solution:
    def canJump(self, nums: List[int]) -> bool:
        bound = 0
        ind=0
        while ind<=bound and ind<len(nums):
            bound = max(bound, ind+nums[ind])
            ind+=1
        return ind>=len(nums)
            

对于这道题,最开始的想法是,假设对于3,每次到底跳几步?一步?两步?三步?每一步貌似影响的下一步也不同,将每一步分开,遍历起来会很复杂。

那么,将思路从近拉远,如果跳几步是无所谓的,那么关键是可跳的范围,将范围内的步数打包到一起看,能使范围扩大的是有效的跳跃。

那么这个问题就转化为跳跃覆盖范围究竟可不可以覆盖到终点!

每次移动取最大跳跃步数(得到最大的覆盖范围),每移动一个单位,就更新最大覆盖范围。

贪心算法局部最优解:每次取最大跳跃步数(取最大覆盖范围),整体最优解:最后得到整体最大覆盖范围,看是否能到终点。

局部最优推出全局最优,找不出反例,试试贪心。
在这里插入图片描述
以cover出发来解题:

class Solution:
    def canJump(self, nums: List[int]) -> bool:
        bound = 0
        ind=0
        while bound<len(nums)-1 and ind<=bound:
            bound = max(bound, ind+nums[ind])
            ind+=1
        return bound>=len(nums)-1
            

2.5.2、45.跳跃游戏II

力扣题目链接

贪心算法:

class Solution:
    def jump(self, nums: List[int]) -> int:
        # 记录当前覆盖范围
        cover = 0
        # 记录范围的起始点
        ind = 0
        # 记录结果,即同一覆盖范围内扩大到下一个范围即+1
        count=0
        # 2 -> 3 1 -> 1 4
        # 这里直接以cover判断而不是ind,因为cover走得比ind快,可以提前结束
        while cover<len(nums)-1:
            # 设置一个量,用来存储临时,下一个覆盖范围最大的值
            tmp = 0
            # 整个覆盖范围进行遍历,从ind开始,寻找下一步的最大覆盖范围
            for i in range(ind, cover+1):
                tmp = max(tmp, i+nums[i])
            # 一定会往前跳而到达尾部,所以这里省去判断
            # if tmp>cover:
            # 每一次覆盖范围扩大,count+=1
            count+=1
            # 从覆盖原始的覆盖范围下一个索引开始
            # 也就是新覆盖范围的第一个点
            ind = cover+1
            # 更新最大覆盖范围
            cover = tmp
            # print(ind, cover)
        return count

代码缩减下为:

```python
class Solution:
    def jump(self, nums: List[int]) -> int:
        if len(nums) == 1: return 0
        ans = 0
        curDistance = 0
        nextDistance = 0
        for i in range(len(nums)):
            nextDistance = max(i + nums[i], nextDistance)
            if i == curDistance: 
                if curDistance != len(nums) - 1:
                    ans += 1
                    curDistance = nextDistance
                    if nextDistance >= len(nums) - 1: break
        return ans

2.5.3、452. 用最少数量的箭引爆气球 - 区间调度问题

力扣题目链接

贪心算法:

以左边界排序:

class Solution:
    def findMinArrowShots(self, points: List[List[int]]) -> int:
        n = len(points)
        points.sort(key=lambda x:x[0])
        right = points[0][1]
        ans=1

        for i in range(1, n):
            if right>=points[i][0]:
                right = min(right, points[i][1])
            else:
                ans+=1
                right = points[i][1]
        return ans

时间复杂度:O(nlog n)
空间复杂度:O(1)

以右边界排序:

class Solution:
    def findMinArrowShots(self, points: List[List[int]]) -> int:
        n = len(points)
        points.sort(key=lambda x:x[1])
        right = points[0][1]
        ans=1

        for i in range(1, n):
            if right<points[i][0]:
                ans+=1
                right = points[i][1]
        return ans

在这里插入图片描述
注意: 满足 xstart ≤ x ≤ xend,则该气球会被引爆。那么说明两个气球挨在一起不重叠也可以一起射爆,所以代码中 if points[i][0] > points[i - 1][1] 不能是>=


2.5.4、435. 无重叠区间 - 区间调度问题

力扣题目链接

贪心算法:

以右区间为标准:
比该区间的右边界大,则取下一个区间ans+=1

class Solution:
    def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int:
        intervals.sort(key=lambda x: x[1])
        n = len(intervals)
        right = intervals[0][1]
        ans=1

        for i in range(1, n):
            if right<=intervals[i][0]:
                ans+=1
                right=intervals[i][1]
        return n-ans
        

以左区间为标准:
先假设每个区间都是不重叠的,其次再加入一些重叠的区间,那么重叠部分划分到前一个区间,取前一个区间的右边界,也就是最小的右边界。

class Solution:
    def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int:
        intervals.sort(key=lambda x: x[0])
        n = len(intervals)
        right = intervals[0][1]
        ans=1

        for i in range(1, n):
            if right<=intervals[i][0]:
                ans+=1
                right=intervals[i][1]
            else:
                right=min(right, intervals[i][1])
        return n-ans

2.5.5、763.划分字母区间

力扣题目链接

贪心算法:
本题本可以将每个字母的区间画出来,但是这样做对解题很麻烦,因为每次迭代节点都要更新前面所有区间,即便能解出来,复杂度相应偏高。

class Solution:
    def partitionLabels(self, s: str) -> List[int]:
        # 使用字典统计每个字母的最大索引位置
        l = {}
        for i in range(len(s)):
            l[s[i]]=i
        # print(l)

        # 寻找包含区间,要设置结果存储,左和右变量
        res = []
        left = 0
        right = 0
        # 遍历list,进行区间选取
        for i in range(len(s)):
            # 包含区间有两种情况:
            # 1.某个单词的起始区间包含多个其他字母的区间
            # 2.某两个单词,起始最小,结尾最大,共同包含多个其他字母的区间
            right = max(l[s[i]], right)
            # 遍历到最大结尾索引,无任何更大的扩展新区间了
            # 那么,该区间为包含区间,输出,再重新移动起始节点为下一位
            if right==i:
                res.append(right-left+1)
                left=i+1
        return res

在这里插入图片描述


2.5.6、56. 合并区间 - 区间合并问题

力扣题目链接

贪心算法:

class Solution:
    def merge(self, intervals: List[List[int]]) -> List[List[int]]:
        intervals.sort(key=lambda x:x[0])
        # 初始化一个区间进行区间扩展
        result = [intervals[0]]
        for i in range(1, len(intervals)):
            last = result[-1]
            # 如果交叉,改变last的右区间
            if last[1]>=intervals[i][0]:
                result[-1][1] = max(intervals[i][1], result[-1][1])
            # 如果不交叉,换下一个区间,输入一个区间进行初始化
            else:
                result.append(intervals[i])
        return result

或者:在原表的基础上修改

class Solution:
    def merge(self, intervals: List[List[int]]) -> List[List[int]]:
    	# 排序,将相邻区间排到一起
        intervals.sort(key=lambda x:x[0])
        for i in range(1, len(intervals)):
        	# 重复的往后更改,扩展区间,之前的变为空
            if intervals[i][0]<=intervals[i-1][1]:
                intervals[i][1] = max(intervals[i][1],intervals[i-1][1])
                intervals[i][0] = min(intervals[i][0],intervals[i-1][0])
                intervals[i-1] = []
        # 将空的去掉
        while [] in intervals:
            intervals.remove([])
        return intervals      

2.5.7、线段重合

牛客题目链接

import sys

n = int(input())
lines = []
for i in range(n):
    lines.append(list(map(int, input().split())))

points = []
for line in lines:
    points.append((line[0], 1))
    points.append((line[1], 0))
points.sort()
# print(points)

max_overlap = 0
overlap = 0
for point in points:
    if point[1] == 1:
        overlap += 1
    else:
        overlap -= 1
    max_overlap = max(max_overlap, overlap)
print(max_overlap)

我们需要确保终点排在起点之前,以便正确地计算最多重合的线段数。

在上面的解决方案中,我们使用了元组 (点, 类型) 来表示每个点,并在排序时确保终点排在起点之前。这是通过使用 Python 的默认元组排序方式实现的。

当我们遍历排序后的列表时,对于每个元素,我们根据它的类型来更新累加器的值。如果当前元素表示起点,那么累加器的值加1;如果当前元素表示终点,那么累加器的值减1。这样,在遍历过程中,累加器的值就表示当前重合的线段数。

为了正确地计算最多重合的线段数,我们需要确保终点排在起点之前。这是因为当两条线段首尾相接时,它们并不重合。例如,对于线段 [1, 2] 和 [2, 3],它们并不重合。因此,在计算最多重合的线段数时,我们需要先处理终点,再处理起点。

如果我们不确保终点排在起点之前,那么算法可能会错误地认为两条首尾相接的线段是重合的。例如,在上面的例子中,如果我们不确保终点排在起点之前,那么算法可能会错误地认为线段 [1, 2] 和 [2, 3] 是重合的。

因此,在计算最多重合的线段数时,我们需要确保终点排在起点之前。


2.6、困难题目2 - 其它问题

2.6.1、53. 最大子序和

力扣题目链接

暴力解法(超时):

class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        res = nums[0]
        for i in range(len(nums)):
            tmp = 0
            for j in range(i, len(nums)):
                tmp+=nums[j]
                if tmp>res:
                    res=tmp
        return res

时间复杂度:O(n^2)
空间复杂度:O(1)

贪心算法:
如果 -2 1 在一起,计算起点的时候,一定是从1开始计算,因为负数只会拉低总和,这就是贪心贪的地方!

局部最优:当前“连续和”为负数的时候立刻放弃,从下一个元素重新计算“连续和”,因为负数加上下一个元素 “连续和”只会越来越小。

全局最优:选取最大“连续和”

局部最优的情况下,并记录最大的“连续和”,可以推出全局最优。

从代码角度上来讲:遍历nums,从头开始用count累积,如果count一旦加上nums[i]变为负数,那么就应该从nums[i+1]开始从0累积count了,因为已经变为负数的count,只会拖累总和。

这相当于是暴力解法中的不断调整最大子序和区间的起始位置。
请添加图片描述

class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        res = -float(inf)
        count = 0
        # 不断调整左区间
        for i in range(len(nums)):
            count += nums[i]
            # 取累积的最大值,
            if res<count:
                res = count
            # 重置起始位置的值
            if count <0:
                count=0
        return res

时间复杂度:O(n)
空间复杂度:O(1)

动态规划:



2.6.2、134. 加油站

力扣题目链接

暴力法(循环列表,超时):

class Solution:
    def canCompleteCircuit(self, gas: List[int], cost: List[int]) -> int:
        # 计算公式为:
        # 上一轮剩余的汽油+(这一轮获得的汽油-这一轮消耗的汽油)
        # 这里括号括起来的部分,可以融合到一起
        new = list(map(lambda x,y: x-y, gas, cost))
        for i in range(len(gas)):
            # 索引往后一位,即i+1
            tmp = new[i]
            index = (i+1)%(len(gas))
            while tmp>=0 and index!=i:
                tmp += new[index]
                index = (index+1)%(len(gas))
            if tmp >=0:
                return i
        return -1

时间复杂度:O(n^2)
空间复杂度:O(1)

贪心算法:

本题的规律为:
情况一:当gas的总和小于cost总和,那么无论从哪里出发都跑不了一圈;反之大于等于0,则总有一个起点可以跑完一圈。

情况二: rest[i] = gas[i]-cost[i]为一天剩下的油,i从0开始计算累加到最后一站,如果累加没有出现负数,说明从0出发,油就没有断过,那么0就是起点。

情况三: 如果累加的最小值是负数,汽车就要从非0节点为起点重新出发,继续走,看哪个节点能这个负数填平,能把这个负数填平的节点就是出发节点。
(注意这里,非0开始时,不需要走完循环一圈,因为满足了情况一的话,从非0走完最后一个索引,剩余下来的油量必然能跑完前面没走过的加油站)

class Solution:
    def canCompleteCircuit(self, gas: List[int], cost: List[int]) -> int:
        # 计算公式为:
        # 上一轮剩余的汽油+(这一轮获得的汽油-这一轮消耗的汽油)
        # 这里括号括起来的部分,可以融合到一起
        tmp, index = 0, 0
        su_m = 0
        for i in range(len(gas)):
            # 索引往后一位,即i+1
            tmp += gas[i]-cost[i]
            su_m += gas[i]-cost[i]
            if tmp <0:
                tmp = 0
                index=i+1
        if su_m<0:
            return -1
        return index

在这里插入图片描述


2.6.3、968.监控二叉树

力扣题目链接

贪心算法:

class Solution:
    def minCameraCover(self, root: TreeNode) -> int:
        # Greedy Algo:
        # 从下往上安装摄像头:跳过leaves这样安装数量最少,局部最优 -> 全局最优
        # 先给leaves的父节点安装,然后每隔两层节点安装一个摄像头,直到Head
        # 0: 该节点未覆盖
        # 1: 该节点有摄像头
        # 2: 该节点有覆盖
        
        result = 0
        # 从下往上遍历:后序(左右中)
        def traversal(curr: TreeNode) -> int:
            nonlocal result
            
            if not curr: return 2
            left = traversal(curr.left)
            right = traversal(curr.right)

            # Case 1:
            # 左右节点都有覆盖
            if left == 2 and right == 2: 
                return 0

            # Case 2:
                # left == 0 && right == 0 左右节点无覆盖
                # left == 1 && right == 0 左节点有摄像头,右节点无覆盖
                # left == 0 && right == 1 左节点有无覆盖,右节点摄像头
                # left == 0 && right == 2 左节点无覆盖,右节点覆盖
                # left == 2 && right == 0 左节点覆盖,右节点无覆盖
            elif left == 0 or right == 0: 
                result += 1
                return 1

            # Case 3:
                # left == 1 && right == 2 左节点有摄像头,右节点有覆盖
                # left == 2 && right == 1 左节点有覆盖,右节点有摄像头
                # left == 1 && right == 1 左右节点都有摄像头
            elif left == 1 or right == 1:
                return 2
            
            # 其他情况前段代码均已覆盖

        if traversal(root) == 0:
            result += 1
            
        return result

2.7、题目练习

475. 供暖器 - bisect二分法

475. 供暖器
双层循环,遍历每个heaters,取距离所有houses最小的值作为每一次整体半径的最大值。

class Solution:
    def findRadius(self, houses: List[int], heaters: List[int]) -> int:
        max_dis = 0
        for i in range(len(houses)):
            min_dis = float('inf')
            for j in range(len(heaters)):
                min_dis = min(min_dis, abs(houses[i]-heaters[j]))
            max_dis = max(max_dis, min_dis)
        return max_dis

思路没问题,但是结果超时,需要进行优化。可以很轻易的看出,第二层循环是寻找最优,最接近的点,那么二分法可以优化这个问题。

bisect,是实现 二分 (bisection) 算法 的模块,能够保持序列顺序不变的情况下对其进行 二分查找和插入,适合用于降低对冗长序列查找的时间成本。当然也可以通过 “以空间换时间” 的方式,例如用于构造 hashmap 的 Counter 类 。

import bisect
class Solution:
    def findRadius(self, houses: List[int], heaters: List[int]) -> int:
        max_dis = 0
        # 进行排序,便于正确采用二分法
        # 二分法空间换时间
        heaters.sort()
        for i in range(len(houses)):
            # bisect_right的插入可能在该点也可能在该点右边一个坐标
            ind = bisect.bisect_right(heaters, houses[i])
            # 所以两个坐标都进行选择与比较,取差值较小的
            ind_left = ind-1

            dis = heaters[ind]-houses[i] if ind<len(heaters) else float('inf')

            left_dis = houses[i]-heaters[ind_left] if ind_left>=0 else float('inf')
            
            max_dis = max(max_dis, min(left_dis, dis))
        return max_dis

参考 二分法的使用


554. 砖墙 - map

554. 砖墙
在这里插入图片描述
第 1 行的间隙有 [1,3,5]
第 2 行的间隙有 [3,4]
第 3 行的间隙有 [1,4]
第 4 行的间隙有 [2]
第 5 行的间隙有 [3,4]
第 6 行的间隙有 [1,4,5]

from collections import defaultdict
class Solution:
    def leastBricks(self, wall: List[List[int]]) -> int:
        ans = len(wall)
        ha = defaultdict(int)
        # 找缝隙
        for bricks in wall:
            wall_width = 0
            for j in range(len(bricks)-1):
                wall_width+=bricks[j]
                ha[wall_width]+=1
        print(ha)
        # 有缝隙则减去墙壁大小,没则直接输出墙壁大小
        return ans-max(ha.values()) if ha else ans

621. 任务调度器 - 模拟得规律

621. 任务调度器

from collections import Counter
class Solution:
    def leastInterval(self, tasks: List[str], n: int) -> int:
        freq = Counter(tasks)
        # 同种类的数量最多的任务
        maxExec = max(freq.values())
        # 与数量最多的任务相同的次数
        maxCount = sum([1 for i in freq.values() if i==maxExec])
        
        return max((maxExec-1)*(n+1)+maxCount, len(tasks))

官方参考 构造方法


11. 盛最多水的容器

11. 盛最多水的容器
在这里插入图片描述

class Solution:
    def maxArea(self, height: List[int]) -> int:
        # 从左右两侧边缘开始向内计算最大面积
        l, r = 0, len(height)-1
        area = 0
        while l<r:
            # 面积的计算,取较短的一个边界,长方形计算公式
            area = max((r-l)*min(height[r], height[l]),area)
            # 移动较短的边界,试图去寻找较高的边界
            # 边界向内移动会造成宽度减少,每次移动-1,面积相对减小
            # 但是同时也能找到较大的高度去弥补缺失的宽度,甚至提升总面积
            if height[l]>=height[r]:
                r-=1
            else:
                l+=1
        return area


三、参考

贪心算法 (维基百科)
启发式 (维基百科)
贪心算法(blog)

  • 14
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 11
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值