动态规划:第一弹(第N个泰波那契数列、使用最小花费爬楼梯、解码方法)

发布于:2025-04-03 ⋅ 阅读:(22) ⋅ 点赞:(0)

目录

前言

1. 第N个泰波那契数列

(1)题目及示例

(2)动态规划解题思路

(3)优化空间

2.使用最小花费爬楼梯

(1)题目及示例

(2)解法一

(3)解法二

3. 解码方法

(1)题目及示例

(2)解题思路

(3)优化


前言

本文主要从三个Leetcode算法题入手,图文并茂的讲解如何使用动态规划解决算法题目。如何定义动态规划表,如何对具体问题分析出状态转移方程,这两个问题是动态规划的核心,本问都会详细说明。


1. 第N个泰波那契数列

(1)题目及示例

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

泰波那契序列 Tn 定义如下: 

T0 = 0, T1 = 1, T2 = 1, 且在 n >= 0 的条件下 Tn+3 = Tn + Tn+1 + Tn+2

给你整数 n,请返回第 n 个泰波那契数 Tn 的值。

示例 1:

输入:4

输出:4

解释:n = 4

T_3 = 0 + 1 + 1 = 2

T_4 = 1 + 1 + 2 = 4

示例 2:

输入:n = 25

输出: 1389537


(2)动态规划解题思路

动态规划是将复杂问题分解为重复的子问题,并通过记录之前的计算结果,推出之后的状态。

泰波那契数列特点很明显,从第四个数开始,是前三个数之和。而这就是这道题的状态转移方程,我们可以使用动态规划的方法解决这道题目。

  • 以这题为例,首先我们需要创建一个dp表,即创建一个整形数组,记得数组元素个数是n+1个。
  • 其次,初始化前三个数列元素的值。但是这里需要判断n的大小,是否等于0,1或者2。如果是其中之一,就直接返回0或者1。因为不判断直接初始化,会导致数组越界。
  • 接着,根据状态转移方程填表。其中该dp表表示泰波那契数列的元素,而该数列元素是前三个元素之和,这个关系就是状态转移方程。
  • 最后,泰波那契数列元素从0号开始,说明有n+1个数,所以返回dp表n下标位置的元素即可。
    int tribonacci(int n) 
    {
        //1. 创建 dp 表
        vector<int> dp(n + 1);
        // 判断n大小,防止dp数组越界
        if(n == 0)
            return 0;
        if(n == 1 || n == 2)
            return 1;
        //2. 初始化
        dp[1] = dp[2] = 1;
        //3. 填表
        for(int i = 3; i < n + 1; i++)
        {
            dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3];
        }
        //4. 返回值
        return dp[n];
    }

分析一下时间复杂度和空间复杂度。上述代码中,需要遍历一次dp表,基本就是n次遍历,时间复杂度是O(n)。在空间上,创建了元素大小为n+1的数组,所以空间复杂度也是O(n)。

(3)优化空间

上面创建dp表,空间复杂度为O(n)。不过这个状态转移方程只跟前三项有关系,我们可以使用滚动数组来优化空间。

因为有三个变量需要初始化,所以我们要使用四个整型变量a、b、c和d。其中前三个变量一开始存储泰波那契数列的三个初始值,d变量用于存储前三个变量的和。

如下图,a、b和c分别初始化为0、1和1。d变量用来存储前三个变量的和。

 接着,a赋值为b,b赋值为c,c赋值为d。d继续存储前三个变量之和。

    int tribonacci(int n) 
    {
        //1. 创建 dp 表
        //2. 初始化
        //3. 填表
        //4. 返回值

        if (n == 0)
            return 0;

        if (n == 1 || n == 2)
            return 1;

        int a = 0, b = 1, c = 1, d = 0;
        for (int i = 3; i < n + 1; i++)
        {
            d = a + b + c;

            a = b;
            b = c;
            c = d;
        }

        return d;
    }

2.使用最小花费爬楼梯

(1)题目及示例

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

给你一个整数数组cost ,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。

你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。

请你计算并返回达到楼梯顶部的最低花费

示例 1:

输入:cost = [ 10, 15, 20 ]

输出:15

解释:你将从下标为 1 的台阶开始

- 支付 15,向上爬两个台阶,到达楼梯顶部

总花费为 15

示例 2:

输入:cost = [ 1,100,1,1,1,100,1,1,100,1 ]

输出:6

解释:你将从下标为 0 的台阶开始。
- 支付 1 ,向上爬两个台阶,到达下标为 2 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 4 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 6 的台阶。
- 支付 1 ,向上爬一个台阶,到达下标为 7 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 9 的台阶。
- 支付 1 ,向上爬一个台阶,到达楼梯顶部。
总花费为 6

