算法——动态规划

1. 什么是动态规划?

动态规划(Dynamic Programming)是一种解决多阶段决策问题的优化方法。它通常用于解决具有重叠子问题和最优子结构性质的问题,能够将一个大问题分解为多个重叠的子问题,并通过存储子问题的解来避免重复计算,从而提高算法效率。

动态规划的基本思想是将原问题分解为若干子问题,先求解子问题的解,然后将这些子问题的解组合起来,逐步推导出原问题的解。为了避免重复计算,动态规划算法通常采用表格(数组)来存储已经求解的子问题的解,这种表格通常称为动态规划(dp)表。 

2. 动态规划算法的解题流程

动态规划算法的一般步骤如下:

  1. 定义状态: 明确定义问题的状态,将原问题转化为具有重叠子问题的子问题。在解题中体现为确认dp表中每一个格子表示什么。

  2. 找到状态转移方程: 建立子问题之间的递推关系,通过状态转移方程描述问题的最优子结构。在解题中体现为dp表如何去填写。

  3. 初始化: 初始化动态规划表,将边界状态的值填入表中。在解题中,初始化的目的是为了保证最后得到的结果是正确的。

  4. 逐步计算: 从边界状态开始,按照状态转移方程逐步计算并填充动态规划表。在解题中,体现为确认填dp表的方向。

  5. 解读结果: 根据动态规划表中的结果得到原问题的解。在解题中,体现为返回正确结果。

3. 应用实例

①斐波那契数列模型

1. 第N个泰波那契数

题目链接:1137. 第 N 个泰波那契数 - 力扣(LeetCode)

解析:看完这道题,我们分析这个题目可以发现,题目已经将几乎动态规划的所有步骤告诉了我们,我们只需要按照他说的完成流程即可,我们定义dp[i]表示第i个泰波那契数,我们的动态转移方程为 dp[i] = dp[i-1] + dp[i-2] + dp[i-3],而初始化要想得到正确结果,我们需要将dp[0] = 0, dp[1] = dp[2] = 1,填表方向则是从左向右从第3个位置开始填,最后返回dp[n]即可(n为0,1,2时需要进行特殊判断),代码如下

class Solution 
{
public:
    int tribonacci(int n) 
    {
        if (n == 0) return 0;
        if (n <= 2) return 1;
        vector<int> dp(n+1);
        dp[1] = dp[2] = 1;

        for (int i = 3; i <= n; i++)
            dp[i] = dp[i-1] + dp[i-2] + dp[i-3];

        return dp[n];
    }
};

2. 三步问题

题目链接:面试题 08.01. 三步问题 - 力扣(LeetCode)

解析:分析这个题目,我们可以创建一个大小为(n+1)的dp表,我们定义dp[i]为到小孩上到第i个阶梯共有多少种方式,根据题目我们可以发现,要想到达dp[i]这个位置,我们有三种方法上来,分别是从前三个位置上来,即

所以我们可以得到dp[i]的动态转移方程:dp[i] = dp[i-1] + dp[i-2] + dp[i-3],初始化时,只有前三个台阶需要特殊处理,由于一开始就处于第0个台阶因此dp[0]=1,到第一个台阶只有一种方法,所以dp[1] = 1,到第二个台阶有2种方法,所以dp[2] = 2,由于题目中是从下往上跳,因此填表顺序为从第三个台阶开始从左向右填,最终返回dp[n]即可,代码如下

class Solution 
{
public:
    int waysToStep(int n) 
    {
        if (n <= 1) return 1;
        if (n == 2) return 2;

        int mod = 1e9 + 7;
        vector<long long> dp(n+1);
        dp[0] = dp[1] = 1;
        dp[2] = 2;

        for (int i = 3; i <= n; i++)
            dp[i] = (dp[i-1] + dp[i-2] + dp[i-3]) % mod;
        
        return dp[n];
    }
};

3. 使用最小花费爬楼梯

题目链接:746. 使用最小花费爬楼梯 - 力扣(LeetCode)

