在算法刷题的道路上,动态规划(Dynamic Programming,简称 DP) 绝对是无数程序员爱恨交织的一座大山。很多人在初学 DP 时,往往会被各种复杂的递推公式、晦涩的转移方程劝退,觉得这种算法高不可攀。
其实,动态规划的核心思想并没有想象中那么神秘。它不是一种死板的公式,而是一种"用空间换时间、通过解决子问题来解决大问题"的极其优雅的工程思维。
1. 底层逻辑:什么是动态规划?
要理解动态规划,我们不妨先把它拆开来看。
- 动态(Dynamic): 意味着事物的状态是随着某些变量(如步数、时间、位置)的变化而不断推移和演变的。
- 规划(Programming): 指的是一种寻找最优策略、填表决策的过程(这里的 Programming 最早源于数学中的"线性规划",而非单纯的写代码)。
在实际算法场景中,动态规划通常用于解决求最值、求可行性、求总方案数 的问题。它的本质可以用八个字来概括:拆分问题,记忆历史。
动态规划 vs 分治算法 vs 暴利递归
为了说透 DP 的底层逻辑,我们看一看它与其他经典算法思维的区别:
- 暴力递归: 自顶向下。大问题拆成子问题,子问题再拆分。缺点是存在大量的重叠子问题,同一个子问题会被重复计算成千上万次,导致时间复杂度爆炸。
- 分治算法: 自顶向下。同样将大问题拆成子问题,但分治法要求子问题之间是相互独立的(例如归并排序,左半边排序和右半边排序互不影响)。
- 动态规划: 自底向上(或带备忘录的自顶向下)。大问题拆成的子问题之间紧密相关(重叠)。为了避免重复计算,DP 选择将已经计算过的子问题答案保存起来(通常存入一个数组中),后续遇到时直接查表拿结果。
动态规划的核心基石:
如果一个大问题的最优解可以由其子问题的最优解推导而来,我们就称这个问题具备最优子结构 。而如果子问题在推导过程中被反复调用,这就是重叠子问题。这两点,正是采用动态规划的硬性前提。
2. 工程方法论:动态规划的"五步分析法"
很多同学拿到一道 DP 题目,盯着白板半天敲不出第一行代码,核心原因在于缺乏一套标准化的思考链路。无论是基础题还是高难度的背包问题,业界普遍推崇"DP 五步法"。只要写代码前严格按照这五步梳理,任何 DP 问题都能迎刃而解:
- 确定 dp 数组(dp table)以及下标的含义: 明确 dpidpidpi 到底代表什么业务数值?iii 又代表什么维度?
- 确定状态转移方程: 这是整个 DP 的核心。思考 dpidpidpi 是如何由前面的状态(如 dpi−1dpi-1dpi−1, dpi−2dpi-2dpi−2)通过怎样的数学关系推导出来的?
- dp 数组如何初始化: 递推的起点是什么?如果初始状态(如 dp0dp0dp0, dp1dp1dp1)错了,后面所有的递推结果全盘皆输。
- 确定遍历顺序: 是从前向后遍历,还是从后向前遍历?是一维遍历还是嵌套循环?
- 举例推导 dp 数组(打印日志): 当你的代码执行结果不对时,在脑海里或者代码里把 dpdpdp 数组的具体数值打印出来,看看它是否符合预期的数学逻辑。
下面,我们就带着这套极具实战价值的"五步法",逐一通关三道经典的动态规划基础题。
3. 实战演练一:LeetCode 509. 斐波那契数
斐波那契数列(Fibonacci Sequence)是数学和算法中家喻户晓的经典序列:0, 1, 1, 2, 3, 5, 8, 13... 它的规律非常直观:从第三项开始,每一项都等于前两项之和。
动规五步法深度拆解:
- 第一步:确定 dp 数组及下标的含义
我们需要求第 nnn 个斐波那契数的值。因此可以定义一个一维数组 dpdpdp。dpidpidpi 的含义就是:第 iii 个斐波那契数的值。 - 第二步:确定状态转移方程
根据题目给出的严格数学定义,第 iii 项由第 i−1i-1i−1 项和第 i−2i-2i−2 项决定。所以状态转移方程极其直接:
dpi=dpi−1+dpi−2dpi = dpi-1 + dpi-2dpi=dpi−1+dpi−2
- 第三步:dp 数组如何初始化
由于状态转移方程在计算 dpidpidpi 时需要用到前两项,因此我们必须预先给出前两项的固定值:
dp0=0dp0 = 0dp0=0, dp1=1dp1 = 1dp1=1。 - 第四步:确定遍历顺序
从状态转移方程可以看出,dpidpidpi 依赖于 dpi−1dpi-1dpi−1 和 dpi−2dpi-2dpi−2。也就是说,必须先有前面的状态,才能推导出后面的状态。因此,遍历顺序必然是从前向后 ,从 i=2i = 2i=2 开始遍历到 nnn。 - 第五步:举例推导 dp 数组
当 n=5n = 5n=5 时,按照方程递推出来的 dpdpdp 数组应当是:[0, 1, 1, 2, 3, 5]。如果代码输出了其他值,说明递推或初始化有误。
完备 Java 代码实现:
java
class Solution {
public int fib(int n) {
// 边界条件处理
if (n <= 1) return n;
// 1. 确定 dp 数组及大小(需要能容纳下标 n,所以大小为 n + 1)
int[] dp = new int[n + 1];
// 3. 初始化起点状态
dp[0] = 0;
dp[1] = 1;
// 4. 从前向后遍历
for (int i = 2; i <= n; i++) {
// 2. 状态转移方程
dp[i] = dp[i - 1] + dp[i - 2];
}
// 返回最终所需状态
return dp[n];
}
}
4. 实战演练二:LeetCode 70. 爬楼梯
爬楼梯问题是斐波那契数在实际业务场景中的经典"变体"。题目要求:假设你正在爬楼梯。需要 nnn 阶你才能到达楼顶。每次你可以爬 111 或 222 个台阶。你有多少种不同的方法可以爬到楼顶?
引入动态规划的破题思考:
拿到题目,我们不要去想复杂的宏观排列组合,而是要把目光聚焦在到达终点的最后一步 。
你要到第 nnn 阶台阶,最后一步是怎么走上来的?
- 情况一:你站在第 n−1n-1n−1 阶,向上跨了 111 步到达第 nnn 阶。
- 情况二:你站在第 n−2n-2n−2 阶,向上跨了 222 步到达第 nnn 阶。
除了这两种情况,没有别的可能。因此,到达第 nnn 阶的方法数,就等于到达第 n−1n-1n−1 阶的方法数加上到达第 n−2n-2n−2 阶的方法数。这就是最优子结构。
动规五步法深度拆解:
- 第一步:确定 dp 数组及下标的含义
定义一维数组 dpdpdp,dpidpidpi 的含义为:爬到第 iii 阶台阶,一共有 dpidpidpi 种不同的方法。 - 第二步:确定状态转移方程
如上所述,到第 iii 阶的方法由前两阶的方法数相加而来:
dpi=dpi−1+dpi−2dpi = dpi-1 + dpi-2dpi=dpi−1+dpi−2
-
第三步:dp 数组如何初始化
-
爬到第 111 阶:只有 111 种方法(直接跨一步),所以 dp1=1dp1 = 1dp1=1。
-
爬到第 222 阶:有 222 种方法(跨两次一步,或者直接跨两步),所以 dp2=2dp2 = 2dp2=2。
(注:在一些代码实现中,为了方便数组下标对齐,会令 dp0=1,dp1=1dp0 = 1, dp1 = 1dp0=1,dp1=1,其数学递推结果也是完全一致的。)
-
第四步:确定遍历顺序
由于 dpidpidpi 依赖前面的两项,遍历顺序依然为从前向后 ,从 i=3i = 3i=3 开始。
-
第五步:举例推导 dp 数组
当 n=4n = 4n=4 时, dp1=1,dp2=2,dp3=3,dp4=5dp1=1, dp2=2, dp3=3, dp4=5dp1=1,dp2=2,dp3=3,dp4=5。
完备 Java 代码实现:
java
class Solution {
public int climbStairs(int n) {
// 边界条件处理,防止小数值导致数组越界
if (n <= 2) return n;
// 1. 定义 dp 数组
int[] dp = new int[n + 1];
// 3. 初始化核心基础状态
dp[1] = 1;
dp[2] = 2;
// 4. 依序遍历
for (int i = 3; i <= n; i++) {
// 2. 状态转移
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
}
5. 实战演练三:LeetCode 118. 杨辉三角
杨辉三角(Pascal's Triangle)是二项式系数在三角形中的一种几何排列。它的特点是:每行端点的值为 111,每个内部数值等于它左上方和正上方两个数值之和。
与前两道一维 DP 题不同,杨辉三角是一道典型的二维动态规划基础入门题。
动规五步法深度拆解:
- 第一步:确定 dp 数组及下标的含义
杨辉三角是一个二维平面网格,因此我们需要定义一个二维数组 dpijdpijdpij。
其含义为:在杨辉三角中,第 iii 行、第 jjj 列的那个数字的具体值(行和列的下标均从 0 开始)。 - 第二步:确定状态转移方程
根据杨辉三角的规律,当前数字等于上一行的左上方加正上方。对应到矩阵坐标中: - 上一行正上方:dpi−1jdpi-1jdpi−1j
- 上一行左上方:dpi−1j−1dpi-1j-1dpi−1j−1
因此状态转移方程为:
dpij=dpi−1j+dpi−1j−1dpij = dpi-1j + dpi-1j-1dpij=dpi−1j+dpi−1j−1
- 第三步:dp 数组如何初始化
杨辉三角的边界非常特殊:每一行的开头(第 0 列)和每一行的结尾(最后一列,即 j=ij = ij=i 的位置)全都是 111。
即:dpi0=1dpi0 = 1dpi0=1 且 dpii=1dpii = 1dpii=1。这些边界不需要进行状态转移计算,应当直接初始化为 111。 - 第四步:确定遍历顺序
采用嵌套双重循环。外层循环遍历每一行(从 i=0i = 0i=0 到 numRows−1numRows - 1numRows−1),内层循环遍历当前行的每一列(从 j=0j = 0j=0 到 iii)。由于当前行依赖于上一行的状态,所以必须从上往下、从左往右遍历。 - 第五步:举例推导 dp 数组
前三行推导结果:
行 0:[1]
行 1:[1, 1]
行 2:[1, 2, 1]
完备 Java 代码实现:
在日常开发或 LeetCode 提交中,杨辉三角通常有两种写法。一种是直接利用 List 容器特性边算边存,另一种是传统的二维数组填表法。这里为了将 DP 思路贯彻得更纯粹,我们分别提供这两种解法。
解法一:纯粹的二维表格填表法(推荐,最符合标准 DP 视角)
java
class Solution {
public List<List<Integer>> generate(int numRows) {
// 1. 定义二维 dp 数组
int[][] dp = new int[numRows][numRows];
// 4. 外层循环遍历行
for (int i = 0; i < numRows; i++) {
// 3. 初始化每行的边界条件:开头和结尾必定为 1
dp[i][0] = 1;
dp[i][i] = 1;
// 内层循环遍历列(除去首尾边界,进行状态转移)
for (int j = 1; j < i; j++) {
// 2. 状态转移方程:左上方 + 正上方
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
}
}
// 将二维 dp 数组转换为题目要求的 List<List<Integer>> 返回格式
List<List<Integer>> ans = new ArrayList<>();
for (int i = 0; i < numRows; i++) {
List<Integer> row = new ArrayList<>();
for (int j = 0; j <= i; j++) {
row.add(dp[i][j]);
}
ans.add(row);
}
return ans;
}
}
解法二:直接利用结果集容器(省去数组转换,空间更紧凑)
java
class Solution {
public List<List<Integer>> generate(int numRows) {
List<List<Integer>> ans = new ArrayList<>();
for (int i = 0; i < numRows; i++) {
List<Integer> row = new ArrayList<>();
for (int j = 0; j <= i; j++) {
// 边界处理
if (j == 0 || j == i) {
row.add(1);
} else {
// 状态转移:直接从上一行的 List 中取数计算
List<Integer> prevRow = ans.get(i - 1);
int num = prevRow.get(j - 1) + prevRow.get(j);
row.add(num);
}
}
ans.add(row);
}
return ans;
}
}
6. 进阶思考:关于动态规划的"空间优化"
当理清了上述三道题的流程后,细心的同学可能会发现一个很有意思的现象:
在计算斐波那契数和爬楼梯时,为了求出 dpndpndpn,我们开辟了一个长度为 n+1n+1n+1 的完整数组。但实际上,我们在计算 dpidpidpi 的时候,仅仅用到了 dpi−1dpi-1dpi−1 和 dpi−2dpi-2dpi−2 这两个紧邻的前驱状态 。再往前的数据(比如 dpi−3dpi-3dpi−3)在以后的递推中再也不会被用到了。
这就引出了动态规划中非常经典的一个进阶技巧------滚动数组(空间优化)。
通过引入两三个变量来交替更新状态,我们可以将斐波那契数和爬楼梯的空间复杂度从 O(N)O(N)O(N) 优化到惊人的 O(1)O(1)O(1) 常数级别。这也是大厂中高频面试官非常喜欢追问的优化细节。关于"滚动数组"和"降维打击"的具体推导,我们将在后续的进阶篇中单独开篇详细推演。
总结
动态规划并不是空中楼阁,它是一种极致的工程落地思维。通过把长远的问题拆解,并在每一步都稳稳扎根于历史数据,从而做出了全局的最优决策。
今天探讨的三道经典题,由于状态转移关系刚好呈现出了特殊的代数结构,其本质都指向了线性递推的逻辑。它们是整个动态规划帝国的地基。
- 遇到一维线性问题,优先考虑一维 dpdpdp 数组。
- 遇到平面、网格或者图逻辑,大胆开辟二维 dpdpdp 表格。
- 牢记"五步法":定含义、写方程、初始化、看顺序、印日志。
牢牢把握住这几条基本线索,后续我们在面对更复杂的打家劫舍系列、股票买卖系列、以及大名鼎鼎的背包问题 时,才会有源源不断的破题底气。
