动态规划算法(DP)初步讲解

动态规划算法(DP)初步讲解

1. 动态规划是什么?

动态规划(Dynamic Programming,简称 DP)是一种经典且核心的算法优化思想,并非固定不变的代码模板,专门用于求解具有重复子问题和最优子结构的最优化问题,是算法竞赛、计算机考研、软件开发刷题中必备的核心算法能力。

很多复杂的计数、求最值、求最优方案问题,用普通暴力枚举、单纯递归求解会出现计算量爆炸、运行超时、逻辑冗余等问题,而动态规划就是专门解决这类痛点的最优解法。

核心一句话精准概括:

把一个难以直接求解的复杂大问题,逐层拆解为多个规模更小、结构相同、会重复出现的简单子问题;所有子问题只计算一次,计算完成后将结果存储起来,后续遇到相同子问题直接调取已存答案,无需重复计算,以少量内存空间为代价,换取算法运行时间的大幅缩减,最终通过子问题的最优解推导出原大问题的全局最优解。

💡 三种常见解题方式全方位深度对比:

  • 暴力枚举法:穷尽问题所有可能的情况,逐一计算筛选答案。缺点是不做任何问题拆解和结果留存,无论子问题是否重复都反复计算,时间复杂度极高,数据规模稍大就会直接超时,仅能适配极小数据量的简单问题。

  • 普通递归解法:遵循自上而下的思路,将大问题自动拆分子问题,但没有任何结果存储机制,相同子问题会被递归函数反复调用、重复计算,时间复杂度呈指数级增长,数据规模稍大就会出现运算超时、程序卡顿甚至递归栈溢出问题。

  • 动态规划解法:兼顾问题拆解和结果存储两大核心,拆分逻辑和递归思路一致,但新增子问题结果缓存机制,每个子问题严格只计算一次,后续重复使用直接查表调取,时间复杂度大幅优化至线性或平方级别,适配绝大多数最优解、计数类算法问题,稳定性和效率远超前两种方式。


2. 动态规划必须满足的两个核心适用条件

并非所有算法问题都能使用动态规划求解,一个问题想要用DP解法,必须同时满足以下两个硬性核心条件,缺一不可,这也是判断能否用DP解题的第一步,做题前优先判断,避免盲目套用DP模板白费功夫。

条件一:重叠子问题

重叠子问题指的是:将原始复杂大问题不断拆解为若干底层子问题后,多个不同的上级大问题,会重复调用、依赖同一个底层子问题的计算结果,子问题具备高频重复出现的特性。

如果一个问题拆解后的所有子问题都是独一无二、仅需计算一次的,就不存在重复计算的冗余,无需使用动态规划,普通递归求解即可。只有子问题大量重叠重复,DP的“存结果、免重算”优化价值才能体现。

经典典型举例:求解斐波那契数列第n项数值,计算f(5)需要f(4)和f(3),计算f(4)需要f(3)和f(2),其中子问题f(3)会被反复调用计算几十上百次,这就是典型的重叠子问题,用普通递归会重复无效计算,用DP存储结果就能直接规避冗余。

条件二:最优子结构

最优子结构指的是:原始整个复杂大问题的全局最优解,不需要额外特殊调整,完全可以由它拆解出来的所有子问题的局部最优解,直接简单组合、递推推导得出

简单通俗解读:局部最优能够直接叠加推导全局最优,子问题的最优状态就是构建大问题最优状态的核心基石,不存在子问题最优但大问题不最优的特殊情况。

经典典型举例:网格最短路径问题,从起点走到终点的全局最短路径,一定等于走到前一个相邻网格点的局部最短路径,加上当前这一步的固定行走距离,只要每个子路段保持最短,整体全程必然最短,完美符合最优子结构特性。反之,如果局部最优无法推导全局最优,就不能用DP求解。


3. 动态规划四大核心必备要素

