基础dp——动态规划

news/2025/2/24 5:18:38

目录

一、什么是动态规划

二、动态规划的使用步骤

1.状态表示

2.状态转移方程

3.初始化

4.填表顺序

5.返回值

三、试题讲解

1.最小花费爬楼梯

2.下降路径最小和

3.解码方法


一、什么是动态规划

        动态规划(Dynamic Programming,简称dp)是一种通过将复杂问题分解为更小的子问题来解决的算法思想,尤其适用于具有重叠子问题最优子结构的优化问题。其核心目标是避免重复计算,通过存储中间结果(记忆化)来提升效率。

动态规划 vs 分治法

  • 共同点:都将问题分解为子问题。

  • 区别

    • 分治法(如归并排序)的子问题独立,无重叠,无需存储中间结果。

    • 动态规划的子问题有重叠,需存储中间结果避免重复计算。

二、动态规划的使用步骤

        为了方便理解下面我将从感性的角度来给大家讲解动态规划的使用步骤

1.状态表示

        首先在解决问题的时候我们通常会使用一块空间来记录已处理的数据,我们把这块空间叫作dp表。

        当我们在解决一个小问题时需要借助之前已解决的问题的数据,就可以到dp表里面查,当前这个小问题解决后继续把它相关的信息存到dp表,然后继续解决下一个小问题。

        这个dp表中的一小块数据表示什么?这些问题指的就是状态表示。在用动态规划解决问题时,要面对最重要的问题就是dp表的状态应该表示是什么?

在解决这个问题时我们通常都是从这几个角度去思考:

  • 1.经验+题目要求
  • 2.分析问题的过程中,发现重复子问题。
  • 3.当我们发现子问题后,假设前(或后)几个小问题已经解决,思考我们在解决当前问题时有没有需要前面已解决的问题的什么信息?

2.状态转移方程

        dp[i]等于什么?这就是状态转移方程。它通常要依赖于已经计算过的状态。

3.初始化

在创建好dp表后主要任务就是对dp表的填写,初始化dp表的作用有以下两点:

  1. 保证填表的时候不越界。
  2. 保证填表的正确性。

4.填表顺序

        为使填写当前状态的时候,所需要的状态已经计算过了。

5.返回值

        最后结合题目要求和状态表示计算结果并返回。

三、试题讲解

1.最小花费爬楼梯

        首先我们分析题目,我们的起点是0或1的位置,而终点是n位置(注意不是n-1位置)。我们在爬楼梯时支付一次费用可以爬一个或两个台阶。

        当我们尝试从动态规划方向去解决,那么我们试着去构造一下相同的子问题,并且假设它已经解决。

注:为了方便在下文我都会把动态规划简称为“动规” 

        用动规解题过程中,在找子问题时我总结了一个很厉害的小技巧,就是保留主问题的逻辑,而换一个问题的对象。比如这里,主问题是起点爬到楼顶的最小花费,那么构造子问题时我们只需要保留这个问题的逻辑,而把楼顶这个对象改成任意位置i。那么我们就得到了一个子问题(即从起点爬到i位置的最小花费)。然后我们再假设前面的相同的子问题已经解决。

比如:

        动规题复杂多变,以上技巧可以解决大部分的基础动规题,但更多的只是作为一个小经验,并不是所有动规题都可以通过构建子问题来解决的。

        在解一个题时,状态表示可以是很多,不同的状态表示就决定了不同的状态转移方程,而要判断一个状态表示是否正确只需看是否能根据该状态表示推出正确的状态转移方程。

能够理解上图那么动态规划的五步骤就水到渠成了。

  • 状态表示: dp[i]:表示爬到i时的最小花费
  • 状态转移方程: dp[i]=min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2])
  • 初始化: dp表初始化为全0
  • 填表顺序: 从下标为2的位置开始,并且从左往右填写
  • 返回值: return dp[n]

代码示例:

class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost){
        int n=cost.size();
        vector<int> dp(n+1);//默认值就是0,不用初始化
        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];    
    }
};

        除了刚才的状态表示我们还可以换一个,只需要改变一下子问题,同样的技巧,主问题逻辑不变,把起点这个对象改成任意位置i, 那么我们就得到了一个子问题(即从i位置爬到楼顶的最小花费)。

  • 状态表示: dp[i]:表示从i爬到楼顶的最小花费
  • 状态转移方程: dp[i]=cost[i]+min(dp[i+1],dp[i+2])
  • 初始化: dp[n-1]=cost[n-1],dp[n-2]=cost[n-2]
  • 填表顺序: 从右往左
  • 返回值: return min(dp[0], dp[1])

        我们可以发现状态表示的改变使得其他四个步骤的实现逻辑改变,所以可见状态表示的重要性。

