双端队列与单调队列

双端队列

双端队列是一种具有队列和栈性质的数据结构,它能在两端进行插入和删除,而且也只能在两端插入和删除。

STL 中的双端队列可以用 deque,头文件为 <deque>,它的用法如下:

  1. dq[i] 返回队列中下标为 i 的元素
  2. dq.front() 返回队头
  3. dq.back() 返回队尾
  4. dq.pop_back() 删除队尾,不返回值
  5. dq.pop_front() 删除队头,不返回值
  6. dq.push_back(x) 在队尾添加一个元素 x
  7. dq.push_front(x) 在队头添加一个元素 x

当然双端队列也可以手写,如果不需要 push_front 下标可以直接从 \(0\) 开始用,用 st 表示队首位置,ed 表示队尾位置(假设队列中元素位置为 st~ed-1),push_back 相当于 q[ed++]=xpop_back 相当于 ed--,取队尾相当于 q[ed-1],取队首相当于 q[st]pop_front 相当于 st++,这些操作时间复杂度都是 \(O(1)\) 的。注意,对于双端队列的 pop_front 来说,需要 \(O(1)\) 的时间复杂度,而 vectorpop_front 时间复杂度是 \(O(n)\) 的,因此不能用 vector 来实现。而如果手写的场景需要 push_front,可以从中间的数组下标开始用,在刚开始把队首和队尾放在中间,这样就可以往两边拓展了,相当于整体偏移,push_front 相当于 q[--st]=x

std::deque 底层原理

image

deque 底层的机制实际上是由一段一段的定量连续空间组成,因此在算法竞赛中针对 deque 有一个需要注意的点是:哪怕 deque 中只添加了一个元素,其实际还是需要消耗“一段”的内存,因此当题目中需要很多双端队列时建议用 STL 中的 list,list 本质上就是双向链表,除了不支持下标访问以外,其头尾操作的用法与 deque 一样,而双向链表显然可以模拟双端队列的操作。

例题:B3656 【模板】双端队列 1

题中需要维护百万级别个双端队列,由前面的底层原理讲解可知,即便每个双端队列中只有一个元素,其消耗的内存并不只是这一百万个元素的内存消耗,还需要乘上“一段”的大小,因此本题如果使用 deque dq[N] 这样的数组会导致内存空间溢出,即 MLE,而用 STL 中的 list(双向链表)来模拟则消耗的内存包括这最多一百万个元素的消耗以及每个元素附带的前驱、后继指针,因此是符合要求的。

#include <cstdio>
#include <list>
using namespace std;
const int N = 1000005;
char op[15];
list<int> dq[N];
int main()
{
    int q; scanf("%d", &q);
    while (q--) {
        scanf("%s", op); 
        int a; scanf("%d", &a);
        if (op[0] == 's') {
            int sz = dq[a].size();
            printf("%d\n", sz);
        } else if (op[0] == 'f') {
            if (!dq[a].empty()) printf("%d\n", dq[a].front());
        } else if (op[0] == 'b') {
            if (!dq[a].empty()) printf("%d\n", dq[a].back());
        } else if (op[1] == 'o') {
            if (!dq[a].empty()) {
                if (op[4] == 'b') dq[a].pop_back();
                else dq[a].pop_front();
            }
        } else if (op[1] == 'u') {
            int x; scanf("%d", &x);
            if (op[5] == 'b') dq[a].push_back(x);
            else dq[a].push_front(x);
        }
    }
    return 0;
}

习题:P10457 占卜DIY

解题思路

题目涉及频繁的堆顶插入堆底取出,以及堆顶取出(生命牌)操作。std::deque(双端队列)是一个非常合适的数据结构,它支持 \(O(1)\) 时间复杂度的头部和尾部增删操作,可以使用 deque 数组来模拟 13 堆牌。

参考代码
#include <cstdio>
#include <deque>
using namespace std;

// 扑克牌结构体
struct Card {
    int val; // 牌的点数
    bool up; // 牌是否正面朝上
};

char s[2];
// 13 堆牌,使用 deque(双端队列)存储,方便从顶部插入和从底部移除
// 下标 1-13 对应 13 堆,第 13 堆为生命牌堆
deque<Card> p[14]; 

// 将字符转换为扑克牌的点数
int get(char ch) {
    if (ch == '0') return 10;
    if (ch == 'A') return 1;
    if (ch == 'J') return 11;
    if (ch == 'Q') return 12;
    if (ch == 'K') return 13;
    return ch - '0';
}

int main()
{
    // 输入 13 堆牌,每堆 4 张
    for (int i = 1; i <= 13; i++) {
        for (int j = 0; j < 4; j++) {
            scanf("%s", s);
            // 初始状态牌都是背面朝上 (up = false)
            // push_back 保持输入的从上到下顺序 (front为顶, back为底)
            p[i].push_back({get(s[0]), false});
        }
    }

    int lives = 0; // 记录死去的生命数,共有 4 条命 (即遇到 4 次 K 结束) 
    
    // 初始动作:从生命牌堆(第 13 堆)的最上面(front)抽取一张
    Card cur = p[13].front();
    p[13].pop_front();

    while (lives < 4) {
        if (cur.val == 13) { 
            // 如果抽到的牌是 K (点数为 13)
            lives++; // 失去一条命
            // 如果还没死够 4 次,重新从生命牌堆(第 13 堆)抽取最上面一张开始
            if (lives < 4) {
                cur = p[13].front();
                p[13].pop_front();
            }
        } else {
            // 如果抽到的不是 K
            int t = cur.val;
            
            // 把这张牌正面朝上 (up = true) 放到对应编号堆 (t) 的最上面 (push_front)
            p[t].push_front({t, true});
            
            // 然后从刚放了牌的那一堆的最底下 (back) 抽取一张牌
            cur = p[t].back();
            p[t].pop_back();
        }
    }

    int ans = 0;
    // 游戏结束,统计有多少对牌“开”了
    // 遍历前 12 堆 (K 堆不算)
    for (int i = 1; i <= 12; i++) {
        int cnt = 0;
        // 统计每一堆中正面朝上的牌的数量
        for (const Card& c : p[i]) {
            if (c.up) cnt++;
        }
        // 如果某一堆 4 张牌都正面朝上,则计数加 1
        if (cnt == 4) ans++;
    }
    printf("%d\n", ans);
    return 0;
}

习题:P7915 [CSP-S 2021] 回文

解题思路

每一步只能从 \(\{ a \}\) 的两端之一取一个数字加到 \(\{b\}\) 的末尾,假设第一步选择了 L 操作,此时必然存在另一个 \(a_x=a_1\),而由于 \(b_1\) 对应 \(a_1\),那么 \(b_{2n}\) 就要对应 \(a_x\),即 \(a_x\) 作为 \(\{ a \}\) 中最后一个被取的。此时 \(a\) 被分成两个部分,一块是 \(a_2 \rightarrow a_{x-1}\),另一块是 \(a_{x+1} \rightarrow a_{2n}\),而下一步 \(a\) 中要取的要么是 \(a_2\) 要么是 \(a_{2n}\),由于需要构造出回文,则倒数第二次操作对应的应该是 \(a_{x-1}\)\(a_{x+1}\),因此只需要分析两次操作元素的对应关系即可决定选择 L 操作还是 R 操作(显然,若两操作均不可行,说明接下来无法再操作;若只能进行其中一种操作那必然选择该操作;若两操作都可行,由于要求字典序最小,优先 L 操作)。

同理,如果第一步操作选择 R 也会有一个类似的分析过程,总体的时间复杂度为 \(O(n)\)

image

#include <cstdio>
#include <deque>
using namespace std;
const int N = 1000005;
int a[N], ans[N], n; // ans[] 0: L, 1: R
deque<int> left, right;
void update(int i, int op1, int op2) {
    ans[i] = op1; ans[n - i + 1] = op2;
}
bool check() {
    for (int i = 2; i <= n / 2; i++) {
        // try L
        if (!left.empty()) {
            // try L...L
            if (left.size() > 1 && left.front() == left.back()) {
                update(i, 0, 0); left.pop_front(); left.pop_back();
                continue;
            }
            // try L...R
            if (!right.empty() && left.front() == right.front()) {
                update(i, 0, 1); left.pop_front(); right.pop_front();
                continue;
            }
        } 
        // try R
        if (!right.empty()) {
            // try R...L
            if (!left.empty() && right.back() == left.back()) {
                update(i, 1, 0); right.pop_back(); left.pop_back();
                continue;
            }
            // try R...R
            if (right.size() > 1 && right.back() == right.front()) {
                update(i, 1, 1); right.pop_back(); right.pop_front();
                continue;
            }
        }
        return false;
    }
    return true;
}
void print_ans() {
    for (int i = 1; i <= n; i++) printf("%c", "LR"[ans[i]]);
    printf("\n");
}
int main()
{
    int t; scanf("%d", &t);
    while (t--) {
        scanf("%d", &n); n *= 2;
        for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
        // first L
        // find the element == a[1] and split into two parts
        left.clear(); right.clear();
        int idx = 1; update(1, 0, 0);
        for (int i = 2; i <= n; i++) {
            if (a[i] == a[1]) idx = i;
            else if (idx == 1) left.push_back(a[i]);
            else right.push_back(a[i]);
        }
        if (check()) {
            print_ans(); continue;
        }
        // first R
        left.clear(); right.clear(); idx = n; update(1, 1, 0);
        for (int i = 1; i < n; i++) {
            if (a[i] == a[n]) idx = i;
            else if (idx == n) left.push_back(a[i]);
            else right.push_back(a[i]);
        }
        if (check()) print_ans();
        else printf("-1\n");
    }
    return 0;
}

例题:P10465 双端队列

因为最终拼接成的是非降序列,这意味着所有的数在最终的序列中是按照数值从小到大排列的。

如果考虑最终有序序列中每个元素对应的原始输入下标,会发现一个双端队列对应的子序列必须满足特定的形状。

对于单个双端队列,元素是依次按原输入顺序进入的。如果将一个新元素放入队头,它的原始下标肯定大于队列中现有的元素(因为它是后输入的),且它在有序序列中排在前面,这意味着在有序序列中,这部分元素的原始下标是递减的。如果将一个新元素放入队尾,它的原始下标也大于队列中现有的元素,且它在有序序列中排在后面,这意味着在有序序列中,这部分元素的原始下标是递增的。因此,一个双端队列在最终有序序列中对应的原始下标序列,形状必然是先单调递减,再单调递增,将其称为“单谷形”。

为了使队列数量最少,需要尽可能让当前的一个双端队列容纳更多的元素。

首先对所有元素进行排序(数值相同按原下标排序),得到最终的目标序列。然后遍历这个序列,观察其原始下标的变化,尝试将其划分为尽可能少的“单谷”片段。

在处理过程中,需要关注数值相同的元素。对于数值相同的元素,它们在最终序列中是相邻的,且因为值相等,可以任意安排它们内部的顺序(实际上是安排它们谁先谁后,从而决定它们是放在下降段还是上升段)。

