单调栈介绍

单调栈可以在 $O(n)$ 时间内解决 “对于每一个index,求右侧/左侧第一个对应数字比它大/小的index” 的问题。

例1 Leetcode2281 Sum of Total Strength of Wizards

题意

给定一个包含 $n$ 个正整数的数组。现在求所有连续的 subarray 的权重和。

一个 subarray 的权重和的定义:这个subarray中的 最小值 乘上 subarray 的和

其中,$n \leq 10^5, a_i \in [1, 10^9]$,答案对 $10^9+7$ 取模。

题解

首先考虑每一个元素能作为哪些subarray的最小值,很明显这是一个单调栈问题,寻找每个元素左/右的第一个比它小的值。

然而这样可能会产生重复,如 [2,2,2,2] 可能会有问题。

一个常见的套路是 一边开,一边闭

即:我们对于每个index $i$,找它右侧的第一个 $\leq a_i$ 的index,再找它左侧的第一个 $< a_i$ 的index,这样就解决了重复问题。

对于一个index $i$,我们假设它影响到的区间为 $[L,R]$,那么我们只要找这个区间内,所有 包含了 $i$ 的subarray的和即可。


这样的和怎么找?考虑左边和右边的贡献。

我们将 $[L, i-1]$ 定义为左边,$[i, R]$ 定义为右边。

我们求出前缀和数组 $s[]$,再求出前缀和数组的前缀和 $pre[]$,令:

$$lsum = \sum\limits_{l=L}^{i-1} sum(a[l, i-1]) = \sum\limits_{l=L}^{i-1} (s[i-1] - s[l-1]) = (i-L)*s[i] - \sum\limits_{l=L-1}^{i-2}s[l] = (i-L)*s[i] - (pre[i-2] - pre[L-1])$$

$$rsum = \sum\limits_{r=i}^R sum(a[r, R]) = -(R-i+1)*s[i-1] + (pre[R] - pre[i-1])$$

最后,这个sum就为

$$sum = lsum * rlen + rsum * llen$$

其中 $llen = length[L, i-1] = i-L, rlen = R-i+1$。

代码
class Solution {
public:
    #define ll long long
    #define maxn 100005
    const int mod = (int)(1e9+7);
    ll a[maxn];
    ll sum[maxn], pre[maxn];
    int l[maxn], r[maxn];
    ll get_presum(int l, int r) {
        if (r < 0 || l > r) return 0;
        if (l > 0) return (pre[r] - pre[l-1] + mod) % mod;
        return pre[r];
    }
    int totalStrength(vector<int>& arr) {
        int n = arr.size();
        ll ans = 0;
        for (int i = 1; i <= n; i++) a[i] = arr[i-1];
        for (int i = 1; i <= n; i++) sum[i] = (sum[i-1] + a[i]) % mod;
        for (int i = 1; i <= n; i++) pre[i] = (pre[i-1] + sum[i]) % mod;
        stack<int> st;
        fill(r, r+maxn, n+1);
        for (int i = 1; i <= n; i++) {
            while (st.size() && a[st.top()] >= a[i]) {
                r[st.top()] = i;
                st.pop();
            }
            st.push(i);
        }
        while (st.size()) st.pop(); 
        for (int i = n; i >= 1; i--) {
            while (st.size() && a[st.top()] > a[i]) {
                l[st.top()] = i;
                st.pop();
            }
            st.push(i);
        }
        
        for (int i = 1; i <= n; i++) {
            ll L = l[i] + 1, R = r[i] - 1;
            // Case1: lsum * rlen
            ll res = 0;
            ll lsum = ((i - L) * sum[i-1] - get_presum(L-1, i-2) + mod) % mod;
            ll rlen = R - i + 1;
            res += lsum * rlen % mod;
            // Case2: rsum * llen
            ll rsum = (-(R - i + 1) * sum[i-1] + get_presum(i, R) + mod) % mod;
            ll llen = i - L + 1;
            res += rsum * llen % mod;
            res %= mod;
            ans = (ans + res * a[i] % mod) % mod;
            ans %= mod;
        }
        return ans;
    }
};

例2. Serval的数学课堂

题意

定义一个可重集合 $S$ 的 exavg 为:

$$\text{exavg }(S) = \frac{(\sum\limits_{x\in S} x) - \max S - \min S}{|S| - 2}$$

也就是去掉最大最小值后,取平均值。

• exavg 仅对大小至少为 $3$ 的集合有定义。

给定一个整数序列 $A_1, …, A_n$,求所有子区间 $[L,R]$ 的 exavg 的平均值,形式化的,求:

$$\text{avg } \{\text{exavg }(A[L…R]) ~|~ 1 \leq L < R \leq n, R-L+1 \geq 3\}$$

其中,$3 \leq n \leq 5 \times 10^5, A_i \in [0, 998244353)$,答案对 $998244353$ 取模。

题解

经典老套路之:讨论每个数的贡献。

很明显我们只要求 $\text{exavg }$ 的 sum 就行。

贡献可以分为三部分,一个是正常贡献,一个是要减去的最大值,还有一个是要减去的最小值。

对于正常贡献,假设当前这个数为 $a_i$,我们希望知道有哪些区间穿过了 $a_i$,假设一个区间的长度为 $l$,那么我们要求的是

$$\sum\limits_{l} \frac{1}{l-2}$$

其中,区间必须穿过 $a_i$,并且长度 $l \geq 3$。

怎么求呢?

我们令 $C_i$ 为一个长度为 $i$ 的区间内,所有子区间的长度 $l$ 的 $\frac{1}{l-2}$ 的值的和,形式化的:

$$C_i = \sum\limits_{L,R \in [1,i], R-L+1 \geq 3}\frac{1}{(R-L+1)-2}$$

那么就有

$$C_1 = C_2 = 0$$

$$C_i = 2C_{i-1} - C_{i-2} + \frac{1}{i-2}, \forall i \geq 3$$


那么要求所有穿过 $a_i$ 的区间的 $\frac{1}{l-2}$ 之和,假设 $a_i$ 的贡献影响范围是 $[L,R]$,那么有:

img

也就是

$$\sum\limits_{l} \frac{1}{l-2} = C_{R-L+1} - C_{i-L} - C_{R-i}$$

那么对于每一个正常贡献,影响范围就是 $L = 1, R = n$。

对于最大最小值,影响范围用单调栈求出即可。

• 为了防止两个值相同导致的贡献重叠,我们利用 这里 的套路:左闭右开 的单调栈即可。

代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 5e5+5;

int n;
Z C[maxn];  // C[i]: 区间内所有长度 x 的区间的 1/(x-2) 的和
int a[maxn];
int l[maxn], r[maxn];
Z ans = 0;
int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 3; i <= n; i++) {
        C[i] = C[i-1] * 2 - C[i-2] + Z(1) / (i-2);
    }

    // 小于的部分
    stack<int> st;
    fill(r, r+maxn, n+1);
    fill(l, l+maxn, 0);
    for (int i = 1; i <= n; i++) {
        while (st.size() && a[st.top()] >= a[i]) {
            r[st.top()] = i;
            st.pop();
        }
        st.push(i);
    }
    for (int i = n; i >= 1; i--) {
        while (st.size() && a[st.top()] > a[i]) {
            l[st.top()] = i;
            st.pop();
        }
        st.push(i);
    }
    while (st.size()) st.pop();

    for (int i = 1; i <= n; i++) {
        int L = l[i] + 1, R = r[i] - 1;
        ans -= (C[R-L+1] - C[R-i] - C[i-L]) * a[i];
    }

    fill(r, r+maxn, n+1);
    fill(l, l+maxn, 0);
    for (int i = 1; i <= n; i++) {
        while (st.size() && a[st.top()] <= a[i]) {
            r[st.top()] = i;
            st.pop();
        }
        st.push(i);
    }
    for (int i = n; i >= 1; i--) {
        while (st.size() && a[st.top()] < a[i]) {
            l[st.top()] = i;
            st.pop();
        }
        st.push(i);
    }
    while (st.size()) st.pop();

    for (int i = 1; i <= n; i++) {
        int L = l[i] + 1, R = r[i] - 1;
        ans -= (C[R-L+1] - C[R-i] - C[i-L]) * a[i];
    }


    for (int i = 1; i <= n; i++) {
        int L = 1, R = n;
        ans += (C[R-L+1] - C[R-i] - C[i-L]) * a[i];
    }

    Z cnt = 0;
    for (int i = 3; i <= n; i++) cnt += n-i+1;
    ans /= cnt;
    cout << ans << endl;

}

例3 CF1795E. Explosions?

题意

给定一个长度为 $n$ 的正整数数组。

我们可以执行若干次操作,每次操作可以选定一个数,让它 $-1$(不能减到负数),每次操作消耗 $1$ 点法力值。

然后我们会选择一个index $i$,消耗 $a_i$ 点法力值,它会被引爆,对两个邻居造成 $a_i - 1$ 点伤害,然后如果邻居被炸死了(比如 $a_{i-1} \leq a_i-1$),则它会继续引爆,对邻居造成 $a_{i-1}-1$ 点伤害。

最后的引爆过程只能进行一次,并且要保证引爆后所有的数都被炸死。

求最小法力值消耗?

其中,$n \leq 3 \times 10^5, a_i \in [1, 10^6]$。

题解

考虑最后引爆的index $i$。

可以知道,在引爆之前,整个数组应该是长这样:

$[a_1,…,a_i]$ 是严格单调递增,$[a_i,…,a_n]$ 是严格单调递减。

有一个特殊情况,$0,0,0,1,2$ 这种也算“严格”单调递增。

现在就是要求 $f_i$,其中 $f_i$ 表示将 $1…i$ 变成严格单调递增的最小操作次数。

这个可以用单调栈在 $O(n)$ 时间算出所有 $f_i$。

具体操作是我们对于栈内元素维护一个pair,$(a_i, cnt_i)$,其中 $a_i$ 就是元素本身,$cnt_i$ 代表以 $i$ 开始,它往前的严格递减(差值恰好为 $1$)的序列长度。

比如 $(9, 1)$ 代表的就是 $[9]$,$(5,3)$ 代表 $[3,4,5]$。

所以我们就用 dp 和单调栈来模拟这个过程即可,注意一些细节即可。

代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 3e5+5;
int T, n;
ll a[maxn], f[maxn], g[maxn];
vector<pll> st;
ll cal(ll x) {
    return x*(x+1)/2;
}
void solve() {
    st.clear();
    for (int i = 1; i <= n; i++) {
        ll cnt = 1;
        while (!st.empty() && st.back().first >= a[i] - cnt + 1) {
            ll c = st.back().first;
            ll d = st.back().second;
            st.pop_back();
            cnt += d;

            if (cnt <= a[i]) {
                f[i] += (c - (a[i]-(cnt-d))) * d;  // starting with a[i] - cnt
            } else {
                f[i] += (cal(c) - cal(c-d));   // x + (x-1) + (x-2) + ... cnt = 1时
                f[i] -= cal(max(0LL, a[i] - (cnt-d)));  // if d = 3, a[i] = 9, cnt = 8, cnt + d = 11, a[i] = 9, only used 1
            }
        }
        st.push_back({a[i], min(a[i], cnt)});
        f[i] += f[i-1];
    }
}
int main() {
    fastio;
    cin >> T;
    while (T--) {
        cin >> n;
        for (int i = 1; i <= n; i++) cin >> a[i], f[i] = g[i] = 0;
        solve();
        for (int i = 1; i <= n; i++) g[i] = f[i], f[i] = 0;
        // swap(f, g);
        reverse(a+1, a+n+1);
        solve();
        reverse(f+1, f+n+1);
        reverse(a+1, a+n+1);
        ll ans = 1e18;
        for (int i = 1; i <= n; i++) {
            ans = min(ans, f[i] + g[i] + a[i]);
        }
        cout << ans << "\n";
    }
}

单调队列介绍

单调队列可以用于解决滑动窗口最值问题。

简单来说,单调队列内维护的是 index,单调队列要满足两个特性:

  1. q[tail] - q[head] < m,其中 $m$ 是窗口的大小。
  2. 队列中的元素,对应的值单调递减/递增。

要维护第二条的话,就需要在插入一个新的元素时,从队列的尾部不断 pop 掉元素,保证单调性(本质上和单调栈一样)。

例1 洛谷P2627 [USACO11OPEN]Mowing the Lawn G

题意

给定一个包含 $n$ 个正整数的数组,从中选取一些数,使得不存在连续的 $>k$ 个数。

输出选取方案中,可能的最大和。

其中,$n \leq 10^5$

题解

DP。设 $dp[i]$ 为前 $i$ 个数字所能得到的最大答案。

所以我们枚举一下上一个不选的位置 $j$,则有

$$dp[i] = \max_{j=i-k}^i\{dp[j-1]+sum(j+1,i)\}$$

$$=\max_{j=i-k}^i\{dp[j-1]+sum[i] - sum[j]\}$$

由于 $i$ 固定,所以可以把 $sum[i]$ 拿出来,我们只要求

$$\max_{j=i-k}^i\{dp[j-1] - sum[j]\}$$

这个东西只与 $j$ 有关,所以就是一个滑动窗口最小值问题了。

代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e5+5;

ll q[maxn<<1], a[maxn], sum[maxn], dp[maxn];
int head = 1, tail = 0, n, k;
int main() {
    cin >> n >> k;
    for (int i = 1; i <= n; i++) cin >> a[i], sum[i] = sum[i-1] + a[i];

    for (int i = 1; i <= k; i++) {
        dp[i] = sum[i];
        while (head <= tail && dp[q[tail]] - sum[q[tail]+1] <= dp[i-1] - sum[i]) tail--;
        q[++tail] = i-1;
    }

    for (int i = k+1; i <= n; i++) {
        while (head <= tail && dp[q[tail]] - sum[q[tail]+1] <= dp[i-1] - sum[i]) tail--;
        q[++tail] = i-1;
        while (q[tail] - q[head] >= k+1) head++;
        int j = q[head];
        dp[i] = sum[i] + dp[j] - sum[j+1];
    }
    cout << dp[n] << endl;
}