动态规划此一篇就够了 万字总结!

本文的文字及图片来源于网络,仅供学习、交流使用,不具有任何商业用途,版权归原作者所有,如有问题请及时联系我们以作处理

本文章来自腾讯云 作者:Python编程爱好者

想要学习Python?有问题得不到第一时间解决?来看看这里“1039649593”满足你的需求,资料都已经上传至文件中,可以自行下载!还有海量最新2020python学习资料。
点击查看

在这里插入图片描述

目录

首先,先大致列下这篇文章会讲到什么
    1.相较于暴力解法,动态规划带给我们的是什么?为什么会有重叠子问题以及怎么去避免的?
    2.用不同难度的动态规划问题举例说明, 最后会使用《打家劫舍》系列三个题再重温一次.
一、动态规划带给我们的优势
传统递归 vs. DP
    1. 先 递归解决
    2. 后 动态规划解决
    3. 动态规划 + 优化
二、动态规划四大解题步骤处理问题
    步骤一:定义dp数组的含义
    步骤二:定义状态转移方程
    步骤三:初始化过程转移的初始值
    步骤四:可优化点(可选)
案例一:打家劫舍I 「来自leetcode198」
    步骤一: 定义dp数组的含义
    步骤二:找出关系元素间的动态方程
    步骤三:初始化数值设定
    步骤四:优化
案例二:不同路径「来自leetcode62」
    步骤一:定义dp数组的含义
    步骤二:找出关系元素间的动态方程
    步骤三:初始化数值设定
    步骤四:优化
案例三:不同路径II 「来自leetcode63」
    步骤一:定义dp数组的含义
    步骤二:找出关系元素间的动态方程
    步骤三:初始化数值设定
    步骤四:优化
案例四:打家劫舍II 「来自leetcode213」
    步骤一: 定义dp数组的含义
    步骤二:找出关系元素间的动态方程
    步骤三:初始化设定
    步骤四:优化
案例五:打家劫舍III 「来自leetcode337」
    步骤一: 定义dp数组的含义
    步骤二:找出关系元素间的动态方程
    步骤三:初始化设定

动态规划 - 超详细系列

该文章较长,比较详细的阐述了动态规划思想,请耐心跟着思路走下去
动态规划 - 超详细系列
动态规划,一直以来听着就是一种很高深莫测的算法思想。尤其是上学时候算法的第一堂课,老师巴拉巴拉列了一大堆的算法核心思想,贪心、回溯、动态规划… …,开始感觉要在算法世界里游刃有余的进行解决各种各样牛B问题了,没想到的还是稀里糊涂学过了之后还就真的是学过了(大学的课程还真是一个样子)。再后来才明白,大学的课程一般来说就是入门级讲解,用来开拓眼界的,真正想要有一番自己的见解,必须要在背后下一番辛苦,形成自己的思考逻辑。

再后来返回头来看,动态规划理解起来还是比较困难,什么重叠子问题、动态转移方程,优化点等等等等,稀里糊涂,最后痛定思痛,好好看着其他人的分享理解了一部分,疯狂刷题几十道。算是基本可以佛挡杀佛了.

在我的这些学习积累过程中,总结出来希望可以给到大家一点小小的帮助,相信在读完这篇文章的时候,你会感觉到动态规划给你带来的奇妙之处。也一定对动态规划形成自己的思考方式. 很🐂的DP!!!

首先,先大致列下这篇文章会讲到什么
1.相较于暴力解法,动态规划带给我们的是什么?为什么会有重叠子问题以及怎么去避免的?
2.用不同难度的动态规划问题举例说明, 最后会使用《打家劫舍》系列三个题再重温一次.
看完本篇文章后,相信大家会对DP问题会有一个初步的思考,一定会入门。后面大家可以继续练习相关问题,熟能生巧,思考的多了就会形成自己的思维逻辑.

好了,话不多说,开搞…

一、动态规划带给我们的优势
很有趣,一定要看完,必定有收获,加油!💪💪💪

平时在我们算法设计的过程中,一般讲求的是算法的执行效率和空间效率的利用情况

也就是我们熟知的时间复杂度(执行时耗费时间的长度)和空间复杂度(执行时占用存储单元的长度)

那下面用时间复杂度和空间复杂度来评估下传统算法设计和用动态规划思想解决下的效率情况

