文章目录
动态规划 - 超详细系列
该文章较长,比较详细的阐述了动态规划思想,可以关注「计算广告生态」,回复“DP”获取pdf文件方便查阅,对你一定有用
动态规划 - 超详细系列
动态规划,一直以来听着就是一种很高深莫测的算法思想。尤其是上学时候算法的第一堂课,老师巴拉巴拉列了一大堆的算法核心思想,贪心、回溯、动态规划… …,开始感觉要在算法世界里游刃有余的进行解决各种各样牛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