题目来源
题目描述
解答
暴力递归:时间复杂度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次方呢?
- 75的二进制数形式为1001011
- 10的75次方=
1
0
64
∗
1
0
8
∗
1
0
2
∗
1
0
1
10^{64} * 10^{8} * 10^2 * 10^{1}
1064∗108∗102∗101
- 在这个过程中,我们先求出 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的二进制形式有多少位,就是用来多少次乘法
- 在步骤2进行的过程中:
- 因为 1 0 64 、 1 0 8 、 1 0 2 、 1 0 1 10^{64}、10^{8}、 10^2、10^1 1064、108、102、101相乘,因为64、8、2、1对应的75而二进制数中,相应的位上是1
- 而 1 0 32 、 1 0 16 、 1 0 4 10^{32}、10^{16}、 10^4 1032、1016、104不应该相乘,因为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
2∗2的矩阵
然后,我们用递推公式带入,就可以得到a = 1、b = 1、c = 1、d = 0