原理
一般dp
普通dp的状态转移是有序的,比如最经典的斐波那契数列:
d
p
(
n
)
=
{
1
n
<
=
2
d
p
(
n
−
1
)
+
d
p
(
n
−
2
)
n
>
2
dp(n)= \begin{cases} 1 & n<=2 \\ dp(n-1)+dp(n-2) & n>2\\ \end{cases}
dp(n)={1dp(n−1)+dp(n−2)n<=2n>2
d
p
(
1
)
=
1
,
d
p
(
2
)
=
1
,
d
p
(
3
)
=
d
p
(
1
)
+
d
p
(
2
)
=
2
,
d
p
(
4
)
=
d
p
(
2
)
+
d
p
(
3
)
=
3
,
d
p
(
5
)
=
d
p
(
3
)
+
d
p
(
4
)
=
5
.
.
.
dp(1)=1,\\ dp(2)=1,\\ dp(3)=dp(1)+dp(2)=2,\\ dp(4)=dp(2)+dp(3)=3,\\ dp(5)=dp(3)+dp(4)=5\\...
dp(1)=1,dp(2)=1,dp(3)=dp(1)+dp(2)=2,dp(4)=dp(2)+dp(3)=3,dp(5)=dp(3)+dp(4)=5...
除了初始的状态
d
p
(
1
)
dp(1)
dp(1)和
d
p
(
2
)
dp(2)
dp(2),其他
d
p
(
n
)
dp(n)
dp(n)计算的时候仅依赖于之前的
d
p
(
n
−
1
)
dp(n-1)
dp(n−1)和
d
p
(
n
−
2
)
dp(n-2)
dp(n−2)。因此从
n
=
3
n=3
n=3开始,从小到大利用
d
p
(
n
−
1
)
dp(n-1)
dp(n−1)和
d
p
(
n
−
2
)
dp(n-2)
dp(n−2)就可以转移到
d
p
(
n
)
dp(n)
dp(n),又因为状态转移时是从小到大的,在算
d
p
(
n
)
dp(n)
dp(n)时
d
p
(
n
−
1
)
dp(n-1)
dp(n−1)和
d
p
(
n
−
2
)
dp(n-2)
dp(n−2)已经计算完毕。因为斐波那契数列的状态转移的有序性,用一个简单的循环就可以实现。
int dp[N];//dp[i]表示斐波那契数列的第i项
void solve(int n){
if(n<=2)return n;
dp[1]=1,dp[2]=2;
for(int i=3;i<=n;i++)
dp[i]=dp[i-1]+dp[i-2];
return dp[n];
}
记搜与一般dp区别
需要记搜的题目的状态转移是无序的,我们举一个经典记忆化搜索的例子:
洛谷P1434 [SHOI2002]滑雪
这题的状态设计比较自然:用
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j]表示从位置
(
i
,
j
)
(i,j)
(i,j)向下滑时的最大高度。起始状态就是那些“谷底”:即四周都不比该位置低,谷底的
d
p
dp
dp值即为1。
先用一个简略版的高度矩阵说明记搜的过程:
3 1 4
2 2 4
因为我们在更新当前位置的最长长度时也不知道它可以从四周的哪个方向进行转移,所以枚举的顺序并没有什么关系,从左上角枚举到右下角即可。
(
1
,
1
)
(1,1)
(1,1)的右侧
(
1
,
2
)
(1,2)
(1,2)和下侧
(
2
,
1
)
(2,1)
(2,1)均为谷底,直接将
d
p
dp
dp值设为1,同时
d
p
[
1
]
[
1
]
dp[1][1]
dp[1][1]被更新为2;
接下去枚举到
(
1
,
3
)
(1,3)
(1,3)位置,左侧
(
1
,
2
)
(1,2)
(1,2)位置
d
p
dp
dp值已为1,
(
1
,
3
)
(1,3)
(1,3)位置
d
p
dp
dp值更新为2;
接下去枚举到
(
2
,
2
)
(2,2)
(2,2)位置,上侧
(
1
,
2
)
(1,2)
(1,2)位置
d
p
dp
dp值为1,因此
(
2
,
2
)
(2,2)
(2,2)位置
d
p
dp
dp值更新为2;
最后位置
(
2
,
3
)
(2,3)
(2,3)左侧
(
2
,
2
)
(2,2)
(2,2)的高度2低于4,且
d
p
dp
dp值已经更新,位置
(
2
,
3
)
(2,3)
(2,3)的
d
p
dp
dp值更新为3。
最后再从
(
1
,
1
)
(1,1)
(1,1)枚举到
(
2
,
3
)
(2,3)
(2,3)位置,求最大的
d
p
dp
dp值作为答案。
记搜与一般dfs区别
我们扩大一下以上高度矩阵:
6 7 9
3 1 4
2 2 4
仍然从左上角到右下角枚举。
h
e
i
g
h
t
[
1
]
[
1
]
>
h
e
i
g
h
t
[
2
]
[
1
]
height[1][1]>height[2][1]
height[1][1]>height[2][1],因此
d
p
[
1
]
[
1
]
dp[1][1]
dp[1][1]可以被
d
p
[
2
]
[
1
]
dp[2][1]
dp[2][1]更新,但此时
d
p
[
2
]
[
1
]
dp[2][1]
dp[2][1]还未更新,因此先去更新
d
p
[
2
]
[
1
]
dp[2][1]
dp[2][1],更新过程同简略版,
d
p
[
2
]
[
1
]
dp[2][1]
dp[2][1]从谷底
d
p
[
3
]
[
1
]
=
1
dp[3][1]=1
dp[3][1]=1和
d
p
[
2
]
[
2
]
=
1
dp[2][2]=1
dp[2][2]=1更新为2,再将更新好的
d
p
[
2
]
[
1
]
dp[2][1]
dp[2][1]去更新
d
p
[
1
]
[
1
]
dp[1][1]
dp[1][1]。
扩大版的dp更新过程如下:
(x,y)表示坐标,height表示高度,dp表示该坐标更新的dp值,即从该位置向下滑的最长长度。
从图中可以看出,状态转移时的需要的那些状态就像当前状态的子树,在记忆化搜索中的状态就像一座森林,其中有一些树会重复地作为别的树的子树,比如上图的(2,2,1,1)。记忆化搜索相比于简单的dfs之所以可以降低时间复杂度,就是因为它记录了一些子树可以重复使用,而不需要在再次遇到该子树时,还需要继续向下递归。
记忆化搜索就是以上的过程,计算状态 d p 1 dp1 dp1时,如果状态转移时需要使用到 d p 2 dp2 dp2,若 d p 2 dp2 dp2已经更新完毕,就像一般 d p dp dp直接更新;否则就递归去计算 d p 2 dp2 dp2,若 d p 2 dp2 dp2计算需要的 d p 3 dp3 dp3仍然未更新,就计算递归计算 d p 3 dp3 dp3…直到需要的 d p dp dp值已经更新好,或者是已经到达了起始的不依赖于其他状态的状态,才直接返回该状态。
#include<bits/stdc++.h>
using namespace std;
const int N=3e2+10;
int g[N][N],dp[N][N];
int n,m,ans;
int dx[4]={1,-1,0,0},dy[4]={0,0,1,-1};
int dfs(int x,int y){
if(dp[x][y]!=-1)return dp[x][y];
int f1=0,f2=0;
for(int i=0;i<4;i++){
int nx=dx[i]+x,ny=dy[i]+y;
if(nx<1||nx>n||ny<1||ny>m)continue;
f1++;
if(g[nx][ny]>=g[x][y]){
f2++;
}
else dp[x][y]=max(dp[x][y],dfs(nx,ny)+1);
}
if(f1==f2)return dp[x][y]=1;
return dp[x][y];
}
int main(){
cin>>n>>m;
memset(dp,-1,sizeof dp);
for(int i=1;i<=n;i++)for(int j=1;j<=m;j++)cin>>g[i][j];
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
ans=max(ans,dfs(i,j));
}
}
cout<<ans<<endl;
return 0;
}
例题
Cake slicing UVA - 1629
题目链接
题目大意: 一块n*m的矩形蛋糕,有k个樱桃,现在要将蛋糕切开使每块蛋糕上都恰有一个樱桃,要求每次只能在一块蛋糕上水平切或竖直切,求最短的刀切长度。
解题思路: 每次可以在一块蛋糕上面切1刀,那块蛋糕会被分成两块,只要每块上面还有樱桃就是合法的,假设切的蛋糕长和宽为 x , y x,y x,y,可以横切和纵切,一共有 x − 1 + y − 1 x-1+y-1 x−1+y−1种切法。还是采用记忆化搜索,因为切好的蛋糕顺序不定。最终状态是整个蛋糕都切好,起始状态是某块🎂只有1个🍒的时候,不用再切,直接返回0。
状态设计: dp[a][b][c][d]表示以(a,b)为左上角坐标,(c,d)为右下角坐标的矩形达到其中每小块只有1个樱桃至少需要切的长度。但是矩形除了坐标还需要知道其中的樱桃个数,只需要用一个二维前缀和数组就可以在O(1)时间计算某个矩形的樱桃个数以确定是否需要继续切割。
状态转移:
- 当前蛋糕只有1个樱桃,返回0。
- 枚举当前蛋糕横切和纵切。
#include<bits/stdc++.h>
using namespace std;
const int N=25;
const int inf=0x3f3f3f3f;
int dp[N][N][N][N],g[N][N],sum[N][N];//dp[lx][ly][rx][ry]为该矩形切割完成后的最少切割长度,num表示该矩形内的樱桃数量
int n,m,cherry;
inline int cal(int a,int b,int c,int d){
return sum[c][d]-sum[a-1][d]-sum[c][b-1]+sum[a-1][b-1];
}
int DP(int a,int b,int c,int d){
int &ans=dp[a][b][c][d];
if(ans!=inf)return ans;
if(cal(a,b,c,d)==1)return ans=0;
for(int i=a;i<c;i++)//横切
if(cal(a,b,i,d)&&cal(i+1,b,c,d))
ans=min(ans,DP(a,b,i,d)+DP(i+1,b,c,d)+d-b+1);
for(int i=b;i<d;i++)
if(cal(a,b,c,i)&&cal(a,i+1,c,d))
ans=min(ans,DP(a,b,c,i)+DP(a,i+1,c,d)+c-a+1);
return ans;
}
int main(){
int kase=0;
while(cin>>n>>m>>cherry&&n&&m){
memset(dp,0x3f,sizeof dp);
memset(sum,0,sizeof sum);
memset(g,0,sizeof g);
for(int i=1;i<=cherry;i++){
int x,y;cin>>x>>y;
g[x][y]=1;
}
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
sum[i][j]=sum[i-1][j]+sum[i][j-1]-sum[i-1][j-1];
if(g[i][j])sum[i][j]++;
}
}
cout<<"Case "<<++kase<<": ";
cout<<DP(1,1,n,m)<<endl;
}
return 0;
}