前言
众所周知,递归算法时间复杂度很高为(2^n),而动态规划算法也能够解决此类问题,动态规划的算法的时间复杂度为(n^2)。动态规划算法是以空间置换时间的解决方式。
一、什么是动态规划
动态规划(Dynamic programming)是一种在数学、计算机科学和经济学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。 动态规划算法是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。
动态规划对于子问题重叠的情况特别有效,因为它将子问题的解保存在存储空间中,当需要某个子问题的解时,直接取值即可,从而避免重复计算!
二、基本策略
基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
动态规划中的子问题往往不是相互独立的(即子问题重叠)。在求解的过程中,许多子问题的解被反复地使用。为了避免重复计算,动态规划算法采用了填表来保存子问题解的方法。
三、什么问题适合用动态规划来解决呢?
适合用动态规划来解决的问题,都具有下面两个特点:最优子结构、重叠子问题。
如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。某阶段状态(定义的新子问题)一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与其以前的状态有关。子问题之间是不独立的(分治法是独立的),一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)。
如果使用递归算法的时候会反复的求解相同的子问题,不停的调用函数,而不是生成新的子问题。如果递归算法反复求解相同的子问题,就称为具有重叠子问题(overlapping subproblems)性质。在动态规划算法中使用数组来保存子问题的解,这样子问题多次求解的时候可以直接查表不用调用函数递归。
这类问题的求解步骤通常如下:
- 分析最优解的性质,并刻画其结构特征,这一步的开始时一定要从子问题入手;
- 定义最优解变量,定义递归最优解公式;
- 以自底向上计算出最优值(或自顶向下的记忆化方式(即备忘录法));
- 根据计算最优值时得到的信息,构造问题的最优解。
四、使用动态规划算法解决问题举例
1.斐波那契(Fibonacci )
前面我们使用了递归的方式去计算斐波那契,使用递归实现的最大问题是每次调用都要把上一步的结果重新计算一次,如果值很大,很容易造成内存溢出,如果值很大,那递归是不能计算出结果的。这时,我们考虑,可不可以把上一步计算的结果保存起来,那下一步就不需要再次计算了,这个思想其实就是动态规划。下面就看看动态规划的两种方法怎样来解决斐波那契数列问题。
①自顶向下的备忘录法
public static int Fibonacci(int n) { if (n <= 0) return n; int[] Memo = new int[n + 1]; for (int i = 0; i <= n; i++) Memo[i] = -1; return fib(n, Memo); } public static int fib(int n, int[] Memo) { if (Memo[n] != -1) return Memo[n]; //如果已经求出了fib(n)的值直接返回,否则将求出的值保存在Memo备忘录中。 if (n <= 2) Memo[n] = 1; else Memo[n] = fib(n - 1, Memo) + fib(n - 2, Memo); return Memo[n]; }
备忘录法也是比较好理解的,创建了一个n+1大小的数组来保存求出的斐波拉契数列中的每一个值,在递归的时候如果发现前面fib(n)的值计算出来了就不再计算,如果未计算出来,则计算出来后保存在Memo数组中,下次在调用fib(n)的时候就不会重新递归了。
②自底向上的动态规划
备忘录法还是利用了递归,上面算法不管怎样,计算fib(6)的时候最后还是要计算出fib(1),fib(2),fib(3)……,那么何不先计算出fib(1),fib(2),fib(3)……,呢?这也就是动态规划的核心,先计算子问题,再由子问题计算父问题。
public static int fib(int n) { if (n <= 0) return n; int[] Memo = new int[n + 1]; Memo[0] = 0; Memo[1] = 1; for (int i = 2; i <= n; i++) { Memo[i] = Memo[i - 1] + Memo[i - 2]; } return Memo[n]; }
自底向上方法也是利用数组保存了先计算的值,为后面的调用服务。观察参与循环的只有 i,i-1 , i-2三项,因此该方法的空间可以进一步优化如下。
public static int fib(int n) { if (n <= 1) return n; int Memo_i_2 = 0; int Memo_i_1 = 1; int Memo_i = 1; for (int i = 2; i <= n; i++) { Memo_i = Memo_i_2 + Memo_i_1; Memo_i_2 = Memo_i_1; Memo_i_1 = Memo_i; } return Memo_i; }
一般来说由于备忘录方式的动态规划方法使用了递归,递归的时候会产生额外的开销,使用自底向上的动态规划方法要比备忘录方法好。
2.钢条切割
来自算法导论,我们只看最优解法:自底向上的动态规划
public static int buttom_up_cut(int[] p) { int[] r = new int[p.length + 1]; for (int i = 1; i <= p.length; i++) { int q = -1; //① for (int j = 1; j <= i; j++) q = Math.max(q, p[j - 1] + r[i - j]); r[i] = q; } return r[p.length]; }
问题中最重要的是理解注释①处的循环,这里外面的循环是求r[1],r[2]……,里面的循环是求出r[1],r[2]……的最优解,也就是说r[i]中保存的是钢条长度为i时划分的最优解,这里面涉及到了最优子结构问题,也就是一个问题取最优解的时候,它的子问题也一定要取得最优解。下面是长度为4的钢条划分的结构图。
五、动态规划的经典模型
1.线性模型:线性模型的是动态规划中最常用的模型,上文讲到的钢条切割问题就是经典的线性模型,这里的线性指的是状态的排布是呈线性的。
2.区间模型:区间模型的状态表示一般为d[i][j],表示区间[i, j]上的最优解,然后通过状态转移计算出[i+1, j]或者[i, j+1]上的最优解,逐步扩大区间的范围,最终求得[1, len]的最优解。
3.01背包模型:参考【动态规划解决01背包问题】有N种物品(每种物品1件)和一个容量为V的背包。放入第 i 种物品耗费的空间是Ci,得到的价值是Wi。求解将哪些物品装入背包可使价值总和最大。f[i][v]表示前i种物品恰好放入一个容量为v的背包可以获得的最大价值。决策为第i个物品在前i-1个物品放置完毕后,是选择放还是不放,状态转移方程为:
f[i][v] = max{ f[i-1][v], f[i-1][v – Ci] +Wi }
时间复杂度O(VN),空间复杂度O(VN) (空间复杂度可利用滚动数组进行优化达到O(V) )。
六、动态规划与分治法的区别和联系
分治法是指将问题划分成一些独立地子问题,递归地求解各子问题,然后合并子问题的解而得到原问题的解。
动态规划适用于子问题独立且重叠的情况,也就是各子问题包含公共的子子问题。动态规划算法对每个子子问题只求解一次,将其结果保存在一张表中,从而避免每次遇到各个子问题时重新计算答案。
分治法主要在于子问题的独立性,比如排序算法等, 动态规划算法主要适用于处理 子问题重复性和最优子结构的的问题。
七、总结
- 动态规划算法的核心就是记住已经解决过的子问题的解。
- 求解的方式有两种:①自顶向下的备忘录法 ②自底向上。
动态规划可以解决哪些类型的问题?
待解决的原问题较难,但此问题可以被不断拆分成一个个小问题,而小问题的解是非常容易获得的;如果单单只是利用递归的方法来解决原问题,那么采用的是分治法的思想,动态规划具有记忆性,将子问题的解都记录下来,以免在递归的过程中重复计算,从而减少了计算量。
我的微信公众号:架构真经(id:gentoo666),分享Java干货,高并发编程,热门技术教程,微服务及分布式技术,架构设计,区块链技术,人工智能,大数据,Java面试题,以及前沿热门资讯等。每日更新哦!
参考资料: