动态规划在程序算法中的运用

文章首发微信公众号:码上观世界

动态规划是在程序算法设计中最重要的方法之一,其重要性不言而喻,而在一些场合其解题思路足让人拍案叫绝,本文通过一个常见的案例开始引入动态规划,然后在其基础上对其拓展并介绍动态规划的一些应用场景。

Part 1 递归在子序列求和上的运用

问题1:给定数值类型的序列,找出子序列最大和,子序列不一定要连续,而且子序列长度不限(最短为1,最长为序列长度)。比如输入 序列{-2, 1, -3, 4, -1, 2, 1, -5, 4},输出最大的子序列{1,4,2,1,4}之和为12。

分析:因为这个题限制条件比较少,所以计算起来有容易的方法,比如你可以只找序列中的正数,然后求和即为最终结果。如果全部是负数,那就找绝对值最小的那一个数,即为结果。现在如果要求解不同的子序列的组合总数,该如何计算呢?此时该题转化为求组合数,我们仍然可以依据组合公式:C(m,n)=m!/(n!*(m-n)!),其中m为序列长度,n为从序列选出的元素个数,于是所有的组合数等于C(m,1)+C(m,2)+...+C(m,m)。

现在如果要打印出所有的组合,怎么办呢?可以参照组合的方式,先找出序列中选出1个数值元素,总共有m种组合,再找出从序列中选出2个数值元素,总共有C(m,2)种组  合,...,最后全部选出,有1种组合,用程序怎么实现组合呢?

比如从0-9的10个数里面选出2个数,可以这样写:

for(int i=0;i<10;i++)
   for(int j=i+1;j<10;j++)
      printf("(%d,%d)",i,j)

用两重循环,先固定外层数值,依次遍历内层数值,这样就可以打印出所有2个数值的组合。这种写法有个问题,因为这里的每个组合的数值是变量,程序怎么反映变化的循环次数呢?不可能选10个数,用10层循环,程序一旦写出就是固定的机器指令序列,除非动态生成代码。如果观察n个循环的执行轨迹,会发现其实是重复执行同样的逻辑,这可以用递归方法实现。

所谓递归( recursion)就是程序调用自身的编程技巧,递归作为一种算法在程序设计语言中广泛应用。一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。递归的能力在于用有限的语句来定义对象的无限集合。一般来说,递归需要有边界条件、递归前进段和递归返回段。当边界条件不满足时,递归前进;当边界条件满足时,递归返回。

比如这里用递归来实现从n个数的序列中选出n个数的组合,递归的边界是选出的个数达到设定的变量n,否则就重复调用自身,程序示例如下:

void compose(int data[], int n, int start, StringBuffer result) {
    int end = data.length;
    for (int i = start; i < end; i++) {
        result.append(data[i]);
        if (result.length() == n) {
            println(result);
        }
    compose(data, n, i + 1, result);
    result.deleteCharAt(result.length() - 1);
   }
}

其中当达到边界条件是执行组合打印:

if (result.length() == n) {
      println(result);
}

没有达到边界,就记录已经选出的元素:

result.append(data[i]);

递归的优势是代码逻辑简洁,对开发人员友好,劣势是容易重复计算,效率不一定高,特别是嵌套递归调用,控制不好的话,容易造成堆栈溢出。比如该例子中,如果每次都从头遍历数值序列,会造成很多重复选取,为了避免无用枚举,每次选取的元素从上次递归循环的下一个元素开始:

for (int i = start; i < end; i++)

递归常常跟回溯方法一起运用,示例中使用StringBuffer暂存中间结果,如果没有回溯就会造成结果序列只增不减,所以需要在回溯的同时删除结果序列的最后一个数值,在递归的框架内,回溯就是递归逐层返回的过程,可以说回溯是递归过程的附带功能,因此回溯的执行逻辑应该放在递归执行的方法返回之后:

compose(data, n, i + 1, result);
result.deleteCharAt(result.length() - 1);

如果计算所有组合的数量,可以把所有不同选取的元素的组合总数加起来,适当修改该算法就可以实现所有组合数和所有组合的最大和等:

for(int c=0;c<data.length;c++)
compose(data, c, 0, new StringBuffer());

Part 2 递推法求解最大子序列和

用递归实现方法逻辑足够清晰但是解法足够暴力,现在回头再看本文开头中提出的问题:求解最大序列和,用更为通用的算法,怎么实现呢?能想到确实不容易,需要坐上思维升级的升降机才行。从前面的算法中可以看到,暴力枚举所有组合,然后求和,存在大量的重复计算,这才是导致性能不高的根本原因,比如求解n个数的组合之和,会重新计算前面(n-1),(n-2),...,1个组合数之和,而求解n-1个组合之和,再次按照同样的方式重新计算其前面的组合之和,唧唧复唧唧,重复何其多!怎么样让每种组合只计算一次呢?我们先试想,从data序列中选取1个元素的最大和,依次遍历data,到达第i个元素为止,已知最大子序列和构成一个跟原始序列同样长度的和序列:

同样,选取2个元素构成的最大子序列和构成的序列为:

依此类推,选取3个元素构成的最大子序列和构成的序列为:

由归纳法可知,选取n个数值构成的和序列将构成一个C(k,n)的矩阵,其中n为原始序列的长度,k为选取的元素数量,且

C[i,j]=max(C[i,j-1],C[i-1,j-1]+data[j])

比如C[1,4]=max(C[1,3],C[0,3]+data[4]),公式表达为:

矩阵右下角的元素即为选取k个元素的最大和。上面的矩阵构造过程只是帮助理解推导过程,实际上无论选取几个元素,计算到达第i个元素的最大和有两种可能,选它和不选它,决定依据就是判断C[i]和C[i-1]+data[i]谁更大,于是矩阵可以压缩成一维数组:

此时k维矩阵“坍缩”成一维数组,递推公式:

注意此时对data序列中每个元素都有选和不选两种可能,跟上面表格中从n个元素一定选k个元素有所不同,下文还会讲到。压缩后的数组最右边的元素即为子序列最大和,而求解最后一个元素,可以根据第一个元素计算第二个,由第二个计算第三个,...,通过递推方法一直到最后一个元素。递推法并不关心具体的组合,只关心组合的和这个结果,而且只跟前一步骤的结果有关。相比其他方法,该算法中一维矩阵数组也可以优化掉,且时间复杂度也达到了O(n),空间复杂度为O(1),算法可谓精妙至极!

Part 3 动态规划的两种运行模式

上述递推法有一个更加学术的名字:动态规划,实际上叫递推法比较实在和接地气。为了跟学术接轨,暂且就称其为动态规划吧。动态规划算法与分治法类似,其基本思想就是将待求解问题分解成若干子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合动态规划法求解的问题,经分解得到的子问题往往不是相互独立的,而分治算法的基本思想是将一个规模为N的问题,分解成K个规模较小的子问题,这些子问题相互独立且与原问题性质相同。求解出子问题的解,合并得到原问题的解。若用分治法来解这类问题,则分解得到的子问题数目太多,以至于最后解决原问题需要耗费指数时间。然而,不同子问题的数目常常只有多项式量级。在用分治法求解时,有些子问题被重复结算了很多次。如果我们能够保存已经解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,从而得到多项式时间复杂度的算法。为了达到目的,可以用一个表来记录所有已解决的子问题的答案,不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中,这就是动态规划的基本思想。分治算法常用递归法来实现,动态规划有两种结构模式:自底向上和自顶向下。为了说明这两种模式的区别,可以看一道应用题:

问题2:有一楼梯共n级,刚开始时你在第一级,若每次只能跨上一级或者二级,要走上n级,共有多少走法?

按照前面的分析思路,假设S[k]表示走到第k级的走法数,那么S[i]=S[i-1]+S[i-2],因为第i级只能从第i-1级或第i-2级走。注意此时S[k]构成的序列数组的原型为斐波那契数列,斐波那契数列起初是研究兔子繁殖规律,在自然界中符合该数列规律的现象非常多,堪称奇迹,用数学表示为:

自底向上就是前面提到的地推,用程序计算方法如下:

int fibonacci(int n) { 
      //保存子问题的解
      int f[] = new int[n];
      f[0] = 0; 
      f[1] = 1; 
      for (int i = 2; i <= n; i++)
            f[i] = f[i-1] + f[i-2];
      return f[n-1]; 
}

自底向上需要从初始值算起,中间不能有跳跃。而采用自顶向下采用递归的实现方式:

int fib(int n){
    if(n <2){
        return n;        
    }
 return fib(n-1) + fib(n-2);
}

以fib(5)为例构成的递归执行轨迹见下图:

可见,很多子问题存在重复计算,为解决重复问题,需要查表法:事先将计算的结果存放在表格中,当下次计算的时候先去查表得到结果,只有当结果不在表格中才会去计算,自顶向下方法也叫备忘录方法。

Part 4 递推法求解问题举例

问题3:将子序列和最大值的条件收紧:要求连续子序列和最大值,又该如何计算呢?假设C[i]表示到达第i个元素为止,已知的连续子序列之和,绘制表格如下:

继续用数学归纳法得出,只有当C[i-1]为非负数的时候,才可以累加data[i],这其实用到了贪心策略:尽可能匹配最大子序列和,递推公式表示如下:

比较连续子序列和非连续子序列最大和,基本的解题思路和方法一致,都是通过一维数组记录到当前元素为止的最大和,不同的地方在于递推公式,非连续子序列最大和C[i],在递推过程中只增不减,而连续子序列最大和C[i],因为会重新寻找子序列的起点,在递推过程中会重新赋初始值。