遍历排序后的数组,每次取出一组数值相同的元素,设这组元素中最小的下标为 \(\min\),最大的下标为 \(\max\)

  1. 如果当前处于下降阶段,希望尽可能维持下降趋势,因为下降之后还可以转为上升,灵活性更大。设当前队列中上一个元素的原始下标为 \(L\),如果 \(L \gt \max\),说明这一整组数值相同的元素,都能以“逆序”(从 \(\max\)\(\min\))的方式接在后面,且仍保持下标递减(\(L \gt \max \gt \cdots \gt \min\)),于是将 \(L\) 更新为 \(\min\),状态保持下降。否则,无法保持全程递减,说明谷底到了,需要转入上升阶段,将这组元素按“顺序”(从 \(\min\)\(\max\))排列,接在后面,于是 \(L\) 应更新为 \(\max\),状态变为上升。
  2. 如果当前处于上升阶段,只能维持上升趋势(一旦开始上升,就不能再下降了,否则就不满足单谷性质)。如果 \(L \lt \min\),说明这组元素可以按“顺序”(从 \(\min\)\(\max\))接在后面,保持递增(\(L \lt \min \lt \cdots \lt \max\)),于是 \(L\) 更新为 \(\max\),状态保持上升。否则,这组元素无法接在当前队列后面了(破坏了单调性),需要开启一个新的双端队列。新队列默认从下降阶段开始,并将这组元素以递减方式放入,更新 \(L = \min\)
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;

const int N = 200005;

struct Element {
    int v, id; // v 为数值,id 为该数在原序列中的下标
};

Element arr[N];

// 排序规则:数值从小到大;数值相同时,下标从小到大
bool cmp(const Element& a, const Element& b) {
    if (a.v != b.v) return a.v < b.v;
    return a.id < b.id;
}

int main()
{
    int n; scanf("%d", &n);
    for (int i = 0; i < n; i++) {
        scanf("%d", &arr[i].v);
        arr[i].id = i;
    }

    // 1. 首先对元素进行排序。因为最终要得到非降序列,所以必须按数值从小到大的顺序处理。
    // 2. 一个双端队列能承载的元素在原序列中的下标 id 必须满足“单谷”性质:即下标先单调递减,再单调递增。
    //    这是因为双端队列支持头插(下标递减)和尾插(下标递增)。
    sort(arr, arr + n, cmp);

    int ans = 1;    // 最少需要的双端队列数量
    int last = n;   // 记录上一个放入队列的元素在原序列中的下标,初始设为极大值
    int i = 0;
    bool down = true;   // 当前状态:true 表示正在处理单谷的下降段,false 表示正在处理上升段

    while (i < n) {
        int j = i;
        // 找到数值相同的一组元素,这些元素可以按原下标任意排列以满足单调性
        while (j < n && arr[j].v == arr[i].v) j++;
        
        // mn 和 mx 分别是这一组相同数值元素中,原下标的最小值和最大值
        int mn = arr[i].id, mx = arr[j - 1].id;

        if (down) {
            // 处于下降趋势时
            if (last > mx) {
                // 如果上一个下标大于这组数的最大下标,则这组数可以全部降序放入,继续保持下降趋势
                last = mn;
            } else {
                // 否则无法保持下降,必须转向单谷的上升段,将这组数升序放入
                down = false;
                last = mx;
            }
        } else {
            // 处于上升趋势时
            if (last < mn) {
                // 如果上一个下标小于这组数的最小下标,则这组数可以全部升序放入,继续保持上升趋势
                last = mx;
            } else {
                // 否则无法放入当前双端队列(无法维持单谷形态),必须新开一个双端队列
                ans++;
                down = true; // 新队列重新从下降段开始
                last = mn;
            }
        }
        i = j;
    }

    printf("%d\n", ans);
    return 0;
}

单调队列

单调队列是一种内部元素具有单调性的队列,当区间的左右端点都单调递增变化时,可以用单调队列维护“区间内最值”。

被单调队列了。

这句话经常在有人比你小又比你强时使用。这是一个非常形象的比喻,以单调队列维护区间最大值为例,如果有个数 \(a_i\)\(a_j\) 后面,并且 \(a_i > a_j\),那么当区间包含 \(a_i\) 时,\(a_j\) 就再也不可能成为区间最大值了。这就是单调队列维护区间最值的核心思想。

