Dynamic Programming 1:入门

简介

如果你常刷leetcode,会发现许多问题带有Dynamic Programming的标签。事实上带有dp标签的题目有115道,大部分为中等和难题,占所有题目的12.8%(2018年9月),是占比例第二大的问题。

如果能系统地对dp这个topic进行学习,相信会极大地提高解题速度,对今后解决实际问题也有思路上的帮助。

本文以分杆问题为切入点,介绍动态规划的算法动机、核心思想和常用的两种实现方法。

分杆问题

The rod-cutting problem(分杆问题)是动态规划问题的一个典例。

给出一根长度为n(n为整数)的杆,可以将杆切割为任意份长短不同的杆(其中包含完全不进行切割的情况),每一份的长度都为整数。给出一个整数数组p[],p[i]表示长度为i的杆的价格,问如何对杆进行切割可以使利润最大。

p[]数组的一个示例如下:

思路

在长度为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)。最终的利润为两个子杆的利润和。

如果用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