代码示例:

class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost){
        int n=cost.size();
        vector<int> dp(n);
        dp[n-1]=cost[n-1],dp[n-2]=cost[n-2];
        for(int i=n-3;i>=0;i--) 
            dp[i]=cost[i]+min(dp[i+1],dp[i+2]);
        return min(dp[0],dp[1]);    
    }
};

        提示:空间优化:在我们填写某一个状态的时候只需要使用它的前两个状态,所以可以把dp数组简化为两个变量就可以解决问题(需要注意变量的更新)。通常把该优化方法叫做滚动数组”。很多基础动规都可以进行这样的优化,大家可以试着去实现,这一点在后面也不再提。 

        提示:为了减少逻辑上的赘述,在以下题解中我都只会讲解一种状态表示作为示例。 

2.下降路径最小和

通过上一题找子问题的经验,我们可以这样做:

抓住主问题:从第一行任意位置下降到最后一行的任意一个位置的最小和。 

        保留问题的主逻辑,把对象“第一行任意位置”或“最后一行任意位置”更改,比如更改“最后一行任意位置”为“第i行的任意位置j”。得到子问题,即从“第一行任意位置下降到第i行的任意位置j的最小和”

        接下来假设与它相同的子问题都解决,并利用它们的结果。如下:

动规五步骤:

  • 状态表示: dp[i][j]:从第一行任意位置下降到第i行j列的最小和
  • 状态转移方程: dp[i][j]=matrix[i][j]+min(dp[i-1][j-1],min(dp[i-1][j],dp[i-1][j+1]) )
  • 初始化:越界区域如下:

        

        创建(n+1)*(n+2)的dp表初始化为全INT_MAX(这样创建dp表减少了为防止越界而做特殊判断带来的成本),再将第一行初始化为全0把dp表初始化为全INT_MAX,是为了防止越界区域参与最小值的筛选再把第一行初始化为全0是逻辑需要,总的目的都是为了保证填表的正确性。

  • 填表顺序: 从上到下,从左往右(或从右往左,只要能保证在填写当前状态时需要的状态已计算过即可)
  • 返回值: 返回最后一行的最小值

代码示例: 

class Solution {
public:
    int minFallingPathSum(vector<vector<int>>& matrix)
    {
        int n=matrix.size();//题目明确指出是方形,所以行数和列数都一样。
        vector<vector<int>> dp(n+1,vector<int>(n+2,INT_MAX));
        for(int j=0;j<n+2;j++) dp[0][j]=0;
        for(int i=1;i<=n;i++)
            for(int j=1;j<=n;j++)
                dp[i][j]=matrix[i-1][j-1]+min(min(dp[i-1][j-1],dp[i-1][j]),dp[i-1][j+1]);
        int ret=INT_MAX;
        for(int j=1;j<=n;j++) ret=min(ret,dp[n][j]);
        return ret;
    }
};

3.解码方法

        主问题: 一个非空字符s的解码方法的总数。但我们好像无法从中获取到可以更改的对象,再回去观察上面我们解决的两道问题。它们主问题的逻辑中都是带有区间式的,比如第一题:从起点到终点,第二题:从第一行到最后一行。那么如果我们还想要那个找子问题的技巧去确定状态表示,那么在总结主问题时就要朝着带有区间式的方向去想。

        这里我们可以把主问题总结为:从s字符串的下标0开始到下标n-1结束的解码方法总数。那么这样我们就构造出了一个区间“开始”——“结束”。子问题就好构建得多了。

子问题:从下标0开始到下标i结束的解码方法总数。

  • 状态表示: dp[i]:表示从下标0开始到下标i结束的解码方法总数
  • 状态转移方程:

        解决了状态表示但这个题的难点主要在于状态转移方程。

     

         我们可以把单独解码和前一个联合解码分开讨论。如果单独解码成功相当于在i-1的所有解码方法中统一加上一个s[i],所以为dp[i-1],解码失败的话,说明不能单独解码,所以结果为0

        同理联合解码成功相当于在i-2的所有解码方法中统一加上一个s[i]与s[i-1]的复合,所以为dp[i-2],解码失败的话,说明不能联合解码所以结果为0最后只需要把单独解码与联合解码的结果相加。

  • 初始化: 因为我们在用dp[i]时需要用到dp[i-1]和dp[i-2]的状态,所以最好把dp[0]和dp[1]初始化。dp[0]可以这样写dp[0]=s[i]!='0',但dp[1]的初始化会比较麻烦,它的初始化逻辑和上面的状态转移方程是一样的。而在下面填表时同样要用到这样的逻辑,就会显得很累赘。

        我们可以使用这样一个初始化技巧:

        那么可以做一个n+1大小的dp表,然后错开一位表示,即 dp[i]:表示从下标0开始到下标i-1结束的解码方法总数。为保证后面的填表顺序正确我们需要dp[1]=s[i]!='0',这是必然的。而dp[0]该初始化为什么?我们可以思考什么时候用到dp[0],即s[0]与s[1]解码成功时,那么它必然是dp[0]=1

  • 填表顺序:从dp的2下标开始,并从左往右。
  • 返回值: return dp[n]

