单调队列优化dp
“单调队列非常适合优化 决策取值范围的上、下界随枚举阶段单调变化,同时每个决策在候选集合中插入或删除至多一次 的 dp 最优化问题 ”
核心思想在于将 i − 1 i-1 i−1 的决策集合以 较小的时间代价 转移至 i i i 上,维护决策集合的 高效性 和 有序性
高效 指候选集合中尽可能 排除不优的决策
有序 指在当前阶段进行决策时,能够快速得到 最优解(通常与数据结构结合)
例1 —— 精确决策上下界
n
≤
16000
n \leq16000
n≤16000
K
≤
100
K \leq100
K≤100
单调队列优化 DP 的常见套路是:从朴素思路入手,写出 复杂度较高 而 直观 的状态转移方程,标清楚 决策 的上下界,然后依据 转移 的性质用 单调队列维护 决策集合 ,进而优化复杂度
对本题,设 f [ i ] [ j ] f[i][j] f[i][j] 表示考虑前 i i i 个木匠,当前刷到了第 j j j 块板,最大报酬
首先有: f [ i ] [ j ] = f [ i − 1 ] [ j ] , f [ i ] [ j ] = f [ i ] [ j − 1 ] f[i][j]=f[i-1][j],f[i][j]=f[i][j-1] f[i][j]=f[i−1][j],f[i][j]=f[i][j−1] 这是第一步的转移,去除了有空板 / 空人的影响
那么接下来考虑:令第 i i i 个人刷第 j j j 块板,假设第 i − 1 i-1 i−1 个人考虑到第 k k k 块,如何转移?
f [ i ] [ j ] = max ( f [ i − 1 ] [ k ] + P [ i ] × ( j − k ) ) f[i][j]=\max(\ f[i-1][k]+P[i]\times (j-k)\ ) f[i][j]=max( f[i−1][k]+P[i]×(j−k) )
显然有限制: k < S i ≤ j k< S_i\leq j k<Si≤j 且 j − k ≤ L i ⟺ j − L i ≤ k < S i j-k\leq L_i \iff j-L_i\leq k<S_i j−k≤Li⟺j−Li≤k<Si
观察这个内层枚举的 k k k 的上下界:随 j j j 的增大, k k k 的左端点增大 1 1 1,符合使用单调队列的第一个条件
观察 DP 式子,从 集中变量 的角度出发,我们将其写作:
f [ i ] [ j ] = P [ i ] × j + max ( f [ i − 1 ] [ k ] − P [ i ] × k ) f[i][j]=P[i]\times j+\max(\ f[i-1][k]-P[i]\times k) f[i][j]=P[i]×j+max( f[i−1][k]−P[i]×k)
显然,后面的这个 m a x max max 函数与 j j j 无关,我们可以将其作为一个整体 g [ k ] g[k] g[k] 放到 单调队列中维护
下面是 单调队列优化 DP 中最重要的部分:排除无用决策
不妨设 决策 k 1 < k 2 k_1<k_2 k1<k2,显然,随着 j j j 的增大, k 1 k_1 k1 会被先排除出决策集合
若 g [ k 1 ] ≤ g [ k 2 ] g[k_1]\leq g[k_2] g[k1]≤g[k2],则 k 2 k_2 k2 一定 比 k 1 k_1 k1 更优,将 k 1 k_1 k1 出队
综上,我们需要维护的是一个 决策 k 单调递增,值 g [ k ] g[k] g[k] 单调递减 的队列
支持从队头删除元素,队尾插入并维护 g [ k ] g[k] g[k] 单调性
#include<bits/stdc++.h>
using namespace std ;
const int N = 16010 , M = 110 ;
typedef long long LL ;
int n , K ;
struct nn
{
int l , v , pos ;
}a[N] ;
bool cmp( nn x , nn y )
{
return x.pos < y.pos ;
}
int f[M][N] , ans ;
deque<int> q ;
int main()
{
scanf("%d%d" , &n , &K ) ;
for(int i = 1 ; i <= K ; i ++ ) {
scanf("%d%d%d" , &a[i].l , &a[i].v , &a[i].pos ) ;
}
sort( a+1 , a+K+1 , cmp ) ;
for(int i = 1 ; i <= K ; i ++ ) {
while( !q.empty() ) q.pop_back() ;
q.push_back( 0 ) ;
for(int j = 1 ; j <= n ; j ++ ) {
f[i][j] = max( f[i-1][j] , f[i][j-1] ) ;
while( !q.empty() && ( q.front() < j-a[i].l ) ) {
q.pop_front() ;
}
if( !q.empty() && j >= a[i].pos && j <= a[i].pos+a[i].l-1 ) {
f[i][j] = max( f[i][j] , a[i].v*j + f[i-1][q.front()]-a[i].v*q.front() ) ;
}
if( j < a[i].pos ) { // 维护决策集合中的元素合法
while( !q.empty() && f[i-1][j]-a[i].v*j >= f[i-1][q.back()]-a[i].v*q.back() ) {
q.pop_back() ;
}
q.push_back( j ) ;
}
ans = max( ans , f[i][j] ) ;
}
}
printf("%d" , ans ) ;
return 0 ;
}
这道题主要告诉我们,写好单调队列优化 DP 的一个必要前提是 标清楚决策的取值范围
集中变量的思想也很重要
例2 —— 单调队列维护决策,其他数据结构维护转移值
如果我们预处理出来 以每个数为结尾,能够向左延申的最大长度 L [ i ] L[i] L[i],显然有:
f [ i ] = min ( f [ j ] + m a x [ a j + 1 , . . . , a i ] ) f[i]=\min(\ f[j]+max[a_{j+1},...,a_i]\ ) f[i]=min( f[j]+max[aj+1,...,ai] ) ,其中 L [ i ] − 1 ≤ j < i L[i]-1\leq j<i L[i]−1≤j<i
观察这个式子我们发现 与上一题不同,这里 m i n min min 函数中的整体同时与 i , j i,j i,j 两维有关,无法直接维护
那么从原点出发:维护 决策集合的 高效性 和 有序性
首先考虑高效:
从性质上看,显然随着 j j j 的枚举, f [ j ] f[j] f[j] 单调递增,而 m a x ( j + 1 , i ) max(j+1,i) max(j+1,i) 单调不增
那么 当 max 值相等时,靠前的 决策 会更优
对比
j
1
,
j
2
j_1,j_2
j1,j2 ,有共同的
m
a
x
max
max 值为
m
a
x
2
max2
max2
那么由于 f f f 的单增, 当二者在同一决策集合中 时 , j 1 j_1 j1 是更优的
更进一步,我们发现,真正优秀的决策必定是 每个后缀 max 的位置!!!
这说明我们只需维护一个 处理后缀 m a x max max 的数据结构,实时添加、删除
—— 那么这不就是单调队列的基本功能吗!!!
只有队列中的元素可能成为最优决策,保证决策集合的高效性
但是这样有一个问题:我们在考虑排除无用决策时,是用 前面的决策排除了后面的决策,而 决策集合 是从左向右滑动的,这样会不会导致某些后面的有效决策被排除?
我们再来思考这种情况怎么才会发生:某段后缀区间的 m a x max max位置的决策 排除了在它后面的部分元素,现在它出队了,谁有能是最优呢?
显然:决策集合的左端点!!!(由上面的性质发现,同一个 m a x max max 区间里越靠左越优)
所以转移除了单调队列中,还需要决策集合左端点
其次考虑有序:
单调队列中维护的是 a [ i ] a[i] a[i] 的有序,而非转移值即 min ( f [ j ] + m a x [ a j + 1 , . . . , a i ] ) \min(\ f[j]+max[a_{j+1},...,a_i]\ ) min( f[j]+max[aj+1,...,ai] ) 的有序
遇到这种情况时,我们要考虑再搞一个数据结构,维护最优的转移值
这个数据结构 与 单调队列 构成 一一映射关系
本题 线段树 / set 皆可,下面以 set 为例
但还是有问题在与: 后面那个 m a x max max 值 是会变的!!我们还需要实时修改
考虑将当前 a [ i ] a[i] a[i] 加入决策集合,思考:哪些转移值需要变?
这时维护高效性的功效就显现出来了:
加入 i i i 时,首先让部分决策出队,并从 set 中删除,不需要更改
准备让 i i i 入队时,只需要更改此时队头即可,因为 a i a_i ai 作为 Max 当且仅当在此时队头的决策中
修改降为 l o g n log\ n log n,那么整体复杂度达到了 O ( n l o g n ) O(n\ log\ n) O(n log n)
细节还是非常多的
#include<bits/stdc++.h>
using namespace std ;
const int N = 1e5 + 100 ;
typedef long long LL ;
LL read()
{
LL x = 0 ; char c = getchar() ;
while( c > '9' || c < '0' ) c = getchar() ;
while( c >= '0' && c <= '9' ) x = (x<<1)+(x<<3)+(c^48) , c = getchar() ;
return x ;
}
int n , L[N] , a[N] , st[20][N] , Lg[N] , Max[N] ; // 记录 决策 j 的 max(j+1,i)
LL m , f[N] ;
void pre_work()
{
Lg[1] = 0 ;
for(int i = 2 ; i <= n ; i ++ ) Lg[i] = Lg[i/2] + 1 ;
for(int i = 1 ; i <= 18 ; i ++ ) {
for(int j = 1 ; j <= n-(1<<i)+1 ; j ++ ) {
st[i][j] = max( st[i-1][j] , st[i-1][j+(1<<(i-1))] ) ;
}
}
}
int query( int l , int r )
{
if( !l ) l = 1 ;
int k = Lg[r-l+1] ;
return max( st[k][l] , st[k][r-(1<<k)+1] ) ;
}
struct nn
{
int id ;
LL num ;
friend bool operator < ( nn x , nn y ) {
return x.num < y.num || ( x.num == y.num && x.id < y.id ) ;
}
};
set<nn> s ;
deque<int> q ; // 单调队列维护 后缀Max 值
int main()
{
n = read() , m = read() ;
LL sum = 0 ;
int l = 0 ;
for(int i = 1 ; i <= n ; i ++ ) {
a[i] = read() ;
st[0][i] = a[i] ;
if( a[i] > m ) {
printf("-1\n") ;
return 0 ;
}
sum += a[i] ;
while( sum > m ) {
sum -= a[l] ;
l ++ ;
}
L[i] = l ;
}
pre_work() ;
q.push_back( 0 ) ;
a[0] = 1e9 ;// a[0]在出决策集合之前,不应被弹出队列
Max[0] = a[1] ;
s.insert( (nn){ 0 , 0+Max[0] } ) ;
for(int i = 1 ; i <= n ; i ++ ) {
// 1.从边界直接转移过来
f[i] = f[L[i]-1] + query( L[i] , i ) ;
// 2.从set中取最优值
while( !q.empty() && q.front() < L[i]-1 ) {//维护决策集合左端点
s.erase( (nn){ q.front() , f[q.front()]+Max[q.front()] } ) ;
q.pop_front() ;
}
// 先用 a[i] 去更新 决策集合
while( !q.empty() && a[i] >= a[q.back()] ) {
s.erase( (nn){ q.back() , f[q.back()]+Max[q.back()] } ) ;
q.pop_back() ;
}
if( !q.empty() ) { // 更新 队尾的区间 Max ,单调队列维护最优决策,使得Max只用改变一个
s.erase( (nn){ q.back() , f[q.back()]+Max[q.back()] } ) ;
Max[q.back()] = a[i] ;
s.insert( (nn){ q.back() , f[q.back()]+Max[q.back()] } ) ;
f[i] = min( f[i] , f[(s.begin()->id)]+query(s.begin()->id+1,i) ) ;
}
q.push_back( i ) ;
s.insert( (nn){ i , f[i] } ) ; // 先用 f[i] 表示,再被后面的 a[i] 更新
}
cout << f[n] ;
return 0 ;
}
例3 —— 结合某些性质,贪心保留最优决策
首先
整体上看,转移时需要维护到两个信息:当前层的长度,上一层的长度
其中第一个可以通过 以 i 为结尾,枚举开头,在本次转移中确定 贡献
而第二个 表面上看 不得不增加 DP 的一维 去维护,但复杂度是不允许的
那么基于 减少无用决策出发:当本层的高度最大时,只保留 最小的 长度即可
这样只需要额外开一个 g 数组 来存储最小长度信息,同时以 f 为第一关键字更新
#include<bits/stdc++.h>
using namespace std ;
const int N = 1e5 + 20 ;
typedef long long LL ;
int read()
{
int x = 0 ; char ch = getchar() ;
while( !isdigit(ch) ) ch = getchar() ;
while( isdigit(ch) ) x = (x<<1)+(x<<3)+(ch^48) , ch = getchar() ;
return x ;
}
int n , a[N] ;
int f[N] , g[N] , sum[N] ;
deque<int> q ;
int main()
{
n = read() ;
for(int i = 1 ; i <= n ; i ++ ) {
a[n-i+1] = read() ;
}
q.push_back( 0 ) ;
int pos = 0 ;
for(int i = 1 ; i <= n ; i ++ ) {
sum[i] = sum[i-1] + a[i] ;
while( q.size() > pos+1 && sum[q[pos+1]]+g[q[pos+1]] <= sum[i] ) { // sum_i 单增
pos ++ ;
}
f[i] = f[q[pos]] + 1 , g[i] = sum[i] - sum[q[pos]] ;
while( !q.empty() && sum[q.back()]+g[q.back()] >= sum[i]+g[i] ) {
q.pop_back() ;
}
q.push_back( i ) ; // 倒着做使得 f[j] 具有单调性,取符合条件的最大的即可
}
printf("%d" , f[n] ) ;
return 0 ;
}