【算法】——一键解决动态规划

前言

动态规划是一种高效解决​​重叠子问题​​和​​最优子结构​​问题的算法思想。它通过​​分治+记忆化​​,将复杂问题分解为子问题,并存储中间结果,避免重复计算,从而大幅提升效率。

​为什么重要?

  1. ​优化暴力解法​​:如斐波那契数列,递归复杂度为O(2n),而动态规划可优化至O(n)。
  2. ​解决经典难题​​:如背包问题、最短路径、编辑距离等,动态规划往往是​​最优解法​​。
  3. ​广泛应用​​:从算法竞赛到实际开发(如资源调度、股票交易策略),动态规划都是核心工具之一。

掌握动态规划,能让你在算法设计与优化中事半功倍!

动态规划流程

我个人是觉得动态规划是相当难的,因为我不太擅长找规律

动态规划就像是我们熟知的找规律,通过已知项得出一个规律

再用这个规律,套用到已知项上,得出未知项

虽然难,但动态规划也有自己的模板,让你看到一个动态规划有个思考方向

动态规划流程

  1. 创建dp表,确定状态表示
  2. 确定状态转移方程
  3. 初始化
  4. 顺序填充
  5. 确定返回值

现在,分别介绍一下

创建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表,怎么进行状态表示

属于直接明牌了

流程解决:

  1. 创建dp表:创建一个大小为n+1的dp表,dp[i]表示的值即为F(i)
  2. 确定状态转移方程:F(n) = F(n-1) + F(n-2)
  3. 初始化:dp[0]=0,dp[1]=1
  4. 顺序填充:从左往右依次填充
  5. 确定返回值: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表,确定状态表示

  1. 根据给定的字符串大小n,创建一个n*n的dp表
  2. 状态表示:dp[i,j]:表示s中区间为[i,j]的子串是回文字符串

例如:s="abcter" 

dp[0][2] 表示"abc"是否是回文字符串

确定状态转移方程

判断 s 的区间[i,j]是否是回文子串

如果 s[i] == s[j] , 则分三种情况进行判断 

  1. i == j,[i,j]代表一个字符,则dp[i][j]=true
  2. i+1 == j,[i,j]代表两个相邻的字符,则dp[i,j]=true
  3. 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]的左下方
  • 因此填充顺序是从下到上,从左到右

确定返回值

  1. 首先设置一个计数器
  2. 遍历一遍dp表,遇到true就让计数器+1
  3. 最后返回计数器的值即可

结语

  动态规划是一种将复杂问题分解为相互关联的子问题的算法思想,其核心在于利用最优子结构和避免重复计算来提升效率。我们通过定义状态、建立转移方程、初始化边界条件和确定计算顺序这四个关键步骤,可以系统性地解决各类动态规划问题。无论是求最值、处理序列问题,还是解决背包或状态机问题,动态规划都展现出强大的建模能力。记住,许多看似复杂的问题,往往都能通过寻找子问题之间的递推关系来优雅解决。动态规划的魅力就在于它用空间换时间的智慧,让原本可能指数级复杂度的问题变得可解。