“ 动态规划的本质是一种更加高级的穷举。”
相比与线性规划,非线性规划,整数规划等数学规划算法,它们更多是通过构建优化目标的函数关系与约束条件,进一步通过科学计算来求的最优解;而动态规划(Dynamic Programming)则是通过看似穷举的方式,来逐步递推出最优解。但是这一种穷举又是一种更加高效,特殊的穷举,要理解它的高效性和特殊性,就要先从理解一般的low穷举算法聊起:
01
—
从一般穷举到递归
在聊穷举算法之前,我们需要先看两个用动态规划求解相关的实例:
一是斐波那契数列问题:
这是数学家列昂纳多.斐波那契发现的一类数列,是来自兔子繁殖的规律,所以又称之为兔子数列。
0、1、1、2、3、5、8、13、21、34、55...
很容易观察出规律:从第三项开始每一项数值都是前两项数值之和。于是,我们可以设第n项的数值为F(n),则可以得到如下表达公式:
F(0)=0, F(1) = 1 F(n)=F(n-1)+F(n-2),n为≥2的整数
二是打家劫舍问题,这是leetcode官网上面的一道经典动态规划问题:
描述的给定了k家房屋及其里面存放的现金金额数,现在有一位小偷想要偷窃这些现金,但由于房屋之间的防盗装置作用,小偷不能连续偷窃相邻房屋的现金,否则会触发防盗装置,导致偷窃失败。问小偷在这k家房屋偷窃的可行方案中,能够偷窃到的最大金额E(k)是多少?
从上面给出的两个案例问题的描述可以看出,最优解其实是可以通过很笨的穷举算法最终求解出来:
比如说,在兔子数列当中,若想要计算第50项的具体数值,完全可以通过逐一列举,最终得到第五十项的兔子数列值。但在现实当中,完全通过列举得到答案,显然是不切实际的,工作量是巨大的,所以我们需要借助科学计算的方法,来求解。于是,递归的算法就是一种选择。
递归,简而言之,就是自身调用自身的函数/程序。比如最简单的阶乘运算就是一种递归算法的运用,matlab代码如下:
function F= factorial(n) %利用递归求正整数n的阶乘
if n==1 %递归的出口
F=1;
else
F=n* factorial(n-1);
end
end
代码当中显示,递归的运用需要满足两个条件:
(1)原问题与子问题须为同一件事情,且子问题更为简单。在本例当中,原问题可以是求8的阶乘,而子问题可以是求7的阶乘,明显更加简单。
(2)程序需要有一个出口,不可无限制地调用本身。比如,在本例当中,1的阶乘是已知的,这就是一个出口。
明显,递归算法通过简洁的代码,就可以实现相较于穷举的更高效的求解。然而,如果考虑到该算法求解的时间复杂度,递归算法不一定最为高效。通过前面介绍的兔子数列,当求解的项数下表很大的时候,程序运行的时间将会很长,那这是什么原因呢?
就是因为:传统的递归算法存在很多重复计算的地方,用一个递归图解就可以一目了然了:
![](https://i-blog.csdnimg.cn/blog_migrate/eca1983db0341c93147c67c85bbd2e2b.png)
从兔子数列的递归图可以看出,有很多重复计算的地方,比如F(3),F(2)等等。这个时候,要想降低代码算法的时间复杂度,不得不引出我们的中级boss:动态规划(DP)算法。
02
—
自底向上的DP算法
要想解决重复计算的问题,我们就需要让代码有记忆的功能:即讲已经计算求出来的结果,先保存到一个类似备忘录的地方(实际上就是后面要讲的DP数组),再返回。
因此,我们需要将已经计算出来的结果先存起来。但是,这个DP算法又具有两种的形式,第一种是自顶向下的备忘录算法,另一种则是我们本文要着重聊的自底向上的DP算法。
要想理解DP问题的自底向上的算法核心,我们需要建立起一个框架,同时结合两个典型的案例,加深对框架的理解和运用。
03
—
原问题与子问题
原问题,无疑就是我们需要求解的问题本身。而子问题,就是和原问题类似,但是其规模和求解难度上都比原问题都要小得多的问题。
比如,在前面提到的兔子数列当中,假设题目要求我们求解出该数列的第50项的数值是多少?那么,求出F(50)就是该问题的原问题。于是,求解第49项的数值,求解第48项的数值........等等都是可以称之为子问题。
再比如,在打家劫舍这一问题当中,假设题目要求,求解出前10家房屋小偷可以偷窃得到的最大金额。那么,求解F(10)就是一个原问题,所以也可以将原问题理解成需要求解的直接的目标。于是,根据子问题的定义:求解前9家房屋,前8家房屋......小偷能够偷窃得到的最大金额,就是原问题的各个子问题。
为何要首先明确出原问题和子问题呢?其实,就是有前面所讲的:DP问题实质上是一种更加高级的穷举问题,而他的高级就体现在——通过求解一个个规模较小,较容易求解的子问题,再逐步递推,得到最终的求解目标——原问题的答案。
于是,我们知道,要想实现原问题的成功求解,就必须先求出子问题,那么,当求解出来子问题(子问题的求解一般都比较容易,一般就是从最简单,最基本的子问题求解开始)后,如何根据子问题得到更高一级子问题的答案呢?从而最终得出原问题的答案呢?(原问题就是最高级的子问题)
04
—
状态及其转移关系
在理解子问题如何向更高一级子问题转化之前,我们来聊一聊什么叫状态?其实,通俗的理解,状态就是函数的自变量,而函数值就是在该状态(自变量)下的对应的结果。
在这里,我们还是结合上面给出的两个例子来加以理解:
在第一个兔子数列问题当中,状态其实就是求解的 “第n项数值” 当中的n。不同的状态(项数n),都对应一个不同状态下的结果(F(n)的值,即该项的数值)。
在第二个打家劫舍问题当中,状态就是 “前K家小偷能够偷窃的最大金额” 中的k。不同的状态(前k家房屋),都对应一个不同状态下的结果(E(k)的值,即前k家能够偷的最大金额)
综上所述,就可以看出状态就是对应在函数里,我们常说的自变量。
在传统函数当中,我们要求解某一自变量下的函数值,只需要根据已知的一组自变量和函数值,来构建一般式的函数关系,再用来求解未知自变量下的函数值。相比传统函数,DP问题则是要求解在某一状态(自变量)下对应的结果值(函数值),但是不同的是,求解这一结果,我们无法通过一组状态与结果值来确定一个一般式关系,因为每一个状态下的结果都是受之前更低级的状态结果的影响的。
通俗讲就是,不同状态下的结果值不是像函数值之间一样相互独立的。每一个状态下的结果值,都无法仅由该状态来求得。
正是这种状态结果值之间的互相影响和依赖性,我们在DP问题就不是再需要去构建状态与其结果值之间的函数关系了,而是要转为构建不同状态结果值之间转移关系——“状态转移方程”。
05
—
核心—状态转移方程
到这儿,基本上可以确定状态转移方程的确定,一定是整个DP问题解决框架中的最核心和关键的问题了。
以下依旧以上文两个实例来加强理解:
在兔子数列中,比如,第50项数值不是由项数50来决定的,而是由之前的状态下的结果值F(49),F(48)来决定的。至于这三个状态之间的关系,题目已经给出了明确:
F(0)=0, F(1) = 1
F(n)=F(n-1)+F(n-2),n为≥2的整数
这样一个状态之间的转移关系,我们可以将它形容成:我们逐一通过子问题最终求得原问题的 “阶梯”。
再看打家劫舍问题中,根据题目所给的约束条件:小偷不能偷窃连续相邻两家的现金。根据这一条件,我们也可以写出状态之间的转移关系:
M(1), k =1
E(k)= max{M(1),(M2)}, k = 2
max{f(k-1),f(k-2)+M(k)},k是≥3的整数
仔细理解这两个问题的状态转移关系方程,可以看出他们的共同点:
两个转移关系都有一个最简单的子问题不需要运用转移关系就已知其结果。比如,在兔子数列里面,第0项和第1项数值都是已知给出的。在打家劫舍问题中,偷窃前1家和偷窃前2家能够偷得的最大金额数值是已知确定的。
那么为什么必须给出最简单的子问题结果值呢?其实就是为了原问题的求解有一个出口,或者有一个自底向上开始的地方。只有已知的最简单的子问题,才能由他们通过循环算法,逐一求出更高级的子问题的结果,进而最终得到原问题的结果。
05
—
DP数组与编程实现
以下给出两个问题的matlab代码:
(1)斐波那契数列求解代码:
% 动态规划之自底向上法 求解斐波那契数列(动态规划方法)
function F=fb_zdxs(n)
FF=ones(1,n); %定义一个DP数组,保存数列各项的数值
if n==1||n==2
F=1;
else
for i=3:n
FF(i)=FF(i-1)+FF(i-2);
end
F=FF(n);
end
end
(2)打家劫舍问题求解代码:
% 动态规划之自底向上方法 求解打家劫舍问题
function F=djjs_zdxs(M)
k=length(M); % k表示给定的房屋金额数组的长度,即房屋的数量。
if k==1
F=M(1);
elseif k==2
F=max(M(1),M(2));
else
FF=zeros(1,k); %定义一个DP数组:FF,用来保存FF(i)。
FF(1)=M(1); %初始化赋值该DP数组的出口,即最简单的子问题的答案。
FF(2)=max(M(1),M(2));
for i=3:k
FF(i)=max(FF(i-1),FF(i-2)+M(i)); %自底向上的状态转移方程。
end
F=FF(k);
end
end
在代码当中,我们看到了两者都定义了一个DP数组,这个数组的作用就在于:把从最简单的子问题开始,逐一求得的不同状态下的子问题的结果值都保存在这一数组当中,这样,在求解下一个状态的结果值得时候,就可以直接调用,这样可以避免重复计算,完美解决了传统递归算法当中得时间复杂度高(程序执行次数高)的缺点。
06
—
结尾
至此,介绍了DP问题的一般解题框架:
Step1.确定原问题与子问题。
原问题直接看问题求解目标,子问题按 “相似” “更简单” 的原则确定
Step2.确定状态转移方程。
相当于构建函数关系,但是这个关系是状态结果与前面状态结果之间的转移关系。这里有一个注意点:就是要确定最简单的子问题的结果值,也就是程序循环的一个出口,保证子问题的循环求解可以有结果,而不是无结果。
Step3.编程实现。
最后在编程实现阶段,需要注意定义一个DP数组,将每个子问题的数值存储起来,从而避免重复计算,实现时间复杂度的降低。
从这一框架也可以看出,DP本质上是一种穷举,只不过它相比一般穷举,是通过状态转移关系来逐一实现的,可以避免重复计算,还可以是算法更加简洁优化。