一、前言
动态规划(dynamic programing)算法是一种分阶段求解决策问题的数学思想,主要思想就是大事化小、小事化了。同时,动态规划有三个基本准则:(1)最优子结构;(2)边界条件;(3)状态转移。利用动态规划方法求解问题就是需要找到上述三个准则,对问题进行动态规划建模。
二、动态规划例子
1、爬楼梯问题
问题描述:有一座高度是10级台阶的楼梯,从下往上走,每跨一步只能向上1级或者2级台阶。要求用程序来求出一共有多少种走法。
这个题目最简单的方法就是穷举法,也就是依次列举每种情况,然后就得出来总的走法数量,但是这是最笨的方法。
刚刚说明了动态规划的三个基本准则,其第一个准则就是最优子结构,这一准则体现了动态规划的基本思想,也就是通过寻找问题的最优子结构来实现将问题大事化小、小事化了,什么叫做大事化小?一般能用动态规划求解的问题都是规模比较大的问题,比如我们的第一个例子,10级台阶一共有多少种走法?这是一个比较大规模的问题,我们需要找到规模比它小且问题本质一样的问题?想一想,要走完10级台阶,你最后一步应该怎么走?最后一步只有两种走法:从第8级到第10级,或者从第9级到第10级。现在,如果我们已经知道了从第1级台阶到第8级和第9级分别有F(8)和F(9)种走法,那么原问题的解F(10)=F(8)+F(9),其中F(8)+F(9)表示的就是问题的最优子结构,整个表达式F(10)=F(8)+F(9)表示的就是问题的状态转移方程。
现在,关于这个问题的动态规划建模分析已经找到了两个:最优子结构和状态转移方程,剩下一个边界条件是什么呢?那就是F(1)=1,F(2)=2(第一种:两次一步;第二种:一次两部),到此,整个问题的动态规划建模分析已经完成,总结如下:
最优子结构:F(n) = F(n-1)+F(n-2)
状态转移方程:F(n) = F(n-1)+F(n-2)
边界条件:F(1)=1,F(2)=2
注意,上面只是利用动态规划的思想对问题进行了建模分析,而且动态规划通常也是作为一种问题建模方法,所以,具体说来动态规划是一样问题的分析方法,一旦一个问题建模完成,它可以用很多问题去解决,最常见的有递归结构、备忘录结构和动态规划结构,下面针对这个例子分别进行说明:
(1)递归结构
int getClimbingWays(int n)
{
if (n < 1)
return 0;
if (n == 1)
return 1;
if (n == 2)
{
return 2;
}
return getClimbingWays(n - 1) + getClimbingWays(n - 2);
}
递归结构就是根据刚刚建立的动态规划模型直接翻译成代码,递归算法尽管能解决这个问题,但是其时间复杂度是O(2^N),因为在递归过程中有很多重复计算,这可以用备忘录结构解决
(2)备忘录结构
要使用备忘录结构,需要建立一个备忘录区用于存储在递归结构中重复计算的结果,针对这个问题,我们只需要知道dp[1]、dp[2]……dp[10],所以,我们可以用一维数组dp[10]来表示备忘录区
int dp[10] = { 0 }; //备忘录区
int getClimbingWays(int n)
{
if (n < 1)
return 0;
if (n == 1)
return 1;
if (n == 2)
{
return 2;
}
if (dp[n] != 0) //查询备忘录,O(1)时间
return dp[n];
else
{
dp[n] = getClimbingWays(n - 1) + getClimbingWays(n - 2);
return dp[n];
}
}
可以看到,备忘录算法的时间复杂度为O(N),但空间复杂度是O(N),这在N很大的时候对整个程序结构还是有影响,下面就可以用动态规划结构解决空间复杂度高的问题。
(3)动态规划结构
不管是递归结构,还是动态规划结构,都是一种自顶向下的求解思路(这也是刚刚动态规划模型中状态转移方程的(正向)推导过程),即如果要求dp[n],则需要先求dp[n-1],这种思路在递归结构中导致了很多重复的计算(时间复杂度),在备忘录结构中导致了较大的空间复杂度,那我们能不能转换一下思维,自底向上的求解这个问题,这是可以的,这里的自底向上只需要(逆向)推导状态转移方程,也就是直接通过上一项求下一项,即先求dp[n-1],在求dp[n]
int getClimbingWays(int n)
{
if (n < 1)
return 0;
if (n == 1)
return 1;
if (n == 2)
{
return 2;
}
int a = 1;
int b = 2;
int temp = 0;
for (int i = 3; i <= n; i++)
{
temp = a + b; //(逆向)推导状态转移方程
a = b;
b = temp;
}
return temp;
}
2、最长匹配括号对子串问题
问题描述:给定一个字符串输入,这个字符串只由圆括号组成,即只包含"("和")",则需要求解在这个字符串中最长的有效匹配括号对子串的长度,比如输入"())()()",则输出4,输入"()(())",则输出6.
这道题咋一看似乎跟动态规划没什么关系啊?好的,那么不用动态规划解法,你可以用什么方法解呢?首先,对于这个问题,如果你采用暴力解法(即穷举全部情况,然后搜索最长的子串长度),除了穷举法,还有什么其他优化方法吗?如果没有了的话,那么对于可以用穷举方法解的问题,我知道了还可以用两种方法进行优化:回溯法(后面会更新相关博文)和动态规划法,而对于回溯法,其实它也是一种穷举法,只是进行了“减枝优化”,好像对于这道题并不好使,既然这样,那先找找这个问题的规律吧,看看能不能用动态规划求解,如果想用动态规划求解,那首先要进行动态规划建模,也就是找到问题的:(1)最优子结构、(2)边界、(3)状态转移方程
(1)动态规划建模
最优子结构:原问题是求输入字符串中最长匹配括号对的长度,这里可以把问题量化(转换),假设输入字符串长度为size,则原问题可以量化为从下标为0的字符到下标为size-1(即最后一个字符)的字符所包含的最长匹配括号对长度,这里记为dp[0],要求dp[0],我们是不是可以先求dp[1],dp[1]表示从下标为1的字符到下标为size-1字符中包含的最长匹配括号对的长度,这里我们已经提取了问题的状态(dp[0]、dp[1]、……dp[size-1]),也就得到了问题的最优子结构为在外面已经知道了dp[n]的情况下,就可以求dp[n-1],注意这里的状态转移过程是dp[n]——>dp[n-1],其中dp[n]即为dp[n-1]的最优子结构
(2)边界
上面我们已经提取出了问题的状态和最优子结构,也可以很显然的得到问题的边界:dp[size-1]=0,因为一个"("或")"字符根本无法构成括号匹配对
(3)状态转移方程
在提取最优子结构中,我们得到了状态转移过程是从dp[n]——>dp[n-1],但具体这个转移过程是怎么样的呢?我们从问题边界开始依次遍历输入字符串,即从dp[size-1]到[0],根据每一个字符的情况(要么是左括号"("或者右括号")")来分析状态转移过程:
注意是逆向遍历,且问题边界已知,所以循环从size-2开始
for(int i=size-2; i>=0; --i)
对于当前遍历的第i个字符(这里我们已经知道了dp[i+1]的值):
如果第i个字符是“(”
则我们跳过dp[i+1]个元素,看其下一个元素是什么?
即看第j=i+dp[i+1]+1个元素是什么,如果j没有越界,则
看其是不是")"。
如果是")",如果j没有越界,且s[j]=")",所以dp[i]=dp[i+1]+2
如果不是,则dp[i]=0(原始值)
上一步已经求得了s[i……j]之间的最长括号对
长度,这里还需要判断j+1是否越界
如果没有越界,则dp[i]=dp[i]+dp[j+1]
如果第i个字符是")",则直接下一次循环
根据上面的问题建模情况,就可以写出下面的代码:
int longestValidParentheses(string s)
{
int i,j,n;
n=s.size();
int *dp = new int[n];
int max=0;
for(i=0;i<n;i++)
dp[i]=0;
for(i=n-2;i>=0;i--)
{
if(s[i]=='(')
{
j=i+dp[i+1]+1; //第j个元素
if(j<n && s[j]==')')
{
dp[i]=dp[i+1]+2;
if(j+1<n)
dp[i]+=dp[j+1];
}
}
if(max<=dp[i])
max=dp[i];
}
return max;
}
三、总结
总之,理解与应用动态规划算法,首先要根据动态规划的三个准则进行问题建模:(1)最优子结构;(2)边界条件;(3)状态转移。然后再考虑用最有的算法结构区根据所建立的动态规划模型进行代码设计与编写。