一维动态规划√

768 阅读6分钟

动态规划简介

本文是面试经典 150 题一维动态规划问题的详细解读。

动态规划(Dynamic Programming)是一种算法思想,它通常用于求解多阶段决策问题,具有重叠子问题和最优子结构性质。一般来说,动态规划问题可以分为以下几个步骤:

  1. 定义状态:明确状态变量的含义,表示问题的子问题和原问题之间的关系。
  2. 定义状态转移方程:根据子问题之间的关系,给出状态转移方程,表示原问题与子问题之间的递推关系。
  3. 定义初始状态:给出问题的边界情况,通常是最简单的子问题的解。
  4. 计算最优解:根据状态转移方程和初始状态,计算出原问题的最优解。

下面以斐波那契数列为例,演示一下最简单的动态规划问题的解法。

斐波那契数列是一个经典的动态规划问题,其定义如下:

Fn=Fn1+Fn2,n3,F1=1,F2=1.Fn​=Fn−1​+Fn−2​,n≥3,\\F1​=1,\\F2​=1.

其中,FnF_n 表示斐波那契数列中第 nn 个数的值。根据定义,可以使用递归的方式来求解斐波那契数列,但是递归算法的时间复杂度较高,会存在重复计算的问题。因此,我们可以采用动态规划的方法来优化算法。

  1. 定义状态

f(n)f(n) 表示斐波那契数列中第 nn 个数的值。

  1. 定义状态转移方程

根据斐波那契数列的递推公式,可以得到状态转移方程:

f(n)=f(n1)+f(n2)f(n)=f(n−1)+f(n−2)

  1. 定义初始状态

根据斐波那契数列的定义,可以得到初始状态:

f(1)=1,f(2)=1f(1)=1,f(2)=1

  1. 计算最优解

根据状态转移方程和初始状态,可以使用循环来计算斐波那契数列的值。

# 暴力递归
def fib(self, n: int) -> int:
    if n == 0 :
        return 0
    elif n == 1 :
        return 1
    else:
        return self.fib(n-1) + self.fib(n-2)
# 借助辅助空间
def fib(self, n: int) -> int:
    arr = [0,1]
    for i in range(2,n+1):
        arr.append(arr[i-1] + arr[i-2])

    return arr[n]
# 不借助辅助空间
    def fib(self, n: int) -> int:
        if n < 2:
            return n
        former, rear = 0, 1
        for i in range(2, n + 1):
            former, rear = rear, former + rear
        return rear

这三种解法都是用来求斐波那契数列的,分别是暴力递归、借助辅助空间的动态规划、和不借助辅助空间的动态规划。

这是三种不同的解法,用于求斐波那契数列的第n项:

  1. 暴力递归:这种解法是最简单直接的方式,但是由于递归的过程会重复计算一些相同的子问题,时间复杂度为O(2n)O(2^n),不适用于大规模计算。
  2. 借助辅助空间:这种解法用一个数组存储之前计算过的结果,时间复杂度为O(n),空间复杂度为O(n)。
  3. 不借助辅助空间:这种解法只用两个变量来存储之前计算过的结果,时间复杂度为O(n),空间复杂度为O(1)。

其中,第三种解法是最优的,因为它不需要额外的空间,并且时间复杂度也是线性的。

在这三种解法中,暴力递归是最简单的方法,但是时间复杂度为 O(2n)O(2^n),指数级别,会超时。而借助辅助空间的动态规划和不借助辅助空间的动态规划都是线性时间复杂度,可以在较短时间内求解。借助辅助空间的动态规划需要开辟一个长度为 n+1n+1 的数组,占用空间较大;而不借助辅助空间的动态规划则只需要两个变量存储中间结果,占用空间较小。因此,在空间限制较为严格的情况下,可以使用不借助辅助空间的动态规划。但是,在空间不是很紧张的情况下,借助辅助空间的动态规划也是一个不错的选择。

看完这个例子,接下来我们看几个简单的一维DP问题。

70. 爬楼梯 - 力扣(Leetcode)

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?  

示例 1:

输入: n = 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶

示例 2:

输入: n = 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶

提示:

  • 1 <= n <= 45

这题比较简单,我们可以很简单的找到初始条件:

  • f(n=1)=1f(n = 1) = 1

  • f(n=2)=2f(n = 2) = 2

  • f(n=3)=3f(n = 3) = 3

因为一次只能上一个台阶,所以当台阶数n=4n=4的时候,是不是可以在n=2n=2的基础上一次上两个台阶,或者在n=3n=3的基础上一次上一个台阶。

因此f(n==4)f(n==4)是从n=2n=2n=3n=3转移过来的,也就是:

f(n)=f(n1)+f(n2)f(n) = f(n-1) + f(n-2)

代码如下:

