DP解题思路的阶段总结
像所有的新手一样,对一种算法思想的理解需要经历从肤浅(流于表面形式)到逐渐触摸到本质的过程。为什么说"逐渐"触摸到本质,是因为很多时候你并不确定一个解释是不是最本质的,有时候会有好几个等价的解释,各自在不同的时候具有启发。
比如对DP的理解,一开始我理解为"递推",但实际上这是最肤浅的理解,对于如何在特定的问题中找到递推关系毫无帮助和启发。换言之,这只是一个描述性的总结,而不是一个建设性的总结,不含方法论。
做(看)了一些题目之后我开始总结关于"How"的方法,怎样寻找到递推关系(递推关系蕴含着子问题)。我得到一个简单的观察,如果一个问题里面含有n这个变量,考虑把n变成n-1的情况。
当然,这个方法是非常特殊的。实际上更具一般性的方法是不仅可以从n-1后面切一刀,还可以在任意地方切(譬如切成两个n/2规模的),以任意标准切(比如像快排那样的)。所有考虑各种切法中哪种能够最有利于建立子问题是有帮助的。
这个我姑且把它叫做通过直接切分问题来寻找子问题。
可是。这还是特殊了。因为显然这个方法不能解决所有的DP问题,连"多数"DP问题都未必能解决。譬如前面提到的很多人熟悉的"最大(小)和连续子序列"问题,就难以通过这种方法求解(可行,但思维难度较大。)。譬如上次Lee给的敲石头问题。那么,在知道了这几个问题的解法之后我继续思考这些解法里面是不是蕴含着更具一般性的解题方法。
看起来我找到了一个,就是分类讨论法,具体做法是:首先,写出可行解的一般形式,譬如a1, a2, ..., an。然后对其中的某个不确定的ai进行讨论。譬如旅行商问题里面对"下一个城市"进行讨论。当不确定时,讨论。讨论的每个分支都带来了进一步的确定性,从而将问题转化成一个子问题。
然后Lee提到,这个方法是自顶向下的,有时候不适于思考,譬如对敲石头问题。并提到一种自底向上,着重于构造"状态"的外推法。
那么,到目前为止,DP的一般性启发式思考方法就已经有了三种:
1. 通过直接对问题切分来探索子问题。
2. 通过对可行解的分类讨论来探索子问题。
3. 通过建立"状态"来自底向上地推导最终解。
可是,我心里还是不踏实,因为"离散"的知识是极其不利于记忆的,需要更多的练习才能在运用的时候"不由自主"的联想起,也容易忘掉。每次做DP题的时候,我都得把好几种指导性的思路一一费劲从脑袋里翻出来,然后尝试。实在很不爽。虽然也许DP题并没有万用的解题手法,但我还是希望能够尽量提取出不同手法之间的本质联系,如果能够将一组看上去离散的知识点统一在一个更具一般性,更本质的知识点下面,我们的知识树就多出了一个根节点,于是下次提取的时候只要提取出那个根节点,下面的几个子节点就会一一乖乖闪现。极大的降低记忆的复杂性。
那么,上面三种手法的本质联系到底是什么呢?有一次在路上走的时候我想出了一种解释。它也许不是最本质的,也许大牛们早就想到过,但是我觉得至少有两个好处:
1. 它能够cover以上三种手法,通过增加一个根节点,减少知识的记忆复杂性和易提取性。
2. 归纳抽象的过程本身就是一种锻炼,锻炼的是归纳抽象的能力。
这个解释就是:DP的可行解的形式大多是一个排列组合(典型的----旅行商问题)。大家都知道,穷举一个规模为N的排列组合复杂性是a^n的,也就是"组合复杂性"。而DP问题的所谓子问题,实际上就是对应最终解的那个排列组合的某个"子排列组合"(某种子集)。而正由于子排列组合的数目往往是多项式的,所以子问题往往重复出现,并且本质上只要计算并缓存所有子组合的最优情况。这就是为什么一个组合复杂性的穷举问题可以DP优化为多项式复杂度的问题。
这一解释也提供了如何探索子问题的一个通用方案:寻找可行解(一个排列组合)中的子组合。还是拿最大和连续子序列说事,其解的形式是:A[i], A[i+1], .., A[j-1], A[j],其中i,j不确定。那么如何得到有意义的子组合呢?首先讨论A[j],为什么要讨论,因为只要A[j]不确定,A[j-1]就不确定,就拿不出子组合来。对于一个确定的A[j0],解的可能性为A[i], A[i+1], .., A[j0-1], A[j0]。其最优解依赖于子组合A[i], A[i+1], .., A[j0-1],到这里子问题就不请子现了:A[i], A[i+1], .., A[j0-1], A[j0]和A[i], A[i+1], .., A[j0-1]的形式相同,意味着它们是同一个问题的不同阶表示。
当然,由于这个指导思想一般性大了点,所以实际问题中往往没有前面提到的三种手法寻找方案来得快----众所周知的是,越特定的手法解题面虽然越窄,但如果题目对口了解决起来也越快。但它至少有两个好处(前面说过了)。所以一般性和特殊性的手法都是有帮助的。
实际上,一个组合复杂性的穷举问题用DP只能优化为 伪 多项式复杂度的问题。因为这种问题的算法复杂度与输入的参数大小相关。
就是普通的背包问题:给定N种物品,每种物品的价值为p_i, 重量为w_i。背包最大容量为C。问如何装包可以获得最大价值。如果每种物品最多放一次,就是0/1背包问题。如果每种最多放n_i次,就是有界被包问题。如果每种随便放多少次,就是无界背包问题,也等价于子集求和问题。可惜不管哪种背包问题,解都是NP-hard。比如用DP来解,复杂度是O(nC),貌似多项式算法,但是注意到该问题的输入的长度同C的位数成正比,而不是同C的值成正比。于是O(nC)就变成NP-Hard乐。这在黑话里叫伪多项式算法,因为这里的多项式同输入的值成多项式关系,而不是同多项式的长度。比如说当C为1000时,它的长度不过10 (1111101000)。狰狞的指数关系2^n就跳出来乐。
就是普通的背包问题:给定N种物品,每种物品的价值为p_i, 重量为w_i。背包最大容量为C。问如何装包可以获得最大价值。如果每种物品最多放一次,就是0/1背包问题。如果每种最多放n_i次,就是有界被包问题。如果每种随便放多少次,就是无界背包问题,也等价于子集求和问题。可惜不管哪种背包问题,解都是NP-hard。比如用DP来解,复杂度是O(nC),貌似多项式算法,但是注意到该问题的输入的长度同C的位数成正比,而不是同C的值成正比。于是O(nC)就变成NP-Hard乐。这在黑话里叫伪多项式算法,因为这里的多项式同输入的值成多项式关系,而不是同多项式的长度。比如说当C为1000时,它的长度不过10 (1111101000)。狰狞的指数关系2^n就跳出来乐。
2008/6/5 Yong Yuan <y...@cs.toronto.edu>:就是普通的背包问题:给定N种物品,每种物品的价值为p_i, 重量为w_i。背包最大容量为C。问如何装包可以获得最大价值。如果每种物品最多放一次,就是0/1背包问题。如果每种最多放n_i次,就是有界被包问题。如果每种随便放多少次,就是无界背包问题,也等价于子集求和问题。可惜不管哪种背包问题,解都是NP-hard。比如用DP来解,复杂度是O(nC),貌似多项式算法,但是注意到该问题的输入的长度同C的位数成正比,而不是同C的值成正比。于是O(nC)就变成NP-Hard乐。这在黑话里叫伪多项式算法,因为这里的多项式同输入的值成多项式关系,而不是同多项式的长度。比如说当C为1000时,它的长度不过10 (1111101000)。狰狞的指数关系2^n就跳出来乐。
看了一下wiki,里面举了一个另例子:常用的测试n是否为素数的算法也是伪多项式复杂度。
我理解为什么可以认为是指数复杂度的,然而我奇怪的是,这两个例子(背包,素数测试),为什么需将问题规模定义为n的位数,而不是n的值本身呢?
对,常用的测试n是否为素数的算法也伪多项式的复杂度。但是对于测试n是否为素数是属于P的,它存在一个8次方的多项式算法。素数测试将问题规模定义为n的位数还是很好理解的吧,因为在计算机中输入n的表示就是二进制的形式。
我都没看过,所以没法建议
2008/6/5 Yong Yuan <y...@cs.toronto.edu>:
--
Yours Sincerely
Kun
blog: www.forwind.cn
我也喜欢Algorithm Design这本书。个人觉得非常适合初学者和程序员。书一开始就给出稳定婚姻问题。从解决问题的角度入手,层层递进地讲解怎么设计,引出算法设计的一般方法。然后用几道常见题目引入常用算法的设计思路。奠定总纲后,后面的书就从不同的角度深化算法设计的方法。例题也有趣。个人相当喜欢从设计到分析的讲解思路。这种方法给读者(至少是程序员)诱人的动机。毕竟很多人读算法书是为了写出更强大的程序。尤其像我这种老人家,NADD症状严重,尤其需要令人信服的动机,不然就老走神。而且作者强调设计,反而提供了钻研算法分析的充分理由,很好地解释了要设计出正确高效的算法,形式化的分析是必要手段。为什要扩展?为什么要到处一堆引理?为什么要追究复杂度?为什么要泛化?为什么要特化?这些看来学究的行为自然地有了现实基础。加上这本书写得晓畅通透,就算你只看书不动手,也可以获得"好舒服,原来我也可以设计出这些算法啊。一点都不难嘛"这种类似K粉后的波浪状快感和幻觉。:-D
pangba 我想是不是你有点过于追求"统一理论"了。就像物理上统一场理论一样,可能现实的动规题过于复杂了,总结一个大统一的理论反而是很难。
第二个是子问题结构 容我想清楚了贴上来 (拆子问题的一些方法应当在此类中)
There is an ordering on the subproblems, and a relation that shows how to solve a subproblem given the answers to "smaller" subproblems, that is, subproblems that appear earlier in the ordering.
谢谢Eric:)
我能理解这个定义,我就是难以理解为什么一定要这样定义。也就是说,为什么对于数值N不也定义输入规模为N。
不过看了你的解释我又想了一下,忽然想通了,一个很简单的原则:
定义:令输入数据所总共占用的空间为S,问题的输入规模为I,则比如满足:
I/S = 常数
这样的话,如果输入的是一个数值N,那么N所占的空间就正比于它的二进制位数,而不是N本身。
不知道这个定义靠谱不?
然而,我有另一个疑问:快排里面的N个数,每个数又自身是一个数值数Ai,如果Ai非常非常大的话,对问题的复杂度毫无疑问也是有影响的,因为比较两个大整数Ai的复杂度正比于Ai的二进制位数而不是Ai本身,因此假设max(Ai位数)=m(也=logAi)的话,快排的复杂度应该是O(nm)。但由于在普通情况下用固定大小的类型(int,long)来存快排的数值,所以m为常数,才得到了O(n)的情况。
2008/6/8 pongba <pon...@gmail.com>:谢谢Eric:)
我能理解这个定义,我就是难以理解为什么一定要这样定义。也就是说,为什么对于数值N不也定义输入规模为N。
不过看了你的解释我又想了一下,忽然想通了,一个很简单的原则:
定义:令输入数据所总共占用的空间为S,问题的输入规模为I,则比如满足:
I/S = 常数
这样的话,如果输入的是一个数值N,那么N所占的空间就正比于它的二进制位数,而不是N本身。
不知道这个定义靠谱不?
然而,我有另一个疑问:快排里面的N个数,每个数又自身是一个数值数Ai,如果Ai非常非常大的话,对问题的复杂度毫无疑问也是有影响的,因为比较两个大整数Ai的复杂度正比于Ai的二进制位数而不是Ai本身,因此假设max(Ai位数)=m(也=logAi)的话,快排的复杂度应该是O(nm)。但由于在普通情况下用固定大小的类型(int,long)来存快排的数值,所以m为常数,才得到了O(n)的情况。
做算法分析的时候会有一些假设的,比如假设四则运算和比较的复杂度为O(1)。不同的假设下面得出的复杂度不一定一样的。一般来说,我们分析出来的复杂度是算法进行了多少步的渐进复杂度,而不是"计算"的复杂度。
以你的例子来说的话,如果不提出Ai本身非常非常大的条件,可以认为比较Ai就是O(1)的复杂度,但是如果提出这个条件的话,则比较Ai的复杂度不可忽略(O(1)就相当于忽略了)。这个时候,比较次数少的算法就占优势了。
Here's my thoughts about DP:
1) State your problem in recursive style.
How to solve the problem?
Recursion + Memoization
well, I heard some C++ compiler only suppor recursion with only 512
depth. Try some other languages like Scheme or Haskell.
:) lol, what do you really want?
btw, 李锋's answer is closest to mine.
How can you recognize a DP structure in a given problem?
1) State your problem in recursive style.
这个帖子的标题改成"如何寻找问题的递归表达"更为贴切。
我想不出有什么方法可以帮你找到递归,前面说的不变量也只是帮你验证你的算法的正确性。似乎递归的描述都是最直观的,往往根据问题的描述就可以获得。
能否举些难点的例子?