区间DP
区间DP是常见的应用场景。区间DP也是线性DP,它把区间当成DP的阶段,用区间的两个端点描述状态和处理状态的转移。
区间DP的主要思想是先在小区间得到最优解,然后合并 到大区间,合并的操作一般是两个相邻子区间的合并,往往需要遍历一个k,找到最优解的中间点 大区间 i,j --> i,k k+1,j 。
石子合并
问题描述
有n堆石子排成一排,每堆石子有一定的数量。将n堆石子合并成一堆,每次只能合并相邻的两堆石子,合并的花费为这两堆石子的总数。进过n-1次合并后成为一堆,求最小总花费。
思路分析
定义dp[i][j]为合并第 i 堆至第 j 堆的最小花费。
状态转移方程为 dp[i][j] = min{ dp[i][k] + dp[k+1][j] + w[i][j] } i<=k<j
w[i][j] 表示从第 i 堆至第 j 堆石子的总数。
代码
cpp
//初始dp都为0
for(int i=2;i<=n;i++){
for(int j=i-1;j>=1;j--){
dp[j][i]=INT_MAX;
for(int k=j;k<i;k++){
dp[j][i] = min(dp[j][i],dp[j][k]+dp[k+1][i] + w[j][i]);
} //区间和可以利用前缀和来实现 w[j][i]=sum[i]-sum[i-1];
}
}
另一种写法,它的i,j,k都是递增的,更易于理解。
代码
cpp
for(int len=2;len<=n;len++){
for(int i=1;i<n-len;i++){ //len为区间长度 i为左端点,j为右端点
int j=i+len-1;
dp[i][j]=INT_MAX;
for(int k=i;k<j;k++){
dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j] + w[i][j]);
} //或者把+w[i][j]操作放在循环外
}
}
上述代码的复杂度为 O(n3) ,不太好。区间DP常常利用四边形不等式优化。
🚀四边形不等式优化
用一个数组s[i][j]记录i~j的最优分割点,在下一次循环中可利用其减小 k 的寻找范围。
代码
cpp
for(int i=1;i<=n;i++) s[i][i]=i;
for(int len=2;len<=n;i++){
for(int i=1;i<n-len;i++){
int j=i+len-1;
dp[i][j]=INT_MAX;
for(int k=s[i][j-1];k<=s[i+1][j];k++){ //缩小寻找区间。
if(dp[i][j]>dp[i][k]+dp[k+1][j]){
dp[i][j]=[i][k]+dp[k+1][j];
s[i][j]=k; //更新最佳分割点。
}
}
dp[i][j]+=w[i][j];
}
}
复杂度由O(n3)降到O(n2)证明略
图示

四边形不等式的定义1 :设w是定义在整数集合的二元函数,对于任意的 i<=i'<=j<=j',
如果有w(i,j)+w(i',j')<= w(i,j')+w(i',j),
则称w满足四边形不等式。
图示可理解为四边形不等式
对角线长度 ij+i'j'大于平行线长度 ij'+i'j

