zl程序教程

您现在的位置是:首页 >  其他

当前栏目

单调队列与单调栈

2023-03-20 15:01:39 时间

单调队列与单调栈

TODO:

补充单调队列例题

前言

单调队列与单调栈是一种存储数据进行优化的数据结构(空间换时间)

思考原始解法数据间的关系,是否有不必要的遍历,若有不必要的遍历,且有单调关系,则可以使用此类数据结构

(Tips):判断大小关系时,先不管等于关系,在确定是大还是小后,再判断等于关系

单调队列

引入

原题链接:P1886 滑动窗口 /【模板】单调队列 - 洛谷

题意简述:有一个长度为 (n) 的数组,求其每 (k) 个连续的数中的最大值及最小值。

常规思路,对于每一段 (isim i+k-1) 的序列,逐个比较来找出最大值(和最小值),时间复杂度约为 (O(n imes k))

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        // n=7,k=4,a={1,3,6,2,5,1,7};
        int n = sc.nextInt(), k = sc.nextInt();
        int[] a = new int[n];
        for (int i = 0; i < n; ++i) {
            a[i] = sc.nextInt();
        }
        for (int i = 0; i + k - 1 < n; ++i) {
            int max = a[i], min = a[i];
            for (int j = 1; j < k; ++j) {
                max = Math.max(max, a[i + j]);
                min = Math.min(min, a[i + j]);
            }
            System.out.println(String.format("从%d开始的k个数中最大值为:%d,最小值为:%d", i, max, min));
        }
    }
}

很显然,这其中进行了大量重复工作,除了开头 (k-1) 个和结尾 (k-1) 个数之外,每个数都进行了 (k) 次比较,而题中 (100\%) 的数据为 (nleq 10^6) ,当 (k) 稍大的情况下,显然会 (TLE)

定义

顾名思义,单调队列的重点分为「单调」和「队列」。

  • 「单调」指的是元素的「规律」——递增(或递减)。

  • 「队列」指的是元素只能从队头和队尾进行操作(是一个双端队列)。

队列是一种先进先出的数据结构,通常队首的元素是结果。

基本思想

维护一个双端队列((Deque)),遍历序列,当且仅当一个元素可能为某个区间的最值时才保留他。

通常会在区间内元素个数足够时(能确定确定他的结果时),更新答案。

常用来求解区间的一些值。

例题分析

以求连续的 (k) 个数的最大值为例。

(n=7,k=4,arr={1,3,6,2,5,1,7})

import java.io.*;
import java.util.ArrayDeque;
import java.util.Deque;

public class Main {
    static StreamTokenizer in = new StreamTokenizer(new BufferedReader(new InputStreamReader(System.in)));

    static PrintWriter out=new PrintWriter(System.out);

    static int get() throws IOException {
        in.nextToken();
        return (int) in.nval;
    }

    public static void main(String[] args) throws IOException {
        final int n = get(), k = get();
        int[] a = new int[n];
        for (int i = 0; i < n; ++i) a[i] = get();
        Deque<Integer> min = new ArrayDeque<>(), max = new ArrayDeque<>();
        for (int i = 0; i < n; ++i) {
            //如果超出窗口范围
            if (!min.isEmpty() && i - min.peekFirst() + 1 > k) min.pollFirst();
            //如果不再可能成为最小值,则出队
            while (!min.isEmpty() && a[min.peekLast()] >= a[i]) min.pollLast();
            min.offerLast(i);
            //说明此时窗口内元素个数已经足够,此后每个队首元素均为对应窗口的最值
            if (i + 1 >= k) out.print(a[min.peekFirst()] + " ");
        }
        out.println();
        for (int i = 0; i < n; ++i) {
            //最大值同理
            if (!max.isEmpty() && i - max.peekFirst() + 1 > k) max.pollFirst();
            while (!max.isEmpty() && a[max.peekLast()] <= a[i]) max.pollLast();
            max.offerLast(i);
            if (i + 1 >= k) out.print(a[max.peekFirst()] + " ");
        }
        out.close();
    }
}

单调栈

引入

原题链接:P5788 【模板】单调栈 - 洛谷

题意简述:求数组中第 (i) 个元素之后第一个大于 (a_i) 的元素的下标

常规思路,从当前位置的下一位置开始遍历,遍历到第一个大于当前位置的元素时找到答案,结束该次循环,如果到结尾仍未找到,则说明不存在,最坏时间复杂度为 (O(n^2))

import java.io.*;

public class Main {
    static StreamTokenizer in = new StreamTokenizer(new BufferedReader(new InputStreamReader(System.in)));

