一. 什么是动态规划
动态规划(Dynamic Programming,简称 DP)是一种求最优解的方法,通过将复杂问题分解为相对简单的问题,并存储子问题的解,以便在下次需要同一个子问题的解时直接查表 ,根据子问题的解求出原问题。核心是 "最优子结构" 和 "重叠子问题" 两个特性。
它和递归的区别在于:递归是自顶向下 重复计算子问题(先递把大问题分解成小问题,直到触达递归终止条 ),而动态规划是自底向上先解决小问题,再用小问题的解推导大问题的解,通过 "记忆化存储" 大幅降低时间复杂度。
例如,斐波那契数列定义如下:
Fib(n)=1; n=0;
Fib(n)=1; n=1;
Fib(n)=Fib(n-1)+Fib(n-2); n>1;
如果给你一个n求Fib(n),使用递归实现的算法如下:
cpp
#include <iostream>
using namespace std;
long long Fib(int n) {
if (n == 0 || n == 1) {
return 1; // 对应定义中的初始项
}
return Fib(n-1) + Fib(n-2);
}
int main() {
int n;
cout << "输入n:";
cin >> n;
cout << "Fib(" << n << ") = " << Fib(n) << endl;
return 0;
}
分析:如求Fib(4)重复的子问题(如Fib(2)被计算两次)不会共享中间结果 ,每次调用都会重新创建栈帧、重新计算,这也是基础递归性能差的根源;
动态规划求解:
cpp
#define _CRT_SECURE_NO_WARNINGS
#define MAXN 100
#include <stdio.h>
int fib3(int n)
{
int dp[MAXN];//明确了数组dp的大小为 100,此时dp数组可存储索引 0~99 的斐波那契数;
dp[0] = dp[1] = 1;
for (int i = 2; i <= n; i++)
{
dp[i]=dp[i-2]+dp[i-1];
}
return dp[n];
}
int main()
{
int a = 0;
scanf("%d", &a);
int b = fib3(a);
printf("%d", b);
}
讲解:
1. 明确的「状态定义」:DP 数组的含义
- 代码中定义了数组
dp[MAXN],其中dp[i]是明确的状态定义:表示斐波那契数列的第i项的值。 - 通过这个状态定义,我们将 "求斐波那契第
n项" 这个原问题,转化为了 "求dp[n]" 这个子问题的集合(先求dp[2]、dp[3]... 最终求dp[n])。
2. 清晰的「状态转移方程」:子问题的递推关系
状态转移方程是动态规划的核心,描述了 "如何从已解决的子问题推导出当前子问题的解":
- 代码中的
dp[i] = dp[i-2] + dp[i-1]就是标准的状态转移方程。 - 含义:第
i项斐波那契数(dp[i]),可以由它的前两项子问题(dp[i-1]和dp[i-2])的解相加得到,这正是斐波那契数列的定义,也是动态规划 "递推" 思想的体现。
3. 确定的「边界条件」:子问题的终止条件
动态规划的递推需要有 "起点",即最基础的子问题(无需再分解的问题),这就是边界条件:
- 代码中的
dp[0] = dp[1] = 1是明确的边界条件。 - 含义:第 0 项和第 1 项斐波那契数是已知的(均为 1),这两个子问题无需再通过其他子问题推导,是整个递推过程的起点,后续所有
dp[i](i≥2)都基于这两个边界值展开计算。
4. 「重叠子问题」的优化:通过 DP 数组缓存避免重复计算
这是动态规划与普通递归的核心区别,也是动态规划的效率优势所在:
- 先回顾普通递归的问题:计算
dp[4]时,需要计算dp[3]和dp[2];计算dp[3]时,又需要重复计算dp[2]和dp[1],dp[2]就是重叠子问题,会被多次计算。 - 这段代码的优化方式:通过
dp数组(相当于 "缓存容器"),将每个子问题(dp[0]到dp[n])的解只计算一次并存储在数组中。当后续计算需要用到前面的子问题解时,直接从数组中读取(无需重新计算),彻底消除了重叠子问题的重复计算,将时间复杂度从递归的O(2^n)降至O(n)。
5. 「自底向上」的求解顺序:从基础子问题到原问题
- 求解顺序:先计算最基础的边界值
dp[0]、dp[1](底层子问题),再依次计算dp[2]、dp[3]、...、dp[n](逐层向上推导),最终得到原问题dp[n]的解。 - 这种顺序无需递归调用,直接通过循环实现,既避免了递归的栈开销,也保证了每个子问题按依赖顺序被求解。
详细过程:
第 1 次循环(i=2):计算dp[2]
- 初始化循环变量
i=2,判断2 <= 4,条件成立,进入循环体; - 执行
dp[i] = dp[i-2] + dp[i-1],即dp[2] = dp[0] + dp[1]; - 代入已知值:
dp[2] = 1 + 1 = 2,将2存入数组索引 2 的位置,此时dp[2]=2; - 循环变量
i自增 1,变为3。
第 2 次循环(i=3):计算dp[3]
- 判断
3 <= 4,条件成立,进入循环体; - 执行
dp[3] = dp[1] + dp[2]; - 代入已知值:
dp[3] = 1 + 2 = 3,将3存入数组索引 3 的位置,此时dp[3]=3; - 循环变量
i自增 1,变为4。
第 3 次循环(i=4):计算dp[4]
- 判断
4 <= 4,条件成立,进入循环体; - 执行
dp[4] = dp[2] + dp[3]; - 代入已知值:
dp[4] = 2 + 3 = 5,将5存入数组索引 4 的位置,此时dp[4]=5;
循环结束
判断5 <= 4,条件不成立,跳出for循环,此时dp数组的前 5 个元素已确定:dp[0]=1、dp[1]=1、dp[2]=2、dp[3]=3、dp[4]=5,其余元素仍为垃圾值。
优化:
cpp
#define _CRT_SECURE_NO_WARNINGS
#define MAXN 100
#include <stdio.h>
//int fib3(int n)
//{
// int dp[MAXN];//明确了数组dp的大小为 100,此时dp数组可存储索引 0~99 的斐波那契数;
// dp[0] = dp[1] = 1;
// for (int i = 2; i <= n; i++)
// {
// dp[i]=dp[i-2]+dp[i-1];
// }
// return dp[n];
//}
int fib4(int n)
{
int dp [3];
dp[0] = dp[1] = 1;
for (int i = 2; i <= n; i++)
{
dp[i%3] = dp[(i - 2) % 3] + dp[(i - 1) % 3];
}
return dp[n%3];
}
int main()
{
int a = 0;
scanf("%d", &a);
int b = fib4(a);
printf("%d", b);
}
:分析
一、核心原理:斐波那契数列的递推仅依赖 "前两项"
斐波那契数列的状态转移方程是 dp[i] = dp[i-2] + dp[i-1],这意味着:
- 计算第
i项时,只需要用到第i-1项和第i-2项的值,不需要保留更早之前的所有项; - 原始
fib3用长度 100 的数组存储所有项是 "冗余" 的,我们只需要有限的存储空间来保存 "前两项" 即可,这里用长度 3 的数组是这种思想的延伸(本质和用 2 个变量迭代是一致的,只是通过数组 + 取模实现了更规整的存储切换)。
二、取模运算(i%3)的核心作用:循环复用数组空间
数组dp的长度为 3,索引为 0、1、2,i%3的作用是将递增的i(2,3,4,5...)映射到固定的数组索引(0,1,2)上,从而循环覆盖数组中 "无用" 的旧数据,实现有限空间的复用:
- 取模运算规律:对于
i≥2,i%3的结果会按2→0→1→2→0→1...的顺序循环;意思是与3的余数只能为0,1,2 不会大于3。 - 关键逻辑:
dp[i%3]存储当前第i项的值,dp[(i-1)%3]和dp[(i-2)%3]恰好能精准定位到 "前两项" 的值(因为取模映射保持了 "前两项" 的依赖关系); - 本质:用长度 3 的数组模拟了 "循环缓冲区",旧的、不再需要的数据会被新计算的值覆盖,无需额外的内存空间。
例子:fib3(4):
以n=4(求解第 4 项)为例,逐步骤验证执行过程
我们跟着代码逻辑一步步走,清晰看到数组空间的复用和计算过程:
步骤 1:初始化fib4函数,定义并初始化数组dp
int dp[3]; // 定义长度为3的数组,初始值为垃圾值
dp[0] = dp[1] = 1; // 初始化边界条件:dp[0]=1,dp[1]=1,dp[2]仍为垃圾值
此时数组状态:dp[0]=1、dp[1]=1、dp[2]=?(无用垃圾值)
步骤 2:执行for循环,i从 2 到 4(n=4)
循环条件:for (int i = 2; i <= 4; i++),共执行 3 次循环,每次通过i%3映射数组索引:
第 1 次循环:i=2,计算第 2 项
- 计算索引映射:
i%3 = 2%3 = 2(当前项存储索引)(i-2)%3 = 0%3 = 0(前第 2 项索引)(i-1)%3 = 1%3 = 1(前第 1 项索引) - 执行赋值:
dp[2] = dp[0] + dp[1] = 1 + 1 = 2 - 此时数组状态:
dp[0]=1、dp[1]=1、dp[2]=2(第 2 项值为 2)
第 2 次循环:i=3,计算第 3 项
- 计算索引映射:
i%3 = 3%3 = 0(当前项存储索引,覆盖原 dp [0])(i-2)%3 = 1%3 = 1(前第 2 项索引,对应原 dp [1]=1)(i-1)%3 = 2%3 = 2(前第 1 项索引,对应 dp [2]=2) - 执行赋值:
dp[0] = dp[1] + dp[2] = 1 + 2 = 3 - 此时数组状态:
dp[0]=3(第 3 项值,覆盖了原 1)、dp[1]=1、dp[2]=2
第 3 次循环:i=4,计算第 4 项
- 计算索引映射:
i%3 = 4%3 = 1(当前项存储索引,覆盖原 dp [1])(i-2)%3 = 2%3 = 2(前第 2 项索引,对应 dp [2]=2)(i-1)%3 = 3%3 = 0(前第 1 项索引,对应 dp [0]=3) - 执行赋值:
dp[1] = dp[2] + dp[0] = 2 + 3 = 5 - 此时数组状态:
dp[0]=3、dp[1]=5(第 4 项值,覆盖了原 1)、dp[2]=2
步骤 3:返回结果
执行return dp[n%3],n=4,4%3=1,返回dp[1]=5,与斐波那契数列第 4 项(按代码边界定义)的结果一致。
二.动态规划求解问题的类型,性质和步骤
1.求解问题的类型
:求目标函数指定的最值(最大值或者最小值)
:判断某个条件是否可行
:统计满足某个条件的方案数
2.动态规划求解问题的性质
并非所有问题都适合用动态规划求解,只有满足以下两个核心性质的问题,才能通过动态规划高效求解,这也是动态规划算法设计的前提条件:
- 最优子结构性质
问题的最优解包含其子问题的最优解。也就是说,若将原问题分解为若干个子问题,当原问题达到最优时,其包含的每个子问题也必然是最优的。这一性质是动态规划能够"自底向上推导最优解的基础------我们可以通过求解子问题的最优解,逐步组合得到原问题的最优解。例如,在最短路径问题中,从起点A到终点C的最短路径若经过中间节点B,则该路径中从A到B的子路径,必然是A到B的最短路径;若存在更短的A到B子路径,替换后可得到更短的A到C路径,与原路径是最优解矛盾,这就验证了最优子结构性质。
- 子问题重叠性质
在求解原问题的过程中,会反复遇到相同的子问题,而非每次遇到的子问题都是全新的。这一性质是动态规划能够通过"状态记忆"(如备忘录、DP数组)提升效率的关键------若子问题不重叠,动态规划与普通的分治法效率差异不大;但当子问题大量重叠时,动态规划可通过记录已求解子问题的结果,避免重复计算,将时间复杂度从穷举的指数级降低到多项式级。例如,在计算斐波那契数列时,直接递归会反复计算f(5)、f(4)等子问题,而动态规划通过DP数组记录f(1)到f(n)的结果,每个子问题仅计算一次,效率大幅提升。但重叠子问题不是动态规划算法的必备条件
- 无后效性性质
也称"状态无后效性",指当前状态的决策仅依赖于当前状态本身,与当前状态之前的决策路径无关;同时,当前决策仅影响未来的状态,对过去的状态无回溯影响。这一性质确保了动态规划的状态定义是有效的------我们可以通过"状态"封装过去的决策信息,无需关注决策路径的细节,只需基于当前状态推导后续状态。例如,在背包问题中,当前的状态定义为"已选择的物品总重量"和"已获得的总价值",无论之前选择的是哪些物品(路径不同),只要总重量和总价值相同,后续的决策(是否选择下一个物品)是完全一致的,这就是无后效性的体现。
3动态规划求解问题的步骤
动态规划的求解过程遵循固定的逻辑框架,核心是"状态定义→状态转移→边界初始化→结果推导",具体步骤可细化为以下五步:
- 问题拆解:识别子问题
首先分析原问题的结构,将其拆解为若干个规模更小、性质相同的子问题。这一步的关键是找到问题的"多阶段决策"特征,明确子问题与原问题的关联------原问题的解可由子问题的解组合得到。例如,求解"从A到C的最短路径",可拆解为"从A到B的最短路径"和"从B到C的最短路径"两个子问题;求解"n个物品的0-1背包问题",可拆解为"n-1个物品的背包问题(不选第n个物品)"和"n-1个物品的背包问题(选第n个物品,背包容量减去第n个物品的重量)"两个子问题。
- 状态定义:封装子问题的核心信息
状态是动态规划的核心,用于描述子问题的关键信息,其定义的合理性直接决定了动态规划算法的可行性与效率。状态的定义需满足"无后效性",且能完整覆盖子问题的核心约束与目标。通常用一个或多个参数来描述状态,并用DP数组(或备忘录)记录状态对应的最优解。例如:
-
0-1背包问题:状态定义为dp[i][j],表示"前i个物品中选择部分物品放入容量为j的背包,可获得的最大价值",其中i(物品数量)和j(背包容量)是状态参数,dp[i][j]是状态对应的最优解;
-
最长公共子序列问题:状态定义为dp[i][j],表示"字符串s1的前i个字符与字符串s2的前j个字符的最长公共子序列长度",i和j是状态参数,dp[i][j]是最优解。
- 状态转移方程:建立子问题与原问题的关联
状态转移方程是动态规划的"核心逻辑",用于描述当前状态的最优解如何由其他状态(子问题的最优解)推导得到。其本质是梳理"决策选择"与"状态变化"的关系------对于当前状态,枚举所有可能的决策,选择能使当前状态最优的决策,进而推导对应的状态转移。例如:
-
0-1背包问题的状态转移方程:对于第i个物品,有两种决策(选或不选)。若不选,dp[i][j] = dp[i-1][j](继承前i-1个物品在容量j下的最优价值);若选(需满足j ≥ 物品i的重量w[i]),dp[i][j] = dp[i-1][j - w[i]] + v[i](前i-1个物品在容量j - w[i]下的最优价值,加上物品i的价值)。因此,状态转移方程为:dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i])(j ≥ w[i]时),否则dp[i][j] = dp[i-1][j];
-
最长公共子序列问题的状态转移方程:若s1[i] = s2[j](当前字符相同),则dp[i][j] = dp[i-1][j-1] + 1(最长公共子序列长度加1);若s1[i] ≠ s2[j],则dp[i][j] = max(dp[i-1][j], dp[i][j-1])(取"s1前i-1个与s2前j个"或"s1前i个与s2前j-1个"的最长长度)。
- 边界条件初始化:确定最小子问题的解
边界条件是状态转移的"起点",对应规模最小的子问题(无法再拆解的子问题),其最优解是已知的,无需通过状态转移推导。边界条件的初始化直接影响后续所有状态的计算准确性,需结合问题场景明确。例如:
-
0-1背包问题的边界:当i=0(无物品可选)时,无论背包容量j多大,最大价值均为0,即dp[0][j] = 0;当j=0(背包容量为0,无法装任何物品)时,无论有多少物品,最大价值均为0,即dp[i][0] = 0;
-
最长公共子序列问题的边界:当i=0(s1为空字符串)或j=0(s2为空字符串)时,最长公共子序列长度为0,即dp[0][j] = 0、dp[i][0] = 0。
- 计算最优解:自底向上或自顶向下推导
根据状态转移方程和边界条件,通过"自底向上"或"自顶向下"的方式计算所有状态的最优解,最终得到原问题的最优解。
-
自底向上(迭代法):从边界条件出发,按照子问题规模从小到大的顺序,逐步计算所有中间状态的最优解,最终推导到原问题对应的状态。这种方式通常使用DP数组实现,效率较高,无递归栈开销。例如,0-1背包问题中,先计算i=1(第一个物品)在不同j下的dp[1][j],再计算i=2(前两个物品)的dp[2][j],直至计算到i=n(所有物品)、j=V(背包总容量)的dp[n][V],即为原问题的最优解;
-
自顶向下(递归+备忘录法):从原问题出发,通过递归拆解为子问题,若子问题已求解则直接调用备忘录中的结果,未求解则计算后存入备忘录。这种方式更符合问题的拆解逻辑,适用于子问题规模不均匀的场景,但需注意递归栈溢出问题。例如,计算斐波那契数列的f(n)时,递归调用f(n-1)和f(n-2),通过备忘录记录已计算的f(k),避免重复计算。
此外,部分问题在计算完成后,可能需要根据DP数组回溯推导具体的最优方案(如背包问题中选择了哪些物品、最长公共子序列的具体字符),这一步需结合状态转移方程的逻辑,反向推导决策路径。
二.实战
1.最长公共子序列
给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
- 例如,
"ace"是"abcde"的子序列,但"aec"不是"abcde"的子序列。
两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。
示例 1:
输入:text1 = "abcde", text2 = "ace"
输出:3
解释:最长公共子序列是 "ace" ,它的长度为 3 。
示例 2:
输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc" ,它的长度为 3 。
示例 3:
输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0 。
思路: