动态规划是一种思想,本质上是用来解决递归中的低效性。在递归解决问题的过程中,会将问题分解为许多小问题,但是很多小问题会被多次重复计算,斐波那契数列的递归过程 就是一个很好的说明。
因此,如果在递归过程中,将子问题处理结果记录下来,对递归树进行剪枝,从而优化时间,这一种方法又叫做记忆化搜索,也是动态规划的一种实现方式。
然后,如果我们直到原问题和子问题之间的关系,那么我们可以通过先求解规模小的子问题,然后通过转移方程反推原问题,这就是动态规划的思想。
个人认为,动态规划不是一蹴而就的过程,没有一拿到题目就知道状态转移方程,我们需要从基本做起,写递归版本,从递归版本中理解问题之间的关系。涉及字符串的问题,我们就可以往动态规划的方面去思考。因为原字符串和子字符串就是原问题和子问题的关系,我们可以比较容易的写出递归版本的代码,根据递归代码给出的思路,分析得到原问题和子问题的状态转移方程,这样就可以写出大部分动态规划问题的代码。
题目描述:
给你一个字符串 s
和一个字符规律 p
,请你来实现一个支持 '?'
和 '*'
的正则表达式匹配。
'?'
匹配任意单个字符,'*'
匹配任意长度字符串,也可以是空字符串
示例:
输入:
s = “aab”
p = “c * a * b”
输出: true
解释: 因为 ‘*’ 表示任意,这里 ‘c’ 为 0 个, ‘a’ 被重复一次。因此可以匹配字符串 “aab”。
算法思路:
看到题目,没有想到啥算法,因此先进行尝试。从两个字符串的头部开始:
如果第一个字符是匹配的,那么可以同时判s
与p
的下一个字符是否匹配。如果第一个字符不匹配,那么就分情况分析。
如果p
串的第一个字符是?
,那么无论s
串第一个元素是什么,都可以匹配。
如果p
串的第一个字符是*
,那么这个字符可以匹配s
串之后任意长度的串,所以p
串不动,s
串向后匹配一个字符;或者把*
当做空字符串,直接匹配p
的后一个字符串。
如果p
串匹配到结尾,判断s
串是否到达结尾,返回true
或者false
因此,可以写出递归版本的代码,如下所示
string s,p;
bool isMatch(int i,int j) //i表示s串当前匹配位置 j表示p串当前匹配位置
{
if(j == p.size()) return i==s.size();
bool first_match = (s[i]==p[j] || p[j]=='?');
if(p[j] == '*')
{
// 当前位置为* 默认匹配掉一个位置 或者 默认*代表空字符串
return isMatch(i+1,j) || isMatch(i,j+1);
}
else
{
// 后面是否匹配 且 当前位置是否匹配
return first_match && isMatch(i+1,j+1);
}
}
通过递归过程,我们就可以分析出原问题和子问题的结构
原问题:字符串s
和字符串p
是否匹配。 子问题:字符串s
和字符串p
除了第一个字符串之外的子串是否匹配。
def:
d p [ i ] [ j ] dp[i][j] dp[i][j],s
串从 i i i 开始的子串 与 p
串从 j j j 开始的子串是否匹配
目标状态: d p [ 0 ] [ 0 ] dp[0][0] dp[0][0],表示字符串s
和字符串p
是否匹配。是需要求解的目标状态
初始状态: d p [ s . s i z e ( ) ] [ p , s i z e ( ) ] dp[s.size()][p,size()] dp[s.size()][p,size()],表示字符串""
和字符串""
是否匹配。是求解的,肯定为true
状态转移方程:
d p [ i ] [ j ] = d p [ i ] [ j + 1 ] o r d p [ i + 1 ] [ j ] ( i f p [ i ] = = ′ ∗ ′ ) dp[i][j] = dp[i][j+1]\quad or\quad dp[i+1][j] \quad(if\quad p[i]=='*') dp[i][j]=dp[i][j+1]ordp[i+1][j](ifp[i]==′∗′)
d p [ i ] [ j ] = f i r s t m a t c h a n d d p [ i + 1 ] [ j + 1 ] ( o t h e r w i s e ) dp[i][j] = firstmatch\quad and\quad dp[i+1][j+1]\quad(otherwise) dp[i][j]=firstmatchanddp[i+1][j+1](otherwise)
代码:
bool isMatch(string s, string p)
{
if(p.size()==0) return s.size()==0;
int m = s.size();
int n = p.size();
vector<vector<bool>> dp(m+1,vector<bool>(n+1,false));
dp[m][n] = true;
for(int j=n-1;j>=0;j--)
{
if(p[j]!='*') break;
dp[m][j] = (p[j]=='*');
}
for(int i=m-1;i>=0;i--)
{
for(int j=n-1;j>=0;j--)
{
bool fm = (s[i]==p[j] || p[j]=='?');
if(p[j]=='*')
{
dp[i][j] = dp[i][j+1] || dp[i+1][j];
}
else
{
dp[i][j] = fm && dp[i+1][j+1];
}
}
}
return dp[0][0];
}
题目描述:
给你一个字符串 s
和一个字符规律 p
,请你来实现一个支持 '.'
和 '*'
的正则表达式匹配。
'.'
匹配任意单个字符,'*'
匹配零个或多个前面的那一个元素
示例:
输入:
s = “aab”
p = “c * a * b”
输出: true
解释: 因为 ‘*’ 表示任意,这里 ‘c’ 为 0 个, ‘a’ 被重复一次。因此可以匹配字符串 “aab”。
算法思路:
本题和上面的题目几乎是同一个题,唯一的区别就在于'*'
的定义不同。这里'*'
表示重复前一个字符多次(包含0次)
沿用上一个题的思路,根据'*'
的定义的特殊性,因此对每个符号,不仅要判断当前位置,还要判断下一位置是否为'*'
s
与p
的下一个字符是否匹配。如果第一个字符不匹配,那么就分情况分析。p
串的第一个字符是.
,那么无论s
串第一个元素是什么,都可以匹配。p
串的下一个字符是*
,那么可以当前字符被重复多次,所以p
串不动,s
串向后匹配一个字符;或者把*
当做匹配0次处理,直接匹配p
的后两个字符串。递归版本代码如下:
bool process(string s,int i,string p,int j)
{
// 字符串s从i位置到结尾 能否与 p从j位置到结尾向匹配
if(j == p.size()) return i==s.size();
bool first_match = false;
if(s[i] == p[j] || p[j]=='.') first_match = true;
if(p[j+1] == '*') // 下一位是*号
{
// 可以选择不要第一位的匹配结果 或者 保留第一位匹配结果
return process(s,i,p,j+2) || (first_match && process(s,i+1,p,j));
}
else // 下一位不是*号,向后试探
{
return (first_match && process(s,i+1,p,j+1));
}
}
然后在根据递归过程改写动态规划,相关定义与上一题相同。
代码:
bool isMatch(string s, string p)
{
int m = s.size();
int n = p.size();
vector<vector<bool>> dp(m+1,vector<bool>(n+1,false));
dp[m][n] = true;
for(int i=m;i>=0;i--)
{
for(int j=n-1;j>=0;j--)
{
bool first_match = i<m? (s[i] == p[j] || p[j]=='.'):false;
if(j+1<n && p[j+1] == '*')
{
dp[i][j] = dp[i][j+2] || (first_match && dp[i+1][j]);
}
else
dp[i][j] = (first_match && dp[i+1][j+1]);
}
}
return dp[0][0];
}
题目描述:
给定一个非空字符串 s 和一个包含非空单词列表的字典 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。
示例:
输入: s = “leetcode”, wordDict = [“leet”, “code”]
输出: true
解释: 返回 true 因为 “leetcode” 可以被拆分成 “leet code”。
算法思路:
首先,仍然采用尝试的方法,对于每一个位置,都尝试能不能分割,如果可以,就继续往下分割。
递归代码如下:
bool process(string s)
{
if(s.size() == 0) return true;
bool find = false;
for(int i=0;i<s.size();i++)
{
if(m.count(s.substr(0,i+1)) == 1)
{
find = true;
return process(s.substr(i+1)); //从i位置开始截取新的字符串
}
}
return find;
}
根据递归代码,我们可以找到动态规划的思路:
d p [ i ] dp[i] dp[i]表示从i
开始的子串能否被完全拆分,如果可以i
位置被记录为一个拆分点。每一个拆分点就是对应有解的子问题。对下一个位置遍历的时候,需要考虑所有可能的拆分点,判断从当前位置到拆分点是否存在于词典中。状态转移方程如下 :
d p [ j ] = d p [ i ] a n d i s s e a r c h ( s , s u b s t r ( j , i − j ) ) ; f o r i i n 所 有 拆 分 点 dp[j]\quad=\quad dp[i]\quad and\quad issearch(s,substr(j,i-j));\quad for\quad i\quad in\quad {所有拆分点} dp[j]=dp[i]andissearch(s,substr(j,i−j));foriin所有拆分点
初始状态: d p [ s . s i z e ( ) ] = t r u e dp[s.size()] = true dp[s.size()]=true,表示空字符串不用分割
目标状态: d p [ 0 ] dp[0] dp[0]
代码:
unordered_map<string,int> m;
bool wordBreak(string s, vector<string>& wordDict)
{
for(auto s:wordDict)
{
m[s]++;
}
vector<bool> dp(s.size()+1,false);
unordered_map<int,int> split_point; // 使用map加速查找过程
dp[s.size()] = true;
split_point[s.size()] = 1;
int end = s.size();
for(int j=s.size()-1;j>=0;j--)
{
for(auto p:split_point) // 遍历所有分割点
{
if(m.count(s.substr(j,p.first-j)) == 1)
{
dp[j] = dp[j] || dp[p.first];
split_point[j] = 1;
}
}
}
return dp[0];
}
感觉字符串的dp问题,dp状态都比较好定义,无非就是从i位置开始的子串 或者是 i位置之前的子串 是否满足题目要求。另外,通过递归去找潜在的逻辑关系不失为一种好方法,可以让自己更加深刻的理解题意。不过动态规划的内容还是很多,很多地方自己也没有很理解,上面三个题只是我做的一些题目自认为比较典型而且好理解的题目。
革命尚未成功,同志仍需努力!
(未完待续!)