前言
动态规划是一种高效解决重叠子问题和最优子结构问题的算法思想。它通过分治+记忆化,将复杂问题分解为子问题,并存储中间结果,避免重复计算,从而大幅提升效率。
为什么重要?
- 优化暴力解法:如斐波那契数列,递归复杂度为O(2n),而动态规划可优化至O(n)。
- 解决经典难题:如背包问题、最短路径、编辑距离等,动态规划往往是最优解法。
- 广泛应用:从算法竞赛到实际开发(如资源调度、股票交易策略),动态规划都是核心工具之一。
掌握动态规划,能让你在算法设计与优化中事半功倍!
动态规划流程
我个人是觉得动态规划是相当难的,因为我不太擅长找规律
动态规划就像是我们熟知的找规律,通过已知项得出一个规律
再用这个规律,套用到已知项上,得出未知项
虽然难,但动态规划也有自己的模板,让你看到一个动态规划有个思考方向
动态规划流程
- 创建dp表,确定状态表示
- 确定状态转移方程
- 初始化
- 顺序填充
- 确定返回值
现在,分别介绍一下
创建dp表,并确定状态表示
DP表(动态规划表)是动态规划算法的核心工具。
它本质是一个数组,其中的每一个元素都是一个解
但这个解的意义是未知的,需要我们自己去规定,即解的状态表示
直接地
例如:Fn = Fn-1 + Fn-2
- 我们创建一个dp表,此时dp[i]表示的值就是Fi的值
间接地
例如:求字符串中一个连续的无重复元素子串的最大长度
- 我们创建一个dp表,此时dp[i]表示的值就是以i位置为结尾的无重复元素子串的最大长度
确定状态转移方程
如何从已知的dp[i]得到未知的dp[i],就需要得出状态转移方程
这就是找规律,也是动态规划最难的一部分,得出正确的状态转移方程,是解决问题的关键部分
有些状态转移方程是明着告诉你的,有些则需要自己去找
这一步相当考验你的经验,解决这一步的唯一方法:多练多思考多画图
顺序填充
dp表的数据元素代表解,我们求解问题,就是求出指定dp[i]
用状态转移方程求出dp[i],可能需要dp[i-1]、dp[i-2]等一个或者多个
有了前面,才有后面,因此必须顺序填充
例如:Fn=Fn-1+Fn-2
- 我们求一个dp[i],就需要先知道dp[i-1]和dp[i-2]的长度
初始化
进行顺序填充前,需要先做好准备工作,防止顺序填充的时候遇到错误
例如:Fn = Fn-1+Fn-2
当你填充dp[1]的时候,你就会发现根本没有所谓的F1-2和F1-1,这就是越界错误
我们需要用初始化来避免越界错误
确定返回值
具体情况具体分析,根据题目要求确定返回值,
例如:得出第几项的值
- 这时候直接返回dp[i]即可
再者:求字符串中一个连续的无重复元素子串的最大长度
- 这时候就需要返回最大的dp[i]
动态规划题型
斐波那契数列模型
斐波那契数列就是求出第几个斐波那契数的值
这是最简单的一类动态规划题型,明明白白地告诉你怎么创建dp表,怎么进行状态表示
属于直接明牌了
流程解决:
- 创建dp表:创建一个大小为n+1的dp表,dp[i]表示的值即为F(i)
- 确定状态转移方程:F(n) = F(n-1) + F(n-2)
- 初始化:dp[0]=0,dp[1]=1
- 顺序填充:从左往右依次填充
- 确定返回值:dp[n]表示的值就是F(n)的值,返回dp[n]即可
代码如下
class Solution {
public:
int fib(int n) {
if(n==0) return 0;
if(n==1) return 1;
//创建dp表
vector<int> dp(n+1);
//初始化
dp[0]=0;
dp[1]=1;
//顺序填充
for(int i=2;i<=n;i++){
dp[i]=dp[i-1]+dp[i-2];
}
//返回结果
return dp[n];
}
};
路径问题
求出到达目标地点有多少种方式
这种就是需要自己来找规律了
经典例题:不同路径
流程解决:
创建dp表,确定状态表示
- 有多少个格子,dp表就需要多大,即dp表的大小就是m*n
- 状态表示有个技巧,题目最后要什么,你就表示什么,要求返回抵达最后一个位置的所有路径总数,则dp[i][j]代表的就是这个抵达这个位置的所有路径总数
确定状态转移方程
这里的状态转移方程就没有明示了,需要我们自己去找
但也不难到达一个格子只有两种方式,从正上方的格子下来,从左方的格子过来
而到达当前格子的正上方格子有多种方式,到达左方格子也有多种方式
因此,到达当前格子总路径数 = 到达上方格子的路径数 + 到达右方格子的路径数
状态转移方程:dp[i][j] = dp[i][j-1] + dp[i-1][j]
初始化
- dp[0][0]等于1
顺序填充
- 从左往右,从上往下进行填充
- 填充的时候,需要注意左方格子和上方格子是否存在,进行取舍
确定返回值
- 返回最后一个格子对应的dp[i][j]即可
代码:
class Solution {
public:
int uniquePaths(int m, int n) {
//创建dp表
vector<vector<int>> dp(m,vector<int>(n,0));
//初始化
dp[0][0]=1;
for(int i=0;i<m;i++){
for(int j=0;j<n;j++){
if(i==0 && j!=0){
dp[i][j]+=dp[i][j-1];
}if(j==0 && i!=0){
dp[i][j]+=dp[i-1][j];
}else if(i!=0 && j!=0){
dp[i][j]=dp[i-1][j]+dp[i][j-1];
}
}
}
return dp[m-1][n-1];
}
};
简单多状态
在常规动态规划问题中,每个子问题通常只需一个状态表示(如dp[i]
)。但在多状态DP中,每个步骤需要维护多个并行的状态,通过它们之间的关系推导最终解。
典型特征:
- 问题在每个步骤有多种可能的状态(如"持有/未持有股票"、"偷/不偷当前房屋")
- 需要为每种状态单独建立DP表或状态变量
- 状态之间存在相互转移关系
这是我个人问题有点难度的题型,因为你需要考虑多种状态下的状态表示
经典例题:打家劫舍
流程解决:
创建dp表
- 创建dp表,dp表的大小即为给定房屋的个数,即为n
- 状态表示:dp[i]表示偷到当前房屋时的最大金额数
但此时房屋可能被偷,也可能没有被偷!
- 被偷时,该房屋的最大金额数应该加上当前房屋的金额
- 没有被偷时,该房屋的最大金额数则不应该加上当前房屋的金额
而一张dp表,是无法表示偷、不偷两种状态下的值的
因此,需要两种dp表
- dpf表,dpf[i]表示当前房屋被偷后,所获得的最大金额
- dpg表,dpg[i]表示当前房屋没有被偷时,所获得的最大金额
确定状态转移方程
当前房屋被偷
- 相邻的房屋一定没有被偷
dpf状态转移方程:dpf[i] = dpg[i] +nums[i];
当前房屋没有被偷,相邻的房屋可能被偷,也可能没有被偷
- 上一个房屋没有被偷时,状态转移方程:dpg[i] = dpg[i-1];
- 上一个房屋被偷时,状态转移方程:dpg[i] = dpf[i-1]
dpg[i]表示当前房屋没有被偷时的最大金额,因此取两者最大即可
dpg状态转移方程:dpg[i] = max(dpg[i-1],dpf[i-1])
初始化
从开始位置开始
- 偷,dpf[0] = nums[0]
- 没偷,dpg[0] = 0;
顺序填充
- 从左到右,依次填充两个dp表
确定返回值
- 返回最后一个房屋的最大金额即可,即max(dpf[n-1],dpg[n-1])
代码:
class Solution {
public:
int rob(vector<int>& nums) {
if(nums.size()==0) return 0;
//创建dp表
int n = nums.size();
vector<int> f(n);
auto g = f;
//初始化
f[0]=nums[0];
g[0]=0;
//顺序填充
for(int i = 1;i<n;i++){
f[i]=g[i-1]+nums[i];
g[i]=max(g[i-1],f[i-1]);
}
return max(f[n-1],g[n-1]);
}
};
子数组问题
子数组问题是动态规划的经典应用场景,通常涉及连续子数组的最优解(如最大和、最长长度等)
子数组问题的DP特点
- 连续性:子数组要求元素连续,与子序列(可不连续)不同
- 单串DP:通常用
dp[i]
表示以第i个元素结尾的子数组的解 - 状态转移:要么延续前一个状态,要么从当前元素重新开始
经典例题:最大子数组和
流程解决:
创建dp表,确定状态表示
- 创建一个大小和数组大小一样的dp表
- 状态表示:dp[i]表示以i位置为结尾的子数组的最大和
确定状态转移方程
连续的子数组,所有下标必须连续,不能间断
- dp[i] = dp[i-1] + nums[i]
子数组可以是多个,也可以是一个,即从当前下标开始
- dp[i] = nums[i]
dp[i]记录的是以i位置为结尾的子数组的最大和,取两者中的最大值
状态转移方程:dp[i] = max(dp[i-1]+nums[i],nums[i])
初始化
- dp[0] = nums[0]
顺序填充
- 从左向右,依次对dp表进行填充
确定返回值
- 我们需要的是该数组的最大子数组和,并不是最后一个位置的最大子数组和
- 所以我们需要找到dp表中的最大值,并返回
代码:
class Solution {
public:
int maxSubArray(vector<int>& nums) {
if(nums.size()==0) return 0;
if(nums.size()==1) return nums[0];
//创建dp表:dp[i]表示当前位置的最大连续子数组和
int n = nums.size();
vector<int> dp(n);
//初始化
dp[0]=nums[0];
int ret = nums[0];
//顺序填充
for(int i=1;i<n;i++){
dp[i]=max(nums[i],dp[i-1]+nums[i]);
if(dp[i]>ret) ret = dp[i];
}
return ret;
}
};
子序列问题
子序列问题是动态规划中的另一大类经典问题,与子数组问题最大的区别在于元素不需要连续。
经典例题:最长递增子序列
流程解决:
创建dp表,确定状态表示
- 创建一个和数组大小一样的dp表
- 状态表示:dp[i]的值表示以i位置为结尾的递增子序列的最大长度
确定状态转移方程
- 递增子序列可以有多个元素,这是元素可以连续,也可以不连续
- dp[i] = dp[j] + 1;
注意:这里的dp[j]可能并不与dp[i]相邻,可以相邻,也可以不相邻,前提是满足nums[j]<nums[i]
- 递增子序列也可能只有一个元素,即当前元素,代表之前没有比其小的元素
- dp[i] = 1
而dp[i]表示的是以i位置为结尾的递增子序列的最大长度
很多人可能会觉得需要在这两者中取最大值
但并不是,这里只能选择符合要求的值
一旦前面没有比当前元素小的元素,那么递增子序列只能重头开始,即长度为1
而如果有,则就需要再在原来的基础长度上加1即可
所以,我们可以在创建dp表的时候,就将所有的dp[i]设置为1
后续如果nums[i]的前面有更小值,直接更新即可!
初始化
- 不需要初始化
顺序填充
- 从左往右依次填充dp表
确定返回值
- 返回dp表中的最大的dp[i]
回文串问题
回文串问题是动态规划的经典应用场景,通常涉及子串/子序列的回文性质判断和最值计算。以下是系统性解题框架和典型例题分析。
回文串问题的DP特点
- 对称性:需判断字符串的对称性质(如
s[i]==s[j]
) - 中心扩展:多数问题可转化为区间DP(从短子串向长子串递推)
- 状态定义:通常用
dp[i][j]
表示子串s[i..j]的回文性质
经典例题:回文子串
思路:
将给定字符串的所有子串进行枚举,并对其每个进行判断是否是回文串
这里的枚举并不是真正的枚举,而是进行一种映射
如:使用[i,j],表示一段区间
流程解决:
创建dp表,确定状态表示
- 根据给定的字符串大小n,创建一个n*n的dp表
- 状态表示:dp[i,j]:表示s中区间为[i,j]的子串是回文字符串
例如:s="abcter"
dp[0][2] 表示"abc"是否是回文字符串
确定状态转移方程
判断 s 的区间[i,j]是否是回文子串
如果 s[i] == s[j] , 则分三种情况进行判断
- i == j,[i,j]代表一个字符,则dp[i][j]=true
- i+1 == j,[i,j]代表两个相邻的字符,则dp[i,j]=true
- i+1 <j,代表不相邻的两个字符相同,接下来看看dp[i+1][j-1]是否是回文字符串
所以,状态转移方程:dp[i][j] = i+1<j?dp[i+1][j-1]:true;
初始化
- 将dp表中的所有元素置为false
顺序填充
- 这里的顺序填充不在是我们正常思维的顺序,而是倒序
- 在状态转移方程中,我们可以发现,求dp[i][j]可能需要dp[i+1][j-1],而dp[i+1][j-1]在dp[i][j]的左下方
- 因此填充顺序是从下到上,从左到右
确定返回值
- 首先设置一个计数器
- 遍历一遍dp表,遇到true就让计数器+1
- 最后返回计数器的值即可
结语
动态规划是一种将复杂问题分解为相互关联的子问题的算法思想,其核心在于利用最优子结构和避免重复计算来提升效率。我们通过定义状态、建立转移方程、初始化边界条件和确定计算顺序这四个关键步骤,可以系统性地解决各类动态规划问题。无论是求最值、处理序列问题,还是解决背包或状态机问题,动态规划都展现出强大的建模能力。记住,许多看似复杂的问题,往往都能通过寻找子问题之间的递推关系来优雅解决。动态规划的魅力就在于它用空间换时间的智慧,让原本可能指数级复杂度的问题变得可解。