浅谈单调栈、单调队列

开放下载:下载地址
注:未经允许不得进行转载、参考,本文归属权为作者所有,如有需求请先联系作者申请。

浅谈单调栈、单调队列

作者:筱柒_Littleseven

地址:http://littleseven.top/

QQ/微信:3364445435 / yuqihao2003

目录

  • 一、栈结构及单调栈的概念

  • 二、单调栈的应用与例题

  • 三、单调栈总结

  • 四、队列结构及单调队列的概念

  • 五、单调队列的一般应用与例题

  • 六、单调队列优化动态规划

一、栈结构及单调栈的概念

栈结构

​ 栈(stack)又名堆栈,它是一种运算受限的线性表。限定仅在表尾进行插入和删除操作的线性表。这一端被称为栈顶,相对地,把另一端称为栈底。向一个栈插入新元素又称作进栈、入栈或压栈,它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素;从一个栈删除元素又称作出栈或退栈,它是把栈顶元素删除掉,使其相邻的元素成为新的栈顶元素。

​ 栈是一种典型的 \(LIFO(last~in~first~out)\) 的线性结构。最后插入栈的值最先出栈。如果按照顺序压入 \(1,2,3,4,5\) 的五个数,那么按照顺序出栈的情况就应该是 \(5,4,3,2,1\)

p1

​ 对于一个栈的主要算法就是入栈和出栈。入栈则是更新栈顶元素,而出栈则是弹出栈顶元素更新栈顶指针。在使用数组模拟栈的时候,通过修改栈顶指针实现入栈出栈。而在 C++ 语言的标准模板库(\(STL\))当中也为我们提供了处理好的栈。在使用的时候需要引用头文件 : #include <stack>,入栈则是 \(sta.push(x)\),出栈是 \(sta.pop()\),访问栈顶指针 \(sta.top()\)

单调栈

定义: 单调栈是指一个栈内部的元素具有严格单调性的数据结构,分为单调递增栈和单调递减栈。

性质:

  • 单调栈中从栈底到栈顶的元素具有严格单调性
  • 满足栈的 \(LIFO\) 特性

操作:

​ 对于一个单调递增栈,若即将入栈元素为 \(now\) ,栈顶元素为 \(top\) 。如果 \(now > top\) 则直接插入;如果 \(now \le top\) 则持续弹出栈顶元素直到第一次 \(now > top\) 为止,再加入栈顶元素。

​ 对于一个单调递减栈,若即将入栈元素为 \(now\) ,栈顶元素为 \(top\) 。如果 \(now < top\) 则直接插入;如果 \(now \ge top\) 则持续弹出栈顶元素知道第一次 \(now<top\) 为止,再加入栈顶元素。

二、单调栈的应用与例题

1. 对于一个数,寻找他左/右第一个比他大/小的值

例题:POJ 3250 Bad Hair Day

题目链接

题目大意:

​ 有一群牛站成一排,每头牛都是面朝右的,每头牛可以看到他右边身高比他小的牛。给出每头牛的身高,要求每头牛能看到的牛的总数。

分析:

​ 这道题是很经典的一道单调栈的题。

​ 首先我们思考一下不加优化怎么做:以每头牛为起点,向右循环找到第一个比他矮的牛,统计数量。这样是 \(O(n^2)\) 的。但是我们发现一个问题,拿图来谈:

MmShJ1.png

​ 我们会发现,我们在从一个奶牛向后遍历的时候,其实已经在不在意的时候对后边的一些奶牛也进行了处理,当我们处理到 \(i\) 号奶牛第一个看不见的奶牛是 \(j\) 的时候,其实已经算出来 \(i+1 \sim j-1\) 之间很多点的答案了。(例如 \(j - 1\) 号看不到的一定是 \(j\) 号)。所以一定会有很多答案被算了多次,甚至于可能会存在一个答案被计算了 \(n\) 次。

​ 我们考虑如何能进行优化而免去这些无用的重复操作,而达到 \(O(n)\) 的复杂度内求出结果。这个时候就需要单调栈。

​ 假设这一排奶牛的身高为 \(4,3,1,5,2\),我们维护一个单调递减的单调栈,模拟一下元素入栈和出栈的情况:

MmP6pR.png

​ 我们如果手动计算,答案应该是:\(4~->~5~~,~3~->~5~~,~1~->5~~,~5~->结尾~~,~2~->~结尾\)。这里指的是某个身高的奶牛被哪个身高的奶牛挡住。而我们发现,我们模拟的结果是:

  • 身高为 \(4\) 的奶牛被身高为 \(5\) 的奶牛弹出。
  • 身高为 \(3\) 的奶牛被身高为 \(5\) 的奶牛弹出。
  • 身高为 \(1\) 的奶牛被身高为 \(5\) 的奶牛弹出。
  • 身高为 \(5\) 的奶牛被弹结尾(设结尾有一个身高无穷大的奶牛)弹出。
  • 身高为 \(2\) 的奶牛被弹结尾(设结尾有一个身高无穷大的奶牛)弹出。