    static int get() throws IOException {
        in.nextToken();
        return (int) in.nval;
    }
    
    public static void main(String[] args) throws IOException {
        PrintWriter out = new PrintWriter(System.out);
        int n = get(), top = -1;
        int[] a = new int[n];
        for (int i = 0; i < n; ++i) a[i] = get();
        for (int i = 0; i < n; ++i) {
            int ans = 0;
            for (int j = i + 1; j < n; ++j) {
                if (a[j] > a[i]) {
                    ans = j + 1;
                    break;
                }
            }
            out.print(ans + " ");
        }
        out.close();
    }
}

定义

顾名思义,单调栈的重点分为「单调」和「栈」。

  • 「单调」指的是元素的「规律」——递增(或递减)。
  • 「栈」指的是元素只能从栈顶进行操作。

栈是一种后进先出的数据结构,通常栈顶的元素是结果。

基本思想

维护一个栈,遍历当前序列,当且仅当,当前元素可能某个区间的结果时才保留他。

此时维护栈的单调性,可以选择栈内元素出栈维护,也可以选择当前元素是否入栈来维护。

通常会在栈内元素出栈时(能确定他的结果时),更新答案。

常用来求解与当前元素达成某种关系的下一个元素或前一个元素。

基本思路

  1. 根据求解的问题,决定要维护哪种单调关系
  2. 根据不需要重复遍历的元素(没有潜力),决定元素是否需要保留或加入到栈中
  3. 判断栈内何时能够确定结果(出栈的时间)

例题分析

洛谷不予 (java) 语言足够的空间通过该题

我们通常使用数组模拟栈,部分题目可以对此使用二分查找

维护一个单调递增的栈,不满足单调递增的元素出栈,这些元素出栈都是因为找到了后方第一个大于他们的元素,

对于没有出栈的元素都是没有结果的

import java.io.*;

public class Main {
    static StreamTokenizer in = new StreamTokenizer(new BufferedReader(new InputStreamReader(System.in)));

    static int get() throws IOException {
        in.nextToken();
        return (int) in.nval;
    }

    public static void main(String[] args) throws IOException {
        PrintWriter out = new PrintWriter(System.out);
        int n = get(), top = -1;
        int[] ans = new int[n], a = new int[n], stack = new int[n];
        for (int i = 0; i < n; ++i) {
            a[i] = get();
            while (top != -1 && a[stack[top]] < a[i]) ans[stack[top--]] = i + 1;
            stack[++top] = i;
        }
        for (int i = 0; i < n; ++i) {
            out.print(ans[i] + " ");
        }
        out.close();
    }
}

应用题目

739. 每日温度 - 力扣

题目链接

与例题相同,找到比当前元素大的下一个元素

class Solution {
    public int[] dailyTemperatures(int[] temperatures) {
        int n = temperatures.length, top = -1;
        int[] ans = new int[n], stack = new int[n];
        // 出栈的元素找到下一更高的温度
        // 维护一个单调递减的栈
        for (int i = 0; i < n; ++i) {
            while (top != -1 && temperatures[stack[top]] < temperatures[i]) {
                int index = stack[top--];
                ans[index] = i - index;
            }
            stack[++top] = i;
        }
        return ans;
    }
}

496. 下一个更大元素 I - 力扣

题目链接

与例题类似,找到下一更大的元素,但是变成了询问式

采用 (hash) 表进行存储结果

class Solution {
    public int[] nextGreaterElement(int[] nums1, int[] nums2) {
        int n1 = nums1.length, n2 = nums2.length;
        int[] ans = new int[n1];
        // 出栈的元素找到下一更大元素
        // 维护一个nums2的单调递减栈
        int[] stack = new int[n2];
        int top = -1;
        HashMap<Integer, Integer> map = new HashMap<Integer, Integer>();
        for (int i = 0; i < n2; ++i) {
            while (top >= 0 && nums2[i] > stack[top]) map.put(stack[top--], nums2[i]);
            stack[++top] = nums2[i];
        }
        for (int i = 0; i < n1; ++i) {
            ans[i] = map.getOrDefault(nums1[i], -1);
        }
        return ans;
    }
}

503. 下一个更大元素 II - 力扣

题目链接

环式的下一更大元素,数组复制一份,或者采用取模的形式模拟复制数组