传统递归 vs. DP
先用一个被大佬们举例举到烂的🌰,这个栗子很烂,但是真的很香:必须着重强调.

《斐波那契(Fibonacci)数列的第n项》

举荐理由:在我自己看来Fibonacci是动态规划设计中的入门级案例,就好比说编程中的“hello world”,大数据中的“word count”.

Fibonacci几乎完美的诠释了动态规划带来的思想和技巧然而没有任何其他的要考虑的细枝末节,这种很清晰的方法看起来很适合整个的动态规划的思维方式,很适合入门来进行的思考方式.

接下来咱们先来看题目:

写一个函数,输入n,求斐波那契(Fibonacci)数列的第 n 项。斐波那契数列的定义如下:

F(0) = 0, F(1) = 1
F(N) = F(N - 1) + F(N - 2), 其中 N > 1.

斐波那契数列由 0 和 1 开始,之后的斐波那契数就是由之前的两数相加而得出。
比较一下传统递归解法和动态规划思想下的解决对比

1. 先 递归解决

传统对于这种题目的思考方式会利用递归求解,做起来比较简单,就是不断的去递归调用,看下面代码:

class Solution(object):
    i = 0
    def fib_recur(self, N):
        print "F(",self.i,") = ", N   # 此处仅仅来看递归输出的N
        self.i += 1
        
        if N <= 1:
            return N
        return self.fib_recur(N-1) + self.fib_recur(N-2)  # 递归输出

输出的结果:

F( 0 ) =  4
F( 1 ) =  3
F( 2 ) =  2
F( 3 ) =  1
F( 4 ) =  0
F( 5 ) =  1
F( 6 ) =  2
F( 7 ) =  1
F( 8 ) =  0

重复计算

明显可以看到,总计 8 次的计算过程中,相同的计算结果有三对进行了重复计算(下图中同色项,不包含灰色),也就是说在递归的过程中,把曾经计算过的项进行了又一次的重复计算,这样对于时间效率是比较低的,唯一的好处可能就是代码看起来比较好懂,但是终归不是一个好的算法设计方法。

代码中,在计算N的时候就去递归计算 fib(N-1) + fib(N-2),那么,这种情况下的计算过程中。会是下面图中的一个计算过程。

可以发现,会有相当一部分的重复计算,这样对于时间都是重复的消耗。

参考图中相同颜色的项,比如说粉色的重复计算、黄色的重复计算等

注意:递归中没有对空间进行了增加,始终都是同样的长度,仅仅是不断的弹出和压入
在这里插入图片描述
为了更好的说明这种重复计算带来时间效率的低下。再比如说,相比上述图中的计算节点,再增加一个节点的计算,增加计算F(5),那么由于递归的计算方式,会有更多的项(下图中线框中部分)进行了重复的计算。在计算F(5)的时候,会递归调用F(4)和F(3),而在下图中,计算F(4)的时候,又会完整的去计算F(3)。这样,如果N很大的话,会有更大的时间消耗.

这样,这棵树的规模进行进行成倍增加,时间复杂度很明显的进行了成倍的扩张。对于时间上来说是很恐怖的.

时间复杂度带来的低效率严重超过了代码的可读性,所以我们可以想办法将过去计算过的节点进行保存。这样,我们就会用到下面要说的动态规划思想带来的时间上的高效.

在这里插入图片描述

时间复杂度: O ( 2 N ) O(2^N) O(2N) —> 指数级
空间复杂度: O ( N ) O(N) O(N)

2. 后 动态规划解决

大概解释一下字面意思:

动态规划:我们不直接去解决问题,而是在每一步解决问题的时候,达到每一步的最优情况。换句话说,就是在每一步解决问题过程中,利用过去的状态以及当前状态的情况而达到一个当前的最优状态.

规划:在一般解决该类问题的时候,会有一个“填表格”的过程,无论是简单情况下的一维表格还是复杂一点的二维表格,都是以开辟空间换时间的思想,以争取最佳的时间效率. (保存过程中间值,方便后续直接使用).

动态:用上面的案例来说,递归解决过程中的每一步都会从基本问题不断的“自顶向下”去求解,在每一步骤中,会有相同的计算逻辑进行了重复的计算。相比于递归思想,动态规划思想增加了对历史上计算结果的保存,逐步记录下中间的计算结果,在每一步求得最优值.