发现单调栈中,如果编号为 \(i\) 的奶牛因为编号为 \(j\) 的奶牛入栈而被弹出,那么奶牛 \(i\) 右边第一个比他高的奶牛就是奶牛 \(j\) 。统计一下就行。

AC代码:

// #include <bits/stdc++.h>

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <stack>

using namespace std;

typedef long long ll;

stack <ll> sta;

int n;

ll high[80010], ans;

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i ++ ) {
        scanf("%lld", &high[i]);
    }
    high[n + 1] = 1 << 30;
    for (int i = 1; i <= n + 1; i ++ ) {
        while (! sta.empty() && (high[i] >= high[sta.top()])) {
            // 注意这里 !sta.empty() 必须放在前边,否则会提前访问不存在的 sta.top() 而造成RE
            ans += i - sta.top() - 1;
            // 统计答案
            sta.pop();
            // 弹栈
        }
        sta.push(i);
        // 入栈
    }
    printf("%lld\n", ans);
    return 0;
}

2. 左右配对(寻找区间)

例题:POJ 2559 Largest Rectangle in a Histogram

题目链接

题目大意:

​ 给你一个由 \(n\) 个矩形组成的柱状图和这 \(n\) 个矩形的高度。求出在这个柱状图当中可以找到的面积最大的矩形的面积。

分析:

​ 分析对于柱状图中一个矩形,怎样才能向两边扩展:

MmtdRs.png

​ 我们发现对于当前矩形 \(i\) ,它只能向右扩展到第一个比他矮的矩形,也只能向左扩展到第一个比他矮的矩形。那么我们可以类比上一道题,使用两个单调栈。一个从 \(1\)\(n\) 维护一个单调递增栈,这样我们可以得到每一个矩形右边第一个比它矮的矩形的编号 \(l\) ;同理,一个从 \(n\)\(1\) 维护一个单调递增栈,这样可以得到每一个矩形左边第一个比他矮的编号 \(r\) 。而对于这个矩形在柱状图中可以扩展出的最大的一个矩形的面积就是 \(high[i] * (r[i] - l[i] - 1)\)

AC代码:

// #include <bits/stdc++.h>
#include <iostream>
#include <cstdio>
#include <cstring>
#include <stack>

using namespace std;

typedef long long ll;

int n, out1[100010], out2[100010];

ll num[100010], ans;

stack <ll> sta1, sta2;

int main() {
    while (1) {
        scanf("%d", &n);
        if (n == 0) {
            break ;
        }
        ans = 0ll;
        for (int i = 1; i <= n; i ++ ) {
            scanf("%lld", &num[i]);
            // 根据数据范围,此题需要longlong
        }
        num[n + 1] = num[0] = 0xefefefefefefefefll; // 两个扩展端点赋极小值
        while (!sta1.empty()) {
            sta1.pop();
        }
        while (!sta2.empty()) {
            sta2.pop();
        }
        // 注意在多组数据下每次应该把栈清空
        for (int i = 1; i <= n + 1; i ++ ) {
            while (! sta1.empty() && num[i] < num[sta1.top()]) {
                out1[sta1.top()] = i;
                sta1.pop();
            }
            sta1.push(i);
        }
        for (int i = n; i >= 0; i -- ) {
            while (! sta2.empty() && num[i] < num[sta2.top()]) {
                out2[sta2.top()] = i;
                sta2.pop();
            }
            sta2.push(i);
        }
        for (int i = 1; i <= n; i ++ ) {
            ll now = 1ll * num[i] * (out1[i] - out2[i] - 1ll);
            // 记录当前点构成的最大矩形面积
            ans = max(ans, now);
            // 统计答案
        }
        printf("%lld\n", ans);
    }
    return 0;
}

例题:洛谷 P1901 发射站

题目链接

题目大意:

​ 现在有 \(n\) 个发射站,每个发射站有一个高度 \(h_i\) 和能量值 \(v_i\)。每一个发射站都会向自己两侧发射能量值为 \(v_i\) 的能量,并且这两个能量只能被在两个方向上比他高且最近的发射站接收。求接收能量值最高的发射站接收了多少能量值。

分析:

​ 这道题和上一道题很类似,也可能会比上一道题更简单一些。我们考虑对于一个发射站,它所发射的能量一定会被 \(0,1\)\(2\) 个发射站接收到。所以我们需要找到它两边的最大值,这个过程可以使用两个单调递减栈维护(维护方式同上一个)。

​ 之后考虑在这道题的答案怎么统计。由于这道题的每一次接收的能量都会转化为对接收方的贡献。我们考虑当一个元素进栈的时候,一定是要比所有被他弹出的元素大,同时也一定能接收到这些发射站的能量。所以我们只要在每一次弹栈的时候都在即将入栈的这个元素的答案上加上接收到的能量值即可。

AC代码:

#include <bits/stdc++.h>

using namespace std;

typedef long long ll;

const int N = 1e6 + 10;

const ll inf = 0x3f3f3f3f3f3f;

#define high(i) (node[i].high)

#define val(i) (node[i].val)

#define ans(i) (node[i].ans)

int n;

struct Node {
    ll high, val, ans;
    // 注意数据范围,一定要开longlong
} node[N];

stack <int> sta1, sta2;
// 代表两个方向的两个单调递减栈
int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i ++ ) {
        scanf("%lld%lld", &high(i), &val(i));
    }
    node[0] = node[n + 1] = (Node){inf, 0ll, 0ll};
    for (int i = 1; i <= n + 1; i ++ ) {
        while (! sta1.empty() && high(i) > high(sta1.top())) {
            ans(i) += val(sta1.top());
            sta1.pop();
        }
        sta1.push(i);
    }
    for (int i = n; i >= 0; i -- ) {
        while (! sta2.empty() && high(i) > high(sta2.top())) {
            ans(i) += val(sta2.top());
            sta2.pop();
        }
        sta2.push(i);
    }
    ll maxx = 0ll;
    for (int i = 1; i <= n; i ++ ) {
        maxx = max(maxx, ans(i));
    }
    printf("%lld\n", maxx);
    return 0;
}

3. 多个区间中的最值(以某个数为最值的最长区间问题)

例题:POJ 2796 Feel Good / 洛谷 P2422 良好的感觉

题目链接 / 题目链接

题目大意:

​ 给出 \(Bill\) 连续 \(n\) 天的感受 \(a_1 \sim a_n\) 。定义他在一段时间内的心情是这段时间感受值的最小值乘上这段时间的感受值总和。求出 \(Bill\) 在哪段时间的心情最好(值最大),心情最好的时候的值是多少。

分析:

​ 首先我们要找到每一个区间和在这个区间当中的最小值才能计算出来这一段区间的心情值。但是显然如果我们枚举出每一个区间,复杂度是 \(O(n^2)\) 的。所以考虑如何使用单调栈降低复杂度。

​ 在之前的几道题当中,不难发现,如果我们维护一个单调递增的单调栈,当某个元素被弹出的时候,就找到了这个元素右边的第一个比他小的元素(正向模拟的情况)。但是考虑对于当前准备入栈的元素 \(now\) ,以及弹栈操作后栈内的栈顶元素 \(top\) ,显然这个 \(top\) 就是 \(now\) 在左边的第一个比他小的元素。而显然这个 \(now\) 能贡献的心情值就是他左边最小值到右边最小值(不包含这两个最小值)的这一段区间。所以维护一个单调递增栈,进行每个元素进栈一次、出栈一次,复杂度是 \(O(n)\) 的。

AC代码:

// #include <bits/stdc++.h>
#include <iostream>
#include <algorithm>
#include <cstdio>
#include <stack>

using namespace std;

const int N = 1e5 + 10;

typedef long long ll;

int n, num[N], l[N], r[N];

ll ans = 0ll, sum[N];

stack <int> sta1, sta2;

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i ++ ) {
        scanf("%d", &num[i]);
        sum[i] = sum[i - 1] + num[i] * 1ll;
    }
    num[0] = num[n + 1] = -2147483648;
    for (int i = 1; i <= n + 1; i ++ ) {
        while (! sta1.empty() && num[i] < num[sta1.top()]) {
            r[sta1.top()] = i;
            // 对于栈内元素,如果被某一个元素弹出,那么这个元素就是栈内元素的区间右端点
            sta1.pop();
        }
        int last;
        if (!sta1.empty()) {
            last = sta1.top();
            // 注意如果栈空时候访问top()会RE
        }
        l[i] = last;
        // 同时,在弹栈操作之后剩下的第一个栈顶元素就是当前入栈元素的左端点。
        sta1.push(i);
    }
    int a = 0, b = 0;
    for (int i = 1; i <= n; i ++ ) {
        ll now = 1ll * (sum[r[i] - 1] - sum[l[i]]) * num[i];
        if (now > ans) {
            ans = now;
            a = l[i] + 1;
            b = r[i] - 1;
            // 统计左右端点
        }
    }
    if (ans == 0) {
        a = 1, b = 1;
        // 特殊处理ans=0的情况
    }
    printf("%lld\n%d %d\n", ans, a, b);
    return 0;
}

4. 单调栈的一些特殊处理

例题:洛谷 P1823 [COI2007] Patrik 音乐会的等待

题目链接

题目大意:

\(N\) 个人正在排队进入一个音乐会。人们等得很无聊,于是他们开始转来转去,想在队伍里寻找自己的熟人。队列中任意两个人 \(A\)\(B\) ,如果他们是相邻或他们之间没有人比 \(A\)\(B\) 高,那么他们是可以互相看得见的。

写一个程序计算出有多少对人可以互相看见。

分析:

