暴力递归与动态规划
递归的思想是:把原问题转化为规模减小后的同类问题。暴力递归的“暴力”体现在递归过程中会有很多的重复计算,影响算法的效率,而动态规划的方法是把递归过程中重复计算的结果记录下来。
学院派的动态规划的方法在面对实际问题时存在的问题是:不知道该从何入手。其实,那些提出动态规划方法的先贤们,也是先写出暴力递归,进而改写出动态规划的。所以,从暴力递归到动态规划,才是简单可行的路线。
下面,结合一个实例,介绍从暴力递归到动态规划的改写方法。
正则表达式的匹配
题目描述
请实现一个函数用来匹配包括‘.’和‘ * ’的正则表达式。模式中的字符 '.'表示任意一个字符,而‘ * ’表示它前面的字符可以出现任意次(包含0次)。在本题中,匹配是指字符串的所有字符匹配整个模式。例如,字符串"aaa"与模式"a.a"和"ab* ac* a"匹配,但是与"aa.a"和"ab* a"均不匹配
暴力递归的解题思路
首先要考虑两种特殊情况:
(1).当两个字符串都是空时,返回true;
(2).当模式为空,而字符串不为空,返回false;
值得注意的是,当字符串为空,模式不为空的时候,不一定返回false。比如字符串为空,而模式为"b* c* a* "时,‘ * ’前面的字符都为0个,这时字符串和模式是匹配的。
这两种特殊情况称之为“base case”,是递归的基本情况,也是递归开始返回的基本情况。
然后减小问题规模,给出递归解决方法:将整个字符串和整个模式能否匹配的问题转化为字符串某个位置iii开始到结尾和模式某个位置jjj开始到结尾能否匹配的问题。
因为一旦解决了这个问题,i=0,j=0i=0,j=0i=0,j=0时就是我们的原始问题。
那么就从iii和jjj开始匹配两个字符串,结果只有两种:匹配成功(返回true)或者匹配失败(返回false),考虑到jjj后面位置的那个字符可能是’* ’ ,分成两种情况:
(1).模式j+1j+1j+1位置上的字符不是‘ * ’,这种情况比较简单,如果iii和jjj匹配失败,则直接返回false;否则,匹配结果就由字符串从i+1i+1i+1开始到结尾和模式从j+1j+1j+1开始到结尾的匹配结果决定了;
这种情况下要注意:模式jjj上的字符如果是‘.’,字符串iii上是任意字符,则匹配成功,但iii上不能是’\0’;
(2).模式j+1j+1j+1位置上的字符是‘ * ’,这时,就要看 ’ * '到底代表0个还是多个:如果‘ * ’代表0个,则匹配结果由字符串从iii开始到结尾和模式从j+2j+2j+2开始到结尾的匹配结果决定;如果‘ * ’代表多个,则匹配结果由字符串从i+1i+1i+1开始到结尾和模式从jjj开始到结尾的匹配结果决定(多个和一个的处理方式一样,因为又变成了情况(2) );
c++版的代码如下:
class Solution {
public:
bool match(char* str, char* pattern)
{
return process(str,pattern,0,0);
}
//递归函数process(),i是str开始匹配位置,j是pattern开始匹配位置
bool process(char* str, char* pattern, int i, int j)
{
//基本情况 base case
if(str[i]=='\0'&&pattern[j]=='\0')
return true;
if(str[i]!='\0'&&pattern[j]=='\0')
return false;
//如果pattern[j+1]不是‘*’
if(pattern[j]!='\0'&&pattern[j+1]!='*'){
return (str[i]==pattern[j]||(pattern[j]=='.'&&str[i]!='\0'))&&
process(str,pattern,i+1,j+1);
}
//如果pattern[j+1]是‘*’
else if(pattern[j]!='\0'&&pattern[j+1]=='*'){
if(str[i]!=pattern[j]&&(pattern[j]!='.'||str[i]=='\0'))
return process(str,pattern,i,j+2); //'*'匹配 0个字符
else{
return process(str,pattern,i+1,j)||process(str,pattern,i,j+2); //'*'匹配 0个或多个字符
}
}
return false;
}
};
从暴力递归到动态规划
暴力递归过程,将process()process()process()函数看出是一个黑盒,str、pattern参数是固定的,可变参数只有iii和jjj, iii的变化范围是0≤i≤strlen(str)0\leq i \leq strlen(str)0≤i≤strlen(str),jjj的变化范围是0≤j≤strlen(pattern)0\leq j \leq strlen(pattern)0≤j≤strlen(pattern)。
所以可以将求process(i,j)process(i,j)process(i,j)的过程看是填一个长为n2n2n2,宽为n1n1n1的二维矩阵dp的过程,其中n1=strlen(str),n2=strlen(pattern)n1=strlen(str),n2=strlen(pattern)n1=strlen(str),n2=strlen(pattern),我们的最终目标是得到dp[0][0]的值是true还是false,也就是红色三角形的位置的值;根据暴力递归的过程我们知道,任意一个dp[i][j]的值依赖于dp[i+1][j]、dp[i+1][j=1]、dp[i][j+2],也就是黑色箭头表示的关系;根据base case,我们知道有一些位置是不需要根据依赖关系就能得到值的,也就是矩阵的最后一列。
一般情况下,有了最终目标、位置依赖关系、base case描述,这道题的动态规划方法就已经确定了。但是这道题有它特殊之处,因为没有最后一行和倒数第二列的已知信息,其他位置就失去了依赖,也就是说暴力递归中的base case是不完整的,虽然不影响暴力递归的求解,但影响了动态规划的求解。
这时就要根据题意确定最后一行和倒数第二列的信息:先看倒数第二列,(i,j)(i,j)(i,j)为(n1−1,n2−1)(n1-1,n2-1)(n1−1,n2−1)时,表示都只有一个字符,此时可能匹配成功,可能匹配失败,根据给定的str和pattern确定,而倒数第二列的其他位置肯定都是false,因为pattern只有一个字符,而str不止一个字符;再看最后一行,str为“\0”,那么pattern要跟str匹配成功只能是“a* b* c*”这种形式,也就是“?”位置可能匹配成功,其余位置肯定匹配失败。
将base case全部填好之后,剩下的工作就是根据依赖关系从base case出发填好整个表,那么目标位置的结果自然也得到了,这就是动态规划,像搭积木一样的过程。
c++版本代码如下:
class Solution {
public:
bool match(char* str, char* pattern)
{
int n1=strlen(str);
int n2=strlen(pattern);
//申请dp矩阵空间
bool** dp;
dp = new bool *[n1+1];
for (int i = 0; i < n1+1; i++){
dp[i] = new bool[n2+1];
}
//base case的填写
dp[n1][n2]=true;
for(int i=n2-2;i>-1;i-=2){
if(pattern[i]!='*'&&pattern[i+1]=='*')
dp[n1][i]=true;
else
dp[n1][i]=false;
}
for(int i=0;i<n1;i++)
{
dp[i][n2]=false;
n2>0&&(dp[i][n2-1]=false);
}
if (n1>0&&n2>0) {
if (pattern[n2-1]=='.'|| str[n1-1]==pattern[n2-1]) {
dp[n1-1][n2-1]=true;
}
}
//根据依赖关系填写dp矩阵
for(int i=n1-1;i>-1;i--)
for(int j=n2-2;j>-1;j--){
if(pattern[j]!='\0'&&pattern[j+1]!='*'){
dp[i][j]=(str[i]==pattern[j]||(pattern[j]=='.'&&str[i]!='\0'))&&
dp[i+1][j+1];
}
else if(pattern[j]!='\0'&&pattern[j+1]=='*'){
if(str[i]!=pattern[j]&&(pattern[j]!='.'||str[i]=='\0'))
dp[i][j]=dp[i][j+2];
else{
dp[i][j]=dp[i+1][j]||dp[i+1][j+2]||dp[i][j+2];
}
}
}
//保存结果
bool res=dp[0][0];
//释放内存
for (int i = 0; i < n1+1; i++){
delete[] dp[i];
}
delete[]dp;
return res;
}
};
总结
其实,从这个例子可以发现,从暴力递归到动态规划的路线,难点在于暴力规划的实现,而改写动态规划的过程是极具套路性的,甚至已经和原题意没有什么关系了。
希望大家多多总结,如果有错漏请指正。