dp即动态规划,为方面个人复习故写一系列dp相关总结体会,本篇是最基础的一篇,但也会跳过最基础的原理性讲解,此后将分题型进行总结。
dp的原理,大多数的dp都可以从递归->记忆化搜索->动态规划的方式去逐步优化,其中记忆化搜索其实就是在递归的基础上加上一个备忘录,从而避免了重复的调用递归,当完成了dp的方法后大多数时候还可以进行空间的优化,或者尝试策略的调整,同样也值得去深究。
一维动态规划
509.斐波那契数
题目描述就略了。
首先这道题是大多数人对于dp的第一印象,是一道非常经典的递归到记忆化搜索到dp 的题目,因为我们很容易得知斐波那契数列的递推式,所以自然想到要去调用递归,basecase也就是第一项第二项都是1,从而用2^的复杂度求解,但很明显这不是最优解,有很多重复调用的过程,很明显可以进行优化,优化的核心就是减少重复调用,所以最简单的优化就是用一个备忘录记录以及调用过的递归,从而避免了重复调用。
那么既然本篇讲动态规划,那么自然要用动态规划的方式去求解,其实动态规划就是按照位置依赖的方式,从底到顶的逐步推出最终答案,比如这个斐波那契数列,我们求就可以从第一项算起,一步一步走到要求的位置,因为每个子问题求解的方式都是一样的,所以这样求解的答案是合理的。
所以回归这道题本身,我们可以开一个一维数组,从底到顶逐步求解,当然也可以进行空间优化,因为我们注意到,其实每次的计算过程只需要三个位置的数字,有很多数字算出后用了两次就再也没用过,所以我们浪费了空间,其实可以用三个变量ppre,pre,cur,分别表示三个相邻位置的数字,滚动更新,达到优化空间的目的,细节见代码。
class Solution {
public:
int fib(int n) {
if(n==1) return 1;
if(n==0) return 0;
int pre=1,ppre=0;
int cur;
for(int i=2;i<=n;i++){
cur=pre+ppre;
ppre=pre;
pre=cur;
}
return cur;
}
};
(2)983.最低票价
在一个火车旅行很受欢迎的国度,你提前一年计划了一些火车旅行。在接下来的一年里,你要旅行的日子将以一个名为 days
的数组给出。每一项是一个从 1
到 365
的整数。
火车票有 三种不同的销售方式 :
- 一张 为期一天 的通行证售价为
costs[0]
美元; - 一张 为期七天 的通行证售价为
costs[1]
美元; - 一张 为期三十天 的通行证售价为
costs[2]
美元。
通行证允许数天无限制的旅行。 例如,如果我们在第 2
天获得一张 为期 7 天 的通行证,那么我们可以连着旅行 7 天:第 2
天、第 3
天、第 4
天、第 5
天、第 6
天、第 7
天和第 8
天。
返回 你想要完成在给定的列表 days
中列出的每一天的旅行所需要的最低消费 。
这题递归的思路比较好想,就是走一步看一步,按照有逻辑的尝试策略逐步往下选,遍历所有选择情况后选择出最优的情况,那么有几个难点,首先是如何进行尝试,容易忽略的一个点就是,本题是允许有空窗期的,也就是不出行的时候无所谓有没有票,也就是说尝试的过程中变化的参数不能是天数,而应该是在确定出行日期的数组里的下标,意味着-第几个出行的日子,每进行一次尝试(买了某种票)就应该更新下标到下一个无法满足出行的日子再次进行递归(也就是下一个子问题。
那么如何更新答案呢,我们知道,每个子结构的最小消费肯定是最终最小消费的一部分(贪心),那么比如在i下标位置,我们可以进行1,7,30天的尝试,从而得到三个子结构的值,在把返回的三个子结构加上对应尝试的代价,我们也就应该向上返回最小的一个,直到求解,下一个问题在于basecase(最小递归)其实并不是下标是最后一个的时候,而应该是越界的时候,因为如果递归下标是最后一个位置则说明这个位置还没有被尝试过,也就是是无票状态,所以basecase应该是数组下标处于越界情况的时候,返回0,因为这时候不再有花费了,这就是递归的解法。
class Solution {
public:
int dur[5]={1,7,30};//暴力递归版本
int mincostTickets(vector<int>& days, vector<int>& costs) {
return f(days,costs,0);
}
int f(vector<int>& days, vector<int>& costs,int n){
if(n==days.size()) return 0;
int ans=INT_MAX,j;
for(int i=0,j=n;i<3;i++){
while(j<days.size()&&days[j]<days[n]+dur[i]){//注意取等的判断,相等也算是解决了该天
j++;
}
ans=min(ans,f(days,costs,j)+costs[i]);//加上尝试代价求min
}
return ans;
}
};
递归解法会超时,但挂上表,用记忆化搜索的方式就不会超时了。
class Solution {
public:
int dur[5]={1,7,30};//记忆化搜索版本
int dp[500];//500足够因为最多只有365天
int mincostTickets(vector<int>& days, vector<int>& costs) {
return f(days,costs,0);
}
int f(vector<int>& days, vector<int>& costs,int n){
if(dp[n]!=0) return dp[n];//判断是否算过
if(n==days.size()) return 0;
int ans=INT_MAX,j;
for(int i=0,j=n;i<3;i++){
while(j<days.size()&&days[j]<days[n]+dur[i]){
j++;
}
ans=min(ans,f(days,costs,j)+costs[i]);
}
dp[n]=ans;//至此说明第一次求解,记录下来
return ans;
}
};
那么动态规划版本呢,既然讲了递归,当然不是白讲,本题能更直观的反映为什么要通过递归到dp,因为其实动态规划的递推式就是根据递归改的,当我们写出递归,只要根据递归的尝试策略进行递推也就可以找到位置的依赖关系得出递推式,什么叫位置依赖关系呢,也就是从底到顶的时候,该位置由底部的哪些位置影响,因为递归恰好是从顶到底的调用求解,那么恰好通过递归就可以得出dp 的位置依赖关系。
就本题而言,我们可以把dp的下标含义定义为从i位置到最后的最小花费,每走到一个位置,需要根据他所依赖的位置进行清算,那么根据我们递归的策略,我们到一个位置就找到该恰好不满足的下标位置,此处的值已经求出,只需要加上此时的代价,最后求最小值,和递归策略一模一样,只不过展开方向不同,细节看代码。
class Solution {
public:
int dur[5]={1,7,30};
vector<int> dp = vector<int>(370, INT_MAX);
int mincostTickets(vector<int>& days, vector<int>& costs) {
int n=days.size();
dp[n]=0;
for(int i=n-1;i>=0;i--){
for(int k=0,j=i;k<3;k++){
while(j<n&&days[i]+dur[k]>days[j]){
j++;
}
dp[i]=min(dp[i],dp[j]+costs[k]);
}
}
return dp[0];
}
};
(3)解码方法
一条包含字母 A-Z
的消息通过以下映射进行了 编码 :
'A' -> "1" 'B' -> "2" ... 'Z' -> "26"
要 解码 已编码的消息,所有数字必须基于上述映射的方法,反向映射回字母(可能有多种方法)。例如,"11106"
可以映射为:
"AAJF"
,将消息分组为(1 1 10 6)
"KJF"
,将消息分组为(11 10 6)
注意,消息不能分组为 (1 11 06)
,因为 "06"
不能映射为 "F"
,这是由于 "6"
和 "06"
在映射中并不等价。
给你一个只含数字的 非空 字符串 s
,请计算并返回 解码 方法的 总数 。
题目数据保证答案肯定是一个 32 位 的整数。
首先研究递归的解法,方案的数量取决于的唯一参数就是在字符串中的位置,我们想要知道从零到最后的总方案数,很容易可以想到子问题就是某位置到最后的方案总数,那么按照分类原则,我们要对该可以编为一位还是两位进行区别,将两个子问题(如果有)相加就是该位置的答案,至于basecase就是最后一位肯定只有一种编码方案,为节省篇幅,直接贴上记忆化搜索版本。
class Solution {
public:
vector<int> dp=vector<int>(150,-1);//记忆表(备忘录)
int numDecodings(string s) {
return f(s,0);//递归方法
}
int f(string s,int n){
if(dp[n]!=-1) return dp[n];//basecase
if(n==s.size()) return 1;
if(s[n]=='0'){//如果是零就一种方案都没有
return 0;
}
long long ans=f(s,n+1);
if(n<s.size()-1){
if((s[n]-'0')*10+(s[n+1]-'0')<=26){//以及排除了0的情况,所以判断是否小于等于26即可
ans+=f(s,n+2);
}
}
dp[n]=ans;
return ans;
}
};
根据递归的尝试策略,我们去分析dp的递推式子,根据从底到顶的写法,dp数列的最后一项应该是1,因为一直编码到最后也算是一种方案,然后按照递推的规则去递推,位置依赖就是依赖该项的下两项,总体很简单,看代码。
class Solution {
public:
int numDecodings(string s) {
int n=s.size();//基本版本dp解法
vector<int> dp(n+1);
dp[n]=1;
for(int i=n-1;i>=0;i--){
if(s[i]=='0'){//无需进一步讨论的情况
dp[i]=0;
}else{
dp[i]=dp[i+1];
if(i<n-1&&(s[i]-'0')*10+(s[i+1]-'0')<=26){//避免没有下一位,下标越界的情况
dp[i]+=dp[i+2];
}
}
}
return dp[0];
}
};
我们注意到,每个位置只依赖本位置的下两个位置,所以空间还有优化的空间,只用三个变量滚动更新即可,cur,next,nnext三个足矣。
class Solution {
public:
int numDecodings(string s) {//压缩空间优化版本
int n=s.size();
int cur,next=1,nnext;//第一次用不到nnext不用定义
for(int i=n-1;i>=0;i--){
if(s[i]=='0'){
cur=0;
}else{
cur=next;
if(i<n-1&&(s[i]-'0')*10+(s[i+1]-'0')<=26){
cur+=nnext;
}
}
nnext=next;//不要忘了滚动更新
next=cur;
}
return cur;
}
};
(4) 639.编码方法2
相比上一个问题就是多了一个*的讨论,但实际上很简单,只不过是需要细细分类讨论,此外需要注意的是,此时方案数并不是简单的相加,因为出现了同种方案的多种情况,需要用到乘法,比如同样都将本位自己编码,当本位是一个确切的数字的时候,就是简单的加f(n+1),但当本位是*的时候,就需要乘以可能的方案数,还有就是注意取模,其余与上题基本相同。
class Solution {
public:
const int MOD=1e9+7;
vector<int> dp=vector<int> (100005,-1);
int numDecodings(string s) {
return f(s,0,dp);
}
long f(string s,int n,vector<int> &dp){
if(dp[n]!=-1) return dp[n];
if(n==s.size()) return 1;
if(s[n]=='0') return 0;
int len=s.size();
long ans=0;
if(s[n]=='*'){
ans+=(9*(f(s,n+1,dp)%MOD))%MOD;
ans%=MOD;
if(s[n+1]=='*'&&n<len-1){
ans+=(15*(f(s,n+2,dp)%MOD))%MOD;
ans%=MOD;
}else{
if(s[n+1]>='0'&&s[n+1]<='6'&&len-1){
ans+=(2*(f(s,n+2,dp)%MOD))%MOD;
ans%=MOD;
}else{
if(n<len-1){
ans+=f(s,n+2,dp)%MOD;
ans%=MOD;
}
}
}
}else{
ans+=f(s,n+1,dp)%MOD;
ans%=MOD;
if(s[n+1]!='*'){
if((s[n]-'0')*10+s[n+1]-'0'<=26&&n<s.size()-1){
ans+=f(s,n+2,dp)%MOD;
ans%=MOD;
}
}else{
if(s[n]=='1'&&n<s.size()-1){
ans+=(9*(f(s,n+2,dp)%MOD))%MOD;
ans%=MOD;
}else{
if(s[n]=='2'&&n<s.size()-1){
ans+=(6*(f(s,n+2,dp)%MOD))%MOD;
ans%=MOD;
}
}
}
}
dp[n]=ans%MOD;
return ans%MOD;
}
};
会超内存,因为每个位置的结果都需要储存。
接下来是dp写法(常规版本)
class Solution {
public:
const int MOD=1e9+7;
int numDecodings(string s) {
vector<long> dp(s.size()+5);
int len=s.size();
dp[len]=1;
for(int i=len-1;i>=0;i--){
if(s[i]=='0'){
dp[i]=0;
continue;
}
if(s[i]=='*'){
dp[i]+=(9*(dp[i+1]%MOD))%MOD;
if(s[i+1]=='*'&&i<len-1){
dp[i]+=(15*dp[i+2])%MOD;
}else{
if(s[i+1]>='0'&&s[i+1]<='6'&&len-1){
dp[i]+=(2*(dp[i+2]%MOD))%MOD;
}else{
if(i<len-1){
dp[i]+=dp[i+2]%MOD;
}
}
}
}else{
dp[i]+=dp[i+1]%MOD;
if(s[i+1]!='*'){
if((s[i]-'0')*10+s[i+1]-'0'<=26&&i<s.size()-1){
dp[i]+=dp[i+2]%MOD;
}
}else{
if(s[i]=='1'&&i<s.size()-1){
dp[i]+=(9*(dp[i+2]%MOD))%MOD;
}else{
if(s[i]=='2'&&i<s.size()-1){
dp[i]+=(6*(dp[i+2]%MOD))%MOD;
}
}
}
}
}
return dp[0]%MOD;
}
};
本题仍然可以用空间优化。
class Solution {
public:
const int MOD=1e9+7;
int numDecodings(string s) {
int len=s.size();
long cur=0,next=1,nnext=0;
for(int i=len-1;i>=0;i--){
if(s[i]=='0'){
cur=0;
nnext=next;
next=cur;
continue;
}
if(s[i]=='*'){
cur=(9*(next%MOD))%MOD;
if(s[i+1]=='*'&&i<len-1){
cur+=(15*nnext)%MOD;
}else{
if(s[i+1]>='0'&&s[i+1]<='6'&&i<len-1){
cur+=(2*(nnext%MOD))%MOD;
}else{
if(i<len-1){
cur+=nnext%MOD;
}
}
}
}else{
cur=next%MOD;
if(s[i+1]!='*'){
if((s[i]-'0')*10+s[i+1]-'0'<=26&&i<len-1){
cur+=nnext%MOD;
}
}else{
if(s[i]=='1'&&i<len-1){
cur+=(9*(nnext%MOD))%MOD;
}else{
if(s[i]=='2'&&i<len-1){
cur+=(6*(nnext%MOD))%MOD;
}
}
}
}
nnext=next;
next=cur;
}
return cur%MOD;
}
};
(5) 32.最长有效括号
给你一个只包含 '('
和 ')'
的字符串,找出最长有效(格式正确且连续)括号
子串
的长度。
分析问题,本题属于字符串处理问题,我们直接来看dp的解法,一般字符串的方案最优问题清算答案的方式都是,当前位置作为最优答案的结尾时考虑可行性,所以我们按照这个思路走下去。
第一,左括号一定不能作为最优答案的结尾,所以遇到左括号我们不予处理,那么对于右括号怎么去清算呢,我们需要跳到上一次清算答案的地方,因为只有这里才会可能和现在的位置的右括号构成最优答案,难点在于,可能当前位置的右括号向左经过很长一段有效区域才找到可以搭配的左括号,常规的思路自然就是一直向前边找边记录,找到第一个不满足有效条件的括号,如果是左括号就可以成功让此时答案+2,那么既然我们学习动态规划的解法就要灵活运用以及计算过的部分,我们发现,恰好前面的值代表了有效区域是多长,我们可以直接借助上一位的位置进行跳转到第一个不满足条件的区域,从而更新答案,
此外还有一个容易忽略的点,那就是有可能因为这个一开始无效的左括号分隔开了两个有效区域,那么通过当前位置和这个左括号的搭配,也成功链接了这两个区域,所以我们也不要忘了加上最右无效位前一位所记录的局部最优答案,同时这个递推只能清算区域最优解,所以在同时我们也应该用一个max时刻更新最优答案,细节看代码。
class Solution {
public:
int longestValidParentheses(string s) {
int len=s.size();
if(len==0) return 0;
vector<int>dp(len+5);
int Max=0;
for(int i=1;i<len;i++){
if(s[i]==')'){
int id=i-dp[i-1]-1;
if(id>=0){
if(s[id]=='('){
dp[i]=dp[i-1]+2;
if(id>0){
dp[i]+=dp[id-1];
}
}
}
}
Max=max(Max,dp[i]);
}
return Max;
}
};
(6)环绕字符串中唯一的子字符串
定义字符串 base
为一个 "abcdefghijklmnopqrstuvwxyz"
无限环绕的字符串,所以 base
看起来是这样的:
"...zabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcd...."
.
给你一个字符串 s
,请你统计并返回 s
中有多少 不同非空子串 也在 base
中出现。
对于本题的分析,略不同于常规对于字符串的dp问题,因为需要统计个数,而不是求出最优解,如果是求出最长子串,维护一个当前持续长度,如果仍然有效就++,否则就置一,最后在更新的全程维护一个max持续更新即可。
但是本题要求求出有多少非空子串,首先就是找到合理的统计方式,那么不难发现,统计子串可以找到一个局部连续最长的子串在其内部进行分类统计,比如一个长度是4的子串,非空子串个数应该是十个,因为以每个字符结尾的子串数量分别是1 2 3 4 ,如此我们便发现了一个有趣的现象,只要找到每个字符作为结尾位置最长串,他的长度恰好就是此时满足条件的子串个数,再将所有字符结尾的情况相加就是最后的答案,这其中恰好用到了我们一开始说的统计最优解的方法,只要稍加变动,把max改为最后求和即可,细节看代码。
class Solution {
public:
int findSubstringInWraproundString(string s) {
int n=s.size();
vector<int> dp(30);
dp[s[0]-'a']=1;
for(int i=1,len=1;i<n;i++){
if(s[i-1]+1==s[i]||(s[i-1]=='z'&&s[i]=='a')){
len++;
}else{
len=1;
}
dp[s[i]-'a']=max(dp[s[i]-'a'],len);
}
long long ans=0;
for(int i=0;i<=26;i++){
ans+=dp[i];
}
return ans;
}
};
(7)940.不同的子序列2
给定一个字符串 s
,计算 s
的 不同非空子序列 的个数。因为结果可能很大,所以返回答案需要对 10^9 + 7
取余 。
字符串的 子序列 是经由原字符串删除一些(也可能不删除)字符但不改变剩余字符相对位置的一个新字符串。
- 例如,
"ace"
是"abcde"
的一个子序列,但"aec"
不是。
这道题的分析很有意思,首先常规的想法就是如果一个字符串每个元素各不相同,那么很容易就可以得到最终子串的答案,2^n-1;但本题特别之处在于,重复字符的出现会干扰这个结果,也就是说我们要减去重复的情况,再次一个从讨论以某字符结尾的情况经典思路入手。
首先我想到的是本位置的dp表的数组值代表从开始到本位置总共有多少个字符串,再去找依赖关系,那么大方向无非两种,要这个位置的字符或者不要,那么不要这个位置的字符就是dp【i-1】,那么要这个位置的字符就需要考虑重复的情况,比如aabb对于最后一个b,如果他是一个新字符,那么第二种情况的字符串数量就应该是dp【i-1】+1(因为还有前面是空串的情况),那么为什么本位置是b的时候就出现重复了呢,因为前面的aa可以组成的字符串可以选择他们两个的任何一个当作后缀,这些情况都是重复的,那么重复了多少个呢,也就是重复了上一个以b结尾的子串个数那么多个,那么就可以通过上一个同样字符清算值算出的值来算出从开头到当前位置的子串数了。
但是问题在于现在没有结构储存这样一个值,所以说明我们对于dp表的定义并不正确,我们应该维护一个26大小的数组,每个位置统计一下以当前位置结尾的子串数量,但是为了得到答案,我们还需要一个变量sum来保存如今一共有多少个子串,然后不断更新sum和dp表即可。
此外需要注意的是,因为我们最后统计的是不包括空串的,但是对于新增一个元素的时候,对于前面已经统计过的子串需要包括子串,因为有可能只有最后一个字符,所以每次都要加上1,也就是代表空串,为了方便我们就按照统计子串去解决,但最后记得减去空串,细节看代码。
class Solution {
public:
int distinctSubseqII(string s) {
int MOD=1e9+7;
vector<int> dp(28);
int sum=1,add;//add代表新增的子串数量,dp表对应值和sum是变化大小一样的。
for(int i=0;i<s.size();i++){
add=(sum-dp[s[i]-'a']+MOD)%MOD;//注意减法的同余原理需要加上MOD
dp[s[i]-'a']=(dp[s[i]-'a']+add)%MOD;
sum=(sum+add)%MOD;
}
return (sum-1+MOD)%MOD;//这里同样
}
};
总的来说,本篇主要讨论了一些基础的一维dp问题,从递归到记忆化到严格位置依赖的dp到空间的优化,以及对于经典dp表中值含义的思路,如何去不断思考,纠正,最后完成合理的算法。
至此。