​ 显然我们要找到对于一个人右边第一个比他高的人(注意是比他高而不是和他相等)。由于两个一样高是可以看得见的,但是对于后边计算过程如果两个一样高是要弹栈的,在维护这个栈的时候显然我们是要维护一个严格递增的栈。但是我们思考如何处理两个或者多个一样高。

​ 这里显然我们可以开一个 \(val[]\) 或者使用 \(pair<>\) 提前处理出对于相邻的几个值是否高度相等(有几个高度相等)。在弹栈的时候在 \(ans\)\(+val[i]\) 或者 \(+ pair<>.second\) 即可。

AC代码:(蛮久之前的代码,这里使用的是手写栈,会比STL的栈快一些)

#include <bits/stdc++.h>

using namespace std;

typedef long long ll;

const int N = 500010;

int n, a[N], val[N], st[N];

int main() {
    // freopen("wait.in", "r", stdin);
    // freopen("wait.out", "w", stdout);

    scanf("%d", &n);
    for (int i = 1; i <= n; i ++ ) {
        scanf("%d", &a[i]);
        val[i] = 1;
    }
    int top = 0;
    ll ans = 0;
    for (int i = 1; i <= n; i ++ ) {
        while (top && a[st[top]] < a[i]) {
            ans += val[st[top]];
            top -- ;
        }
        while (top && a[st[top]] == a[i]) {
            ans += val[st[top]];
            val[i] += val[st[top]];
            top -- ;
        }
        if (top) {
            ans ++ ;
        }
        st[ ++ top] = i;
    }
    printf("%lld\n", ans);

    fclose(stdin);
    fclose(stdout);
    
    return 0;
}

三、单调栈总结

​ 单调栈的用途实际有很多,在进行处理的时候也很强大。尤其是在处理一个数列的大小关系上的时候,这个不太难的数据结构却有时候比一些很高级的数据结构做的更优秀。单调栈并不难,主要就是在如何想好为什么他可以优化、优化在哪里,也要理解好单调栈的整个实现过程。甚至于手写单调栈的时候的一些细节上的处理,都是单调栈的小坑。

​ 在有些题目中单调栈虽然可能只起到微乎其微的作用,但是没有他这道题可能就会 \(TLE\) 。而且什么时候选用单调栈、用哪一种单调栈,都是要看情况,看题目而言。

​ 掌握单调栈,一种比较基础的数据结构,有时候对于一道题就会有一条解决的方案。

四、队列结构及单调队列的概念

队列结构

​ 队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。

​ 如果说栈是一种 \(LIFO(last~in~first~out)\) 的线性结构,那么队列就是一种 \(FIFO(first~in~firse~out)\) 的线性结构。先进入队列的值先离开队列。如果按照顺序向队列中加入 \(1,~2,~3,~4,~5\) ,那么按照顺序出队的情况就应该是 \(1,~2,~3,~4,~5\)

MuUVCF.png

​ 对于一个队列的主要算法和栈一样,是入队和出队。入队是向队列尾部加入一个元素,而将队列中其他元素都向前推动一位;出队则是删除队首元素并更新队首指针。而在 C++ 语言的标准模板库($STL $)当中提供了处理好的队列。在使用的时候需要引用头文件:#include <queue>,入队是 \(que.push()\) ,出队是 \(que.pop()\) ,访问队首元素用 \(que.front()\)

单调队列

定义: 单调队列,就是一个符合单调性质的队列,它同时具有单调的性质以及队列的性质。他在编程中使用频率不高,但却占有至关重要的地位。 并且单调队列并不是严格符合上述队列结构的队列。在 \(STL\) 当中需要使用 \(deque\) (双端队列)来维护单调队列。

性质:

  • 队列具有单调性,即 递减 或 递增 。
  • 与队列有着相似的性质,只能从队头和队尾进行操作,需要手写或者使用双端队列维护。

操作:

​ 对于一个单调递增队列,若即将入队元素为 \(now\),队尾元素为 \(tail\),限定合法队列长度 \(k\) 。如果 \(now > tail\) 则直接入队;否则循环弹出队尾直到第一次出现 \(now > tail\) 为止。如果当前的队首元素 \(front\) 的时间戳已经不合法,则弹出队首元素。

​ 对于一个单调递减队列,若即将入队元素为 \(now\),队尾元素为 \(tail\),限定合法队列长度 \(k\) 。如果 \(now<tail\) 则直接入队;否则循环弹出队尾直到第一次出现 \(now<tail\) 为止。如果当前的队首元素 \(front\) 的时间戳已经不合法,则弹出队首元素。

下面用经典的滑动窗口来模拟一下单调(递增)队列:

MuRihd.png

​ 题目中的要求是找到在这一个数列当中所有长度为 \(3\) 的子区间当中的最小值。我们发现如果暴力搜索的话,复杂度会是 \(O(n^3)\) 的。

