讲道理,我对dp这块的理解并不深,当初学的时候,也仅仅停留在一些名词上。比如大佬们经常说的数位dp,树形dp,区间dp,插头dp,棋盘dp,背包dp等等。。。
那究竟什么是dp呢?维基百科上是这样定义的:
DP就是一种方法,该方法能够将复杂的问题分解成为一系列简单的子问题。
时隔一段时间了,准备好好把dp这个算法总结总结,不会的地方抓紧补上,会的地方夯实夯实,写出一些自己不一样的见解,争取在每天的积累中,能够不断的进步。
在介绍DP之前,我们还是先来看看什么是递推,什么是递归?因为,我觉得,要想把dp的问题做好,必须对于递推和递归的理解够深入,这样才能在后续理解dp的过程中,游刃有余。
先从上楼梯问题1来说:
每次只能上1个台阶,或者两个台阶。求上到第n节台阶的总的方法数是多少?
这个问题,先从最原始的想法开始。
如果我们正着开始想。
当n=1时,只有1种方法
当n=2时,有2种方法
当n=3时,有3种方法
当n=4时,有5种方法
。。。
当n=k时候,聪明的读者可能会总结出规律来,答案为f(k-1)+f(k-2)
但是,如果你对于这些数字不敏感的话,有可能不能得出这个结论。这个时候,我们不妨把问题的角度转换下。比如说,我们从最后一节台阶来开始,倒着考虑问题。假如说我们站在最后一节台阶,那么怎么样才能达到最后一节台阶呢?
● 从倒数第二层,一次性上两节台阶到达最后一层
● 从倒数第一层,一次性上一节台阶到达最后一层
令最后一层台阶是n,这样,到达最后一节的方法数就是f(n) = f(n-1)+f(n-2)了。
然后,我们倒着推问题,最后就得到了一个边界条件,f(1)=1, f(2) = 2
那么无论n(n>=1)取多少,我们都可以得到最终的解。
关于f(n-1)和f(n-2)我们把它定义为最优子结构
f(1)=1, f(2) = 2定义为边界
f(n) = f(n-1)+f(n-2)定义为状态转移方程
这三个概念是所有dp问题中所包含的最重要的概念。我们必须要好好吃透它,关于这三个概念的定义和使用,下面的问题还会多次出现,如果不是很懂,不要紧,耐住性子,继续往下读就行了。
到这里,我们就能够写出来这个问题的递归求解过程
int fun( int step ) {
if ( step < 1 ) return 0;
if ( step == 1 ) return 1;
if ( step == 2 ) return 2;
else return fun(step-1)+fun(step-2);
}
是不是感觉我们已经把这个题目完美的解决了呢?我们再来分析下这个问题的时间复杂度和空间复杂度各是多少。
可以看出,时间复杂度是O(2^n),空间复杂度也是O(n)。
时间复杂度太高了,发现问题了吗?其实,在我们每次递归的过程中,都会有重复计算的部分。比如说,f(8) = f(7)+f(6) , f(7) = f(6)+f(5),但是当我们计算f(7)的时候,需要f(6)。
计算f(8)的时候也需要计算f(6)。这两个是同一个f(6)。故,只需计算一次就行了。
那么,我们就针对这个问题引入一个新的东西,叫做记忆化数组。
记忆化数组的作用就是干这样的事情的,使得那些需要重复计算过的部分仅仅计算一次,尽快的求出问题的解。类似hash的思想,来暂存计算结果。
实现代码如下:
int memory[MAX]={0};
int fun( int step ) {
if ( memory[step]!=0 ) return memory[step];
if ( step<1 ) return 0;
if ( step==1 ) return 1;
if ( step==2 ) return 2;
else return memory[step] = fun(step-1)+fun(step-2);
}
再来分析下时间复杂度和空间复杂度各为多少?
首先,时间复杂度就是O(n),空间复杂度也为O(n)
其实,做到这一步实际上就可以了,时间上已经是最快的了,你要是还想把空间复杂度再变低一些,那就是优化到O(1)。这个可以借助我们先前学过的斐波那契数列的循环式写法来做。
int fun( int step ) {
if ( step < 1 ) return 0;
if ( step == 1 ) return 1;
if ( step == 2 ) return 2;
int a = 1;
int b = 2;
int temp = 0;
for ( int i = 3;i <= step;i++ ) {
temp = a+b;
a = b;
b = temp;
}
return temp;
}
这个爬楼梯问题,我们已经解决的非常好了,那么接下来,我们再来将问题进一步的加强。
上楼梯问题2:
● 每次爬楼梯时,可以往上爬不超过4节( 1,2,3,4 )
● 求爬上N节楼梯的不同方案数
还是和刚刚的问题求解思路一样,我们从最后一步开始看问题。
f(n) = f(n-1)+f(n-2)+f(n-3)+f(n-4)
当n=4时,f(4) = f(3)+f(2)+f(1)+f(0)
那么,通过手算,我们得到f(1) = 1, f(2) = 2, f(3) = 4, f(4) = 8
这样来看f(0) = 8-4-2-1 = 1
所以,我们就得到了上楼梯问题2的最优子结构,边界,状态转移方程,该问题就顺利解决了。代码如下
int fun( int step ) {
if ( step==0 ) return 1;
if ( step==1 ) return 1;
if ( step==2 ) return 2;
if ( step==3 ) return 4;
return f(step-1)+f(step-2)+f(step-3)+f(step-4);
}
上楼梯问题3:
当我们把每次可以往上爬的台阶数规定为不超过n的时候。
那么,求上到第n节台阶所需要的方法数。
如果还是用前面的最优那个O(n)的方法, 当n趋向特别大的时候,该方法显然不成立。
那么,我们就可以通过使用矩阵乘法+快速幂的方法把问题降低到O(logn)的复杂度。
通过矩阵快速幂,就能达到O(logn)的复杂度
递归:
什么是递归呢?我对于递归的理解就是一个自己调用自己的过程,但是往往递归的过程中,必须要明确递归终止的条件是什么,如果你不知道递归终止的条件,那么就会导致栈溢出的问题。
和很多讲算法的书一样,讲解递归的时候,一开始给大家介绍的也是“汉诺塔”问题。
汉诺塔问题其实可以被拆分成三个步骤子问题来思考:
第一个步骤子问题:首先将A柱子上的前n-1个盘子,借助C柱子,移动到B柱子上
第二个步骤子问题:然后将A柱子上的第n个盘子,直接移动到C柱子上
第三个步骤子问题:将B柱子上的前n-1个盘子,借助A柱子,移动到C柱子上。
如果按照次序执行上述步骤子问题,我们就能得到最终问题的解。细心的读者可能会发现,第一个步骤子问题和第三个步骤子问题的本质是一样的,只不过方法所应对的参数有所不同。那这个时候递归就可以起到作用了。解决的代码如下所示: