划分型DP
划分型DP常见的场景是:给定一个数组,求满足条件的划分子数组的方案或可行性。
一般定义 f[i] 表示长为 i 的前缀 a[:i] 能否划分。枚举最后一个子数组的左端点 L,从 f[L] 转移到 f[i],并考虑 a[L:i] 是否满足要求。
检查数组是否存在有效划分
问题描述
给你一个下标从 0 开始的整数数组 nums ,你必须将数组划分为一个或多个不相交的子数组。
如果获得的这些子数组中每个都能满足下述条件 之一 ,则可以称其为数组的一种 有效 划分:
- 子数组 恰 由
2个相等元素组成,例如,子数组[2,2]。 - 子数组 恰 由
3个相等元素组成,例如,子数组[4,4,4]。 - 子数组 恰 由
3个连续递增 元素组成,相邻元素之间的差值为1。如,子数组[3,4,5],但是子数组[1,3,5]不符合要求。
如果数组 至少 存在一种有效划分,返回 true ,否则,返回 false 。
思路分析
如何想出状态定义?
- 如果 nums 的最后两个数相等,那么去掉这两个数,问题变成剩下 n−2 个数能否有效划分。
- 如果 nums 的最后三个数相等,那么去掉这三个数,问题变成剩下 n−3 个数能否有效划分。
- 如果 nums 的最后三个数是连续递增的,那么去掉这三个数,问题变成剩下 n−3 个数能否有效划分。
我们要解决的问题都形如「nums 的前 i 个数能否有效划分」。
于是定义 f[0]=true,f[i+1] 表示能否有效划分 nums[0~i]。
根据有效划分的定义,有:
f[i+1]= ∨ {
f[i−1] ∧ nums[i]=nums[i−1] , i>0
f[i−2] ∧ nums[i]=nums[i−1]=nums[i−2] , i>1
f[i−2] ∧ nums[i]=nums[i−1]+1=nums[i−2]+2, i>1
}
答案为 f[n]。
代码
cpp
bool validPartition(vector<int> &nums) {
int n = nums.size();
vector<int> f(n + 1);
f[0] = true;
for (int i = 1; i < n; i++) {
if (f[i - 1] && nums[i] == nums[i - 1] ||
i > 1 && f[i - 2] && (nums[i] == nums[i - 1] && nums[i] == nums[i - 2] ||
nums[i] == nums[i - 1] + 1 && nums[i] == nums[i - 2] + 2)) {
f[i + 1] = true;
}
}
return f[n];
}
作者:灵茶山艾府
🚀单调队列优化
回顾一下单调队列的概念,它具有以下特征:
- **实现:**用双端队列实现,队头和队尾都能插入和弹出。
- 单调性:队列内元素具有单调性,从小到大,或从小到大。(这种单调性是人为维护造成的,不是数据结构本身的的点)
- **维护:**每个新元素都能进入队列,它从队尾进入队列时,为维护队列单调性,应该与队尾比较,把破坏单调性的队尾弹出。
单调队列在DP优化中的基本应用,是对一类DP状态转移方程进行优化:
dp[i]=min { dp[j] + a[i] + b[j] },L(i)<=j<=R(i)
方程的特点是其中关于 i 的项 a[i] 和关于j的项 b[j] 是独立的。j被限制在窗口[L(i),R(i)]内,这需要j在窗口中作滑动操作找到最优点。在窗口内寻找最优值的子问题 就是典型的滑动窗口题,单调队列就是滑动窗口问题常见的做法。相比每次在[L(i),R(i)]中遍历寻找的O(n^2)做法,使用单调队列,可以理解为记住了那个最优点,平均能在O(1)时间复杂度内找到最优点,总时间复杂度能降一维。
具体实现
-
求一个dp[i],i是外层循环,j是内层循环,在做内层循环时,可以把外层循环的 i 看做一个定值,此时
a[i]可以看作常量(能否将关于i的式子作为常量是判断该题能否使用单调队列优化的关键),dp的状态转移方程等价于:
ds[i] = min{dp[j]+b[j]}+a[i] -
问题转化为一个求窗口内的最优值的问题。用单调队列处理
ds[j],排除不合格的决策,最后求得区间内的最优值即队首,另外队列中留下的待选决策在后面i变化后仍然有用。
最多k个连续的子序列最大和
问题描述
有一个包括n个正整数的序列,第i个元素为 Ei,给定一个整数k,找出这样的子序列,子序列中的数在原序列中连续 不超过k个。对所有满足要求的子序列求和,问最大和是多少?
例如:n=5,原序列为{7,2,3,4,5},k=2,子序列{7,2,4,5}有最大和18,其中在原数组中连续部分{7,2},{4,5},长度都不超过k=2。
思路分析
由于n较大,算法时间复杂度应该在O(n)左右。
用DP求解,定义dp[i]为前 i 个整数满足要求的最长子序列和,状态转移方程为:
dp[i] = max{dp[j-1] + sum(j+1,i) }, i-k<=j<=i ,sum(j,i)表示原数组[j+1,i]的区间和。
根据前缀和的知识,区间和sum(j,i)可用sum[i]-sum[j]表示,原方程可变为:
dp[i] = max{dp[j-1] + sum(j+1,i) }, i-k<=j<=i.
方程符合单调队列优化的标准方程:dp[i] = min{ dp[j]+b[i] } + a[i]。下面用{7,2,3,4,5}为例讲解详细过程。
把 i 看作定值,上述方程等价于:dp[i] = min{ dp[j-1]-sum[j] } + sum[i] , i-k<=j<=i。
求dp[i]j就是找到一个决策j ,使dp[j-1]-sum[j]最大。首先,对于一个固定的 i ,用一个递减的单调队列求最大的dp[j-1] - sum[j].记ds[j]=dp[j-1]-sum[j],记这个i对应的最大值dsMax[i] = max{ds[j]}。用单调队列求dsMax[i]的步骤如下:
- 设从
j=1开始,首先让ds[1]进入队列。此时窗口内最大值就是ds[1] j=2,ds[2]进入队列,讨论两种情况:ds[2]≥ds[1],说明ds[2]更优,弹出ds[1],ds[2]进入队列成为新的队头,这里体现了单调队列优化的关键,把ds[1]弹出队列,后面不会再用到,能选到ds[2]肯定不会选ds[1]ds[2]<ds[1],ds[2]入队。队头依然是ds[1]
- 继续上述操作,让窗口内每个j
(i-k≤j≤i)都有机会进入队列,并保持队列时从大到小的单调队列。
在取队头元素时,要判断队头是否过期,判断i-k≤j≤i是否满足,不满足弹出队列,直到让满足要求的值当队头。所以实际编程时队列中存储 j 值,判断是否过期判断 j 值,计算时用dp[j-1]-sum[j]计算即可。
整体思路可能有些晦涩,最好结合经典滑动窗口【滑动窗口最大值】来思考。
代码
cpp
#include<bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 100005;
ll n, k, e[N], sum[N], dp[N];
ll ds[N];
deque<int>dq; //优先队列
ll getMax(int i) { //求 i-k≤j≤i 范围内最大的dp[j-1]-sum[j]
ds[i] = dp[i - 1] - sum[i];
while (!dq.empty() && ds[dq.back()] < ds[i]) dq.pop_back();
dq.push_back(i); //存入下标值
while (!dq.empty() && dq.front() < i - k) dq.pop_front(); //头删,不用判断队列是否为空也行,肯定是有数据的
return ds[dq.front()];
}
int main() {
cin >> n >> k; sum[0] = 0;
for (int i = 1; i <= n; i++) {
cin >> e[i]; //输入原数组
sum[i] = sum[i - 1] + e[i]; //计算前缀和
}
dq.push_back(0); //添加一个前置哨兵表示 dp[-1]-0;
for (int i = 1; i <= n; i++) dp[i] = getMax(i) + sum[i];
cout << dp[n];
return 0;
}
统计极差最大为k的分割方案数
问题描述
给你一个整数数组 nums 和一个整数 k。你的任务是将 nums 分割成一个或多个 非空 的连续子段,使得每个子段的 最大值 与 最小值 之间的差值 不超过 k。
返回在此条件下将 nums 分割的总方法数。
由于答案可能非常大,返回结果需要对 109 + 7 取余数。
思路分析
定义dp[i]表示前i个元素的分割方案数,最后的dp[n]就是答案。
对于计算dp[i],考虑直接暴力枚举后缀区间的方式计算,若[t,i]子数组的极差小于等于k,那将dp[t]加入dp[i]。
dp[i] += dp[i-1] + dp[i-2] + ...dp[t](t为满足条件的最小值,称为临界值)。
cpp
class Solution {
public:
int countPartitions(vector<int>& nums, int k) {
const int MOD = 1e9+7;
int n=nums.size();
vector<int>dp(n+1);
dp[0]=1;
for(int i=1;i<=n;i++){
dp[i]=dp[i-1];
int maxn=nums[i-1],minn=nums[i-1];
for(int j=i-1;j>=1;j--){
maxn=max(maxn,nums[j-1]);
minn=min(minn,nums[j-1]);
if(maxn-minn<=k){
dp[i]=(dp[i]+dp[j-1])%MOD;
}else break;
}
}
return dp[n];
}
};
上述代码中,每次计算dp[i]都从i-1开始向后暴力枚举邻界点,总时间复杂度为O(n^2),会超时,本题需要接近O(n)时间复杂度的算法。
求i-1对应的临界点可以使用单调队列利用上次求得的结果来优化时间效率。若对于i-2的临界点为k,i-1的邻界点肯定大于等于k。
定义最小值队列min_q(从队头到队尾单调递增)队头维护从临界点 t 到 i 的后缀数组nums[t,i]的最小值下标,
定义最大值队列max_q(从队头到队尾单调递减)队头维护从临界点 t 到 i 的后缀数组nums[t,i]的最小值下标。
代码
cpp
int countPartitions(vector<int>& nums, int k) {
const int MOD = 1'000'000'007;
int n = nums.size();
deque<int> min_q, max_q;
vector<int> f(n + 1);
f[0] = 1;
long long sum_f = 0; // 窗口中的 f[i] 之和
int left = 0;
for (int i = 0; i < n; i++) {
int x = nums[i];
// 1. 入
sum_f += f[i];
while (!min_q.empty() && x <= nums[min_q.back()]) {
min_q.pop_back();
}
min_q.push_back(i);
while (!max_q.empty() && x >= nums[max_q.back()]) {
max_q.pop_back();
}
max_q.push_back(i);
// 2. 出
while (nums[max_q.front()] - nums[min_q.front()] > k) {
sum_f -= f[left];
left++;
if (min_q.front() < left) {
min_q.pop_front();
}
if (max_q.front() < left) {
max_q.pop_front();
}
}
// 3. 更新答案
f[i + 1] = sum_f % MOD;
}
return f[n];
}
作者:灵茶山艾府
字符拆分
问题描述
给你一个字符串 s 和一个字符串列表 wordDict 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s 则返回 true,否则返回false。
**注意:**不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
思路分析
建立dp,dp[i]表示s前i个字符组成的字符串能否拆分。
cpp
bool wordBreak(string s, vector<string>& wordDict) {
int n=s.size();
vector <bool> dp(n + 1); //dp[i] 表示前i个字符组成的字符串能否拆分
dp[0] = true; //前置哨兵,空串可以拆分
for (int i = 1; i <= n; ++i) {
for (string word:wordDict) {
int m=word.size();
if(m<=i)
if (dp[i-m] && s.substr(i-m, m)==word) {
dp[i] = true;
break;
}
}
}
return dp[n];
}
字符拆分||
问题描述
给定一个字符串 s 和一个字符串字典 wordDict ,在字符串 s 中增加空格来构建一个句子,使得句子中所有的单词都在词典中。以任意顺序 返回所有这些可能的句子。
**注意:**词典中的同一个单词可能在分段中被重复使用多次。
思路分析
本题与上题不同之处在于本题要得到具体的分割方案。思路还是一样的,定义dp[i]存储前 i 个字符的分割方案。
代码
cpp
vector<string> wordBreak(string s, vector<string>& wordDict) {
int n=s.size();
vector<vector<string>>dp(n+1);
dp[0]={""};
for(int i=1;i<=n;i++){
for(string word:wordDict){
int m=word.size();
if(m<=i){
if(s.substr(i-m,m)==word){
for(string it:dp[i-m]){
it+=(it==""?"":" ")+word;
dp[i].push_back(it);
}
}
}
}
}
return dp[n];
}