解析:分析题目,我们可以定义dp[i]表示到第i个阶梯的最小花费,而要想到达第i个阶梯,要么只能从第i-1个阶梯来,要么只能从i-2个阶梯来,我们只需要最小的花费,所以动态转移方程如下 dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]);,由于可以选择下标从0或1的阶梯开始爬,因此我们无需初始化,填表顺序为从前往后填,最后返回dp[n]即可,代码如下

class Solution 
{
public:
    int minCostClimbingStairs(vector<int>& cost) 
    {
        int n = cost.size();
        vector<int> dp(n+1);

        for(int i = 2; i <= n; i++)
            dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]);

        return dp[n];
    }
};

除了从左向右填表外,我们还可以从右向左填表,定义dp[i]表示从第i个阶梯开始到达楼顶的最小花费,从第i个阶梯向后移动,要么只能移动一步要么只能移动两步,我们需要得到其中较小的一种情况,因此有 dp[i] = min(dp[i+1], dp[i+2]) + cost[i],由于从倒数第一和倒数第二个阶梯都能直接到达楼顶,因此我们需要初始化dp[n-1] = cost[n-1], dp[n-2] = cost[n-2], 填表时从后往前填即可,最终返回dp[0]与dp[1]中的较小值,代码如下

class Solution 
{
public:
    int minCostClimbingStairs(vector<int>& cost) 
    {
        int n = cost.size();
        vector<int> dp(n+1);
        dp[n-1] = cost[n-1];
        dp[n-2] = cost[n-2];

        for(int i = n - 3; i >= 0; i--)
            dp[i] = min(dp[i+1], dp[i+2]) + cost[i];

        return min(dp[0], dp[1]);
    }
};

4. 解码方法

题目链接:91. 解码方法 - 力扣(LeetCode)

解析:分析这个题目,我们可以定义dp[i]表示到达第i个字符时共有多少种解码方法,若当前单个字符能够解码(不为'0'),则表明当前字符是一种解码方法,此时该字符可以与前面的串形成一种编码即dp[i-1],若当前字符不能够解码(为'0'),则表明当前字符不是一种解码方式,此时dp[i]就置为0;若当前字符能与前一个字符进行解码,则表明这两个字符是一种解码方法,即dp[i-2],若不能形成,则dp[i]置于0,对于初始化我们只需要知道第一个字符是否为'0'即可,是的话dp[0] = 0,不是的话dp[0] = 1,dp[1]有0,1,2三种情况,填表顺序为从左往右填,最终返回dp[n-1]即可,代码如下

class Solution 
{
public:
    int numDecodings(string s) 
    {
        int n = s.size();
        vector<int> dp(n);
        if (s[0] != '0') dp[0] = 1;
        if (n == 1) return dp[0];

        int code = stoi(s.substr(0, 2));
        if (s[1] != '0' && s[0] != '0') dp[1]++;
        if (code >= 10 && code <= 26) dp[1]++; 
            
        for (int i = 2; i < n; i++)
        {
            if (s[i] != '0') dp[i] += dp[i-1];
            code = stoi(s.substr(i-1, 2));
            if (code >= 10 && code <= 26) dp[i] += dp[i-2];
        }

        return dp[n-1];
    }
};

②路径问题

1. 不同路径

题目链接:62. 不同路径 - 力扣(LeetCode)

解析:分析题目,我们可以规定dp[i][j]表示到达(i, j)位置的所有路径数,由于机器人每次只能向下或者向右移动,因此对于一个dp[i][j]它只能从左侧过来,或者从上方下来,即 dp[i][j] = dp[i-1][j] + dp[i][j-1],为了免除边界处理的情况,我们可以人为的为dp表填上一行,防止i-1与j-1越界,即直接初始化dp大小为(m+1)*(n+1),此时要注意,由于我们添加了一行因此原数组的位置的映射关系发生了改变,即dp[i][j]表示的是到达(i-1, j-1)位置的路径数,为了保证结果的正确我们可以挑选dp[0][1] = 1 或者 dp[1][0] = 1,填表顺序为从左向右,从上至下,最后返回dp[m][n]即可,代码如下