例题:P1886 滑动窗口 /【模板】单调队列

分析:暴力枚举的方式是进行 \(n-k\) 次循环,每次查找长度为 \(k\) 区间的最值,这样的算法时间复杂度是 \(O(nk)\) 的,无法通过这个题目。

以下分析以最大值为例,最小值同理。可以建立一个队列来维护这些数据,队首在左,队尾在右。首先队列为空,将元素加入到队列中。如果接下来的元素比队尾元素更大,那么将队尾元素出队,直到这个元素不大于队尾元素为止。当处理到的数据下标大于等于 \(k\),说明已经处理完了 \(k\) 个数字,可以查询这个区间的最大值了。这里的最大值就是队首元素。

在每次新加入元素之前,先要检查队首元素是否过期(不再处于需要统计的区间中):如果队首元素的下标小于等于 \(i-k\),说明队首元素过期了,此时队首元素就需要出队。

例如数据依次为 \([3,19,1,12,5,8,10,6]\),而 \(k=4\),维护过程如下:
image

这个队列不仅可以从队尾入队、队首出队,还可以从队尾出队(想象一个队伍在排队,排在最后的人发现后面要加入队伍的人惹不起,主动放弃了排队)。使用这种队列,保障队列内的元素具有单调性,这种队列被称为单调队列

每个元素最多入队一次、出队一次,且出入队时间复杂度都为 \(O(1)\),因此总时间复杂度为 \(O(n)\)

#include <cstdio>
#include <deque>
using namespace std;
const int N = 1000005;
int a[N];
int main()
{
    int n, k; scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
    deque<int> dq; // 单调队列中存的实际上是元素在序列中的下标
    for (int i = 1; i <= n; i++) {
        while (!dq.empty() && dq.front() <= i - k) dq.pop_front();
        while (!dq.empty() && a[dq.back()] >= a[i]) dq.pop_back();
        dq.push_back(i);
        if (i >= k) printf("%d%c", a[dq.front()], i == n ? '\n' : ' ');
    }
    dq.clear();
    for (int i = 1; i <= n; i++) {
        while (!dq.empty() && dq.front() <= i - k) dq.pop_front();
        while (!dq.empty() && a[dq.back()] <= a[i]) dq.pop_back();
        dq.push_back(i);
        if (i >= k) printf("%d%c", a[dq.front()], i == n ? '\n' : ' ');
    }
    return 0;
}

因为要知道队首元素是否过期,单调队列里面存储的实际上是下标而不是数据本身。

习题:P2032 扫描

解题思路

模板题,求区间长度为 \(k\) 的滑动窗口最大值。

#include <cstdio>
#include <deque>
using namespace std;
const int N = 2000005;
int a[N];
int main()
{
    int n, k; scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
    deque<int> dq;
    for (int i = 1; i <= n; i++) {
        while (!dq.empty() && dq.front() <= i - k) dq.pop_front();
        while (!dq.empty() && a[dq.back()] <= a[i]) dq.pop_back();
        dq.push_back(i);
        if (i >= k) printf("%d\n", a[dq.front()]);
    }
    return 0;
}

习题:B3667 求区间所有后缀最大值的位置

解题思路

实际上区间内后缀最大值的个数就是用单调队列求滑动窗口最大值时单调队列中元素的个数。

#include <cstdio>
#include <deque>
using namespace std;
typedef unsigned long long ULL;
const int N = 1000005;
ULL a[N];
int main()
{
    int n, k; scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i++) scanf("%llu", &a[i]);
    deque<int> dq;
    for (int i = 1; i <= n; i++) {
        // [i-k+1, i]
        while (!dq.empty() && dq.front() <= i - k) dq.pop_front();
        while (!dq.empty() && a[dq.back()] <= a[i]) dq.pop_back();
        dq.push_back(i);
        if (i >= k) printf("%d\n", int(dq.size()));
    }
    return 0;
}