(2)解法一

该题需要根据题目给出的具体数组,来求出到达顶部的最小花费。

如果使用动态规划,一般需要创建dp表数组,还要明确dp[i]表示什么状态。在上一题泰波那契数列中,dp[i]表示下标为i数列值,而该数列已经明确i位置的值是前三个数列元素之和。

我们也需要明确dp[i]表示什么,可以从前往后,和从后往前两种思路。先以从前往后思路为例,需要关注当前dp表元素和前面元素的关系。一般来说规定dp[i]为从开始位置到i下标台阶的最小花费。

  • 我们支付完当前台阶费用后,可以选择向上爬一个或两个台阶。
  • 如果是向上爬一个台阶到达 i 位置台阶,那么之前所在位置是 i-1 的台阶。此时费用到达 i-1 位置的最小花费加上 i-1 位置的费用。其中到达 i-1 位置的最小花费就是dp[i-1],所以总费用就是dp[i-1]+cost[i-1]
  • 如果是向上爬两个台阶到达 i 位置台阶,那么之前所在位置是 i-2 的台阶。此时费用分析跟上面如出一辙,总费用就是dp[i-2]+cost[i-2]
  • 但是要判断从i-1位置或者i-2位置到达i位置台阶的哪个总费用更少,就取较小值。

写代码时,需要注意初始化dp表前两个值为0,因为可以直接选择从第一个或者第二个台阶开始走。还要走到楼梯顶部是比原数组多一个树,相当于到数组第n+1个数。

    int minCostClimbingStairs(vector<int>& cost) 
    {
        int n = cost.size();

        // vesion 1 从前往后,dp[i]表示到达i位置时的最小花费
        vector<int> dp(n + 1);
        for(int i = 2; i <= n; i++)
        {
            dp[i] = min(cost[i-2]+dp[i-2], cost[i-1]+dp[i-1]);
        }
        return dp[n];
    }

(3)解法二

解法一中,dp[i]表示开始位置到 i 位置台阶的最小花费。我们也可以从后往前,定义dp[i]表示从楼顶到 i 位置台阶的最小花费。

  • 按照dp表的定义,我们需要从后往前开始完善dp表。首先,只需要n个元素大小的dp表。因为楼顶的位置在n+1的位置。
  • 因为我们可以向上爬一个或两个台阶,所以楼顶位置往前一到两个台阶需要初始化为cost数组中的花费。
  • 之前的台阶,可以向上爬一个或两个台阶。当向上爬一个台阶时,需要加上当前台阶的费用,即cost[i],和之前楼顶到达该位置台阶的最小花费,即dp[i-1]。
  • 当向上爬两个个台阶时,需要加上当前台阶的费用,即cost[i],和之前楼顶到达该位置台阶的最小花费,即dp[i-2]。
  • 这两种情况下,取最小值,就是楼顶到该台阶的最小花费。
  • 最后,返回的时候,需要比较下标为0和1台阶的最小花费,取其中最小值。
    int minCostClimbingStairs(vector<int>& cost) 
    {
        int n = cost.size();
        // version 2
        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] = min(cost[i]+dp[i+1], cost[i]+dp[i+2]);
        }

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

总的来说,for循环经历大约n次遍历,时间复杂度为O(n)。dp表开辟了大约n个变量的空间,空间复杂度也为O(n)。

3. 解码方法

(1)题目及示例

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

一条包含字母 A-Z 的消息通过以下映射进行了 编码 :

"1" -> 'A'    "2" -> 'B'   ...    "25" -> 'Y'   "26" -> 'Z'

然而,在解码已编码的消息时,你意识到有许多不同的方式来解码,因为有些编码被包含在其它编码当中("2" 和 "5" 与 "25")。

例如,"11106" 可以映射为:

    "AAJF" ,将消息分组为 (1, 1, 10, 6)
    "KJF" ,将消息分组为 (11, 10, 6)
    消息不能分组为  (1, 11, 06) ,因为 "06" 不是一个合法编码(只有 "6" 是合法的)。

注意,可能存在无法解码的字符串。

给你一个只含数字的 非空 字符串 s ,请计算并返回解码方法的总数。如果没有合法的方式解码整个字符串,返回 0。

题目数据保证答案肯定是一个 32 位 的整数。

示例 1:

输入:s = "12"

输出:2

解释:它可以解码为 "AB"(1 2)或者 "L"(12)

示例 2:

输入:s = "226"

输出:3

解释:它可以解码为 "BZ" (2 26), "VF" (22 6), 或者 "BBF" (2 2 6)

示例 2:

输入:s = "06"

输出:0

