一、 什么是动态规划?
动态规划(Dynamic Programming)并不是一种具体的算法,而是一种解题思想。
它通过把原问题分解为相对简单的子问题,并保存子问题的结果,从而避免重复计算。
DP 解题三部曲:
-
状态定义:dp[i] 到底代表什么?
-
状态转移方程:dp[i] 怎么由前面的 dp 值推导出来?
-
初始化与边界:从哪里开始"递推"?
举个例子:
你要数清面前这一大堆散落的麦粒。
正常直接数 ,后果就是有可能会忘记当前数到第几个了,然后大概率需要从头开始数了。
实际上生活中很多人选择分开数,拿一张纸和一支笔,把数过的结果记下来,数过10粒则放在一边,并且记录已经数到10粒了,总数+10,然后就是不管这10粒了。
因此,当一个复杂问题的解决方案中包含大量重复出现的"子问题"时,记录下这些子问题的答案是明智的。
那么迁移到下面的经典场景:
爬楼梯
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
当拿到这个问题的时候,如果像直接开始数就会陷入误区:
我需要一直排列组合,凑出结果,虽然是可行的,但是这个和直接数一样的困难。
而动态规划就是分开数
假设我要知道(第10层)
如果你已经知道了到达第 8 层有 21 种方法,到达第 9 层有 34 种方法。而你只需要跨一步或两步就能到达第 10 层。
那么,到达第 10 层的方法数,是不是由到达第 8 层和第 9 层的结果共同决定的?
因为第 10 层只能从那两层走上来。
那问题是我们没有算出第 8 层和第 9 层,我们能直接得出第 10 层的结果吗?
显然是不行的不能,就像数麦粒,我还没数到30我怎么知道+10就是40粒?
所以,这里的逻辑链条是:当前的果(第10层),必然由之前的因(第8、9层)推导而来。
二、 经典题型全解析
1. 基础入门:爬楼梯 &杨辉三角
爬楼梯
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
解:
我只需要知道前面能到当前位置的两个层级(前一层和前前层)需要多少步,然后从前往后累加就行
java
class Solution {
public int climbStairs(int n) {
// 1. 基准情况:如果只有1阶或2阶,结果就是它自己
if (n <= 2) {
return n;
}
// 2. 阶段性记录:
//要推导当前阶,只需要知道前两阶的结果。
int p = 1; // 到达"前前一阶"的方法数(初始为第1阶)
int q = 2; // 到达"前一阶"的方法数(初始为第2阶)
int res = 0; // 准备记录:当前这一阶的结果
// 3. 递推过程:从第3阶开始,一直数到第n阶
for (int i = 3; i <= n; i++) {
// 相加
// 到达当前阶的方法 = 到达前一阶的方法 + 到达前前一阶的方法
res = p + q;
// 既然已经算出了当前的 res,那么原来的 p(前前一阶)就没用了。
// 我们把记录往前挪:
p = q; // 原来的"前一阶"变成了现在的"前前一阶"
q = res; // 原来的"当前阶"变成了现在的"前一阶"
}
// 5. 最终返回:数到最后,q 里存的就是第 n 阶的答案
return q;
}
}
杨辉三角
给定一个非负整数 numRows, 生成「杨辉三角」的前 *numRows*行。
在**「杨辉三角」**中,每个数是它左上方和右上方的数的和。

解题:
和爬楼梯一模一样,我只需要知道上一行的数相加即为当前位置的数字
java
class Solution {
public List<List<Integer>> generate(int numRows) {
// 1. 创建大仓库:用来存放所有"阶段性记录"的结果
List<List<Integer>> res = new ArrayList<>();
// 2. 外层循环:代表"阶段",即每一行
for (int i = 0; i < numRows; i++) {
// 创建当前行的记录单
List<Integer> row = new ArrayList<>();
for (int j = 0; j <= i; j++) {
// 每一行的开头(j=0)和末尾(j=i)必然是 1
if (j == 0 || j == i) {
row.add(1);
} else {
//不需要重新数,只需要查阅上一行(res.get(i - 1))
List<Integer> prevRow = res.get(i - 1);
// 当前数字 = 左上方(j-1) + 正上方(j)
// 这里的相加,就是把之前拆解并记录的小结果拼成新结果
int num = prevRow.get(j - 1) + prevRow.get(j);
row.add(num);
}
}
// 保存当前阶段的记录:
// 这一行填满了,我们就"不管"它里面的具体相加过程了
// 把它存入仓库 res,作为下一行(i+1)计算时的"因"
res.add(row);
}
return res;
}
}
2. 线性选择:打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。

解题:
看起来和上两题不一样,但其实核心依旧是动态规划。
但是它的"相加"不是盲目的数到10粒就要,而是带有"如果...就..."的选择。
而题目的选择就是:
如果我要当前房间,后果是我前后的房间都不能拿。
题目要求的是能够偷窃到的最高金额,核心思路就是看单个房间,偷了亏不亏。
我怎么知道的偷了亏不亏?
如果我不拿 我就能就拿着前一间的钱
如果我拿了 我只能拿前前房间和当前房间的钱
java
class Solution {
public int rob(int[] nums) {
// 基准情况:如果没房可偷,记录就是 0
if (nums == null || nums.length == 0) {
return 0;
}
int n = nums.length;
// 只有一间房,记录就是这间房的钱
if (n == 1) {
return nums[0];
}
// 偷到"前前一间房"时的最大收益
int prev2 = nums[0];
// 偷到"前一间房"时的最大收益
// 此时有两个选择:偷第1间或第2间,我们要记录钱多的那个
int prev1 = Math.max(nums[0], nums[1]);
for (int i = 2; i < n; i++) {
// 核心决策逻辑:
// 此时你站在第 i 间房门前,你有两个方案:
// 方案 A:不偷这间。那么我的总钱数就是"前一间房"记录的最高金额 (prev1)。
// 方案 B:偷这间。那么我必须放弃"前一间",拿"前前一间"记录的钱 (prev2) 加上这一间的钱 (nums[i])。
// 我们只记录这两个方案中"钱更多"的那个结果!
int current = Math.max(prev1, prev2 + nums[i]);
// 既然已经算出了当前的最高金额 current,
// 那么原来的"前前一间 (prev2)"记录就彻底作废了,我们把它扔掉。
prev2 = prev1; // 原来的"前一间"变成现在的"前前一间"
prev1 = current; // 原来的"当前"变成现在的"前一间"
}
// 数到最后一间房,prev1 里存的就是整条街能偷到的最高金额
return prev1;
}
}