《动态规划-基础篇》

在算法刷题的道路上,动态规划(Dynamic Programming,简称 DP) 绝对是无数程序员爱恨交织的一座大山。很多人在初学 DP 时,往往会被各种复杂的递推公式、晦涩的转移方程劝退,觉得这种算法高不可攀。

其实,动态规划的核心思想并没有想象中那么神秘。它不是一种死板的公式,而是一种"用空间换时间、通过解决子问题来解决大问题"的极其优雅的工程思维。


1. 底层逻辑:什么是动态规划?

要理解动态规划,我们不妨先把它拆开来看。

  • 动态(Dynamic): 意味着事物的状态是随着某些变量(如步数、时间、位置)的变化而不断推移和演变的。
  • 规划(Programming): 指的是一种寻找最优策略、填表决策的过程(这里的 Programming 最早源于数学中的"线性规划",而非单纯的写代码)。

在实际算法场景中,动态规划通常用于解决求最值、求可行性、求总方案数 的问题。它的本质可以用八个字来概括:拆分问题,记忆历史

动态规划 vs 分治算法 vs 暴利递归

为了说透 DP 的底层逻辑,我们看一看它与其他经典算法思维的区别:

  • 暴力递归: 自顶向下。大问题拆成子问题,子问题再拆分。缺点是存在大量的重叠子问题,同一个子问题会被重复计算成千上万次,导致时间复杂度爆炸。
  • 分治算法: 自顶向下。同样将大问题拆成子问题,但分治法要求子问题之间是相互独立的(例如归并排序,左半边排序和右半边排序互不影响)。
  • 动态规划: 自底向上(或带备忘录的自顶向下)。大问题拆成的子问题之间紧密相关(重叠)。为了避免重复计算,DP 选择将已经计算过的子问题答案保存起来(通常存入一个数组中),后续遇到时直接查表拿结果。

动态规划的核心基石:

如果一个大问题的最优解可以由其子问题的最优解推导而来,我们就称这个问题具备最优子结构 。而如果子问题在推导过程中被反复调用,这就是重叠子问题。这两点,正是采用动态规划的硬性前提。


2. 工程方法论:动态规划的"五步分析法"

很多同学拿到一道 DP 题目,盯着白板半天敲不出第一行代码,核心原因在于缺乏一套标准化的思考链路。无论是基础题还是高难度的背包问题,业界普遍推崇"DP 五步法"。只要写代码前严格按照这五步梳理,任何 DP 问题都能迎刃而解:

  1. 确定 dp 数组(dp table)以及下标的含义: 明确 dpidpidpi 到底代表什么业务数值?iii 又代表什么维度?
  2. 确定状态转移方程: 这是整个 DP 的核心。思考 dpidpidpi 是如何由前面的状态(如 dpi−1dpi-1dpi−1, dpi−2dpi-2dpi−2)通过怎样的数学关系推导出来的?
  3. dp 数组如何初始化: 递推的起点是什么?如果初始状态(如 dp0dp0dp0, dp1dp1dp1)错了,后面所有的递推结果全盘皆输。
  4. 确定遍历顺序: 是从前向后遍历,还是从后向前遍历?是一维遍历还是嵌套循环?
  5. 举例推导 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 表格。
  • 牢记"五步法":定含义、写方程、初始化、看顺序、印日志。

牢牢把握住这几条基本线索,后续我们在面对更复杂的打家劫舍系列、股票买卖系列、以及大名鼎鼎的背包问题 时,才会有源源不断的破题底气。

相关推荐
进击的荆棘1 小时前
优选算法——队列+宽搜
数据结构·c++·算法·leetcode·bfs·队列
黎阳之光1 小时前
虚实同源·数智治水:黎阳之光视频孪生,重构智慧水务新范式
运维·物联网·算法·安全·数字孪生
江屿风1 小时前
C++OJ题经验总结(竞赛)4
开发语言·c++·笔记·算法·dp·双指针
Deep-w1 小时前
【MATLAB】微电网四DG逆变器下垂策略与分布式MPC协同控制仿真分析
开发语言·分布式·算法·matlab
手写码匠1 小时前
华为云Flexus+DeepSeek征文|万字实战:MaaS 推理服务 + Dify 高可用部署 + AI Agent 开发全流程
人工智能·深度学习·算法·aigc
yu85939581 小时前
基于卡尔曼滤波器的集中式机器人轨迹定位算法
算法·机器人
进击的荆棘1 小时前
优选算法——栈
数据结构·c++·算法·leetcode·
ʚ希希ɞ ྀ2 小时前
岛屿数量 -- 图论
算法·深度优先·图论
aWty_3 小时前
实分析入门(11)--Cantor三分集
学习·数学·算法·实变函数