class Solution {
    public int[] nextGreaterElements(int[] nums) {
        int n = nums.length, top = -1;
        Deque<Integer> stack = new ArrayDeque<Integer>(n);
        int[] ans = new int[n];
        Arrays.fill(ans, -1);
        // 单调递减栈,出栈的元素都是因为找到了下一个更大的元素
        for (int i = 0; i < 2 * n; ++i) {
            while (!stack.isEmpty() && nums[stack.peekLast()] < nums[i % n]) {
                ans[stack.peekLast()] = nums[i % n];
                if (stack.pollLast() >= n) return ans;
            }
            stack.offerLast(i % n);
        }
        return ans;
    }
}

962. 最大宽度坡 - 力扣

题目链接

靠前的大的元素可能成为答案,靠后的小的元素也可能成为答案

而靠后的大的(前面有小的元素)一定没有机会成为答案

因此维护单调递减栈

遍历到某一元素时,如果栈内元素出栈,此时 (i) 并不一定与出栈元素构成最大宽度

因为未遍历到的大的元素有机会和这个元素构成最大宽度

对于后面元素,向前找能构成的最大宽度的值,此时才能确定这是出栈元素能构成的最大宽度

如果当前元素的值大于等于栈顶元素,它不可能作为左端点成为答案,因为栈顶元素比他靠前且小

因此,第二次遍历时进行出栈,此时才能确定答案

时间复杂度 (O(n))

class Solution {
    public int maxWidthRamp(int[] nums) {
        // 求距j最远的i,且nums[i] <= nums[j]
        // 靠前的i比现在的j小,则j没有机会提供最大宽度了
        // 而大的在前的仍有机会
        // 维护一个单调严格递减的单调栈
        // 选择是否入栈来维护单调栈
        int ans = 0, n = nums.length, top = -1;
        int[] stack = new int[n];
        stack[++top] = 0;
        for (int i = 1; i < n; ++i) {
            if (nums[i] < nums[stack[top]]) {
                stack[++top] = i;
            }
        }

        // 逆序遍历原数组
        for (int i = n - 1; i >= 0; --i) {
            while (top >= 0 && nums[stack[top]] <= nums[i]) {
                ans = Math.max(ans, i - stack[top--]);
            }
            if (top == -1) return ans;
        }

        return ans;
    }
}

上述是找当前元素前面的 能与其构成的最大宽度的 另一元素

对于当前元素,能与它构成最大宽度的前面的元素已经都在栈中了

可以选择从下至上遍历栈,遇到的第一个小于等于当前元素的值就是能构成的最大宽度

又由于这个栈是单调递减的,因此可以使用二分查找

时间复杂度 (O(nlog n))

class Solution {
    public int maxWidthRamp(int[] nums) {
        // 求距j最远的i,且nums[i] <= nums[j]
        // 靠前的i比现在的j小,则j没有机会提供最大宽度了
        // 而大的在前的仍有机会
        // 维护一个单调严格递减的单调栈
        int ans = 0, n = nums.length, top = -1;
        int[] stack = new int[n];
        stack[++top] = 0;
        for (int i = 1; i < n; ++i) {
            if (nums[i] < nums[stack[top]]) stack[++top] = i;
            int left = 0, right = top;
            // 二分找第一个小于等于nums[i]的下标
            while (left < right) {
                int mid = left + right >> 1;
                if (nums[stack[mid]] <= nums[i]) {
                    right = mid;
                } else {
                    left = mid + 1;
                }
            }
            if (nums[stack[left]] <= nums[i]) ans = Math.max(ans, i - stack[left]);
        }
        return ans;
    }
}

1124. 表现良好的最长时间段 - 力扣

题目链接

题目描述:设某一区间 (hour>8) 的个数为 (a)(hourle 8) 的个数为 (b),求 (a>b) 的区间最大长度

(hour>8) 的值为 (1),否则为 (-1),则题目转化为区间和 大于 (0) 的区间最大长度

区间和通过求前缀和 (sum) 转化为两值(O(1))相减

元素出栈时,要找到能构成的最长时间段

即对于 (i<j),使得 (sum[i]<sum[j])(没有后续元素大时出栈)

因此维护单调递减栈

大于等于栈顶元素的 (sum) 不需要入栈,因为它的值比前面的大,后续元素求区间和时前面的元素更优

与上一题相同,一次遍历时,未遍历元素可能与目前出栈元素构成最大长度

导致不能确定出栈元素是否为最大长度

因此,第二次遍历时进行出栈,此时才能确定答案

