贪心算法(又称贪婪算法)是指,在对问题求解时,总是做出在当前看来是最好的选择,就能得到问题的答案。贪心算法需要充分挖掘题目中条件,没有固定的模式,解决有贪心算法需要一定的直觉和经验。
贪心算法不是对所有问题都能得到整体最优解。能使用贪心算法解决的问题具有「贪心选择性质」。「贪心选择性质」严格意义上需要数学证明。能使用贪心算法解决的问题必须具备「无后效性」,即某个状态以前的过程不会影响以后的状态,只与当前状态有关。
从力扣官网对贪心算法的介绍来看,学贪心算法真就需要题海战术。记录一下有价值的题目吧 (持续更新中)
最长回文子串
【问题描述】
给你一个字符串 s,找到 s 中最长的回文子串。
如果字符串的反序与原始字符串相同,则该字符串称为回文字符串。
【样例】
输入 | 输出 |
s = "babad" | "bab" |
s = "cbbd" | "bb" |
【解析及代码】
Manacher 算法解决
class Solution:
def longestPalindrome(self, s):
s = "#".join(s.join("^$"))
# 以对应字符为中心 最长回文串的半径
p = [0] * len(s)
center, border = 0, 0
for i in range(1, len(s) - 1):
# 利用回文串的对称性进行赋值, 利用 min 防止越界
p[i] = min(p[max(0, 2 * center - i)], max(0, border - i))
# 中心扩展法
while s[i - p[i] - 1] == s[i + p[i] + 1]: p[i] += 1
# 更新回文串中心, 回文串右端点 ("#")
if i + p[i] > border: center, border = i, i + p[i]
i = max(range(len(s)), key=p.__getitem__)
j = i - p[i]
return s[j + (j & 1): i + p[i] + 1: 2]
盛水最多的容器
【问题描述】
给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。
找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。 返回容器可以储存的最大水量。
说明:你不能倾斜容器。
【样例】
输入 | 输出 |
height = [1,8,6,2,5,4,8,3,7] | 49 |
height = [1,1] | 1 |
【解析及代码】
使用双指针,分别指向两端,保证容器的宽度最大。然后看两个指针对应的高度,哪个小就移动哪个,每次都计算一次,然后比较取最优
class Solution(object):
def maxArea(self, height):
max_area = 0
# 初始化双指针
l, r = 0, len(height) - 1
while l < r:
# 和当前最优解比较
max_area = max(max_area, (r - l) * min(height[l], height[r]))
if height[l] <= height[r]:
l += 1
else:
r -= 1
return max_area
跳跃游戏
【问题描述】
给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标。
【样例】
输入 | 输出 |
nums = [2,3,1,1,4] | true |
nums = [3,2,1,0,4] | false |
【解析及代码】
思路很简单,直接看代码的注释吧
class Solution(object):
def canJump(self, nums):
# 可到达的最远点索引
max_idx = 0
for i, j in enumerate(nums):
# 确认当前点 (索引 i) 可到达
if max_idx >= i:
max_idx = max(i + j, max_idx)
if max_idx >= len(nums) - 1: return True
return False
买卖股票的最佳时机Ⅱ
【问题描述】
给你一个整数数组 prices ,其中 prices[i] 表示某支股票第 i 天的价格。
在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售。 返回 你能获得的 最大 利润
【样例】
输入 | 输出 |
prices = [7,1,5,3,6,4] | 7 |
prices = [1,2,3,4,5] | 4 |
prices = [7,6,4,3,1] | 0 |
【解析及代码】
比方说现在给定的 prices = [1, 2, 3, 4],最优的结果肯定是第一天买,最后一天卖出最好:4 - 1 = 3。当然这也等同于:- 1 + 2 - 2 + 3 - 3 + 4 = (- 1 + 2) + (- 2 + 3) + (- 3 + 4) = 3
所以说我们可以只扫描一次 prices,只要相邻两价格呈递增关系,就直接把差价算成利润
class Solution(object):
def maxProfit(self, prices):
return sum(prices[i + 1] - prices[i]
for i in range(len(prices) - 1) if prices[i] < prices[i + 1])
加油站
【问题描述】
在一条环路上有 n 个加油站,其中第 i 个加油站有汽油 gas[i] 升。
你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。
给定两个整数数组 gas 和 cost ,如果你可以绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1 。如果存在解,则 保证 它是 唯一 的
【样例】
输入 | 输出 |
gas = [1,2,3,4,5], cost = [3,4,5,1,2] | 3 |
gas = [2,3,4], cost = [3,4,3] | -1 |
【解析及代码】
照上面的样例讲一下思路:当处在 0 号加油站时,开往下一个加油站会使油量 -2 (由 1 - 3 得),同理得各个加油站的前进开销为:[-2, -2, -2, 3, 3]
如果我们从 0 号加油站出发,则会在到 3 号加油站时,油量达到最低 -6 (见下图蓝色线)
如果把蓝线向上平移 6 个单位,即以 3 号加油站作为起点,则油量永远不会低于0 —— 所以前进开销累积的最小值处,就是起点 (仅当最小值 < 0 时)
当然,前进开销累积到最后,是非负值才会有起点 (即总油量 ≥ 总消耗)
class Solution(object):
def canCompleteCircuit(self, gas, cost):
import itertools as it
# 转为前缀和, 并求取最小值
res = list(it.accumulate(g - c for g, c in zip(gas, cost)))
x = min(res)
return -1 if res[-1] < 0 else (
(res.index(x) + 1) % len(res) if x < 0 else 0)
分发糖果
【问题描述】
n 个孩子站成一排。给你一个整数数组 ratings 表示每个孩子的评分。
你需要按照以下要求,给这些孩子分发糖果:
1. 每个孩子至少分配到 1 个糖果。
2. 相邻两个孩子评分更高的孩子会获得更多的糖果。
请你给每个孩子分发糖果,计算并返回需要准备的 最少糖果数目 。
【样例】
输入 | 输出 |
ratings = [1,0,2] | 5 |
ratings = [1,2,2] | 4 |
【解析及代码】
将孩子的评分绘制成折线图,会发现折线由若干个峰构成,依据峰谷的位置进行划分
对某个峰,用 up_len 表示递增部分长度,用 down_len 表示递减部分长度 (均不包括峰顶,但均包括峰谷),如:up_len = 2,down_len = 3,则糖果分配是 [1, 2, 4, 3, 2, 1]
得某个峰对糖果的需求为:
这个方法在实现过程中要注意一些边界条件,最终代码如下:
class Solution(object):
def candy(self, ratings):
# 记录总糖果数
sum_ = 0
# 升序、降序部分长度
up_len, down_len = 0, 0
# 计算 1 + 2 + ... + last
deng_cha = lambda last: (1 + last) * last // 2
# 计算峰对糖果的需求
feng_sum = lambda up_len, down_len: deng_cha(up_len) + deng_cha(down_len) + max([up_len, down_len]) + 1
for i in range(1, len(ratings)):
if ratings[i] > ratings[i - 1]:
if down_len:
# 经过峰谷,即形成新的峰, 结算上一个峰; 波谷是两个峰共享的, 避免重复计算应 -1
sum_ += feng_sum(up_len, down_len) - 1
# 置零升序、降序部分
up_len, down_len = 0, 0
# 叠加升序部分
up_len += 1
# 叠加降序部分
elif ratings[i] < ratings[i - 1]:
down_len += 1
# 两评分相等时,第一个评分作为峰谷,第二个评分作为新峰的峰顶/峰谷
else:
sum_ += feng_sum(up_len, down_len)
# 置零升序、降序部分
up_len, down_len = 0, 0
# 未结算的峰
sum_ += feng_sum(up_len, down_len)
return sum_
去除重复字母
【问题描述】
给你一个字符串 s ,请你去除字符串中重复的字母,使得每个字母只出现一次。需保证 返回结果的字典序最小(要求不能打乱其他字符的相对位置)。
【样例】
输入 | 输出 |
s = "bcabc" | "abc" |
s = "cbacdcbc" | "acdb" |
【解析及代码】
字典序与字符串长度无关,Python 的 str 类型比较依据就是字典序,不会的话可以调试找找规律
拿到题首先还是先想 O(n) 的思路,从头到尾扫描一遍字符串,在扫描过程中要加入怎样的操作才可以完成任务?
以 'cabacb' 为例:
- 读取 'c':计入新的字符串:'c'
- 读取 'a','c' 后面还有,而且 'c' > 'a' (新增逆序区) → 去掉 'c',计入新的字符串:'a'
- 读取 'b',前面没出现过,而且 'a' < 'b' (新增顺序区),计入新的字符串:'ab'
- 读取 'a',前面出现过,跳过
- 读取 'c',前面没出现过,而且新增顺序区,计入新的字符串:'abc'
- 读取 'b',前面出现过,跳过
最终结果是 'abc'
class Solution(object):
def removeDuplicateLetters(self, s):
stringio = ''
for i, char in enumerate(s):
if char not in stringio:
# 删除部分字符
while stringio:
front = stringio[-1]
# 不产生逆序区 / 不是重复字符
if front < char or front not in s[i:]: break
stringio = stringio[:-1]
# 追加新字符
stringio += char
return stringio
按要求补齐数组
【问题描述】
给定一个已排序的正整数数组 nums ,和一个正整数 n 。从 [1, n] 区间内选取任意个数字补充到 nums 中,使得 [1, n] 区间内的任何数字都可以用 nums 中某几个数字的和来表示。
请返回 满足上述要求的最少需要补充的数字个数
【样例】
输入 | 输出 |
nums = [1,3], n = 6 | 1 |
nums = [1,5,10], n = 20 | 2 |
nums = [1,2,2], n = 5 | 0 |
【解析及代码】
最重要的两个条件:nums[i] ≥ 1,按照升序排列
假设输入是:nums = [2, 5], n = 30
- 因为第一个数不是1,所以需要补入“1”
- 第一个数是2,则可覆盖区间由 [1, 1] 变为 [1, 3]
- 第二个数是5,如果直接加上,则可覆盖区间是 [1, 3] | [5, 3+5],缺失了4,所以先不加上。如果这时加上的数是4,则可覆盖区间变为 [1, 3] | [4, 3+4] = [1, 7],当然,小于4的数也可以,但是推算会发现4是最优的选择:记可覆盖区间的右边界为 reachable,最优的选择即为 reachable + 1
- 再次读取第二个数,可覆盖区间由 [1, 7] 变为 [1, 12]
- 此时数组里的数都用过了,但是还没有达到 [1, 30],所以按照步步最优补上:[13, 26]
最终补齐的数组是:[1, 2, 4, 5, 13, 26],return 4
class Solution(object):
def minPatches(self, nums, n):
# 需要补上的数字数, 指针
need, pin = (0, 1) if nums[0] == 1 else (1, 0)
# 可覆盖区间为 [1, reachable]
reachable = 1
while pin < len(nums):
# 读取当前指针指向的数
next_ = nums[pin]
# 假如可覆盖为 [1, 3], 添加 4 的收益最大
require = reachable + 1
# 如果添加 5, 则无法覆盖 4
if next_ > require:
# 添加收益最大的 4, 指针不动
need += 1
reachable += require
# 如果添加 2/3 等, 则直接叠加
else:
pin += 1
reachable += next_
if reachable >= n:
return need
# 当原始数组已遍历过
while reachable < n:
# 总是添加最优
need += 1
reachable += reachable + 1
return need
递增的三元子序列
【问题描述】
给你一个整数数组 nums ,判断这个数组中是否存在长度为 3 的递增子序列。
如果存在这样的三元组下标 (i, j, k) 且满足 i < j < k ,使得 nums[i] < nums[j] < nums[k] ,返回 true ;否则,返回 false
【样例】
输入 | 输出 |
nums = [1,2,3,4,5] | true |
nums = [5,4,3,2,1] | false |
nums = [2,1,5,0,4,6] | true |
【解析及代码】
用 p1 指向已搜索区域的最小值,用 p2 指向已搜索区域的次小值,这两个值无需关注顺序先后
class Solution(object):
def increasingTriplet(self, nums):
if len(nums) < 3: return False
p1, p2 = nums[0], 1 << 32
# 保持 p1 < p2
for p3 in nums[1:]:
if p2 < p3: return True
# p2 取次小值
if p3 > p1:
p2 = min(p2, p3)
# p1 取最小值
else:
p1 = p3
return False
以 [7, 6, 3, 4, 1, 2, 5] 为例来推一遍
操作 | 读取值 p3 | 最小值 p1 | 次小值 p2 |
初始化 | 7 | inf | |
1 | 6 | 6 | inf |
2 | 3 | 3 | inf |
3 | 4 | 3 | 4 |
4 | 1 | 1 | 4 |
5 | 2 | 1 | 2 |
6 | 5 (True) | 1 | 2 |
每次读取,都会选择 输出、改变 p1、改变 p2,也就是 p1 和 p2 不会同时改变
- 改变 p1 时,p1 只会变成更小的值,仍然满足p1 < p2,当 p2 < p3 时仍可输出正确结果
- 改变 p2 时,需判断 p3 > p1,p2 取 min([p2, p3]),此时 p3 在位置上一定在 p1 后,也就是改变后的 p2 也一定在 p1 后
- 数值上:p1 < p2 恒成立;位置上:一定存在 x 满足 p1 < x < p2,x 的位置在 p2 前
鸡蛋掉落-两枚鸡蛋
【问题描述】
给你 2 枚相同 的鸡蛋,和一栋从第 1 层到第 n 层共有 n 层楼的建筑。
已知存在楼层 f ,满足 0 <= f <= n ,任何从 高于 f 的楼层落下的鸡蛋都 会碎 ,从 f 楼层或比它低 的楼层落下的鸡蛋都 不会碎 。
每次操作,你可以取一枚 没有碎 的鸡蛋并把它从任一楼层 x 扔下(满足 1 <= x <= n)。如果鸡蛋碎了,你就不能再次使用它。如果某枚鸡蛋扔下后没有摔碎,则可以在之后的操作中 重复使用 这枚鸡蛋。
请你计算并返回要确定 f 确切的值 的 最小操作次数 是多少?
【样例】
输入 | 输出 |
n = 2 | 2 |
n = 100 | 14 |
【解析及代码】
以第二个样例推导一下为什么答案是 14:
- 从 14 层丢下,如果碎了就从 1~13 依次丢,如果没碎则继续以下步骤
- 从 27 层丢下,如果碎了就从 15~26 依次丢,如果没碎则继续以下步骤
- 从 39 层丢下,如果碎了就从 28~38 依次丢,如果没碎则继续以下步骤
- ……
最后可以发现,如果鸡蛋一直没碎,那么我们需要把鸡蛋从 14、27、39、50、60、69、77、84、90、95、99、102、104、105 楼丢下,而它们的差分数列刚好是等差数列
不难归纳出,楼层总数 n 与答案 x 存在以下关系:
class Solution(object):
def twoEggDrop(self, n):
import math
return int(math.ceil(math.sqrt(2 * n + .25) - .5))
鸡蛋掉落
【问题描述】
给你 k 枚相同的鸡蛋,并可以使用一栋从第 1 层到第 n 层共有 n 层楼的建筑。
已知存在楼层 f ,满足 0 <= f <= n ,任何从 高于 f 的楼层落下的鸡蛋都会碎,从 f 楼层或比它低的楼层落下的鸡蛋都不会破。
每次操作,你可以取一枚没有碎的鸡蛋并把它从任一楼层 x 扔下(满足 1 <= x <= n)。如果鸡蛋碎了,你就不能再次使用它。如果某枚鸡蛋扔下后没有摔碎,则可以在之后的操作中 重复使用 这枚鸡蛋。
请你计算并返回要确定 f 确切的值 的 最小操作次数 是多少?
【样例】
输入 | 输出 |
k = 1, n = 2 | 2 |
k = 2, n = 6 | 3 |
k = 3, n = 14 | 4 |
【解析及代码】
当鸡蛋个数 k = 1 时,显然答案等于 n
当鸡蛋个数 k = 2 时,根据上一题的结果可知答案等于
当鸡蛋个数 k = 3 时,可以与上一题采用相似的思路
以 表示鸡蛋个数为 k、操作次数为 t 时所能测量的最大的楼层
e.g.,
其中的 1 表示 决策点:以第一个一个 1 为例,在相应的位置丢鸡蛋,碎了则在其楼层之下测试,可测量的最大楼层为 ;如果没碎则在其楼层之上测试,可测量的最大楼层为
再枚举几个例子并进行验证后,不难归纳出以下规律:
当给定 k 个鸡蛋时,利用第一条规律,我们可以以最快的速度依次计算
将每次计算的结果与 n 比较,便可得到最小的操作次数
class Solution(object):
def superEggDrop(self, k, n):
from functools import lru_cache
import math
@lru_cache(maxsize=None)
def f(k, t):
''' k: 鸡蛋个数
t: 操作次数
return: n 的最大值'''
if k == 1: return t
if k == 2: return (t + 1) * t // 2
# 鸡蛋个数大于 2
return t + sum(f(k - 1, i) for i in range(1, t))
if k == 1 or n == 1: return n
if k == 2: return int(math.ceil(math.sqrt(2 * n + .25) - .5))
# 鸡蛋个数大于 2: f(k, 1) = 1
reach = 1
for t in range(1, n):
# f(k, t+1) = f(k, t) + f(k-1, t) + 1
reach += f(k - 1, t) + 1
if reach >= n: return t + 1