解锁动态规划的奥秘:从零到精通的创新思维解析(1)

前言

在算法的世界里,动态规划(Dynamic Programming, DP)以其强大的问题分解与优化能力,占据着极为重要的地位。无论是在学术研究还是实际应用中,它都广泛用于解决最优子结构和重叠子问题的复杂场景。从路径规划到资源分配,从游戏策略到数据压缩,动态规划的方法论为我们提供了一把破解复杂问题的利器。然而,初学者往往会被它的理论抽象和实现细节所困扰。本文将通过一道经典动态规划习题的详细讲解,帮助大家深入理解其本质,并掌握在实际问题中如何灵活运用。希望通过这篇文章,您能对动态规划的“自顶向下”与“自底向上”有更清晰的认识,从而在算法学习的旅程中迈出扎实的一步。下面我先从几个方面介绍动态规划。

1.动态规划的简单介绍

什么是动态规划?

动态规划(Dynamic Programming,简称 DP)是一种通过将问题分解为更小的子问题,逐步求解以达到整体最优解的算法方法。它的核心思想在于:利用 最优子结构重叠子问题,避免重复计算,从而提升算法效率。


动态规划的基本思想

动态规划的本质是 递归的优化。它通过记录中间计算结果,避免了重复求解,降低了计算复杂度。与传统的递归方法不同,动态规划通过 记忆化自底向上 的方式,将问题解决过程转化为一次性计算,节省了大量时间。


动态规划的两个关键特性

  1. 最优子结构
    问题的最优解可以通过其子问题的最优解构造而来。例如,求解一个最短路径问题,当前点的最短路径是由前一个点的最短路径加上当前边的权重得来的。

  2. 重叠子问题
    问题被分解后,子问题会重复出现,动态规划通过记录这些子问题的解,避免了冗余计算。例如,斐波那契数列中的某些值会在递归中被多次计算,动态规划通过缓存方式解决这个问题。


动态规划的基本步骤

  1. 确定状态
    明确问题的阶段性状态,通常用一个数组或矩阵表示。每个状态表示问题的一个子问题的解。

  2. 状态转移方程
    找到子问题之间的递推关系,定义如何从子问题推导出当前问题。
    例如,斐波那契数列的状态转移方程是:
    [
    f(n) = f(n-1) + f(n-2)
    ]

  3. 初始化
    根据问题特点,确定初始状态的值。例如,在背包问题中,初始状态为当容量为 0 时,最大价值为 0。

  4. 计算顺序
    通常采用从小到大(自底向上)或从大到小(自顶向下)的方式,计算所有状态。

  5. 输出结果
    从最终状态中提取问题的解。


动态规划的常见应用

  1. 最优化问题:如背包问题、最长公共子序列、最短路径问题。
  2. 计数问题:如硬币兑换、分割方案。
  3. 博弈问题:如棋盘游戏、棋类博弈。
  4. 数列问题:如斐波那契数列、卡特兰数。

动态规划的优势与挑战

优势
  • 减少重复计算,提升效率。
  • 为很多看似复杂的问题提供了结构化的解决方案。
挑战
  • 状态定义和状态转移方程的推导较为抽象,需要深入理解问题。
  • 有时需要结合空间优化技巧(如滚动数组)来降低空间复杂度。

动态规划虽然起步时较为复杂,但一旦掌握了它的核心思想,它将成为解决复杂问题的重要工具。对于上面的讲解,小编其实是偷懒的用ChatGPT生成的,我认为我自己对于动态规划的理解还是有限的,所以我就把对于其介绍的部分用更加官方的话介绍了,下面不多废话,开启今日的做题之旅。

2.第N个泰波那契数

2.1.题目来源

本题是来自力扣的一个题目,下面小编给上本题目的出处:1137. 第 N 个泰波那契数 - 力扣(LeetCode)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

2.2.题目解读

相信不少的小伙伴都在高中时学过斐波那契数列,或者在学习C语言递归的时候写过斐波那契数的递归,可能很多人对于泰波那契数表示没听说过,小编自然也是刚开始的时候拿到本题的时候很蒙,但是幸好题目的介绍就告诉了我们关于其的定义,它其实就是给定我们一个数,这个数等于它前三个数之和,对于上面的式子我们可以改进一下,如下所示:
T ( n ) = T ( n − 1 ) + T ( n − 2 ) + T ( n − 3 ) T(n) = T(n - 1) + T(n - 2) + T(n - 3) T(n)=T(n1)+T(n2)+T(n3)
如上面的式子所示,一个泰波那契数就是让我们用这个来表示,本题就是给定我们一个整数n,想要我们求出第n个泰波那契数,其实只要我们知道这个的定义,这个题目其实就已经手拿把掐了,下面小编进入本题目的思路讲解。

2.3.思路讲解

本题的做题方法小编在简介中就说过,对于动态规划的题目,我们往往分为五步来做,分别是:1.状态分析。2.状态转移方程。3.初始化。4.填表顺序。5.返回值,以后我们的动态分析题目就通过这五步进行讲解。

1.状态分析

一般的动态规划的题目,我们通常需要一个dp表来辅助我们进行作答,dp表可以是一维数组,也可以是二维数组,这个完全是靠题目来决定的。

对于状态分析,我们通常要先从这两个方向考虑:

1.是什么? 答:dp表里面的值所代表的含义

2.怎么做? 答:1.题目要求 2.经验 + 题目要求 3.分析问题的过程中,发现重复子问题(这个在我们做完很多动态规划的题目就会领悟到)

