单调队列,顾名思义,就是队列中的值要么单调递增要么单调递减。之前做题的时候对这个并没有太在意,但是今天的每日抑题用堆实现的时候发现太耗时了,借此机会好好学一下单调队列。
求满足条件的最长连续子数组的长度,一看就是滑动窗口的题,按着模板来就行,但是这里难的点在于要维护窗口内的最大值和最小值,只要保证在扩张右边界和收缩左边界的时候可以拿到窗口内的最大值和最小值,这个题就迎刃而解了。所以第一想法是用两个堆来分别维护最大值和最小值,代码如下:
public int longestSubarray(int[] nums, int limit) {
int ans = 0;
int left = 0, right = 0;
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
int maxVal = 0, minVal = 0;
while (right < nums.length) {
minHeap.offer(nums[right]);
maxHeap.offer(nums[right]);
minVal = minHeap.peek();
maxVal = maxHeap.peek();
while (maxVal - minVal > limit) {
minHeap.remove(nums[left]);
maxHeap.remove(nums[left]);
minVal = minHeap.peek();
maxVal = maxHeap.peek();
left++;
}
ans = Math.max(ans, right - left + 1);
right++;
}
return ans;
}
除了数据处理和之前写的滑动窗口的题不一样,模板的大致结构还是一样的。(求的是最大值,其实可以按之前博客里写的那样用if判断条件就可以,不用while收缩左边界,感兴趣的可以自己改一下代码)
但是这个代码最大的问题在于remove这个函数,别的语言压根就不提供这个方法,因为堆最重要的只有堆顶,所以删别的数是很麻烦的,看一下源码就知道了:
public boolean remove(Object o) {
int i = indexOf(o);
if (i == -1)
return false;
else {
removeAt(i);
return true;
}
}
private int indexOf(Object o) {
if (o != null) {
for (int i = 0; i < size; i++)
if (o.equals(queue[i]))
return i;
}
return -1;
}
和大家能想到的一样,只能遍历整个数组来删数据,然后还要调整堆,所以这个方法最后耗时200+ms,击败16%。
单调队列
那么更简单的维护最大值和最小值的方法就是用单调队列了,可以保证删数据的时候不用遍历整个数组。先看一个简单的模板题:剑指offer59。这个题用堆当然可以实现,并且也不麻烦,但是下面换单调队列来实现 一下,顺便理解单调队列的思想。
既然是单调队列,那么队列内的数肯定是要单调递增或递减的,然后队首就是我们需要的最大值/最小值。那么应该是单调递增还是单调递减呢?现在要求的是最大值,那么单调队列应该是单调递减的,保证队首永远是当前队列中最大的值。下面举个简单的例子来理解一下。
比如现在有一个普通的队列用来存数:[5, 2, 3, 4, 7, 6, 1],那么它的单调队列应该存什么东西呢?一步步分析:
- 5入队,单调队列同时入队5。此时单调队列为 [5]。
- 2入队,2小于单调队列的队尾5,单调队列入队2,满足单调递减。此时单调队列为 [5, 2]。
- 3入队,3大于单调队列的队尾2,单调队列应该弹出队尾2,此时单调队列为 [5],3小于单调队列的队尾5,单调队列入队3,满足单调递减。此时单调队列为 [5, 3]。
先看到这里,会发现为了满足单调递减的条件,需要不断调整单调队列弹出队尾,所以用的肯定是双端队列,那么意义在哪里呢?为什么3入单调队列的时候,2就一定要弹出去呢?这就需要代入求最大值的场景了,现在存数据的队列为[5, 2, 3],单调队列为[5, 3],假如队列不改变,那么单调队列的队首永远是5,也就是最大值一直是5。现在如果需要弹出普通队列的队首5,那么单调队列也一样要弹出队首,因为单调队列的队首也是5,既然存数据的队列已经弹出5了,那么再求最大值的时候肯定不能是5了。此时存数据的队列为[2, 3],单调队列为[3],是不是发现此时单调队列的队首依然是存数据队列的最大值呢?
这时再想一下为什么3入队,2就要从队尾弹出去。因为3既然比2大了,并且3还比2晚入队列,那么怎么都轮不到2当最大值,因为存数据的是普通队列,是不可能从队尾弹出数据的,所以只要2还在队列中,3就一定在,所以轮不到2当最大值。下面接着分析:
- 4入队,单调队列调整为[5, 4]。
- 7入队,会发现[5, 4]都比7小,所以一块踢出去,然后单调队列调整为[7]。此时队列为[5, 2, 3, 4, 7],单调队列为[7],也就是说前面的数都轮不到当最大值,只能是7,因为7只可能是最后一个被移出队列的。
- 6入队,单调队列入队6。此时单调队列为[7, 6]。
- 1入队,单调队列入队1。此时单调队列为[7, 6, 1]。
最终队列为[5, 2, 3, 4, 7, 6, 1],单调队列为[7, 6, 1]。此时不停从队列中移出队首,会发现单调队列的队首始终是当前队列的最大值,不需要我们去遍历单调队列删除某个数据。可以这么理解:单调队列的队首是当前队列最大值,后面的都是“候选”的最大值。一旦当前最大值在存数据的队列中被移出了,那么“候选”的最大值马上上位。
理解这个例子之后,代码就好写的多了:
public class MaxQueue {
// 单调队列,因为要维护队头的最大值,所以单调队列是单调递减的,存的都是“候选”最大值
private Deque<Integer> max;
// 队列,存所有的数
private Queue<Integer> queue;
public MaxQueue() {
max = new LinkedList<>();
queue = new LinkedList<>();
}
public int max_value() {
if (queue.isEmpty()) {
return -1;
} else {
return max.peekFirst();
}
}
public void push_back(int value) {
queue.offer(value);
// 维护单调队列的单调性
while (!max.isEmpty() && max.peekLast() < value)
max.pollLast();
max.offer(value);
}
public int pop_front() {
if (queue.isEmpty()) {
return -1;
} else {
int poll = queue.poll();
if (poll == max.peekFirst())
max.pollFirst();
return poll;
}
}
}
每日抑题的改进解法
会了单调队列后,就可以对上面的代码进行改进了:
public int longestSubarray(int[] nums, int limit) {
Deque<Integer> max = new LinkedList<>();
Deque<Integer> min = new LinkedList<>();
int left = 0, right = 0, ans = 0;
while (right < nums.length) {
int offer = nums[right];
// 右边界如果比max队列的队尾还大,那么只要这个数还在窗口内,比这个数还小的就不可能是最大值,可以踢出去
while (!max.isEmpty() && offer > max.peekLast()) {
max.pollLast();
}
// 右边界如果比min队列的队尾还小,那么只要这个数还在窗口内,比这个数还大的数就不可能是最小值,可以踢出去
while (!min.isEmpty() && offer < min.peekLast()) {
min.pollLast();
}
max.offer(offer);
min.offer(offer);
while (max.peekFirst() - min.peekFirst() > limit) {
int poll = nums[left];
// 踢出后max和min的队首还分别是最大值和最小值,比堆效率高
if (min.peekFirst() == poll) min.pollFirst();
if (max.peekFirst() == poll) max.pollFirst();
left++;
}
ans = Math.max(ans, right - left);
right++;
}
return ans;
}
耗时30+ms,击败90%,比堆的效率提高太多。