文章目录
单调队列概念简介
数据结构:定义一种性质并且维护这种性质。
数据结构-队列:存储、获取、删除数据的一种结构。这种结构中,保证先存储的数据被先获取/删除,后存储的数据被后获取/删除。像排队一样,先进先出。
单调队列:队列中的数据始终保持从队首到队列单调递增或单调递减的性质。
解决什么问题: 单调队列解决滑动窗口的最值问题;一些实际问题可以转化为滑动窗口的最值问题。
单调队列经典问题
1. Leetcode 239: 滑动窗口的最大值
题目链接
解题思路1:属于维护动态区间内的最大值问题,所以可用大顶堆。将大小为k的窗口 内的元素都存到一个大顶堆中,每次移动时将堆顶元素加到结果数组中。
移动过程中需要判断堆顶元素是否还在窗口内,如果不在需要弹出。因此还需获取堆内元素的下标索引,故存储堆时存索引和数值同时存到堆中。(该方法是为了复习堆的常用写法)
class Solution {
public:
//堆内的元素,存储数值和索引
struct Node {
int x, ind;
};
//自定义堆的比较函数,a < b对应默认的大顶堆。
struct cmp{
bool operator()(const Node &a, const Node &b) {
return a.x != b.x ? a.x < b.x : a.ind > b.ind;
}
};
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
int n = nums.size();
//结果数组
vector<int> res;
//自定义的大顶堆
priority_queue<Node, vector<Node>, cmp> pq;
//先将前k个元素放到优先队列中
for (int i = 0; i < k; i++) {
pq.push((Node){nums[i], i});
}
res.push_back(pq.top().x); //取出堆顶元素放到结果数组中
//将k下标之后的元素依次遍历入堆
for (int i = k; i < n; i++) {
//只要堆顶元素不在窗口内则弹出
while (!pq.empty() && pq.top().ind <= i - k) {
pq.pop();
}
//入队下标为i的元素
pq.push((Node){nums[i], i});
res.push_back(pq.top().x);
}
return res;
}
};
解题思路2:在遍历数组获取滑动窗口内的元素时,如果某个数x要入队,其前面比其小的数一定不可能是答案,所以在入队x前可以先尾部弹出所有比x小的数,最后再将x 入队。获取结果数组时只需将队首元素加到结果数组里面即可。
以上正是维护了一个单调递减队列。
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
deque<int> q; //双端队列
vector<int> res;
for (int i = 0; i < nums.size(); i++) {
//入队前先弹出队尾比待入队小的数
while (!q.empty() && nums[q.back()] < nums[i]) q.pop_back();
q.push_back(i); //队列中存储下标,方便判断元素是否在窗口内
if (i - q.front() + 1 > k) q.pop_front(); //入队后将队首不在窗口内的元素弹出
if (i + 1 >= k) { //此时窗口成形,将队首元素加到结果数组中
res.push_back(nums[q.front()]);
}
}
return res;
}
};
总结:滑动窗口最值问题本质上就是维护一个单调队列。
2. 面试题 59-II. 队列的最大值
题目链接
解题思路:维护队列的最值问题,所以可以用单调队列。
但是不能只用一个单调队列存储所有的值,因为比如队列中先存储了5,4,3,2,1,这个时候要存入一个6,那么单调队列中的5,4,3,2,1都要出队(为了维护单调队列的性质),所以入队6以后如果要出队首元素,本应5被弹出,但是单调队列会弹出6。
所以可以维护两个队列,一个普通队列q1,另一个单调队列q2,某元素x入队时均加入q1和q2; 要获取最大值时直接获取q2的队首元素;要弹出队首元素时,q1正常弹出x, 若x等于q2的队首元素,则q2也弹出队首元素,否则q2无事发生。
class MaxQueue {
public:
queue<int> q1;
deque<int> q2;
MaxQueue() {
}
int max_value() {
//获取q2队首
return q1.empty() ? -1 : q2.front();
}
void push_back(int value) {
//入队时q1和q2正常入队
q1.push(value);
while (!q2.empty() && q2.back() < value) q2.pop_back();
q2.push_back(value);
}
int pop_front() {
//弹队时q1正常排出,q2比较后弹出。
if (q1.empty()) return -1;
int val = q1.front();
q1.pop();
if (!q2.empty() && val == q2.front()) q2.pop_front();
return val;
}
};
/**
* Your MaxQueue object will be instantiated and called as such:
* MaxQueue* obj = new MaxQueue();
* int param_1 = obj->max_value();
* obj->push_back(value);
* int param_3 = obj->pop_front();
*/
总结:队列的出入操作可以看做动态的滑动窗口。
3. Leetcode 862: 和至少为k的最短子数组
题目链接
题目解析:首先涉及区间和,所以可用前缀和。题目转化为:
确定前缀和数组
s
s
s的两个下标
i
i
i和
j
j
j, 使得
s
[
j
]
−
s
[
i
]
>
=
k
,
j
>
i
且
j
−
i
最小
s[j] - s[i] >= k, j > i 且j-i最小
s[j]−s[i]>=k,j>i且j−i最小, 求
j
−
i
j - i
j−i。
从而可以用动态的滑动窗口遍历数组s, 遍历的过程可用队列实现。
将s 的每一个下标
i
i
i 都依次入队,入队前,因为
i
i
i 之后可能会作为滑动窗口的起点,所以
i
i
i 前面比
s
[
i
]
s[i]
s[i] 大的元素一定不可能是最小滑动窗口的起点。因为假设i前面的某个位置i1对应的元素比i大,窗口终点为j, 则一定有s[j] - s[i1] < s[j] - s[i], 而且j - i <= j - i1, 问题要求窗口差值尽量大而且窗口宽度尽量小,所以得出此结论。
所以i入队前可以将队尾比s[i]大的元素都删掉,再讲 i 入队。如此即维护了一个单调递增队列。
i入队前,同样可计算出s[i]和队首元素的差,只要其满足>=k, 则可将队首元素弹出,并比较和记录此时的最小队列长度。
class Solution {
public:
int shortestSubarray(vector<int>& nums, int k) {
int n = nums.size();
vector<long> arr(n + 1); //前缀和数组
for (int i = 0; i < n; i++) {
arr[i + 1] = arr[i] + nums[i]; //arr[i] 表示nums前i个数字的和
if (nums[i] >= k) return 1;
}
deque<int> q;
int res = 100005; //结果最小窗口宽度
for (int i = 0; i <= n; i++) {
while (!q.empty() && arr[q.back()] >= arr[i]) q.pop_back();
//将队尾大于i元素的值都pop掉,因为其一定不可能是窗口的左端点
//关于其是否可能是窗口右端点,在下一个while处(入队前已经判断)
while (!q.empty() && arr[i] - arr[q.front()] >= k) {
//假设i为窗口右端点,计算出arr[i]和队首元素的差,只要其满足>=k,
//则可将队首元素弹出,并记录此时的最小队列长度。
res = min(res, i - q.front());
q.pop_front();
}
//最后将i入队,维护了一个单调递增队列
q.push_back(i);
}
if (res == 100005) return -1;
return res;
}
};
总结:1. 区间和问题可以转化为前缀和问题;
2. 涉及数组中任意两端点的元素差、索引差的问题,可以用一个窗口来滑动(双指针)。
4. Leetcode 1438: 绝对值不超过限制的最长连续子数组
题目链接
题目解析:可以用一个单调递增队列维护滑动窗口内的最小值,另一个单调递减队列维护滑动窗口内的最大值,最小值和最大值的差即为该子数组(滑动窗口)内的最大绝对值差。用一个双指针遍历所有的滑动窗口即可求得最长的连续子数组的长度。
class Solution {
public:
int longestSubarray(vector<int>& nums, int limit) {
int n = nums.size();
deque<int> q_max; //窗口最大值,单调递减队列
deque<int> q_min; //窗口最小值,单调递增队列
int res = 0;
int left = 0, right = 0; //双指针,指向滑动窗口的两端
while (right < n) {
//right位置元素进入单调队列
while (!q_max.empty() && q_max.back() < nums[right]) q_max.pop_back();
while (!q_min.empty() && q_min.back() > nums[right]) q_min.pop_back();
q_max.push_back(nums[right]);
q_min.push_back(nums[right]);
//固定right,遍历left, 找到left最小的满足要求限制的left位置
while (!q_max.empty() && !q_min.empty() && q_max.front()-q_min.front() > limit) {
//以下代表滑动窗口的左端点向右移动一位
if (q_max.front() == nums[left]) q_max.pop_front();
if (q_min.front() == nums[left]) q_min.pop_front();
left++;
}
// printf("left %d right %d\n", left, right);
res = max(res, right - left + 1); //更新结果
right++; //right继续移动
}
return res;
}
};
总结:单调队列不仅可以解决固定滑动窗口大小的最值问题,还可以解决数组内任意两端点之间的最值问题。(本题中任意的left 和right)
5. Leetcode 45: 跳跃游戏||
题目链接
首先属于状态转化的最少步数问题,所以可以用广度优先搜索,队列实现。队列中存储能跳跃到的所有位置和对应的步数,每次弹出队首元素后将队首能跳跃到的位置都加入队列中,直到能跳跃到的位置中出现了目标元素。具体代码略。
队列广搜只能解决到最少步数问题,对于每一步应该跳跃到哪一个位置没有答案。如果要知道每步跳跃到哪个位置,可以这样想:谈心算法,当前位置能跳跃到的最佳位置,应该是跳跃以后 下一步跳跃以后能跳到最远位置。这样每跳一步都能调到一个最佳位置(数学归纳法的思想,只跳一步n=1时成立,假设跳了k步(n=k)时,则可用反证法证明,要跳跃下一步n=k+1也成立)。所以对于任意n都成立,即这种走法都是最佳走法。
class Solution {
public:
int jump(vector<int>& nums) {
int n = nums.size();
if (n == 1) return 0;
int start = 0, end = nums[0];//start代表当前位置,end代表下一步的最远位置。
int step = 0;
while (end < n - 1) {
int temp = 0, ind = 0;
for (int i = start + 1; i <= end; i++) {
//求出下下一步的最远位置,对应得到下一步的最佳位置。
if (i + nums[i] >= temp) {
ind = i;
temp = i + nums[i];
}
}
start = ind; //当前最佳位置
end = temp; //下一步的最远位置
step++;
}
return step + 1; //end>=n+1代表下一步能跳到目标位置(还没跳),所以返回step+1.
}
};
总结:1. 正常用广搜即可。2. 单调队列的本质是遍历的过程中跳过一定不可能是答案的状态,本题可理解为借鉴了这种思想。
6. Leetcode 93: 复原IP地址
题目链接
题目解析:可以看成一个N叉树的四层遍历,每层遍历可能的数字,加到路径path中,当遍历完时把满足要求的path加到结果数组中。用深度搜索回溯的方法。
class Solution {
public:
vector<string> restoreIpAddresses(string s) {
vector<string> ret; //结果数组
vector<string> path; //搜索过程的路径数组
if (s.size() > 12) return ret; //最多支持12个字符
dfs(s, 0, 4, path, ret); //深度搜索回溯
return ret;
}
void dfs(string s, int begin, int res_part,
vector<string> &path, vector<string> &res) {
//s为给定字符串, begin表示当前从哪个位置开始在遍历,res_part为剩余部分数,一共有四个部分,每部分为一个ip数字
//path为搜索过程中对应的路径; res为结果数组
if (begin == s.length()) {
if (res_part == 0) {
//已经遍历完数组而且剩余待添加的部分为0
res.push_back(concat_str_list(path));
}
return;
}
//将起点为begin,终点为begin, begin+1, begin+2的数字分别加到路径中(前提是数字满足要求)
for (int i = 0; i < begin + 3; i++) {
if ((res_part - 1) * 3 < s.size() - i - 1) continue; //加到路径前判断一下,
//如果路径中加了这个数字,那会剩下res_part-1个部分,
//最多还需要(res_part - 1) * 3个字符,s[begin:i]加到路径中后,
//还将剩下s.size() - i - 1个数字,若前者小于后者,则说明剩下的数字太多了,
//一定不可能满足要求,所以i位置直接不继续加了
if (!is_valid_num(s, begin, i)) continue; //s[begin:i]是一个合法的ip数字
path.push_back(s.substr(begin, i - begin + 1)); //前面都判断完没问题后加到结果中
dfs(s, i + 1, res_part - 1, path, res); //进入下一层回溯
path.pop_back(); //回溯完一遍需要撤销回溯前的操作,当成没回溯
}
return;
}
string concat_str_list(vector<string> path) {
//'.'.join(path)
string ret;
for (int i = 0; i < path.size(); i++) {
if (i > 0) ret += '.';
ret += path[i];
}
return ret;
}
bool is_valid_num(string s, int start, int end) {
//判断s[start: end] 是否为合法的ip 数字(start与end均包含)
if (end < start) return false;
if (end - start + 1 > 1 && s[start] == '0') return false;
int res = 0, i = start;
while (i <= end) {
res *= 10;
res += ((s[i] - '0'));
i++;
}
return res >= 0 && res <= 255;
}
};
总结:复习深度回溯;单调队列的本质是遍历的过程中跳过一定不可能是答案的状态,本题可理解为借鉴了这种思想。