道长的算法笔记:使用单调结构来做优化

(一)单调栈查找前驱值与后继值

 单调栈其实就是一个栈,并非什么新颖的数据结构,是栈结构的一种十分常用的操作,与用栈进行括号匹配、表达式求值乃至模拟递归一样,都是单调栈仅仅是一种常见的用途。

 单调栈是一种满足单调性的栈结构,其维护单调性方式是弹出栈顶不符合的条件的元素,也就是说,单调栈存储的并非入栈的全部元素,相当一部分元素会被弹掉。使用单调栈通常是为了预处理算出数组每个元素的 前驱值、后续值,以此优化一些问题的复杂度。此处 前驱值后继值 概念与线性表中所学的概念并不相同,此处的前驱值是指其前面第一个比它更大的元素,后继值是指其后面第一个比它更大的元素。

image

问题
思路描述
LC0496.下一个更大元素I 维护一个单调递减栈,栈顶元素出栈时候已经找到了右侧后继
LC0503. 下一个更大元素 II 复制一份数组,破环成链,并在维护单调栈的时候限制一下长度
LC0556. 下一个更大元素 III 使用单调栈判断元素是否存在后继,反向遍历,对第一个有后继元素右侧升序排列,然后交换其与第一个大于其的元素
LC2454. 下一个更大元素 IV 使用两个单调栈s,t维护元素x 右侧第二个出现的大于x元素,栈中元素转移的时候需要保序,因而最好使用数组来模拟栈
LG1823. 音乐会的等待 使用单调栈维护维护前驱值的变体问题。当高人进栈会把前面矮人挡住,使其无法与高人身后的后来者交流,因而矮人出栈,因而维护一个单调递减栈,同时留意身高相同的情况,由于等高也可以交流,因而栈中需要另存等高的人数信息。

image

#include <bits/stdc++.h>
#include <limits.h>
using namespace std;

typedef long long ill;
typedef pair<ill, ill> ii;
#define  MAXN  600005

// 本题来自于 LG1823,本题难点在于使用pair存储等高个体的数量,维护一个 pair 类型的单调栈
ii stk[MAXN];
ill n, ans, h[MAXN], top;

