dp入门——由分杆问题认识动态规划
简介
如果你常刷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
传统递归实现
按照上面的分析,可以初步做一个递归实现如下:
- int cutRod(int n, int[] p){
- if(n == 0)
- return 0;
- int max = Integer.MIN_VALUE;
- for(int i = 1; i <= n; i++)
- max = Math.max(max, p[i] + cutRod(n - i, p));
- return max;
- }
传统递归实现的复杂度
在节点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,有备忘的意思),即用数组或表等结构缓存计算结果。在每次递归运算时,先判断想要的结果是否在缓存中,如果没有才进行运算并存入缓存。
- int cutRod(int n, int[] p){
- int[] memo = new int[n + 1];
- for(int i = 0; i < memo.length; i++)
- memo[i] = Integer.MIN_VALUE; //initialization
- return cutRod(n, p, memo);
- }
- int cutRod(int n, int[] p, int[] memo){
- if(memo[n] != Integer.MIN_VALUE)
- return memo[n]; //return value directly if memoized
- if(n == 0)
- return 0;
- int max = Integer.MIN_VALUE;
- for(int i = 1; i <= n; i++)
- max = Math.max(max, p[i] + cutRod(n - i, p, memo));
- memo[n] = max; //memoize it
- return max;
- }
bottom-up with tabulation
相比于top-down,bottom-up的特点是使用循环而非递归,先解决子问题,再利用子问题的答案解决父问题。tabulation也很好理解,即用一个表格存放子问题的答案,然后查表获得父问题需要的所有信息去解决父问题,解决后也填在表中,直至把表填满。
事实上,dynamic programming这个令人费解的名字即来源于此。programming在数学界有“列表法”(tabular method)的意思,指的是为了求某函数的最大/最小值,将函数的所有变量的所有可能值列在表中并对表进行某些操作来获得结果。在这里,表格是“静态”的,每个格子中的信息是独立的;而在动态规划中,表格是“动态”的,一些格子中的信息依赖于另一些格子中的计算答案。所以,dynamic programming也可以理解为“动态列表法”,也即此处的tabulation。
top-down的实现如下:
- int cutRod(int n, int[] p){
- int[] table = new int[n + 1];
- for(int j = 1; j <= n; j++){ //fill table from j = 1 to n
- int max = Integer.MIN_VALUE;
- for(int i = 1; i <= j; i++)
- max = Math.max(max, p[i] + table[j - i]); //calculate f(j)
- table[j] = max;
- }
- return table[n];
- }
复杂度
在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-down和bottom-up。
- top-down通常以递归形式出现,从父问题开始,递归地求解子问题。top-down的求解过程通常与memoization结合,即将计算过的结果缓存在数组或者哈希表等结构中。当进入递归求解问题时,先查看缓存中是否已有结果,如果有则直接返回缓存的结果。
- bottom-up通常以循环形式出现。bottom-up的求解过程通常与tabulation结合,即先解最小的子问题,解决后将结果记录在表格中(通常是一维或二维数组),解决父问题时直接查表拿到子问题的结果,然后将父问题的结果也填在表中,直到把表格填满,最后填入的就是起始问题的结果。
参考资料
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解题思路
dp入门——由分杆问题认识动态规划的更多相关文章
- 【专章】dp入门
动态规划(简称dp),可以说是各种程序设计中遇到的第一个坎吧,这篇博文是我对dp的一点点理解,希望可以帮助更多人dp入门. ***实践是检验真理的唯一标准,看再多文章不如自己动手做几道!!!*** 先 ...
- 树形dp 入门
今天学了树形dp,发现树形dp就是入门难一些,于是好心的我便立志要发一篇树形dp入门的博客了. 树形dp的概念什么的,相信大家都已经明白,这里就不再多说.直接上例题. 一.常规树形DP P1352 没 ...
- 【学习笔记】dp入门
知识点 动态规划(简称dp),可以说是各种程序设计中遇到的第一个坎吧,这篇博文是我对dp的一点点理解,希望可以帮助更多人dp入门. 先看看这段话 动态规划(dynamic programming) ...
- 【DP入门到入土】
DP例题较多,可以根据自己需求食用~ update:下翻有状压DP入门讲解,也只有讲解了(逃~ DP的实质,就是状态的枚举. 一般用DP解决的问题,都是求计数或最优问题,所以这类问题,我们也可以用搜索 ...
- poj 3254 状压dp入门题
1.poj 3254 Corn Fields 状态压缩dp入门题 2.总结:二进制实在巧妙,以前从来没想过可以这样用. 题意:n行m列,1表示肥沃,0表示贫瘠,把牛放在肥沃处,要求所有牛不能相 ...
- xbz分组题B 吉利数字 数位dp入门
B吉利数字时限:1s [题目描述]算卦大湿biboyouyun最近得出一个神奇的结论,如果一个数字,它的各个数位相加能够被10整除,则称它为吉利数.现在叫你计算某个区间内有多少个吉利数字. [输入]第 ...
- 【dp入门题】【跟着14练dp吧...囧】
A HDU_2048 数塔 dp入门题——数塔问题:求路径的最大和: 状态方程: dp[i][j] = max(dp[i+1][j], dp[i+1][j+1])+a[i][j];dp[n][j] = ...
- 数位dp入门 hdu2089 不要62
数位dp入门 hdu2089 不要62 题意: 给定一个区间[n,m] (0< n ≤ m<1000000),找出不含4和'62'的数的个数 (ps:开始以为直接暴力可以..貌似可以,但是 ...
- POJ 2342 树形DP入门题
有一个大学的庆典晚会,想邀请一些在大学任职的人来參加,每一个人有自己的搞笑值,可是如今遇到一个问题就是假设两个人之间有直接的上下级关系,那么他们中仅仅能有一个来參加,求请来一部分人之后,搞笑值的最大是 ...
随机推荐
- typescritp 导出默认接口
假如有ITest.ts文件,如下: export default interface ITest{ } 这样会报错,编译不通过.据说是设计成这样的,具体详细见:https://github.com/M ...
- ipa的plist文件查看
1.ipa包解压缩:右键.ipa包,使用[归档实用工具/unarchiver]打开 2.进入解压缩后的payload目录,右键ipa包-显示包内容 3.找到info.plist文件,直接拖拽出来 4. ...
- linux调度器源码分析 - 概述(一)
本文为原创,转载请注明:http://www.cnblogs.com/tolimit/ 引言 调度器作为操作系统的核心部件,具有非常重要的意义,其随着linux内核的更新也不断进行着更新.本系列文章通 ...
- 遇到电脑IP地址冲突了怎么解决
由于路由器是自动分配IP地址的,如果多个设备设置的是IP地址自动获取,就会出现IP地址冲突的情况当局域网内有相同IP,并且该机器启动了防火墙,那就没办法自动更新到下一个IP的地址了,所以此时发生了冲突 ...
- Tomcat 访问页面或服务器异常,请检查这些方面
若还没有部署网站,请检查 防火墙是否关闭 数据库服务是否打开 浏览器访问的地址和端口是否正确 tomcat 配置文件中的端口是否发生冲突,换一个试试 若出现的是"拒绝连接",检查阿 ...
- 装饰器 以及 django 中的应用
装饰器本质上是一个Python函数,它可以让其他函数在不需要做任何代码变动的前提下增加额外功能,装饰器的返回值也是一个函数对象.它经常用于有切面需求的场景,比如:插入日志.性能测试.事务处理.缓存.权 ...
- (9)Python循环结构
- centos7下安装docker(7docker base command 命令词典)
上一章中我总结了学习docker 镜像时所用过的命令,今天先来将docker base command 记录一下,参考:https://docs.docker.com/edge/engine/refe ...
- 【转】一个域名是用哪里的DNS来解析的,电脑怎么知道找哪一个DNS呢? 我注册域名的时候会在服务商那里配置DNS解析,一般需要24小时后才能访问,我想知道,解析后的这个数据是不是会同步到世界上所有的DNS服务器呢!如果不是,当我访问我的这个域名的时候,电脑怎么知道去找到我注册的这一家的DNS服务器呢,谁告诉他的呢?
看看DNS一些基础知识,你就了解了.1.DNS就是域名服务器,他的任务就是确定域名的解析,比如A记录MX记录等等. 2.任何域名都至少有一个DNS,一般是2个.为什么要2个以上呢?因为DNS可以轮回处 ...
- CSS3中和动画有关的属性transform、transition 和 animation
CSS3中和动画有关的属性有三个 transform. transition 和 animation.下面来一一说明: transform 从字面来看transform的释义为 ...