因此,动态规划可以避免重复计算,达到了时间上的最优,从 O ( 2 N ) O(2^N) O(2N)指数级变为 O ( N ) O(N) O(N)常数级别,相较于开辟的一段内存空间存放中间过程值的开销,是非常值得的.

那么,接下来咱们依照动态规划的思路进行对Fibonacci进行下解决

依据题中的规则:

F(0) = 0, F(1) = 1

F(N) = F(N - 1) + F(N - 2), when N > 1

那么,👇👇F(N) 的值只与他的前两个状态有关系👇👇

a. 初始化值 : F(0) = 0, F(1) = 1

b. 想要计算得到F(2), 那么F(2) = F(0) + F(1) --> 保存 F(2)

c. 想要计算得到F(3), 那么F(3) = F(2) + F(1) --> 保存 F(3)

d. 想要计算得到F(3), 那么F(4) = F(3) + F(2) --> 保存 F(4)

利用动态规划思想,以一维数组辅助实现的Fibonacci,看下图

在这里插入图片描述
是不是很简单的思路,仅仅靠保存过程中的一些值就能很简单的利用循环就可以实现了,没必要用递归反复计算进行实现。

想要计算得到第 n 个值的多少?那么,以下几点是我们必须要做到的

a. **定义一个一维数组**   --->  一般用dp来命名
b. **动态方程的设定**   --->  题中的F(N) = F(N - 1) + F(N - 2)
c. **初始化数值**   --->  F(0) = 0和F(1) = 1

上述的 a、b 和 c 点就是动态规划思想的几个核心要素

下面来看下要实现的代码(代码中,用dp来代替上面的F())

class Solution(object):
    def fib(self, N):
        if N == 0:
            return 0
     
        dp = [0 for _ in range(N+1)] # 1定义dp[i]保存第i个计算得到的数值
        dp[0] = 0   	# 2初始化
        dp[1] = 1			# 2初始化
        for i in range(2, N+1):	# 3动态方程实现,由于0和1都实现了赋值,现在需要从第2个位置开始赋值
            dp[i] = dp[i - 1] + dp[i - 2]
       
        print dp		 # 记录计算过程中的次数,与上述递归形成对比
        return dp[N]

输出:

[0, 1, 1, 2, 3]
3

以上,最重要的就是1 2 3 点,而执行过程参照输出对比递归算法,计算少了很多,同样的计算只计算了一次。

时间复杂度: O ( N ) O(N) O(N)

空间复杂度: O ( N ) O(N) O(N)

介绍了上面的内容了,此处来条分割线吧,针对上述的 递归 vs. DP

既然动态规划的方案也介绍了,下面咱们再仔细看看,是否有优化的空间,毕竟对于一个算法方案的设计,都有找到其优化点,无论是时间还是空间的效率都想要达到一个理想的值。

3. 动态规划 + 优化

咱们看下这张图解,发现每个计算节点都只与前两个项有关系。换句话说,咱们只要保存两个值就好了,计算新的节点值的时候,把新的值赋值给前两个值的第一个就好了
在这里插入图片描述
话说只要两个值,现在定义两个变量 dp1 和 dp2。那么,现在咱们一步一步模拟一下:

a. 初始化值 : F(0) = 0, F(1) = 1

在这里插入图片描述
b. 想要计算得到F(2), 那么F(2) = F(0) + F(1) --> 保存 F(2)

顺带将F(1)赋值给dp1, f(2)赋值给dp2

在这里插入图片描述
c. 想要计算得到F(3), 那么F(3) = F(2) + F(1) --> 保存 F(3)

顺带将F(2)赋值给dp1, F(3)赋值给dp2

在这里插入图片描述
d. 想要计算得到F(3), 那么F(4) = F(3) + F(2) --> 保存 F(4)

顺带将F(3)赋值给dp1, F(4)赋值给dp2

在这里插入图片描述
至此为止,整个过程仅仅用到了两个变量来存储过程中产生的值,也就之前没有优化的空间效率得到了优化

咱们把代码也贴一下吧,供参考

class Solution(object):
    def fib_dp1(self, N):
        if N == 0: return 0

        dp1, dp2 = 0, 1

        for i in range(2, N+1):
            dp1 = dp1 + dp2
            dp1, dp2 = dp2, dp1

        return dp2

看起来是不是更加简洁了。

