动态规划是一种自底向上的算法,通常用于解决最大、最小等最值问题。
能使用动态规划解决的问题,一定具备:
- 重叠子问题:和暴力搜索不同,需要记录子问题的解,避免重复求解(剪枝)
- 最优子结构:子问题达到最值,整体才能达到最值,即以小见大
- 状态转移方程:在每个“状态”做出的“选择”会到达什么“状态”
然后就以合适的顺序填表,穷举所有情况并求最值即可
整体流程:明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义
文章目录
- 1.凑零钱
- 2.最长递增子序列*
- 3.最长回文子序列*
- 最长回文子串
- 最长公共子序列
- 最长公共子串
- 4.打家劫舍
- 5.打家劫舍II
- 6.打家劫舍III *
- 7.含冷冻期的股票买卖
- 股票买卖的最佳时机II *
- 股票买卖的最佳时机III
- 8.编辑距离
- 带权编辑距离
- 8.01背包问题
- 9.分割等和子集(背包问题模板)
- 10.凑硬币II(完全背包问题)
- 11.目标和(01背包)
- 12 n个骰子的点数
1.凑零钱
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
class Solution(object):def coinChange(self, coins, amount):""":type coins: List[int]:type amount: int:rtype: int"""# F[n]为凑n所需要的最少硬币数# F[n] = min(F[n-k]) + 1,其中k是每个面值dp = [10001 for _ in range(amount + 1)]dp[0] = 0for c in coins:if c <= amount:dp[c] = 1# 填表for i in range(1, amount + 1):for c in coins:if i - c >= 0:dp[i] = min(dp[i], dp[i - c] + 1)return dp[-1] if dp[-1] != 10001 else -1
2.最长递增子序列*
- 明确状态:dp[n] = 以下标n结束的(而非从下标0到下标n的,这种弱绑定),最长序列长度
- 不是所有的题目都采用“强绑定”,只能说随机应变
class Solution(object):def lengthOfLIS(self, nums):""":type nums: List[int]:rtype: int"""# F[n] = max(F[k] + 1) if nums[k] < nums[n]n = len(nums)dp = [1 for _ in range(n)]res = 1for i in range(n):for j in range(i):dp[i] = max(dp[j] + 1, dp[i]) if nums[i] > nums[j] else dp[i]res = max(res, dp[i])return res
3.最长回文子序列*
- dp[ i ][ j ] = 从下标 i 到下标 j 的最长回文子序列的长度(不包括头尾,即 s[ i ] != s[ j ] 时 dp[ i ][ j ] 可以不为 0)
- 每次填一个左斜的列
class Solution(object):def longestPalindromeSubseq(self, s):""":type s: str:rtype: int"""# dp[i][j] = 下标i到j中,最长回文子序列的长度length = len(s)dp = [[1 for _ in range(length)] for _ in range(length)]# 初始化两对角线for i in range(length - 1):if s[i] == s[i + 1]:dp[i][i + 1] = 2# 填表for k in range(2, length):i, j = 0, kwhile (i < length and j < length):if (s[i] == s[j]):dp[i][j] = dp[i + 1][j - 1] + 2else:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])i += 1j += 1return dp[0][length - 1]
最长回文子串
- 填表的时候,每个位置需要查看其左下方向的值,所以填表顺序可以是,先将对角线上的两列初始化,再按列填表
- dp[ i ][ j ] = s[ i : j + 1] 是否是回文子串
class Solution(object):def longestPalindrome(self, s):""":type s: str:rtype: str"""if s == "":return s# 动态规划length = len(s)table = [[False for i in range(length)] for j in range(length)] # n*n的矩阵:指示从i到j是否回文start, end = 0, 0# 初始化对角线上的两列for i in range(length):table[i][i] = Truefor i in range(length-1):if s[i] == s[i+1]:table[i][i+1] = Truestart, end = i, i + 1# 填表:按列的顺序for j in range(2, length):for i in range(0, j-1):flag = table[i+1][j-1]if flag and s[i] == s[j]:table[i][j] = Trueif j - i > end - start:start, end = i, jreturn s[start:end+1]
最长公共子序列
class Solution(object):def longestCommonSubsequence(self, text1, text2):""":type text1: str:type text2: str:rtype: int"""# dp[i][j] = t1的前i个字母,和t2的前j个字母的最长相同子序列长度l1, l2 = len(text1), len(text2)dp = [[0 for _ in range(l2 + 1)] for _ in range(l1 + 1)]# 填表,按行填for i in range(1, l1 + 1):for j in range(1, l2 + 1):if text1[i - 1] == text2[j - 1]:dp[i][j] = dp[i - 1][j - 1] + 1else:dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])return dp[-1][-1]
最长公共子串
class Solution:def LCS(self , str1 , str2 ):# write code herem, n = len(str1), len(str2)if m == 1:return str1 elif n == 1:return str2 dp = [[0 for _ in range(n)] for _ in range(m)]res, end1idx = 0, 0 # 初始化for i in range(m):if str1[i] == str2[0]:dp[i][0] = 1res = 1end1idx = ifor j in range(n):if str2[j] == str1[0]:dp[0][j] = 1# 填表for i in range(1, m):for j in range(1, n):if str1[i] == str2[j]:dp[i][j] = dp[i - 1][j - 1] + 1end1idx = i if res < dp[i][j] else end1idxres = max(res, dp[i][j])return str1[end1idx - res + 1: end1idx + 1]
4.打家劫舍
一般的动态规划(推荐)
class Solution(object):def rob(self, nums):""":type nums: List[int]:rtype: int"""# 写个动态规划# 状态:偷到i号房子时的最大值# 选择:是否偷当前的房子# dp[i]=max(dp[i - 2] + nums[i], dp[i - 1])length = len(nums)if length == 1:return nums[0]dp = [0 for _ in range(length)]dp[0] = nums[0]dp[1] = nums[0] if nums[0] > nums[1] else nums[1]for i in range(2, length):steal = dp[i - 2] + nums[i]not_steal = dp[i - 1]dp[i] = steal if steal > not_steal else not_stealreturn dp[length - 1]
还可以使用自顶向下递归+备忘录。这种方法引入了递归,比较适合解决树结构的问题——打家劫舍III
class Solution(object):def rob(self, nums):""":type nums: List[int]:rtype: int"""memo = [-1 for _ in range(len(nums))]return self.search(0, nums, memo) # 或memo[0]def search(self, idx, nums, memo):length = len(nums)# 递归出口if idx > length - 1:return 0# 查表避免计算elif memo[idx] != -1:return memo[idx]# 后根,先递归后返回,以获取当前序列之后的信息else:steal = nums[idx] + self.search(idx + 2, nums, memo)not_steal = self.search(idx + 1, nums, memo)memo[idx] = max(steal, not_steal)return memo[idx]
5.打家劫舍II
将输入变成环形,和 I 相比讨论两种情况,取最大值。
class Solution(object):def rob(self, nums):""":type nums: List[int]:rtype: int"""length = len(nums)if length == 1:return nums[0]elif length <= 3:return max(nums)# 分两种情况:[1:]和[:-1]nums1 = nums[1: ]nums2 = nums[: -1]dp1 = [0 for _ in range(length - 1)]dp1[0] = nums1[0]dp1[1] = max(nums1[0], nums1[1])for i in range(2, length - 1):dp1[i] = max(dp1[i - 1], dp1[i - 2] + nums1[i])dp2 = [0 for _ in range(length - 1)]dp2[0] = nums2[0]dp2[1] = max(nums2[0], nums2[1])for i in range(2, length - 1):dp2[i] = max(dp2[i - 1], dp2[i - 2] + nums2[i])return max(dp1[-1], dp2[-1])
6.打家劫舍III *
- 一道典中典的题:动态规划+树结构 = memo+递归
- 第二次做的时候思维卡在了,想先获取某节点的父节点,和父节点的父节点(类似于普通dp的想法)来确定当前点的值,使用递归的好处是可以“知道”后续的信息,在当前加以判断
对于树结构的问题,明确每个节点该干什么
class Solution(object):def rob(self, root):""":type root: TreeNode:rtype: int"""# dp+树=memo+递归memo = dict()def search(node):# 出口if node is None:return 0# 查表if memo.has_key(node):return memo[node]# 抢l, r = 0, 0if node.left is not None:l = search(node.left.left) + search(node.left.right)if node.right is not None:r = search(node.right.left) + search(node.right.right)yes = node.val + l + r# 不抢no = search(node.left) + search(node.right)# 取最大值写入memomemo[node] = max(yes, no)return memo[node]return search(root)
7.含冷冻期的股票买卖
给定一个整数数组,其中第 i 个元素代表了第 i 天的股票价格 。
设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):
- 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
- 卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
class Solution(object):def maxProfit(self, prices):""":type prices: List[int]:rtype: int"""# 状态:已买/未买/冷冻期 (状态不要写成:买入/卖出/冷冻,分清楚状态和动作)# 选择:买入/卖出/不变# DFAn = len(prices)have = [0 for _ in range(n)]not_have = [0 for _ in range(n)]freeze = [0 for _ in range(n)]have[0] = -prices[0]for i in range(1, n):# 第i天*持有*股票的最大利润:昨天持有,今天持有/昨天没有,今天买入have[i] = max(have[i - 1], not_have[i - 1] - prices[i])# 第i天*不持有*股票的最大利润:昨天不持有,今天也不持有/昨天卖出,今天冷冻not_have[i] = max(not_have[i - 1], freeze[i - 1])# 第i天***进入冷冻(卖出)***的最大利润freeze[i] = have[i - 1] + prices[i]# 最后一天处于不持有/刚卖出时的利润最大return max(not_have[-1], freeze[-1])
股票买卖的最佳时机II *
- 可以多次交易,但每次只能持有一个股票
- 状态DP,加入当前是否已持有股票的状态
class Solution(object):def maxProfit(self, prices):""":type prices: List[int]:rtype: int"""# 状态DP# dp[n][0] 代表截至第n天,不持有股票的最大利润,dp[n][1]代表持有股票的最大利润length = len(prices)dp = [[0, 0] for _ in range(length)]dp[0][1] = -prices[0]for i in range(1, length):dp[i][0] = max(dp[i - 1][0], prices[i] + dp[i - 1][1]) # 昨天没买今天不买/昨天买了今天卖dp[i][1] = max(dp[i - 1][0] - prices[i], dp[i - 1][1]) # 昨天没买今天买/昨天买了今天不卖return dp[-1][0]
股票买卖的最佳时机III
- 和上面类似,但限制最多只能交易两次
- 需要额外加入两个状态
class Solution(object):def maxProfit(self, prices):""":type prices: List[int]:rtype: int"""# 状态dp plus# dp[n]:第一位代表截至第n天,第二位代表是否持有股票,第三位代表完成的购买次数length = len(prices)dp = [[[0, 0, 0] for _ in range(2)] for _ in range(length)]# 初始化dp[0][1][1] = -prices[0]# 将不合理位置设置为负值!!!dp[0][1][0] = -1000000dp[0][1][2] = -1000000dp[0][0][1] = -1000000dp[0][0][2] = -1000000# 填表for i in range(1, length):dp[i][1][1] = max(dp[i - 1][1][1], dp[i - 1][0][0] - prices[i]) # 第i天有股票买过一次:可能是之前买的,也可能是今天买,取利润最大dp[i][1][2] = max(dp[i - 1][1][2], dp[i - 1][0][1] - prices[i]) # 第i天有股票买过两次:可能是之前买的,也可能是今天买,取利润最大dp[i][0][1] = max(dp[i - 1][0][1], dp[i - 1][1][1] + prices[i]) # 第i天没股票买过一次:可能是之前卖的,也可能是今天卖的dp[i][0][2] = max(dp[i - 1][0][2], dp[i - 1][1][2] + prices[i]) # 第i天没股票买过两次:可能是之前卖的,也可能是今天卖的return max(dp[-1][0])
8.编辑距离
问题复杂,题解简介。关键是找到DP数组的定义。
选择思路——引用leetcode下面的高赞评论:
-
问题1:如果 word1[0…i-1] 到 word2[0…j-1] 的变换需要消耗 k 步,那 word1[0…i] 到 word2[0…j] 的变换需要几步呢?
-
答:先使用 k 步,把 word1[0…i-1] 变换到 word2[0…j-1],消耗 k 步。再把 word1[i] 改成 word2[j],就行了。如果 word1[i] == word2[j],什么也不用做,一共消耗 k 步,否则需要修改,一共消耗 k + 1 步。
-
问题2:如果 word1[0…i-1] 到 word2[0…j] 的变换需要消耗 k 步,那 word1[0…i] 到 word2[0…j] 的变换需要消耗几步呢?
-
答:先经过 k 步,把 word1[0…i-1] 变换到 word2[0…j],消耗掉 k 步,再把 word1[i] 删除,这样,word1[0…i] 就完全变成了 word2[0…j] 了。一共 k + 1 步。
-
问题3:如果 word1[0…i] 到 word2[0…j-1] 的变换需要消耗 k 步,那 word1[0…i] 到 word2[0…j] 的变换需要消耗几步呢?
-
答:先经过 k 步,把 word1[0…i] 变换成 word2[0…j-1],消耗掉 k 步,接下来,再插入一个字符 word2[j], word1[0…i] 就完全变成了 word2[0…j] 了。
class Solution(object):def minDistance(self, word1, word2):""":type word1: str:type word2: str:rtype: int"""m, n = len(word1), len(word2)dp = [[1000 for _ in range(n + 1)] for _ in range(m + 1)] # dp[i][j]:word1前i个(坐标i - 1)和word2前j个匹配的最小操作数# 初始化for i in range(m + 1):dp[i][0] = i for j in range(n + 1):dp[0][j] = j# 填表for i in range(1, m + 1):for j in range(1, n + 1):# 无需进行任何操作if word1[i - 1] == word2[j - 1]:dp[i][j] = dp[i - 1][j - 1]else:dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])return dp[m][n]
带权编辑距离
给定两个字符串str1和str2,再给定三个整数ic,dc和rc,分别代表插入、删除和替换一个字符的代价,请输出将str1编辑成str2的最小代价
class Solution:def minEditCost(self , str1 , str2 , ic , dc , rc ):# write code here# dp[i][j] 代表从s1[0...i] 修改为s2[0...j]的代价# dp[i][j] = dp[i-1][j-1] if equals else ...m, n = len(str1), len(str2)dp = [[0 for _ in range(n + 1)] for _ in range(m + 1)]for i in range(m + 1):dp[i][0] = i * dc for j in range(n + 1):dp[0][j] = j * ic for i in range(1, m + 1):for j in range(1, n + 1):if str1[i - 1] == str2[j - 1]:dp[i][j] = dp[i - 1][j - 1] else:dp[i][j] = min(dp[i - 1][j] + dc, dp[i][j - 1] + ic, dp[i - 1][j - 1] + rc)return dp[-1][-1]
8.01背包问题
- 算法设计课上的例题。物品只能选择装入/不装入,所以是01背包。
- 01背包的问题形式:凑够目标和target ——(能否)凑够target,凑target有几种方式
# dp[i][j] 代表对于物品i,背包容量为j时能承载的最大价值
for i in range(num_items):for j in range(capacity):dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + val[i]) # 选择装物品i或者不装物品i(伪码,未做防止越界的处理
return dp[-1][-1]
9.分割等和子集(背包问题模板)
抽象成可装载重量为 sum / 2 的背包,每个物品的重量为 nums[i],在 sum/2 的前提下,尽量往里装最多的数字,如果恰好能为sum / 2则满足题意
class Solution(object):def canPartition(self, nums):""":type nums: List[int]:rtype: bool"""# 特判奇偶,奇数不能划分summary = sum(nums)if summary % 2 == 1:return Falsetarget = summary // 2n = len(nums)dp = [[0 for _ in range(target + 1)] for _ in range(n)]# 初始化首行if nums[0] == target:return Truefor i in range(target + 1):dp[0][i] = nums[0] if i >= nums[0] else 0# 填表:经典的背包模板for i in range(1, n):for j in range(target + 1):# 不能装下物品iif j < nums[i]:dp[i][j] = dp[i - 1][j]# 可以装下物品ielse:dp[i][j] = max(dp[i - 1][j], (dp[i - 1][j - nums[i]] if nums[i] <= j else 0) + nums[i])return dp[-1][-1] == target
可以进一步优化空间:因为每行在填写时只使用上一行dp,所以dp只保留一行即可
10.凑硬币II(完全背包问题)
- 处理背包问题一定要注意dp数组的定义,不要少定义下标
- 完全背包问题的每种物品数量无限
- 这道题同样可以对dp数组进行空间优化
class Solution(object):def change(self, amount, coins):""":type amount: int:type coins: List[int]:rtype: int"""# 凑和,背包问题;每种物品无限,完全背包问题# dp[i][j]:前i个硬币凑和jlength = len(coins)dp = [[0 for _ in range(amount + 1)] for _ in range(length)]# 初始化:凑0元有1种方式for i in range(length):dp[i][0] = 1# 初始化:只用首个硬币for j in range(1, amount + 1):if j % coins[0] == 0:dp[0][j] = 1# 填表for i in range(1, length):for j in range(amount + 1):dp[i][j] = dp[i - 1][j] # 不用第i个硬币dp[i][j] += dp[i][j - coins[i]] if (j - coins[i] >= 0) else 0 # 用第i个硬币return dp[-1][-1]
11.目标和(01背包)
给你一个整数数组 nums 和一个整数 target 。 向数组中的每个整数前添加 ‘+’ 或 ‘-’ ,然后串联起所有整数,可以构造一个
表达式 : 例如,nums = [2, 1] ,可以在 2 之前添加 ‘+’ ,在 1 之前添加 ‘-’ ,然后串联起来得到表达式
“+2-1” 。 返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
class Solution(object):def findTargetSumWays(self, nums, target):""":type nums: List[int]:type target: int:rtype: int"""# dp[i][j]:前i个数字加减 得到总和j 的方法数目# dp[i][j] = dp[i - 1][j - nums[i]] + dp[i - 1][j + nums[i]]"""!!!!数组的列范围 要开到[-sum(nums), sum(nums)]而非[-target, target]!!!!"""# 特判summary = sum(nums)if summary < target:return 0n = len(nums)dp = [[0 for _ in range(2 * summary + 1)] for _ in range(n)] # 包含负数,下标target处为0# 初始化首行for j in range(2 * summary + 1):if nums[0] == 0:dp[0][summary] = 2breakelse:real_val = j - summaryif nums[0] == real_val or -nums[0] == real_val:dp[0][j] = 1# 填表for i in range(1, n):for j in range(2 * summary + 1):minus = j - nums[i]plus = j + nums[i]dp[i][j] += dp[i - 1][minus] if minus >= 0 else dp[i - 1][0]dp[i][j] += dp[i - 1][plus] if plus <= 2 * summary else dp[i - 1][0]return dp[-1][summary + target]
12 n个骰子的点数
- 把n个骰子扔在地上,所有骰子朝上一面的点数之和为s。输入n,打印出s的所有可能的值出现的概率
class Solution(object):def dicesProbability(self, n):""":type n: int:rtype: List[float]"""# dp[i][j] = 前i个骰子点数和为j的概率# dp[i][j] = sigma(dp[i - 1][j - k]), k = 1...6dp = [[0 for _ in range(6 * n + 1)] for _ in range(n)]# 初始化for i in range(1, 7):dp[0][i] = 0.16667# 填表for i in range(1, n):for j in range(i + 1, 6 * i + 7):for k in range(1, 7):dp[i][j] += dp[i - 1][j - k] / 6 if j - k > 0 else 0return dp[-1][n:]