单调队列及其应用

单调队列

Any DP problem where A[i] = min(A[j:k]) + C where j < k <= i
This is a sliding max/min window problem.

单调队列是一种广泛应用的数据结构,它能够动态地维护定长序列中的最值。可以这么理解,存在有一个队列,队列中的元素需要满足单调性,而且队尾元素和队首元素距离最大是k

以递减队列为例子,构造队列的方式如下:

  • 入队:待加入的元素值x,如果x小于队尾元素,直接入队;如果x大于等于队尾元素,则弹出队尾元素,直到队列是空或者队尾元素大于x,此时x加入队列
  • 出队:对于插入的元素,检查该元素和队首元素之差,如果超过k,则弹出队首元素。

注意: 单调队列的长度,不是指元素的个数,而是指队首和队尾元素的距离。我们使用单调队列,是为了在 O ( 1 ) O(1) O(1)的时间内,求出指定区间范围的最大值和最小值。而且,入队和出队是时间有序的,这点是非常重要的。

堆的复杂度是 l o g 2 N log_{2}^{N} log2N,但是堆不能新元素加入的时候,判断是否删除或者删除哪一个,因为堆内部的元素是无序的。平衡树可以,但是结构复杂,复杂度相对来说更高一些,虽然渐进复杂度是一样的。

涉及到时间序列的最值问题,优先考虑单调队列。

单调队列求解滑动窗口

题目赘述,在这里,核心思路是考虑问题的角度。一般来说,我们认为窗口是滑动的,从字面了解也确实是这样。不过我们从另一个角度来看,可以理解为数据流流动进入窗口,终止的时候是数据流的末尾和窗口末尾重叠的时候。这两种情况是等价的。

然后,我们需要一个队列,仅保存那些可能成为最大值的元素。假设我们有了一个队列,里面存储的都是可能成为最大值的元素,则该队列必然是递减的,队头到队尾表示元素进入的时间序列。举个例子,假设此时队列中只有一个元素值是x,窗口的宽度是w,此时新加入的元素值是y。如果y > x,且yx的距离小于等于w,则x是不可能成为最大值的,因为它们此时在一个窗口内,此时应该弹出x并加入y。如果队列有多个元素,则一直执行该步骤,直到队列空,或者有元素大于末尾元素。如果y小于x,则x滑出后,y可能是最大值,如果队列中有其他多个元素也是同理。x滑出的条件就是,yx的距离大于w,不论y是否可能是最大值,或者说,新的元素来临时,新元素和队头元素的距离大于w

从另一个角度来看,我们可以任务,滑动窗口是无状态的,已经离开窗口的元素不会对之后的结果造成影响。

#include <iostream>
#include <vector>
#include <deque>

void initArr(std::vector<int>& vec, int N) {
	std::cout << "input " << N << " elements:\n";
	for (int i = 0; i < N; i++) {
		int n;
		std::cin >> n;
		vec.push_back(n);
	}
}

std::vector<int> slideWinMaxNum(const std::vector<int>& input, int w) {
	std::vector<int> res;
	if (input.empty() || w <= 0 || input.size() < w) {  // 边界条件
		return res;  
	}
	std::deque<int> maxDep;
	for (int i = 0; i < input.size(); i++) {
		while (!maxDep.empty() && input[maxDep.back()] <= input[i]) {
			maxDep.pop_back();
		}
		maxDep.push_back(i);
		if (maxDep.front() == i - w) {
			maxDep.pop_front();
		}
		if (i >= w - 1) {
			res.push_back(input[maxDep.front()]);
		}
	}
	return res;
}

int main() {
	int N, w;
	std::vector<int> input;
	std::cout << "input number of element and window width:\n";
	std::cin >> N >> w;
	initArr(input, N);
	auto&& res = slideWinMaxNum(input, w);
	for (const auto& val: res) {
		std::cout << val << " ";
	}
	std::cout << std::endl;
	return 0;
}

最终结果:

input number of element and window width:
8 3
input 8 elements:
4 3 5 4 3 3 6 7
5 5 5 4 6 7

复杂度分析,虽然内部嵌套while循环,但是从优先队列来看,每个元素至多进入队列1次,因此复杂度是 O ( N ) O(N) O(N)

扩展

最大值减去最小值之差小于等于num的子区间的个数。

#include <iostream>
#include <vector>
#include <deque>

/*
 *  基本思路是,利用两个单调队列,在以某个元素作为区间左边界的情况下,
 *  不断更替区间范围,并动态获取最值,之后利用最值之差和下标之差,计算
 *  符合条件的子数组的个数
 */

int MaxSubMinNum(const std::vector<int>& arr, int num) {
    if (arr.empty()) {
        return 0;
    }
    std::deque<int> qmax;  // 递减队列
    std::deque<int> qmin;  // 递增队列
    int res = 0;
    int i = 0;  // 区间左边界
    int j = 0;  // 区间右边界
    int N = arr.size();
    while (i < N) {
        while (j < N) {
            while (!qmax.empty() && arr[qmax.back()] <= arr[j]) {
                qmax.pop_back();  // 维护递减队列性质
            }
            qmax.push_back(j);
            while (!qmin.empty() && arr[qmin.back()] >= arr[j]) {
                qmin.pop_back();  // 维护递增队列性质 
            }
            qmin.push_back(j);
            if (qmax.front() - qmin.front() >= num) {
                break;  // 找到满足性质的区间,不再需要递增区间右边界范围
            }
            ++j;
        }
        if (qmax.front() == i) {
            qmax.pop_front();  // 以arr[i]为边界的区间查找结束
        }
        if (qmin.front() == i) {
            qmin.pop_front();
        }
        res += (j - i);  // arr[i]为边界的个数
        i++;  // 移动窗口右边界
    }
}

int main() {
    return 0;
}

参考文献

  • https://ksmeow.moe/monotonic_queue/
  • https://endlesslethe.com/monotone-queue-and-stack-tutorial.html
  • https://carecraft.github.io/basictheory/2018/12/monotone_stack/
  • https://ruanx.pw/post/%E5%8D%95%E8%B0%83%E9%98%9F%E5%88%97.html
  • https://leetcode.com/problems/shortest-subarray-with-sum-at-least-k/discuss/204290/Monotonic-Queue-Summary
  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值