​ 我们来看模拟单调队列的结果。不难发现,每次加入一个数字后,整个队列的队首元素就是以当前这一个元素为结尾的这一个区间的最小值,也就是这一个区间的答案。

五、单调队列的一般应用与例题

例题:POJ 2823 滑动窗口

题目链接

题目大意:

​ 现在给出一个长度为 \(n\) 的数列,有一个可以容纳 \(k\) 个数字的窗口。这个窗口会从左边一直在数列上滑动到右边。现在请你求出每次窗口中这 \(k\) 个数字的最大值和最小值。

分析:

​ 这道题便是在上一框题中介绍单调队列时候所进行模拟的原型。在上边的讲解当中,我们发现当我们维护一个严格单调递增的队列的时候,每一次处理后的队首就是这一段区间内的最小值。同理,当我们维护一个严格单调递减的队列的时候,每一次处理后的队首就是这一段区间内的最大值。

​ 在考虑单调队列的时候,更多是在进行模拟。对于这一道题,希望大家也可以按照上边的那个图模拟一下单调递增的情况。

AC代码:

\(STL\) 版本:

// #include <bits/stdc++.h>
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <deque>

using namespace std;

const int N = 1e6 + 10;

int n, k, num[N];

int minn[N], maxx[N];

deque <int> deq_min, deq_max;

void Get_Min() {
    deq_min.push_back(1);
    minn[1] = num[1];
    for (int i = 2; i <= n; i ++ ) {
        while (! deq_min.empty() && num[i] < num[deq_min.back()]) {
            deq_min.pop_back();
            // 弹出队尾不合法的元素
        }
        deq_min.push_back(i);
        while (! deq_min.empty() && deq_min.front() < i - k + 1) {
            deq_min.pop_front();
            // 弹出队首时间戳过小的元素
        }
        minn[i] = num[deq_min.front()];
        // 统计答案
    }
}

void Get_Max() {
    deq_max.push_back(1);
    maxx[1] = num[1];
    for (int i = 2; i <= n; i ++ ) {
        while (! deq_max.empty() && num[i] > num[deq_max.back()]) {
            deq_max.pop_back();
            // 弹出队尾不合法的元素
        }
        deq_max.push_back(i);
        while (! deq_max.empty() && deq_max.front() < i - k + 1) {
            deq_max.pop_front();
            // 弹出队首时间戳过小的元素
        }
        maxx[i] = num[deq_max.front()];
        // 统计答案
    }
}

int main() {
    scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i ++ ) {
        scanf("%d", &num[i]);
    }
    Get_Max();
    Get_Min();
    if (n <= k) {
        // 对特殊情况进行特判
        printf("%d\n", num[deq_min.front()]);
        printf("%d\n", num[deq_max.front()]);
        return 0;
    }
    for (int i = k; i <= n; i ++ ) {
        printf("%d ", minn[i]);
    }
    printf("\n");
    for (int i = k; i <= n; i ++ ) {
        printf("%d ", maxx[i]);
    }
    printf("\n");
    return 0;
}

数组模拟:

// #include <bits/stdc++.h>
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 1e6 + 10;

int n, k;

int que_maxx[N], que_minn[N], num[N];

int maxx[N], minn[N];

int head_minn, tail_minn;

int head_maxx, tail_maxx;

void Get_Min() {
    head_minn = 1, tail_minn = 1;
    memset(que_minn, 0, sizeof que_minn);
    que_minn[tail_minn] = 1;
    minn[1] = num[1];
    for (int i = 2; i <= n; i ++ ) {
        while ((head_minn <= tail_minn) && (num[i] < num[que_minn[tail_minn]])) {
            tail_minn -- ;
            // 弹出队尾不合法的元素
        }
        que_minn[ ++ tail_minn] = i;
        while ((head_minn <= tail_minn) && (que_minn[head_minn] < i - k + 1)) {
            head_minn ++ ;
            // 弹出队首时间戳过小的元素
        }
        minn[i] = num[que_minn[head_minn]];
        // 统计答案
    }
}

void Get_Max() {
    head_maxx = 1, tail_maxx = 1;
    memset(que_maxx, 0, sizeof que_maxx);
    que_maxx[tail_maxx] = 1;
    maxx[1] = num[1];
    for (int i = 2; i <= n; i ++ ) {
        while ((head_maxx <= tail_maxx) && (num[i] > num[que_maxx[tail_maxx]])) {
            tail_maxx -- ;
            // 弹出队尾不合法的元素
        }
        que_maxx[ ++ tail_maxx] = i;
        while ((head_maxx <= tail_maxx) && (que_maxx[head_maxx] < i - k + 1)) {
            head_maxx ++ ;
            // 弹出队首不合法的元素
        }
        maxx[i] = num[que_maxx[head_maxx]];
        // 统计答案
    }
}