无论一维简单DP、二维进阶DP,还是区间DP、状态压缩DP等高阶题型,所有动态规划题目解题的核心核心,全部围绕以下四大要素展开,搞定这四点,任何DP题目都能稳步拆解求解,这也是写作业、答题时必须写明的核心步骤。

① 状态定义

状态定义是动态规划解题的第一步,也是最难的一步。需要我们根据题目所求目标,自主定义一维或二维dp数组,清晰、精准标注dp数组中每一个变量、每一个下标对应的具体实际含义,不能模糊笼统,否则后续转移方程和计算都会全部出错。

常规常用状态定义举例,直观易懂:

  • dp\[i\](一维DP通用):代表处理前i个目标元素、或走到第i个位置时,对应的最优解、总方案数、最大价值等题目所求目标结果。例如爬楼梯问题中,dp[i]专属定义为爬到第i阶楼梯的所有可行行走方法总数。

  • dp\[i\]\[j\](二维DP通用):代表仅考虑前i个物品/元素,同时满足j个限制条件(如背包容量、步数、长度等)时,对应的最优目标结果。例如01背包问题中,dp[i][j]专属定义为选取前i件物品,背包最大容量为j时,能够装载的物品最大总价值。

② 状态转移方程

状态转移方程是动态规划的运算核心,简单来说就是数学计算公式,作用是根据已经提前计算完成、存储在dp数组中的前期已知子问题状态,推导计算出当前未知的最新状态结果。

通俗理解核心逻辑:当前dp状态的值,不需要单独复杂计算,直接由前面已经算好的若干个旧dp状态,通过加减、取最大值、取最小值等简单运算组合得出,这个组合运算的公式就是状态转移方程。

③ 初始边界条件

初始边界条件是动态规划中规模最小、无法再继续向下拆分的基础子问题,这类子问题没有前置依赖状态,无法通过转移方程计算得出,必须根据题目实际含义手动提前赋值初始化。

核心两大作用:一是为后续循环递推计算提供基础起点,让DP循环能够正常启动运算;二是有效避免数组下标越界、逻辑计算为空、初始数值错误等问题,是DP计算不出错的关键保障。常见边界情况包括下标为0、数量为0、容量为0、第一个元素等基础初始状态。

④ 最终答案定位

完成所有循环递推计算后,dp数组会存储所有子问题和中间状态的结果,需要根据最初的状态定义,精准确定最终所求的全局大问题答案对应dp数组中的哪一个下标位置的值,直接输出该值即可,切勿选错下标导致解题结果错误。


4. 动态规划两种核心实现方式

动态规划的核心思想统一,但代码实现分为两种主流方式,两种方式解题逻辑一致,只是计算顺序不同,分别适配不同场景,考试、刷题、工程开发中优先推荐自底向上迭代循环方式。

方式一:自顶向下(记忆化递归实现)

核心解题思路:完全贴合人脑思考逻辑,从最终需要求解的复杂大问题出发,自上而下不断递归拆分,直到拆解到无法拆分的基础边界子问题;每计算完成一个子问题,就立刻将结果存入数组或哈希表缓存,后续再次需要该子问题结果时,直接调取缓存数据,不再重复递归计算。

  • 核心优点:解题思考逻辑简单直观,和日常拆解问题的思维一致,不需要复杂循环设计,上手容易,适合新手理解DP核心思想。

  • 核心缺点:依赖编程语言的递归机制,当数据规模过大、递归层数过多时,极易出现递归栈溢出问题,运行效率低于迭代写法,不适合考试刷题和大数据量场景使用。

方式二:自底向上(迭代循环实现)

核心解题思路:和递归思路相反,自下而上逐步计算,先提前算好规模最小、最简单的基础边界子问题,将结果存入dp数组,再按照从小到大的规模顺序,通过循环不断递推计算规模更大的子问题,层层向上推导,最终算出原始复杂大问题的全局最优答案

  • 运行速度最快:全程循环运算,无递归调用开销,运算效率极高。

  • 稳定性强:无递归栈溢出风险,适配所有数据规模的DP题目。

  • 支持空间优化:可根据转移方程特性,压缩dp数组空间,减少内存占用。


