单调队列与单调栈

热身一、数据结构
操作分析:

  1. 插入操作:可以用一个栈模拟,一直往栈顶放元素
  2. 删除操作:就是正常的弹栈操作
  3. 左移操作:弹栈操作,可弹出的元素需要保留,可以放到第二个栈里面
  4. 右移操作:讲第二个栈顶元素,移动到第一个栈里面
  5. 询问操作:维护一个数组F,每次元素更新,都需要维护数组F

问题分析:

  1. 关键就是新造一个数据结构,结构定义 + 结构操作
  2. 模拟光标的功能,做移动,右移动,插入,删除,用对顶栈来模拟
  3. 实现对顶栈,用数组模拟,或者用链表模拟
  4. 题目中的BUG:Query K中 K 可能大于当前位置
#include<iostream>
#include <stack>
#include <vector>
#include <stdio.h>
using namespace std;
#define ll long long
class NewStruct {
public:
    NewStruct() {
        sum[0] = 0;
        ans[0] = -2147483647;
    }
    void insert(ll x) {
        s1.push(x);
        int ind = s1.size();
        ll val = x + sum[ind - 1];
        ll val1 = max(ans[ind - 1], val);
        sum[ind] = val;
        ans[ind] = val1;
        return ;
    }
    void del() {
        if (s1.empty()) return ;
        s1.pop();

        return ;
    }
    void move_left() {
        if (s1.empty()) return ;
        s2.push(s1.top());
        del();
        return ;
    }
    void move_right() {
        if (s2.empty()) return ;
        insert(s2.top());
        s2.pop();
        return ;
    }
    int query(ll k) {
        return ans[k];
    }
private:
    stack<ll> s1, s2;
    ll sum[1005];//sum 存从第一个位置到当前位置的和
    ll ans[1005];//ans存放当前位置的字段和最大的
};

int main() {
    ll n;
    cin >> n;
    string op;
    ll val;
    NewStruct s;
    for (int i = 0; i < n; i++) {
        cin >> op;
        switch (op[0]) {
            case 'I': cin >> val; s.insert(val); break;
            case 'D': s.del(); break;
            case 'L': s.move_left(); break;
            case 'R': s.move_right(); break;
            case 'Q': {
                cin >> val;
                cout << s.query(val) << endl;
            } break;
        }
    }
    return 0;
}

热身二:火车进站
有 n 列火车按 1 到 n 的顺序从东方左转进站,这个车站是南北方向的,它虽然无限长,只可惜是一个死胡同,而且站台只有一条股道,火车只能倒着从西方出去,而且每列火车必须进站,先进后出。
现在请你按字典序输出前 20 种可能的出栈方案。

题目分析:

  1. 当前进栈的最大数字是x, 序列中当前待出栈的数字是y

  2. y < x y \lt x y<x,说明y 一定是栈顶元素

  3. y > x y > x y>x,将 x + 1 , y {x + 1 , y} x+1,y入栈,此时栈顶元素一定是y

  4. 通过使用next_permutation来枚举出所有全排序的方案

  5. 然后模拟栈的进出,来判断该情况是否合理

#include<iostream>
#include <stack>
#include <algorithm>
using namespace std;

int a[30];
int s[30], top;

bool is_valid(int *a, int n) {
    int j = 0;//j 记录当前入栈的最大值
    top = 0;//把栈清空
    for (int i = 1; i <= n; i++) {
        while(j < a[i]) { s[++top] = (++j); }//小于这个数的都入栈,不能入栈看能否匹配
        if (top == 0 || s[top] - a[i]) return false;
        --top;
    }
    return true;
}

int main() {
    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) a[i] = i;
    int cnt = 0;
    do {
        if (!is_valid(a, n)) continue;
        for (int i = 1; i <= n; i++) {
            cout << a[i];
        }
        cout << endl;
        cnt++;
        if (cnt == 20) break;

    } while(next_permutation(a + 1, a + n + 1));

    return 0;
}

单调队列

  1. 本质问题是:固定查询结尾的RMQ问题,例如RMQ ( x , 7 ) (x, 7) x,7
  2. 问题性质:维护滑动窗口最值问题

问题引入: RMQ(X, Y)就是询问数组[x, y]区间内部的最小值 例如RMQ(0, 3) = 1, RMQ(3 , 7) = 2
现在,固定询问区间的尾部,例如:RMQ(x, 7) 请思考,如下序列中最少记录几个元素,就可以满足RMQ(x, 7)的任何需求

