递归的思想是:把原问题转化为规模减小后的同类问题。暴力递归的“暴力”体现在递归过程中会有很多的重复计算,影响算法的效率,而动态规划的方法是把递归过程中重复计算的结果记录下来。
学院派的动态规划的方法在面对实际问题时存在的问题是:不知道该从何入手。其实,那些提出动态规划方法的先贤们,也是先写出暴力递归,进而改写出动态规划的。所以,从暴力递归到动态规划,才是简单可行的路线。
下面,结合一个实例,介绍从暴力递归到动态规划的改写方法。
请实现一个函数用来匹配包括‘.’和‘ * ’的正则表达式。模式中的字符 '.'表示任意一个字符,而‘ * ’表示它前面的字符可以出现任意次(包含0次)。在本题中,匹配是指字符串的所有字符匹配整个模式。例如,字符串"aaa"与模式"a.a"和"ab* ac* a"匹配,但是与"aa.a"和"ab* a"均不匹配
首先要考虑两种特殊情况:
(1).当两个字符串都是空时,返回true;
(2).当模式为空,而字符串不为空,返回false;
值得注意的是,当字符串为空,模式不为空的时候,不一定返回false。比如字符串为空,而模式为"b* c* a* "时,‘ * ’前面的字符都为0个,这时字符串和模式是匹配的。
这两种特殊情况称之为“base case”,是递归的基本情况,也是递归开始返回的基本情况。
然后减小问题规模,给出递归解决方法:将整个字符串和整个模式能否匹配的问题转化为字符串某个位置 i i i开始到结尾和模式某个位置 j j j开始到结尾能否匹配的问题。
因为一旦解决了这个问题, i = 0 , j = 0 i=0,j=0 i=0,j=0时就是我们的原始问题。
那么就从 i i i和 j j j开始匹配两个字符串,结果只有两种:匹配成功(返回true)或者匹配失败(返回false),考虑到 j j j后面位置的那个字符可能是’* ’ ,分成两种情况:
(1).模式 j + 1 j+1 j+1位置上的字符不是‘ * ’,这种情况比较简单,如果 i i i和 j j j匹配失败,则直接返回false;否则,匹配结果就由字符串从 i + 1 i+1 i+1开始到结尾和模式从 j + 1 j+1 j+1开始到结尾的匹配结果决定了;
这种情况下要注意:模式 j j j上的字符如果是‘.’,字符串 i i i上是任意字符,则匹配成功,但 i i i上不能是’\0’;
(2).模式 j + 1 j+1 j+1位置上的字符是‘ * ’,这时,就要看 ’ * '到底代表0个还是多个:如果‘ * ’代表0个,则匹配结果由字符串从 i i i开始到结尾和模式从 j + 2 j+2 j+2开始到结尾的匹配结果决定;如果‘ * ’代表多个,则匹配结果由字符串从 i + 1 i+1 i+1开始到结尾和模式从 j j j开始到结尾的匹配结果决定(多个和一个的处理方式一样,因为又变成了情况(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;
}
};
暴力递归过程,将 p r o c e s s ( ) process() process()函数看出是一个黑盒,str、pattern参数是固定的,可变参数只有 i i i和 j j j, i i i的变化范围是 0 ≤ i ≤ s t r l e n ( s t r ) 0\leq i \leq strlen(str) 0≤i≤strlen(str), j j j的变化范围是 0 ≤ j ≤ s t r l e n ( p a t t e r n ) 0\leq j \leq strlen(pattern) 0≤j≤strlen(pattern)。
所以可以将求 p r o c e s s ( i , j ) process(i,j) process(i,j)的过程看是填一个长为 n 2 n2 n2,宽为 n 1 n1 n1的二维矩阵dp的过程,其中 n 1 = s t r l e n ( s t r ) , n 2 = s t r l e n ( p a t t e r n ) 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)为 ( n 1 − 1 , n 2 − 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;
}
};
其实,从这个例子可以发现,从暴力递归到动态规划的路线,难点在于暴力规划的实现,而改写动态规划的过程是极具套路性的,甚至已经和原题意没有什么关系了。