算法:动态规划从斐波那契数列开始

题目来源

LEETCODE

题目描述

在这里插入图片描述

解答

暴力递归:时间复杂度O(N^2)

递归写法:

class Solution {
    int helper(int n){
        // base case
        if (n == 0 || n == 1){
            return n;
        }

        // 普通情况
        return helper(n - 1)  + helper(n - 2);
    }
public:
    int fab(int n){
        // 检查不合法参数
        if(n < 0){
            return -1; 
        }
        // 开始递归
        return helper(n);
    }
};

递归的栈写法:

int helper_stack(int num){
    if (num == 0 || num == 1){
        return num;
    }

    std::stack<int> stack;
    stack.push(num);
    int ans = 0, data = 0;
    while (!stack.empty()){
        data = stack.top();
        stack.pop();
        if (data < 2){
            ans += data;
        }else{
            stack.push(data - 1);
            stack.push(data - 2);
        }
    }

    return ans;
}

在这里插入图片描述
递归树

在这里插入图片描述
对应递归树如上图。

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),指数级别,爆炸

递归算法的空间复杂度:

  • 空间复杂度:O(N)
    • 每一个i都要入栈一次,出栈一次,所以空间复杂度是O(N)。
    • 该堆栈跟踪 fib(N) 的函数调用,随着堆栈的不断增长如果没有足够的内存则会导致 StackOverflowError。

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

这就是动态规划问题的第一个性质:重叠子问题

ps:关于[最优子结构]

  • 动态规划的另⼀个重要特性叫做「最优⼦结构」。这道题没有涉及,因为它不需要求最值,所以斐波那契数列严格来讲并不算动态规划

下面我们想办法解决这个问题

带备忘录的递归算法:时间复杂度 O(n)

既然耗时的原因是重复计算,那么我们可以用一个[备忘录]:

  • 每次算出某个子问题的答案后别急着返回,先记到[备忘录]里再返回
  • 每次遇到一个子问题时先去[备忘录]里查一查,如果发现之前已经解决过这个问题了,就直接把答案拿出来用,不要再去重复计算了

一般使用数组来充当这个[备忘录],当然可以用哈希表

class Solution {
    int helper(std::vector<int> memo, int n){
        if (n == 0 || n == 1){
            return n;
        }

        // 已经计算过
        if(memo[n] != 0){
            return memo[n];
        }

        memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
        return memo[n];
    }
public:


    int fab(int n){
        if(n < 0){
            return -1;
        }
        
        // 备忘录全初始化为 0
        std::vector<int> memo(n + 1, 0);
        return helper(memo, n);
    }

};

其对应递归树如下:

在这里插入图片描述
实际上,带[备忘录]的递归算法,把一颗存在存在巨量冗余的递归树通过「剪枝」,改造成了⼀幅不存在冗余的递归图,极⼤减少了⼦问题(即递归图中节点)的个数。

在这里插入图片描述
递归算法的时间复杂度怎么算?⼦问题个数乘以解决⼀个⼦问题需要的时
间。

  • ⼦问题个数,即图中节点的总数,由于本算法不存在冗余计算,⼦问题就是f(1) , f(2) , f(3) … f(20) ,数量和输⼊规模 n = 20 成正⽐,所以⼦问题个数为 O(n)。
  • 解决⼀个⼦问题的时间,同上,没有什么循环,时间为 O(1)。

所以,本算法的时间复杂度是 O(n)。⽐起暴⼒算法,是降维打击。

⾄此,带备忘录的递归解法的效率已经和迭代的动态规划解法⼀样了。实际上,这种解法和迭代的动态规划已经差不多了,只不过这种⽅法叫做「⾃顶向下」,动态规划叫做「⾃底向上」。

  • 什么叫做[自顶向下]呢?注意到我们刚刚画的递归树,是从上到下延伸,都是从一个规模较大的原问题比如f(20),向下逐层分解规模,直到f(1)f(2)触底,然后逐层返回答案,这就是[自上而下]
  • 什么叫做[⾃底向上]呢?我们从最下面,最简单,问题规模最下的f(1)f(2)开始向上推,直到推到我们想要的答案f(20),这就是动态规划的思路。这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算