int main(){
    scanf("%lld", &n);
    for (int i = 0; i < n; i++){
        scanf("%lld", &h[i]);
        ii hp{h[i], 1};
        while (top && h[i] >= stk[top].first) {
            ans += stk[top].second;
            if (stk[top].first == h[i]) {
                hp.second += stk[top].second;
            }
            top--;
        }
        if(top) ans++;
        stk[++top] = hp;
    }

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



(二)单调栈维护子数组左右边界

 这个方法稍加延伸,即可用于求解子数组边界,例如给定一个元素互不相同的序列 A, 询问子数组有多少个以 A[i] 作为最小值或最大值的子数组。比较朴素的想法是使用双指针沿着位置 i 向其两侧扩展,看其边界能够到达多远,然后再以 i 作为分界线,根据乘法原理算出子数组的个数。这种做法的复杂度较高,但是使用单调栈维护复杂度只需要 O(n) 即可。


题目
思路描述
LC0907. 子数组的最小值的之和 使用单调栈,预处理算出每个元素的左右边界,然后根据乘法原理算出子数组个数,再计算每个子数组对答案的总贡献量
LC1856. 子数组最小乘积的最大值 使用单调栈,预处理算出每个元素的左右边界,同时维护前缀和数组用于快速求解区间和,然后逐项扫描记录最大值
LC2281. 巫师力量的总和 预处理左右边界,同时维护一个前缀和的前缀和,然后枚举所有可能的巫师组求和
LC2104. 子数组范围和 枚举左右端点并以稀疏表优化;或者使用两次单调栈维护每个元素作为最大值的边界,作为最小值的边界,然后根据乘法原理算出以某个元素作为最大值、最小值的出现次数,然后加权求和即可
// 本题来自于 LC2104,本题可以作为一个模板题用于掌握单调栈的模板
typedef long long ill;
class Solution {
public: 
    int stk[10000];
    ill subArrayRanges(vector<int>& nums) {
        int n = nums.size();
        vector<int> min_lt(n, -1), min_rt(n, n);
        vector<int> max_lt(n, -1), max_rt(n, n);
        // 维护最小值子数组两侧边界,单调栈内元素是单调递增的
        for(int i = 0, top = 0; i < n; i++){
            while(top && nums[i] <= nums[stk[top]]){
                min_rt[stk[top--]] = i;
            }
            if(top) min_lt[i] = stk[top];
            stk[++top] = i;
        }
        // 维护最大值子数组两侧边界,单调栈内元素是单调递减的
        for(int i = 0, top = 0; i < n; i++){
            while(top && nums[i] >= nums[stk[top]]){
                max_rt[stk[top--]] = i;
            }
            if(top) max_lt[i] = stk[top];
            stk[++top] = i;
        }

        ill ans = 0;
        for(int i = 0; i < n; i++){
            int x = nums[i];
            ill k1 = 1LL * (i - max_lt[i]) * (max_rt[i] - i) * x;
            ill k2 = 1LL * (i - min_lt[i]) * (min_rt[i] - i) * x;
            ans += k1 - k2;
        }
        return ans;
    }
};



(三)使用单调队列维护滑动窗口

 单调队列基本应用用于求解滑动窗口的最值。同时单调队列能够优化很多动态规划问题。

题目
思路描述
LG1886. 滑动窗口 使用单调队列维护限长窗口,自左向右滑动,如果窗口过长则让左侧出列。如果元素如果大于列尾元素则令列尾出列,直接无法出列,再将当前元素入列。此时列首即为窗口最大值。最小值求法同理。
LG1725. 琪露诺 列出状态转移方程之后会发现,状态都在滑动窗口之内,且只有窗口内部的最大值对答案有贡献,故以单调队列优化。
LG2034.选择数字 每个数字选或不选分为状态,如果不选直接O(1)复杂度即可完成更新,如果选择当前数字则从状态所在的可行区间中选一个最大值更新当前值,通过单调队列维护区间最大值优化即可。
LG3957.跳房子 显然花钱越多,能够跳跃的区间也就越大、灵活性越高,我们发现单调性,故用二分答案求解。其中 check 函数是以动态规划的方式验证,又由于玩家可以在任意时刻结束游戏,因而只要有一个状态大于等于 k 即可,直接枚举可行区间的会超时,因而需要再用单调队列优化,这题坐标数值很大且不连续,不像LG1725那样能够直接相减得到,所以需要维护一个指针,指向第一个可行解,再将所有可行状态入列

 对于 LG1725,不难写出 O(N2) 级别的状态转移方程,

dp[i]=maxiRjiL{dp[j]}+A[i]

 然而L,R 取值的最坏情况会使复杂度达到 O(N2),仔细观察会发现能够转移到达 i 坐标的区间就是 [iR,iL],能够到达 i+k 坐标的区间就是 [iR+k,iL+k],其中k=1,2,3...,至此会发现这是一个滑动窗口求最值的问题,因而选择 L 作为起点遍历,走到坐标 i 则令 iL 入列,如果列中元素 x 已经不在区间范围之内,i.e. x<iR,则将这个坐标出列。如此一来,每次更新 dp[i] 只要取出列首更新即可。


 对于 LG2034,这道题有点类似于 LC打家劫舍,又有有一点像是背包问题,

dp[i][0]=max{dp[i1][0],dp[i1][1]

 本题的关键信息是所有的数字均为非负整数,因而连续子段在不大于等于 k 这个前提之下,肯定是越长越好。对于坐标i,若取这个位置,那么包括 i 在内,上一个可行状态 j[ik,i1],那么 能够获取的子段范围即为 [ik+1,i] ,我们维护一下前缀和 s,以便计算子段的贡献,那么不难列出下列 O(N2) 级别的状态转移方程,

dp[i][1]=maxikji1{dp[j][0]+s[i]s[j]},=maxikji1{dp[j][0]s[j]}+s[i]

 由于 s[i] 是一个常量,将其提出之后,我们使用单调队列维护 d[j][0]s[j] 即可, 使用列首的最佳状态j,更新 i,最终答案即为max{dp[n][1]dp[n][0]};这种通过单调队列优化状态转移的方法也能用在多重背包问题中。本题比较具有代表性,具体可参考下列代码。

#include <bits/stdc++.h>
#include <limits.h>
using namespace std;

typedef long long ill;
typedef unsigned long long ull;
#define  MAXN  2000000

ill dp[MAXN][2], p[MAXN], s[MAXN];
int deq[MAXN], n, k, head, tail = -1;

int main() {
    scanf("%d %d", &n, &k);
    for (int i = 1; i <= n; i++) {
        scanf("%lld", &p[i]);
        s[i] = s[i - 1] + p[i];
    }

    deq[++tail] = 0;
    for (int i = 1; i <= n; i++){
        dp[i][0] = max(dp[i - 1][0], dp[i - 1][1]);
        while (head <= tail && i - deq[head] > k) {
            head++;
        }
        dp[i][1] = dp[deq[head]][0] - s[deq[head]] + s[i];
        while (head <= tail && dp[i][0] - s[i] > dp[deq[tail]][0] - s[deq[tail]]) {
            tail--;
        }
        deq[++tail] = i;
    }
    printf("%lld\n", max(dp[n][1], dp[n][0]));
    return 0;
}



(四)使用单调队列维护最大子段和

 单调队列实际是一种双端队列,通过单调队列维护一个前缀和序列能够贪心的求解最大子段和问题,并且单调队列的扩展性非常好,即使对于环状序列的最大子段和,对于总和至少等于K 子段和等这些问题均可使用单调队列解决。

问题
思路描述
LG1115. 最大子段和 使用单调队列维护前缀和序列,列中元素单调递减,贪心的更新最大值即可
LG1121. 环状最大子段和 复制一份数组,破坏成链,然后按照常规最大子段和方法求解,并在添加判定条件避免子段中出现元素重复。
LG1714.切蛋糕 使用单调队列维护一个限长的窗口,类似于滑动窗口求最值那样,如果已经超过了窗口长度则左端元素出队,与其部分与按照最大子段和求解即可。



支持作者

posted @   道长陈牧宇  阅读(35)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示