int main() {
    scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i ++ ) {
        scanf("%d", &num[i]);
    }
    Get_Min();
    Get_Max();
    if (n <= k) {
        // 特判情况
        cout << num[que_minn[head_minn]] << endl;
        cout << num[que_maxx[head_maxx]] << endl;
        return 0;
    }
    for (int i = k; i <= n; i ++ ) {
        printf("%d ", minn[i]);
    }
    printf("\n");
    for (int i = k; i <= n; i ++ ) {
        printf("%d ", maxx[i]);
    }
    printf("\n");
    return 0;
}

例题:洛谷 P1440 求m区间内的最小值

题目链接

题目大意:

一个含有 $n$ 项的数列($n\le2000000$),求出每一项前的 $m$ 个数到它这个区间内的最小值。若前面的数不足 $m$ 项则从第 $1$ 个数开始,若前面没有数则输出 $0$ 。 

分析:

​ 和上一题很类似,而且不需要求出最大值。注意一下这里是从第一个开始输出即可。

AC代码:

// #include <bits/stdc++.h>
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 2e6 + 10;

int n, k;

int que_maxx[N], que_minn[N], num[N];

int maxx[N], minn[N];

int head_minn, tail_minn;

int head_maxx, tail_maxx;

void Get_Min() {
    head_minn = 1, tail_minn = 1;
    memset(que_minn, 0, sizeof que_minn);
    que_minn[tail_minn] = 1;
    minn[1] = num[1];
    for (int i = 2; i <= n; i ++ ) {
        while ((head_minn <= tail_minn) && (num[i] < num[que_minn[tail_minn]])) {
            tail_minn -- ;
            // 弹出队尾不合法的元素
        }
        que_minn[ ++ tail_minn] = i;
        while ((head_minn <= tail_minn) && (que_minn[head_minn] < i - k + 1)) {
            head_minn ++ ;
            // 弹出队首时间戳过小的元素
        }
        minn[i] = num[que_minn[head_minn]];
        // 统计答案
    }
}

int main() {
    scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i ++ ) {
        scanf("%d", &num[i]);
    }
    Get_Min();
    for (int i = 0; i <= n - 1; i ++ ) {
        printf("%d\n", minn[i]);
    }
    return 0;
}

例题:BZOJ 1047: [HAOI2007]理想的正方形 / 洛谷 P2216 [HAOI2007]理想的正方形

题目链接 题目链接

题目大意:

有一个 $a \times b$ 的整数组成的矩阵,现请你从中找出一个$n\times n$的正方形区域,使得该区域所有数中的最大值和最小值的差最小。  

分析:

​ 首先我们发现这是一个二维的滑动窗口问题。我们首先要找出来对于每一行横向滑动窗口的最大最小值,而且要找到对于每一列纵向滑动窗口的最大最小值。

​ 那么我们思考怎么实现这个过程。

​ 首先我们先对于每一个横向进行一次滑动窗口求出一个最大值最小值,存在 \(minn[][]\)\(maxx[][]\) 当中。我们考虑这两个数组的意义:对于每一行来说,连续 \(n\) 个数的最大值最小值。那么换个角度来考虑,还能有什么意义:

​ 对于每一行来说,每一个合法区间对答案的贡献,或者说是这一个区间的答案

​ 那么我们考虑就把这个区间缩成这一个点,显然这是可以的,因为每个区间只有一个最大值贡献和最小值贡献。这时候我们完成了一个维度上的滑动窗口。

​ 那么我们考虑纵向滑动窗口。理解一下这句话:如果一个矩形完成了横向挤压,那么再完成纵向挤压就可以把这个矩形压为一个合法的点。

​ 而第二个维度就是在进行完横向的挤压后,似的原来宽为 \(n\) 的矩形现在等同于 \(minn[][]\) 和 $maxx[][] $中宽为 \(1\) 的矩形。这个时候在对这两个数组纵向的跑滑动窗口。最终就实现了把 \(n\times n\) 的一个矩形压成了一个 \(1 \times 1\) 的点,最终统计答案即可。

AC代码:

#include <bits/stdc++.h>

using namespace std;

const int N = 1010;

int n, m, k;

int num[N][N], que1[N], que2[N];

int head1, head2, tail1, tail2;

int minn[N][N], maxx[N][N];

int Minn[N][N], Maxx[N][N];