class Solution {
    public int longestWPI(int[] hours) {
        int n = hours.length;
        int[] sum = new int[n + 1];
        int[] stack = new int[n + 1];
        int top = -1;
        stack[++top] = 0;
        // sum靠前的且值小的潜力越大
        // 靠前的值大的,同样有可能
        // 维护一个sum单调递减栈(从下向上看)
        // sum相等的情况 自然是越靠前的越好

        // 单调递减栈,靠后的小的可能成为最长的答案
        // 靠前的小的同样可能成为最长的答案
        // 因此,靠前的大的不能出栈,且一遍循环不能解决

        for (int i = 1; i <= n; ++i) {
            sum[i] = sum[i - 1] + (hours[i - 1] > 8 ? 1 : -1);
            // 小于的情况才入栈
            // 相等的情况不加入栈(因为此时靠前的肯定更优)
            if (sum[i] < sum[stack[top]]) stack[++top] = i;
        }
        
        int ans = 0;
        for (int i = n; i >= 0; -- i) {
            while (top != -1 && sum[i] > sum[stack[top]]) {
                ans = Math.max(ans, i - stack[top--]);
            }
        }

        return ans;
    }
}

84. 柱状图中最大的矩形 - 力扣

题目链接

对于每一根柱子向左和向右能扩展的距离,这时构成它所能达到的最大面积

因此,查找左端点和右端点

heights = [2,1,5,6,2,3] 中第三根柱子 (5) 所能构成最大面积为 (10),第四根柱子 (6) 所能构成的最大面积为 (6)

查找某一元素作为右端点的矩形

出栈的元素找到右端点(当前元素没有栈内的元素大,严格大于时才找到右端点)

因此维护一个单调递增栈

当前元素的左端点是当前栈顶元素(最靠近当前位置且比当前位置小的元素)

记录下这个左端点,在栈内这个元素出栈时调用

为保证所有元素出栈(都可能为答案)

循环结束后,逐个将栈内元素弹出,他们的右端点都是最右边的边界

class Solution {
    public int largestRectangleArea(int[] heights) {
        // 找到目前遍历到的元素,向前扩展,最多能扩展到哪里
        // 前面小的能满足,则中间大的一样能覆盖
        // 因此维护一个单调递增的单调栈
        // 出栈时的元素的右端点已经找到(不包含)
        int n = heights.length;
        if (n == 1) return heights[0];
        int ans = 0, top = -1;
        int[] stack = new int[n], left = new int[n];
        // left 指向左边界(不包含)
        for (int i = 0; i < n; ++i) {
            // 维护单调递增
            while (top != -1 && heights[stack[top]] > heights[i]) {
                int index = stack[top--];
                ans = Math.max(ans, (i - left[index] - 1) * heights[index]);
            }
            if (top != -1) left[i] = stack[top];
            else left[i] = -1;
            stack[++top] = i;
        }
        // 剩下没有出栈的元素,边界都是最右边
        while (top != -1) {
            int index = stack[top--];
            ans = Math.max(ans, (n - left[index] - 1) * heights[index]);
        }
        return ans;
    }
}

面试题 16.16. 部分排序 - 力扣

题目链接

找左端点和右端点

右端点与已遍历元素的最大值有关,如果小于以遍历元素的最大值,则说明它也需要排序,更新右端点

左端点与目前未遍历元素有关,若未遍历元素小于这个元素,则它需要排序,更新左端点

因此,维护单调递增栈(栈内元素大时出栈,并更新左端点)

class Solution {
    public int[] subSort(int[] array) {
        int n = array.length;
        if (n <= 1) return new int[]{-1, -1};
        int left = n, right = n;
        int[] stack = new int[n];
        int top = -1, max = Integer.MIN_VALUE;
        // 找左边界及右边界
        // 右边界与前面元素的最大值有关
        // 左边界与后面的最小值有关
        // 对于左边界
        // 如果前面的小的都要排序了,那么小的后面的大的元素也一定要排序
        // 因此 维护一个单调递增栈
        // 相等的情况栈内留下靠前的元素(因为此时要排序的话靠前的也要排序)
        for (int i = 0; i < n; ++i) {
            while (top != -1 && array[stack[top]] > array[i]) {
                left = Math.min(left, stack[top--]);
            }
            // 小于max时更新右端点
            if (array[i] < max) right = i;
            if (max < array[i]) max = array[i];
            if (top == -1 || array[stack[top]] < array[i]) stack[++top] = i;
        }
        if (left == n) return new int[]{-1, -1};
        return new int[]{left, right};
    }
}

42. 接雨水 - 力扣

题目链接

参考资料

单调队列 - OI Wiki

算法学习笔记(66): 单调队列 - Pecco - 知乎