vlambda博客
学习文章列表

算法:动态规划-Dynamic Programming(一)

     动态规划算法一直都是面试时很长问的问题,右庸计划花3篇文章的篇幅对DP算法进行分享,也会加入实例。开篇的话还是主要针对算法思想进行论述,在对思想有了些许把握再着手于实例,在实践中发现问题再来强化思想。

      掌握思想最重要的是掌握该算法能解决什么问题,任何易中算法被提出来一定是因为它能解决一些有针对性的问题,这里右庸首先先抛出问题,再来看看动态规划是如何解决的。

         钢条切割问题:

    某公司购买长钢条,将其切割为短钢条出售。切割工序本身没有成本支出。公司管理层希望知道最佳的切割方案。假定我们知道公司出售一段长为i英寸的钢条的价格为pi(i=1,2,…,单位为美元)。钢条的长度均为整英寸。图15-1给出了一个价格表的样例。



钢条切割问题是这样的:给定一段长度为n英寸的钢条和一个价格表pi(i=1,2,…n),求切割钢条方案,使得销售收益rn最大。注意,如果长度为n英寸的钢条的价格pn足够大,最优解可能就是完全不需要切割。

  我们可以先穷举一下,长度为n英寸的钢条共有2^(n-1)种不同的切割方案,因为在距离钢条左端i(i=1,2,…n)英寸处,总是可以选择切割或不切割。很明显要是穷举的话是不现实的。这里我们可以引入动态规划的基础思想-最优子结构:问题的最优解由相关的子问题的最优解组合而成,而这些子问题可以独立求解。这跟分治法的思想有些类似。我们不妨就将问题的最优解分为两个子问题的最优解的组合。

设:长度为n的钢条其最大收益值和长度为k和n-k的两条钢条的最大收益值组合。


那么长度为n的钢条的最大收益值:

r(n) = max[pn,r(k)+r(n-k)],pn为不切割时的价格。


这样可以通过组合两个相关子问题的最优解,并在所有可能的两段切割方案中选取组合收益最大者,构成原问题的最优解。这是泛化的最优子结构思想,只要问题可以分出最优子结构就可以这么思考。当然还有另一种思路,只不过这种思路不适用于任何问题,但对该问题是适用的。


另一种思路:将钢条从左边切割长度为i的一段,只对右边剩下的长度为n-i的一段继续进行切割(递归求解),对左边的一段则不再进行分割。


rn = max(pi + r(n-i)) (i取值范围[1,n])。在这个公式中,原问题的最优解只包含一个相关子问题(右端剩余部分)的解。听上去思路确实没问题,但是并不适用于任何问题,我个人的理解是不适用于具有顺序的问题,后续会有这样的实例。为了简单起见,该例子使用第二种思路。

     按照上述思路我们可以写代码了

int cutRod(int prof[], int n) 3 { 4 if (n == 0) 5 return 0; 6 else 7 { 8 int profit = 0; 9 for (int i = 1; i <= n; i++)10 profit = max(profit, prof[i] + cutRod(prof, n - i));11 return profit;12 }13 14 }


这是朴素的递归算法,根据代码可以分析得出:


随着n的增大,程序的运行时间会成倍的增加。读者可以把代码复制到自己的编译器上运行一下,当输入n为40时,你会发现程序会运行好几分钟,所以用朴素递归算法效率真的很差。这时候我们就要找到效率差的原因,并加以优化使得算法效率增强。那么我们可以小规模的模拟算法的运行,观察到底是问题出在哪?


当 n = 4时,递归调用树如下:



   我们会发现cutRod反复地用相同的参数值对自身进行递归调用,就是说它反复求解相同的子问题。这就造成了低效率,既然是因为对相同的子问题反复求解造成的问题,那么我们想办法使得对每个子问题只求解一次不就好了。我们可以找一些空间把求得的子问题的解存储起来,如果还需要该子问题的解我们直接在空间中找就OK了,不用重复计算了。这就是最重要的动态规划的基础思想:对每个子问题只求解一次,并将结果保存下来。就是付出额外的内存空间来节省计算时间。

     实际上动态规划核心的思想就是我们上述说的两个思想,很好掌握和理解,重点是应用于实践中。右庸在这里给出该例子的动态算法代码,分为两类:

 1.带备忘的自顶向下法:

int memoizedCutRodAux(int pro[], int r[], int n){ if (r[n] > 0) return r[n]; else { int profit = 0; for (int i = 1; i <= n; i++) profit = max(profit, pro[i] + memoizedCutRodAux(pro, r, n - i)); r[n] = profit; return profit; }}

当需要一个子问题的解时,首先检查备忘录中是否已有此解。

2.自底向上法:

int bottomUpCutRod(int pro[], int r[], int n){ for (int i = 1; i <= n; i++) { int profit = 0; for (int j = 1; j <= i; j++) profit = max(profit, pro[j] + r[i - j]); r[i] = profit; } return r[n];}

该方法将子问题按从小到大的顺序进行求解,当求解到某个子问题时。它所依赖的那些更小的子问题都已经求解完毕,结果已经保存。

由于没有频繁的递归函数的调用开销,自底向上方法的时间复杂度函数通常具有更小的系数。


扫描二维码关注右庸的学习分享,跟右庸一起来学习机器学习吧!