在这里插入图片描述
答:4个
0–7:1
1–7:1
2–7:2
3–7:2
4–7:2
5–7:8:
6–7:8
7–7:12

在这里插入图片描述

单调队列所维护的区间最小值一定是在队首 所以需要建立的是一个单调递增的序列
插入元素以后,所有大于该元素的值,都将被移出队列

入队操作:
队尾入队,会把之前破坏单调性的元素,都从队尾移出(维护单调性)
出队操作:
如果队首元素超出区间范围,就将元素从队首出队
元素性质:
1. 队首元素,永远是当前维护区间的(最大/最小值)
2. 序列中的每一个元素,在依次入队的过程中,每个元素都【蓝】过
3. 单调队列维护的是滑动窗口内部的区间最值

实际案例: 把数值代表人的能力,窗口代表时间范围, 如果一个人年龄比你小,能力比你强,自己就会被出队。。
维护最小值,则维护单调递增队列
维护最大值,则维护单调递减队列

题目一:滑动窗口:单调队列的模板题

思考:单调队列中是记录值还是记录下标的问题
结论:记录下标,因为有了下标可以索引到值,记录值反向不可查

实现单调队列的套路,先入队,再把过期元素出队

#include<iostream>
using namespace std;

#define MAX_N 300000
int q[MAX_N + 5], head = 0, tail = 0;//单调队列存储的是原数列的下标,有下标可以索引到值
int val[MAX_N + 5];

int main() {
    int n, k;
    cin >> n >> k;
    for (int i = 1; i <= n; i++) {
        cin >> val[i];
    }
    for (int i = 1; i <= n; i++) {
        while(tail - head && val[q[tail - 1]] > val[i]) --tail;//当队尾元素大于该元素,队尾元素出队
        q[tail++] = i;
        if (q[head] <= i - k) head++;//当第一个元素超过滑动窗口范围,第一个元素出队
        if (i >= k) {//当队列中的值满足滑动窗口的值
            i > k && cout << " ";
            cout << val[q[head]];
        }
    }
    cout << endl;//单调递减s队列
    head = tail = 0;//清空队列
    for (int i = 1; i <= n; i++) {
        while(tail - head && val[q[tail - 1]] < val[i]) --tail;
        q[tail++] = i;
        if (q[head] <= i - k) head++;
        if (i >= k) {
            i > k && cout << " ";
            cout << val[q[head]];
        }
    }
    cout << endl;
    return 0;
}

单调栈

对于单调队列,尾部入队,把违反单调性的队列从尾部出队,把过期的元素,从头部出队
若把单调队列的一头堵死,从尾部入,从尾部出,就构成了单调栈
单调栈不过滤过期元素


单调栈的性质
一个数字入栈以后,他前面的数字一定是离他最近小于/大于他的值
擅长维护最近(大于/小于)关系

  1. 单调栈保留了单调队列的入队操作
  2. 单调栈依然是维护了一种单调性
  3. 问题性质:最近(大于/小于)关系
  4. 入栈之前,符合单调性的栈顶元素,就是我们要找的最近(大于/小于)关系
  5. 均摊时间复杂度: O ( 1 ) O(1) O(1)

题目二:最大矩形面积:单调栈的模板题
1. 分析最优解性质
1. 最大矩形的性质 : 矩形高度 = 区域最矮模板的高度
2. 以每一块模板作为矩形高度,求能得到的最大矩形面积,最后在所有面积中,取得最大值
2. 需要求解:每一块木板最近得高度小于当前木板得位置。所以需要用单调栈

分别从左面和右面找到小于该高度的值,距离相减求得宽度,然后以当前为高求得面积

#include<iostream>
using namespace std;
#define MAX_N 100000
#define ll long long
ll s[MAX_N + 5], top;
ll height[MAX_N + 5];
ll l[MAX_N + 5], r[MAX_N + 5];//分别找到左面小于当前和右面小于当前的值