dp 数组的迭代解法:时间复杂度 O(n)

思路一:暴力递归改动态规划(推荐)

我们先回到原始的递归方程:

class Solution {
    // 递归函数
    int helper(int n){
        // base case
        if (n == 0 || n == 1){
            return n;
        }

        // 普通情况
        return helper(n - 1)  + helper(n - 2);
    }
public:
    int fab(int n){
        // 检查不合法参数
        if(n < 0){
            return -1; 
        }
        // 开始递归
        return helper(n);
    }
};

(1)准备一个表

  • 所谓的动态规划,就是填表。这个表一般用数组来表示。
  • 问题是,这个数组是什么样的呢?是一维的还是二维还是三维?数组应该有多大?
  • 我们可以通过分析递归函数的可变参数有几个,其变化范围有多大来决定
 int helper(int n)
  • 上面,可变参数为num,只有一个,所以应该准备一个一维数组
  • num的变化范围是0~n,所以数组长度为:n+ 1
 std::vector<int> dp(n + 1)

(2)确定返回值

  • 怎么确定呢?通过看主函数是怎么调用递归函数的
return helper(n);
  • 所以应该返回 d p [ n ] dp[n] dp[n]

(3)填表
(3.1) 先初始化表

  • 用base case初始化表
	  if (n == 0 || n == 1){
            return n;
        }
  • 因此:
dp[0] = 0;
dp[1] = 1;

(3.2) 再填写表的其他位置。

  • 首先,看依赖
return helper(n - 1)  + helper(n - 2);
  • 可以看到,它主要依赖左边两个位置的格子。所以,应该从左往右填写,并从n = 2开始填表
for (int i = 2; i <= N; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
}

(4)综上所述,代码为:

class Solution {
public:
    int fib(int N) {
        if (N <= 1) return N;
        vector<int> dp(N + 1);
        dp[0] = 0;
        dp[1] = 1;
        for (int i = 2; i <= N; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[N];
    }
};

思路二:状态转移方程

这里我们要用一个一维dp数组来保存递归的结果。

(1)确定dp数组以及下标的含义

  • dp[i]的定义为:第i个数的斐波那契数值是dp[i]

(2)确定递推公式

  • 题目已经把递推公式直接给我们了:状态转移方程 dp[i] = dp[i - 1] + dp[i - 2];

(3)dp数组如何初始化

  • 题目中把如何初始化也直接给我们了,如下:
dp[0] = 0;
dp[1] = 1;

(4)确定遍历顺序

从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的

(5)举例推导dp数组

按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导一下,当N为10的时候,dp数组应该是如下的数列:

0 1 1 2 3 5 8 13 21 34 55

如果代码写出来,发现结果不对,就把dp数组打印出来看看和我们推导的数列是不是一致的。

(6)代码实现

class Solution {
public:
    int fib(int N) {
        if (N <= 1) return N;
        vector<int> dp(N + 1);
        dp[0] = 0;
        dp[1] = 1;
        for (int i = 2; i <= N; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[N];
    }
};

在这里插入图片描述
可以看出,这个 DP table 特别像之前那个「剪枝」后的结果,只是反过来算⽽已。实际上,带备忘录的递归解法中的[备忘录],最终完成后就是这个 DP table,所以说这两种解法其实是差不多的,⼤部分情况下,效率也基本相同。

这⾥,引出「状态转移⽅程」这个名词,实际上就是描述问题结构的数学形式:

在这里插入图片描述
为啥叫「状态转移⽅程」?把 f(n) 想做⼀个状态 n,这个状态 n 是由状态 n - 1 和状态 n - 2 相加转移⽽来,这就叫状态转移,仅此⽽已。

可以发现,上⾯的⼏种解法中的所有操作,例如 return f(n - 1) + f(n - 2),dp[i] = dp[i - 1] + dp[i - 2],以及对备忘录或 DP table 的初始化操作,都是围绕这个⽅程式的不同表现形式。可⻅列出「状态转移⽅程」的重要性,它是解决问题的核⼼。很容易发现,其实状态转移⽅程直接代表着暴⼒解法。

千万不要看不起暴⼒解,动态规划问题最困难的就是写出状态转移⽅程,即这个暴⼒解。优化⽅法⽆⾮是⽤备忘录或者 DP table,再⽆奥妙可⾔。

空间优化:时间复杂度 O(n)

根据斐波那契数列的状态转移⽅程,当前状态只和之前的两个状态有关,其实并不需要那么⻓的⼀个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就⾏了。所以,可以进⼀步优化,把空间复杂度降为 O(1):

class Solution {
public:
    int fib(int N) {
        if (N <= 1) return N;
        int dp[2];
        dp[0] = 0;
        dp[1] = 1;
        for (int i = 2; i <= N; i++) {
            int sum = dp[0] + dp[1];
            dp[0] = dp[1];
            dp[1] = sum;
        }
        return dp[1];
    }
};

矩阵乘法:时间复杂度O(logN)

矩阵乘法(Matrix multiplication)最重要的方法是一般矩阵乘积。它只有在第一个矩阵的列数(column)和第二个矩阵的行数(row)相同时才有意义。

矩阵是什么

矩阵是什么?是一个数字阵列,一个二维数组,n行r列的阵列称为n*r矩阵。如果n==r则称为方阵。

下图为2×3矩阵
在这里插入图片描述
下图为5×5方阵
在这里插入图片描述
单位矩阵

除了对角线为1,其他位置为0的矩阵。类似乘法中的1.

如下为3×3单位矩阵
在这里插入图片描述

矩阵加法

矩阵加法就是相同位置的数字加一下。
在这里插入图片描述
矩阵减法也类似。

矩阵乘

矩阵乘以一个常数,就是所有位置都乘以这个数。
在这里插入图片描述

矩阵乘法

矩阵乘以矩阵时
在这里插入图片描述
怎么算出来的呢?

教科书告诉你,计算规则是,第一个矩阵第一行的每个数字(2和1),各自乘以第二个矩阵第一列对应位置的数字(1和1),然后将乘积相加( 2 x 1 + 1 x 1),得到结果矩阵左上角的那个值3。

在这里插入图片描述

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

再举个例子

在这里插入图片描述

在这里插入图片描述
也就是说,结果矩阵第m行与第n列交叉位置的那个值,等于第一个矩阵第m行与第二个矩阵第n列,对应位置的每个值的乘积之和。也就是:
在这里插入图片描述

怎么会有这么奇怪的规则?关键在于,矩阵的本质就是线性方程式,两者是一一对应关系。如果从线性方程式的角度,理解矩阵乘法就毫无难度。

举个例子,下面是一组线性方程式。
在这里插入图片描述
矩阵的最初目的,只是为线性方程组提供一个简写形式。

在这里插入图片描述

注意:当矩阵A的列数(column)等于矩阵B的行数(row)时,A与B可以相乘。

斐波那契数列与矩阵乘法

在这里插入图片描述
那么问题的本质,就是如何解决计算这一块:

在这里插入图片描述

那应该怎么快速算出N次方呢?

问题:假设一个整数是10,那么该如何最快的求解10的75次方呢?

