详解动态规划
关于贪心算法的详解,请参考详解贪心算法
文章目录
references:
What is the role of recursion in Dynamic Programming?
动态规划的特点
对于使用动态规划解决的问题有如下几种特点:
- 可以分解成更小的问题来解决
- 拥有最佳子结构,也就是说求最佳 F(n),通常依赖最佳 F(n-1)
- 通常是先解决子问题,最终选择出来最好的结果
动态规划解题步骤
- 通过穷举找到规律
- 确认边界
- 写状态转移方程和最优子结构
- 判断是否需要暂存结果
关于动态规划的思考: 递归 vs dp
关于递归
递归是函数中出现调用本身的代码,它解决的问题模型是:大的问题通过递归到小的问题最后递归到边界问题来解决。(很像 dp 解决的问题是吧)
summary
但 dp 实则是递归的升级,它通过总结递归过程中重复计算的那些点,对结果进行缓存,最终求得最佳答案。
在一些博客中讲 dp 有两种实现方式:(个人主要认为动态规划主要是自底向上的解决方法,但此处都列出来方便大家理解)
- Bottom Up 自底向上
recursion 在这个过程中发挥的作用是辅助你理解!你可以通过穷举发现有哪些步骤被重复了,然后通过从最小的子问题开始解决+缓存结果来解决问题。
- 通常如果状态转移方程中决定答案的只有一个参数:
F(n)= min(F(n-1),x)
此时一般通过单层循环+一维数组解决问题 - 如果状态转移方程中决定答案的有两个参数
F(n,arr)=min(F(n-1,arr[:i]))
此时一般通过双层循环+二维数组来解决问题
- Top Down 自顶向下
这个过程通常通过 recursion 来解决问题,通常我们遇到一个问题时可以用递归写出来那么就该想想是不是能用动态规划来对递归进行优化即看子问题是否有重叠或重复计算的地方。
基于 dp 的经典算法
Kadane algorithm
kadane algorithm实质上是一种动态规划算法,
- 状态转移方程:F(n)=max(f(n), f(n)+F(n-1)) 标识前面 n+1 个元素以索引 n 结尾的最大子序列和,
- 最优子结构:F(n-1) 显然就是前面 n 个序列以 n-1 结尾的子序列最大和,
- n 个元素的连续子序列最大和,可能以 0 结尾可能以1 结尾,所以求暂存数组的最大值即可。
时间复杂度:O(n)
空间复杂度: O(1)
# kadane 伪代码
cur, res = 0, -sys.maxsize-1
for x in nums:
# 负数是一个关键停止点
cur = x + max(cur, 0)
# res 实际上是在暂存 cur
res = max(res, cur)
# 更容易理解的 kadane 伪代码
arr = [float('-inf')] * length
arr[0] = nums[0]
for i in range(1, length):
# 如果以 i 结尾,那么一种可能是包含 i-1,另一种可能就是只有它自己
arr[i] = max(arr[i-1]+nums[i], nums[i])
return max(arr)
根据 kadane 算法你能写出来最小子数组和吗?
# 伪代码
cur, res = 0, sys.maxsize
for x in nums:
# 正数是一个关键停止点
cur = x + min(cur, 0)
# res 实际上是在暂存 cur
res = min(res, cur)
# 更容易理解的 kadane 伪代码
arr = [float('inf')] * length
arr[0] = nums[0]
for i in range(1, length):
# 如果以 i 结尾,那么一种可能是包含 i-1,另一种可能就是只有它自己
arr[i] = min(arr[i-1]+nums[i], nums[i])
return min(arr)
简单验证 kadane 算法:
假如数组是 [0,1,2],如果穷举所有的子序列
0
0 1
0 1 2
1
1 2
2
使用 kadane 算法,找一个 arr 暂存所有以 i 结尾的最大子序列和
0 0
1 1 0+1
2 2 1+2 0+1+2
由此可见其实穷举了所有的可能性。
基于 kadane 算法的题目:
题解请参考我的 github repo
- 最大子数组和
- 环形子数组的最大和
- leetcode-1186-Maximum Subarray Sum with One Deletion 🌟🌟🌟
- 300. Longest Increasing Subsequence🌟🌟🌟
Kadane algorithm varint
kadane algorithm 适用于求最大连续子数组和,或者最长的连续递增子数组,那如果求最大不一定连续的递增子数组呢?此时我们需要加一层循环来对比:
F(n) 标识 前 n+1 个元素,包含索引为 n 的元素时最长的递增子数组长度,则能得到状态转移方程
if nums(0~n-1)<nums[n]:
F(n) = max(F(0~n-1)) + 1
else:
F(n) = 1
边界条件:F[0] = 1
Kadane algorithm varint
- Leetcode-354-Russian doll 这道题也有其他更好的做法,参考详解二分查找法
例题
322. 零钱兑换考察如何暂存结果避免重复计算,这是个很好的题目 🌟🌟🌟
股票问题
传统的股票问题我们通常使用贪心算法即选定一个贪心策略然后计算就行了,LeetCode 上也有一些股票问题的变体,它们恰恰使用动态规划更合适
买卖股票的最佳时机 II-leetcode-122
题解:
买卖股票并没有增加什么限制,只有唯一限制是不能手里同时有多个股票,这个题只需要有正收益的时候购买就可以了,所以我们可以用贪心算法,总是找更小的值买入,找到比它大的值就可以卖掉随即将当前股票持有,比如 2 5 7 而言,7-2=5-2+7-5
def maxProfit_0(self, prices: List[int]) -> int:
"""
由于可以当天可以立即买入然后立即卖出,我们可以总是累积比如 [1,2,3,4,5] 可以是交易四次的值
ts: O(N)
ss: O(1)
:param prices:
:return:
"""
res, min_val = 0, prices[0]
for i in range(1, len(prices)):
if prices[i] > min_val:
res += prices[i] - min_val
min_val = prices[i]
return res
如果用动态规划的思想来看,每天的状态只有买入和卖出两种状态,且这两种状态都和上一次买入和上一次卖出相关:
状态转移方程:其中 buy 为持有股票时最大收益,sell 为不持有股票时最大收益
buy=max(last_buy, last_sell-prices[i])
sell=max(last_sell, last_buy+prices[i])
边界条件:
buy = -prices[0]
sell = 0
def maxProfit(self, prices: List[int]) -> int:
buy = -prices[0]
sell = 0
for i in range(1, len(prices)):
tmp = buy
buy = max(buy, sell-prices[i])
sell = max(sell, tmp+prices[i])
return sell
买卖股票的最佳时机 III-leetcode-123
题解:
其实经过穷举之后我们可以发现一直到最后一天,我们想要计算出最大收益,就需要前一天的一些状态,由此我们可以得出结论:
- 问题具有最佳子结构的性质,即总问题的最优解是由子问题的最优解组成的
- 列举状态:一天结束后有如下几种状态
- 未进行过任何操作;最大利润为 0
- 只进行过一次买操作;代号 buy1 = max(buy1, -prices[i])
- 进行了一次买操作和一次卖操作,即完成了一笔交易;sell1=max(sell1, buy1+prices[i])
- 在完成了一笔交易的前提下,进行了第二次买操作;buy2 =max(buy2, sell1-prices[i])
- 完成了全部两笔交易。sell2 = max(sell2, buy2+prices[i])
- 列初始状态:
- buy1 = -prices[0]
- sell1 = 0
- buy2 = -prices[0]
- selle2 = 0
class Solution:
# 时间复杂度:O(n)
# 空间复杂度:O(1)
def maxProfit(self, prices: List[int]) -> int:
buy1 = -prices[0]
sell1 = 0
buy2 = -prices[0]
sell2 = 0
for i in range(1, len(prices)):
buy1 = max(buy1, -prices[i])
sell1 = max(sell1, buy1 + prices[i])
buy2 = max(buy2, sell1 - prices[i])
sell2 = max(sell2, buy2 + prices[i])
return sell2
"""
或直接回到最初的初始状态
buy1 = float('-inf')
sell1 = float('-inf')
buy2 = float('-inf')
sell2 = float('-inf')
"""
def maxProfit(self, prices: List[int]) -> int:
"""
时间复杂度:O(N)
空间复杂度:O(1)
:param prices:
:return:
"""
buy1 = float('-inf')
sell1 = float('-inf')
buy2 = float('-inf')
sell2 = float('-inf')
res = 0
for i in range(len(prices)):
buy1 = max(buy1, -prices[i])
sell1 = max(sell1, buy1 + prices[i])
buy2 = max(buy2, sell1 - prices[i])
sell2 = max(sell2, buy2 + prices[i])
res = max(buy1, sell1, buy2, sell2)
return res
最佳买卖股票时机 IV-leetcode-188
题解:
我们假设 k=3 即最多有三次交易来进行穷举,那么最后一天结束时的状态有以下几种:
- 没有进行过任何交易 0 f[0][0] = 0 增加一个假设值 f[0][1] = 0
- 进行过一次买入 f[1][0] = max(f[1][0], f[0][1]-prices[i]) 默认值是 -prices[0]
- 进行过一次买入 一次卖出 f[1][1] = max(f[1][1], f[1][0]+prices[i]) default: 0
- 进行过一次交易+一次买入即两次买入 f[2][0] = max(f[2][0], f[1][1]-prices[i]) default: -prices[0]
- 进行过两次交易 f[2][1]=max(f[2][1], f[2][0]+prices[i]) default: 0
- 进行过三次买入 f[3][0] …
- 进行过三次交易 f[3][1] …
我们发现本题其实是股票 III 的复杂版:
- 问题具有最佳子结构的性质,即总问题的最优解是由子问题的最优解组成的
- 列举状态:一天结束后有如下几种状态
- 未进行过任何操作;最大利润为 0
- 进行过 k 次买入操作: f[k][0] = max(f[k][0], f[k-1][1]-prices[i])
- 进行过 k 次交易:f[k][1] = max(f[k][1], f[k][0]+prices[i])
- 列初始状态:
- f[0][0]=f[0][1]=0
- f[k][0]=-prices[0]
- f[k][1]=0
class Solution:
# 时间复杂度:O(n*k)
# 空间复杂度:O(k)
def maxProfit(self, k: int, prices: List[int]) -> int:
# 定义一个二维数组
res, length = [[0, 0] for i in range(k+1)], len(prices)
if length == 0:
return 0
# 赋初值
for i in range(1, k+1):
res[i][0] = -prices[0]
for i in range(1, length):
for j in range(1, k+1):
res[j][0] = max(res[j][0], res[j - 1][1] - prices[i])
res[j][1] = max(res[j][1], res[j][0] + prices[i])
# 求二维数组中最大的值
# tmp = 0
# for i in range(k+1):
# tmp = max(tmp, res[i][0], res[i][1])
return max(max(item) for item in res)
最佳买卖股票时机含冷冻期-leetcode-309
题解:
和上一题类似,通过穷举之后我们发现:
- 主问题的最优解是由子问题的最优解组成的,因此具有最优子结构性质
- 列举状态:最终那天的状态有处于冷冻期和不处于冷冻期两种
- 不处于冷冻期+买入了一支股票:no_cool_in = max(no_cool_in, no_cool-prices[i])
- 不处于冷冻期+不持有股票或卖出了一支股票: no_cool = max(no_cool, cool)
- 处于冷冻期: cool = no_cool_in+prices[i]
- 列初始状态:
- no_cool_in = -prices[0]
- no_cool = 0
- cool = 0
经过上面的分析之后是不是发现本题和股票 III 既有相似之处也有很多不同之处呢?相似之处是都涉及多个状态,不同之处在于:股票 III 最终成交两笔交易也就是最终结果确定,所以它是有多个其他辅助状态来确定。而本题是最终形态不确定,因此最终结果是从多个状态里取最大值
class Solution:
# 时间复杂度 O(n)
# 空间复杂度 O(1)
def maxProfit(self, prices: List[int]) -> int:
# 分为处于冷冻期和不处于冷冻期两种选择
# 不处于冷冻期:买入了一只股票
no_cool_in = -prices[0]
# 不处于冷冻期:手里没有股票或者说卖出了一支
no_cool = 0
# 处于冷冻期
cool = 0
for i in range(1, len(prices)):
no_cool_in = max(no_cool_in, no_cool-prices[i])
no_cool = max(cool, no_cool)
cool = no_cool_in + prices[i]
return max(no_cool_in, no_cool, cool)
leetcode-1653-使字符串平衡🌟🌟🌟🌟🌟
初读这道题的时候可能会一下子想到这个不就是变形版的求最长的非连续子串吗,所以用这种方法去解题,实际上这种方式时间复杂度为 O(NlogN),使用动态规划或者前缀和来将时间复杂度降为 O(N)