洋洋洒洒不知不觉写了这么多了。

如果有读者说这太简单了,我这篇文章内容面对的是小白级别的,如果读者是中等往上的水平,可直接跳到后面的案例三开始参考。

另外,如果有任何的意见可随时对我的文章进行评论,欢迎&感谢大家一起讨论

大家感觉这个例子怎么样,三点说明:1.定义dp数组 2.动态方程 3.初始化数值

这也说明了为什么用斐波那契数列来引入动态规划的,因为斐波那契数列本身就明确的告诉你动态方程是什么,初始化的值是什么,所以好好的体会这种思想,尤其是从传统递归 -> 动态规划的思想解决,再到优化的方面,很值得深思。

那接下来,咱们就找几个有代表性的栗子来尝尝鲜

在这里插入图片描述
到这里有没有一种对动态规划的感觉了

二、动态规划四大解题步骤处理问题

上面用斐波那契数列问题,引出了下面的几点,在这里再详细赘述一下

在后面的案例中将会尽量严格按照这几个步骤进行解决问题

步骤一:定义dp数组的含义

步骤二:定义状态转移方程

步骤三:初始化过程转移的初始值

步骤四:可优化点(可选)

步骤一:定义dp数组的含义
绝大部分情况下,我们需要定义一维数组或者二维数组进行存储在计算过程中产生的最优值,这里为什么是最优值呢?是因为在解决问题过程中,一般情况dp数组用来保存从开始到当前情况的最优值,故而保存的是截止到目前的最优值,避免重复计算(这里看起来思维有混乱的同学们,想想上面Fibonacci 递归解法和动态规划的对比)

所以,dp无论是一维的还是二维的,要想清楚代表什么,一般来说代表的是截止到目前情况下的最优值

步骤二:定义状态转移方程
什么是动态转移方程? 如果有一个问题摆在我们面前,然后这个问题在解决的过程中,会发现有很多的重叠的子问题,重叠子结构,而通过这些子问题的解决,最终将会把该问题进行解决

通俗来说,在解决问题过程中,能够发现一个不断解决子问题的动态规律,比如说Fibonacci中的F(N) = F(N - 1) + F(N - 2),而在其他的可以用动态规划解决的问题中,需要我们自己去发现这样的内在规律。这个是最难的也是最终于要的,只要这一步解决了,接下来我们解决这个问题基本就没问题了.

步骤三:初始化过程转移的初始值
顺着步骤二的思路来,既然动态方程定义好了,是不是需要一个支点来撬动它进行不断的计算下去。

那么,这个支点就需要我们来初始定义,将动态方程激活,进行计算。举例来说Fibonacci中的F(0) = 0和F(1) = 1,有了这两个值,它的动态方程F(N) = F(N - 1) + F(N - 2)就可以进行下去了

这个就是我们要想好的初始值,实际问题可能还需要我们想想清楚.

步骤四:可优化点(可选)
可优化的这里,最重要的会是dp数组这块,也会有不同问题不同的优化点

在例子中,我们会进行不同的优化.

总之一点,建议大家动笔多画画图,很多细节慢慢就会出现了.

案例一:打家劫舍I 「来自leetcode198」
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

示例 1:

输入: [1,2,3,1]
输出: 4
解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4

示例2:

输入: [2,7,9,3,1]
输出: 12
解释: 偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
     偷窃到的最高金额 = 2 + 9 + 1 = 12

把经典案例系列拆分开讨论下吧,咱们首先将「打家劫舍I」来看看

该题可以用动态规划的思想来解决的原因是,在小偷不断偷取的过程中,始终想要偷得的物品价值最大,最优,每一步骤都与之前的偷取情况有关系,而且每一步都要考虑是否能偷,是否会带来最大利益,这就使得我们可以用动态规划的思想来解决问题。 然后严格按照四步骤进行解题.

在这里插入图片描述

也就是想要得到dp[i]的值,我们必须要知道dp[i-1],dp[i-2],dp[i-3] ... 的每一步的最优值,在这个状态转移的过程中,我们必须要想清楚怎么去定义关系式。然而在每一步的计算中,都与前几项有关系,这个固定的关系就是我们要寻找的重叠子问题,也同样是接下来要详细定义的动态方程
该题目中,当小偷到达第 i 个屋子的时候,他的选择有两种:一种是偷,另外一种是不偷, 然后选择价值较大者
a. 偷的情况计算:必然是dp[3] = nums[2] + dp[1],如果是偷取该屋子的话,相邻屋子是不能偷取的,因此,通项式子是:dp[i] =  nums[i-1] + dp[i-2]