ll solve(ll n) {
    height[0] = height[n - 1] = -1;
    top = -1;
    s[++top] = 0;
    for (int i = 1; i <= n; i++) {
        while( top != -1 && height[s[top]] >= height[i] ) --top;//大于等于是因为对于和当前相同的值的时候,小于当前的临界值,显然不是等于该值的,下面的同理
        l[i] = s[top];
        s[++top] = i;
    }
    top = -1;
    s[++top] = 0;
    for (int i = n; i >= 1; i--) {
        while( top != -1 && height[s[top]] >= height[i] ) --top;
        r[i] = s[top];
        s[++top] = i;
    }
    ll ans = 0;
    for (int i = 1; i <= n; i++) {
        ans = max(ans, height[i] * (r[i] - l[i] - 1));
    }
    return ans;
}
int main() {
    ll n, ans;
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> height[i];
    cout << solve(n) << endl;
    return 0;
}

动态规划与单调栈/队列

题一:矩形

在这里插入图片描述

能组成矩形就是看有没有左上点和该点可以匹配成一个矩形 找矩形,就以该列得高度为最矮得高度,然后在左面找第一个小于该点的高度
以第三行为例
每个矩形得高度为2、3、1、3、3、3 以第五行为例:小于该列的高度为第3列,高度为1,所以能确定在第三列右侧,
S 总 矩 形 面 积 为 = h 第 五 列 的 高 度 × ( 第 五 列 − 第 三 列 ) 宽 度 S_{总矩形面积为} =h_{第五列的高度} \times (第五列 - 第三列)_{宽度} S=h×()
而能构成多大的矩形,就说明有多少个点可以匹配右下角的点构成一个三角形, 对于第三行以及左面,以第三行为右下角点,构成多少矩形,则为 f ( 3 ) f(3) f(3)
所以 f ( 5 ) = h 5 ∗ ( 5 − 3 ) + f ( 3 ) f(5) = h_5 * (5 - 3) + f(3) f(5)=h5(53)+f(3)
推出动态转移方程为 f ( n ) = h n ∗ ( n − m ) + f ( m ) ∣ m : 左 面 第 一 个 小 于 该 列 高 度 的 列 f(n) = h_n * (n - m)+ f(m) | m:左面第一个小于该列高度的列 f(n)=hn(nm)+f(m)m:

解题思路:

  1. 左上角坐标和右下角坐标可以唯一确定一个子矩形
  2. 确定一行,将问题转换成子问题,右下角坐标落在固定的一行上,求每一点能构成的子矩形数量
  3. 通过观察,将问题变成两个子问题
  4. 首先找到左侧离x点最近的,小于x点的位置i
  5. f ( x ) f(x) f(x) 代表以x做为右下角坐标所能构成的合法子矩形数量
  6. 首先找到左侧离x点最近的,小于x点的位置i
  7. f ( x ) = h x × ( x − i ) + f ( i ) f(x) = h_x \times (x - i) + f(i) f(x)=hx×(xi)+f(i)
  8. 因为需要求解离x最近的小于x的位置,所以要用单调栈
#include<iostream>
using namespace std;

#define MAX_N 1000
#define MOD_NUM 100007

int s[MAX_N + 5], top;
int c[MAX_N + 5][MAX_N + 5];//每一个点向上数白色格子数量
int f[MAX_N];
int n, m;

void read() {
    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            cin >> c[i][j];
            if (c[i][j]) c[i][j] += c[i - 1][j];
        }
    }
    return ;
}

long long solve() {
    long long ans = 0;
    for (int i = 1; i<= n; i++) {
        top = -1;
        s[++top] = 0;
        c[i][0] = -1;
        f[0] = 0;
        for (int j = 1; j <= m; j++) {
            while (top != -1 && c[i][s[top]] >= c[i][j]) --top;
            f[j] = c[i][j] * (j - s[top]) + f[s[top]];
            s[++top] = j;
            f[j] %= MOD_NUM;
            ans += f[j];
            ans %= MOD_NUM;
        }
    }
    return ans;
}

int main() {
    read();
    cout << solve() << endl;
    return 0;
}

题二:古老的打字机:斜率优化

题目分析: 设 dp[i] 为打印 i个字符的最小消耗\ C k C_k Ck:为从 k k k i i i 的所有和
d p [ i ] = m i n ( d p [ j ] + ∑ k = j + 1 k = i C k 2 + M ) dp[i] = min(dp[j] + \sum_{k = j + 1}^{k = i}C_k^2 + M) dp[i]=min(dp[j]+k=j+1k=iCk2+M):从i之前找到一个区间 使得 单次打印磨损值 与该区间之前的磨损值之和 最小的
该转移方程的时间复杂度为 O ( n 2 ) O(n^2) O(n2) 但是因为题目中 n n n 值为1e6,会导致超时的结果从而进行优化

