窗口的加数与减数
开始之前窗口的左右边界L和R都设为-1
- 当R向右移动时,窗口中的元素也相应地增加;
- 当L向右移动时,窗口中的元素也相应地减少。
此外,在任何情况下,R和L都不能回退,L也不能大于R。
窗口内的最大值
由于窗口的L和R是可能随时变动的,因此窗口内的元素是不固定的,即最大值也是不固定的。
你当然可以在每次窗口变动后遍历一下[L, R]来求最大值,但这样时间复杂度就是O(窗口的长度)。
所以要使用一种新的方式来解决上述问题
双端队列结构
对于一组数,每次只在队列尾添加数据,同时要将队尾元素和待添加元素比较,若待添加元素小于队尾元素,直接添加在队列尾部;若待添加元素大于等于队尾元素,那么就将队尾元素出队列,然后再次将新的队尾元素和待添加元素比较。
此外,队列头元素始终是最大的元素。
当移除元素时,L向右移动,此时查看队列头的索引是否小于L,若小于,则将队列头元素出队。
一个例子
有一组数:5 4 1 3 6,那么使用双端队列进行上述算法的流程应该是:
- 初始L和R都为-1
- R右移一位,变为0,5准备进队列,此时队列为空,直接进。目前队列状态为:[5]
- R右移一位,变为1,4准备进队列,此时队列不为空,队尾元素是5,比4大,因此4直接放在队尾。那么队列状态为:[5 4]
- R右移一位,变为2,1准备进队列,此时队列不为空,队尾元素是4,比1大,因此1直接放在队尾。那么队列状态为:[5 4 1]
- R右移一位,变为3,3准备进队列,此时队列不为空,队尾元素是1,比3小,因此将1出队,并将3继续与队尾元素4比较,4更大,因此将3放在队尾。目前队列状态为[5 4 3]
- R右移一位,变为4,6准备进队列,此时队列不为空,队尾元素是3,比6小,因此将3出队,并将6继续与队尾元素4比较,4更小,因此将4也出队,继续将6与5比较,5还是小,因此5也出队,此时队列为空,将6入队。目前队列状态为[6]
- 若此时L右移,变为1,那么此时队首元素下标为4,大于1,说明下标为1的元素已经出队了,因此队列仍为[6]
- …
问:为什么3进来的时候非要将1出队?6进来的时候[5 4 3]都可以出队?
因为R既然到了3这个元素,那么1就再也不可能是最大值了(L、R只能向右走,不存在先删除3再删除1的情况)。
6也是同理,既然R已经到了6且6比队列中所有的元素更大,说明到目前为止的窗口中没有谁能比6更大了,而且即便L往右走,也不会影响结果。
练习题 1
上题的输出结果应该是 [5 5 5 4 6 7]
public class MaxWindow {
public int[] getMaxWindow(int[] arr, int w) {
if (arr == null || w < 1 || arr.length < w) {
return null;
}
int[] res = new int[arr.length - w + 1];
int index = 0;
LinkedList<Integer> linkedList = new LinkedList<>();
for (int i = 0; i < arr.length; i++) {
while (!linkedList.isEmpty() && arr[linkedList.peekLast()] <= arr[i]) {
linkedList.pollLast();
}
linkedList.addLast(i);
if (i - w == linkedList.peekFirst()) {
linkedList.pollFirst();
}
if (i + 1 >= w) {
res[index++] = arr[linkedList.peekFirst()];
}
}
return res;
}
public static void main(String[] args) {
int[] arr = new int[] {4, 3, 5, 4, 3, 3, 6, 7};
int[] res = new MaxWindow().getMaxWindow(arr, 3);
System.out.println(Arrays.toString(res));
}
}
练习题 2
这题用暴力做当然可以,但时间复杂度太高:
public int Solution(int[] arr, int num) {
int res = 0;
for (int i = 0; i < arr.length; i++) {
for (int j = i; j < arr.length; j++) {
if (isValid(arr, i, j, num)){
res++;
}
}
}
return res;
}
private boolean isValid(int[] arr, int start, int end, int num) {
int max = Integer.MIN_VALUE;
int min = Integer.MAX_VALUE;
for (int i = start; i <= end; i++) {
max = Math.max(arr[i], max);
min = Math.min(arr[i], min);
}
return (max - min) <= num;
}
下面看看用窗口怎么做这道题:
首先有如下两个事实:
- 对于一个数组的子数组[L, R]是满足题目要求的,即里面的最大值-最小值小于等于给定的值,那么这个子数组的所有子数组肯定都满足要求。因为[L, R]的子数组里面数的范围只可能比[L, R]小,因此子数组的最大值只可能小于等于[L, R]的最大值,子数组的最小值只可能大于等于[L, R]的最小值。
- 若一个子数组已经不满足要求,那么继续扩张也必然不满足要求。因为子数组中的最大值-最小值已经超过给定的值了,那么继续增大子数组范围只会让最大值更大,最小值更小。
基于以上两点,有以下思路:
- 准备两个双端队列,一个队列头存当前遍历到的最大值,就像最开始介绍的那样;另一个队列存当前遍历到的最小值
- 先将子数组左边界L设为0,然后移动右边界R,同时向两个队列中放入元素,直到两个队列头元素之差大于给定值时,R停止移动
- 此时左边界为0,右边界停留在第一个不满足要求的位置,即从左边界到右边界前一个位置的子数组[L, R-1]为满足要求的最长子数组,那么由之前的结论:一个子数组满足要求,那么其所有的子数组肯定也满足要求,因此以左边界开头的所有子数组的数量为(R-1)-L+1,这样就将所有以L开头的数组都求出来了。
举例:设L为0,R为4,那么[0, 3]为最长满足要求子数组,所以[0],[0, 1],[0, 1, 2],[0, 1, 2, 3]都是满足要求的。 - 之所以只算以L开头的子数组而不算以[L, R]之间元素开头的子数组,是因为当前R位置不满足要求,这个不满足要求可能是针对L位置说的,例如有数组[2, 6, 7, 8],L为0,规定的上限值是4.也就是说超过4就不满足要求,那么R最后应该停在2位置,因为7-2=5>4,那么以2开头的子数组为[2],[2, 6],而不算以6开头的子数组是因为R仍然可以向右移,到3位置8-6=2仍然满足要求,所以如果这时求以6开头的子数组你只能得到[6],[6,7],而得不到[6, 7, 8]。因此对于每一个L,要看看R最远能扩到哪才能求出所有的子数组。
代码:
public int Solution2(int[] arr, int num) {
if (arr == null || arr.length == 0) {
return 0;
}
LinkedList<Integer> maxQ = new LinkedList<>();
LinkedList<Integer> minQ = new LinkedList<>();
int start = 0;
int end = 0;
int res = 0;
while (start < arr.length) {
while (end < arr.length) {
while (!maxQ.isEmpty() && arr[maxQ.peekLast()] <= arr[end]) {
maxQ.pollLast();
}
maxQ.addLast(end);
while (!minQ.isEmpty() && arr[minQ.peekLast()] >= arr[end]) {
minQ.pollLast();
}
minQ.addLast(end);
if (arr[maxQ.peekFirst()] - arr[minQ.peekFirst()] > num) {
break;
}
end++;
}
if (start == maxQ.peekFirst()) {
maxQ.pollFirst();
}
if (start == minQ.peekFirst()) {
minQ.pollFirst();
}
res += end - start;
start++;
}
return res;
}
public static void main(String[] args) {
int[] arr = new int[] {2, 6, 7, 8, 12};
System.out.println(new MaxMinusMin().Solution2(arr, 4));
}
结果:
10