动态规划学习笔记(Dynamic Programming)


reference:https://github.com/labuladong/fucking-algorithm

Intro

动态规划三要素:重叠子问题、最优子结构、状态转移方程
写出状态转移方程:明确状态 -> 定义dp数组/函数的含义 -> 明确「选择」-> 明确 base case

动态规划:一般求最优解/最值

  1. 计数
    有多少种方式走到右下角,有多少种方法选出k个数 和为sum
  2. 求最大值/最小值
    路径最大数字和,最长上升子序列
  3. 求存在性
    能不能选出k个数和为sum,先手是否必胜

斐波那契数列

  1. 纯粹递归
def fibo(N: int):
    if N ==1 or N == 2:
        return 1
    return fibo(N-1) + fibo(N-2)

时间复杂度O(2 ^ n)
在这里插入图片描述
过多的重复子问题

  1. 带备忘录的递归
    造一个「备忘录」,每次算出某个子问题的答案,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。
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),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。

  1. 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);

寻找状态转移方程:

  1. 确定状态:原问题和子问题中变化的变量。由于硬币数量无限,所以唯一的状态就是目标金额 amount。

  2. 确定dp的定义:当前目标金额是n,至少需要dp[n]个硬币凑出金额。

  3. 确定选择:对于每个状态,可以做出什么选择改变当前状态。从面额列表coins中选择一个硬币,然后目标金额随之减少 。

  4. 确定base case:目标金额为0时,所需硬币为0;金额小于0,无解,返回-1.

  5. 记忆化递归

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)
  1. 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/
在这里插入图片描述
四步骤

  1. 确定状态
    使用的数组 dp[ i ] or dp[ i ][ j ] 代表什么
    • 最后一步:
      k枚硬币a1, a2, …, ak面值加起来等于27,一定有最后一枚硬币 ak
      在这里插入图片描述
      最后一步之前,仍然是最优
    • 子问题:
      现在要求的问题是:最少用多少硬币拼出27-ak
      就可以得出状态 => dp[x] 最少用多少枚硬币可以拼出 x
      在这里插入图片描述
  2. 转移方程:
    在这里插入图片描述
  3. 初始条件和边界情况
    x-2, x-5, x-7 < 0? 什么时候停下来?
    • 如果不能拼出Y, dp[ Y ] = 正无穷:dp[ -1 ] = dp[ -2 ] = … = 正无穷
    • 初始条件:dp[ 0 ] = 0 转移方程算不出来的
  4. 确定计算顺序
    自底向上

没有任何重复,时间复杂度 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的格子,左上走到右下,总过有多少种不同路径

  1. 确定状态
    • 最后一步: 右下角坐标(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)
  2. 转移方程
    在这里插入图片描述
  3. 初始条件和边界条件
    在这里插入图片描述
    时间复杂度 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
需要满足两个条件:

  1. 可以跳到石头i
  2. 最后一步不超过该石头最大跳跃距离: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/
三种情况:

  1. nums[i] * 前 i - 1构成的最大值(正数相乘)
  2. nums[i] * 前 i - 1构成的最小值 (负数相乘)
  3. 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]

最长回文子序列

  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]
    

动态规划之子序列问题解题模板

  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] + ...)
    }
}
  1. 二维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]。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值