若是你常刷leetcode,会发现许多问题带有Dynamic Programming的标签。事实上带有dp标签的题目有115道,大部分为中等和难题,占全部题目的12.8%(2018年9月),是占比例第二大的问题。html
若是能系统地对dp这个topic进行学习,相信会极大地提升解题速度,对从此解决实际问题也有思路上的帮助。算法
本文以分杆问题为切入点,介绍动态规划的算法动机、核心思想和经常使用的两种实现方法。编程
The rod-cutting problem(分杆问题)是动态规划问题的一个典例。数组
给出一根长度为n(n为整数)的杆,能够将杆切割为任意份长短不一样的杆(其中包含彻底不进行切割的状况),每一份的长度都为整数。给出一个整数数组p[],p[i]表示长度为i的杆的价格,问如何对杆进行切割可使利润最大。缓存
p[]数组的一个示例以下:app
在长度为n的杆上进行整数切割,共有2n-1种状况,由于有n-1个点能够选择是否切割。函数
将这些能够切割的点编号为1,2,3, ..., n-1,若是先试着在1处切割,则杆变成了长度为1和n-1的两段;若是试着在2处切割,则杆变为了长度为2和n-2的两段,以此类推,共有n种切法(包含彻底不做切割)。这样,咱们迈出了递归的第一步,即把长为n的杆的最优切割分红两个子问题:长为i的杆的最优切割和长为n-i的杆的最优切割(i = 1,2,...,n)。最终的利润为两个子杆的利润和。post
若是用fn表示长度为n的杆切割后能获得的最大利润,通过以上分析,咱们求取两个子杆的利润和的最大值便可。即学习
fn = max(pn, f1 + fn-1, f2 + fn-2, ..., fn-1 + f1).优化
这种思路是正确的,但不是太好,有心人能够注意到子问题之间有较大的重叠之处,好比计算fn-1时会须要查看f1 + fn-2,即f1 + fn-1这个子问题须要查看f1 + f1 + fn-2这个切法;而计算f2时又须要查看f1 + f1,即f2 + fn-2这个子问题也会查看到f1 + f1 + fn-2这个切法,至关于把一些可能性重复查看了多遍。
一个更简洁合理的思路是:设定左边这个长为i的杆不可再切割,只有右边长为n-i的杆能够再切割。则问题变为
fn = max(pi + fn-i), i = 1,2,...,n
按照上面的分析,能够初步作一个递归实现以下:
1 int cutRod(int n, int[] p){ 2 if(n == 0) 3 return 0; 4 int max = Integer.MIN_VALUE; 5 for(int i = 1; i <= n; i++) 6 max = Math.max(max, p[i] + cutRod(n - i, p)); 7 return max; 8 }
在节点n,算法的时间复杂度为
Tn = 1 + ∑ Ti (i = 0,1, ..., n-1)
(其中的1是在节点处作加法和max运算的常数复杂度)
这个式子很好推算,只要将Ti的值以此从后往前代入便可:
Tn = 1+T0+T1+ ... +Tn-1 = 1+T0+T1+ ... +Tn-2+(1+T0+T1+ ... +Tn-2)
= 2 (1+T0+T1+ ... +Tn-2) = 2 (1+T0+T1+ ... +Tn-3+(1+T0+T1+ ... +Tn-3))
= 22 (1+T0+T1+ ... +Tn-3) = ... (总结规律) = 2n-1 (1 + T0)
= 2n
即传统递归算法的时间复杂度为O(2n),为指数级别。
上一节中说到切割的可能性共有2n-1种,也就是说递归算法会将每种可能性都遍历到。是否还有优化的可能性呢?
以n = 4为例,画出递归树结构(节点包含的数字为n的值)
本图摘自算法导论(英文版)3rd Ed. 15.1 P346
能够看到子树之间存在重叠状况。最明显的是n = 2的子问题和n = 3的子问题调用的子树彻底相同,进行了两遍一样的计算。而这个子树中又包含n = 1的子树,也就是说浪费的幅度是相乘的。
一个优化思路是将每一个子问题的计算结果记录下来,下一次再遇到一样的问题时直接使用记录值,这就是动态规划的核心思想。
如上节所述的,动态规划是一种“以空间换时间”的思想,适用于子问题之间存在重叠状况的优化问题。它的基本思想是将计算过的子问题的答案记录下来,从而达到每一个子问题只计算一次的目的。
动态规划的实现方法分为top-down和bottom-up两种,能够理解为前者从递归树的根节点向下递归调用,然后者从树的叶结点开始不停地向上循环。
top-down方法比较容易理解,就是在传统递归的基础上加入memoization(注意与memorization的区别。memoization来自memo,有备忘的意思),即用数组或表等结构缓存计算结果。在每次递归运算时,先判断想要的结果是否在缓存中,若是没有才进行运算并存入缓存。
1 int cutRod(int n, int[] p){ 2 int[] memo = new int[n + 1]; 3 for(int i = 0; i < memo.length; i++) 4 memo[i] = Integer.MIN_VALUE; //initialization 5 return cutRod(n, p, memo); 6 } 7 8 int cutRod(int n, int[] p, int[] memo){ 9 if(memo[n] != Integer.MIN_VALUE) 10 return memo[n]; //return value directly if memoized 11 if(n == 0) 12 return 0; 13 int max = Integer.MIN_VALUE; 14 for(int i = 1; i <= n; i++) 15 max = Math.max(max, p[i] + cutRod(n - i, p, memo)); 16 memo[n] = max; //memoize it 17 return max; 18 }
相比于top-down,bottom-up的特色是使用循环而非递归,先解决子问题,再利用子问题的答案解决父问题。tabulation也很好理解,即用一个表格存放子问题的答案,而后查表得到父问题须要的全部信息去解决父问题,解决后也填在表中,直至把表填满。
事实上,dynamic programming这个使人费解的名字即来源于此。programming在数学界有“列表法”(tabular method)的意思,指的是为了求某函数的最大/最小值,将函数的全部变量的全部可能值列在表中并对表进行某些操做来得到结果。在这里,表格是“静态”的,每一个格子中的信息是独立的;而在动态规划中,表格是“动态”的,一些格子中的信息依赖于另外一些格子中的计算答案。因此,dynamic programming也能够理解为“动态列表法”,也即此处的tabulation。
top-down的实现以下:
1 int cutRod(int n, int[] p){ 2 int[] table = new int[n + 1]; 3 for(int j = 1; j <= n; j++){ //fill table from j = 1 to n 4 int max = Integer.MIN_VALUE; 5 for(int i = 1; i <= j; i++) 6 max = Math.max(max, p[i] + table[j - i]); //calculate f(j) 7 table[j] = max; 8 } 9 return table[n]; 10 }
在bottom-up解法中,咱们从1至n填入表格,在填入table[j]时,须要查询table[j-1]到table[0]的全部元素,即要作j次查询。则填满表格共要作1+2+3+...+n = O(n2)次查询。则bottom-up解法的时间复杂度为O(n2)。
在top-down解法中,能够这样分析复杂度:首先因为缓存机制,每一个子问题只会被计算一次;为了解决大小为n的问题,咱们须要计算大小为0,1,2,...,n-1的问题(第15行);计算大小为n的问题又须要n次计算(第14行),所以top-down解法的复杂度也为O(n2)。
实际上,动态规划将前文图中的递归树作了简化,将互相重叠的子树合并,获得了一个子问题树。子问题树中的边和节点都减小了,意味着时间复杂度获得了优化。
->
看完例子,咱们来总结一下动态规划算法的相关概念。
dynamic programming -- wikipedia
What is dynamic programming? -- Stackoverflow
Tabular Method of Minimisation
Dynamic programming and memoization: bottom-up vs top-down approaches
dp系列下一篇:dp方法论——由矩阵相乘问题学习dp解题思路