代码示例:

class Solution {
public:
    int numDecodings(string s)
    {
        vector<int> dp(s.size()+1,0);
        dp[1]=s[0]!='0',dp[0]=1;
        for(int i=2;i<dp.size();i++)
        {
            int m=(s[i-2]-'0')*10+s[i-1]-'0';
            if(s[i-1]!='0') dp[i]+=dp[i-1];
            if(m>=10&&m<=26) dp[i]+=dp[i-2];
        }
        return dp.back();
    }
};

非常感谢您能耐心读完这篇文章。倘若您从中有所收获,还望多多支持呀!74c0781738354c71be3d62e05688fecc.png


http://www.niftyadmin.cn/n/5863975.html

相关文章

[通俗易懂C++]:指针和const

之前的文章有说过,使用指针我们可以改变指针指向的内容(通过给指针赋一个新的地址)或者改变被保存地址的值(通过给解引用指针赋一个新值): int main() {int x { 5 }; // 创建一个整数变量 x&#xff0c;初始值为 5int* ptr { &x }; // 创建一个指针 ptr&#xff0c;指向 …

如何手动设置u-boot的以太网的IP地址、子网掩码、网关信息、TFTP的服务器地址,并进行测试

设置IP地址 运行下面这条命令设置u-boot的以太网的IP地址&#xff1a; setenv ipaddr 192.168.5.9设置子网掩码 运行下面这条命令设置u-boot的以太网的子网掩码&#xff1a; setenv netmask 255.255.255.0设置网关信息 运行下面这条命令设置u-boot的网关信息&#xff1a; …

案例-14.文件上传-简介

一.简介 文件上传涉及到两个部分&#xff0c;一个是前端程序&#xff0c;另一个是服务端程序。 二.前端程序 1.前端上传文件必须有三要素&#xff1a; 1.form表单&#xff0c;并且在form表单中要定义一个表单项&#xff0c;类型为file。其效果是会弹出一个“选择文件”的按钮…

深度学习pytorch之19种优化算法(optimizer)解析

提示&#xff1a;有谬误请指正 摘要 本博客详细介绍了多种常见的深度学习优化算法&#xff0c;包括经典的LBFGS 、Rprop 、Adagrad、RMSprop 、Adadelta 、ASGD 、Adamax、Adam、AdamW、NAdam、RAdam以及SparseAdam等&#xff0c;通过对这些算法的公式和参数说明进行详细解析…

解决Excel文件格式损坏问题:如何通过程序读取并复制内容

在日常工作中&#xff0c;我们经常会遇到因文件损坏或格式问题无法打开的 Excel 文件。尤其是 .xls 格式的旧版 Excel 文件&#xff0c;在某些情况下可能由于损坏、格式不兼容或其他原因&#xff0c;导致无法通过常规方式读取和处理。本文将分享一种通过 Python 程序解决损坏的…

C#上位机--进程和线程的区别

引言 在 C# 上位机开发中&#xff0c;进程和线程是两个非常重要的概念&#xff0c;它们在程序的运行和性能优化方面起着关键作用。理解进程和线程的区别&#xff0c;能够帮助开发者更好地设计和实现高效、稳定的上位机程序。本文将深入探讨 C# 上位机中进程和线程的区别&#…

结构型模式-Bridge模式(桥接模式)

解释 桥接模式是将抽象部分与它的实现部分分离&#xff0c;使它们都可以独立地变化。它是一种对象结构型模式。 Bridge模式核心思想将抽象与实现解耦&#xff0c;使二者可以独立变化。适用于一个类存在多个变化维度且需要灵活扩展的场景。 场景假设&#xff1a;图形渲染系统 …

体育电竞比分网开发流程

开发一个体育电竞比分网的流程可以分为以下几个主要步骤&#xff1a; 1. 需求分析 目标用户&#xff1a;确定网站的主要用户群体&#xff0c;如体育迷、电竞爱好者等。 功能需求&#xff1a;列出网站需要实现的功能&#xff0c;如实时比分更新、赛事日程、新闻资讯、用户评论…