设s[i] 为从0到i的加和
d p [ i ] = m i n ( d p [ j ] + ∑ k = j + 1 k = i C k 2 + M ) = m i n ( d p [ j ] + ( s i − s j ) 2 + M ) dp[i] = min(dp[j] + \sum_{k = j + 1}^{k = i}C_k^2 + M) = min(dp[j] + (s_i - s_j)^2 +M) dp[i]=min(dp[j]+k=j+1k=iCk2+M)=min(dp[j]+(sisj)2+M)
= m i n ( d p [ j ] + s i 2 − s j 2 − 2 ∗ s i ∗ s j + M ) = min(dp[j] + s_i^2 - s_j^2 - 2 * s_i * s_j + M) =min(dp[j]+si2sj22sisj+M)
在上述式子中:
s i 2 + M s_i^2 + M si2+M : 定值项
d p [ j ] + s j 2 dp[j] + s_j^2 dp[j]+sj2: 转移项
2 ∗ s i ∗ s j 2 * s_i * s_j 2sisj:混合项

在这里插入图片描述
g ( i , j ) = f ( i ) − f ( j ) s i − s j g(i, j) = \frac{f(i) - f(j)}{s_i - s_j} g(i,j)=sisjf(i)f(j)
在这里插入图片描述

g ( j , k ) < g ( k , l ) g(j, k) < g(k, l) g(j,k)<g(k,l) 2 ∗ s i < g ( j , k ) < g ( k , l ) 2*s_i<g(j, k) < g(k, l) 2si<g(j,k)<g(k,l) : 则 j j j 不如 k k k k k k, 不如 l l l则最优解为 l l l
g ( j , k ) < 2 ∗ s i < g ( k , l ) g(j, k) < 2*s_i< g(k, l) g(j,k)<2si<g(k,l):则 k k k 不如 l l l j j j k k k 好,最优解在 j j j l l l 中取
g ( j , k ) < g ( k , l ) 2 ∗ s i g(j, k) < g(k, l) 2*s_i g(j,k)<g(k,l)2si:则 k k k l l l j j j k k k 好, 删除 k k k最优解为 j j j

综上:若出现背脊形状,则背脊处为无用点

在这里插入图片描述
优化之后:时间复杂度为 O ( n ) O(n) O(n)

解题思路:

  1. 状态定义
    d p [ i ] dp[i] dp[i] 代表打印i个字符,最小消耗值
  2. 状态转移方程
    定义: s i = ∑ k = 1 i C k s_i = \sum_{k = 1}^iC_k si=k=1iCk
    d p [ i ] = m i n ( d p [ j ] + ( s i − s j ) 2 + M ) dp[i] = min(dp[j] + (s_i - s_j)^2 + M) dp[i]=min(dp[j]+(sisj)2+M)
    时间复杂度为 O ( n 2 ) O(n^2) O(n2)

斜率优化

假设从j转移要优于从k转移

d p [ j ] + ( s i − s j ) 2 + M < d p [ k ] + ( s i − s k ) 2 + M dp[j] + (s_i - s_j)^2 + M < dp[k] + (s_i - s_k)^2 + M dp[j]+(sisj)2+M<dp[k]+(sisk)2+M

d p [ j ] + s j 2 − 2 s i s j < d p [ k ] + s k 2 − 2 s i s k dp[j] + s_j^2 - 2s_is_j < dp[k] + s_k^2 - 2s_is_k dp[j]+sj22sisj<dp[k]+sk22sisk

( d p [ j ] + s j 2 ) − ( d p [ k ] + s k 2 ) < 2 s i ( s j − s k ) (dp[j] + s_j^2) - (dp[k] + s_k^2) < 2s_i(s_j - s_k) (dp[j]+sj2)(dp[k]+sk2)<2si(sjsk)

( d p [ j ] + s j 2 ) − ( d p [ k ] + s k 2 ) s j − s k < 2 s i \frac {(dp[j] + s_j^2) - (dp[k] + s_k^2) }{s_j - s_k}<2s_i sjsk(dp[j]+sj2)(dp[k]+sk2)<2si

