爬楼梯与路径类题目记忆化递归与动态规划双解法(Leetcode题解-Python语言)

70. 爬楼梯剑指 Offer 10- II. 青蛙跳台阶问题

递归(英语:Recursion),是指在函数的定义中使用函数自身的方法。有意义的递归通常会把问题分解成规模缩小的同类子问题,当子问题缩写到寻常的时候,我们可以直接知道它的解。然后通过建立递归函数之间的联系(转移)即可解决原问题。

记忆化递归,就是用数组或者哈希表记录下递归过程中的计算结果,避免重复计算。

以爬楼梯为例,我们想知道爬 n 阶楼梯的方案数 f(n),由于每次只能爬 1 阶或 2 阶楼梯,所以其实如果知道爬 n - 1 阶和 n - 2 阶楼梯的方案数 f(n-1) 和 f(n-2),就能知道爬 n 阶楼梯的方案数 f(n) = f(n-1) + f(n-2)。 式子中最小为 n-2 ,根据题意 n-2 >= 0(也可以严格大于0,区别不大,后面相应修改) ,那么 n >= 2。意味着最后一次递归调用为 f(2) = f(1) + f(0),边界就是 f(1) = 1f(0) = 1

直接递归的代码如下:

class Solution:
	def climbStairs(self, n: int) -> int:
        if n <= 1:
	        return 1
	    return self.climbStairs(n - 1) + self.climbStairs(n - 2)

是会超时的,利用记忆化递归可以减少许多重复运算,顺利通过:

class Solution:
    memo = dict()
    def climbStairs(self, n: int) -> int:
        if n <= 1:
            return 1
        if n in self.memo:
            return self.memo[n]
        self.memo[n] = self.climbStairs(n-1) + self.climbStairs(n-2)
        return self.memo[n]

思路不难,只是用一个字典 memo 记录出现过的台阶与对应的方案数,如果有记录的话就不用往下递归了,直接返回结果即可。剑指 Offer 的题目区别只在于结果要对 1000000007 取余数。

509. 斐波那契数剑指 Offer 10- I. 斐波那契数列

class Solution:
    memo = dict()
    def fib(self, n: int) -> int:
        if n <= 1:
            return n
        if n in self.memo:
            return self.memo[n]
        self.memo[n] = self.fib(n-1) + self.fib(n-2)
        return self.memo[n]

求斐波那契数,除了边界,其余代码都是一样的。

1137. 第 N 个泰波那契数

class Solution:
    memo = dict()
    def tribonacci(self, n: int) -> int:
        if n == 0:
            return 0
        if n == 1 or n == 2:
            return 1
        if n in self.memo:
            return self.memo[n]
        self.memo[n] = self.tribonacci(n-1) + self.tribonacci(n-2) + self.tribonacci(n-3)
        return self.memo[n]

这题求的是泰波那契数,思路基本一样,只是递归公式中最小的是 n-3,所以 n >= 3,最后一次递归是 n =3,若知道 n = 0, 1, 2 的值即可得到 n = 3 的结果,所以递归边界可知。(题目其实给了)

746. 使用最小花费爬楼梯

class Solution:
    memo = dict()
    def minCostClimbingStairs(self, cost: List[int]) -> int:
        if len(cost) == 1:
            return 0
        if len(cost) == 2:
            return min(cost)
        if tuple(cost) in self.memo:
            return self.memo[tuple(cost)]
        self.memo[tuple(cost)] = min(cost[0] + self.minCostClimbingStairs(cost[1:]), 
                                     cost[1] + self.minCostClimbingStairs(cost[2:]))
        return self.memo[tuple(cost)]

这题注意开始爬楼梯时,爬一个台阶是到 cost[0],两个台阶是到 cost[1],而不是 cost[0] 为起点。想要继续使用字典只能用可哈希对象元组,不能用数组。

说完记忆化递归,我们说说动态规划。动态规划(英语:Dynamic programming,简称DP)是通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。

我们应该留意到,动态规划的核心思路也是通过记忆化避免重复运算,实际上,动态规划中的 dp 数组(状态数组)对应的就是记忆化递归中的 memo 字典。关于动态规划的简单入门,我推荐这篇知乎文章

总结来说就是三点:定义 dp 数组元素的含义(状态是什么)、找出 dp 数组元素间的关系式(递推公式或状态转移方程)、找出初始条件。用上面的例题进行说明:

70. 爬楼梯剑指 Offer 10- II. 青蛙跳台阶问题

dp 数组元素 dp[i] 的含义为:爬 i 阶楼梯的方案数。
数组元素间的关系,由最开始的分析可知,dp[i] = dp[i-1] + dp[i-2]。
初始条件为 dp[1] = 1, dp[2] = 2,注意我这里不讨论 dp[0] 的初始化,是因为题目说了 n 不可能为 0!所以我的遍历也是从 3 开始的,代码如下:

class Solution:
    def climbStairs(self, n: int) -> int:
        if n == 1 or n == 2:
            return n
        dp = [0] * (n+1)
        dp[1] = 1
        dp[2] = 2
        for i in range(3, n+1):
            dp[i] = dp[i-1] + dp[i-2]
        return dp[-1]

509. 斐波那契数剑指 Offer 10- I. 斐波那契数列

class Solution:
    def fib(self, n: int) -> int:
        if n == 0 or n == 1:
            return n
        dp = [0] * (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[-1]

dp[i] 的含义:i 对应的斐波那契数

递推公式:dp[i] = dp[i-1] + dp[i-2]

初始条件: dp[0] = 0, dp[1] = 1

观察到实际上每次都只改变了三个位置,所以优化写法如下:

class Solution:
    def fib(self, n: int) -> int:
        if n == 0 or n == 1:
            return n
        f1 = 0
        f2 = 1
        f3 = 0
        for i in range(1, n):
            f3 = f1 + f2
            f1, f2 = f2, f3
        return f3

1137. 第 N 个泰波那契数

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

dp[i] 的含义:i 对应的泰波那契数

递推公式:dp[i] = dp[i-3] + dp[i-2] + dp[i-1]

初始条件: dp[0] = 0, dp[1] = 1, dp[2] = 1

优化写法:

class Solution:
    def tribonacci(self, n: int) -> int:
        if n <= 1:
            return n
        if n == 2:
            return 1
        f1 = 0
        f2 = 1
        f3 = 1
        f4 = 2
        for i in range(3, n+1):
            f4 = f1 + f2 + f3
            f1, f2, f3 = f2, f3, f4
        return f4

746. 使用最小花费爬楼梯

class Solution:
    def minCostClimbingStairs(self, cost: List[int]) -> int:
        n = len(cost)
        dp = [0] * (n + 1)
        dp[0] = cost[0]
        dp[1] = cost[1]
        for i in range(2, n):
            dp[i] = cost[i] + min(dp[i-1], dp[i-2])
        dp[n] = min(dp[n-1], dp[n-2])
        return dp[n]

dp[i] 的含义:到达第 i 级台阶时最小的总花费(第一步有花费,最后一步没花费)

递推公式:dp[i] = cost[i] + min(dp[i-1], dp[i-2])

初始条件: dp[0] = cost[0], dp[1] = cost[1]

由于最后一步没花费,所以最后一个元素需要单独求。优化写法:

class Solution:
    def minCostClimbingStairs(self, cost: List[int]) -> int:
        n = len(cost)
        f1 = cost[0]
        f2 = cost[1]
        f3 = 0
        for i in range(2, n):
            f3 = cost[i] + min(f1, f2)
            f1, f2 = f2, f3
        f3 = min(f1, f2)
        return f3

62. 不同路径剑指 Offer II 098. 路径的数目

class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        dp = [[1 for _ in range(n)] for _ in range(m)]
        for i in range(1, m):
            for j in range(1, n):
                dp[i][j] = dp[i-1][j] + dp[i][j-1]
        return dp[m-1][n-1]

dp[i][j] 的含义:表示从 (0, 0) 出发,到 (i, j) 有 dp[i][j] 条不同的路径

递推公式:dp[i][j] = dp[i-1][j] + dp[i][j-1] (只能从上或左到达当前位置)

初始条件: dp[i][0]一定都是1,因为从 (0, 0) 的位置到 (i, 0) 的路径只有一条,dp[0][j]也同理,方便起见,就将整个 dp 二维数组都初始化为 1

for 循环是 m 和 n,因为要返回的就是 dp[m-1][n-1],符合定义。

63. 不同路径 II

class Solution:
    def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
        m = len(obstacleGrid)
        n = len(obstacleGrid[0])
        dp = [[0 for _ in range(n)] for _ in range(m)]
        for i in range(m):
            if obstacleGrid[i][0] == 1:
                break
            else:
                dp[i][0] = 1
        for j in range(n):
            if obstacleGrid[0][j] == 1:
                break
            else:
                dp[0][j] = 1
        #print(dp)
        for i in range(1, m):
            for j in range(1, n):
                if obstacleGrid[i][j] != 1:
                    dp[i][j] = dp[i-1][j] + dp[i][j-1]
        return dp[m-1][n-1]

dp[i][j] 的含义:表示从 (0, 0) 出发,到 (i, j) 有 dp[i][j] 条不同的路径

递推公式:dp[i][j] = dp[i-1][j] + dp[i][j-1] (前提是当前位置不是障碍)

初始条件: 先将整个 dp 二维数组都初始化为 0,然后 dp[i][0] (第一列)在遇到障碍之前都为 1,因为从 (0, 0) 的位置到 (i, 0) 的路径只有一条,dp[0][j](第一行)同理。

64. 最小路径和剑指 Offer II 099. 最小路径之和

class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        m = len(grid)
        n = len(grid[0])
        dp = [[0 for _ in range(n)] for _ in range(m)]
        dp[0][0] = grid[0][0]
        for i in range(1, m):
            dp[i][0] = dp[i-1][0] + grid[i][0]
        for j in range(1, n):
            dp[0][j] = dp[0][j-1] + grid[0][j]
        for i in range(1, m):
            for j in range(1, n):
                dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
        return dp[m-1][n-1]

dp[i][j] 的含义:表示从 (0, 0) 出发,到 (i, j) 的最小路径之和

递推公式:dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]