class Solution 
{
public:
    int uniquePaths(int m, int n) 
    {
        vector<vector<int>> dp(m+1, vector<int>(n+1));
        dp[0][1] = 1;

        for (int i = 1; i <= m; i++)
            for (int j = 1; j <= n; j++)
                dp[i][j] = dp[i-1][j] + dp[i][j-1];

        return dp[m][n];
    }
};

2. 不同路径Ⅱ

题目链接:63. 不同路径 II - 力扣(LeetCode)

解析:这道题整体的思路与上一题类似,但是这道题在遇见1的时候是到达不了这个地方的,此时将该位置置为0即可(注意下标间的映射为dp[i][j]对应ob[i-1][j-1]),代码如下

class Solution 
{
public:
    int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) 
    {
        int m = obstacleGrid.size();
        int n = obstacleGrid[0].size();
        vector<vector<int>> dp(m+1, vector<int>(n+1));
        dp[0][1] = 1;

        for (int i = 1; i <= m; i++)
            for (int j = 1; j <= n; j++)
                if (obstacleGrid[i-1][j-1] == 1) dp[i][j] = 0;
                else dp[i][j] = dp[i-1][j] + dp[i][j-1];

        return dp[m][n];
    }
};

3. 珠宝的最高价值

题目链接:LCR 166. 珠宝的最高价值 - 力扣(LeetCode)

解析:分析这道题,我们可以规定dp[i][j]表示到达(i, j)位置时拿取珠宝的最大价值,由于该位置只能从左边或者上边得来,因此我们每次拿取当前珠宝数较大的一方即可,即  dp[i][j] = max(dp[i-1][j] , dp[i][j-1]) + frame[i-1][j-1]; 对于初始化,我们只需要拓宽一行一列即可,填表顺序为从左到右从上至下,最终返回dp[m][n]即可,代码如下

class Solution 
{
public:
    int jewelleryValue(vector<vector<int>>& frame) 
    {
        int m = frame.size(), n = frame[0].size();

        vector<vector<int>> dp(m+1, vector<int>(n+1));

        for (int i = 1; i <= m; i++)
            for (int j = 1; j <= n; j++)
                dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + frame[i-1][j-1];

        return dp[m][n];
    }
};

4. 下降路径最小和

题目链接:931. 下降路径最小和 - 力扣(LeetCode)

解析:分析这道题目,我们可以规定dp[i][j]代表到达(i, j)位置的最小路径和,每一个位置应该由上一行临近的三个方向中的最小值来决定,即 dp[i][j] = min(dp[i-1][j-1], min(dp[i-1][j], dp[i-1][j+1])) + matrix[i-1][j-1],对于这道题为了防止出界,我们应该补上第一行,第一列与最后一列,其中为了最终答案的正确,我们应该使第一行的值都为0,剩余补上的列都初始化为INT_MAX保证最终结果正确,填表顺序为从左到右从上到下,最终返回最后一行中的最小值即可,代码如下

class Solution 
{
public:
    int minFallingPathSum(vector<vector<int>>& matrix) 
    {
        int m = matrix.size(), n = matrix[0].size();

        vector<vector<int>> dp(m+1, vector<int>(n+2));
        for (int i = 1; i <= m; i++) dp[i][0] = dp[i][n+1] = INT_MAX;

        for (int i = 1; i <= m; i++)
            for (int j = 1; j <= n; j++)
                dp[i][j] = min(dp[i-1][j-1], min(dp[i-1][j], dp[i-1][j+1])) + matrix[i-1][j-1];

        int ret = INT_MAX;
        for (auto& e : dp[m]) ret = min(ret, e);

        return ret;
    }
};

5. 最小路径和

题目链接:64. 最小路径和 - 力扣(LeetCode)