设: f ( i ) = d p [ i ] + s i 2 f(i) = dp[i] + s_i^2 f(i)=dp[i]+si2

f ( j ) − f ( k ) s j − s k < 2 s i \frac {f(j) - f(k)}{s_j - s_k} < 2s_i sjskf(j)f(k)<2si,这东西就是一个人斜率

经过斜率优化以后,时间复杂度优化成了: O ( n ) O(n) O(n)
代码演示:

#include<iostream>
using namespace std;
#define MAX_N 1000000
#define S(a) ((a) * (a))
#define ll long long
ll dp[MAX_N + 5];
ll c[MAX_N + 5], n,  M, s[MAX_N + 5];//s:sum 从0 到i
ll f[MAX_N + 5]; //dp[i] + si^2
ll q[MAX_N + 5], head, tail;

double slope(ll i, ll j){
    return 1.0 * (f[i] - f[j]) / (s[i] - s[j]);
}

void read() {
    cin >> n >> M;
    for (int i = 1; i <= n; i++) {
        cin >> c[i];
        s[i] = s[i - 1] + c[i];
    }
    return ;
}

void set_dp(int i, int j) {
    dp[i] = dp[j] + S(s[i] - s[j]) + M;
    f[i] = dp[i] + S(s[i]);
    return ;
}

ll solve() {
    head = tail = 0;
    q[tail++] = 0;
    for (int i = 1; i <= n; i++) {
        while ((tail - head) >= 2 && slope(q[head + 1], q[head]) < (2 * s[i])) ++head;//队列中多于两个元素,并且最优解为q[head + 1]
        set_dp(i, q[head]);//得出当前位置的dp值
        while (tail - head >= 2 && slope(i, q[tail - 1]) < slope(q[tail - 2], q[tail - 1])) --tail;//要放入元素的时候,先看看有没有背脊状
        q[tail++] = i;
    }
    return dp[n];
}
int main() {
    read();
    cout << solve() << endl;
    return 0;
}

题三:双生序列:单调队列

本题确定队尾p,然后比较下标,实际比较队列中的数量即可

解题思路

  1. 因为两个序列的每个区间的RMQ值都相等,等价于两个序列的单调队列长得一样
  2. 将两个序列,依次插入到单调队列中,过程中判断单调队列是否一样,如果不一样就退出

#include<iostream>
using namespace std;
#define MAX_N 500000
int a[MAX_N + 5], b[MAX_N + 5];

class Queue {
public :
    Queue(int *arr) :arr(arr){}
    void push(int i) {
        while (tail - head && arr[q[tail - 1]] > arr[i]) --tail;
        q[tail++] = i;
        return ;
    }
    void pop() {++head; }
    int size() {
        return tail - head;
    }
private:
    int *arr;
    int q[MAX_N + 5], head, tail;
};

int n;
Queue q1(a), q2(b);

void read() {
    cin >> n;
    for (int i = 0; i < n; i++) cin >> a[i];
    for (int i = 0; i < n; i++) cin >> b[i];
    return ;
}

int main() {
    read();
    int p;
    for (p = 0; p < n; p++) {
        q1.push(p);
        q2.push(p);
        if (q1.size() != q2.size()) break;
    }
    cout << p << endl;
    return 0;
}

题四:最大子序和

  1. 有个限制条件:子序列的长度不超过M
  2. 转换成前缀和数组上的问题,就是 S i − s j S_i - s_j Sisj, 其中 i − j < = M i - j <= M ij<=M
  3. 在前缀和数组上,维护一个大小为M的滑动窗口中的最小值
  4. 所以,采用单调队列
#include<iostream>
#include <climits>
using namespace std;
#define MAX_N 300000
int s[MAX_N + 5], n, m;
int q[MAX_N + 5], head, tail;
void read() {
    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        cin >> s[i];
        s[i] += s[i - 1];
    }
    return ;
}

int solve() {
    int ans = INT_MIN;
    head = tail = 0;
    q[tail++] = 0;
    for (int i = 1; i <= n; i++) {
        if (i - q[head] > m) head++; //队首元素过期
        ans = max(ans, s[i] - s[q[head]]);
        while (tail - head && s[q[tail - 1]] > s[i]) tail--;//找区间内最小的前缀和
        q[tail++] = i;
    }
    return ans;
}

int main() {
    read();
    cout << solve() << endl;
    return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值