初始条件:dp[0][0] 就是 grid[0][0],然后第一列与第一行的最小路径之和都是唯一的,就是单纯地累加

931. 下降路径最小和

class Solution:
    def minFallingPathSum(self, matrix: List[List[int]]) -> int:
        n = len(matrix)
        dp = [[0 for _ in range(n)] for _ in range(n)]
        for j in range(n):
            dp[0][j] = matrix[0][j]
        for i in range(1, n):
            for j in range(n):
                if j == 0:
                    dp[i][j] = min(dp[i-1][j], dp[i-1][j+1]) + matrix[i][j]
                elif j == n-1:
                    dp[i][j] = min(dp[i-1][j], dp[i-1][j-1]) + matrix[i][j]
                else:
                    dp[i][j] = min(dp[i-1][j], dp[i-1][j+1], dp[i-1][j-1]) + matrix[i][j]
        return min(dp[-1])

dp[i][j] 的含义:表示从 (0, 0) 出发,到达 (i, j) 的最小路径之和

递推公式
如果在最左边的话,路径和就等于正上方和右上方两者中小的路径和加上当前的路径花费,
dp[i][j] = min(dp[i-1][j], dp[i-1][j+1]) + matrix[i][j]
如果在最右边的话,路径和就等于左上方和正上方两者中小的路径和加上当前的路径花费,
dp[i][j] = min(dp[i-1][j], dp[i-1][j-1]) + matrix[i][j]
如果在中间,则路径和就等于左上方、正上方和右上方三者中小的路径和加上当前的路径花费,dp[i][j] = min(dp[i-1][j], dp[i-1][j+1], dp[i-1][j-1]) + matrix[i][j]

初始条件:dp 数组的第一层等于 matrix 第一层,实际上最左边和最右边也可以作为初始化

120. 三角形最小路径和剑指 Offer II 100. 三角形中最小路径之和

class Solution:
    def minimumTotal(self, triangle: List[List[int]]) -> int:
        n = len(triangle)
        dp = []
        for i in range(1, n+1):
            dp.append([0 for _ in range(i)])
        dp[0][0] = triangle[0][0]
        for i in range(1, n):
            for j in range(i+1):
                if j == 0:
                    dp[i][j] = dp[i-1][j] + triangle[i][j]
                elif j == i:
                    dp[i][j] = dp[i-1][j-1] + triangle[i][j]                    
                else:
                    dp[i][j] = min(dp[i-1][j], dp[i-1][j-1]) + triangle[i][j]
        return min(dp[-1])

dp[i][j] 的含义:表示从 (0, 0) 出发,到达 (i, j) 的最小路径之和

