第一讲内容来自《算法问题实战策略》【韩】具宗万著
重复子问题与制表
动态规划(DP,dynamic programming)和分治法具有类似的处理方式,都是先把问题分割成若干子问题,然后求出这些子问题
的解,再利用这些解得到整个问题
的最后答案。
不过,动态规划和分治法在分割子问题的方式上存在不同。DP中,有些子问题的计算结果会用于多个问题的求解过程(如果不理解参考下面的示例)。因此,在对子问题进行一次计算的情况下,重复利用其结果就能提高运算的速度。为了实现这种方法,需将子问题的解预先保存到内存中,保存的方法称为“制表”,能够重复利用两次以上的子问题称为重复子问题(overlapping subproblems)
典型示例
应用动态规划最有名的示例是二项式系数(binomial coefficient)的计算,二项式系数
表示在n个互不相等的元素中无顺序的挑选出r个元素的方法的总数。二项式系数具有如下递归式:
利用此递归式就可以编写出对给定的n和r返回 结果值的函数bino(n,r),如代码1-1所示:
//代码1-1
int bino(int n,int r){
//初始部分:n=r(选择完所有元素的情况)
//或r=0(没有可选元素的情况)
if(r==0||r==n)return 1;
return bino(n-1,r-1)+bino(n-1,r);
下图表示计算bino(4,2)过程中函数调用的流程。
值得注意的是,bino(2,1)将会被调用两次,因为计算bino(3,1)和bino(3,2)都需要先调用bino(2,1)。不仅如此,bino(1,0)和bino(1,1)也会调用两次。由此可见,有些子问题会被重复计算多次,并且,重复调用的次数会随着n和r的增大而呈几何级数增长。下表给出了计算bino(n,n/2)而调用的函数次数.
n | 2 | 3 | 4 | 5 | 6 | … | 18 | 19 | … | 24 | 25 |
---|---|---|---|---|---|---|---|---|---|---|---|
调用bino()的次数 | 3 | 5 | 11 | 19 | 39 | … | 97239 | 184755 | … | 5408311 | 10400599 |
那么有没有办法避开那些重复的计算呢?输入值n和r一定时,bino(n,r)的返回值也是一定的,利用该原理就可以去掉重复计算。
首先,定义一个缓存数组,查询有没有相应的结果值。如果有,就返回数组中的值,否则直接计算;计算结果先存储到数组中,然后再返回。这种“先定义保存函数结果值的空间,然后重复使用结果值”的优化方法称为制表(memorization)
int cache[30][30];//初始化为-1
int bino2(int n,int r){//我们用把经过优化的函数称为bino2
//初始部分
if(r==0||n==r)return 1;
//如果不是-1,则说明这个值是之前已计算过的结果值,直接返回。
if(cache[n][r]!=-1)
return cache[n][r];
//直接计算并保存到数组中。
return cache[n][r]=bino2(n-1,r-1)+bino2(n-1,r);
去重后bino2()的调用的情况如下图:
适用制表方法的情况
有些函数的返回值只依赖于输入值,这种特性称为引用透明性(referential transparency);而有的函数不具备这种特性,因为函数的执行不仅依赖于输入值,而且会受到全局变量、输入文件、类的成员变量等诸多因素的影响。当然,制表的方法只适用于具有引用透明性的函数。
实现制表的范式
范式,即模板。对于一个算法,掌握一种范式是非常好的习惯。不必要求每个人都必须按照下面的范式编写,只是有一种适合自己的固定格式。
//把所有的数组元素初始化为-1
int cache[2500][2500];
//a和b是[0,2500)区间的整数
//返回值总是int类型的非负整数
int someObscureFunction(int a,int b){
//先处理初始化部分
if(...)return...;
//已求解过(a,b),直接返回解过的值
int& ret=cache[a][b];
if(ret!=-1)return ret;
//在此求解
...
return ret;
}
int main(){
//利用memset()初始化cache数组
memset(cache,255,sizeof(cache));
//小技巧:用memeset函数想赋值-1的话,写255
分析制表的时间复杂度
分析这种”分治思想”的算法的复杂度有一个简单的公式:
(子问题的个数)x(解一个子问题时循环语句的执行次数)
利用上述公式计算bino2()的时间复杂度:r的最大值是n,所以计算bino2(n,r)时能够分割的最大子问题个数是O( )(注意并不严格等于 )。求解子问题时,因为没有循环语句,所以时间复杂度为O(1),所以bino2()耗费的时间复杂度为O( )xO(1)=O( ).