const fib = (n) =>{ if(n=== 1 || n===2)return 1; return fib(n-1) + fib(n-2); } function Fibonacci(n){// 暴力递归 斐波那契数列的数学形式就是递归的 return fib(Number(n)) } module.exports = { Fibonacci : Fibonacci };我们也知道这样写代码虽然简洁易懂,但是十分低效,低效在哪里?假设 n = 20,请画出递归树:
首先计算子问题个数,即递归树中节点的总数。显然二叉树节点总数为指数级别,所以子问题个数为 O(2^n)。
然后计算解决一个子问题的时间,在本算法中,没有循环,只有 f(n - 1) + f(n - 2) 一个加法操作,时间为 O(1)。
所以,这个算法的时间复杂度为二者相乘,即 O(2^n),指数级别,爆炸。
观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 f(18) 被计算了两次,而且你可以看到,以 f(18) 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 f(18) 这一个节点被重复计算,所以这个算法及其低效。
这就是动态规划问题的第一个性质:重叠子问题。
2、带备忘录的递归解法const helper = (memo,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] } const fib = (n) =>{ let memo = Array(n+1).fill(0) return helper(memo,n) } function Fibonacci(n){ return fib(Number(n)) }
子问题个数,由于本算法不存在冗余计算,子问题就是 f(1), f(2), f(3) … f(20),数量和输入规模 n = 20 成正比,所以子问题个数为 O(n)。
解决一个子问题的时间,同上,没有什么循环,时间为 O(1)。
所以,本算法的时间复杂度是 O(n),比起暴力算法,是降维打击。
啥叫「自顶向下」?注意我们刚才画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 这两个 base case,然后逐层返回答案,这就叫「自顶向下」。
3、dp 数组的迭代(递推)解法
const fib = (n) =>{ if(n === 0)return 0; let dp = Array(n+1).fill(0); // base case dp[0] = 0;dp[1] = 1; // 状态转移 for(let i = 2;i <= n;i++){ dp[i] = dp[i-1] + dp[i-2]; } return dp[n] } function Fibonacci(n){ return fib(Number(n)) }
为啥叫「状态转移方程」?其实就是为了听起来高端。
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,再无奥妙可言。
我们会发现,根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态有关,其实并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了。
所以,可以进一步进行细节优化,把空间复杂度降为 O(1)。也就是我们最常见的计算斐波那契数的算法:
const fib = (n) =>{ if(n === 0 || n === 1){ return n;// base case } // let dp = Array(n+1).fill(0); // 分别代表 dp[i - 1] 和 dp[i - 2] let dp_i_1 = 1,dp_i_2 = 0; // 状态转移 for(let i = 2;i <= n;i++){ // dp[i] = dp[i-1] + dp[i-2]; let dp_i = dp_i_1 + dp_i_2; // 滚动更新 dp_i_2 = dp_i_1; dp_i_1 = dp_i; } return dp_i_1; } function Fibonacci(n){ return fib(Number(n)) }
这一般是动态规划问题的最后一步优化,如果我们发现每次状态转移只需要 DP table 中的一部分,那么可以尝试缩小 DP table 的大小,只记录必要的数据,从而降低空间复杂度。
上述例子就相当于把DP table 的大小从 n 缩小到 2。一般来说是把一个二维的 DP table 压缩成一维,即把空间复杂度从 O(n^2) 压缩到 O(n)。
动态规划的另一个重要特性「最优子结构」,没有涉及?斐波那契数列的例子严格来说不算动态规划,因为没有涉及求最值,以上旨在说明重叠子问题的消除方法,演示得到最优解法逐步求精的过程。