int main() {
    scanf("%d%d%d", &n, &m, &k);
    for (int i = 1; i <= n; i ++ ) {
        for (int j = 1; j <= m; j ++ ) {
            scanf("%d", &num[i][j]);
        }
    }
    for (int i = 1; i <= n; i ++ ) {
        head1 = head2 = 1;
        tail1 = tail2 = 1;
        que1[1] = que2[1] = 1;
        // 初始化
        for (int j = 2; j <= m; j ++ ) {
            while (head1 <= tail1 && num[i][j] >= num[i][que1[tail1]]) {
                tail1 -- ;
            }
            while (head2 <= tail2 && num[i][j] <= num[i][que2[tail2]]) {
                tail2 -- ;
            }
            // 横向压缩矩形,处理最大最小值。
            que1[ ++ tail1] = j;
            que2[ ++ tail2] = j;
            while (head1 <= tail1 && j - que1[head1] >= k) {
                head1 ++ ;
            }
            while (head2 <= tail2 && j - que2[head2] >= k) {
                head2 ++ ;
            }
            if (j >= k) {
                maxx[i][j - k + 1] = num[i][que1[head1]];
                minn[i][j - k + 1] = num[i][que2[head2]];
                // 更新minn[][]和maxx[][]
            }
        }
    }
    
    for (int i = 1; i <= m - k + 1; i ++ ) {
        head1 = head2 = 1;
        tail1 = tail2 = 1;
        que1[1] = que2[1] = 1;
        for (int j = 2; j <= n; j ++ ) {
            while (head1 <= tail1 && maxx[j][i] >= maxx[que1[tail1]][i]) {
                tail1 -- ;
            }
            while (head2 <= tail2 && minn[j][i] <= minn[que2[tail2]][i]) {
                tail2 -- ;
            }
            // 纵向压缩横向压缩后的矩形,处理最值
            que1[ ++ tail1] = j;
            que2[ ++ tail2] = j;
            while (head1 <= tail1 && j - que1[head1] >= k) {
                head1 ++ ;
            }
            while (head2 <= tail2 && j - que2[head2] >= k) {
                head2 ++ ;
            }
            if (j >= k) {
                Maxx[j - k + 1][i] = maxx[que1[head1]][i];
                Minn[j - k + 1][i] = minn[que2[head2]][i];
                // 更新最终的Maxx[][]和Minn[][]
            }
        }
    }
    int ans = 0x3f3f3f3f;
    for (int i = 1; i <= n - k + 1; i ++ ) {
        for (int j = 1; j <= m - k + 1; j ++ ) {
            ans = min(ans, Maxx[i][j] - Minn[i][j]);
        }
    }
    printf("%d\n", ans);
    return 0;
}

六、单调队列优化动态规划

单调队列与动态规划的关系

​ 在上边的讲解中我们发现了单调队列的一些用处:

  • 维护一个单调的队列
  • 维护区间最值

​ 那么考虑动态规划的松弛过程。如果松弛变量 \(j\) 有一个固定长度的取值范围,我们可以在这个范围内维护一个单调队列(考虑滑动窗口),而在 \(O(1)\) 的时间找到队头元素直接转移即可。这样会在时间上压下来一维,也正是单调队列优化动态规划的意义所在。

​ 而单调队列一般都是优化线性DP,形如 \(f[i] = max/min(f[j])+val[i]\) 并且满足 \(j < i,~~val[i]\)\(f[j]\) 无关。这时候我们就可以优化 \(f[j]\) 。这时候由于我们每次都可以直接找到符合答案的 \(f[j]\),我们只需要枚举一次 \(i\) ,而压掉了 \(j\) 的一维。

例题

JDOJ 1056: 烽火传递

题目链接

题目大意:

​ 烽火台又称烽燧,是重要的军事防御设施,一般建在险要或交通要道上。一旦有敌情发生,白天燃烧柴草,通过浓烟表达信息;夜晚燃烧干柴,以火光传递军情,在某两座城市之间有n个烽火台,每个烽火台发出信号都有一定代价。为了使情报准确地传递,在连续m个烽火台中至少要有一个发出信号。请计算总共最少花费多少代价,才能使敌军来袭之时,情报能在这两座城市之间准确传递。

分析:

​ 很显然我们会发现这道题的转移方程:
\[ f[i] = min\{f[j]\}+num[i]~~~~~~~~k\in[i-m,~i-1] \]
​ 但是如果我们在更新每一个 \(i\) 的时候都去循环找那个合法的 \(j\) ,就会变成 \(O(n^2)\)。而考虑之前做过的滑动窗口,我们一定是可以通过使用单调队列的优化使得这里可以 \(O(1)\) 时间内直接取到最优的 \(f[j]\) 队首元素。所以在 DP 的过程中动态维护一个单调队列即可。

AC代码:

#include <bits/stdc++.h>

using namespace std;

const int N = 1e5 + 10;

int n, m, f[N];

int que[N], val[N], l, r;

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i ++ ) {
        scanf("%d", &val[i]);
    }
    f[0] = 0;
    l = r = 0;
    que[1] = f[1];
    for (int i = 1; i <= n; i ++ ) {
        while (l <= r && i - que[l] > m) {
            l ++ ;
        }
        // 把时间戳过于靠前的元素弹出
        f[i] = f[que[l]] + val[i];
        while (l <= r && f[i] < f[que[r]]) {
            r -- ;
        }
        // 处理队尾
        que[ ++ r] = i;
    }
    int ans = 0x3f3f3f3f;
    for (int i = n - m + 1; i <= n; i ++ ) {
        ans = min(ans, f[i]);
    }
    printf("%d\n", ans);
    return 0;
}