解析:分析题目,我们可以定义dp[i][j]表示到达(i, j)时最小的路径和,由于只能从上左两个方向来,我们需要得到最小的路径和,因此我们可以得到动态转移方程: dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i-1][j-1]; 初始化就选择多一行多一列,为了保证最后的结果正确我们需要将所有位置初始化为INT_MAX,再将dp[0][1] = dp[1][0]初始化为0,最后按从左往右从上到下填表,最后返回dp[m][n]即可,代码如下

class Solution 
{
public:
    int minPathSum(vector<vector<int>>& grid) 
    {
        int m = grid.size(), n = grid[0].size();

        vector<vector<int>> dp(m+1, vector<int>(n+1, INT_MAX));
        dp[0][1] = dp[1][0] = 0;

        for (int i = 1; i <= m; i++)
            for (int j = 1; j <= n; j++)
                dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i-1][j-1];

        return dp[m][n];
    }
};

③简单多状态dp问题

1. 按摩师

题目链接:面试题 17.16. 按摩师 - 力扣(LeetCode)

解析:分析题意,我们规定dp[i]表示到i位置时当前最优的预约集合,根据题意我们在两次预约之间可以间隔很远,且两次预约不能相邻,那么对于每一位置的预约,我们都有选或不选两种情况,我们可以分别使用f和g两个表来分别表示选与不选两种情况,即

对于表f,我们有 f[i] = g[i-1] + nums[i];

对于表g,我们有 g[i] = max(f[i-1], g[i-1]);

在了解了两个表的动态转移方程之后,我们再对他们进行初始化,f表表示要选所以f[0] = nums[0],而g表表示不选所以g[0] = 0,同时从左往右填两个表,最终返回f[n-1]和g[n-1]中的最大值,代码如下

class Solution 
{
public:
    int massage(vector<int>& nums) 
    {
        int n = nums.size();
        if (n == 0) return 0;
        vector<int> f(n);
        vector<int> g(n);
        f[0] = nums[0];

        for (int i = 1; i < n; i++)
        {
            f[i] = g[i-1] + nums[i];
            g[i] = max(f[i-1], g[i-1]);
        }

        return max(f[n-1], g[n-1]);
    }
};

2. 打家劫舍Ⅱ

题目链接:213. 打家劫舍 II - 力扣(LeetCode)

解析:这道题与第一题相似,我们定义dp[i]表示到第i家为止我们偷窃到的最高金额,同样的我们可以创建f与g两个表来分别表示是否偷窃第i家,其中f表表示偷取第i家,g表表示不偷取第i家,对于f表有 f[i] = g[i-1] + nums[i]; 对于g 表有 g[i] = max(f[i-1], g[i-1]);,根据题目要求,如果选择偷窃了第一家就不能偷窃第二家与最后一家,所以我们可以问题转换为两种情况,即

其中,f1表示要偷窃第一家,所以要从第三家偷到倒数第二家,初始化f1[2] = nums[2],f2表示不偷窃第一家,所以要从第二家偷到最后一家,初始化f2[1] = nums[1],最终代码如下

class Solution 
{
public:
    int rob(vector<int>& nums) 
    {
        int n = nums.size();
        vector<int> f1(n), f2(n);
        vector<int> g1(n), g2(n);

        int res1 = 0;
        // f1表示偷窃第一家
        if (n <= 2) res1 = nums[0];
        else 
        {
             f1[2] = nums[2];
            for (int i = 3; i <n-1; i++)
            {
                f1[i] = g1[i-1] + nums[i];
                g1[i] = max(f1[i-1], g1[i-1]);
            }
            res1 = nums[0] + max(f1[n-2], g1[n-2]); 
        }

        int res2 = 0;
        // f2表示不偷第一家
        if (n == 1) res2 = 0; 
        else 
        {
            f2[1] = nums[1];
            for (int i = 2; i < n; i++)
            {
                g2[i] = max(f2[i-1], g2[i-1]);
                f2[i] = g2[i-1] + nums[i];
            }
            res2 = max(f2[n-1], g2[n-1]);
        }

        return max(res1, res2);
    }
};

看起来分情况可能较多,可以采取封装一个子函数来简化代码。

3. 删除并获得点数

题目链接:740. 删除并获得点数 - 力扣(LeetCode)

