一拳干掉动态规划
reference:https://github.com/labuladong/fucking-algorithm
Intro
动态规划三要素:重叠子问题、最优子结构、状态转移方程
写出状态转移方程:明确状态 -> 定义dp数组/函数的含义 -> 明确「选择」-> 明确 base case
动态规划:一般求最优解/最值
- 计数
有多少种方式走到右下角,有多少种方法选出k个数 和为sum - 求最大值/最小值
路径最大数字和,最长上升子序列 - 求存在性
能不能选出k个数和为sum,先手是否必胜
斐波那契数列
- 纯粹递归
def fibo(N: int):
if N ==1 or N == 2:
return 1
return fibo(N-1) + fibo(N-2)
时间复杂度O(2 ^ n)
过多的重复子问题。
- 带备忘录的递归
造一个「备忘录」,每次算出某个子问题的答案,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。
def fibo(N):
if N < 1: return 0
memo = [0] * (N+1)
def helper(memo, n):
# base case
if n == 1 or n == 2:
return 1
# remove duplicate
if memo[n] != 0:
return memo[n]
memo[n] = helper(memo, n-1) + helper(memo, n-1)
return memo[n]
return helper(memo, N)
子问题个数,即图中节点的总数,由于本算法不存在冗余计算,子问题就是 f(1), f(2), f(3) … f(20),数量和输入规模 n = 20 成正比,所以子问题个数为 O(n)。
时间复杂度O(n)
啥叫「自顶向下」
?注意我们刚才画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 触底,然后逐层返回答案,这就叫「自顶向下」。
啥叫「自底向上」
?反过来,我们直接从最底下,最简单,问题规模最小的 f(1) 和 f(2) 开始往上推,直到推到我们想要的答案 f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。
- dp数组迭代
将备忘录独立成一个表
def fibo(N):
dp = [0] * (N+1)
dp[1] = 1
dp[2] = 1
for i in range(3, N+1):
dp[i] = dp[i-1] + dp[i-2]
return dp[N]
凑零钱问题
先看下题目:给你 k 种面值的硬币,面值分别为 c1, c2 … ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。算法的函数签名如下:
// coins 中是可选硬币面值,amount 是目标金额
int coinChange(int[] coins, int amount);
寻找状态转移方程:
-
确定
状态
:原问题和子问题中变化的变量。由于硬币数量无限,所以唯一的状态就是目标金额 amount。 -
确定
dp的定义
:当前目标金额是n,至少需要dp[n]个硬币凑出金额。 -
确定
选择
:对于每个状态,可以做出什么选择改变当前状态。从面额列表coins中选择一个硬币,然后目标金额随之减少 。 -
确定
base case
:目标金额为0时,所需硬币为0;金额小于0,无解,返回-1. -
记忆化递归
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
memo = dict()
def dp(n):
# 查备忘录,避免重复
if n in memo:
return memo[n]
if n == 0: return 0
if n < 0: return -1
# 求最小值,所以初始化为正无穷
res = float('INF')
for coin in coins:
subproblem = dp(n - coin)
# 子问题无解,跳过
if subproblem == -1: continue
res = min(res, 1 + subproblem)
return res if res != float('INF') else -1
return dp(amount)
- dp数组迭代
dp[i] = x 表示,当目标金额为 i 时,至少需要 x 枚硬币。
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
dp = [float('INF')] * (amount + 1)
dp [0] = 0
# 求所有子问题:amount = i
# 凑 i 需要多少个coin
for i in range(len(dp)):
for coin in coins:
if i - coin < 0:
continue
dp[i] = min(dp[i], dp[i - coin] + 1)
if dp[amount] != float("INF"):
return dp[amount]
else:
return -1
322. Coin change
https://leetcode.com/problems/coin-change/
四步骤
- 确定状态
使用的数组 dp[ i ] or dp[ i ][ j ] 代表什么- 最后一步:
k枚硬币a1, a2, …, ak面值加起来等于27,一定有最后一枚硬币ak
最后一步之前,仍然是最优 - 子问题:
现在要求的问题是:最少用多少硬币拼出27-ak
就可以得出状态 => dp[x] 最少用多少枚硬币可以拼出 x
- 最后一步:
- 转移方程:
- 初始条件和边界情况
x-2, x-5, x-7 < 0? 什么时候停下来?- 如果不能拼出Y, dp[ Y ] = 正无穷:dp[ -1 ] = dp[ -2 ] = … = 正无穷
- 初始条件:dp[ 0 ] = 0 转移方程算不出来的
- 确定计算顺序
自底向上
没有任何重复,时间复杂度 27 * 3 (n 目标钱数 * m 钱币种类)
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
dp = [float('INF')] * (amount+1)
dp[0] = 0
for i in range(1, amount +1):
for coin in coins:
if i >= coin: # 要拼的面值要大于当前的coin面值才有可能
# 从不同的i - coin到现在的i的所有可能,取最小值
dp[i] = min(dp[i-coin]+1, dp[i])
if dp[amount ] == float('INF'):
return -1
return dp[amount]
dp[ i ]: amount ==1,用了dp[ i ]个coins
62. Unique path
https://leetcode.com/problems/unique-paths/
m x n的格子,左上走到右下,总过有多少种不同路径
- 确定状态
- 最后一步: 右下角坐标(m-1, n-1),那么前一步(m-2, n-1)(m-1. n-2)
- 子问题:机器人有x种方式走到(m-2, n-1),有y种方式走到(m-1, n-2),那么有x+y中方式走到(m-1, n-1)=> 子问题:有多少种方式走到(m-2, n-1)和(m-1, n-2)
dp[ i ][ j ]: 机器人有多少种方式走到(i, j)
- 转移方程
- 初始条件和边界条件
时间复杂度 O( MN )
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
dp = [[1 for i in range(n)] for i in range(m)]
for r in range(1, m):
for c in range(1, n):
dp[r][c] = dp[r - 1][c] + dp[r][c - 1]
return dp[-1][-1]
55. Jump Game
https://leetcode.com/problems/jump-game/
输入:一个数组,数组的值代表在当前石头可以跳跃的最大距离。初始在数组第一个位置
输出:是否可以跳到最后一个位置
Input: nums = [2,3,1,1,4]
Output: true
Explanation: Jump 1 step from index 0 to 1, then 3 steps to the last index.
最后一步:如果能跳到最后一个石头n-1,那么考虑他的最后一步
最后一步从石头 i 来:i < n - 1
需要满足两个条件:
- 可以跳到石头i
- 最后一步不超过该石头最大跳跃距离:n-1-i < nums[ i ]
子问题:能不能跳到 i
状态: dp[ j ]:能不能跳到石头 j
转移方程:
class Solution:
def canJump(self, nums: List[int]) -> bool:
n = len(nums)
dp = [False for i in range(n)]
dp[0] = True
for j in range(1, n):
for i in range(j):
if (dp[i] and j - i <= nums[i]):
dp[j] = True
return dp[n-1]
超时
Greedy
O(n)
https://leetcode.com/problems/jump-game/discuss/452807/Python-DP-O(n)-with-explanation
dp[ i ]: 从 i 石头可以跳出去的最远index
class Solution:
def canJump(self, nums: List[int]) -> bool:
length = len(nums)
dp = [0] * length
dp[0] = nums[0]
for i in range(1, length - 1):
if dp[i - 1] < i:
return False
dp[i] = max(i + nums[i], dp[i - 1])
if dp[i] >= length - 1:
return True
return dp[length - 2] >= length - 1
152. Maximum Product Subarray
https://leetcode.com/problems/maximum-product-subarray/
三种情况:
- nums[i] * 前 i - 1构成的最大值(正数相乘)
- nums[i] * 前 i - 1构成的最小值 (负数相乘)
- num[i] 本身(前面的不够大)(或者异号)
只关心当前数和上一个数 的最大/最小值
class Solution:
def maxProduct(self, nums: List[int]) -> int:
if len(nums) == 0: return 0
maxP = nums[0] # 到目前为止的最大乘积
minP = nums[0] # 到目前为止的最小乘积
res = nums[0]
for i in range(1, len(nums)): # 第一个元素写入边界跳进,不加入循环。第一个元素只能自己×自己
tmp = maxP # previous max product
maxP = max(tmp * nums[i], minP * nums[i], nums[i])
minP = min(tmp * nums[i], minP * nums[i], nums[i])
res = max(maxP, res)
return res
53. Maximum Subarray
https://leetcode.com/problems/maximum-subarray/
Input: nums = [-2,1,-3,4,-1,2,1,-5,4]
Output: 6
Explanation: [4,-1,2,1] has the largest sum = 6.
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
n = len(nums)
# maxSum[i]: 0 ~ i数字中子数组和的最大值
maxSum = [float('-inf') for i in range(n)]
maxSum[0] = nums[0]
for i in range(1, n):
if maxSum[i-1] > 0: # 0 ~ i-1加起会大于零,加它
maxSum[i] = maxSum[i-1] + nums[i]
else: # 0 ~ i-1加起来一定小于零,那没必要加它,从i重新开始
maxSum[i] = nums[i]
return max(maxSum)
KMP字符匹配算法
labuladong
KMP算法
快速的从字符串(主串)txt 中找出你想要的子串(模式串)pat:仅仅后移模式串,比较指针不回溯。
有匹配的公共前后缀,模式串使得其前缀和主串后缀匹配。
对于暴力算法,如果出现不匹配字符,同时回退 txt 和 pat 的指针,嵌套 for 循环,时间复杂度
O
(
M
N
)
O(MN)
O(MN),空间复杂度
O
(
1
)
O(1)
O(1)。最主要的问题是,如果字符串中重复的字符比较多,该算法就显得很蠢。
KMP 算法的不同之处在于,它会花费空间来记录一些信息,在上述情况中就会显得很聪明:
KMP 算法永不回退 txt 的指针 i,不走回头路(不会重复扫描 txt),而是借助 dp 数组中储存的信息把 pat 移到正确的位置继续匹配,时间复杂度只需 O(N),用空间换时间,所以我认为它是一种动态规划算法。
KMP 算法最关键的步骤就是构造这个状态转移图。要确定状态转移的行为,得明确两个变量,一个是当前的匹配状态,另一个是遇到的字符;确定了这两个变量后,就可以知道这个情况下应该转移到哪个状态。
打家劫舍
198. House Robber
https://leetcode.com/problems/house-robber/
每一个点包含两个子问题:
max( 当前抢:i-2抢 dp[i-2] + nums[ i ], 当前不抢:i-1抢 dp[ i-1 ] + 0 )
class Solution:
def rob(self, nums: List[int]) -> int:
n = len(nums)
if n == 1: return nums[0]
if not nums: return 0
dp = [0] * n
for i in range(n):
dp[i] = max(dp[i-2] + nums[i], dp[i-1])
return dp[-1]
213. House Robber II
房子是一个圈,首尾相接。
约束条件:首尾房子只能有一个被抢(两种情况取最大)
class Solution:
def rob(self, nums: List[int]) -> int:
n = len(nums)
if n == 1:
return nums[0]
if not nums:
return 0
# 从start到end,可以抢到的最大金额
def money(nums, start, end):
dp = [0] * n
for i in range(start, end):
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
return dp
# 抢头不抢尾:0(头) ~ n-2
maxHead = money(nums, 0, n-1)[-2] # 不抢尾,取尾前面一个的最大值
# 抢尾不抢头:1 ~ n-1(尾)
maxTail = money(nums, 1, n)[-1]
return max(maxHead, maxTail) # 抢头和抢尾取最大值
337. House Robber III
https://leetcode.com/problems/house-robber-iii/
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
class Solution:
def rob(self, root: TreeNode) -> int:
memo = {}
if root == None: return 0
if root in memo:
return memo[root]
if root.left == None:
left = 0
else:
left = self.rob(root.left.left) + self.rob(root.left.right)
if root.right == None:
right = 0
else:
right = self.rob(root.right.left) + self.rob(root.right.right)
rob_it = root.val + left + right
not_rob = self.rob(root.left) + self.rob(root.right)
res = max(rob_it, not_rob)
memo[root] = res
return res
TLE
class Solution:
def rob(self, root: TreeNode) -> int:
def dp(root):
if root == None:
return [0, 0]
left = dp(root.left)
right = dp(root.right)
rob = root.val + left[0] + right[0]
not_rob = max(left[0], left[1]) + max(right[0], right[1])
return [not_rob, rob]
res = []
res = dp(root)
return max(res[0], res[1])
子序列问题
动态规划之子序列问题解题模板
Subsequence: 不连续的序列 子序列
Substring: 连续的 子串
最长递增子序列
最长递增子序列
300. Longest Increasing Subsequence
dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列的长度。
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
n = len(nums)
if n == 0: return 0
dp = [1] * n
for i in range(n):
for j in range(i):
# 寻找nums[i] 之前,比它小的递增自序列(这样nums[i]才可以加在后面)
if nums[i] > nums[j]:
# 在各个追加的结果中取最大值
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
时间复杂度 O(N^2)
最长公共子序列
最长公共子序列
1143. Longest Common Subsequence
输入: str1 = “abcde”, str2 = “ace”
输出: 3
解释: 最长公共子序列是 “ace”,它的长度是 3
dp[ i ][ j ] 的含义是:对于 s1[1…i] 和 s2[1…j],它们的 LCS 长度是 dp[i][j]。
比如上图的例子,d[2][4] 的含义就是:对于 “ac” 和 “babc”,它们的 LCS 长度是 2。我们最终想得到的答案应该是 dp[3][6]。
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
m, n = len(text1), len(text2)
# dp[r][c]: text1[0:r](0 ~ r-1)和 text2[0:c](0 ~ c-1)的最长公共子序列个数
dp = [[0 for c in range(m + 1)] for r in range(n + 1)]
for c in range(1, m + 1):
for r in range(1, n + 1):
if text1[c - 1] == text2[r - 1]:
# 这边找到一个 lcs 的元素,继续往前找
dp[r][c] = dp[r - 1][c - 1] + 1
else:
# 谁能让 lcs 最长,就听谁的
dp[r][c] = max(dp[r - 1][c], dp[r][c - 1])
return dp[-1][-1]
最长回文子序列
- Longest Palindromic Substring
寻找最长对称子字符串
Input: “babad”
Output: “bab”
Note: “aba” is also a valid answer.
从中间开始向两边找,只有两边的字符相同的时候才继续向下进行。
l + 1到r - 1 是回文字符串 and 两边字符相同:l 到 r是回文字符串
class Solution:
def longestPalindrome(self, s: str) -> str:
n = len(s)
# 以l,r为中心点最长的回文字符串是多长
def getLen(l, r): # l, r表示中心点;相同 奇数;不相同 偶数
while l >= 0 and r < n and s[l] == s[r]: # 没有越界而且两边对应字符相等
l -= 1 # 向两边扩展
r += 1
return r-l-1
start = 0
length = 0 # 当前最优解
for i in range(n): # i 中心点
cur = max(getLen(i, i), getLen(i, i+1)) # 比较奇数情况和偶数情况
if cur <= length:
continue
length = cur # 更新最优解
start = i - (cur - 1) // 2 # 算出当前回文序列的起始点
return s[start : start + length]
DP
只能长度,不能返回哪一个最长
DP:
在子串 s[i…j] 中,最长回文子序列的长度为 dp[i][j]
子问题:
假设你知道了子问题 dp[i+1][j-1] 的结果(s[i+1…j-1] 中最长回文子序列的长度
- 那么如果s[ i ] == s[ j ], 那么它俩加上 s[i+1…j-1] 中的最长回文子序列就是 s[i…j] 的最长回文子序列
- 如果它俩不相等,说明它俩不可能同时出现在 s[i…j] 的最长回文子序列中,那么把它俩分别加入 s[i+1…j-1] 中,看看哪个子串产生的回文子序列更长即可
base case:
class Solution:
def longestPalindrome(self, s: str) -> str:
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]
动态规划之子序列问题解题模板
- 一维dp数组
n = len(nums)
dp = [] * n
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
dp[i] = 最值(dp[i], dp[j] + ...)
}
}
- 二维dp数组
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (arr[i] == arr[j])
dp[i][j] = dp[i][j] + ...
else
dp[i][j] = 最值(...)
}
}
-
涉及两个字符串/数组时(比如最长公共子序列),dp 数组的含义如下:
在子数组 arr1[0…i] 和子数组 arr2[0…j] 中,我们要求的子序列(最长公共子序列)长度为 dp[i][j]。 -
只涉及一个字符串/数组时(比如本文要讲的最长回文子序列),dp 数组的含义如下:
在子数组 array[ i … j ] 中,我们要求的子序列(最长回文子序列)的长度为 dp[i][j]。