解释:"06" 无法映射到 "F" ,因为存在前导零("6" 和 "06" 并不等价)。


(2)解题思路

这个题目也是要根据给出的字符串具体分析解码的个数。

首先,我们需要给dp表下定义,即定义dp[i]表示什么内容或者什么状态。根据题目要求,可知dp[i]可以表示为从下标为0字符到下标为 i 字符最多的解码方式。

其中,我们可以对一到二个字符进行组合,转换成整型变量。假设下面是一个字符数组,每一个格子装有一个字符。下标为i的字符可以独自解码,也可以跟前一个字符组合解码。

因为数字字符本身是ASCII码值,只要减'0’字符ASCII码值48,就可以转换为整型值。

如果s[i]独自解码,用整型变量m表示解码后的数字,那么m = s[i]- '0'

  • 如果满足0<m<9这个条件,可以匹配对应英文字符,解码成功。相当于前面 i-1 个字符加上s[i]这个字符,那么解码数就等于前面i-1 个字符的总解码数,即dp[i-1]
  • 如果m变量等于0,就无法匹配到英文字母,解码失败,那么解码数为0。

如果s[i]与是s[i-1]字符组合在一起解码,把s[i-1]字符转换为整型值用n表示,那么两个字符解码后的整型值,可以表示为 n*10 + m

  • 如果想要解码成功,两个字符转换成的整型值要在26之内,并且不能出现前导0,如"09"这种情况,即满足10<= n*10 + m <=26这个条件。此时相当于 i-2 个字符拼接上这两个字符,不会影响前 i-2 个字符的总解码数,即dp[i-2]。
  • 如果不满足上面的条件,解码失败,得到的解码数为0。

 可以得到状态转移方程为dp[i] = dp[i-1] +dp[i-2],不过都是有条件的,需要进行判断。

  • dp表元素大小开辟为n个。一开始还需要初始化前两位dp表中的元素。第一个字符判断转换为整型值是否大于0小于9。两个字符组合转换成的整型值判断条件跟上面相同。
  • 最后返回dp[n-1]即可。还需要注意判断字符串的字符长度是否大于1!
    int numDecodings(string s) 
    {
        int n = s.size();
        vector<int> dp(n, 0);
        if(s[i] != '0')
            dp[0] = 1;
        
        // 需要判断字符个数,否则后面会越界访问
        if(n == 1) return dp[0];

        if(s[0] != '0' && s[1] != '0') 
            dp[1] += 1;
        int tmp = (s[0] - '0') * 10 + s[1] - '0';
        if(tmp >= 10 && tmp <= 26) 
            dp[1] += 1;

        for(int i = 2; i < n; i++)
        {
            if(s[i] != '0') 
                dp[i] += dp[i - 1];
            int tmp = (s[i - 1] - '0') * 10 + s[i] - '0';
            if(tmp >= 10 && tmp <= 26) 
                dp[i] += dp[i - 2];
        }
        return dp[n - 1];
    }

(3)优化

观察上面给出的代码,你会发现for循环中的两个if判断和初始化时的if判断十分相似。那有什么办法可以减少代码的冗余呢?我们可以通过扩建dp表做到。

  • 一般来说,将dp表多开一个元素空间,即由原来的n个元素变成n+1个元素。其中扩建后的dp表中的第一个元素,用来为dp表中前几个元素的初始化做准备。
  • 因为多了一个元素空间,就可以把字符串s的第一个元素放在dp表第二个位置,直接从字符串s第二个元素开始进行动态规划。
  • 此时,需要把dp[0]初始化为1,因为s字符串前两个字符组合转换成的整型值满足条件的话,解码数就为1。
  • 还需要注意下标的变化。在for循环中,tmp变量存储的字符s下标都要多减去1。
    int numDecodings(string s) 
    {
        // 优化
        int n = s.size();
        vector<int> dp(n + 1);
        dp[0] = 1; // 保证后面的填表是正确的
        if(s[0] != '0')        
            dp[1] = 1;

        for(int i = 2; i <= n; i++)
        {
            if(s[i - 1] != '0') 
                dp[i] += dp[i - 1];
            int tmp = (s[i - 2] - '0') * 10 + s[i - 1] - '0';
            if(tmp >= 10 && tmp <= 26) 
                dp[i] += dp[i - 2];
        }

        return dp[n];
    }

总的来说,for循环经历大约n次遍历,时间复杂度为O(n)。dp表开辟了大约n个变量的空间,空间复杂度也为O(n)。


创作充满挑战,但若我的文章能为你带来一丝启发或帮助,那便是我最大的荣幸。如果你喜欢这篇文章,请不吝点赞、评论和分享,你的支持是我继续创作的最大动力!