本篇是个人根据网络资料总结有关区间dp常见思路以及一些题解。
从形式上去看,区间dp的特征就是绝大多数是二维的dp问题,两个参数分别代表区间的左右端点,往往问题与整体有关,或者单次效果与左右邻居有关,或者可以选择头或尾的值,或者需要划分为很多段,等等等等,只言片语很难全面概括,但是单纯从讨论的情况来分类,大致分为按照左右端点区分来讨论,或者按照区分点来讨论,分为两个区间。
1312.让字符串成为回文串的最少插入次数
给你一个字符串 s
,每一次操作你都可以在字符串的任意位置插入任意字符。
请你返回让 s
成为回文串的 最少操作次数 。
「回文串」是正读和反读都相同的字符串。
本类题目就是区间dp最基本的一种,因为涉及回文串,像往常的以当前位置作为回文串结尾这样的讨论,我们其实需要让他和尽可能远端的相同字符配对,这样就不难想到从左右的端点讨论,也就是如果两端点相同,那么没有问题无需插入就继续讨论l+1,r-1即可,但是如果不相等,那么就利用左右端点分别讨论这种思想,我们需要求出最优的答案,但是目前插入和右侧和左侧的代价都是一,重点是插入后在那边进行讨论更优,所以就有了递推式dp[l][r]=min(dp[l+1][r],dp[l][r-1])+1,代码还需要注意的就是,每个格子依赖的是自己下方左方左下方的值,一开始我们可以确定对角线上的值,和l=r-1位置的值(值相同则0,不同则为1)所以我们可以从下往上从左到右去填,或者沿着左上右下对角线填,都可以,代码按照第一种方式填。
class Solution {
public:
int minInsertions(string s) {
int n=s.size();
vector<vector<int>> dp(n+1,vector<int> (n+1));
for(int i=0;i<n-1;i++){
if(s[i]!=s[i+1]){
dp[i][i+1]=1;
}
}
for(int l=n-3;l>=0;l--){
for(int r=l+2;r<n;r++){
if(s[l]==s[r]){
dp[l][r]=dp[l+1][r-1];
}else{
dp[l][r]=min(dp[l+1][r],dp[l][r-1])+1;
}
}
}
return dp[0][n-1];
}
};
486.预测赢家
给你一个整数数组 nums
。玩家 1 和玩家 2 基于这个数组设计了一个游戏。
玩家 1 和玩家 2 轮流进行自己的回合,玩家 1 先手。开始时,两个玩家的初始分值都是 0
。每一回合,玩家从数组的任意一端取一个数字(即,nums[0]
或 nums[nums.length - 1]
),取到的数字将会从数组中移除(数组长度减 1
)。玩家选中的数字将会加到他的得分上。当数组中没有剩余数字可取时,游戏结束。
如果玩家 1 能成为赢家,返回 true
。如果两个玩家得分相等,同样认为玩家 1 是游戏的赢家,也返回 true
。你可以假设每个玩家的玩法都会使他的分数最大化。
class Solution {
public:
bool predictTheWinner(vector<int>& nums) {
int n=nums.size();
int sum=0;
for(int i=0;i<n;i++) sum+=nums[i];
vector<vector<int>> dp(n+1,vector<int>(n+1));
for(int i=0;i<n-1;i++) dp[i][i]=nums[i],dp[i][i+1]=max(nums[i],nums[i+1]);
dp[n-1][n-1]=nums[n-1];
for(int l=n-3;l>=0;l--){
for(int r=l+2;r<n;r++){
dp[l][r]=max(min(dp[l+2][r],dp[l+1][r-1])+nums[l],min(dp[l][r-2],dp[l+1][r-1])+nums[r]);
}
}
return dp[0][n-1]>=sum-dp[0][n-1];
}
};
1039.多边形三角形剖分最低得分
你有一个凸的 n
边形,其每个顶点都有一个整数值。给定一个整数数组 values
,其中 values[i]
是第 i
个顶点的值(即 顺时针顺序 )。
假设将多边形 剖分 为 n - 2
个三角形。对于每个三角形,该三角形的值是顶点标记的乘积,三角剖分的分数是进行三角剖分后所有 n - 2
个三角形的值之和。
返回 多边形进行三角剖分后可以得到的最低分 。
class Solution {
public:
int minScoreTriangulation(vector<int>& values) {
int n=values.size();
vector<vector<int>> dp(n,vector<int>(n));
for(int l=n-3;l>=0;l--){
for(int r=l+2;r<n;r++){
dp[l][r]=INT_MAX;
for(int m=l+1;m<r;m++)
dp[l][r]=min(dp[l][r],dp[l][m]+dp[m][r]+values[l]*values[r]*values[m]);
}
}
return dp[0][n-1];
}
};
1547.切棍子的最小成本
有一根长度为 n
个单位的木棍,棍上从 0
到 n
标记了若干位置。例如,长度为 6 的棍子可以标记如下:
给你一个整数数组 cuts
,其中 cuts[i]
表示你需要将棍子切开的位置。
你可以按顺序完成切割,也可以根据需要更改切割的顺序。
每次切割的成本都是当前要切割的棍子的长度,切棍子的总成本是历次切割成本的总和。对棍子进行切割将会把一根木棍分成两根较小的木棍(这两根木棍的长度和就是切割前木棍的长度)。请参阅第一个示例以获得更直观的解释。
返回切棍子的 最小总成本 。
class Solution {
public:
int minCost(int n, vector<int>& cuts) {
int len=cuts.size();
sort(cuts.begin(),cuts.end());
vector<int> a(len+2);
for(int i=0;i<len;i++){
a[i+1]=cuts[i];
}
a[0]=0,a[len+1]=n;
vector<vector<int>> dp(len+2,vector<int>(len+2));
for(int i=1;i<=len;i++){
dp[i][i]=a[i+1]-a[i-1];
}
for(int l=len-1;l>=1;l--){
for(int r=l+1;r<=len;r++){
dp[l][r]=INT_MAX;
for(int m=l;m<=r;m++){
dp[l][r]=min(dp[l][r],dp[l][m-1]+dp[m+1][r]+a[r+1]-a[l-1]);
}
}
}
return dp[1][len];
}
};
312.戳气球
有 n
个气球,编号为0
到 n - 1
,每个气球上都标有一个数字,这些数字存在数组 nums
中。
现在要求你戳破所有的气球。戳破第 i
个气球,你可以获得 nums[i - 1] * nums[i] * nums[i + 1]
枚硬币。 这里的 i - 1
和 i + 1
代表和 i
相邻的两个气球的序号。如果 i - 1
或 i + 1
超出了数组的边界,那么就当它是一个数字为 1
的气球。
求所能获得硬币的最大数量。
class Solution {
public:
int maxCoins(vector<int>& nums) {
int len=nums.size();
vector<int> n(len+2);
for(int i=1;i<=len;i++) n[i]=nums[i-1];
n[0]=1,n[len+1]=1;
vector<vector<int> >dp(len+2,vector<int>(len+2));
for(int i=1;i<=len;i++){
dp[i][i]=n[i]*n[i-1]*n[i+1];
}
for(int i=len;i>=1;i--){
for(int j=i+1;j<=len;j++){
dp[i][j]=max(dp[i][j],max(n[i]*n[i-1]*n[j+1]+dp[i+1][j],dp[i][j-1]+n[j]*n[j+1]*n[i-1]));
for(int k=i+1;k<j;k++){
dp[i][j]=max(dp[i][j],n[k]*n[i-1]*n[j+1]+dp[i][k-1]+dp[k+1][j]);
}
}
}
return dp[1][len];
}
};
因为枚举先戳爆哪个气球计算得分需要知道该气球前后的气球是什么,信息不足,所以我们可以枚举一个区间内最后戳爆的气球,并且保证这个区间的左侧和右侧没有被戳爆,因为枚举分割点的时候的意义是最后戳爆,所以分割后的子区间仍然保证左右没有被戳爆(是分割点)。
面试题 08.14布尔运算
给定一个布尔表达式和一个期望的布尔结果 result,布尔表达式由 0
(false)、1
(true)、&
(AND)、 |
(OR) 和 ^
(XOR) 符号组成。实现一个函数,算出有几种可使该表达式得出 result 值的括号方法。
示例 1:
输入: s = "1^0|0|1", result = 0 输出: 2 解释: 两种可能的括号方法是 1^(0|(0|1)) 1^((0|0)|1)
示例 2:
输入: s = "0&0&0&1^1|0", result = 1 输出: 10
class Solution {
public:
int countEval(string s, int result) {
int n=s.size();
vector<vector<vector<int>>> dp(n,vector<vector<int>>(n,vector<int>(2)));
for(int i=0;i<n;i+=2){
dp[i][i][s[i]-'0']++;
}
for(int i=n-3;i>=0;i-=2){
for(int j=i+2;j<n;j+=2){
for(int k=i+1;k<j;k+=2){
if(s[k]=='^'){
dp[i][j][0]+=dp[i][k-1][0]*dp[k+1][j][0]+dp[i][k-1][1]*dp[k+1][j][1];
dp[i][j][1]+=dp[i][k-1][1]*dp[k+1][j][0]+dp[i][k-1][0]*dp[k+1][j][1];
}else {
if(s[k]=='&'){
dp[i][j][0]+=(dp[i][k-1][0]+dp[i][k-1][1])*(dp[k+1][j][1]+dp[k+1][j][0])-dp[i][k-1][1]*dp[k+1][j][1];
dp[i][j][1]+=dp[i][k-1][1]*dp[k+1][j][1];
}else{
dp[i][j][0]+=dp[i][k-1][0]*dp[k+1][j][0];
dp[i][j][1]+=(dp[i][k-1][0]+dp[i][k-1][1])*(dp[k+1][j][1]+dp[k+1][j][0])-dp[i][k-1][0]*dp[k+1][j][0];
}
}
}
}
}
return dp[0][n-1][result];
}
};
P4170 涂色
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int mod=19650827;
vector<vector<int>> dp(100,vector<int>(100));
int f(string s,int l,int r){
if(dp[l][r]!=0){
return dp[l][r];
}
if(l==r){
return 1;
}
if(l+1==r){
return 1+(s[l]!=s[r]);
}
if(s[l]==s[r]){
return f(s,l,r-1);
}
int ans=INT_MAX;
for(int k=l;k<r;k++){
ans=min(ans,f(s,l,k)+f(s,k+1,r));
}
dp[l][r]=ans;
return ans;
}
int main()
{
string s;
cin>>s;
int n=s.size();
cout<<f(s,0,n-1);
return 0;
}
这个题目是既要考虑左右端点的转移又要考虑分割点,因为首先分析问题,很容易想到的贪心,两侧同色的部分一次涂完然后再处理内部的颜色一定是最优的,那么如果端点同色的话我们不需要额外的花费,直接转移到内侧(l+1,r或者l,r-1都可以),那么对于内侧的处理,显然最小子段(长度为1或2)的最小花费可以直接得出,所以我们可以分割区间,每个区间都进行左右端点的转移以及分割点的枚举,其余没有难点。
546.移除盒子
给出一些不同颜色的盒子 boxes
,盒子的颜色由不同的正数表示。
你将经过若干轮操作去去掉盒子,直到所有的盒子都去掉为止。每一轮你可以移除具有相同颜色的连续 k
个盒子(k >= 1
),这样一轮之后你将得到 k * k
个积分。
返回 你能获得的最大积分和 。
class Solution {
public:
int f(int l,int r,int cnt,vector<int>&boxes,vector<vector<vector<int>>>&dp){
if(l>r){
return 0;
}
if(dp[l][r][cnt]!=0){
return dp[l][r][cnt];
}
if(l==r){
return (1+cnt)*(1+cnt);
}
int s=l;
while(s+1<=r&&boxes[s+1]==boxes[l]){
s++;
}
int ans=-1;
int k=s-l+cnt+1;
ans=max(ans,k*k+f(s+1,r,0,boxes,dp));
for(int i=s+2;i<=r;i++){
if(boxes[i]==boxes[l]&&boxes[i-1]!=boxes[i]){
ans=max(ans,f(s+1,i-1,0,boxes,dp)+f(i,r,k,boxes,dp));
}
}
dp[l][r][cnt]=ans;
return ans;
}
int removeBoxes(vector<int>& boxes) {
int n=boxes.size();
vector<vector<vector<int>>> dp(n+1,vector<vector<int>>(n+1,vector<int>(n+1)));
return f(0,n-1,0,boxes,dp);
}
};
大概是这个系列最难想的一个题目,首先盲目区间划分以求简化并不现实,因为一个区间能带来的收益并不孤立,所以我们需要另外的信息去维护,这个题的做法是用前缀信息来表示左端点之前和左端点相同的值的个数,为什么需要把他直接作为dp参数的一部分呢,因为其实我们去考虑尝试策略,并不一定有多长的区间我们就要去消掉,可以把中间不同的值消掉然后让这个相同部分变长再相消,所以一个区间可以有不同的前缀值,可能代表消掉中间部分后能消掉的长度,来到每个区间的时候,我们可以选择尝试直接消掉前缀,然后继续研究零前缀的子区间,或者先保留,寻找子区间上是否有可以连接的部分,消掉中间的部分然后再把左端点值相同的子区间的前缀加上当前的前缀代表可以一起消掉,从而完成了尝试策略实现转移。
1000.合并石头的最小成本
有 n
堆石头排成一排,第 i
堆中有 stones[i]
块石头。
每次 移动 需要将 连续的 k
堆石头合并为一堆,而这次移动的成本为这 k
堆中石头的总数。
返回把所有石头合并成一堆的最低成本。如果无法合并成一堆,返回 -1
。
class Solution {
public:
int mergeStones(vector<int>& stones, int k) {
int n=stones.size();
if((n-1)%(k-1)!=0){
return -1;
}
vector<vector<int>> dp(n,vector<int>(n));
vector<int> pre(n);
pre[0]=stones[0];
for(int i=1;i<n;i++){
pre[i]=pre[i-1]+stones[i];
}
for(int i=n-2;i>=0;i--){
for(int j=i+1;j<n;j++){
int ans=100000000;
for(int s=i;s<j;s+=(k-1)){
ans=min(ans,dp[i][s]+dp[s+1][j]);
}
if((j-i)%(k-1)==0){
if(i!=0){
ans+=pre[j]-pre[i-1];
}else ans+=pre[j];
}
dp[i][j]=ans;
}
}
return dp[0][n-1];
}
};
同样是巧妙的转移,首先因为n,k的值不同有可能导致最终不可能合并成一堆,当我们确定了可以合并成功后,因为不同的合并顺序会影响最后的结果,所以我们如果枚举分割点的意义就应该是,把左侧右侧分别结算完后,加上把左右侧一起合并的成本,另外需要注意的是,我们枚举分割点的时候,左侧结算完成,可能剩下几个也可能不剩下,右侧也同样,所以只有当整体区间满足可以合并到只剩下一个的条件时,才有意义去讨论左右侧一起合并,并且,分割点并不是全部都合适,如果左右侧的子区间都不会
730.统计不同回文子序列
给你一个字符串 s
,返回 s
中不同的非空回文子序列个数 。由于答案可能很大,请返回对 109 + 7
取余 的结果。
字符串的子序列可以经由字符串删除 0 个或多个字符获得。
如果一个序列与它反转后的序列一致,那么它是回文序列。
如果存在某个 i
, 满足 ai != bi
,则两个序列 a1, a2, ...
和 b1, b2, ...
不同。
P3205 合唱队
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int mod=19650827;
void solve(){
int n;
cin>>n;
vector<int> a(n);
for(int i=0;i<n;i++) cin>>a[i];
vector<vector<vector<int>>> dp(n,vector<vector<int>>(n,vector<int>(2)));
for(int i=0;i<n-1;i++){
dp[i][i][0]=1;
dp[i][i][1]=1;
if(a[i]<a[i+1]){
dp[i][i+1][0]=1;
dp[i][i+1][1]=1;
}
}
dp[n-1][n-1][0]=1;
dp[n-1][n-1][1]=1;
for(int l=n-3;l>=0;l--){
for(int r=l+2;r<n;r++){
dp[l][r][0]=((a[l+1]>a[l]?dp[l+1][r][0]:0)+(a[r]>a[l]?dp[l+1][r][1]:0))%mod;
dp[l][r][1]=((a[l]<a[r]?dp[l][r-1][0]:0)+(a[r]>a[r-1]?dp[l][r-1][1]:0))%mod;
}
}
cout<<(dp[0][n-1][0]+dp[0][n-1][1])%mod;
}
int main()
{
solve();
return 0;
}
同样,不要受限于典型的区间dp,当遇到仅仅区间值无法表示某种转移的状态的时候,就应该适当的尝试增加尝试结构或者增加一维参数,比如这道题,我们在进行尝试策略的分析的时侯,发现,如果新来了一个人他应该去哪一边不止受限于当前的区间是什么,同时也受限于这个区间最后来的人是左侧还是右侧的,并和他去比较决定新来的人去哪一侧,同样因为转移方式受限于已经确定的区间,所以我们可以把某个位置的值表示为,本区间的最左侧或者最右侧的人是这个区间最后来的一个人,我们就可以用一个参数代表左侧最后来还是右侧,我这里用0,1分别代表左右,那么一个区间左侧最后来的情况下的方案数应该依赖于子区间的左侧最后来和右侧最后来的方案数,如果满足大小的比较条件(题意)那么就可以得出转移方程,最后答案就是总区间左侧加右侧的总方案数。
至此,区间dp总结结束。