  1. 75的二进制数形式为1001011
  2. 10的75次方= 1 0 64 ∗ 1 0 8 ∗ 1 0 2 ∗ 1 0 1 10^{64} * 10^{8} * 10^2 * 10^{1} 1064108102101
    • 在这个过程中,我们先求出 1 0 1 10^1 101,然后根据 1 0 1 10^1 101求出 1 0 2 10^2 102,再根据 1 0 2 10^2 102求出 1 0 4 10^4 104,根据 1 0 4 10^4 104求出 1 0 8 10^8 108,根据 1 0 8 10^8 108求出 1 0 16 10^{16} 1016,根据 1 0 16 10^{16} 1016求出 1 0 32 10^{32} 1032,最后根据 1 0 32 10^{32} 1032求出 1 0 64 10^{64} 1064
    • 即75的二进制形式有多少位,就是用来多少次乘法
  3. 在步骤2进行的过程中:
    • 因为 1 0 64 、 1 0 8 、 1 0 2 、 1 0 1 10^{64}、10^{8}、 10^2、10^1 1064108102101相乘,因为64、8、2、1对应的75而二进制数中,相应的位上是1
    • 1 0 32 、 1 0 16 、 1 0 4 10^{32}、10^{16}、 10^4 10321016104不应该相乘,因为32、16、4对应的75而二进制数中,相应的位上是0

矩阵同理,所以其代码实现如下:

class Solution {
    // 两个矩阵乘完之后的结果返回
    std::vector<std::vector<int>>  product(std::vector<std::vector<int>> a, std::vector<std::vector<int>> b) {
        int n = a.size();
        int m = b[0].size();
        int k = a[0].size(); // a的列数同时也是b的行数
        std::vector<std::vector<int>> ans = std::vector<std::vector<int>>(n, std::vector<int>(m));
        for(int i = 0 ; i < n; i++) {
            for(int j = 0 ; j < m;j++) {
                for(int c = 0; c < k; c++) {
                    ans[i][j] += a[i][c] * b[c][j];
                }
            }
        }
        return ans;
    }
    std::vector<std::vector<int>> matrixPower(std::vector<std::vector<int>> m, int p){
        std::vector<std::vector<int>> res = std::vector<std::vector<int>>(m.size(), std::vector<int>(m[0].size()));
        for (int i = 0; i < res.size(); ++i) {
            res[i][i] = 1;
        }

        // res = 矩阵中的1
        auto t = m;// 矩阵1次方
        for (; p != 0; p >>= 1) {
            if ((p & 1) != 0) {
                res = product(res, t);
            }
            t = product(t, t);
        }
        return res;
    }
public:
    int fib(int n) {
        if (n < 1) {
            return 0;
        }
        if (n == 1 || n == 2) {
            return 1;
        }
        // [ 1 ,1 ]
        // [ 1, 0 ]
        std::vector<std::vector<int>> base = {
                { 1, 1 },
                { 1, 0 }
        };
        auto res = matrixPower(base, n - 2);
        return res[0][0] + res[1][0];
    }
};

犯过的错误

最终结果可能需要取模

class Solution {
public:
    int fib(int n) {
        int MOD = 1000000007;
        if (n < 2) {
            return n;
        }
        int p = 0, q = 0, r = 1;
        for (int i = 2; i <= n; ++i) {
            p = q; 
            q = r; 
            r = (p + q)%MOD;
        }
        return r;
    }
};

小结

  • 通过这一题,我们可以知道什么叫做【重叠子问题】,因为它不需要求最值,所以它没有[最优子结构]
  • 还可以看出,可以通过「备忘录」或者「dp table」的⽅法来优化递归树。这两种⽅法本质上是⼀样的,只是⾃顶向下和⾃底向上的不同⽽已。

矩阵乘法与动态规划有什么关系

  • 如果除了base case之外,对于每一个f(n),都有一个严格的递推式,比如f(n) = f(n - 1)之类,那么就可以用矩阵乘法将之改为O(log_n)的时间复杂度操作
  • 如果对于每一个f(n),它没有严格的递推式,它的递推式是有条件的,就不可以用。比如:
    • 当n = 偶数时,f(n) = 3 * f(n - 1)
    • 当n = 奇数时,f(n) = -3 * f(n - 1)
    • 像上面那样的就是有条件的递推式

补充:什么叫做二阶递推

比如,因为f(n) = f(n - 1) + f(n - 2),对于每一个n,它减少的最多的是2,所以它是一个二阶递推

所以,它一定也可以用矩阵乘法的形式表示,而且状态矩阵为 2 ∗ 2 2*2 22的矩阵
在这里插入图片描述
然后,我们用递推公式带入,就可以得到a = 1、b = 1、c = 1、d = 0
在这里插入图片描述

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值