class Solution:
    def climbStairs(self, n: int) -> int:
        if n<3:
            return n
        s1,s2 = 1,2
        for i in range(3,n+1):
            s1,s2 = s2,s1+s2
        return s2

198. 打家劫舍 - 力扣(Leetcode)

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

示例 1:

输入: [1,2,3,1]
输出: 4
解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4

示例 2:

输入: [2,7,9,3,1]
输出: 12
解释: 偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
     偷窃到的最高金额 = 2 + 9 + 1 = 12

提示:

  • 1 <= nums.length <= 100
  • 0 <= nums[i] <= 400

初始条件:

  • 只有一家的时候就偷这一家

  • 两家的时候偷价钱更高的一家

  • 三家的的时候,看max(1+3, 2)

所以当有n家的时候,要看max(f(n2)+n,f(n1))max(f(n-2)+n,f(n-1))

class Solution:
    def rob(self, nums: List[int]) -> int:
        if len(nums) <= 2:
            return max(nums)
        p = nums[0]
        q = max(nums[0],nums[1])

        for i in range(2,len(nums)):
            p,q = q, max(p+nums[i],q)
        return q

不借助辅助数组看起来太抽象的话,给你们看一下带辅助空间的:

class Solution:
    def rob(self, nums: List[int]) -> int:
        if len(nums) < 3:
            return max(nums)
        dp = [0] * len(nums)
        dp[0] = nums[0]
        dp[1] = max(nums[0],nums[1])
        for i in range(2,len(nums)):
            dp[i] = max(dp[i-1],dp[i-2] + nums[i])

        return dp[-1]

322. 零钱兑换 - 力扣(Leetcode)

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。

你可以认为每种硬币的数量是无限的。

 

示例 1:

输入: coins = [1, 2, 5], amount = 11
输出: 3 
解释: 11 = 5 + 5 + 1

示例 2:

输入: coins = [2], amount = 3
输出: -1

示例 3:

输入: coins = [1], amount = 0
输出: 0

 

提示:

  • 1 <= coins.length <= 12
  • 1 <= coins[i] <= 231 - 1
  • 0 <= amount <= 104
class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        # dp数组
        dp = [amount+1]*(amount+1)
        dp[0] = 0

        for i in range(1,len(dp)):
            for coin in coins:
                if(i-coin <0):
                    continue
                dp[i] = min(dp[i], 1+dp[i-coin])
        return dp[-1] if dp[-1] != amount+1 else -1

这段代码定义了一个长度为 amount+1dp 数组,其中 dp[i] 表示凑出总额为 i 需要的最小硬币数。然后通过两层循环来更新 dp 数组的值,最后返回 dp[amount]

对于每个总额 i,枚举硬币面值列表 coins 中的硬币面值 coin,如果 i-coin<0,说明无法使用这个硬币来凑出总额为 i,直接跳过。否则,根据动态规划的转移方程 dp[i] = min(dp[i], 1+dp[i-coin]),更新 dp[i] 的值。

最后,如果 dp[amount] 的值等于 amount+1,说明无法凑出指定的零钱总额,返回 -1。否则,返回 dp[amount] 的值,即需要找的最小硬币数。

139. 单词拆分 - 力扣(Leetcode)

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。

注意: 不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

 

示例 1:

输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以由 "leet""code" 拼接成。

示例 2:

输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以由 "apple" "pen" "apple" 拼接成。
     注意,你可以重复使用字典中的单词。

示例 3:

输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出: false

 

提示:

  • 1 <= s.length <= 300
  • 1 <= wordDict.length <= 1000
  • 1 <= wordDict[i].length <= 20
  • s 和 wordDict[i] 仅有小写英文字母组成
  • wordDict 中的所有字符串 互不相同
class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        dp = [False] * (len(s)+1)
        dp[0] = True
        for i in range(1,len(s)+1):
            for j in range(i):
                if dp[j] and s[j:i] in wordDict:
                    dp[i] = True
        return dp[-1] 
  1. wordDict = set(wordDict)wordDict 转换为一个集合,以提高单词查找的效率。

  2. dp = [False]*(len(s)+1) 创建一个长度为 len(s)+1 的数组 dp,并将其所有元素初始化为 False。其中 dp[i] 表示字符串的前 i 个字符是否可以被拆分成 wordDict 中出现过的单词。

  3. dp[0] = Truedp[0] 设为 True,表示空字符串可以被拆分成空单词列表。

  4. for i in range(1,1+len(s)): 遍历字符串 s 中所有可能的子串。

  5. for j in range(i): 遍历子串中所有可能的前缀。

  6. if dp[j] and s[j:i] in wordDict: 如果前缀 s[0:j] 可以被拆分成 wordDict 中出现过的单词,并且剩余的子串 s[j:i] 也在 wordDict 中出现过,那么说明当前的子串 s[0:i] 可以被拆分成 wordDict 中出现过的单词,因此将 dp[i] 设为 True

  7. return dp[len(s)] 返回 dp[len(s)],表示整个字符串 s 是否可以被拆分成 wordDict 中出现过的单词。

  8. print(dp) 打印数组 dp,以便调试和检查结果。