例题:P1714 切蛋糕

限制最大长度的最大子段和问题:在一段长为 \(n\) 的数列中,找出一个长度大于等于 \(1\) 且小于等于 \(m\) 的子段,使得它的和最大。

解题思路

回顾普通的最大子段和问题,可以用前缀和思想解决,对于第 \(i\) 个元素结尾的子段,其最大长度限定的最大子段和可以表示为 \(\max \{sum_i-sum_j\}\),其中 \(j \lt i\)

minsum = 0;
for (int i = 1; i <= n; i++) { // 枚举右端点
    ans = max(ans, sum[i] - minsum);
    minsum = min(minsum, sum[i]);
}

对于本题来说,相当于改成 \(j\) 属于 \([i-m, i-1]\)。如果暴力扫描所有 \(j\) 的可能,则时间复杂度为 \(O(nm)\),无法通过题目。

注意每次的 \(sum_i\) 实际上是个定值,所以实际上是去最小化 \(sum_j\),针对 \(j\) 的取值范围,可以发现 \(j\) 实际上用到的范围依次是 \([0,0], [0,1], \dots, [0,m-1], [1,m], [2,m+1], [3,m+2], [4,m+3], \dots\)。因此用单调队列维护一个滑动窗口的最小值即可,每个点只会进出各一次,最终时间复杂度 \(O(n)\)。实现时有两种方法,可以先完成这次的计算,再放 \(i\),也可以在 \(i\) 来的时候,把 \(i-1\) 放进去,再进行计算。

参考代码
#include <cstdio>
#include <deque>
using namespace std;
const int N = 500005;
const int INF = 1e8;
int p[N], sum[N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &p[i]); sum[i] = sum[i - 1] + p[i];
    }
    deque<int> dq;
    dq.push_back(0);
    int ans = -INF;
    for (int i = 1; i <= n; i++) {
        while (!dq.empty() && dq.front() < i - m) dq.pop_front();
        ans = max(ans, sum[i] - sum[dq.front()]);
        while (!dq.empty() && sum[dq.back()] >= sum[i]) dq.pop_back();
        dq.push_back(i);
    }
    printf("%d\n", ans);
    return 0;
}

习题:P2216 [HAOI2007] 理想的正方形

有一个 \(a \times b\) 的整数组成的矩阵,现请你从中找出一个 \(n \times n\) 的正方形区域,使得该区域所有数中的最大值和最小值的差最小。
保证 \(2 \le a,b \le 1000, n \le a, n \le b, n \le 100\),矩阵的元素均为不超过 \(10^9\) 的非负整数。

解题思路

分析:暴力枚举的方式是枚举所有符合要求的小正方形,每次在正方形中查找最小最大值并统计,这样时间复杂度是 \(O(n^2 (a-n+1)(b-n+1))\),不能通过这个题目。

如何求出一个正方形矩阵的最大值呢?可以将这个正方形的每一行数字求最大值,然后再求每一行最大值中的最大值,剩下的一个值就是这个正方形矩阵的最大值。

image

以样例为例,首先处理每一行,找出一行中每一个长度为 \(2\) 的区间的最大值;然后处理每一列,找到一列中每一个长度为 \(2\) 的区间的最大值。最后得到的矩阵中的一个元素就对应着一个 \(2 \times 2\) 的正方形的最大值。使用相同的方式求得每个 \(2 \times 2\) 正方形的最小值,然后遍历这两个矩阵,计算这两个数的极差并统计即得到答案。

image

可以使用单调队列来求指定区间的最值。