5. 入门例题一:爬楼梯

题目描述

有一段一共有n阶的楼梯,某人每次爬楼梯只能选择两种操作:要么一次性爬1阶楼梯,要么一次性爬2阶楼梯。要求计算爬到楼顶第n阶楼梯,一共有多少种不重复的可行行走方法总数。

1)状态定义

严格按照DP规范定义:dp[i] 表示一个人稳稳爬到第 i 阶楼梯时,所有不重复的可行行走方法总数量。

2)规律深度推导逻辑

想要最终成功爬到第 i 阶楼梯,到达当前台阶的最后一步只有且仅有两种合法可能,没有其他任何情况:

  1. 第一种可能:前期已经稳稳爬到第 i-1 阶楼梯,最后一步直接爬1阶,即可到达第i阶。

  2. 第二种可能:前期已经稳稳爬到第 i-2 阶楼梯,最后一步直接爬2阶,即可到达第i阶。

爬到第i阶的所有方法,就是以上两种前置情况的方法数量直接相加,两种路径互不冲突、没有重叠,累加后就是当前台阶的总方法数。

3)状态转移方程

dp[i] = dp[i-1] + dp[i-2]

4)初始边界条件手动赋值

dp[1] = 1:楼梯只有1阶时,只有唯一1种方法,直接爬1阶即可到达。

dp[2] = 2:楼梯有2阶时,有两种可行方法,分两次各爬1阶、或一次性直接爬2阶。

5)手动填表逐步计算(以n=5为例)

dp[3] = dp[2] + dp[1] = 2 + 1 = 3

dp[4] = dp[3] + dp[2] = 3 + 2 = 5

dp[5] = dp[4] + dp[3] = 5 + 3 = 8

6)最终结果

爬到第 5 阶楼梯,一共存在8种不重复的可行行走方法

7)完整可运行代码(C++带详细注释)

#include <iostream>
using namespace std;

int main() {
    int n = 5; // 目标楼梯总阶数
    int dp[n + 1]; // 定义DP数组,dp[i]存储爬到第i阶楼梯的方法总数

    // 初始化DP初始边界条件(基础子问题,手动赋值)
    dp[1] = 1; // 1阶楼梯,只有1种爬法
    if (n >= 2) {
        dp[2] = 2; // 2阶楼梯,有2种爬法
    }

    // 自底向上循环递推,从小到大计算每个阶数的方法数
    for (int i = 3; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2]; // 套用一维DP状态转移方程
    }

    // 输出最终全局最优结果
    cout << "爬到第" << n << "阶楼梯的方法总数:" << dp[n] << endl;
    return 0;
}

6. 入门例题二:01背包

题目描述

给定一个固定最大容量的背包,同时提供若干件不同的物品,每件物品具备专属的固定重量和固定价值,每件物品只有两种选择:要么装入背包,要么不装入背包,且每件物品最多只能选一次,不能重复选取。要求合理选择物品装入背包,在不超过背包最大容量的前提下,求出背包能够装载的物品最大总价值。

1)状态定义

严格规范定义:dp\[i\]\[j\] 表示只考虑前 i 件物品可供选择,背包当前最大承载容量为 j 时,背包能够装入物品的最大总价值。

2)每个物品仅有的两种核心选择分析

  1. 不选择装入当前第i件物品:当前背包最大价值和不考虑这件物品时的价值一致,直接沿用前i-1件物品、容量j的最优结果,即dp[i][j] = dp[i-1][j]。

  2. 选择装入当前第i件物品(仅背包容量足够装下才可选择):装入后需要预留当前物品重量的空间,剩余容量对应的最优价值加上当前物品自身价值,就是装入后的总价值。

