DP在ACM的算法里面可算是重中之重,题目类型千变万化,题目难度差异也很大.是一种很讲究技巧的算法,而且代码实现相对容易,1y率非常高(除有些bt数据外).总之DP就是一向非常重要,又非常博大精深的算法.我们学校的Roba的大牛在这方面就有很深的造诣.
说一下自己这几天接触的初级DP,DP中最重要的往往是状态和状态之间的转移,找到状态转移方程,用递归或者是递推的方式列出方程,题目也就迎仞而解了,而所谓的难题往往是初看不知所措,找不出状态转移方程,或者根本连什么是状态的都看不清,下面分析一下有关DP的非常经典的3个问题:
1.求局部最大和
大意是这样给你一个数组,在其中取任意连续多个,使其和要最大.假设这个数组大小是10,一般会想到怎么做呢,一个连续的序列,肯定能被一个st和end唯一的表示出来,遍历所有的可能,求出其中最大的即可.
以下内容为程序代码:
for(st=0;st<n;st++)for(end=st;end<n;end++)for(i=st;i<=end;i++)max+=n[i];
一个3重循环,耗时是10^3,可以在不改变算法思想的基础上加以优化吗,答案是肯定的.
建立一个sum[10]数组表示从数组n的累加和,这样的话每次计算max只需要一次操作sum[end]-sum[st-1]了,至于sum数组的建立过程,只需要一个O(n)的时间即可做到,所以总的时间复杂度降为O(10^2);能把复杂度将到O(n)吗?这时候就要用到动态规划的思想了.假设当前状态是在以end结尾的局部最大和,通过这个能否得到以end+1结尾的局部最大和.想一想,以end+1结尾,可以分为两部分,一个是只有end+1这一个元素,另外就是再加上以end结尾的局部最大和,说一般了,就是从先前状态最优推出了当先的最优态,具体状态转移方程就是:
以下内容为程序代码:
if(ans[i]<0)ans[i+1]=n[i+1];else ans[i+1]=ans[i]+n[i+1];
再把遍历所有的ans,找出最大的就OK了,这样的话只需要O(n)的时间就能完成.
TOJ1782有道类似的题目,感兴趣的同学可以去看看.
2.最长递增子序列
大意是在一个序列中找到一个最长的递增子序列,求出其长度.例如1 7 3 5 9 4 8 Ans: 4 (1 -> 3 -> 5 -> 8),咋看之下,好象没有合适的做法.遍历?2^n的复杂度,显然是不可行的.通过上面例题,我们可以考虑用DP的方法来解决这类问题.假设一个长度为n的数组,现在已经知道以从1到i为序列的尾的最大递增子序列,那么要求以i+1为尾的序列的最大递增子序列,只要判断n[i+1]是否大于a[1:i],然后取其中ans[i]最大的那个加1就是按ans[i+1]的值.最后在遍历所有的ans找出最大的即可.是不是和局部最大和很想象,唯一的不同就在于在局部最大和中只需要ans[i]即可求出ans[i+1],这是因为n[i+1]的前面只能接n[i]导致的,而这题的ans[i+1]确是由ans[1:i]共同决定的,这是由于最长递增子序列可以不连续导致的.换句话说,要是这题是求最长递增连续子序列的话,那就和上题的处理几乎一样了.说到这里,相信大家对DP都有了一个基本的了解了,我的理解是对于有些不知如何下手的题目,确定一个先前状态往往是给了一个已知条件,是问题明朗化了,再找到当前状态,所以说DP的难点在于状态划分和确定状态转移方程,而我的感觉是往往前者更重要.回到刚才那个题目,内层循环要做的是找到一个小于n[i+1]的并且长度最长的子序列,既然是查找,那能否找到O(logn)查找复杂度呢.答案是肯定的.具体怎么实现,大家可以查看这个网址:最长递增子序列的高效算法.上面说的很详细,我也理解的不好.
第三个问题下次再说吧,睡觉去了...
接着上次的内容,第三个经典例题:最长公共子串;
大意是:有两个字符串A,B,另有一字符串C,若C同时是A,B的子串,则C为公共子串,求出长度最长的公共子串的长度.
ans[i][j]=0;//if(i==0||j==0)(1)
ans[i][j]=ans[i-1][j-1];//if(A[i]==B[j])(2)
ans[i][j]=Max(ans[i][j-1],ans[i-1][j])//if(A[i]!=B[j])(3)
分析一下(2),(3),对于(2),如果A[i]==B[j],显然ans[i][j]要加1,那么会不会加2呢?如果加2的话,那么说明A[i],B[j]分别为公共子串两个不同的字符,那么必然有前后之分,前面的那个字符已经对应了一个字符串的尾部,另外一个字符则不可能是公共子串了,所以对于A[i]==B[j],ans[i][j]加且仅加1.对于(3)采取同样的分析,易知其分别减去A,B尾部的字符,必有一长度不变.
具体的代码实现可以有两种方式.1.采取递推的实现方式,自底向上采取两重循环,时间复杂度为O(n^2),不过不是所有的状态转移方程都可以这样做,后面会详细分析.2.自顶向下,逐级递归,但要注意递归层数(据说不能超过7600层),一般情况下都伴随这记忆化,这样能使时间复杂度从指数级别降低到O(n^2)的级别.至于和递推在时间上的优劣关系,或者说有没有题用其中一种会TLE而另一种会AC,就不清楚了,望达人指教.
总结一下这三道经典例题.虽然是属于入门级的简单DP,但还是能给我们一点启示的.如何划分状态?有些时候,状态的划分不能仅仅根据答案,还应该加上某些限制条件,这样的话会更有利于状态的转移.像求局部最大和中ans[i]并不是前i个数列中的局部最大和值,还加上了该局部最大和的尾部必须是n[i],最长递增子序列也是一样.还有一点是状态的转移条件,有些时候只有一个前驱,而有的时候又有多个前驱,像2.(这也是2.的复杂度要比1.高的原因),这个要注意.关于这3道题,还有许多的类似问题,像多维局部最大和,最大m子段和等等.TOJ1564,POJ2479,TOJ1633都是相应的题目,感兴趣的同学可以做一下.
回到刚才所说的,什么时候能用递推,什么时候又不能呢?
先看一下下面这道例题,POJ1088
大意是:给一个M*N的矩阵,每一点附上一个值h,要求求一条路径,路径上的相邻两点必须在矩阵中前后左右相邻,并且前驱的h值高于后继.很容易想到状态转移方程:l[i][j]=Max(l[i][j+1],l[i][j-1],l[i+1][j],l[i-1][j])+1//if(h[i][j+1]>h[i][j]...),那么用递归写的话只需
if(map[i][j]>map[i][j+1])
if(flag[i][j+1]==-1)
a=flag[i][j+1]=search(i,j+1);
else
a=flag[i][j+1];
if(map[i][j]>map[i][j-1])
if(flag[i][j-1]==-1)
b=flag[i][j-1]=search(i,j-1);
else
b=flag[i][j-1];
if(map[i][j]>map[i+1][j])
if(flag[i+1][j]==-1)
c=flag[i+1][j]=search(i+1,j);
else
c=flag[i+1][j];
if(map[i][j]>map[i-1][j])
if(flag[i-1][j]==-1)
d=flag[i-1][j]=search(i-1,j);
else
d=flag[i-1][j];
return Max(a,b,c,d)+1;
要是用递推怎么写呢,好象不好写吧,结合前面的3个例题(都是用递推写的),可以得出满足写递推式的两个条件:1.起点必须确定,象3.中ans[i][j]=0//if(i==0||j==0)是现而易见的,而此题在不同的输入下结果是不同的.2.每一点在推出时都能确保其前驱已被推出,在3.中,在二重循环中,明显在计算ans[i][j]中ans[i-1][j],ans[i][j-1],ans[i-1][j-1]已经确定了,但是此题却不能保证.
好了,说了这么多,相信大家对DP都有一个大致的印象了,划分好状态,找出状态转移方程,看能否用递推,不能的话就递归,但记住要记忆化哦.但是有一类题,和DP很像,但却很难找到转移方程,或者说起状态转移方程只不过是一个遍历的过程.对于这种题只要不和DP弄混,还是很好做的.
ZOJ1687大意是:一堆石头m个.两队人轮流取石头,每人取的上限都给出,至少要取一个,谁最后取完谁就失败,要你判断哪队能赢,咋看之下,确实没有状态转移,其实根本不用这么复杂,对于第i个人还剩下j块石头直接
for (i=1;i<=a[p];i++)
if (tot - i > 0 && solve(tot-i,next) == 0)
{
ans[tot][p] = 1;
return 1; //如果取子后对方会输,则返回1
}
完全是暴力嘛,就这么简单!时间复杂度为O(石头数*人数),对于n队,应该也有同样的做法.