LOJ #10176. 「一本通 5.5 例 2」最大连续和 / 洛谷 P1714 切蛋糕

题目链接 题目链接

题目大意:

​ 给你一个长度为 \(n\) 的整数序列 \(\{A_1,A_2,A_3 \dots A_n\}\),要求从中找出一段连续的长度不超过 \(m\) 的子序列,使得这个序列的和最大。

分析:

​ 首先我们可以很轻松找到这道题的转移方程:
\[ f[i] = max\{sum[i] - sum[i- k]\} ~~~~~~k\in[1,m]\\ ~~~~~~= sum[i] - min\{sum[i-k]\} ~~~~~~~k\in[1,m] \]
​ 所以我们可以通过单调队列维护最小的 \(sum[i-k]\) 即可。

AC代码:

#include <bits/stdc++.h>

using namespace std;

const int N = 3e5 + 10;

int n, m, f[N];

int num[N], sum[N], que[N];

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i ++ ) {
        scanf("%d", &num[i]);
        sum[i] = sum[i - 1] + num[i];
        // 求前缀和
    }
    int l = 0, r = 0;
    for (int i = 1; i <= n; i ++ ) {
        while (l <= r && i - que[l] > m) {
            l ++ ;
        }
        f[i] = sum[i] - sum[que[l]];
        while (l <= r && sum[que[r]] >= sum[i]) {
            // 维护一个单调递减的队列
            r -- ;
        }
        que[ ++ r] = i;
    }
    int ans = -0x3f3f3f3f;
    for (int i = 1; i <= n; i ++ ) {
        ans = max(ans, f[i]);
    }
    printf("%d\n", ans);
    return 0;
}

洛谷 P1725 琪露诺

题目链接

题目大意:

​ 见题意。

分析:

​ 这道题多出来了一个 \(l\) 和一个 \(r\) 来限定这里的 \(dp[j]\) 。所以我们在维护单调队列的时候要注意是从 $l $ 维护到 \(r\) 。所以这里队列的过程是要从 \(l\) 开始的,我们用 \(pos\) 来维护当前能够达到的最近距离,接下来维护滑动窗口优化DP即可。

AC代码:

#include <bits/stdc++.h>

using namespace std;

const int N = 2e5 + 10;

const int inf = 0x3f3f3f3f;

int n, l, r, num[N];

int que[N], f[N];

int main() {
    scanf("%d%d%d", &n, &l, &r);
    for (int i = 0; i <= n; i ++ ) {
        scanf("%d", &num[i]);
    }
    int head = 1, tail = 1, ans = -inf;
    for (int i = 1; i <= n; i ++ ) {
        f[i] = -inf;
    }
    int pos = 0;
    // 维护最近距离
    for (int i = l; i <= n; i ++ ) {
        while (head <= tail && f[que[tail]] < f[pos]) {
            tail -- ;
        }
        que[ ++ tail] = pos;
        while (head <= tail && i - que[head] > r) {
            head ++ ;
        }
        f[i] = f[que[head]] + num[i];
        pos ++ ;
    }
    // 单调队列
    for (int i = n - r + 1; i <= n; i ++ ) {
        ans = max(ans, f[i]);
    }
    printf("%d\n", ans);
    return 0;
}

其他题目

Vijos P1243 生产产品

POJ 1742 Coins

Codeforces372C Watching Fireworks is Fun

七、单调队列总结

​ 单调队列,看似很简单,很好想的一个数据结构,却可以做许多事情。简单的滑动窗口可能并没有太高的思维难度、局限性又很大的一道题目,可是在这些题中却没有一道可以离得开它。从滑动窗口到理解单调队列,再到对数据结构单调性的理解就是这么逐步逐步的积累起来的。

​ 单调队列看起来并不是一个很重要或者很常用的东西。当时往往在需要它的时候,它的确可以派的上大用场。在它优化动态规划的时候,可能只有那么短短的几行,简单的模拟,压掉的却是 \(n\) 次的循环。对于DP来说,单调队列也不妨是一种解决问题的思考方式,或者是帮助解决问题的很好的工具。

​ 单调性其实有的时候总是可以帮助我们在解决一个问题上走上一些捷径,无论是单调栈、单调队列还是其他的单调结构,都是一种方法、一种工具。熟悉和使用单调队列也可以说为自己在解题的时候增加了一种想法。

参考与应用的部分网站:(乱序)

https://loj.ac

http://poj.org

https://vijos.org

https://www.luogu.org

https://baike.baidu.com

https://www.cnblogs.com/mcomco/p/10108694.html

https://www.cnblogs.com/xiefengze1/p/8495272.html

https://www.cnblogs.com/Cindy-Chan/p/11203791.html

https://www.cnblogs.com/ECJTUACM-873284962/p/7125130.html

https://www.cnblogs.com/Dxy0310/p/9742045.html

猜你喜欢

转载自www.cnblogs.com/littleseven777/p/11853980.html