最后,注意在函数定义中,s 参数后面的 : strwordDict 参数后面的 : List[str] 是类型注释,用于指定参数的类型。-> bool 表示函数返回值的类型为布尔值。这些类型注释不影响函数的实际行为,但可以帮助代码编辑器和其他开发者更好地理解函数的意图和用法。

300. 最长递增子序列 - 力扣(Leetcode)

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例 1:

输入: nums = [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长递增子序列是 [2,3,7,101],因此长度为 4 。

示例 2:

输入: nums = [0,1,0,3,2,3]
输出: 4

示例 3:

输入: nums = [7,7,7,7,7,7,7]
输出: 1

 

提示:

  • 1 <= nums.length <= 2500
  • -104 <= nums[i] <= 104

 

进阶:

  • 你能将算法的时间复杂度降低到 O(n log(n)) 吗?

注意这里写的是 子序列

在计算机科学中,序列是指一组按照顺序排列的元素组成的数据结构。而子序列和子数组都是序列的子集,但它们略有不同的定义。

  • 子序列是指从一个序列中选择出一些元素,并保持它们在原序列中的相对顺序,形成的新序列。也就是说,子序列可以跨越原序列中的元素。例如,序列 [1, 2, 3, 4] 的一些子序列包括 [1, 2, 3], [2, 3, 4], [1, 3, 4], [1, 2], [2, 4], [1], [2], [3], [4] 等等。

  • 而子数组是指从一个数组中选择出一些元素,并保持它们在原数组中的相对顺序和连续性,形成的新数组。也就是说,子数组不能跨越原数组中的元素。例如,数组 [1, 2, 3, 4] 的一些子数组包括 [1, 2, 3], [2, 3, 4], [1], [2], [3], [4] 等等,但不包括 [1, 3, 4], [1, 2, 4] 等不连续的数组。

在算法问题中,通常会涉及到对一个序列或数组的子序列或子数组进行计算或处理,因此对它们的区别需要明确。

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        dp = [1]*len(nums)
        for i in range(1,len(dp)):
            for j in range(i):
                if nums[i] > nums[j]:
                    dp[i] = max(dp[i],dp[j]+1)
        return max(dp)
  1. dp = [1]*len(nums):创建一个与输入列表 nums 长度相同的列表 dp,并将其初始化为 1。dp 中的每个元素代表以 nums 中对应位置的元素为结尾的最长上升子序列的长度。

  2. for i in range(1,len(dp))::遍历 dp 中的每个元素,从下标 1 开始。

  3. for j in range(i)::对于每个 i,遍历其前面的所有元素,从下标 0 到 i-1

  4. if nums[i] > nums[j]::如果当前的元素 nums[i] 大于前面的元素 nums[j],说明可以将 nums[i] 加入以 nums[j] 结尾的最长上升子序列中,此时更新 dp[i] 的值为 dp[j] + 1

  5. return max(dp):返回 dp 中的最大值,即整个列表的最长上升子序列的长度。

该算法的时间复杂度为 O(n2)O(n^2),其中 nn 是输入列表的长度。如果使用二分查找算法,可以将时间复杂度优化到 O(nlogn)O(n\log n)

注意题目有个进阶:

image.png

可以使用二分查找算法来优化寻找最长上升子序列的过程,将时间复杂度降为 O(nlogn)O(n\log n)

具体来说,我们可以使用一个辅助数组 dp,其中 dp[i] 表示以第 i 个元素结尾的最长上升子序列的长度。初始时,dp 数组的所有元素都赋值为 1,然后遍历输入列表 nums 中的每个元素 nums[i],将其与 nums[0:i] 中的每个元素 nums[j] 比较:

  1. 如果 nums[i] > nums[j],则更新 dp[i]dp[j] + 1,表示将 nums[i] 加入以 nums[j] 结尾的最长上升子序列中可以得到一个更长的上升子序列。
  2. 遍历完 nums[0:i] 中的所有元素后,dp[i] 的值即为以第 i 个元素结尾的最长上升子序列的长度。

遍历完整个 nums 数组后,dp 数组中的最大值即为最长上升子序列的长度。以下是实现该算法的代码:

from bisect import bisect_left
class Solution:
    def lengthOfLIS(self,nums):
        dp = []
        for n in nums:
            if not dp or n > dp[-1]:
                dp.append(n)
            else:
                idx = bisect_left(dp, n)
                dp[idx] = n
        return dp

本文正在参加「金石计划」