和大家一起刷题不快不慢,没想到已经进行到了第二阶段,「动态规划」这部分题目很难,而且很不容易理解,目前我的题目做了一半,凭着之前对于「动态规划」的理解和最近做的题目做一个阶段性的总结!这篇文章其实是我之前写过的一篇,然后现在拿来再做一个润色。
「动态规划」看这篇我...保证可以!
目标:给小白以及没有明确思路的同学一个指引!
拍胸脯保证:读完这篇文章,对于大多数的动态规划的思维逻辑能有一个质的提升。
那么,咱们开始吧...
零、初印象
动态规划,一直以来听着就是一种很高深莫测的算法思想。
尤其是上学时候算法的第一堂课,老师巴拉巴拉列了一大堆的算法核心思想,贪心、回溯、动态规划... ...,开始感觉要在算法世界里游刃有余的进行解决各种各样牛B问题了,没想到的还是稀里糊涂学过了之后还就真的是学过了(大学的课程还真是一个样子)。
再后来才明白,大学的课程一般来说就是入门级讲解,用来开拓眼界,真正想要有一番自己的见解,必须要在背后下一番辛苦,形成自己的思考逻辑。tips:这个思考逻辑一定是要有记录的,是真的有时候会忘记。
再后来返回头来看,动态规划理解起来还是比较困难,重叠子问题、动态转移方程,优化点等等等等,稀里糊涂,最后痛定思痛,好好看着其他人的分享理解了一部分,在之后疯狂刷题几十道。现在回过头来再看,算是基本可以佛挡杀佛了。
在我的这些学习积累过程中,把一部分「动态规划」的问题复盘出来。希望可以给到大家一点小小的帮助,相信在读完这篇文章的时候,你会感觉到动态规划给你带来的奇妙之处。也一定对动态规划形成自己的思考方式。
相信我!这不是一篇难以读懂的文章!
一、本文要点
1.相较于暴力解法,动态规划带给我们的是什么?为什么会有重叠子问题以及怎么去避免的?
2.用不同难度的动态规划问题举例说明, 最后会使用《打家劫舍》系列三个题再重温一次!
「动态规划」思维逻辑
看完本篇文章后,相信大家会对DP问题会有一个初步的思考,一定会入门。后面大家可以继续练习相关问题,熟能生巧,思考的多了就会形成自己的思维逻辑。
好了,话不多说,开搞...
二、动态规划带来的优势
看完定有收获,加油!💪💪💪
平时在我们算法设计的过程中,一般讲求的是算法的执行效率和空间效率的利用情况。
也就是我们熟知的时间复杂度(执行时耗费时间的长度)和空间复杂度(执行时占用存储单元的长度)
那下面用时间复杂度和空间复杂度来评估下传统算法设计和用动态规划思想解决下的效率情况。
引出:传统递归 vs. 动态规划
先用一个被大佬们举例举到烂的🌰,这个栗子很烂,但是真的很香:必须着重强调。
**斐波那契(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. 递归解决
这个例子恐怕是我们大学中第一堂递归的经典案例了。
那么首先尝试用递归来解决。做起来比较简单,就是不断的去递归调用。
看下面代码:
def fib(self, n):
print('计算 F(%d)' % n)
if n < 2:
return n
return self.fib(n-1) + self.fib(n-2)
输出的结果:
计算 F(4)
计算 F(3)
计算 F(2)
计算 F(1)
计算 F(0)
计算 F(1)
计算 F(2)
计算 F(1)
计算 F(0)
可以明显看到一个现象:重复计算
总计 9 次的计算过程中,相同的计算结果有三对进行了重复计算(下图中同色项,不包含灰色),也就是说在递归的过程中,把曾经计算过的项进行了又一次的重复计算,这样对于时间效率是比较低的,唯一的好处可能就是代码看起来比较好懂,但是终归不是一个好的算法设计方法。
代码中,在计算N的时候就去递归计算 fib(N-1) + fib(N-2)
,那么,这种情况下的计算过程中。会是下面图中的一个计算过程。
可以发现,会有相当一部分的重复计算,这样对于时间和空间都是重复的资源消耗。
参考图中相同颜色的项,比如说粉色的重复计算、黄色的重复计算等
为了更好的说明这种重复计算带来时间效率的低下。再比如说,相比上述图中的计算节点,再增加一个节点的计算,增加计算F(5)
,那么由于递归的计算方式,会有更多的项(下图中线框中部分)进行了重复的计算。在计算F(5)
的时候,会递归调用F(4)
和F(3)
,而在下图中,计算F(4)
的时候,又会完整的去计算F(3)
。这样,如果N很大的话,会产生更大的时间消耗。
这样,这棵树的规模进行进行成倍增加,时间复杂度很明显的进行了成倍的扩张。对于时间上来说是很恐怖的.
时间复杂度带来的低效率严重超过了代码的可读性,所以我们可以想办法将过去计算过的节点进行保存。这样,我们就会用到下面要说的「动态规划」思想带来的时间上的高效。
时间复杂度:O(2^N) ---> 指数级
空间复杂度:O(N)
2. 动态规划解决
到重点了:大概解释一下字面意思:
动态规划:我们不直接去解决问题,而是在每一步解决问题的时候,达到每一步的最优情况。换句话说,就是在每一步解决问题过程中,利用过去的状态以及当前状态的情况而达到一个当前的最优状态.
规划:在一般解决该类问题的时候,会有一个“填表格”的过程,无论是简单情况下的一维表格还是复杂一点的二维表格,都是以开辟空间换时间的思想,以争取最佳的时间效率. (保存过程中间值,方便后续直接使用).
动态:用上面的案例来说,递归解决过程中的每一步都会从基本问题不断的“自顶向下”去求解,在每一步骤中,会有相同的计算逻辑进行了重复的计算。相比于递归思想,动态规划思想增加了对历史上计算结果的保存,逐步记录下中间的计算结果,在每一步求得最优值.
因此,动态规划可以避免重复计算,达到了时间上的最优,从$O(2^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(0)、F(1)
直接拿取,保存 F(2)
c. 想要计算得到F(3)
那么F(3) = F(2) + F(1)
--> F(1)、F(2)
直接拿取,保存 F(3)
d. 想要计算得到F(4)
那么F(4) = F(3) + F(2)
--> F(2)、F(3)
直接拿取,保存 F(4)
利用动态规划思想,以一维数组辅助实现的Fibonacci,看下图
结合之前的递归调用,这样子解决是不是很简单的思路,仅仅靠保存过程中的一些值就能很简单的利用循环就可以实现了,没必要用递归反复计算进行实现。
想要计算得到第 n 个值的多少?那么,以下几点是我们必须要做到的
第一、定义一个一维数组 ---> 一般用dp来命名
第二、动态方程的设定 ---> 题中的F(N) = F(N - 1) + F(N - 2)
第三、初始化数值 ---> F(0) = 0和F(1) = 1
上述的 3 点就是动态规划思想的几个核心要素或者说是解决问题的步骤!
下面来看下要实现的代码(代码中,用dp来代替上面的F()
)