参考代码
#include <cstdio>
#include <deque>
#include <algorithm>
using namespace std;
const int N = 1005;
const int INF = 1000000000;
int mat[N][N], r1[N][N], r2[N][N], mn[N][N], mx[N][N];
int main()
{   
    int a, b, n;
    scanf("%d%d%d", &a, &b, &n);
    for (int i = 1; i <= a; i++)
        for (int j = 1; j <= b; j++)
            scanf("%d", &mat[i][j]);
    deque<int> qmin, qmax;
    for (int i = 1; i <= a; i++) {
        // 求每行区间最大、最小值
        qmin.clear(); qmax.clear();
        for (int j = 1; j < n; j++) {
            while (!qmin.empty() && mat[i][qmin.back()] >= mat[i][j]) 
                qmin.pop_back();
            qmin.push_back(j);
            while (!qmax.empty() && mat[i][qmax.back()] <= mat[i][j]) 
                qmax.pop_back();
            qmax.push_back(j);
        }
        for (int j = 1; j <= b - n + 1; j++) {
            while (!qmin.empty() && qmin.front() < j) 
                qmin.pop_front();
            while (!qmin.empty() && mat[i][qmin.back()] >= mat[i][j + n - 1]) 
                qmin.pop_back();
            qmin.push_back(j + n - 1);
            r1[i][j] = mat[i][qmin.front()];
            while (!qmax.empty() && qmax.front() < j) 
                qmax.pop_front();
            while (!qmax.empty() && mat[i][qmax.back()] <= mat[i][j + n - 1]) 
                qmax.pop_back();
            qmax.push_back(j + n - 1);
            r2[i][j] = mat[i][qmax.front()];
        }
    }
    for (int i = 1; i <= b - n + 1; i++) {
        // 在刚刚得到的新矩阵的基础上,求每列区间最大、最小值
        qmin.clear(); qmax.clear();
        for (int j = 1; j < n; j++) {
            while (!qmin.empty() && r1[qmin.back()][i] >= r1[j][i]) 
                qmin.pop_back();
            qmin.push_back(j);
            while (!qmax.empty() && r2[qmax.back()][i] <= r2[j][i])
                qmax.pop_back();
            qmax.push_back(j);
        }
        for (int j = 1; j <= a - n + 1; j++) {
            while (!qmin.empty() && qmin.front() < j) 
                qmin.pop_front();
            while (!qmin.empty() && r1[qmin.back()][i] >= r1[j + n - 1][i]) 
                qmin.pop_back();
            qmin.push_back(j + n - 1);
            mn[j][i] = r1[qmin.front()][i];
            while (!qmax.empty() && qmax.front() < j) 
                qmax.pop_front();
            while (!qmax.empty() && r2[qmax.back()][i] <= r2[j + n - 1][i]) 
                qmax.pop_back();
            qmax.push_back(j + n - 1);
            mx[j][i] = r2[qmax.front()][i];
        }
    }
    int ans = INF;
    for (int i = 1; i <= a - n + 1; i++)
        for (int j = 1; j <= b - n + 1; j++)
            ans = min(ans, mx[i][j] - mn[i][j]);
    printf("%d\n", ans);
    return 0;
}

习题:P7546 [BJWC2014] 珠链

给定一个长度为 \(N \ (1 \le N \le 5 \times 10^5)\) 的珠链,每个位置 \(i\) 的珠子有一个魔力值,等于 \(i\) 的约数个数 \(d(i)\)。Alex 需要从中截取一段连续的珠链,使其成为“纯净珠链”,纯净珠链的定义:

  1. 可以分成若干个长度相等的段,每段长度 \(\text{len}\) 满足 \(L \le \text{len} \le R \ (1 \le L,R \le N, \ 0 \le R-L \le 10)\)
  2. 划分的段数 \(k\) 满足 \(k \ge S \ (1 \le S \le N)\)
  3. 关键条件:任何两段在相同相对位置的珠子颜色(用输入的大/小写字母给出)必须不同,即对于任意段 \(i,j \ (i \ne j)\) 和相对位置 \(p \in [0, \text{len}-1]\),第 \(i\) 段的第 \(p\) 个珠子与第 \(j\) 段的第 \(p\) 个珠子颜色不同。

