Stacks (with "max" method)
1. Problem statement
Implement a stack with "max" method (Aziz et al., 2018, p. 106).
2. Insights
A stack supports two basic operations, push and pop, to add and remove elements from the stack in first-in-last-out (aka "FILO") order.
Keeping track of the max value is easy when we push in new elements into the stack. But when we pop an element out and it also happens to be the current max, how can we reconstruct the previous max value? We'd have to keep track of this somehow.
One way is to use a cache: for each element, we record what the current max value is. So we store both the element itself and the current max value on every push. Then when we pop this item out, we don't lose any information (we just look at the max value of the last-inserted item). This way we don't have to go through the entire stack (\(O(n)\) time complexity) to see which is the next max value.
3. Solutions
3.1. Store (elt, max)
We represent the stack with a linked list. We already implemented a linked list in "Linked lists", so we just reuse that here.
Aziz et al. (2018) use a native Python list to represent the stack. This is simpler to implement. But we choose the linked list instead because it more closely mirrors a true stack — namely, we are somewhat forced look at the last-inserted element only (unlike a list where one can do an index lookup to get any element in constant time).
3.1.1. Insertion (push)
Insertion into a stack is done by adding an item to the end of the list. The linked list implementation we use inserts items by prepending to the linked list. This is already what we need.
What we need to add is the tracking of the max element whenever we push into the stack. So instead of storing just the element itself, we store a separate max value that represents the max value of all previously-pushed elements.
3.1.1.1. Complexity
- Time: \(O(1)\).
- Space: \(O(n)\), where \(n\) is the number of elements in the stack.
3.1.2. Max
For getting the max value, we just return the max
variable that we saved along
with the element.
3.1.3. Deletion (pop)
Popping isn't really special at all — we just make sure to:
- delete the element, and
- return the value of the element to the caller.
3.1.3.1. Complexity
- Time: \(O(1)\).
- Space: \(O(n)\), where \(n\) is the number of elements in the stack.
3.1.4. Print (__repr__
)
This is useful for debugging. The code here follows the __repr__
example from
CPython's datetime implementation.
3.1.5. Size
3.1.6. Equality
3.2. Use two stacks
The downside of storing the max element with every element is that it can be wasteful. For example, if the stack has 100 elements of the same value, most of the max value information would be redundant.
We can deduplicate this redundancy by using an auxiliary stack, whose elements
are of the form (max, count)
. The count
simply stores how many times the
current-highest max value is stored in the stack (so that popping it off would
reduce this count value). The primary stack would simply store the elements as
they are.
We inherit from the StackNaive
class because we can reuse some of the existing
code.
The main thing is that we have to add a new secondary stack (ll_max
) to keep
track of the maximum values.
3.2.1. Insertion (push)
Insertion is pretty simple: we first insert the element into the main stack,
ll
.
Now we have to update ll_max
. The first edge case is if we are inserting the
very first element into the main stack. If so, then by default this element is
the current maximum, and so we insert it immediately. We set this element's
counter to 1
as it is the first max item at this value.
Now comes the case where we are inserting into a many-element stack. In this
case we have to consider what is the current (leading) max value, and only
insert into ll_max
if the just-pushed element is at least the same size or
greater the current maximum.
If the element is the same size as the current maximum, we just bump this maximum value's counter.
Otherwise, we have to insert a new value into ll_max
, just like we did for the
case above for inserting into an empty stack.
3.2.2. Max
Getting the max value is as easy as just observing the top of the ll_max
stack.
3.2.3. Deletion (pop)
First we have to delete the element from the main stack.
If we just popped the last element of the stack, then the stack is empty and
there are no more maximum values, and so we also delete whatever last item
remaining in ll_max
.
If the stack is not empty, then we have to check whether the just-popped element
was equal to the current max value. That is, we can ignore the case where the
element was smaller than the current max. E.g., if the stack is [3, 2, 1]
with
3
as the first element, then the current max is 3
and popping either the 1
or the 2
will have no effect on the max. This is why we can ignore such cases.
It is also impossible for the element to be greater than the current max value, because of how we handle insertions (we update the max if the just-pushed element is greater than the current max).
So assuming that the just-popped element is equal to the max, we have to
decrement the max value's counter in ll_max
by 1. We do this by first deleting
the max value, and restoring it to its counter with its decremented value, but
only if it is above 1 (if zero, then we have to delete this max value outright).
3.2.4. Print (__repr__
)
Printing is straightforward — we just reuse the __repr__()
from the parent
class StackNaive
, but also marshal the second stack ll_max
.
3.2.5. Equality
Equality simply checks whether both the primary and secondary stacks are equal.
3.2.6. Complexity
- Time: \(O(1)\).
- Space: \(O(1)\) in the best case (when the maximum changes infrequently, such as
the very first element being the max), \(O(n)\) in the worst case where every
single newly-pushed element is greater than all previously-pushed elements
(such that
ll_max
grows at the same pace asll
).
4. Tests
The from __future__ import annotations
line is to enable self-referencing type
annotations.
4.1. Initialization
Here are some basic checks regarding initialization.
4.2. Insertion (push)
4.3. Size
4.4. Equality
4.5. Deletion (pop)
4.6. Max API
The stack should maintain the current max value as we push and pop elements with it.
5. Export
Make this library available in other problems.
from __future__ import annotations from typing import Any, Optional code