本文的网课内容学习自B站左程云老师的算法详解课程,旨在对其中的知识进行整理和分享~
一.单调队列模板题
题目:滑动窗口最大值
算法原理
-
整体思路
- 这个算法主要利用了单调队列的思想来解决滑动窗口求最大值的问题。
- 单调队列是一种特殊的数据结构,在这里维护的是一个递减的序列(从队头到队尾数值递减)。
-
算法步骤原理
-
初始化窗口部分(先形成长度为(k - 1)的窗口)
- 对于(i = 0)到(k - 2):
- 当新元素(arr[i])加入时,通过
while
循环比较新元素和队列末尾元素(arr[deque[t - 1]]
)的大小。 - 如果新元素大于等于队列末尾元素,就将队列末尾元素弹出(
t--
),这是为了保证队列中的元素从队头到队尾是递减的,这样队头元素始终是当前窗口中的最大值。 - 然后将新元素的索引(i)加入队列(
deque[t++] = i
)。
- 当新元素(arr[i])加入时,通过
- 对于(i = 0)到(k - 2):
-
处理完整窗口及滑动过程
- 对于(l = 0)到(n - k)(这里(n)是数组(arr)的长度):
- 先将(r)位置(当前窗口的最右位置)的数加入单调队列。通过
while
循环,和初始化窗口时类似,如果新元素大于等于队列末尾元素,就弹出队列末尾元素,保证队列递减性,然后将(r)的索引加入队列。 - 此时队头元素(
arr[deque[h]]
)就是当前窗口(大小为(k))的最大值,将其存入结果数组ans
中。 - 然后检查队头元素的索引是否等于(l)(窗口的最左位置),如果是,就将队头元素弹出(
h++
),因为下一次滑动窗口时,这个元素就不在窗口内了。
- 先将(r)位置(当前窗口的最右位置)的数加入单调队列。通过
- 对于(l = 0)到(n - k)(这里(n)是数组(arr)的长度):
-
代码实现
// 滑动窗口最大值(单调队列经典用法模版)
// 给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧
// 你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
// 返回 滑动窗口中的最大值 。
// 测试链接 : https://leetcode.cn/problems/sliding-window-maximum/
public class Code01_SlidingWindowMaximum {
public static int MAXN = 100001;
public static int[] deque = new int[MAXN];
public static int h, t;
public static int[] maxSlidingWindow(int[] arr, int k) {
h = t = 0;
int n = arr.length;
// 先形成长度为k-1的窗口
for (int i = 0; i < k - 1; i++) {
// 大 -> 小
while (h < t && arr[deque[t - 1]] <= arr[i]) {
t--;
}
deque[t++] = i;
}
int m = n - k + 1;
int[] ans = new int[m];
// 当前窗口k-1长度
for (int l = 0, r = k - 1; l < m; l++, r++) {
// 少一个,要让r位置的数进来
while (h < t && arr[deque[t - 1]] <= arr[r]) {
t--;
}
deque[t++] = r;
// 收集答案
ans[l] = arr[deque[h]];
// l位置的数出去
if (deque[h] == l) {
h++;
}
}
return ans;
}
}
二.绝对差不超过限制的最长连续子数组
题目:绝对差不超过限制的最长连续子数组
算法原理
-
整体思路
- 这个算法的目标是在给定的整数数组
nums
中找到最长的连续子数组,使得子数组中任意两个元素的绝对差不超过给定的限制limit
。算法主要通过双端单调队列(一个用于维护最大值,一个用于维护最小值)来实现。
- 这个算法的目标是在给定的整数数组
-
算法步骤原理
- 初始化与变量定义
- 定义了两个单调队列
maxDeque
和minDeque
,分别用于维护窗口内的最大值和最小值。还定义了相关的指针(如maxh
、maxt
、minh
、mint
)以及一些辅助变量(如n
表示数组长度,ans
用于存储最终结果)。
- 定义了两个单调队列
- 外层循环(遍历起始位置(l))
- 对于每个可能的起始位置(l)(从(0)到(n - 1)),有一个内层的
while
循环来确定以(l)为起始位置的子数组能向右延伸的最大范围。
- 对于每个可能的起始位置(l)(从(0)到(n - 1)),有一个内层的
ok
函数原理- 在
ok
函数中,当考虑将number
加入窗口时,计算如果加入后的新窗口的可能最大值(如果队列不为空则取Math.max(arr[maxDeque[maxh]], number)
,否则直接为number
)和可能最小值(类似最大值的计算)。然后判断最大值与最小值的差是否不超过limit
,如果是则返回true
,否则返回false
。
- 在
push
函数原理- 当将
r
位置的数字加入窗口时:- 对于维护最大值的单调队列
maxDeque
,通过while
循环比较新元素arr[r]
和队列末尾元素arr[maxDeque[maxt - 1]]
的大小。如果新元素大于等于队列末尾元素,就将队列末尾元素弹出(maxt--
),以保证队列中的元素从队头到队尾是递减的,然后将r
的索引加入队列(maxDeque[maxt++] = r
)。 - 对于维护最小值的单调队列
minDeque
,类似地,通过while
循环比较新元素arr[r]
和队列末尾元素arr[minDeque[mint - 1]]
的大小。如果新元素小于等于队列末尾元素,就将队列末尾元素弹出(mint--
),以保证队列中的元素从队头到队尾是递增的,然后将r
的索引加入队列(minDeque[mint++] = r
)。
- 对于维护最大值的单调队列
- 当将
pop
函数原理- 当窗口要吐出(l)位置的数时:
- 对于维护最大值的单调队列,如果队头元素
maxDeque[maxh]
等于(l,则将队头指针
maxh后移一位(
maxh++`),因为这个元素已经不在窗口内了。 - 对于维护最小值的单调队列,如果队头元素
minDeque[minh]
等于(l,则将队头指针
minh后移一位(
minh++`)。
- 对于维护最大值的单调队列,如果队头元素
- 当窗口要吐出(l)位置的数时:
- 计算结果
- 在每次确定了以(l
为起始位置的子数组能向右延伸的最大范围(通过内层
while循环)后,计算并更新最长子数组的长度
ans(
ans = Math.max(ans, r - l))。最后返回
ans`作为结果。
- 在每次确定了以(l
- 初始化与变量定义
代码实现
// 绝对差不超过限制的最长连续子数组
// 给你一个整数数组 nums ,和一个表示限制的整数 limit
// 请你返回最长连续子数组的长度
// 该子数组中的任意两个元素之间的绝对差必须小于或者等于 limit
// 如果不存在满足条件的子数组,则返回 0
// 测试链接 : https://leetcode.cn/problems/longest-continuous-subarray-with-absolute-diff-less-than-or-equal-to-limit/
public class Code02_LongestSubarrayAbsoluteLimit {
public static int MAXN = 100001;
// 窗口内最大值的更新结构(单调队列)
public static int[] maxDeque = new int[MAXN];
// 窗口内最小值的更新结构(单调队列)
public static int[] minDeque = new int[MAXN];
public static int maxh, maxt, minh, mint;
public static int[] arr;
public static int longestSubarray(int[] nums, int limit) {
maxh = maxt = minh = mint = 0;
arr = nums;
int n = arr.length;
int ans = 0;
for (int l = 0, r = 0; l < n; l++) {
// [l,r),r永远是没有进入窗口的、下一个数所在的位置
while (r < n && ok(limit, nums[r])) {
push(r++);
}
// 从while出来的时候,[l,r)是l开头的子数组能向右延伸的最大范围
ans = Math.max(ans, r - l);
pop(l);
}
return ans;
}
// 判断如果加入数字number,窗口最大值 - 窗口最小值是否依然 <= limit
// 依然 <= limit,返回true
// 不再 <= limit,返回false
public static boolean ok(int limit, int number) {
// max : 如果number进来,新窗口的最大值
int max = maxh < maxt ? Math.max(arr[maxDeque[maxh]], number) : number;
// min : 如果number进来,新窗口的最小值
int min = minh < mint ? Math.min(arr[minDeque[minh]], number) : number;
return max - min <= limit;
}
// r位置的数字进入窗口,修改窗口内最大值的更新结构、修改窗口内最小值的更新结构
public static void push(int r) {
while (maxh < maxt && arr[maxDeque[maxt - 1]] <= arr[r]) {
maxt--;
}
maxDeque[maxt++] = r;
while (minh < mint && arr[minDeque[mint - 1]] >= arr[r]) {
mint--;
}
minDeque[mint++] = r;
}
// 窗口要吐出l位置的数了!检查过期!
public static void pop(int l) {
if (maxh < maxt && maxDeque[maxh] == l) {
maxh++;
}
if (minh < mint && minDeque[minh] == l) {
minh++;
}
}
}
三.接水花盆
题目:Flowerpot S
算法原理
-
整体思路
- 该算法旨在解决在给定(N)滴水的坐标((x)表示水平位置,(y)表示高度)以及时间差要求(D)的情况下,找到能接住水滴且满足时间差至少为(D)的最小花盆宽度。通过滑动窗口和单调队列的思想来实现。
-
算法步骤原理
- 输入处理与初始化
- 利用
BufferedReader
和StreamTokenizer
高效读取输入。先读取水滴数量(n)和时间差(D),再读取每滴水的(x)、(y)坐标并存入arr
数组。同时初始化一些变量,如ans
(初始为Integer.MAX_VALUE
,用于存储最小花盆宽度),以及单调队列相关的指针(maxh
、maxt
、minh
、mint
)。
- 利用
- 水滴坐标排序
- 对水滴坐标按照(x)坐标进行排序(
Arrays.sort(arr, 0, n, (a, b) -> a[0] - b[0]);
)。这是为了方便后续按照水滴顺序确定花盆位置,以便通过顺序遍历水滴来寻找满足条件的花盆宽度。
- 对水滴坐标按照(x)坐标进行排序(
- 滑动窗口操作
- 外层循环(确定左边界(l))
- 通过
for
循环(for (int l = 0, r = 0; l < n; l++)
),(l)作为花盆左边界从(0)到(n - 1)遍历。
- 通过
- 内层循环(确定右边界(r))
- 内层
while
循环(while (!ok() && r < n)
),(r)作为花盆右边界(未进入窗口的下一个水滴),不断扩展(r),直到满足ok
函数条件(即窗口内水滴高度最大值 - 最小值(\geq d))或者(r = n)。
- 内层
- 外层循环(确定左边界(l))
ok
函数原理- 计算当前窗口内水滴高度的最大值和最小值。若最大值与最小值的差大于等于(d),则从这个窗口内被花盆接住的第一滴水到最后一滴水的时间差满足要求,返回
true
;否则返回false
。通过单调队列获取最大值和最小值,若队列不为空取队列头部对应的水滴高度,否则取默认值(最大值默认为(0))。
- 计算当前窗口内水滴高度的最大值和最小值。若最大值与最小值的差大于等于(d),则从这个窗口内被花盆接住的第一滴水到最后一滴水的时间差满足要求,返回
push
函数(单调队列维护)- 当将(r)位置的水滴加入窗口时:
- 对于维护最大值的单调队列,若新水滴高度大于等于队列末尾水滴高度(
while (maxh < maxt && arr[maxDeque[maxt - 1]][1] <= arr[r][1])
),弹出队列末尾水滴索引(maxt--
),保证队列递减(按水滴高度),然后加入(r)索引(maxDeque[maxt++] = r
)。 - 对于维护最小值的单调队列,若新水滴高度小于等于队列末尾水滴高度(
while (minh < mint && arr[minDeque[mint - 1]][1] >= arr[r][1])
),弹出队列末尾水滴索引(mint--
),保证队列递增(按水滴高度),然后加入(r)索引(minDeque[mint++] = r
)。
- 对于维护最大值的单调队列,若新水滴高度大于等于队列末尾水滴高度(
- 当将(r)位置的水滴加入窗口时:
pop
函数(单调队列维护)- 当窗口要吐出(l)位置的水滴(左边界移动)时:
- 对于维护最大值的单调队列,若队头水滴索引
maxDeque[maxh] = l
,则队头指针maxh
后移一位(maxh++
)。 - 对于维护最小值的单调队列,若队头水滴索引
minDeque[minh] = l
,则队头指针minh
后移一位(minh++
)。
- 对于维护最大值的单调队列,若队头水滴索引
- 当窗口要吐出(l)位置的水滴(左边界移动)时:
- 结果计算
- 当内层
while
循环结束且ok
为true
时,找到以(l)为左边界的可行花盆放置位置。计算花盆宽度(arr[r - 1][0] - arr[l][0]
),并更新最小花盆宽度ans
(ans = Math.min(ans, arr[r - 1][0] - arr[l][0]);
)。 - 最后,如果
ans = Integer.MAX_VALUE
,表示未找到满足条件的花盆放置位置,返回 - 1;否则返回ans
作为最小花盆宽度。
- 当内层
- 输入处理与初始化
代码实现
// 接取落水的最小花盆
// 老板需要你帮忙浇花。给出 N 滴水的坐标,y 表示水滴的高度,x 表示它下落到 x 轴的位置
// 每滴水以每秒1个单位长度的速度下落。你需要把花盆放在 x 轴上的某个位置
// 使得从被花盆接着的第 1 滴水开始,到被花盆接着的最后 1 滴水结束,之间的时间差至少为 D
// 我们认为,只要水滴落到 x 轴上,与花盆的边沿对齐,就认为被接住
// 给出 N 滴水的坐标和 D 的大小,请算出最小的花盆的宽度 W
// 测试链接 : https://www.luogu.com.cn/problem/P2698
// 请同学们务必参考如下代码中关于输入、输出的处理
// 这是输入输出处理效率很高的写法
// 提交以下的code,提交时请把类名改成"Main",可以直接通过
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
import java.util.Arrays;
public class Code03_FallingWaterSmallestFlowerPot {
public static int MAXN = 100005;
public static int[][] arr = new int[MAXN][2];
public static int n, d;
public static int[] maxDeque = new int[MAXN];
public static int[] minDeque = new int[MAXN];
public static int maxh, maxt, minh, mint;
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StreamTokenizer in = new StreamTokenizer(br);
PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
while (in.nextToken() != StreamTokenizer.TT_EOF) {
n = (int) in.nval;
in.nextToken();
d = (int) in.nval;
for (int i = 0; i < n; i++) {
in.nextToken();
arr[i][0] = (int) in.nval;
in.nextToken();
arr[i][1] = (int) in.nval;
}
int ans = compute();
out.println(ans == Integer.MAX_VALUE ? -1 : ans);
}
out.flush();
out.close();
br.close();
}
public static int compute() {
// arr[0...n-1][2]: x(0), 高度(1)
// 所有水滴根据x排序,谁小谁在前
Arrays.sort(arr, 0, n, (a, b) -> a[0] - b[0]);
maxh = maxt = minh = mint = 0;
int ans = Integer.MAX_VALUE;
for (int l = 0, r = 0; l < n; l++) {
// [l,r) : 水滴的编号
// l : 当前花盘的左边界,arr[l][0]
while (!ok() && r < n) {
push(r++);
}
if (ok()) {
ans = Math.min(ans, arr[r - 1][0] - arr[l][0]);
}
pop(l);
}
return ans;
}
// 当前窗口 最大值 - 最小值 是不是>=d
public static boolean ok() {
int max = maxh < maxt ? arr[maxDeque[maxh]][1] : 0;
int min = minh < mint ? arr[minDeque[minh]][1] : 0;
return max - min >= d;
}
public static void push(int r) {
while (maxh < maxt && arr[maxDeque[maxt - 1]][1] <= arr[r][1]) {
maxt--;
}
maxDeque[maxt++] = r;
while (minh < mint && arr[minDeque[mint - 1]][1] >= arr[r][1]) {
mint--;
}
minDeque[mint++] = r;
}
public static void pop(int l) {
if (maxh < maxt && maxDeque[maxh] == l) {
maxh++;
}
if (minh < mint && minDeque[minh] == l) {
minh++;
}
}
}
四.总结
单调队列最经典的用法是解决如下问题:
滑动窗口在滑动时,r++代表右侧数字进窗口,l++代表左侧数字出窗口
这个过程中,想随时得到当前滑动窗口的 最大值 或者 最小值
窗口滑动的过程中,单调队列所有调整的总代价为O(n),单次操作的均摊代价为O(1)
注意:这是单调队列最经典的用法,可以解决很多题目,下篇文章将继续介绍其他的用法
注意:单调队列可以和很多技巧交叉使用!比如:动态规划+单调队列优化,会在【扩展】系列里更新