动态规划(一):以最简单的斐波拉契数列为例子初步认识动态规划

前言

  • 你问我为什么要学习算法?我的回答是:多一个技能点就多一个机会。程序员越来越多,门槛也越来越高。学如逆水行舟,不进则退,咱们得保持一直持续学习的心态。算法和数据结构作为程序员的内功,能决定你以后职业生涯的高度!无论如何,咱们都得克服困难,攻克它!
  • 那为什么第一篇与算法有关的文章就是大家望而却步的动态规划呢?因为它很难,有必要总结下~那我们现在就开始斐波拉契数列之旅吧。

一、什么是斐波拉契数列

  • 我相信在大家学习递归算法时,第一个接触的就是斐波拉契数列问题吧。我这里重温下,斐波拉契数列问题:

    存在这么一个数列:0、1、1、2、3、5、8、13、21、34; 其中f(0) = 0, f(1) = 1, 求f(n)

    通过题目的描述,我们很容易发现特点:n >= 2时,当前元素等于前两个元素之和,即fib(n) = fib(n - 1) + fib(n - 2); 其中n >= 2,于是我们可以快速解题。

二、常用解法 - 递归

  • 大家都知道斐波拉契数列的解决方案很简单,使用递归,一行代码就能解决,如下:
    public static int fib(int n) {
        return n <= 1 ? n : fib(n - 1) + fib(n - 2);
    }
    
    咱们做算法题,要考虑它的空间和时间复杂度(也许现在是互联网时代,在高并发项目下,大家好像更倾向的是时间复杂度的优化)。那咱们也来分析下它的时间复杂度吧。它的时间复杂度为:O( 2 n 2^n 2n),我以求fib(5)为例子画出了它的执行流程,如下所示:
    在这里插入图片描述
    实际调用fib方法的次数为:14次,可以看到,整个结果下来,其实斐波拉契数列的递归解法的流程会产生一棵二叉树,若将二叉树的所有节点都填充的话(变成一棵满二叉树),f(5)的时间复杂度为:O( 2 5 2^5 25),大家可以数一下,树的节点总数为: 2 0 2^0 20 + 2 1 2^1 21 + 2 2 2^2 22 + 2 3 2^3 23 + 2 4 2^4 24 = 1 + 2 + 4 + 8 + 16 = 31 ≈ \approx 32 = 2 5 2^5 25。将树结构画出来后,有个很明显的问题,就是有很多重复的计算,比如在求f(4)的时候,其实f(3)已经计算过了,但还需要重复计算一次。为了解决重复计算的问题,我们可以把计算过的值缓存起来,于是,我们把算法进行优化,添加缓存

三、引入缓存,优化时间复杂度

  • 要添加缓存,在java中,最常见的就是使用hashMap,因为它的时间复杂度为O(1),且我们无需范围查找,那么首选hash类型的缓存(额外知识点:MySQL中hash索引的应用场景之一)。于是我们使用map来尝试下,优化后的代码如下:
    private static Map<Integer, Integer> cache = new HashMap<>();
    
    public static int fibCache(int n) {
        if (cache.get(n) == null) {
            cache.put(n, n <= 1 ? n : fibCache(n - 1) + fibCache(n - 2));
        }
        return cache.get(n);
    }
    
    针对优化后的解法,我画了一幅图来加深理解:
    在这里插入图片描述
    由上图可知,整个斐波拉契数列求fib(5)时,由于添加了缓存的原因,只需要计算5次即可,即fib(0)、fib(1)、fib(2)、fib(3)、fib(4),虽然fib(4) = fib(3) + fib(2),但只要fib(3)或者fib(2)执行过一次,就会被添加至缓存中。最终,时间复杂度优化成了O(n)。咱们是不是该好好庆祝下,整个算法的时间复杂度从O( 2 n 2^n 2n)降到了O(n)。但其实这还不是最优解,因为递归是很耗费空间的,每递归一次就要占用一次调用栈,况且还额外新增了一个HashMap的数据结构(底层是数组 + 链表(不考虑转换成红黑树)),又占用了一点空间。那我们能不能不使用递归且不需要额外新增HashMap缓存来解决呢?