递推公式
如果在最左边的话,路径和就等于正上方的路径和加上当前的路径花费,
dp[i][j] = dp[i-1][j] + triangle[i][j]
如果在最右边的话,路径和就等于左上方的路径和加上当前的路径花费,
dp[i][j] = dp[i-1][j-1] + triangle[i][j]
如果在中间,则路径和就等于正上方和左上方两者中小的路径和加上当前的路径花费,
dp[i][j] = min(dp[i-1][j], dp[i-1][j-1]) + triangle[i][j]

初始条件:dp 数组的第一层等于 triangle 第一层,实际上最左边和最右边也可以作为初始化

这题注意 j 的循环次数,在第 i 层就循环 i 次

1289. 下降路径最小和 II

困难题,我最开始的想法是,在上上题的基础上,让当前位置最小路径和等于不是当前列的路径和中最小的值再加上当前位置的路径花费:

class Solution:
    def minFallingPathSum(self, grid: List[List[int]]) -> int:
        m, n = len(grid), len(grid[0])
        dp = [[0] * n for _ in range(m)]
        dp[0][:] = grid[0][:]
        for i in range(1, m):
            for j in range(n):
                temp = float('inf')
                for k in range(n):
                    if k != j and dp[i-1][k] < temp:
                        temp = dp[i-1][k]
                dp[i][j] = temp + grid[i][j]
        return min(dp[-1])

但是显然,这个方法的时间复杂度是 O(m * n * n),容易超时。换一个思路,如果知道了第一层的最小路径,那么在第二层除了跟它同一列的位置,都是加上这个最小路径为最优;那同一列的位置加谁呢?第一层中第二小的路径呗。这样实际上就完成了从第一层到第二层的递推,dp 数组自然可以构建出来了。

class Solution:
    def minFallingPathSum(self, grid: List[List[int]]) -> int:
        m, n = len(grid), len(grid[0])
        dp = [[0] * n for _ in range(m)]
        dp[0][:] = grid[0][:]
        for i in range(1, m):
            # 找到最小的值
            minflag = dp[i-1].index(min(dp[i-1]))
            # 除了同一列的位置,都加上这个最小值
            for j in range(0, n):
                if j != minflag:
                    dp[i][j] = grid[i][j] + dp[i-1][minflag]
            # 找到第二小的值
            if minflag == 0:
                minflag2 = min(dp[i-1][1:])
            elif minflag == n - 1:
                minflag2 = min(dp[i-1][:-1])
            else:
                minflag2 = min(min(dp[i-1][:minflag]), min(dp[i-1][minflag+1:]))
            # 给同一列的位置加上这个第二小的值
            dp[i][minflag] = grid[i][minflag] + minflag2

        return min(dp[-1])

343. 整数拆分

class Solution:
    def integerBreak(self, n: int) -> int:
        dp = [0 for _ in range(n+1)]
        dp[2] = 1
        for i in range(3, n+1):
            for j in range(1, i):
            # 假设对正整数 i 拆分出的第一个正整数是 j(1 <= j < i),则有以下两种方案:
            # 1) 将 i 拆分成 j 和 i−j 的和,且 i−j 不再拆分成多个正整数,此时的乘积是 j * (i-j)
            # 2) 将 i 拆分成 j 和 i−j 的和,且 i−j 继续拆分成多个正整数,此时的乘积是 j * dp[i-j]
                dp[i] = max(dp[i], j * (i - j), j * dp[i - j])
        return dp[n]

dp[i] 的含义:表示分拆数字 i,可以得到的最大乘积为 dp[i]

递推公式:dp[i] = max(dp[i], j * (i - j), j * dp[i - j])

初始条件:dp[2] = 1(dp[0] dp[1] 不应该初始化,因为没有意义)

96. 不同的二叉搜索树

class Solution:
    def numTrees(self, n: int) -> int:
        dp = [0 for _ in range(n+1)]
        dp[0] = 1
        for i in range(1, n+1):
            for j in range(1, i+1):
                dp[i] += dp[j-1] * dp[i-j]
        return dp[n]

dp[i] 的含义:1到 i 为节点组成的二叉搜索树的个数为 dp[i]

递推公式:dp[i] += dp[j-1] * dp[i-j]

初始条件:dp[0] = 1

当 n = 1 时,dp[1] = 1;当 n = 2 时,dp[2] = 2;当 n = 3 时,左右子树可能的数量分别为(2,0)、(1,1)、(0,2),这对应了 dp[3] = dp[2] * dp[0] + dp[1] * dp[1] + dp[0] * dp[2];后面的以此类推。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值