算法总结3 贪心算法
一、理解贪心算法
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、贪心算法的做题步骤
贪心算法一般分为如下四步:
- 将问题分解为若干个子问题
- 找出适合的贪心策略
- 求解每一个子问题的最优解
- 将局部最优解堆叠成全局最优解
这是一个细分,真正做题的时候很难分出这么详细的解题步骤,可能就是因为贪心的题目往往还和其他方面的知识混在一起。
二、经典题型
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. 任务调度器 - 模拟得规律
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. 盛最多水的容器
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