四、动态规划

  • 回到最初,斐波拉契数列的模样是这样子的:

    0、1、1、2、3、5、8、13、21、34

  • 从下(后)往上(前)分析推导出斐波拉契数列的递推关系式,并结合缓存实现动态规划

    我们仔细观察后,很容易发现:从第n >= 2开始,当前位置的值是前两个元素的和。所以我们将数列的规则以表达式的方式定义出来:opt[n] = opt[n - 1] + opt[n - 2];其中n>=2,且opt[0] = 0; opt[1] = 1
    有了这个后,我们很容易求出opt(n),因为opt(n)就是斐波拉契数列的递推关系表达式。但是,当我们求opt(5)时,发现还需要知道opt[4]和opt[3]的值才行(PS: 思考到这里就停止,不要继续去想opt[4]的计算需要opt[3]和opt[2],这样很容易把自己陷进去),那么能不能假设opt[4]和opt[3]的值已经存在了,直接拿结果(因为我们肯定知道opt[3]和opt[4]是要计算出来的,所以此时可以理解成计算出来就把它放入缓存中,此时在计算opt[5]时认为opt[3]和opt[4]已经存在了也是合理的)。如果是这样的话,那opt数组其实就是第三章中的hashMap缓存了。

    于是,我们尝试解读下如下代码:

    public static int dynamicFib(int n) {
        // 定义上述说的opt数组,长度为什么为n + 1呢?
        // 因为我们要求opt[5], 实际上它位于的是数组
        // index为5的位置上,index == 5,那是不是说明
        // 数组长度为6, 因此opt数组长度为n + 1
        int opt[] = new int[n + 1];
        // opt[0]和opt[1]的赋值为0和1,由题目可知
        opt[0] = 0;
        opt[1] = 1;
    	
    	// 一层循环,i从2开始(满足上述的n >= 2时,才使用递推关系式进行求解)
        for (int i = 2; i <= n; i++) {
            // 递推关系式写在循环里面
            // 当求opt[2]是,计算了opt[0]和opt[1]
            // 而opt[0]和opt[1]是已知条件
            // 计算完后,就把值存在了opt中index = 2
            // 的位置上了,完成了缓存的操作
            opt[i] = opt[i - 1] + opt[i -2];
        }
    	// 数组中的最后一个元素就是要求的结果。
        return opt[n];
    }
    

    上述代码,就是使用动态规划的方式解决了斐波拉契数列的问题,时间复杂度为O(n),因为遍历了整个opt数组,只开启了一个长度为n的数组,所以空间复杂度为O(n)。至此,从空间复杂度和时间复杂度两个维度来考虑,使用动态规划的方式解决斐波拉契数列问题是最优的

五、动态规划题目解题思路

  • 递归 + 记忆化(缓存) => 递推关系式

    从斐波拉契数列的递归解法开始,到添加缓存优化时间复杂度,再到动态规划。要解决动态规划问题,若不能马上找到递推公式,可以从递归方法开始着手。

  • 状态的定义: opt[n], dp[n], fib[n]

    所谓opt[n], dp[n], fib[n]表示的都是针对这个问题的递推公式,比如在斐波拉契数列的递推公式中,fib(n) = fib(n - 1) + fib(n - 2); 其中fib(n)表示的就是斐波拉契数列中的一种状态(求f(n)的状态)

  • 状态转移方程:opt[n] = best_of(opt[n - 1], opt[n - 2], …)

    所谓状态转移方程,通常指的就是递推关系式,使用状态转移方程可以求解决最优子结构(最优子问题)

  • 最优子结构

    最优子结构通常指的就是算法题中的一些最优解,比如一些常见的最值问题,通常都可以从状态转移方程中得到。

六、总结

  • 动态规划题目很难,我们需要不断练习找灵感。斐波拉契数列是最简单的动态规划题,但我们通常使用的都是时间复杂度为O( 2 n 2^n 2n)的递归算法,如果能使用动态规划的方式进行求解,那说明你很棒!
  • 本片博客是自己学习动态规划的一些总结,下篇博客内容将围绕计算不同路径的数目的算法题进行总结,进一步加深对动态规划的了解
  • I am a slow walker, but I never walk backwards.
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值