目标是使截取的纯净珠链魔力值之和最大,如果不存在,输出 \(-1\)

解题思路

珠子在原序列位置 \(i\) 的魔力值为 \(d(i)\)(约数个数),可以通过类似筛法的方式在 \(O(N \log N)\) 时间内预处理出 \(1\)\(N\) 所有数的约数个数,并计算其前缀和,以便在 \(O(1)\) 时间内查询一段区间的魔力值总和。

题目中 \(R-L \le 10\),这意味着可能的段长度非常少,可以直接枚举 \(\text{len} \in [L,R]\)

对于固定的 \(\text{len}\),问题转化为:找到一段长度为 \(k \cdot \text{len} \ (k \ge S)\) 的区间 \([l,r]\),使得在该区间内,所有距离为 \(\text{len}\) 的整数倍的位置上的珠子颜色互不相同。

对于位置 \(i\),设 \(\text{nxt}_i\) 表示在 \(i\) 之后,第一个满足 \(j = i + t \cdot \text{len} \ (t \ge 1)\) 且颜色与 \(i\) 相同的珠子位置。如果不存在这样的 \(j\),则 \(\text{nxt}_i = \infty\)

“任何两段在相同相对位置颜色不同”等价于:对于截取区间 \([l,r]\) 内的每一个位置 \(i \in [l,r]\),都有 \(\text{nxt}_i \gt r\)

对于固定的 \(l\),希望找到最大的 \(r\),使得对于所有的 \(i \in [l,r]\),满足 \(\text{nxt}_i \gt r\)。由于随着 \(r\) 的增大,条件越难满足,因此可以使用双指针结合单调队列来维护。

  • 双指针:左指针 \(l\)\(1\) 遍历到 \(N\),右指针 \(r\) 尽可能向右扩展。
  • 单调队列:维护区间 \([l,r]\)\(\text{nxt}_i\) 的最小值。
参考代码
#include <cstdio>
#include <deque>
using namespace std;
const int N = 5e5 + 5;
char a[N];
int d[N], sum[N], nxt[N], last[128];
void init(int n) {
    for (int i = 1; i <= n; i++)
        for (int j = i; j <= n; j += i) {
            d[j]++;
        }
    for (int i = 1; i <= n; i++) sum[i] = sum[i - 1] + d[i];
    for (int i = 0; i < 128; i++) last[i] = n;
}
int main()
{
    int n, l, r, s;
    scanf("%d%d%d%d%s", &n, &l, &r, &s, a + 1);
    init(n);
    int ans = -1;
    for (int i = l; i <= r; i++) {
        if (1ll * s * i > n) break;
        for (int j = 1; j <= n; j++) nxt[j] = n + 1;
        for (int j = n; j > n - i; j--) {
            for (int k = j; k > 0; k -= i) {
                nxt[k] = last[(int)a[k]];
                last[(int)a[k]] = k;
            }
            for (int k = j; k > 0; k -= i) last[(int)a[k]] = n + 1;
        }
        deque<int> dq;
        int r = 0;
        for (int l = 1; l <= n; l++) {
            while (!dq.empty() && dq.front() < l) dq.pop_front();
            if (r < l) r = l;
            while (r <= n && (dq.empty() || min(nxt[dq.front()], nxt[r]) > r)) {
                while (!dq.empty() && nxt[dq.back()] > nxt[r]) dq.pop_back();
                dq.push_back(r);
                r++;
            }
            int cnt = (r - l) / i;
            if (cnt >= s) {
                ans = max(ans, sum[l + cnt * i - 1] - sum[l - 1]);
            }
        }
    }
    printf("%d\n", ans);
    return 0;
}
posted @ 2024-02-06 09:37  RonChen  阅读(622)  评论(0)    收藏  举报