3)状态转移方程分情况判定

如果背包当前容量装不下当前物品(j < 物品重量w):只能不选当前物品

dp[i][j] =dp[i - 1][j]

如果背包当前容量可以装下当前物品(j ≥ 物品重量w):选与不选当前物品,取价值更大的方案

dp[i][j] = max(dp[i-1][j], dp[i-1][j-w]+v)

4)初始边界条件

没有任何物品可供选择(i=0),或背包容量为0无法装任何物品(j=0)时,无论何种情况,背包能装的物品总价值全部为0,即dp[0][j]=0、dp[i][0]=0。默认初始化二维dp数组时,C++全局数组默认初始值为0,无需手动额外赋值,直接满足边界条件。

5)01背包完整C++实操代码

#include <iostream>
#include <algorithm> // 调用max函数必备头文件
using namespace std;

int main() {
    int goodsNum = 3; // 物品总数量
    int bagVolume = 5; // 背包最大承载容量

    // 数组分别存储每件物品的重量、对应价值
    int weight[] = {0, 2, 3, 4}; // 下标从1开始,适配DP状态定义
    int value[] = {0, 3, 4, 5};

    // 定义二维DP数组:dp[i][j]前i件物品,容量j的最大价值
    int dp[goodsNum + 1][bagVolume + 1] = {0};

    // 自底向上遍历所有物品和所有背包容量,递推计算DP状态
    for (int i = 1; i <= goodsNum; i++) { // 遍历每一件物品
        for (int j = 1; j <= bagVolume; j++) { // 遍历每一种背包容量
            if (j < weight[i]) {
                // 背包容量不足,装不下当前物品,只能不选
                dp[i][j] = dp[i - 1][j];
            } else {
                // 容量足够,选与不选当前物品,取价值最大值
                dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
            }
        }
    }

    // 输出最终背包不超容量的最大总价值
    cout << "01背包可装载物品最大总价值:" << dp[goodsNum][bagVolume] << endl;
    return 0;
}

7. 动态规划万能解题五步模板

  1. 第一步:判断是否适用DP解法:核对题目是否同时具备重叠子问题和最优子结构两大核心条件,满足即可用DP,不满足则更换其他算法。

  2. 第二步:精准定义DP状态:清晰写明一维或二维dp数组的具体含义,明确每个下标对应的实际问题场景,状态定义精准无误。

  3. 第三步:推导状态转移方程:根据题目规则和状态含义,分析当前状态与前置历史状态的关联,推导出专属运算转移公式。

  4. 第四步:设置初始边界条件:手动初始化最小基础子问题的数值,为循环计算提供起点,规避数组越界和计算错误。

  5. 第五步:循环递推计算并输出答案:按照自底向上顺序循环运算,填充完整dp数组,根据状态定义定位并输出最终所求全局最优解。

8. 动态规划常见易错点与避坑注意事项

  • 状态定义切勿模糊笼统,状态含义写错,后续所有转移方程和计算全部无效,是最常见的扣分点。

  • 边界条件不要遗漏初始化,未赋值边界会导致循环初始计算错误,结果完全偏离正确答案。

  • 循环遍历顺序不能随意更改,一维DP、二维DP均需严格遵循自底向上、先算子问题再算大问题的顺序。

  • 区分题目是求最大值、最小值还是计数方案数,对应转移方程选用max、min或累加运算,切勿混用运算逻辑。

  • 不要盲目套用DP模板,做题第一步先判断适用条件,简单问题无需强行使用动态规划,避免过度复杂化。

9. 动态规划核心总结

动态规划 = 大事化小拆解问题 + 子问题算过就永久存储 + 自底向上查表递推 + 拒绝重复无效计算 + 局部最优推导全局最优

posted @ 2026-04-25 22:57  阿尹想学会C++  阅读(41)  评论(0)    收藏  举报