贪心算法
贪心算法:一种在每次决策时,总是采取在当前状态下的最好选择,从而希望导致结果是最好或最优的算法。换句话说,贪心算法不从整体最优上加以考虑,而是一步一步进行,每一步只以当前情况为基础,根据某个优化测度做出局部最优选择,从而省去了为找到最优解要穷举所有可能所必须耗费的大量时间。
特征:对许多问题来说,可以使用贪心算法,通过局部最优解而得到整体最优解或者是整体最优解的近似解。但并不是所有问题,都可以使用贪心算法的。
一般来说,这些能够使用贪心算法解决的问题必须满足下面的两个特征:
- 贪心选择性质:指的是一个问题的全局最优解可以通过一系列局部最优解(贪心选择)来得到。
- 最优子结构:指的是一个问题的最优解包含其子问题的最优解。
贪心算法正确性的证明
贪心算法最难的部分不在于问题的求解,而在于是正确性的证明。我们常用的证明方法有「数学归纳法」和「交换论证法」。
- 数学归纳法:先计算出边界情况的最优解,然后再证明对于每个 n n n, F n + 1 F_{n + 1} Fn+1 都可以由 F n F_n Fn推导出。
- 交换论证法:从最优解出发,在保证全局最优不变的前提下,如果交换方案中任意两个元素 / 相邻的两个元素后,答案不会变得更好,则可以推定目前的解是最优解。
判断一个问题是否通过贪心算法求解,是需要进行严格的数学证明的。但是在日常写题或者算法面试中,不太会要求大家去证明贪心算法的正确性。
所以,当我们想要判断一个问题是否通过贪心算法求解时,我们可以:
- 凭直觉:如果感觉这道题可以通过「贪心算法」去做,就尝试找到局部最优解,再推导出全局最优解。
- 举反例:尝试一下,举出反例。也就是说找出一个局部最优解推不出全局最优解的例子,或者找出一个替换当前子问题的最优解,可以得到更优解的例子。如果举不出反例,大概率这道题是可以通过贪心算法求解的。
贪心算法三步走
- 转换问题:将优化问题转换为具有贪心选择性质的问题,即先做出选择,再解决剩下的一个子问题。
- 贪心选择性质:根据题意选择一种度量标准,制定贪心策略,选取当前状态下「最好 / 最优选择」,从而得到局部最优解。
- 最优子结构性质:根据上一步制定的贪心策略,将贪心选择的局部最优解和子问题的最优解合并起来,得到原问题的最优解。
贪心算法的应用
分发饼干
题目大意:一位很棒的家长为孩子们分发饼干。对于每个孩子
i
i
i,都有一个胃口值
g
[
i
]
g[i]
g[i],即每个小孩希望得到饼干的最小尺寸值。对于每块饼干
j
j
j,
都有一个尺寸值
s
[
j
]
s[j]
s[j],只有当
s
[
j
]
>
g
[
i
]
s[j]>g[i]
s[j]>g[i] 时,我们才能将饼干
i
i
i。每个孩子最多只能给一块饼干。尽可能满足越多数量的孩子,并求出这个最大数值。
示例:
输入:g = [1,2,3], s = [1,1]
输出:1
解释:你有三个孩子和两块小饼干,3 个孩子的胃口值分别是:1, 2, 3。虽然你有两块小饼干,由于他们的尺寸都是 1,你只能让胃口值是 1 的孩子满足。所以应该输出 1。
输入: g = [1,2], s = [1,2,3]
输出: 2
解释: 你有两个孩子和三块小饼干,2个孩子的胃口值分别是1, 2。你拥有的饼干数量和尺寸都足以让所有孩子满足。所以你应该输出 2。
解题思路:为了尽可能的满⾜更多的⼩孩,而且一块饼干不能掰成两半,所以我们应该尽量让胃口小的孩子吃小块饼干,这样胃口大的孩子才有大块饼干吃。
所以,从贪心算法的角度来考虑,我们应该按照孩子的胃口从小到大对数组 g g g进行排序,然后按照饼干的尺寸大小从小到大对数组 s s s 进行排序,并且对于每个孩子,应该选择满足这个孩子的胃口且尺寸最小的饼干。
使用贪心算法三步走的方法解决:
- 转换问题:将原问题转变为,当胃口最小的孩子选择完满足这个孩子的胃口且尺寸最小的饼干之后,再解决剩下孩子的选择问题。
- 贪心选择性质:对于当前孩子,用尺寸尽可能小的饼干满足这个孩子的胃口。
- 最优子结构性质:在上面的贪心策略下,当前孩子的贪心选择 + 剩下孩子的子问题最优解,就是全局最优解。也就是说在贪心选择的方案下,能够使得满足胃口的孩子数量达到最大。
from typing import List
class Solution:
def findContentChildren(self, g: List[int], s: List[int]) -> int:
g.sort()
s.sort()
index_g, index_s = 0, 0
res = 0
while index_g < len(g) and index_s < len(s):
if g[index_g] <= s[index_s]:
res += 1
index_g += 1
index_s += 1
else:
index_s += 1
return res
无重叠区间
题目大意:给定一个区间的集合
i
n
t
e
r
v
a
l
s
intervals
intervals,其中
i
n
t
e
r
v
a
l
s
[
i
]
=
[
s
t
a
r
t
i
,
e
n
d
i
]
intervals[i] = [starti, endi]
intervals[i]=[starti,endi]。从集合中移除部分区间,使得剩下的区间互不重叠。返回需要移除区间的最小数量。
示例:
输入:intervals = [[1,2],[2,3],[3,4],[1,3]]
输出:1
解释:移除 [1,3] 后,剩下的区间没有重叠。
输入: intervals = [ [1,2], [1,2], [1,2] ]
输出: 2
解释: 你需要移除两个 [1,2] 来使剩下的区间没有重叠。
解题思路:这道题我们可以转换一下思路。原题要求保证移除区间最少,使得剩下的区间互不重叠。换个角度就是:「如何使得剩下互不重叠区间的数目最多」。那么答案就变为了:「总区间个数 - 不重叠区间的最多个数」。我们的问题也变成了求所有区间中不重叠区间的最多个数。
从贪心算法的角度来考虑,我们应该将区间按照结束时间排序。每次选择结束时间最早的区间,然后再在剩下的时间内选出最多的区间。
使用贪心算法三步走的方法解决:
- 转换问题:将原问题转变为,当选择结束时间最早的区间之后,再在剩下的时间内选出最多的区间(子问题)。
- 贪心选择性质:每次选择时,选择结束时间最早的区间。这样选出来的区间一定是原问题最优解的区间之一。
- 最优子结构性质:在上面的贪心策略下,贪心选择当前时间最早的区间 + 剩下的时间内选出最多区间的子问题最优解,就是全局最优解。也就是说在贪心选择的方案下,能够使所有区间中不重叠区间的个数最多。
from typing import List
class Solution:
def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int:
if not intervals:
return 0
intervals.sort(key=lambda x: x[1])
end_pos = intervals[0][1]
count = 1
for i in range(1, len(intervals)):
if end_pos <= intervals[i][0]:
count += 1
end_pos = intervals[i][1]
return len(intervals) - count
用最少数量的箭引爆气球
题目大意:在一个坐标系中有许多球形的气球。对于每个气球,给定气球在 x 轴上的开始坐标和结束坐标 。同时,在
x
x
x轴的任意位置都能垂直发出弓箭,假设弓箭发出的坐标就是
x
x
x。那么如果有气球满足
x
s
t
a
r
t
≤
x
≤
x
e
n
d
x_{start} ≤ x ≤ x_{end}
xstart≤x≤xend,则该气球就会被引爆,且弓箭可以无限前进,可以将满足上述要求的气球全部引爆。现在给定一个数组
p
o
i
n
t
s
points
points,其中
p
o
i
n
t
s
[
i
]
=
[
x
s
t
a
r
t
,
x
e
n
d
]
points[i] = [x_{start}, x_{end}]
points[i]=[xstart,xend]代表每个气球的开始坐标和结束坐标。返回能引爆所有气球的最小弓箭数。
示例:
输入:points = [[10,16],[2,8],[1,6],[7,12]]
输出:2
解释:气球可以用 2 支箭来爆破:
- 在x = 6 处射出箭,击破气球 [2,8] 和 [1,6]。
- 在x = 11 处发射箭,击破气球 [10,16] 和 [7,12]。
输入:points = [[1,2],[3,4],[5,6],[7,8]]
输出:4
解释:每个气球需要射出一支箭,总共需要 4 支箭。
解题思路:首先,我们按照气球的结束坐标对气球进行排序。这是关键步骤,因为这样可以确保我们在选择箭的位置时,尽可能多地覆盖后续的气球。
从第一个气球开始,选择其结束坐标作为箭的位置。这样可以确保当前箭能够击破尽可能多的气球。
续检查后续的气球,如果某个气球的开始坐标大于当前箭的位置,说明当前箭无法击破该气球,需要发射新的箭。
每次需要新的箭时,选择当前气球的结束坐标作为新的箭的位置,重复上述过程,直到所有气球都被击破。
def findMinArrowShots(points):
if not points:
return 0
# 按照气球的结束坐标进行排序
points.sort(key=lambda x: x[1])
# 初始化箭的数量和第一个箭的位置
arrows = 1
arrow_pos = points[0][1]
# 遍历每个气球
for i in range(1, len(points)):
# 如果当前气球的开始坐标大于箭的位置,则需要一个新的箭
if points[i][0] > arrow_pos:
arrows += 1
arrow_pos = points[i][1]
return arrows
# 示例测试
print(findMinArrowShots([[10,16],[2,8],[1,6],[7,12]])) # 输出: 2
print(findMinArrowShots([[1,2],[3,4],[5,6],[7,8]])) # 输出: 4