monotonic-queue

::: info Prerequisite Knowledge

Before reading this article, you should first learn:

:::

In a previous article Solving Three Algorithm Problems Using Monotonic Stackarrow-up-right, we introduced the special data structure called monotonic stack. Here, we will discuss a similar data structure called "monotonic queue."

You might not have heard of this data structure before. Essentially, it is just a "queue" that uses a clever method to ensure that the elements in the queue are all monotonically increasing (or decreasing).

Why invent a structure like the "monotonic queue"? It is mainly to solve the following scenario:

Given an array window with a known extreme value A, if you add a number B to window, you can immediately calculate the new extreme value by comparing A and B. However, if you remove a number from window, you cannot directly obtain the extreme value. If the removed number happens to be A, you need to traverse all elements in window to find the new extreme value.

This scenario is common, but it seems that a monotonic queue is not necessary. For example, a priority queue (binary heap)arrow-up-right is specifically designed for dynamically finding extreme values. By creating a max (min) heap, you can quickly get the maximum (minimum) value, right?

For simply maintaining the extreme values, a priority queue is professional, with the head of the queue being the extreme value. However, a priority queue cannot satisfy the standard "first-in, first-out" time sequence of a queue structure. This is because a priority queue uses a binary heap to dynamically sort elements, and the order of dequeue is determined by the element size, having no relation to the order of enqueue.

Thus, a new queue structure is needed that can both maintain the "first-in, first-out" time sequence of queue elements and correctly maintain the extreme values of all elements in the queue, which is the "monotonic queue" structure.

The "monotonic queue" is mainly used to assist in solving problems related to sliding windows. In the previous article Core Framework of Sliding Windowarrow-up-right, the sliding window algorithm is explained as a part of the double pointer technique. However, some slightly more complex sliding window problems cannot be solved with just two pointers and require more advanced data structures.

For instance, in the previous article Core Framework of Sliding Windowarrow-up-right, for each problem discussed, whenever the window is expanded (right++) or contracted (left++), you can decide whether to update the answer based solely on the elements entering and leaving the window.

But in the example at the beginning of this article about determining the extreme value in a window, you cannot update the extreme value of the window based solely on the element leaving the window, unless you traverse all elements again, which increases the time complexity, something we do not want to see.

Let's look at LeetCode Problem 239 Sliding Window Maximumarrow-up-right, which is a standard sliding window problem:

Given an array nums and a positive integer k, there is a window of size k that slides from left to right across nums. Please output the maximum value of k elements in the window each time.

The function signature is as follows:

For example, here is a sample problem provided by LeetCode:

Next, we will use a monotonic queue structure to calculate the maximum value in each sliding window in $O(1)$ time, allowing the entire algorithm to be completed in linear time.

1. Building the Solution Framework

Before introducing the API of the "monotonic queue" data structure, let's compare the standard API of a regular queuearrow-up-right with the API implemented by the monotonic queue:

Of course, the implementation of these APIs for a monotonic queue is different from a regular Queue, but for now, let's assume these operations have a time complexity of O(1) and first set up the solution framework for this "sliding window" problem:

This idea is quite simple, right? Now let's start with the main part, the implementation of the monotonic queue.

2. Implementing the Monotonic Queue Data Structure

By observing the sliding window process, we can see that implementing a "monotonic queue" requires a data structure that supports insertion and deletion at both the head and tail. Clearly, a doubly linked listarrow-up-right meets this requirement.

The core idea of the "monotonic queue" is similar to the "monotonic stack." The push method still adds elements at the queue's tail, but it removes elements in front that are smaller than itself:

Imagine that the size of a number represents a person's weight. A heavier weight will flatten the lighter ones in front of it until it encounters a weight of greater magnitude.

If each element is processed in this manner when added, the elements in the monotonic queue will maintain a monotonically decreasing order. This makes our max method straightforward to implement: simply return the front element of the queue. The pop method also operates on the front of the queue. If the front element is the element n to be removed, then delete it:

The reason the pop method checks if n == maxq.getFirst() is that the front element n we want to remove might have been "flattened" during the push process and may no longer exist. In this case, we don't need to remove it:

With this, the design of the monotonic queue is complete. Let's look at the full solution code:

Do not overlook some detailed issues. When implementing MonotonicQueue, we used Java's LinkedList because the linked list structure supports fast insertion and deletion of elements at both the head and tail. Meanwhile, the res in the solution code uses the ArrayList structure, as elements will be accessed by index later, making the array structure more suitable. Pay attention to these details when implementing in other languages.

Regarding the time complexity of the monotonic queue API, readers might be puzzled: the push operation contains a while loop, so the worst-case time complexity should be $O(N)$, and with an additional for loop, the algorithm's time complexity should be $O(N^2)$, right?

Here, we apply amortized analysis as mentioned in the Guide to Algorithm Time and Space Complexity Analysisarrow-up-right:

Looking at the push operation alone, the worst-case time complexity is indeed $O(N)$, but the average time complexity is $O(1)$. Typically, we use average complexity rather than the worst-case complexity to measure API interfaces, so the overall time complexity of this algorithm is $O(N)$, not $O(N^2)$.

Alternatively, analyzing from an overall perspective: the algorithm essentially involves adding and removing each element in nums from the window at most once. It is impossible to repeatedly add and remove the same element, so the overall time complexity is $O(N)$.

The space complexity is easy to analyze, which is the size of the window $O(k)$.

Further Exploration

Finally, I pose a few questions for you to consider:

  1. The MonotonicQueue class provided in this article only implements the max method. Can you add a min method to return the minimum value of all elements in the queue in $O(1)$ time?

  2. The pop method of the MonotonicQueue class provided in this article still requires a parameter, which is not so elegant and goes against the standard queue API. Please fix this defect.

  3. Implement the size method of the MonotonicQueue class to return the number of elements in the monotonic queue (note that each push operation might remove elements from the underlying q list, so the number of elements in q is not the number of elements in the monotonic queue).

In other words, can you implement a general-purpose monotonic queue:

I will provide a general implementation of the monotonic queue and classic exercises in General Implementation and Applications of Monotonic Queuearrow-up-right. For more data structure design problems, see Classic Exercises on Data Structure Designarrow-up-right.

Last updated