问题4:如果计算最大连续递增子序列的长度,该如何实现呢?

分析:这里的疑惑可能是如何反映这种递增条件?以C[i]表示到当前元素i为止,已知最长的连续子序列长度,继续绘制表格:

通过归纳发现,只有当相邻的两个元素严格满足递增data[i]>data[i-1],才会在C[i-1]基础上累加,用公式表示为:

如果计算最大非连续递增子序列的长度,又该怎么实现呢?此时情况要复杂一些,C[i]记录的是到当前元素为止,已知最长的子序列长度,计算C[i]不仅要跟data[i-1],还可能要跟data[i-1]之前的数值比较,比如如果data[i]>data[i-1],则C[i]=C[i-1]+1;否则,需要在data[i-1]之前找到一个比其小的元素,位置记为k,则C[i]=C[k]+1,继续绘制表格来帮助分析:

比如data[i]>data[0],所以C[1]=C[0]+1;data[4]<data[3],需要往前找满足小于data[4]的元素,可以是data[0]或者data[2],于是C[4]=C[2]+1,通过归纳法可知计算公式:

问题5:如果要求选取不超过k个元素的最大子序列和,又该怎么计算呢?

分析:这时需要回头看前面的从n个数明确选出k个数的分析思路,上文中通过精确计算选取1个元素、2个元素和3个元素的最大和,然后得出了递推公式,跟上面不同的地方在于,”至多选取“ 可以不选取某个元素,而”精确选取“,则为了选够数不得不选。下面同样可以按照上述思路来推导:

”至多选取“中C[i,j]不仅跟C[i-1,j-1],C[i,j-1]还跟C[i-1,j]有关,比如

C[1,4]=max(C[1,3],C[0,3]+data[4],C[0,4])

公式表达为:

有了上述解题思路,来看看0-1背包问题:

问题6:一共有N件物品,第i(i从1开始)件物品的重量为w[i],价值为v[i]。在总重量不超过背包承载上限W的情况下,能够装入背包的最大价值是多少?

分析:如果假设每件物品重量为1,0-1背包问题就被还原为如何在由物品价值组成的序列中,选出至多k个物品,保证价值最大?跟上面一般示例的区别是价值是正整数,此时C[i,j]可以不必跟C[i,j-1]比较,递推公式为:

然后还原每件物品重量为w[i],很容易得出递推公式:

这是最简单的背包问题,更多的背包变种,感兴趣的读者可以自己去推导。

小结

作为分治算法的一个特例,动态规划的子问题是不相互独立的,而是有关联的,因此应用动态规划难点在于得出推导公式,然后基于公式递推,逐步从局部最优解得出全局最优解。贪心算法也是类似的策略,但跟贪心算法策略的局部最优解有所不同:贪心策略是由上一步的最优解推导下一步的最优解,但结果不一定是最优解,动态规划的全局最优解中一定包含某个局部最优解,但不一定包含前一个局部最优解。

求最优解的问题,从根本上说是一种对解空间的遍历。最直接的暴力分析容易得到,最优解的解空间通常都是以指数阶增长,因此暴力穷举都是不可行的。最优解问题大部分都可以拆分成一个个的子问题,把解空间的遍历视作对子问题树的遍历,则以某种形式对树整个的遍历一遍就可以求出最优解。贪心和动态规划本质上是对子问题树的一种修剪。两种算法要求问题都具有的一个性质就是“子问题最优性”。即,组成最优解的每一个子问题的解,对于这个子问题本身肯定也是最优的。如果以自顶向下的方向看问题树(原问题作根),则,我们每次只需要向下遍历代表最优解的子树就可以保证会得到整体的最优解。形象一点说,可以简单的用一个值(最优值)代表整个子树,而不用去求出这个子树所可能代表的所有值。

动态规划方法代表了这一类问题的一般解法。我们自底向上(从叶子向根)构造子问题的解,对每一个子树的根,求出下面每一个叶子的值,并且以其中的最优值作为自身的值,其它的值舍弃。动态规划的代价就取决于可选择的数目(树的叉数)和子问题的的数目。

贪心算法是动态规划方法的一个特例。每一个子树的根的值不取决于下面叶子的值,而只取决于当前问题的状况。换句话说,不需要知道一个节点所有子树的情况,就可以求出这个节点的值。通常这个值都是对于当前的问题情况下,显而易见的“最优”情况。因此用“贪心”来描述这个算法的本质。由于贪心算法的这个特性,它对解空间树的遍历不需要自底向上,而只需要自根开始,选择最优的路,一直走到底就可以了。这样,与动态规划相比,它的代价只取决于子问题的数目,而选择数目总为1。关于贪心算法的应用,敬请关注后续文章。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值