算法,蒜鸟蒜鸟-P4-理解“动态规划-DP”

欢迎来到啾啾的博客🐱。
记录学习点滴。分享工作思考和实用技巧,偶尔也分享一些杂谈💬。
有很多很多不足的地方,欢迎评论交流,感谢您的阅读和评论😄。

博客头像3.0.png

引言

  • ✅ 回溯 (Backtracking): 一种“发散”的、通过“选择-递归-撤销”来探索所有解空间的暴力美学。我们用它解决了排列组合问题。
  • ✅ 分治 (Divide and Conquer): 一种“收敛”的、通过“分而治之、合而为一”来高效解决问题的思想。我们用它掌握了面试中的常青树——二分查找

接下来,让我们一起了解动态规划。

动态规划(Dynamic Programming,DP)

DP 的本质就是 “空间换时间”“记忆化 (Memoization)” 。它专门用来解决那些包含了大量重叠子问题 (Overlapping Subproblems) 的难题。

口诀:记录过去,预见未来。

重叠子问题,一般都需要递归,DP与递归关系紧密。

LeetCode

70. 爬楼梯

简单、经典的动态规划提。

在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶?

示例: n = 3
输出: 3
解释: 有三种方法可以爬到楼顶。

  1. 1 阶 + 1 阶 + 1 阶
  2. 1 阶 + 2 阶
  3. 2 阶 + 1 阶

【朴素的递归思考】

  • 想爬到第 n 阶,我只有两种可能:

    1. 从第 n-1 阶爬 1 步上来。
    2. 从第 n-2 阶爬 2 步上来。
  • 所以,爬到第 n 阶的方法数 f(n),就等于爬到第 n-1 阶的方法数 f(n-1) 加上爬到第 n-2 阶的方法数 f(n-2)。

  • 即 f(n) = f(n-1) + f(n-2)。这不就是斐波那契数列吗!

这是,重叠子问题出现了,如果我们用纯递归来算 f(5):

  • f(5) = f(4) + f(3)
  • f(4) = f(3) + f(2)
  • f(3) = f(2) + f(1)

显然,为了算 f(5),f(3) 被计算了两次,f(2) 被计算了三次… 当 n 很大时,这种重复计算是灾难性的。

动态规划采用“记录重复子问题”的方式解决问题,也就是拿小本本(数组或者Hash)表记录:

  1. 先算 f(1) = 1,记下来。
  2. 再算 f(2) = 2,记下来。
  3. 算 f(3) 时,不用再递归了,直接查小本本:f(3) = f(2) + f(1) = 2 + 1 = 3。记下来。
  4. 算 f(4) 时,查本本:f(4) = f(3) + f(2) = 3 + 2 = 5。记下来。

这就是动态规划。通过一个 dp 数组,自底向上地解决问题,每一步都利用了之前已经计算好的结果。

DP-五步法

几乎所有的基础 DP 问题,都可以通过下面这五个固定的步骤来求解。只要严格按照这个框架来思考,再难的 DP 问题也能被分解成一个个可以解决的小块。

以“70. 爬楼梯”为例,来走一遍这五步:

第一步:定义 dp 数组的含义

  • 这是最最最重要的一步!必须清晰地定义 dp[i] 代表什么。
  • 对于本题: 我们可以定义 dp[i] 为:爬到第 i 级台阶,总共有 dp[i] 种不同的方法。

第二步:找出递推公式 (状态转移方程)

  • 思考 dp[i] 是如何由之前的 dp 状态推导出来的。
  • 对于本题: 根据我们之前的分析,要想到达第 i 级台阶,只能从第 i-1 级或第 i-2 级上来。所以,到达第 i 级的方法数,就是这两种方法的总和。
  • 公式:dp[i] = dp[i-1] + dp[i-2]

第三步:初始化 dp 数组

  • 根据递推公式,我们需要为 dp 数组提供最初的“启动值”,也就是基础情况 (Base Case)
  • 对于本题:
    dp[1] = 1 (爬到第1级台阶,只有一种方法:1步)
    dp[2] = 2 (爬到第2级台阶,有两种方法:1+1 或 2)
    • 注意:有时候为了公式整齐,我们会设置 dp[0],这取决于你的定义。在这里,从1和2开始更直观。

第四步:确定遍历顺序

  • 思考 for 循环应该从前往后,还是从后往前。
  • 对于本题: 我们的递推公式 dp[i] = dp[i-1] + dp[i-2] 明确告诉我们,要计算 dp[i],必须先知道 dp[i-1] 和 dp[i-2] 的值。所以,遍历顺序必须是从前向后

第五步:根据 dp 数组得出最终答案

  • 题目要求的是什么?对应到 dp 数组的哪个值?
  • 对于本题: 题目要求爬到第 n 阶的方法数,根据我们的定义,这正好就是 dp[n]

代码如下:

class Solution {
    public int climbStairs(int n) {
        // 处理边界情况
        if (n <= 2) {
            return n;
        }

        // 1. 定义 dp 数组。大小设为 n+1 是为了让下标 i 和台阶数 i 对应起来
        // dp[i] 的含义是:爬到第 i 级台阶的方法数
        int[] dp = new int[n + 1];

        // 3. 初始化 dp 数组的 base case
        dp[1] = 1;
        dp[2] = 2;

        // 4. 确定遍历顺序:从前向后
        // 从 3 开始,因为 1 和 2 已经初始化了
        for (int i = 3; i <= n; i++) {
            // 2. 运用递推公式
            dp[i] = dp[i - 1] + dp[i - 2];
        }

        // 5. 返回最终答案
        return dp[n];
    }
}

我们清晰地完成了70题,但是这样的代码还有优化的空间,就是要计算 dp[i],我们实际上只需要知道它前面两个状态 (dp[i-1] 和 dp[i-2]) 的值。至于 dp[i-3]dp[i-4] … 这些更早的状态,我们根本用不上了。

我们没有必要记录用不上的数据,因此,我们可以只用两个变量来滚动地记录最新的两个状态,从而将空间复杂度从 O(n) 优化到 O(1)。这个技巧被称为 “滚动数组”“状态压缩”

最终解如下:

class Solution {
    public int climbStairs(int n) {
        if (n <= 2) {
            return n;
        }

        // pre1 代表 dp[i-2] 的值,初始化为 f(1)
        int pre1 = 1;
        // pre2 代表 dp[i-1] 的值,初始化为 f(2)
        int pre2 = 2;

        // 我们已经有了 f(1) 和 f(2) 的值,
        // 所以我们从 i=3 开始,一直计算到 n
        for (int i = 3; i <= n; i++) {
            // 1. 计算当前 dp[i] 的值
            int current = pre1 + pre2;
            
            // 2. 更新两个指针,为下一次循环做准备
            //    原来的 pre2 (dp[i-1]) 变成了下一次循环的 pre1 (dp[i-2])
            pre1 = pre2;
            //    刚算出来的 current (dp[i]) 变成了下一次循环的 pre2 (dp[i-1])
            pre2 = current;
        }

        // 循环结束后,pre2 里存储的就是 dp[n] 的值
        return pre2;
    }
}

其他练习题:
53. 最大子数组和
322. 零钱兑换

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

tataCrayon|啾啾

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值