接下来我们先从dp表里面的值是什么下手,本题目我们是想要求解第n个泰波那契数,所以dp表里面的元素自然就可以是相应位置的泰波那契数,通过我们分析出来了dp表的含义,我们自然知道dp[I]表示的是什么,它表示第i个位置的泰波那契数,下面我们进入本题目的最难的部分,状态转移方程的求解。

2.状态转移方程(最难的部分)

其实在本题目中,dp[i]怎样表示就已经告诉我们了,但这不妨碍这一步是动态规划题目中最有难度的一部分,因为大部分动态规划的习题都是需要我们自己分析的,甚至在后面小编讲述的股票问题,我们需要通过状态机来分析状态转移方程,当然这些都是后话了,以后遇到在说,下面给出本题目的状态转移方程。

dp[i]  = dp[i - 1] + dp[i - 2] + dp[i - 3];

3.初始化

相信不少小伙伴已经发现上面代码出现的一些细节问题了,倘若此时的i是0 到 2 的话,此时这个数组就越界了,如果是别的刷题网站,可能越界不会直接报错,但是力扣是很严格的,倘若我们不考虑此时的初始化问题,那么代码直接寄,所以我们要先给0到2位置的数据进行初始化操作,通过题目的分析,我们可以知道本题非常细心的告诉了我们如何进行初始化,如下所示:

dp[0] = 0,dp[1] = dp[2] = 1;

当然,我们需要在初始化之前判断一下本题目给定我们的n值是否大于2,如果n恰好就在给定的我们范围之内,那么我们直接返回相应位置的数即可,不然不考虑的话代码依旧会报错(某个人在写代码的时候就是这么错的(⊙︿⊙))。

4.填表顺序

本题调表的顺序也是很简单的,因为我们在填表的时候需要用到左边的数据,那么我们自然从左到右依次填写了。

5.返回值

因为此题我们是想要求解第n个位置的泰波那契数,所以我们直接返回dp[n]即可。

此时本体已经全部分析完毕,下面我们进入代码书写阶段,

3.代码书写

在我们创建dp表之前,我们首要任务是确定此时的n是否是大于2的,如果此时的n在0到2内,那么我们直接返回对应位置的值即可,不然本题目依然是要越界的,如下所示:

if(n == 0) return 0; //先把特殊情况处理,避免越界
if(n == 1 || n == 2) return 1;

之后我们就要创建dp表了,由于此时我们需要求解第n个位置的元素,所以我们的dp表就要多创建一个,之后我们通过上面的思路分析,先对一些数据进行初始化避免越界。

vector<int> dp(n + 1);  //要多开辟一块空间
dp[0] = 0;
dp[1] = dp[2] = 1;

准备工作做完以后,此时我们就要通过循环的方式进行数据的填入了,此时我们循环开始自然是从3开始,到n时结束,循环体的内容自然就是我们前面分析状态转移方程。

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

填完表以后,我们直接返回第n个位置的泰波那契数即可。

return dp[n];

4.代码展示

class Solution {
public:
    int tribonacci(int n) {
        if(n == 0) return 0; //先把特殊情况处理,避免越界
        if(n == 1 || n == 2) return 1;
        vector<int> dp(n + 1);  //要多开辟一块空间
        dp[0] = 0;
        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];
    }
};

5.代码优化

其实上面的代码已经是一个很好的代码了,但是经过我们对于复杂度的分析,可以知晓此时的时间复杂度和空间复杂度都是O(N),其实也不错了,只不过我们其实还可以通过一个方法来让此时的空间复杂度变的更小,此时就需要一个很好的工具帮助我们执行了,它就是——滚动数组,

滚动数组是一种优化动态规划(Dynamic Programming,简称 DP)算法中状态存储空间的技术。其核心思想是通过重用有限的几个数组空间,代替原本需要的大量数组存储空间,从而大幅降低内存消耗。滚动数组特别适用于状态转移关系仅与前几行或前几个状态相关的场景。

此时对于滚动数组的用法,其实就是通过定义四个数来进行实现,它很像小编之前写过的一个链表题目的解法,具体的我也忘了,反正此时我们通过前三个数表示原来状态转移方程的右半部分,一个数表示左半部分,通过滚动的方式来进行书写,下面我通过几个图片来展示一个滚动数组的用法。

首先,我们假定n是6,此时我们让a为0,b为1,c为1,d先不表示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

之后,我们让d为三者之和,然后让a,b,c向右进行移动,具体移动方式就是让a = b,b = c,c = d,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

之后我们通过不断的循环,自然就可以得到第n个位置的泰波那契数,具体的代码小编就不带着各位进行书写了,因为和前面差不多。

6.优化后的代码的展示

class Solution {
public:
    int tribonacci(int n) {
        //优化后,无需开辟数组
        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; i++)
        {
            d = a + b + c;
            a = b;
            b = c;
            c = d;
        }
        return d;
    }
};

7.总结

本文章到这里就已经结束了,因为本文是动态规划的第一道题目,所以小编对理论部分讲解的是比较详细的,之后的文章可能就不会和今天一样这么详细了,对于动态规划的题目,小编的建议就是在学完之后,多做,多看,多理解,因为这部分的题目说实话难度还是很大的,所以各位一定要多做相关的题目,时光冉冉,一起写题的时光总是很短暂,那么各位大佬们,我们下一篇文章见啦!
在这里插入图片描述

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