dp入门——由分杆问题认识动态规划

简介

若是你常刷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 with memoization

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 }

bottom-up with tabulation

相比于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

  • dynamic programming (动态规划)是一类经常使用来解决优化问题的算法。它的最大特色是使用子问题的信息帮助解决父问题,使解题的难度减少。好比,求第1,000,002个斐波那契数这个问题看起来很复杂,但若是已知了第1,000,000和第1,000,001斐波那契数,事情就简单多了。
  • 动态规划问题有一个显著的特色,就是子问题之间存在相互重叠。动态规划经过记录子问题的结果,保证每一个子问题只计算一次,减小了时间浪费。动态规划的时间复杂度一般是多项式复杂度(即O(nk),k为非负常数),而不记录结果的算法因为重复计算,复杂度一般远高于动态规划,达到指数复杂度。
  • dynamic programming这个英文名词有些让人难懂。实际上,这里的programming不是指编程,而是数学上的一种解决优化问题的方法,叫作列表法(tabular method),大体过程是将函数的不一样变量值在表中列出并对表进行各类操做来求得结果。若是列表法是静态(static)的,则动态规划算法中,表格是慢慢增加的,先解决相对简单的子问题,而后经过子问题的结合求取父问题,这样表格好像是“动态”的。这就是dynamic programming的意思。

Memoization vs. Tabulation 简介

  • 动态规划一般有两种解法:top-downbottom-up
  • top-down一般以递归形式出现,从父问题开始,递归地求解子问题。top-down的求解过程一般与memoization结合,即将计算过的结果缓存在数组或者哈希表等结构中。当进入递归求解问题时,先查看缓存中是否已有结果,若是有则直接返回缓存的结果。
  • bottom-up一般以循环形式出现。bottom-up的求解过程一般与tabulation结合,即先解最小的子问题,解决后将结果记录在表格中(一般是一维或二维数组),解决父问题时直接查表拿到子问题的结果,而后将父问题的结果也填在表中,直到把表格填满,最后填入的就是起始问题的结果。

参考资料

dynamic programming -- wikipedia

算法导论(英文版)3rd Ed. 15.1

What is dynamic programming? -- Stackoverflow

Tabular Method of Minimisation

數位邏輯之化簡(列表法)

Dynamic programming and memoization: bottom-up vs top-down approaches

 

dp系列下一篇:dp方法论——由矩阵相乘问题学习dp解题思路

相关文章
相关标签/搜索