定义1的特例是定义2
四边形不等式的定义2 :对于整数 i<i+1<=j<j+1 如果有w(i,j)+w(i+1,j+1)<= w(i,j+1)+w(i+1,j)
则称w满足四边形不等式。
单调性 :如果对任意整数 i<=i'<=j<=j' ,有w( i , j' )>=w( i' , j ),称w具有单调性。
对于wij满足四边形不等式和单调性的,可以采用四边形不等式定理优化dp,时间复杂度为O(n^2);
定理1 :dpij=min(dpij,dpik+dpk+1j + wij) ,如果wij满足四边形不等式和单调性,则dpij也满足四边形不等式。
定理2 :记sij=k 为dpij取得最优时的k,如果dpij满足四边形不等式,那么有 sij-1<=sij<=si+1j。
证明过程复杂,这里就不赘述了,详情参阅《算法竞赛》--罗勇军 郭卫斌著 第5章10.4节。
戳气球
问题描述
有 n 个气球,编号为0 到 n - 1,每个气球上都标有一个数字,这些数字存在数组 nums 中。
现在要求你戳破所有的气球。戳破第 i 个气球,你可以获得 nums[i - 1] * nums[i] * nums[i + 1] 分数。 这里的 i - 1 和 i + 1 代表和 i 相邻的两个气球的序号。如果 i - 1或 i + 1 超出了数组的边界,那么就当它是一个数字为 1 的气球。
求所能获得分数的最大数量。
思路分析
定义f[i][j]表示戳破[i...j]区间的气球所能获得的最大分数,
状态转移方程为dp[i][j] = max{ d[i][k-1]+d[k+1][j]+nums[i-1]*nums[k]*nums[j+1] } i<=k<j
因为数组边界问题,应在原数组前后各插入一个 1 以方便计算。
注意:在计算dp[i][j]之前d[i][k-1] 和 d[k+1][j]都要先算出,因此可以设计左边界i从右往左遍历,右边界j从左往右遍历,
当i>j时,dp[i][j]等于0
代码
cpp
int maxCoins(vector<int>& nums) {
int f[310][310];
// f[i][j] 代表戳破以 i , j 为边界的所有气球获得的最大价值
memset(f,0,sizeof f);
vector<int> cur;
cur.push_back(1);
cur.insert(cur.end(),nums.begin(),nums.end());
cur.push_back(1);
int n = nums.size();
if(n==0) return 0;
for(int i=n;i>0;i--){
for(int j=i;j<=n;j++){
for(int k=i;k<=j;k++){
f[i][j]=max(f[i][j],f[i][k-1]+f[k+1][j]+cur[i-1]*cur[k]*cur[j+1]);
}
}
}
return f[1][n];
}
数位翻转
蓝桥杯2024年国赛题
问题描述
小明创造了一个函数f(x)用来翻转x的二进制的数位(无前导0)。比如f(11)=13,小明随机出了一个长度为n的整数数组{a1,a2,,...an},他想知道在这个数组中最多选m个不相交的区间,将这些区间内的二进制数位翻转(将ai变为f(ai))后,整个数组的最大和是多少?
思路分析
首先将数组中的所有数都预处理,计算出一个d数组,di存储的是f[ai]-ai。
原问题转化一个子问题:从di中选最多m个不相交的区间,这些区间内所有数的和最大为多少?该问题的值再加上sum(sum为a数组的总和)就是原问题答案。
现在来思考这个子问题:如果贪心或者模拟来求具体是哪几个区间是不容易的,因为区间不能重叠,要考虑的细节很多。可以使用动态规划来解决。
定义dp[i][j]表示前 i 个数取最多 j 个不相交的区间的最大总和。枚举到i时,计算dp[i][j],有两种选择:
- 不选最后一个下标
i的后缀区间,dp[i][j]=dp[i-1][j] - 选最后一个下标
i的后缀区间,dp[i][j]=max(dp[k-1][j-1]+suffix(k,i),suffix(k,i)表示d的子数组[k,i]的区间和。
状态转移方程为:dp[i][j]=max(dp[i-1][j],max(dp[k-1][j-1]+suffix(k,i)))
时间复杂度O(mn^2)。
代码
cpp
#include<bits/stdc++.h>
using namespace std;
using ll = long long;
ll getDiff(int x) {
int p = x;
int tr = 0;
while (x) {
tr <<= 1;
if (x & 1) tr |= 1;
x >>= 1;
}
return tr - p;
}
int main()
{
int n, m;
cin >> n >> m;
vector<ll>d(n + 1);
vector<vector<ll>>dp(n + 1, vector<ll>(m+1)); //dp[i][j]表示前i个数取最多j个不相交的区间的最大总和
ll sum = 0;
for (int i = 1; i <= n; i++) {
int t; cin >> t;
sum += t;
d[i] = getDiff(t);
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) dp[i][j] = dp[i - 1][j]; //不选从i开始的后缀子数组
for (int j = 1; j <= m; j++) {
ll suffix = 0;
for (int k = i; k >= 1; k--) {
suffix += d[k];
dp[i][j] = max(dp[i][j], dp[k - 1][j - 1] + suffix); //选从i开始的后缀子数组
}
}
}
cout << sum + dp[n][m];
return 0;
}