动态规划,斐波那契数列和暴力递归

262 阅读4分钟

一、动态规划

算法技巧就那⼏个套路,如果你⼼⾥有数,就会轻松很多,本⽂就来扒⼀扒 动态规划的裤⼦,形成⼀套解决这类问题的思维框架。废话不多说了,上⼲ 货。

动态规划问题的⼀般形式就是求最值。动态规划其实是运筹学的⼀种最优化 ⽅法,只不过在计算机问题上应⽤⽐较多,⽐如说让你求最⻓递增⼦序列 呀,最⼩编辑距离呀等等。 既然是要求最值,核⼼问题是什么呢?求解动态规划的核⼼问题是穷举。因 为要求最值,肯定要把所有可⾏的答案穷举出来,然后在其中找最值呗。

动态规划就这么简单,就是穷举就完事了?我看到的动态规划问题都很难 啊!⾸先,动态规划的穷举有点特别,因为这类问题存在「重叠⼦问题」,如果 暴⼒穷举的话效率会极其低下,所以需要「备忘录」或者「DP table」来优 化穷举过程,避免不必要的计算。 ⽽且,动态规划问题⼀定会具备「最优⼦结构」,才能通过⼦问题的最值得 到原问题的最值。

另外,虽然动态规划的核⼼思想就是穷举求最值,但是问题可以千变万化, 穷举所有可⾏解其实并不是⼀件容易的事,只有列出正确的「状态转移⽅ 程」才能正确地穷举。

以上提到的重叠⼦问题、最优⼦结构、状态转移⽅程就是动态规划三要素。 具体什么意思等会会举例详解,但是在实际的算法问题中,写出状态转移⽅ 程是最困难的,这也就是为什么很多朋友觉得动态规划问题困难的原因,我 来提供我研究出来的⼀个思维框架,辅助你思考状态转移⽅程: 明确「状态」 -> 定义 dp 数组/函数的含义 -> 明确「选择」-> 明确 base case。

二、斐波那契数列

下⾯通过斐波那契数列问题和凑零钱问题来详解动态规划的基本原理。前者 主要是让你明⽩什么是重叠⼦问题(斐波那契数列严格来说不是动态规划问 题),后者主要举集中于如何列出状态转移⽅程。

请读者不要嫌弃这个例⼦简单,只有简单的例⼦才能让你把精⼒充分集中在 算法背后的通⽤思想和技巧上,⽽不会被那些隐晦的细节问题搞的莫名其 妙。想要困难的例⼦,历史⽂章⾥有的是。

1、暴⼒递归

斐波那契数列的数学形式就是递归的,写成代码就是这样:

int fib(int N) { 
if (N == 1 || N == 2) return 1; 
return fib(N - 1) + fib(N - 2); 
}

这个不⽤多说了,学校⽼师讲递归的时候似乎都是拿这个举例。我们也知道 这样写代码虽然简洁易懂,但是⼗分低效,低效在哪⾥?假设 n = 20,请画 出递归树。

PS:但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复 杂度,寻找算法低效的原因都有巨⼤帮助。

这个递归树怎么理解?就是说想要计算原问题 f(20) ,我就得先计算出⼦ 问题 f(19) 和 f(18) ,然后要计算 f(19) ,我就要先算出⼦问题 f(18) 和 f(17) ,以此类推。最后遇到 f(1) 或者 f(2) 的时候,结果已知,就 能直接返回结果,递归树不再向下⽣⻓了。 递归算法的时间复杂度怎么计算?⼦问题个数乘以解决⼀个⼦问题需要的时 间。⼦问题个数,即递归树中节点的总数。

显然⼆叉树节点总数为指数级别,所 以⼦问题个数为 O(2^n)。 解决⼀个⼦问题的时间,在本算法中,没有循环,只有 f(n - 1) + f(n - 2) ⼀ 个加法操作,时间为 O(1)。 所以,这个算法的时间复杂度为 O(2^n),指数级别,爆炸。

观察递归树,很明显发现了算法低效的原因:存在⼤量重复计算,⽐如 f(18) 被计算了两次,⽽且你可以看到,以 f(18) 为根的这个递归树体量 巨⼤,多算⼀遍,会耗费巨⼤的时间。更何况,还不⽌ f(18) 这⼀个节点 被重复计算,所以这个算法及其低效。

这就是动态规划问题的第⼀个性质:重叠⼦问题。下⾯,我们想办法解决这 个问题。点击链接可获取全部的数据结构算法视频课程。请多关注,下次再说带备忘录的递归解法。