本篇文章根据labuladong的算法小抄汇总动态规划(子序列问题和背包问题)的常见算法,采用python3实现
文章目录
一、动态规划基本技巧
-
一般形式:求最值,如最长递增子序列,最小编辑距离等
-
核心问题:穷举
-
动态规划三要素:
-
重叠子问题:备忘录或DP数组优化穷举过程,避免不必要的计算
-
最优子结构:可以从子问题的最优结果推出更大规模问题的最优结果
-
⭐状态转移方程:明确base case → 明确[状态] → 明确[选择] → 定义dp数组/函数的定义
如,
#初始化base case dp[0][0][...] = base #进行状态转移 for 状态1 in 状态1的所有取值: for 状态2 in 状态2的所有取值: for ... dp[状态1][状态2][...] = 求最值(选择1,选择2,...)
-
-
dp数组的遍历方向
#正向遍历 for i in range(m): for j in range(n): #计算dp[i][j] #反向遍历 for i in range(m-1,-1,-1): for j in range(n-1,-1,-1): #计算dp[i][j] #斜向遍历 for l in range(2,n+1): for i in range(0,n): j = l + i - 1 #计算dp[i][j]
- 遍历过程中,所需的状态必须是已经计算出来的
- 遍历的终点必须是存储结果的那个位置
-
状态压缩技巧:
- 针对:二维dp问题
- 特点:状态转移方程,如果计算状态 d p [ i ] [ j ] dp[i][j] dp[i][j]需要的都是 d p [ i ] [ j ] dp[i][j] dp[i][j]相邻的状态,就可以使用状态压缩技巧,将二维的dp数组转化成一维,将空间复杂度从O(N^2)降到O(N)
斐波那契数列
1、带备忘录的递归解法
def fib(N):
memo = [0 for i in range(N+1)]
return helper(memo,N)
def helper(memo,n):
if (n == 0) or (n == 1):
return n
if memo[n] != 0:
return memo[n]
memo[n] = helper(memo,n-1) + help(memo,n-2)
return memo[n]
2、dp数组的迭代解法
def fib(n):
if (n == 0) or (n == 1):
return n
dp = [0 for i in range(n+1)]
dp[0] = 0
dp[1] = 1
for i in range(2,n+1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
3、状态压缩的dp数组
def fib(n):
if (n == 0) or (n == 1):
return n
prev = 0
curr = 1
for i in range(2,n+1):
sum = prev + curr
prev = curr
curr = sum
return curr
零钱兑换
1、带备忘录的递归解法
#O(kn)
def coinChange(coins,amount):
def dp(coins,amount):
if amount < 0:
return -1
if amount == 0:
return 0
if memo[amount] != 0:
return memo[amount]
res = float("inf")
for coin in coins:
subProblem = dp(coins,amount-coin)
if subProbkem == -1:
continue
res = min(res,subProblem+1)
memo[amount] = res if res != float("inf") else -1
return memo[amount]
memo = [0 for i in range(amount+1)]
return dp(coins,amount)
2、dp数组的迭代解法
def coinChange(coins,amount):
dp = [amount + 1 for i in range(amount+1)]
dp[0] = 0
for i in range(amount+1):
for coin in coins:
if i - coin < 0:
continue
dp[i] = min(dp[i],1+dp[i-coin])
return dp[amount] if dp[amount] != (amount+1) else -1
下降路径最小和
def minFallingPathSum(matrix):
def dp(matrix,i,j):
if (i < 0) or (j < 0) or (i >= len(matrix)) or (j >= len(matrix[0])):
return float("inf")
if i == 0:
return matrix[0][j]
if memo[i][j] != float("inf"):
return memo[i][j]
memo[i][j] = matrix[i][j] + min(dp(matrix,i-1,j-1),dp(matrix,i-1,j),dp(matrix,i-1,j+1))
return memo[i][j]
n = len(matrix)
res = float("inf")
memo = [[float("inf") for i in range(n)] for j in range(n)]
for j in range(n):
res = min(res,dp(matrix,n-1,j))
return res
二、子序列类型问题
编辑距离
def minDistance(word1,word2):
def dp(i,j):
if i == -1:
return j + 1
if j == -1:
return i + 1
if s1[i] == s2[j]:
return dp(i-1,j-1)
else:
return min(dp(i,j-1)+1,dp(i-1,j)+1,dp(i-1,j-1)+1)
return dp(len(word1)-1,len(word2)-1)
加备忘录:
def minDistance(word1,word2):
def dp(i,j):
if i < 0:
return j + 1
if j < 0:
return i + 1
if (i,j) in memo:
return memo[(i,j)]
if word1[i] == word2[j]:
memo[(i,j)] = dp(i-1,j-1)
else:
memo[(i,j)] = min(dp(i,j-1)+1,dp(i-1,j)+1,dp(i-1,j-1)+1)
return memo[(i,j)]
memo = dict()
return dp(len(word1)-1,len(word2)-1)
dp数组
#dp数组定义:dp[i][j]表示从word1[0...i]到word2[0..j]需要变动的最小次数
def minDistance(word1,word2):
m = len(word1)
n = len(word2)
dp = [[0 for i in range(n+1)] for j in range(m+1)]
for i in range(1,m+1):
dp[i][0] = i
for j in range(1,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] = min(dp[i-1][j-1]+1,dp[i][j-1]+1,dp[i-1][j]+1)
return dp[m][n]
最长递增子序列
- 子串一定连续,子序列不一定连续
#O(N^2)
#dp数组定义:dp[i]表示以nums[i]结尾的最长递增子序列
def lengthOfLIS(nums):
n = len(nums)
dp = [1 for i in range(n)]
for i in range(n):
for j in range(i):
if nums[i] > nums[j]:
dp[i] = max(dp[i],dp[j]+1)
return max(dp)
俄罗斯套娃信封问题
-
先对宽度w进行升序排序,若w相同,则对高度h降序排序。之后把所有h作为一个数组,在这个h数组上计算LIS的长度
#envelopes = [[w,h],[w,h],...] def maxEnvelopes(envelopes): def lengthOfLIS(nums): n = len(nums) dp = [1 for i in range(n)] for i in range(n): for j in range(i): if nums[i] > nums[j]: dp[i] = max(dp[i],dp[j]+1) return max(dp) n = len(envelopes) #排序 envelopes.sort(key = lambda a: (a[0],-a[1])) #height数组 height = [0 for i in range(n)] for i in range(n): height[i] = envelopes[i][1] return lengthOfLIS(height)
最大子序和
#dp数组定义:dp[i]表示以nums[i]结尾的最大子数组和
#时间复杂度O(N),空间复杂度O(N)
def maxSubArray(nums):
n =len(nums)
if n == 0:
return 0
dp = [0 for i in range(n)]
dp[0] = nums[0]
for i in range(1,n):
dp[i] = max(nums[i],nums[i]+dp[i-1])
return max(dp)
状态压缩版本:
def maxSubArrat(nums):
n = len(nums)
if n == 0:
return 0
dp_0 = nums[0]
dp_1 = 0
res = dp_0
for i in range(1,n):
dp_1 = max(nums[i],nums[i]+dp_0)
dp_0 = dp_1
res = max(res,dp_1)
return res
最长公共子序列(LCS)
- 对于两个字符串求子序列的问题,都是用两个指针i和j分别在两个字符串上移动,大概率是动态规划思路
备忘录:
def longestCommonSubsequence(text1,text2):
#计算s1[i...]和s2[j...]的最长公共子序列长度
def dp(s1,i,s2,j):
if (i == len(s1)) or (j == len(s2)):
return 0
if memo[i][j] != -1:
return memo[i][j]
if s1[i] == s2[j]:
memo[i][j] = 1 + dp(s1,i+1,s2,j+1)
else:
memo[i][j] = max(
#s1[i]不在lcs中
dp(s1,i+1,s2,j),
#s2[j]不在lcs中
dp(s1,i,s2,j+1))
return memo[i][j]
m = len(text1)
n = len(text2)
memo = [[-1 for i in range(n)] for j in range(m)]
return dp(text1,0,text2,0)
dp数组:
def longestCommonSubsequence(text1,text2):
m = len(text1)
n = len(text2)
dp = [[0 for i in range(n+1)] for j in range(m+1)]
for i in range(1,m+1):
for j in range(1,n+1):
if text1[i-1] == text2[j-1]:
dp[i][j] = 1 + dp[i-1][j-1]
else:
dp[i][j] = max(dp[i][j-1],dp[i-1][j])
return dp[m][n]
两个字符串的删除操作
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
def longestCommonSubsequence(s1,s2):
m = len(s1)
n = len(s2)
dp = [[0 for i in range(n+1)] for j in range(m+1)]
for i in range(1,m+1):
for j in range(1,n+1):
if s1[i-1] == s2[j-1]:
dp[i][j] = 1 + dp[i-1][j-1]
else:
dp[i][j] = max(dp[i-1][j],dp[i][j-1])
return dp[m][n]
m = len(word1)
n = len(word2)
r = longestCommonSubsequence(word1,word2)
return m - r + n - r
两个字符串的最小ASCII删除和
def minimumDeleteSum(s1,s2):
#dp:将s1[i..]和s2[j..]删除成相同字符串
def dp(s1,i,s2,j):
res = 0
m = len(s1)
n = len(s2)
if i == m:
for x in range(j,n):
res += ord(s2[x])
return res
if j == n:
for x in range(i,m):
res += ord(s1[x])
return res
if memo[i][j] != -1:
return memo[i][j]
if s1[i] == s2[j]:
memo[i][j] = dp(s1,i+1,s2,j+1)
else:
memo[i][j] = min(
ord(s1[i]) + dp(s1,i+1,s2,j)
ord(s2[j]) + dp(s1,i,s2,j+1)
)
return memo[i][j]
m = len(s1)
n = len(s2)
memo = [[-1 for i in range(n)] for j in range(m)]
return dp(s1,0,s2,0)
子序列解题模板
思路一:一维dp数组
如最长递增子序列
n = len(array)
dp = [0 for i in range(n)]
for i in range(1,n):
for j in range(i):
dp[i] = 最值(dp[i],dp[j]+...)
思路二:二维dp数组
n = len(arr)
dp = [[0 for i in range(n)] for j in range(n)]
for i in range(n):
for j in range(1,n):
if arr[i] == arr[j]:
dp[i][j] = dp[i][j] + ...
else:
dp[i][j] = 最值(...)
-
涉及两个字符串/数组时(如最长公共子序列),dp数组含义:
在子数组arr1[0…i]和子数组arr2[0…j]中,我们要求的子序列长度为 d p [ i ] [ j ] dp[i][j] dp[i][j]
-
只涉及一个字符串/数组时(如最长回文子序列),dp数组含义:
在子数组array[i…j]中,我们要求的子序列的长度为 d p [ i ] [ j ] dp[i][j] dp[i][j]
最长回文子序列
def longestPalindromeSubseq(s):
n = len(s)
dp = [[0 for i in range(n)] for j in range(n)]
for i in range(n):
dp[i][i] = 1
for i in range(n-1,-1,-1):
for j in range(i+1,n):
if s[i] == s[j]:
dp[i][j] = dp[i+1][j-1] + 2
else:
dp[i][j] = max(dp[i+1][j],dp[i][j-1])
return dp[0][n-1]
状态压缩:
def longestPalindromeSubseq(s):
n = len(s)
dp = [1 for i in range(n)]
for i in range(n-2,-1,-1):
pre = 0
for j in range(i+1,n):
temp = dp[j]
if s[i] == s[j]:
dp[j] = pre + 2
else:
dp[j] = max(dp[j],dp[j-1])
pre = temp
return dp[n-1]
三、背包类型问题
0-1背包问题
问题:给你一个可装载重量为W的背包和N个物品,每个物品有重量和价值两个属性。其中第i个物品的重量为wt[i],价值为val[i],现在让你用这个背包装物品,最多能装的价值是多少?
背包问题的状态:背包的容量&可选择的物品 → 二维dp数组
背包问题的选择:装进背包or不装进背包
步骤:
1、明确状态和选择
2、明确dp数组的定义,包括base case和最终想得到的答案
3、根据选择,思考状态转移的逻辑
#dp[i][w]:对于前i个物品,当前背包容量为w,可以装的最大价值
def knapsack(W,N,wt,val):
dp = [[0 for i in range(W+1)] for j in range(N+1)]
for i in range(1,N+1):
for w in range(1,W+1):
if w - wt[i-1] < 0:
dp[i][w] = dp[i-1][w]
else:
dp[i][w] = max(dp[i-1][w],dp[i-1][w-wt[i-1]]+val[i-1])
return dp[N][W]
子集背包问题:分割等和子集
#dp[i][j]=x:对于前i个物品,当前背包容量为j,若x为True表示可以恰好将背包装满,否则不能
def canPartition(nums):
sumNum = sum(nums)
if sum % 2 != 0:
return False
n = len(num)
sumNum = n // 2
dp = [[False for i in range(sumNum+1)] for j in range(n+1)]
for i in range(n+1):
dp[i][0] = True
for i in range(1,n+1):
for j in range(1,sumNum+1):
if j < nums[i-1]:
dp[i][j] = dp[i-1][j]
else:
dp[i][j] = dp[i-1][j] or dp[i-1][j-nums[i-1]]
return dp[n][sumNum]
状态压缩:
def canPartition(nums):
sumNum = sum(nums)
if sumNum % 2 != 0:
return False
n = len(nums)
sumNum = sumNum // 2
dp = [False for i in range(sumNum+1)]
dp[0] = True
for i in range(n):
for j in range(sumNum,-1,-1):
if j - nums[i] >= 0:
dp[j] = dp[j] or dp[j-nums[i]]
return dp[sumNum]
完全背包问题:零钱兑换
完全背包问题:每个物品的数量是无限的
#dp[i][j]:如果只使用前i个物品(可以重复使用),当背包容量为j时,有dp[i][j]种方法可以装满背包
def change(amount,coins):
n = len(coins)
dp = [[0 for i in range(amount+1)]for j in range(n+1)]
for i in range(n+1):
dp[i][0] = 1
for i in range(1,n+1):
for j in range(1,amount+1):
if j < coins[i-1]:
dp[i][j] = dp[i-1][j]
else:
dp[i][j] = dp[i-1][j] + dp[i][j-coins[i-1]]
return dp[n][amount]
目标和
1、回溯
def findTargetSumWays(nums,target):
def backtrack(nums,i,rest):
if len(nums) == i:
if rest == 0:
self.res += 1
return
rest -= nums[i]
backtrack(nums,i+1,rest)
rest += nums[i]
rest += nums[i]
backtrack(nums,i+1,rest)
rest -= nums[i]
self.res = 0
backtrack(nums,0,target)
return self.res
2、备忘录消除重叠子问题
def findTargetSumWays(nums,target):
def dp(nums,i,rest):
if len(nums) == i:
if rest == 0:
return 1
return 0
key = str(i) + "," + str(rest)
if key in memo:
return memo[key]
result = dp(nums,i+1,rest-nums[i])+dp(nums,i+1,rest+nums[i])
memo[key] = result
return result
if len(nums) == 0:
return 0
memo = dict()
return dp(nums,0,target)
3、动态规划
转化为子集划分问题,转化为背包问题
如果把nums分成两个子集A和B,分别代表分配+的数和分配-的数,那么:
sum(A) - sum(B) = target
sum(A) = target + sum(B)
2 * sum(A) = target + sum(nums)
于是原问题转化为:nums中存在几个子集A,使其元素和为(target+sum(nums))/2
#dp[i][j]=x:只在前i个物品中选择,若当前背包的容量为j,则最多有x种方法可以恰好装满背包
def findTargetSumWays(nums,target):
def subsets(nums,target):
n = len(nums)
dp = [[0 for i in range(target+1)] for j in range(n+1)]
for i in range(n+1):
dp[i][0] = 1
for i in range(1,n+1):
for j in range(target+1): #注意从0开始,否则target为1时,range(1,1)为空
if j < nums[i-1]:
dp[i][j] = dp[i-1][j]
else:
dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i-1]]
return dp[n][target]
sumNum = sum(nums)
if (sumNum < abs(target)) or ((sumNum+target) % 2 != 0): #注意abs,否则会造成dp数组为空
return 0
return subsets(nums,(sumNum+target)//2)