④子数组问题

1. 最大子数组和

题目链接:53. 最大子数组和 - 力扣(LeetCode)

解析:分析题目,我们可以规定dp[i]表示:以i位置为结尾的最大子数组和,那么有

我们需要的是最大的子数组和,因此动态转移方程为dp[i] = max(nums[i], dp[i-1] + nums[i]),在这里由于出现了i-1我们可以在dp表前添加一个虚拟节点,根据动态转移方程,我们只需要将dp[0]=0,即可保证最后的填表结果正确,由于任何一个位置都有可能是结果,因此我们需要遍历整个dp表,拿到其中的最大值并返回,在这之中需要我们注意的是由于我们添加了一个虚拟节点,因此对应原数组的下标关系都应该为nums[i-1],代码如下

class Solution 
{
public:
    int maxSubArray(vector<int>& nums) 
    {
        int n = nums.size();
        if (n == 1) return nums[0];
        vector<int> dp(n+1);

        int ret = INT_MIN;
        for (int i = 1; i <= n; i++)
        {
            dp[i] = max(nums[i-1], dp[i-1] + nums[i-1]);
            ret = max(ret, dp[i]);
        }
        
        return ret;
    }
};

2. 环形子数组的最大和

题目链接:918. 环形子数组的最大和 - 力扣(LeetCode)

解析:分析题目,我们还是可以规定dp[i]表示:以i位置为结尾的子数组最大和,分析有

因此我们需要两个dp表,在这里我使用f(最大)与g(最小)来表示,在从左向右填完所有的表后,有一种特殊情况即

最终代码如下

class Solution 
{
public:
    int maxSubarraySumCircular(vector<int>& nums) 
    {
        int n = nums.size();
        vector<int> f(n+1);
        vector<int> g(n+1);

        int fmax = INT_MIN, gmin = INT_MAX, sum = 0;
        for (int i = 1; i <= n; i++)
        {
            f[i] = max(nums[i-1], f[i-1] + nums[i-1]);
            g[i] = min(nums[i-1], g[i-1] + nums[i-1]);
            fmax = max(fmax, f[i]), gmin = min(gmin, g[i]);
            sum += nums[i-1];
        }

        return gmin == sum ? fmax : max(fmax, sum - gmin);
    }
};

3. 乘积最大子数组

题目链接:152. 乘积最大子数组 - 力扣(LeetCode)

解析:分析题目,我们可以规定dp[i]表示:以i为结尾的最大乘积子数组,分析有

在这里,我们同样需要为dp表创建虚拟节点,而在这里需要将虚拟节点的值初始化为1才不会影响最终结果,填完所有的表后我们返回f表中的最大值即可,代码如下

class Solution 
{
public:
    int maxProduct(vector<int>& nums) 
    {
        int n = nums.size();
        vector<int> f(n+1);
        auto g = f;
        f[0] = g[0] = 1;

        int ret = INT_MIN;
        for (int i = 1; i <= n; i++)
        {
            int x = nums[i-1];
            f[i] = max(x, max(x*f[i-1], x*g[i-1]));
            g[i] = min(x, min(x*f[i-1], x*g[i-1]));
            ret = max(ret, f[i]);
        }

        return ret;
    }
};

⑤子序列问题

1. 最长递增子序列

题目链接:300. 最长递增子序列 - 力扣(LeetCode)

解析:分析题目,我们可以规定dp[i]表示:以i位置为结尾的最长递增子序列,分析有

最终由于每个位置都有可能是结果,所以我们需要返回dp表中的最大值,代码如下

class Solution 
{
public:
    int lengthOfLIS(vector<int>& nums) 
    {
        int n = nums.size();
        vector<int> dp(n, 1);

        for (int i = 0; i < n; i++)
            for (int j = 0; j < i; j++)
                if (nums[j] < nums[i])
                    dp[i] = max(dp[j]+1, dp[i]);

        int ret = 0;
        for (auto& e : dp) ret = max(e, ret);

        return ret;
    }
};

  • 22
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值