算法学习笔记:单调栈/单调队列
前言
单调栈与单调队列是很常用的一种数据结构,它们充分体现了利用单调性优化算法、去除冗余状态的思想。单调队列优化 DP 也是比赛中常考的内容。
单调栈
Luogu P5788 【模板】单调栈
题意
给定长度为 \(n\) 的序列 \(a\)。定义
若不存在满足条件的 \(j\),则 \(f_i=0\)。现在 \(\forall i=1,2,\cdots,n\),求出 \(f_i\)。
对于所有数据,\(1\leq n\leq 3\times 10^6\)。
题解
题目即对于每个 \(a_i\) 求出它右边最靠左的满足 \(a_j>a_i\) 的 \(j\)。
正常做肯定是 \(O(n^2)\) 的,这个过程中会遍历许多无用的 \(j\),又叫做决策点,所以我们考虑怎样的决策点是无用的。容易想到,若存在两个决策点 \(i,j\),其中 \(i<j\) 且 \(a_i>a_j\),那么 \(j\) 显然就是完全无用的决策点。
根据上述性质,我们倒序枚举 \(i\),每次计算完 \(f_i\) 就把 \(a_i\) 放到决策集合的末尾。倒序枚举 \(i\) 使得每次加入决策集合的下标 \(j\) 是单调递减的。所以考虑决策集合在加入 \(a_j\) 之前所存的最靠左的决策点下标 \(j'\),它必然满足 \(j<j'\)。此时若还有 \(a_j>a_{j'}\),那么 \(j'\) 就是完全无用的决策点,可以删除。可以发现,每次向决策集合中加入新的决策、去除冗余决策后,决策集合中的决策满足下标单调递减、对应值单调递增。我们开一个栈维护这个决策集合的对应下标,每次取栈顶作为决策点,然后不断弹出栈顶,直到栈顶对应的元素值小于当前将要加入的值。这个维护决策集合的栈,就被称为单调栈。
每个下标至多进出栈各 \(1\) 次,整体时间复杂度为 \(O(n)\)。
代码
int n, a[N], ans[N];
int top, s[N];
int main() {
ios::sync_with_stdio(false); cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; ++i) {
cin >> a[i];
while (top && a[s[top]] < a[i]) ans[s[top--]] = i;
s[++top] = i;
}
for (int i = 1; i <= n; ++i) cout << ans[i] << " \n"[i == n];
return 0;
}
Luogu P6503 [COCI2010-2011#3] DIFERENCIJA
题意
给定长度为 \(n\) 的序列 \(a\),求
对于所有数据,\(2\leq n\leq 3\times 10^5\)。
题解
套路题。首先给原式拆成 \(\min\) 和 \(\max\) 两部分:
下面讨论 \(\max\) 的做法。考虑拆贡献,即对于每个 \(a_i\),我们尝试计算有多少个包含 \(a_i\) 的区间以 \(a_i\) 为严格最大值。从 \(i\) 开始向两边扩展极长的合法区间,显然遇到第一个不小于 \(a_i\) 的数的位置停止。也就是说,记 \(l\) 表示 \(a_i\) 的左边第一个不小于 \(a_i\) 的数的下标,\(r\) 表示 \(a_i\) 的右边第一个不小于 \(a_i\) 的数的下标,则合法区间数为 \((i-l)(r-i)\)。而 \(l,r\) 都可以用单调栈 \(O(n)\) 维护出来,于是枚举 \(a_i\) 累加贡献即可。时间复杂度 \(O(n)\)。
具体实现时,注意弹栈时的判断条件是否取等。
单调队列
Luogu P1886 滑动窗口 /【模板】单调队列
题意
给出长度为 \(n\) 的序列 \(a\) 和区间长度 \(k\)。对于所有 \(1\leq i\leq n-k+1\),求 \(\max/\min_{j=i}^{i+k-1}\{a_j\}\)。
对于所有数据,\(1\leq k\leq n\leq 1\times 10^6\)。
题解
下面讨论 \(\max\) 的做法。从单调栈的角度考虑,可以发现一个性质:常规维护一个单调递减的 \(a[1,i]\) 的单调栈,那么栈底就是 \(a[1,i]\) 的区间最大值。由单调栈的维护过程,正确性显然。
我们推广这个性质:考虑区间 \(a[l,r]\),将单调栈中下标 \(i<l\) 的元素全部自栈底弹出后,此时单调栈的栈底就是 \(a[l,r]\) 的区间最大值。
于是就做完了!我们用一个双端队列维护决策集合,每次按照单调栈的方式维护队列内元素单调性的同时,也从队头(栈底)弹出区间外的决策。时间复杂度 \(O(n)\)。
由此容易发现,单调栈是单调队列的一种特殊情况,即始终维护一个前缀的单调队列。
代码
int n, k, a[N];
int l = 1, r = 0, q[N];
int main() {
ios::sync_with_stdio(false); cin.tie(nullptr);
cin >> n >> k;
for (int i = 1; i <= n; ++i) cin >> a[i];
for (int i = 1; i <= n; ++i) {
while (l <= r && a[q[r]] > a[i]) --r;
q[++r] = i;
while (l <= r && q[r] - q[l] >= k) ++l;
if (i >= k) cout << a[q[l]] << " \n"[i == n];
}
l = 1; r = 0;
for (int i = 1; i <= n; ++i) {
while (l <= r && a[q[r]] < a[i]) --r;
q[++r] = i;
while (l <= r && q[r] - q[l] >= k) ++l;
if (i >= k) cout << a[q[l]] << " \n"[i == n];
}
return 0;
}
Luogu U482536 环形数组最大子段和
夹带私货
题意
求一个长度为 \(n\) 的环形序列 \(a\) 的最大子段和。
对于所有数据,\(1\leq n\leq 10^7\)。
题解
套路题。解决环形问题有经典套路断环成链,于是把 \(a\) 断成链,再复制一次添加到末尾,得到新的长度为 \(2n\) 的序列 \(a'\)。原题等价于在 \(a'\) 上求一个长度不超过 \(n\) 的最大子段和。
先计算好前缀和 \(s\)。考虑枚举右端点 \(i\),那么我们就要求出
请注意这种将定值从 \(\max/\min\) 等运算中分离出来是很常见的套路。后面这个 \(\max\{s_j\}\) 是区间最大值的形式,于是枚举右端点的同时用单调队列维护即可。时间复杂度 \(O(n)\)。
当然,这题还有一个比较清新的做法。环形序列的最大子段和有两种,一种是跨过 \((a_n,a_1)\) 这个相邻点对的,一种则不跨过。后者是平凡的,而对于前者,注意到子段外的数依然是一个序列上的平凡连续子段,并且由于原来的子段和最大化,因此这个子段外的的连续子段和,实际上就是 \(a\) 的不跨环最小子段和。对两种情况分别跑一次贪心/DP,取最大值即可。
单调队列优化 DP
一种及其常见的数据结构优化 DP 的手段,本质上就是利用决策单调性排除冗余状态。
通过一些单调队列优化 DP 的好题,就会发现单调队列本身是极其灵活的,不止用来维护区间极值那么简单。
Luogu P2627 [USACO11OPEN] Mowing the Lawn G
题意
给出长度为 \(n\) 的序列 \(a\)。你可以从中选出若干个数,使得选出来的数中不存在长度超过 \(k\) 的连续段。试最大化选出来的数之和。
对于所有数据,\(1\leq k\leq n\leq 10^5\)。
题解
算是很裸的单调队列优化 DP 的题了。令 \(f_i\) 表示考虑 \(a[1,i]\),能得到的最大和。直接枚举从 \(a_i\) 开始往前的极长连续段 \(a[j,i]\) 就能得到转移:
用单调队列对每个位置维护这个 \(f_i-s_i\) 即可做到 \(O(n)\)。
Luogu P1776 宝物筛选
题意
多重背包。有 \(n\) 件物品,\(v_i,w_i,m_i\) 分别为物品的价值、重量和个数,背包容量为 \(W\)。
对于所有数据,\(n\leq \sum m_i \leq 10^5\),\(0\le W\leq 4\times 10^4\),\(1\leq n\le 100\)。
题解
二进制拆分可以 \(O(nW\log{m})\) 地通过。这里讲 \(O(nW)\) 的单调队列优化 DP 做法。
令 \(f_{i,j}\) 表示考虑前 \(i\) 个物品,选出来的物品重量之和恰好为 \(j\) 的最大价值。枚举当前物品选的个数 \(k\),转移显然:
发现这个式子并不像很典的单调队列优化 DP 的形式,我们需要做出转化。注意到 \(f_{i,j}\) 取值的决策集合,与 \(f_{i,j-1},f_{i,j-2},\cdots\) 完全不相交,但和 \(f_{i,j-w_i}\) 几乎一致,这启发我们把所有 \(j\) 按照 \(j\bmod{w_i}\) 分组分别进行 DP。
现在我们来思考同组内如何进行 DP。我们显然可以把 \(j\) 写成 \(qw_i+r(0\leq r< w_i)\) 的形式,且对于同组内的 \(j\) 而言,\(r\) 是定值。尝试重写转移方程:
至此,单调队列优化 DP 的方式已经显而易见了。依然是对每个位置维护 \(\max\) 内部的东西即可。每个状态都是均摊 \(O(1)\) 的转移,所以时间复杂度为 \(O(nW)\)。
Luogu P6563 [SBCOI2020] 一直在你身旁
题意
给出长度为 \(n\) 的序列 \(a\)。现在需要猜一个正整数 \(1\leq x\leq n\),你可以花费 \(a_i\) 的价钱知道 \(x\) 是否大于 \(i\)。试问你至少需要花多少钱才能保证知道 \(x\) 的值。
多测。对于所有数据,\(1\leq n\leq \sum{n}\leq 7100\),\(a_1\leq a_2\leq\cdots\leq a_n\leq 1\times 10^9\)。
题解
算是比较妙的题了,然而思维难点不在单调队列优化 DP 上。
令 \(f_{i,j}\) 表示已经确定 \(i\leq x\leq j\) 之后所需的最小价钱。容易写出 \(O(n^3)\) 的区间 DP 转移:
这个转移式子看上去很难优化的样子。首先这个 \(\max\) 就很棘手,所以考虑分类讨论来去掉它,也就是说我们寻找一个分界点 \(p\),使得 \(f_{i,p-1}\leq f_{p,j}\) 且 \(f_{i,p}>f_{p+1,j}\)。充分挖掘性质,考虑固定 \(i\),于是我们有 \(f_{i,j}\leq f_{i,j+1}\),因此当 \(j\) 增加时, \(p\) 是单调不减的!于是,对于每个 \(i\),寻找 \(p\) 的过程就是整体 \(O(n)\) 的。
然后我们进行分类讨论。当 \(i\leq k<p\) 时,决策值为 \(f_{k+1,j}+a_k\),很显然我们固定 \(j\),移动 \(i\),就可以利用单调队列均摊 \(O(1)\) 的转移;当 \(p\leq k\leq j\),决策值为 \(f_{i,k}+a_k\),这个更简单,\(f_{i,k}\) 和 \(a_k\) 都关于 \(k\) 单调递增,直接取 \(k=p\) 即可。
时间复杂度优化到了 \(O(n^2)\)。
Luogu P5665 [CSP-S2019] 划分
题意
给定长度为 \(n\) 的序列 \(a\)。你需要找到一些分界点 \(1 \leq k_1 \lt k_2 \lt \cdots \lt k_p \lt n\),使得
在此基础上,最小化
对于所有数据,\(2\leq n\leq 4\times 10^7\),\(1\leq a_i\leq 10^9\)。
题解
这题的单调队列就很灵活了。
令 \(f_{i,j}\) 表示考虑 \(a[1,i]\),且最后一段为 \(a[j+1,i]\) 的最小价值。转移时直接枚举倒数第二段的划分点并保证合法即可做到 \(O(n^3)\)。
进一步优化,考虑贪心。我们有一个性质:在最优划分方案中,最后一段的元素和必然是所有合法划分方案中最小的。
证明:设某种划分方案的最后一段的元素和为 \(sum_k\),考虑某一段 \(j\) 使得令 \(sum_k\leftarrow sum_k-d,sum_j\leftarrow sum_j+d(d>0)\)后方案依然合法。显然原来 \(sum_j+d\leq sum_k-d\),拆平方就能得到
即调整后更优。得证。
这时候我们改变状态,令 \(f_i\) 表示考虑 \(a[1,i]\),且最后一段为 \([f_i+1,i]\) 时和最小。再令 \(g_i=s_i-s_{f_i}\),即最优划分方案中最后一段的元素和。考察一个位置 \(j\) 能够作为决策点的条件:
所以我们需要维护这个 \(val_j=g_j+s_j\)。考虑单调队列优化。对于两个合法决策点 \(i,j\),我们只需要保留较大的那一个,所以我们维护一个下标和对应的 \(val\) 均单调递增的单调队列。每次循环一个 \(i\),若 \(val_{q_{head+1}}\leq s_i\),就不断弹出队头。决策点就是最终合法的队头或 \(0\)。加入一个新的决策时,平凡地维护单调性即可。时间复杂度 \(O(n)\)。
本题需要上压位高精且时空都卡。具体实现时注意优化。

浙公网安备 33010602011771号