在这里插入图片描述

b. 不偷的情况计算:必然是dp[3] = dp[2],如果是不偷取该屋子的话,相邻屋子就是其最优值,因此,通项式子是:dp[i] =  dp[i-1]

在这里插入图片描述
在这里插入图片描述

时间复杂度:O(N)

空间复杂度:O(N)

那下面想想看有没有可优化的地方,尽量的释放一部分计算机资源

步骤四:优化

从 dp[i] = max(dp[i-1], nums[i-1]+dp[i-2]) 关系来看,每一次动态变化,都与前两次状态有关系(dp[i-1], dp[i-2]),而前面的一些值是没有必要留存的.
所以,dp只需要定义两个变量就好,将空间复杂度降为O(1)	
class Solution(object):

    def rob_o(self, nums):
        # 依照上面的思路,其实我们用到的数据永远都是dp的dp[i-1]和dp[i-2]两个变量
        # 因此,我们可以使用两个变量来存放前两个状态值
        # 空间使用由O(N) -> O(1)

        size = len(nums)
        if size == 0:
            return 0

        dp1 = 0
        dp2 = nums[0]
        for i in range(2, size+1):
            dp1 = max(dp2, nums[i-1]+dp1)
            dp1, dp2 = dp2, dp1
        return dp2

在这里插入图片描述

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 非常感谢您的建议,我们会尽快为大家准备一篇关于C语言入门的学习笔记,帮助大家更好地掌握这门编程语言。我们会尽可能详细地介绍C语言的基本语法、数据类型、运算符、流程控制语句等内容,同时也会提供一些实例代码和练习题,帮助大家更好地理解和掌握C语言编程。希望我们的学习笔记能对大家有所帮助,让大家更快地入门C语言编程。 ### 回答2: C语言是一门广泛应用于系统编程、嵌入式系统、游戏开发等领域的高级程序设计语言。入门C语言对于软件开发工程师和计算机科学爱好者来说非常重要。 在C语言学习笔记中,我们首先需要熟悉C语言的基础知识,如数据类型、变量、运算符、流程控制语句和函数等。了解基础知识将会帮助我们更好地理解后续学习内容。 其次,学习C语言需要掌握一些编程基本技巧,如程序的模块化设计、调试和测试、程序的优化等。这些技巧可以帮助我们提高代码质量和效率。 同时,我们还需要了解C语言的高级编程特性,如指针、动态内存分配、结构体、文件操作等。掌握这些编程特性可以让我们更加灵活地进行编程。 最后,想要成为一名优秀的C语言程序员,我们需要不断实践和探索,不断完善自己的编程技能、提高编程水平。需要经常练习编写代码,加入开源社区进行开源项目的贡献和参与,与其他程序员交流经验,扩展自己的技术视野。 总之,学习C语言需要投入大量的时间和精力,需要不断地学习、实践和交流。但是,在通过不断地学习和实践后,C语言将成为你的强大工具,可以开发出各种高效、可靠的应用程序,实现自己的编程理想和目标。 ### 回答3: C语言是一门非常基础但又非常重要的编程语言,这门语言被广泛应用于各个领域,如嵌入式系统,操作系统开发等。C语言入门,是每个程序员必经的过程,通过学习C语言,我们掌握了基本的编程思想和方法,同时也为我们日后学习其他高级语言奠定了扎实的基础。 在这篇学习笔记中,我们可以学到C语言的各种基础知识点,例如数据类型、运算符、控制语句、函数等。这些知识点是C语言编程的基础,掌握它们非常重要。在学习的过程中,需要认真阅读教材,并且要动手实践,自己编写一些小程序,才能真正理解和掌握知识点。 除此之外,我们还可以通过学习C语言的标准库函数来扩展语言的使用范围,这些标准库函数非常常用,不仅可以方便快捷地实现某些功能,而且也是日后学习其他语言时会用到的知识点。 在学习C语言过程中,需要有一个良好的学习态度,要不断地做笔记、做练习,不断地复习、总结,才能真正掌握这门语言,更好的为日后的编程生涯打好基础。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值