Prerequisites

Before attempting this problem, you should be comfortable with:

  • Stacks - Understanding LIFO operations (push, pop, top) and their O(1) time complexity
  • Balanced Binary Search Trees / Sorted Sets - Using self-balancing trees (TreeSet, SortedList) for O(log n) insertion, deletion, and ordered access
  • Heaps / Priority Queues - Implementing max heaps for efficient maximum element retrieval
  • Lazy Deletion - Deferring actual removal by marking elements as deleted and cleaning up on access

1. Two Balanced Trees

Intuition

A regular stack gives us O(1) access to the top element, but finding or removing the maximum requires scanning. To support efficient peekMax and popMax, we maintain two balanced trees (or sorted sets). One tree orders elements by their insertion index (simulating stack order), while the other orders by value (for quick max access). Each element is stored as a pair of (index, value). When we pop or popMax, we remove from both structures to keep them synchronized.

Algorithm

  1. Maintain two balanced trees: stack keyed by insertion count, and values keyed by value.
  2. push(x): Insert (cnt, x) into stack and (x, cnt) into values. Increment cnt.
  3. pop(): Remove the last element from stack (highest index), then remove the corresponding entry from values. Return the value.
  4. top(): Return the value of the last element in stack.
  5. peekMax(): Return the value of the last element in values (highest value).
  6. popMax(): Remove the last element from values, then remove the corresponding entry from stack. Return the value.
from sortedcontainers import SortedList

class MaxStack:

    def __init__(self):
        self.stack = SortedList()
        self.values = SortedList()
        self.cnt = 0

    def push(self, x: int) -> None:
        self.stack.add((self.cnt, x))
        self.values.add((x, self.cnt))
        self.cnt += 1

    def pop(self) -> int:
        idx, val = self.stack.pop()
        self.values.remove((val, idx))
        return val

    def top(self) -> int:
        return self.stack[-1][1]

    def peekMax(self) -> int:
        return self.values[-1][0]

    def popMax(self) -> int:
        val, idx = self.values.pop()
        self.stack.remove((idx, val))
        return val

Time & Space Complexity

  • Time Complexity: O(logN)O(\log N) for each operation except for initialization. All operations other than initialization involve finding/inserting/removing elements in a balanced tree once or twice. In general, the upper bound of time complexity for each of them is O(logN)O(\log N). However, note that top and peekMax operations, which require only the last element in a balanced tree, can be done in O(1)O(1) with set::rbegin() in C++ and special handling on the last element of SortedList in Python. However, last for TreeSet in Java hasn't implemented similar optimization yet, so we have to get the last element in O(logN)O(\log N).

  • Space complexity: O(N)O(N) the maximum size of the two balanced trees.

Where NN is the number of elements to add to the stack.


2. Heap + Lazy Update

Intuition

We can use a standard stack for regular push/pop/top operations and a max heap to quickly find the maximum. The challenge is that removing an element from one structure does not automatically remove it from the other. We solve this with lazy deletion: when we remove an element, we record its index in a removed set. Before accessing the top of the stack or heap, we skip over any elements that have been marked as removed. This defers the actual cleanup until it is needed.

Algorithm

  1. Maintain a stack for LIFO access, a max heap for maximum access, and a removed set for tracking deleted indices.
  2. push(x): Add (x, cnt) to both stack and heap. Increment cnt.
  3. pop(): Skip elements at the top of stack that are in removed. Pop the top element, add its index to removed, and return the value.
  4. top(): Skip removed elements at the top of stack, then return the top value.
  5. peekMax(): Skip removed elements at the top of heap, then return the top value.
  6. popMax(): Skip removed elements at the top of heap. Pop the top, add its index to removed, and return the value.
class MaxStack:

    def __init__(self):
        self.heap = []
        self.cnt = 0
        self.stack = []
        self.removed = set()

    def push(self, x: int) -> None:
        heapq.heappush(self.heap, (-x, -self.cnt))
        self.stack.append((x, self.cnt))
        self.cnt += 1

    def pop(self) -> int:
        while self.stack and self.stack[-1][1] in self.removed:
            self.stack.pop()

        num, idx = self.stack.pop()
        self.removed.add(idx)

        return num

    def top(self) -> int:
        while self.stack and self.stack[-1][1] in self.removed:
            self.stack.pop()

        return self.stack[-1][0]

    def peekMax(self) -> int:
        while self.heap and -self.heap[0][1] in self.removed:
            heapq.heappop(self.heap)

        return -self.heap[0][0]

    def popMax(self) -> int:
        while self.heap and -self.heap[0][1] in self.removed:
            heapq.heappop(self.heap)
            
        num, idx = heapq.heappop(self.heap)
        self.removed.add(-idx)

        return -num

Time & Space Complexity

  • Time Complexity:

    • push: O(logN)O(\log N). It costs O(logN)O(\log N) to add an element to the heap and O(1)O(1) to add it to the stack.
    • The amortized time complexity of operations caused by a single pop / popMax call is O(logN)O(\log N). For a pop call, we first remove the last element in the stack and add its ID to removed in O(1)O(1), resulting in the deletion of the top element in the heap in the future (when peekMax or popMax is called), which has a time complexity of O(logN)O(\log N). Similarly, popMax needs O(logN)O(\log N) immediately and O(1)O(1) for the operations later. Note that because we lazy-update the two data structures, future operations might never happen in some cases. However, even in the worst cases, the upper bound of the amortized time complexity is still only O(logN)O(\log N).
    • top: O(1)O(1), excluding the time cost related to popMax calls we discussed above.
    • peekMax: O(1)O(1), excluding the time cost related to pop calls we discussed above.
  • Space Complexity: O(N)O(N), the maximum size of the heap, stack, and removed.

Where NN is the number of elements to add to the stack.


Common Pitfalls

Forgetting to Synchronize Both Data Structures

When using two balanced trees (or a stack + heap), removing an element from one structure without removing the corresponding entry from the other leads to stale data. Both pop and popMax must update both structures to maintain consistency.

Incorrect Handling of Duplicate Values

Multiple elements can have the same value but different insertion indices. Using only the value as a key causes collisions and incorrect behavior. Always pair values with unique identifiers (like insertion count) to distinguish between duplicates.

Not Implementing Lazy Deletion Properly

In the heap + lazy update approach, forgetting to skip removed elements before accessing the top of the stack or heap returns invalid data. The cleanup loop must run before every top, pop, peekMax, and popMax operation.

Using Wrong Comparator for Max Heap

When implementing a max heap, accidentally using a min heap comparator (or vice versa) returns the wrong maximum element. Ensure the comparator orders elements so the largest value and most recent index appear at the top.

Integer Overflow in Counter

If the counter used for unique indices is not managed properly and the stack experiences many push operations, the counter could overflow. While rare in practice, using a sufficiently large integer type prevents this edge case.