咱们先从两个问题入手,来学习动态规划。java
某公司想要把一段长度为n的钢条切割成若干段后卖出,目前市场上长度为i(0<i<10,i为整数)的钢条的价格行情以下:学习
长度i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 其余 |
---|---|---|---|---|---|---|---|---|---|---|---|
价格pi | 1 | 5 | 8 | 9 | 10 | 17 | 17 | 20 | 24 | 30 | 0 |
不考虑切割的成本(即切割成多少段随意),请你给出一个使收益最大化的切割方案。测试
长度为n的钢条共有\(2^{n-1}\)种切割方案,最简单作法是采用暴力方法,比较全部方案的收益,找出最大值。spa
暴力破解方法的关键是如何对全部的可能状况进行不重不漏的分类。做以下考虑:code
由于不管咱们怎么切割,总能够当作是一段完整的长度为i的钢条加上另外一部分总长度为n-i的钢条(可能被切割,也可能没有)。
那么切割长度为n的钢条的最大收益能够用以下公式表出:递归
\[ r_n = \max_{1\leq i\leq n}( p_i + r_{n-1}) \]数学
所以,咱们能够采用一种被叫作自顶向下的递归方法去解决该问题。table
public static int cutRod(int[] p, int n) { if (n == 0) { return 0; } int max = Integer.MIN_VALUE; for (int i = 1; i <= n && i <= p.length; i++) { max = Integer.max(max, p[i - 1] + cutRod(p, n - i)); } return max; }
在测试时,咱们发现,当钢条的长度稍微变大(好比n=30)时,上述程序的运行时间会大大增大。仔细考虑缘由,会发现实际上咱们作了不少重复的递归操做。好比在求解cutRod(p, n)
过程当中,咱们会递归求解cutRod(p, 0 ~ n-1)
,而在求解cutRod(p, n-1)
的过程当中,一样咱们会递归求解cutRod(p, 1 ~ n-1)
,能够看出,仅仅就是这两次的调用,就重复调用了n-2次。时间效率固然会降低。class
用一个树状图很容易就能看出(以n=4为例):效率
设对于长度为n的钢条,上述程序的运行时间为T(n),则T(n)可用以下递归式表示:
\[ T(n) = 1+ \sum_{i=0}^{n-1}T(j) \]
用数学概括法很容易计算出\(T(n) = 2^n\)。这也就解释了为何随着n的“简单”增大,时间会猛增。
既然上述程序重复计算了不少次,那么咱们能够将每次计算的结果保存起来,下次再须要计算一样的问题时,就直接取出咱们计算的结果。
下面是改进以后的代码:
public static int memoziedCutRod(int[] p, int n) { memoziedArray = memoziedArray == null ? new int[n] : memoziedArray; if (n == 0) { return 0; } if (memoziedArray[n - 1] > 0) { return memoziedArray[n - 1]; } int max = Integer.MIN_VALUE; for (int i = 1; i <= n && i <= p.length; i++) { max = Integer.max(max, p[i - 1] + memoziedCutRod(p, n - i)); } memoziedArray[n - 1] = max; return max; }
再次测试发现,时间效率有明显的提升。
若是上述代码再加上一个记录各长度最优分割方案的“完整段”的长度的“备忘录”,咱们即可以很容易的找出长度为1~n的全部状况的详细的最优切割方案。
如下是n=10时的状况:
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|---|
r[i] | 0 | 1 | 5 | 8 | 10 | 13 | 17 | 18 | 22 | 25 | 30 |
s[i] | 0 | 1 | 2 | 3 | 2 | 2 | 6 | 1 | 2 | 3 | 10 |
咱们用一个简化版的递归树再来描述上述调用过程,以n=4为例子:
上述的自顶向下的调用过程在解决问题时,是从最大规模的问题入手,而最大规模的问题是依赖比它小的子问题的,所以便递归的去解决子问题,直到全部的子问题都解决了,该问题也就解决了。
既然咱们已经知道了,每一个问题的求解必将依赖它的子问题的解,那么咱们何不直接按问题规模的从小到大的顺序去依次解决呢?
public static int bottomUpCutRod(int[] p, int n) { int[] memoziedArray = new int[n + 1]; memoziedArray[0] = 0; int max = Integer.MIN_VALUE; for (int i = 1; i <= n; i++) { for (int j = 1; j <= i && j <= p.length; j++) { max = Integer.max(max, p[j - 1] + memoziedArray[i - j]); } memoziedArray[i] = max; } return max; }
在上述代码中,咱们用了两层嵌套循环替换了递归调用。其中,外层循环来控制问题的规模(规模从i~n);内层循环来求解当前规模的问题。由于在每次求解规模为i的问题时,其子问题,即问题规模为1~i-1的问题都已经求解出(存放在)memoziedArray
中,所以能够从memoziedArray
中直接读取出最优解。
上面咱们已经说过,不带“备忘”的递归调用的时间复杂度为\(2^n\);带了“备忘”的自底向上的方法的时间复杂度为\(n^2\),实际上就是一个等差数列的求和;能够想象,自定向下的递归调用实际上和自底向上的方法没有太大的差异(只是求解方向顺序的不一样,固然,递归调用因为须要压栈,空间复杂度会较大一些),所以时间复杂度也是\(n^2\)。
下一篇会从矩阵乘法链问题入手,继续探讨动态规划问题。