Jc-alt logo
jc
76 min read
#data structures and algorithms
Table Of Contents

Stack Intro

LeetCode problems with elegant solutions using stacks.

Stack Application: Tracking Nested or Hierarchical Structures

We can track structure while iterating over an object ensuring it maintains some criteria

Ex: Validate if a string containing brackets ()[] is properly balanced:

    def balancedParentheses(s: str) -> bool:
        stack = []
        pairs = {')': '(', ']': '[', '}': '{'}
        for char in s:
            if char in pairs.values():
                stack.append(char)
            elif char in pairs:
                if not stack or stack.pop() != pairs[char]:
                    return False
        return not stack

Stack Application: Backtracking by Tracking History or State

We can use stacks in backtracking to store the state of exploration. When a branch reaches a dead end or a solution, we pop the state to return to the previous state and continue exploring other branches.

Ex: Subset Sum with Backtracking

    def subset_sum(nums, target):
        stack = [(0, [], 0)]  # (index, current_subset, current_sum)
        result = []
        
        while stack:
            index, current_subset, current_sum = stack.pop()
            
            if current_sum > target:  # Prune invalid paths
                continue
            
            if current_sum == target:  # Valid solution
                result.append(list(current_subset))
                continue
            
            # Push new states for further exploration
            for i in range(index, len(nums)):
                stack.append((i + 1, current_subset + [nums[i]], current_sum + nums[i]))
        
        return result

    # subset_sum([2, 3, 6, 7], 7) = [[7]]

Stack Application: Monotonic Property Maintenance

A stack can maintain a monotonic property (increasing or decreasing) over a sequence while processing elements, ensuring efficient lookups or modifications.

Ex: Find the Next Greater Element

    def nextGreaterElement(nums):
        stack = []  # Stores indices of elements in decreasing order
        result = [-1] * len(nums)  # Initialize result with -1
        
        for i in range(len(nums)):
            while stack and nums[i] > nums[stack[-1]]:
                idx = stack.pop()
                result[idx] = nums[i]  # Found the next greater element
            stack.append(i)
        
        return result

    # Example: nextGreaterElement([2, 1, 2, 4, 3]) -> [4, 2, 4, -1, -1]

Stack Application: Simulating Recursion or Call Stacks

We can use a stack to emulate recursion by explicitly managing the call stack.

Ex: Traverse a binary tree in preorder (root -> left -> right):

    def preorderTraversal(root):
        if not root:
            return []
        
        stack = [root]  # Start with the root node
        result = []
        
        while stack:
            node = stack.pop()  # Simulate recursion by processing the top of the stack
            if node:
                result.append(node.val)  # Visit the node
                # Push right child first so the left child is processed next
                if node.right:
                    stack.append(node.right)
                if node.left:
                    stack.append(node.left)
        
        return result

    # Example: For a tree with root → 1, left → 2, right → 3, preorderTraversal(root) -> [1, 2, 3]

Stack Application: Expression Evaluation and Parsing

We can use a stack to evaluate or parse expressions by storing operands and incrementally applying operators. This approach is well-suited for postfix and prefix notations.

Ex: Post and Prefix

    def evaluatePostfix(expression):
        stack = []  # To hold operands during evaluation
        
        for token in expression.split():
            if token.isdigit():  # If it's an operand, push it to the stack
                stack.append(int(token))
            else:  # If it's an operator, pop two operands and apply the operator
                b = stack.pop()
                a = stack.pop()
                if token == '+':
                    stack.append(a + b)
                elif token == '-':
                    stack.append(a - b)
                elif token == '*':
                    stack.append(a * b)
                elif token == '/':  # Assuming integer division
                    stack.append(a // b)
        
        return stack.pop()  # Final result is the only item left in the stack

    # Example:
    # Input: "3 4 + 2 * 1 +"
    # Output: 15 (Equivalent to (3 + 4) * 2 + 1)

Stack Application: Dynamic Programming State Compression

We can use a stack to compress the necessary state while scanning through data, especially when enforcing a specific constraint or invariant like monotonicity. Instead of storing the entire history, we prune irrelevant elements from the stack to keep only the most useful summary of the past

Ex: Given an array, partition it into the minimum number of strictly increasing subsequences

    def min_partitions(nums):
        stacks = []  # Each element represents the last number in a subsequence
        
        for num in nums:
            placed = False
            for i in range(len(stacks)):
                # If we can append to subsequence i
                if stacks[i] < num:
                    stacks[i] = num
                    placed = True
                    break
            if not placed:
                # Start a new subsequence (partition)
                stacks.append(num)
        return len(stacks)

    # Example usage:
    nums = [1, 3, 2, 4, 6, 5]
    print(min_partitions(nums))  # Output: 2

Stack Application: Interval and Range Processing

We can use stacks to efficiently process intervals or ranges, such as merging overlapping intervals, calculating spans, or finding next/previous smaller or larger elements within a range.

Ex: Largest Rectangle in Histogram

    def largestRectangleArea(heights):
        stack = []  # stores indices of bars
        max_area = 0
        
        for i, h in enumerate(heights + [0]):  # Add sentinel to flush stack
            while stack and heights[stack[-1]] > h:
                height = heights[stack.pop()]
                left = stack[-1] if stack else -1
                width = i - left - 1
                max_area = max(max_area, height * width)
            stack.append(i)
        
        return max_area

    # Example:
    # Input: [2, 1, 5, 6, 2, 3]
    # Output: 10  (largest rectangle is formed by heights 5 and 6)

20. Valid Parentheses ::2:: - Easy

Topics: String, Stack

Intro

Given a string s containing: ( ) [ ] { }, determine if the input string is valid. An input string is valid if: 1. Open brackets must be closed by the same type of brackets 2. Open brackets must be closed in the correct order. 3. Every close bracket has a corresponding open bracket of the same type.

InputOutput
"()"true
"()"false
"(]"true
"([])"true

Constraints:

1 ≤ s.length ≤ 104

s consists of parentheses only '()[]'

Abstract

We need to validate that every open parenthesis has a matching close in the correct order.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
Brute Force (replace with empty)
Stack (redundant operations)
Stack

Brute Force: Replace Pairs with empty

    def isValid(self, s: str) -> bool:
        
        # note: 
        # in python, an empty string "" is falsy evaluating to false
        # not "" -> true

        # each iterate removes at least one pair 
        # there are at most n/2 pairs so O(n/2) iterations O(n/2)
        # each iteration takes O(n)
        # leading to: O(n) * O(n/2) = O(n^2)
        # time complexity: iterate over string of n length O(n)
        while '()' in s or '{}' in s or '[]' in s:
            # time complexity: n/2 replacements O(n/2), per total outer iterations O(n), leading to O(n^2)
            s = s.replace('()', '').replace('{}', '').replace('[]', '')
        
        # overall: time complexity O(n^2)
        # overall: space complexity O(1)
        return not s
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Iterate over stringO(n)O(1)Iterate over array of n length O(n)No additional memory allocation for iteration O(1)
Total IterationsO(n/2)O(1)At most n/2 pairs and thus n/2 removals O(n/2)No additional memory allocation for iteration O(1)
OverallO(n2)O(1)Single iteration in O(n) for total iterations n/2 O(n/2), leading to O(n2) time complexityNo additional memory allocation for iteration, leading to constant O(1)

Find the Bug: Hashmap Count (does not track order)

    def isValid(self, s: str) -> bool:

        # time complexity: constant hashmap of 3 length O(1)
        counts = {"(": 0, "[": 0, "{": 0}
        closing = {")": "(", "]": "[", "}": "{"}

        # time complexity: iterate over string of n length O(n)
        for c in s:

            # Count opening brackets
            # time complexity: lookup operation in constant O(1)
            if c in counts:  
                counts[c] += 1

            # Count closing brackets
            # time complexity: lookup operation in constant O(1)
            else c in closing:  

                closingMatch = closing[c]

                # No matching opening bracket
                if counts[closingMatch] == 0:  
                    return False

                # Matching opening bracket
                counts[closingMatch] -= 1

        # INCORRECT: hashmap open value may be 0, but did not take into 
        # consideration whether they were in the correct order
        # time complexity: iterate over hashmap of 3 length O(1)
        for count in counts.values():
        if count != 0:
            return False

        # overall: time complexity O(n)
        # overall: space complexity O(1)
        return True
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Iterate
Verify
Overall

Find the Bug: Forgot to append to stack

    def isValid(self, s: str) -> bool:

        stack = []
        mapping = {
            ')' : '(',
            ']' : '[',
            '}' : '{'
        }

        for c in s:

            if c in mapping:

                if not stack:
                    return False

                topElem = stack.pop()

                if mapping[c] != topElem:
                    return False 

            # INCORRECT:
            # missing else clause to append char to stack
            # should be:
            # else:
            #   stack.append(c)

        return not stack

Find the Bug: Did not check for empty Stack

    def isValid(self, s: str) -> bool:

        stack = []

        mapping = {
            ')' : '(',
            ']' : '[',
            '}' : '{'
        }

        for c in s:
            
            if c in mapping:

                if not stack:
                    return False

                topElem = stack.pop()

                if mapping[c] != topElem:
                    return False
            else:
                stack.append(c)
        
        # INCORRECT:
        # stack could still have elements and be invalid
        return True

Solution 1: Manual Condition Stack Check [TC Opt] - Stack/Tracking Nested or Hierarchical Structures

    def isValid(self, s: str) -> bool:

        # Empty List In Python []:
        # An empty list [] is falsy evaluating to false
        # not [] -> true

        # sc: relative to input O(n)
        stack = []

        # tc: iterate list O(n)
        for c in s:

            # found close bracket, match with open

            # tc: constant length list check effectively constant O(1)
            if c in ')]}':

                # check if stack is empty, no open to match
                # tc: len check O(1)
                if not stack:
                    return False

                # tc: pop constant O(1)
                topElem = stack.pop()

                # check if open bracket matches
                # tc: equal in constant O(1)
                if  ((c == ')' and topElem != '(') or
                     (c == '}' and topElem != '{') or
                     (c == ']' and topElem != '[')):
                    return False

            # found open bracket, push to stack

            # tc: constant length list check effectively constant O(1)
            if c in '([{':

                # tc: list append amortized O(1)
                stack.append(c)
                               
        # stack is empty, success

        # overall: tc O(n)
        # overall: sc O(n)
        return not stack
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
IterationO(n)O(n)Iteration string of n length O(n)Memory allocation for n opening brackets O(n)
ValidationO(1)O(1)Pop in constant O(1) and char comparison in constant O(1)No additional memory allocation for pop or comparison O(1)
OverallO(n)O(n)Iteration over string of n length dominates, leading to O(n)Memory allocation for stack with n opening brackets dominates, leading to O(n)

Solution 2: Stack with Hashmap lookup [TC Opt] - Stack/Tracking Nested or Hierarchical Structures

    def isValid(self, s: str) -> bool:

        # sc: relative to input O(n)
        stack = []

        # bracket mapping: closed -> open

        # sc: create hashmap fixed entries O(1)
        mapping = {
            ')' : '(',
            ']' : '[',
            '}' : '{'
        }

        # tc: iterate over string of n length O(n)
        for c in s:
            
            # found close bracket, match open
            if c in mapping:

                # check if stack is empty, no open to match
                # tc: len check O(1)
                if not stack:
                    return False

                # tc: pop constant O(1)
                topElem = stack.pop()

                # check if open bracket matches
                # tc: equal in constant O(1)
                if mapping[c] != topElem:
                    return False
            
            # found opening bracket, push to stack
            else:

                # tc: list append amortized O(1)
                stack.append(c)
                
        # stack is empty, success

        # overall: time complexity O(n)
        # overall: space complexity O(n)
        return not stack
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
IterationO(n)O(n)Iteration over string of n length O(n)Memory allocation for stack for n opening brackets O(n)
ValidationO(1)O(1)Pop in constant O(1) and char comparison in constant O(1)No additional memory allocation for pop or comparison O(1)
OverallO(n)O(n)Iteration over string of n length dominates, leading to O(n)Memory allocation for stack for n opening brackets dominates, leading to O(n)

155. Min Stack ::2:: - Medium

Topics: Stack, Design

Intro

Design a stack that supports push, pop, top, and retrieving the minimum element in constant time. Implement the MinStack class: MinStack() initializes the stack object. void push(int val) pushes the element val onto the stack. void pop() removes the element on the top of the stack. int top() gets the top element of the stack. int getMin() retrieves the minimum element in the stack. You must implement a solution with O(1) time complexity for each function.

InputOutput
["MinStack","push","push","push","getMin","pop","top","getMin"] [[],[-2],[0],[-3],[],[],[],[]][null,null,null,null,-3,null,0,-2]

Constraints:

-231 ≤ val ≤ 231 - 1

Methods pop, top and getMin operations will always be called on non-empty stacks.

At most 3 * 104 calls will be made to push, pop, top, and getMin.

Abstract

We need to design a stack that runs in O(1) time complexity for each main function.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark

Brute Force:

    def __init__(self):
        
        # tracking logical size vs physical size
        # space complexity: stores all n elements O(n)
        self.stack = []
        self.size = 0

    def push(self, val: int) -> None:
        # time complexity: append operation in constant O(1)

        # size = 1     vs [5, _, _]
        # logical size vs physical size -> 
        # stack will only grow when absolutely necessary / avoids unnecessary resizing
        if self.size < len(self.stack):
            # self.size == length, so append at self.size
            self.stack[self.size] = val
        else:
            # stack needs to grow
            self.stack.append(val)

        # increases logical size
        self.size += 1

    def pop(self) -> None:       
        if self.size == 0:
            raise IndexError("Pop from empty stack")

        # size = 2 [5, 4]
        # size = 1 [5, 4]
        # logical size vs physical size -> 
        # time complexity: pop operation in constant O(1)

        # decreases logical size
        self.size -= 1

    def top(self) -> int:
        if self.size == 0:
            raise IndexError("Stack is empty")
 
         # time complexity: index top element in constant O(1)
        return self.stack[self.size - 1]

    def getMin(self) -> int:
        if self.size == 0:
            raise IndexError("Stack is empty")
        
        minVal = float('inf')

        # stack is not minSorted
        # time complexity: iterate over entire stack n length O(n)
        for i in range(self.size):
            if self.stack[i] < minVal:
                min = self.stack[i] 
        return minVal

    # overall: time complexity O(n)
    # overall: space complexity O(n)
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

    def __init__(self):
        # space complexity: 
        self.stack = []
        self.size = 0
        self.minVal = float('inf')

    def push(self, val: int):
        # time complexity:
        if self.size < len(self.stack)
            self.stack[self.size] = val
        else:
            self.stack.append(val)

        # increases logical size
        self.size += 1

        if val < self.minVal:
            self.minVal = val

    def pop(self):
        # time complexity:
        if self.size == 0:
            raise IndexError("Pop from empty stack")
        
        # decreases logical size
        # INCORRECT: does not update minVal when the popped value is the minVal
        # Stack implementation does not track previous minVal for updates
        self.size -= 1

        
    def top(self):
        # time complexity:
        if self.size == 0:
            raise IndexError("Stack is empty")
        
        return self.stack[self.size - 1]

    def getMin(self):
        # time complexity:
        return self.minVal

    # overall: time complexity
    # overall: time complexity 
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Solution 1: 2 Stacks For Main and Min - Stack/Dynamic Programming State Compression

class MinStack:

    # Main + MinTracker Stack:
    # Tracks the min value at each level of the main stack. 

    # overall: sc O(n)
    def __init__(self):

        # sc: two stacks for n elements O(n)

        # all values
        self.mainStack = []
        # minimum value at current level
        self.minTracker = []
        # pointer to the curr element
        self.logicalSize = 0

    # helper:
    # edge case checker for empty stack
    def _validateNotEmpty(self, errMsg: str):
        if self.logicalSize == 0:
            raise IndexError(errMsg)

    # helper:
    # checks if logical pointer is behind actual length of stack
    def _checkLogicalVsActualSize(self, stack):
        return self.logicalSize < len(stack)
            
    # overall: tc O(1)
    def push(self, val: int):

        # If logical pointe ris behind actual length, we replace at pointer
        # else append to end of the stack
        if self._checkLogicalVsActualSize(self.mainStack):
            self.mainStack[self.logicalSize] = val
        else:
            self.mainStack.append(val)

        # new min:
        # check if non empty stack, if so compare against previous min
        # else update to new value
        currMin = min(val, self.minTracker[self.logicalSize - 1] if self.logicalSize > 0 else val)

        # If logical pointer is behind actual length, we replace at pointer
        # else append to end of the stack
        if self._checkLogicalVsActualSize(self.minTracker):
            self.minTracker[self.logicalSize] = currMin
        else:
            self.minTracker.append(currMin)
        
        # increase logical size
        self.logicalSize += 1

    # overall: tc O(1)
    def pop(self):
        # validate non empty, decrease logical length
        self._validateNotEmpty("Invalid pop(), stack is empty")
        self.logicalSize -= 1

    # overall: tc O(1)
    def top(self):
        # validate non empty, return elem
        self._validateNotEmpty("Invalid top(), stack is empty")
        return self.mainStack[self.logicalSize - 1]

    # overall: tc O(1)
    def getMin(self):
        # validate non empty, return min
        self._validateNotEmpty("Invalid getMin(), stack is empty")
        return self.minTracker[self.logicalSize - 1]

    # overall: tc O(1)
    # overall: sc O(n)
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
PushO(1)O(1)Insert to main and min stack in constant O(1)No additional memory allocation for push operation O(1)
PopO(1)O(1)Decrease logical length in constant O(1)No additional memory allocation for logical length decrease O(1)
TopO(1)O(1)Indexing into main array in constant O(1)No additional memory allocation for indexing array O(1)
getMinO(1)O(1)Indexing into min array in constant O(1)No additional memory allocation for indexing array O(1)
OverallO(1)O(n)Each individual operation takes constant O(1)MainStack and minTracker memory allocation dominate for n values, leading to O(n)

Solution 2: Tuple Stack - Stack/Dynamic Programming State Compression

class MinStack:

    # MinTracker Tuple Stack: 
    # We combine the main and minTracker stack with a tuple (val, min), 
    # tracking the min at each level of the stack.

    # overall: sc O(n)
    def __init__(self):

        # sc: stores all pushed tuples (val, min) O(n)
        self.stack = []
        self.size = 0

    # helper:
    # edge case checker for empty stack
    def _validateNotEmpty(self, errMsg: str):
        if self.size == 0:
            raise IndexError(errMsg)
 
    # helper:
    # checks if logical pointer is behind actual length of stack
    def _checkLogicalVsActualSize(self):
        return self.size < len(self.stack)

    # overall: tc O(1)
    def push(self, val: int):

        # new min:
        # check if non empty stack, if so compare against previous min
        # else update to new value
        currMin = min(val, self.stack[self.size - 1][1] if self.size > 0 else val)
        
        # If logical pointer is behind actual length, we replace at pointer
        # else append to end of the stack
        if self._checkLogicalVsActualSize():
            self.stack[self.size] = (val, currMin)
        else:
            self.stack.append((val, currMin))

        # increase logical size
        self.size += 1

    # overall: tc O(1)
    def pop(self):
        self._validateNotEmpty("Invalid pop(), stack is empty")
        self.size -= 1

    # overall: tc O(1)
    def top(self):
        self._validateNotEmpty("Invalid top(), stack is empty")
        return self.stack[self.size - 1][0]

    # overall: tc O(1)
    def getMin(self):
        self._validateNotEmpty("Invalid getMin(), stack is empty")
        return self.stack[self.size - 1][1]

    # overall: tc O(1)
    # overall: sc O(n)
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
PushO(1)O(1)Insert into stack in constant O(1)No additional memory allocation for insert O(1)
PopO(1)O(1)Decrease logical length in constant O(1)No additional memory allocation for decreasing logical length O(1)
TopO(1)O(1)Indexing into array in constant O(1)No additional memory allocation for indexing O(1)
getMinO(1)O(1)Indexing into array in constant O(1)No additional memory allocation for indexing O(1)
OverallO(1)O(n)Each individual operation takes constant O(1)Stack memory allocation dominates for n values, leading to O(n)

150. Evaluate Reverse Polish Notation ::1:: - Medium

Topics: Array, Stack, Math, Design

Intro

You are given an array of strings tokens that represents an arithmetic expression in a Reverse Polish Notation. Evaluate the expression. Return an integer that represents the value of the expression. Note that: The valid operators are '+', '-', '*', and '/'. Each operand may be an integer or another expression. The division between two integers always truncates toward zero. There will not be any division by zero. The input represents a valid arithmetic expression in a reverse polish notation. The answer and all the intermediate calculations can be represented in a 32-bit integer.

InputOutput
["2","1","+","3","*"]9
["4","13","5","/","+"]6
["10","6","9","3","+","-11","","/","","17","+","5","+"]22

Constraints:

1 ≤ tokens.length ≤ 104

tokens[i] is either an operator: "+", "-", "*", or "/", or an integer in the range [-200, 200].

Abstract

We're designing abstract syntax tree that when execute, will execute the given operations in reverse polish notation.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark

Brute Force:

    def evalRPN(self, tokens: List[str]) -> int:
        stack = []

        for token in tokens:
            if token not in "+-*/":
                # Push operand onto stack
                stack.append(int(token))
            else:
                # Pop two operands
                b = stack.pop()
                a = stack.pop()
                
                # Perform operation and push result
                if token == "+":
                    stack.append(a + b)
                elif token == "-":
                    stack.append(a - b)
                elif token == "*":
                    stack.append(a * b)
                elif token == "/":
                    stack.append(int(a / b))  # Truncate towards 0

        return stack.pop()
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug: Operand Order

    def evalRPN(self, tokens: List[str]) -> int:
        stack = []

        for token in tokens:
            if token not in "+-*/":
                stack.append(int(token))
            else:

                # INCORRECT order of popping operands
                a = stack.pop()  # a should be second operand
                b = stack.pop()  # b should be the first operand
                
                if token == "+":
                    stack.append(a + b)  # Incorrect operand order
                elif token == "-":
                    stack.append(a - b)  # Incorrect operand order
                elif token == "*":
                    stack.append(a * b)  # Correct, since order doesn't matter
                elif token == "/":
                    stack.append(int(a / b))  # Incorrect operand order

        return stack.pop()

Find the Bug: Pushing Characters onto stack instead of Int

    def evalRPN(self, tokens: List[str]) -> int:
        
        stack = []

        for token in tokens:

            # found operand, push to stack
            if token not in "+-*/":
                
                # INCORRECT:
                # pushed chars instead of int onto stack
                stack.append(token)
            
            # found operator, calculate
            else:

                # stack:
                # [1, 6, 3] 
                # INCORRECT:
                # should not need to do int() when popping
                # this is a band aid 
                b = int(stack.pop()) # b
                a = int(stack.pop()) # a

                if token == "+":
                    stack.append(a+b)
                elif token == "-":
                    stack.append(a-b)
                elif token == "*":
                    stack.append(a*b)
                elif token == "/":

                    # truncate towards 0
                    # -5 / 2 = -2.3333 -> -2.0
                    # not floor, but int()
                    stack.append(int(a/b))

        # return top of stack
        # INCORRECT:
        # stack is list of characters, will return "2" instead of 2
        # error fixed when appending
        return stack[-1]

Solution 1: Stack Postfix Expression Evaluation Algorithm - Stack/Expression Evaluation and Parsing

    def evalRPN(self, tokens: List[str]) -> int:
        
        # Reverse Polish Notation:
        # We only push numbers to the stack,
        # and when we hit an operator we pop b the a
        # since the numbers are on the stack in reverse

        # input:    ["4","13","5","/","+"]
        # expected: 4 + (13 / 5) = 6
        # "4"    -> [4]         push() 4 to stack
        # "13"   -> [4, 13]     push() 13 to stack
        # "5"    -> [4, 13, 5]  push() 5 to stack
        # "/"    -> [4]         hit operator, pop() b = 5, pop() a = 13, complete operation int(13 / 5) = 2
        # "2"    -> [4, 2]      push() 2 to stack
        # "+"    -> []          hit operator, pop() b = 2, pop() a = 4, finish operator (4 + 2) = 6
        # "6"    -> [6]         push() 6 to stack

        # [6] stack holds answer

        # sc: stack holds up to n/2 intermediate results O(n)
        stack = []

        # tc: iterate over n O(n)
        for token in tokens:

            # Found Integer:
            # Cast to int and push() to stack
            if token not in "+-*/":
                stack.append(int(token))
            
            # Found Operation:
            # Pop() 2 numbers from stack, b then a, and compute
            else:

                # tc: pop operation constant O(1)
                b = stack.pop()
                a = stack.pop()

                # Complete Operation: 
                # Push() result to stack
                match token:
                    case "+":
                        stack.append(a + b)
                    case "-":
                        stack.append(a - b)
                    case "*":
                        stack.append(a * b)
                    case "/":
                        
                        # a / b
                        # 13 / 5

                        # Explicit truncation towards zero
                        # -7 / 3         # -2.333  division:        Remainder                                         x
                        # -7 // 3        # -3      floor division:  Rounds down "towards infinity"                    x
                        # int(-7 / 3)    # -2      int(division):   Rounds up "towards 0", as required by RPN      this one

                        stack.append(int(a / b))

        # Top of stack holds result
        res = stack[-1]

        # overall: tc O(n)
        # overall: sc O(n)
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Operands StackO(1)O(n)Insert into operands stack in constant O(1)Stack will store at most n/2 operands, leading to O(n)
Token iterationO(n)O(1)Iteration over tokens array of n length O(n)No additional memory allocation for iteration O(1)
Stack operationsO(1)O(1)Push and pop from stack in constant O(1)No additional memory allocation for stack operations O(1)
Stack operationsO(1)O(1)Add, sub, multi, and div all in constant O(1)No additional memory allocation for operations
OverallO(n)O(n)Iterating over tokens array dominates, leading to O(n)Operands stack for token array of n length dominates, leading to O(n)

22. Generate Parentheses ::4:: - Medium

Topics: String, Stack, Dynamic Programming, Backtracking

Intro

Given n pairs of parentheses, write a function to generate all combinations of well-formed parentheses.

InputOutput
1["()"]
3["((()))","(()())","(())()","()(())","()()()"]

Constraints:

1 ≤ n ≤ 8

Abstract

Given a number, generate all possible combinations of parentheses.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark

Brute Force:

    def generateParenthesis(self, n: int) -> List[str]:
        
        # generates all possible combinations of n pairs of parentheses
        def generate_combinations(current, length, combinations):
            
            # base case: if 
            if length == 2 * n:
                combinations.append(current)
                return

            # recursively add '(' and ')' to the current string to generate all possible combinations
            generate_combinations(current + "(", length + 1, combinations)
            generate_combinations(current + ")", length + 1, combinations)

        # checks if given parentheses string is valid
        def isValid(s: str) -> bool:
            # counter to track balance of parentheses
            balance = 0

            # iterate through parentheses string
            for char in s:
                
                # increment balance
                if char == '(':
                    balance += 1
                # decrement balance
                else:
                    balance -= 1
                # closing parenthesis has no matching
                if balance < 0:
                    return False

            # validate all parentheses have match
            return balance == 0

        # grab all possible combinations
        combinations = []
        generate_combinations("", 0, combinations)

        result = []
        
        # generate and validate all combinations
        for combo in combinations:
            if isValid(combo):
                result.append(combo)
        
        # overall: time complexity
        # overall: space complexity
        return result
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug: Adding Closing Parentheses Before Opening

    def generateParenthesis(self, n: int) -> List[str]:
        
        # Initialize a stack to simulate depth-first search (DFS)        
        # (currString, openParenCount, closedParenCount)
        stack = [("", 0, 0)]
        result = []

        # while stack is non-empty
        while stack:

            # 
            current, openCount, closeCount = stack.pop()

            # if we have a valid combination of n '(' and n ')' add to list
            if openCount == n and closeCount == n:
                result.append(current)
                continue

            # INCORRECT: condition allows ')' to be added even if they exceed the number of '('
            # should be `closeCount < openCount`
            if closeCount < n:
                stack.append((current + ")", openCount, closeCount + 1))
            if openCount < n:
                stack.append((current + "(", openCount + 1, closeCount))

        # overall: time complexity
        # overall: space complexity
        return result

Find the Bug: Else clause instead of Adding Closed Parentheses based on Open Count

    def generateParenthesis(self, n: int) -> List[str]:
       
        res = []

        def helper(curr, open_count, close_count):

            if open_count + close_count == (2*n):
                res.append("".join(curr))
                return

            if open_count < n:
                curr.append('(')
                helper(curr, open_count + 1, close_count)
                curr.pop()
            
            # INCORRECT:
            # we only want to add a closing parentheses if the closed count 
            # is less than the opening parentheses count
            # to ensure that every closed has a matching open
            # Instead:
            # if close_count < open_
            else:
                curr.append(')')
                helper(curr, open_count, close_count + 1)
                curr.pop()

            
        helper([], 0, 0)

        return res

Find the Bug: Mutually exclusive if and if instead of if and elif

    def generateParenthesis(self, n: int) -> List[str]:
       
        res = []

        def helper(curr, open_paren, close_paren):

            if open_paren == n and close_paren == n:
                res.append("".join(curr))
                return

            if open_paren < n:
                curr.append('(')
                helper(curr, open_paren + 1, close_paren)
                curr.pop()
            
            # INCORRECT:
            # the two if statements should be mutually exclusive
            # we do not one to skip the elif, when the if is true
            # Instead:
            # if close_paren < open_paren
            elif close_paren < open_paren:
                curr.append(')')
                helper(curr, open_paren, close_paren + 1)
                curr.pop()

        helper([], 0, 0)

        return res

Find the Bug: list.append() modifies in place and returns None

    def generateParenthesis(self, n: int) -> List[str]:
       
        res = []

        def helper(curr, open_count, close_count):

            if open_count + close_count == (2*n):
                res.append("".join(curr))
                return

            if open_count < n:
                # INCORRECT:
                # treating list as string
                # list.append() modifies the list in-place and returns None.
                # So curr.append('(') does append the character, but returns None
                # instead:
                # curr.append()
                # helper(curr, open_count + 1, close_count)
                # curr.pop()
                helper(curr.append('('), open_count + 1, close_count)

            if close_count < open_count:
                # INCORRECT:
                # treating list as string
                # list.append() modifies the list in-place and returns None.
                # So curr.append('(') does append the character, but returns None
                # instead:
                # curr.append()
                # helper(curr, open_count + 1, close_count)
                # curr.pop()
                helper(curr.append(')'), open_count, close_count + 1)

        stack = []
        helper(stack, 0, 0)

        return res

Find the Bug: Iterative Affects Other Branches Instead of Recursive

    def generateParenthesis(self, n: int) -> List[str]:

        # Note:
        # Explicit stack simulates backtracking process iteratively.
        # Each stack element stores the current string and counts of open and closed parentheses.
        # Allows state tracking without recursion.

        # Store valid parentheses
        result = []

        # Tracks state (current_string, open_count, closed_count)
        stack = [([], 0, 0)] 

        # Simulate backtracking
        # time complexity: each state processed once, building up to O(Catalan(n)) strings
        # space complexity: stack can grow to O(Catalan(n)), result stores O(Catalan(n)) strings
        while stack:

            # Pop current state, current state is explored only once
            curr, openCount, closeCount = stack.pop()

            # Base case: reached valid string
            if openCount == n and closeCount == n:
                result.append("".join(curr))
                continue

            # Check: open count is less than total expected
            # Ensures: string is always valid, with total number of parens being valid
            # Iterative step 1: add '('
            if openCount < n:
                
                # INCORRECT:
                # since there is only 1 curr,
                # appending will affect all branches
                # Instead:
                # make a copy
                # new_curr = curr + ['(']
                curr.append('(')
                stack.append((curr, openCount + 1, closeCount))

            # Check: closed count is less than open count
            # Ensures: string is always valid, with each open having a matching close
            # Iterative step 2: add '('
            if closeCount < openCount:
                # INCORRECT:
                # since there is only 1 curr,
                # appending will affect all branches
                # Instead:
                # make a copy
                # new_curr = curr + ['(']
                curr.append(')')
                stack.append((curr, openCount, closeCount + 1))

        # overall: time complexity O(Catalan(n) * n)
        # overall: space complexity O(Catalan(n) * n)
        return result

Find the Bug: Forgot to initialize 0th level of DP

    def generateParenthesis(self, n: int) -> List[str]:

        dp = [[] for i in range (n+1)]

        # INCORRECT:
        # dp is never initialized, so it can never be built upon
        # Instead:
        # dp[0] = [""]

        # generate for level i pairs
        for i in range(n+1):

            # iterate over created lists up to i
            for j in range(i):

                # forward iteration
                for left in dp[j]:
                    # backward iteration
                    for right in dp[i - 1 - j]:
                        dp[i].append(f"({left}){right}")


        return dp[n]

Find the Bug: Forgot to update Parentheses count

    def generateParenthesis(self, n: int) -> List[str]:

        res = []

        def helper(currList, openCount, closedCount):

            if openCount + closedCount == (2*n):
                res.append("".join(currList))
                return

            if openCount < n:
                currList.append("(")
                helper(currList, openCount+1, closedCount)
                currList.pop()

            if closedCount < openCount:
                currList.append(")")
                
                # INCORRECT:
                # closed count was not updated
                # Instead:
                # helper(currList, openCount, closedCount + 1)
                helper(currList, openCount, closedCount)
                currList.pop()

        
        helper([], 0, 0)

        return res

Find the Bug: Cannot initialize empty list with multiplication, use bucket sort method

def generateParenthesis(self, n: int) -> List[str]:

        # INCORRECT:
        # Instead:
        # dp = [[] for i in range(n+1)]
        dp = [] * (n+1)

        dp[0] = [""]

        for i in range(n+1):

            for j in range(i):

                for left in dp[j]:
                    for right in dp[i-1-j]:
                        dp[i].append(f"({left}){right}")

        return dp[n]

Solution 1: [Backtracking] Recursive with Immutable String Concatenation - Stack/Backtracking by Tracking History or State

    def generateParenthesis(self, n: int) -> List[str]:

        # String Immutability:
        # Every time we do current + "(" in python a new string is created
        # and the entire contents of current are copied.
        # Since this happens at every recursion level and there are Catalan(n) valid sequences,
        # the time becomes O(Catalan(n) * n^2)
        # due to copying. 
        # Solution 2 has a time of O(Catalan(n) * n), this is here to show the slow version

        # Temporary/Working Memory:
        # We also use more memory with strings as along one recursion path we create ~n strings
        # each of up to length n, so peak temporary memory is O(n^2)
        # Solution 2 has temporary memory of O(n), since we have a single stack we are popping/appending from

        # Backtracking:
        # Explores all valid sequences of parentheses.
        # A new string is created on each recursive call using string 
        # concatenation for string length up to 2n leading to O(n^2).

        res = []

        # tc: each recursion path creates  explores a state; only leaf call does ''.join(), leading to O(n)
        # sc: call stack depth is O(n), string copying adds O(n) per leaf path, leading to O(n^2)
        def dfs_backtrack(current: List[str], num_open: int, num_closed: int):

            # Process Root: reached valid string 
            if len(current) == n * 2:
                res.append(current)
                return

            # Explore Choice 1: add '('
            # Check: open count is less than total expected
            # Implies: adding '(' will create valid string
            if num_open < n: 

                # Build: append open '('
                # tc: concat O(n)
                new_str = current + "("

                # Explore: recursion with new string copy
                dfs_backtrack(new_str, num_open + 1, num_closed)

                # Backtrack: no need to modify copy as original string still exists

            # Explore Choice 2: add ')'
            # Check: closed count is less than open count
            # Implied: adding ')' is required for valid string
            if num_closed < num_open:
                
                # Build: append close ')'
                # tc: concat per iteration O(n)
                new_str = current + ")"
    
                # Explore: recursion with new string copy
                dfs_backtrack(new_str, num_open, num_closed + 1)

                # Backtrack: no need to modify copy as original string still exists

        # Initial call, empty list passed
        dfs_backtrack("", 0, 0)

        # overall: tc O(Catalan(n) * n^2)
        # overall: sc O(Catalan(n) * n)
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Recursive PathsO(Catalan(n))O(n)Explanation of Catalan Derivation
String concatenationO(n) per pathO(n) per resultExplanation of total O(n2) is the same as the double for loop derivation
OverallO(Catalan(n) * n2)O(Catalan(n) * n)Catalan(n) unique strings with copy cost dominates as string grows O(n2) per solution dominates, leading to O(Catalan(n) * n2)Catalan(n) valid strings each of length 2n dominates, leading to O(Catalan(n) * n)

Solution 2: [Backtracking] Recursive with Mutable List Appending - Stack/Backtracking by Tracking History or State

    def generateParenthesis(self, n: int) -> List[str]:
        
        # List Mutability:
        # We use a single list curr and modify it in place using append/pop.
        # Append() and pop() are both constant O(1) and no copying happens at each recursion step.
        # The list only gets converted once we have found a valid combination using "".join(curr).
        # Since there are Catalan(n) valid sequences 
        # the time becomes O(Catalan(n) * n) which is faster than the string version of O(Catalan(n) * n^2)

        # Temporary/Working Memory:
        # We use a single stack to track our changes, so peak temporary memory is O(n)

        # Backtracking:
        # Explores all valid sequences of parentheses.
        # Only when a complete sequence is found (length == 2 * n), 
        # we convert the list to a string via concat in O(n)

        res = []
        
        # tc: each recursion explores a state; only leaf call does ''.join(), leading to O(n)
        # sc: call stack depth O(n), current list holds up to 2n
        def dfs_backtrack(current, openCount, closeCount):

            # Base case: 
            # If we have used all open and close parentheses
            if openCount == n and closeCount == n:
                # tc: convert once at leaf for 2n length O(n)
                res.append("".join(current))
                return

            # Recursive case 1: add '('
            # Check: open count is less than total expected
            # Ensures: string is always valid, with total number of parens being valid
            if openCount < n: 

                # Build: append open '('
                current.append('(')

                # Explore: recurse with updated list
                helper(current, openCount + 1, closeCount)

                # Backtrack: remove last open '('
                current.pop()
            
            # Recursive case 1: add ')'
            # Check: closed count is less than open count
            # Ensures: string is always valid, with each open having a matching close
            if closeCount < openCount:

                # Build: append close ')'
                current.append(')')

                # Explore: recurse with updated list
                helper(current, openCount, closeCount + 1)

                # Backtrack: remove last open '('
                current.pop()

        # Initial call, empty list passed
        backtrack([], 0, 0)

        # overall: tc O(Catalan(n) * n)
        # overall: sc O(Catalan(n) * n)
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Recursive pathsO(Catalan(n))O(n)Exponential branchingStack depth of 2n
String creationO(n) per complete pathO(n) per resultOnly joined once per resultEach string result is size 2n
OverallO(Catalan(n) * n)O(Catalan(n) * n)Avoids repeated copiesStores all valid combinations

Solution 3: [Backtracking] Iterative Stack State Tracking To Mimic Recursion - Stack/Backtracking by Tracking History or State

    def generateParenthesis(self, n: int) -> List[str]:

        # Explicit Iterative Stack:
        # Simulates backtracking process iteratively by storing state on the stack.
        # Each state element holds the current string and counts of open and closed parentheses,
        # which allows state tracking without recursion

        # Store valid parentheses
        result = []

        # Tracks state (current_string, open_count, closed_count)
        stack = [([], 0, 0)] 

        # Simulate backtracking
        # tc: each state processed once, building up to O(Catalan(n)) strings
        # sc: stack can grow to O(Catalan(n)), result stores O(Catalan(n)) strings
        while stack:

            # Process Root:
            # Grab/pop current state from stack, current state is explored only once
            curr, openCount, closeCount = stack.pop()

            # Process Root: reached valid string
            if openCount == n and closeCount == n:
                result.append("".join(curr))
                continue

            # Explore Choice 1: add '('
            # Check: open count is less than total expected
            # Implies: we have space to add an open parenthesis
            if openCount < n:
                
                # Build: append open '('
                # Creating copy of list to avoid mutating other branches,
                # (cannot do curr.append('(') because that would modify the original list)
                new_curr = curr.copy()
                new_curr.append('(')

                # Explore: iterative recursion by putting new state on stack for future exploration
                stack.append((new_curr, openCount + 1, closeCount))

                # Backtrack:
                # No need to .pop() as the pop() from the stack will remove this new state eventually

            # Explore Choice 2: add ')'
            # Check: closed count is less than open count
            # Implies: we have a matching open for our closed parenthesis
            if closeCount < openCount:

                # Build: append close ')'
                # Creating copy of list to avoid mutating other branches,
                # (cannot do curr.append(')') because that would modify the original list)
                new_curr = curr + [')']

                # Explore: iterative recursion by putting new state on stack for future exploration
                stack.append((new_curr, openCount, closeCount + 1))

                # Backtrack:
                # No need to .pop() as the pop() from the stack will remove this new state eventually

        # overall: tc O(Catalan(n) * n)
        # overall: sc O(Catalan(n) * n)
        return result
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
States exploredO(Catalan(n))O(n) per pathOne entry and exit per valid stateStack holds up to O(Catalan(n)) intermediate states
Mutable list opsO(1) per opO(n) per pathAppend and pop are in-place (no copies)Each state contains up to 2n
Final string joinO(n) per resultO(n) per resultEach completed list is joined into a string onceOutput list contains O(Catalan(n)) strings, each of length O(n)
OverallO(Catalan(n) * n)O(Catalan(n) * n)One entry and exit per valid state exploring ?Final output dominates space; stack and temp lists also contribute O(n) each

Solution 4: [Dynamic Programming] Two Pointer Opposite Ends Catalan Pattern To Build Parentheses Combinations - Stack/Dynamic Programming State Compression

    def generateParenthesis(self, n: int) -> List[str]:
        
        # Dynamic Programming:
        # We exploit the recursive structure of Catalan numbers
        # by building a list of all valid parentheses combinations for each level n

        # Two Pointer:
        # If we have a list of valid combinations of parenthesis, 
        # we can append them in a format that generate more valid combinations

        # dp:    list of list of parentheses combinations 
        # dp[n]: list containing all valid parenthesis combinations for the nth level (n pair of parenthesis)
        dp = []
        for _ in range(n + 1):
            dp.append([])     

        # Base case: valid combination for n = 0
        dp[0] = [""]  
        
        # Iterate: create valid lists from dp[1] to dp[n]
        # Current: building list for ith pair
        # tc: iterate over list of n length O(n) 
        for i in range(1, n + 1): 

            # iterate over stored lists up to now
            for j in range(i):  

                # Opposite Ends Two Pointer Variation:
                # Since pointers will meet in middle and cross each other, 
                # they will get all variations of combinations already built,
                # which allows us to format them to generate more valid combinations

                # Setup: Opposite Ends Two Pointer Variation
                # Forward and Reverse Iteration

                # Forward Iteration List 1: 
                # Iterate over each valid string of parentheses for level j,
                # starting at first parentheses pairs level of 0 pairs of parenthesis
                for left in dp[j]:

                    # Reverse Iteration List 2: 
                    # Iterate over each valid string of parentheses for level (i - 1 - j),
                    # starting at last parenthesis pairs level of largest number pairs currently available
                    for right in dp[i - 1 - j]:

                        # Generation Format:
                        # ({left}){right} or {left}({right}) are both valid formulas
                        # Since we are doing Opposite Ends and left and right will cross over each other,
                        # left and right will both individually use all values
                        # which means with these 2 iterations
                        # we are eventually doing both the same thing
                        # dp[i].append(f"{left}({right})")
                        dp[i].append(f"({left}){right}")

        # overall: tc O(Catalan(n) * n)
        # overall: sc O(Catalan(n) * n)
        return dp[n]
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
DP State buildO(Catalan(n))O(Catalan(n))Combines all previous resultsdp[0] to dp[n] built cumulatively
String constructionO(n) per combinationO(n) per resultEach result takes O(n) to formFinal dp[n] holds all combinations
OverallO(Catalan(n) * n)O(Catalan(n) * n)No recursion but same asymptotic boundStore all intermediate and final results

739. Daily Temperatures ::3:: - Medium

Topics: Array, Stack, Monotonic Stack, Two Pointers, Dynamic Programming

Intro

Given an array of integers temperatures represents the daily temperatures, return an array answer such that answer[i] is the number of days you have to wait after the ith day to get a warmer temperature. If there is no future day for which this is possible, keep answer[i] == 0 instead.

InputOutput
[30,40,50,60][1,1,1,0]
[30,60,90][1,1,0]
[73,74,75,71,69,72,76,73][1,1,4,2,1,1,0,0]

Constraints:

1 ≤ temperatures.length ≤ 105

30 ≤ temperatures[i] ≤ 100

Abstract

Generate array representing the number of days until a warmer temperature. Generate array representing number of days until monotonic decreasing is valid.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark

Brute Force: Stack

    def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
        
        # space complexity: 
        n = len(temperatures)
        res = [0] * n

        # time complexity: iterate over list of n length O(n)
        for i in range(n):

            # time complexity: iterate over list of n length per outer iteration O(n^2)
            for j in range(i + 1, n):

                # found higher temperature, calculate difference
                if temperatures[j] > temperatures[i]:
                    res[i] = j - i
                    break  # Stop once the first warmer day is found
        
        # overall: time complexity O(n^2)
        # overall: space complexity O(1)
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug: Forgot to Push onto Stack

    def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
        
        # space complexity:
        st = []
        res = [0] * len(temperatures)

        # time complexity:
        for i in range(len(temperatures)):

            # 
            while st and temperatures[i] > temperatures[st[-1]]:
                idx = st.pop()
                res[idx] = i - idx  # Correctly compute days difference

            # INCORRECT: current day i is never pushed 

        return res

Find the Bug: Comparing Indexes to Temperatures, instead of Temp to Temp

    def dailyTemperatures(self, temperatures: List[int]) -> List[int]: 

        # foward track colder days
        # monotonic decreasing
        stack = []

        res = [0] * len(temperatures)

        # time complexity: iterate over index temperatures
        for i in range(len(temperatures)):

            # Check: stack is non empty
            # Check: if current breaks monotonic decreasing
            # Implies: have found higher temperature
            # Then: pop and calculate

            # INCORRECT:
            # stack[-1] is an index of a temperature
            # and we are comparing it to a temperature
            # Instead:
            # while stack and temperature[stack[-1]] < temperatures[i]:
            while stack and stack[-1] < temperatures[i]:

                #   0  1  2  3
                # [ 8, 6, 3, 9]
                # 9 pops ever element
                # index of lower temperature
                lowerTempIndex = stack.pop()

                # calculate distance between higher temp and curr low temp
                res[lowerTempIndex] = i - lowerTempIndex

            stack.append(i)

        return res

Find the Bug: Pop with If Statement instead of a While Loop

    def dailyTemperatures(self, temperatures: List[int]) -> List[int]:  

        # foward track colder days
        # monotonic decreasing
        stack = []

        res = [0] * len(temperatures)

        # time complexity: iterate over index temperatures
        for i in range(len(temperatures)):

            # Check: stack is non empty
            # Check: if current breaks monotonic decreasing
            # Implies: have found higher temperature
            # Then: pop and calculate

            # INCORRECT:
            # using if statement to pop()
            # which i\
            # instead of using while loop to pop
            # while monotonic property is broken
            # 
            if stack and temperatures[stack[-1]] < temperatures[i]:

                #   0  1  2  3
                # [ 8, 6, 3, 9]
                # 9 pops ever element
                # index of lower temperature
                lowerTempIndex = stack.pop()

                # calculate distance between higher temp and curr low temp
                res[lowerTempIndex] = i - lowerTempIndex

            stack.append(i)

        return res

Find the Bug: Added extra if else statement during day calculation

    def dailyTemperatures(self, temperatures: List[int]) -> List[int]:

        dp = [0] * len(temperatures)

        hottest_day = temperatures[len(temperatures)-1]

        for i in range(len(temperatures)-2, -1, -1):

            if temperatures[i] >= hottest_day:
                hottest_day = temperatures[i]
            else:
                
                j = i+1
                # INCORRECT:
                # no need for if else statement,
                # as hottest_day check + while loop guarantees
                # there to be a hotter day eventually
                # Instead:
                # remove if else, leave while loop:
                # j = i+1
                # while temperatures[i] >= temperatures[j]:
                #   j += dp[j]
                # dp[i] = j - i
                if temperatures[i] < temperatures[j]:
                    dp[i] = j-i

                else:
                    while temperatures[i] >= temperatures[j]:
                        j += dp[j]
                    dp[i] = j - i
        return dp

Find the Bug: Reverse Iteration, forgot to pop matching old hot Temperatures

    def dailyTemperatures(self, temperatures: List[int]) -> List[int]:

        # monotonic decreasing 
        stack = []
        res = [0] * len(temperatures)


        for i in range(len(temperatures)-1, -1, -1):

            # monotonic decreasing broken
            # INCORRECT:
            # only popping index if temperature is greater than
            # however, we also want to pop if new hot temperature
            # matches old ho temperature as we want to track 
            # more recent days 
            # Instead:
            # while stack and temperatures[stack[-1]] <= temperatures[i]
            while stack and temperatures[stack[-1]] < temperatures[i]:
                stack.pop()

            # there is a hotter temperature left
            if stack:
                res[i] = stack[-1] - i

            stack.append(i)

        return res

Find the Bug: Dynamic, forgot to replace hottest_day with equal temperatures

    def dailyTemperatures(self, temperatures: List[int]) -> List[int]:

        n = len(temperatures)
        dp = [0] * n

        hottest_day = temperatures[n-1]

        for i in range(n-2, -1, -1):

            # INCORRECT:
            # need to remove to equal hot day as well
            # Instead:
            # if temperatures[i] >= hottest_day:
            if temperatures[i] > hottest_day:
                hottest_day = temperatures[i]

            else:
                j = i + 1

                while temperatures[i] >= temperatures[j]:
                    j += dp[j]

                dp[i] = j-i

        return dp

Solution 1: [Monotonic] Monotonic Decreasing Stack of Cold Temps - Stack/Monotonic Property Maintenance

    def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
        
        # Monotonic Stack: 
        # A stack that maintains monotonic decreasing temperatures 
        # When monotonic decreasing rule breaks, that means we have found a temperature 
        # that is hotter than at least 1 of the previous temperatures.
        
        # Stack:
        #      *                                   *                             *                        *                       *
        #      *  *                                *  *                          *  *                     *  *                    *  *    
        #      *  *             *                  *  *             *            *  *          *          *  *       *            *  *  * 
        #      *  *  *          *                  *  *  *          *            *  *  *       *          *  *       *            *  *  * 
        #      *  *  *          *                  *  *  *          *            *  *  *       *          *  *       *            *  *  * 
        #      *  *  *  *   +   *                  *  *  *  *   +   *            *  *  *   +   *          *  *   +   *            *  *  * 
        #     ------------     ---       ==>      ------------     ---    ==>   ---------     ---   ==>  ------     ---   ==>   --------- 
        #      0  1  2  3       4      calc wait   0  1  2  3       4            0  1  2       4          0  1       4   join()   0  1  4
        #     older          hot day     days          
        #                                          Day 3 waits 1 day           Day 2 waits 2 days     Day 4 is colder than Day 1

        # sc: list for wait time for n temperatures O(n)
        n = len(temperatures)
        res = [0] * n

        # sc: stores indices for up to n temperatures O(n)
        stack = []

        # tc: iterate over n O(n)
        for i in range(n):

            # Check: if stack is non empty, need to verify monotonic decreasing
            # Check: if monotonic decreasing broken, current temperature will serve as hot day 
            # Implies: we need to pop off the stack until monotonic decreasing is true again
            # Implies: for those temperatures popped, we can calculate a hot day wait time
            while stack and temperatures[stack[-1]] < temperatures[i]:
                
                # i: hot day index
                # stack[-1]: cold day index
                coldDayIndex = stack.pop()
                hotDayIndex = i

                # hot day is the closest hotter day for the cold day,
                # get distance between indexes to get how many days must be waited
                coldDayWaitTime = hotDayIndex - coldDayIndex

                # set wait time for current cold day
                res[coldDayIndex] = coldDayWaitTime


            # Check: appending temperature will keep monotonic decreasing rule
            # Implies: this is the coldest day on the stack
            stack.append(i)  
        
        # overall: time complexity O(n)
        # overall: space complexity O(n)
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Result ListO(n)O(n)Initialization of list for n temperature wait days O(n)Stores wait time for n temperatures O(n)
IterationO(n)O(1)Iteration over list of temperatures n length O(n)No additional memory allocation for iteration O(1)
Stack operationsO(n)O(n)Each index is pushed and popped at most once for n length O(n)Monotonic stack stores at most n indices O(n)
Temperature comparisonsO(1)O(1)Comparison operation in constant O(1)No additional memory allocation for comparison O(1)
OverallO(n)O(n)Iteration over temperatures dominates, leading to O(n)Stack and result list dominates, leading to O(n)

Solution 2: [Reverse] [Monotonic] Monotonic Decreasing Stack of Warm Temps [SC Opt] - Stack/Monotonic Property Maintenance

    def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
        
        # Aggressive Pruning:
        # Reverse tends to use less actual in memory.
        # In the reverse, we are only keep track of hot temperatures in monotonic decreasing order
        # ensuring we have a list of the most recent hot temperatures to ensure minimum wait time for cold
        # days. So due to the aggressive pruning of colder temperatures during reverse traversal
        # this tends to use less memory

        # Temperatures:
        # temp:[ 1, 2, 4, 5, 4, 3, 1]
        # index: 0  1  2  3  4  5  6

        # Monotonic increasing stacks when iterating backwards, represent decreasing temperatures
        #                                        
        #               *                       *
        #            *  *                       *  *      
        #         *  *  *                       *  *  *
        #         *  *  *                       *  *  *  
        #      *  *  *  *                       *  *  *  *  
        #     ---------------     which      ---------------    none of these have hotter days,
        #      6  5  4  3   ... represents      3  4  5  6      so we do not care about monotonic increasing stacks
        

        # Monotonic decreasing stacks when iterating backwards, represent increasing temperatures
        #                                        
        #      *                                        *
        #      *  *                                  *  *  
        #      *  *                                  *  *
        #      *  *  *                            *  *  *
        #      *  *  *  *                      *  *  *  *  
        #     ---------------     which      ---------------   all of these have hotter days,
        #      3  2  1  0   ... represents     0  1  2  3      so we care about monotonic decreasing stacks


        # Forward vs Backwards Stack Tracking:
        # So the difference
        #       Forward iteration: we are tracking decreasing temperatures, in order to calculate distance when a hotter day appears
        #       Backwards iteration: we are tracking increasing temperatures, in order to keep track of the most recent hotter temperatures

        # sc: storing up to n indexes O(n)
        n = len(temperatures)
        stack = []

        # sc: storing wait days for n temperatures
        res = [0] * n

        # tc: iterate over list of n temperatures O(n)
        for i in range(n-1, -1, -1):

            # Check: monotonic decreasing
            # Implies: Keep list of decreasing temp of hot temps
            # Check: if monotonic decreasing broken, we have found a more recent hotter temperature
            # Then: pop() older hot temperature with new hotter temperature to ensure minimum wait time for cold days
            while stack and temperatures[stack[-1]] <= temperatures[i]:
                stack.pop()

            # Check: if stack is not empty
            # Implies: stack has a hotter temperature, can calculate wait days for current cold day
            if stack:

                # Hot and cold days
                # Hot and cold days
                futureHotDayIndex = stack[-1]
                currentColdDayIndex = i

                # Wait time calculation
                # Wait time calculation
                waitDays = futureHotDayIndex - currentColdDayIndex
                res[i] = waitDays
                res[i] = waitDays

            # Invariant: current cold day could serve as potential more recent hot day for earlier days for earlier days, append to stack
            # Invariant: this cold day will get replaced if a more recent hotter day appears
            stack.append(i)        
        
        # overall: time complexity O(n)
        # overall: space complexity O(n)
        return res
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Result listO(n)O(n)Stores wait day for n temperatures O(n)Stores wait day for n temperatures O(n)
Reverse IterationO(n)O(1)Iteration over list of n temperatures O(n)No additional memory allocation for iteration O(n)
Stack operationsAmortized O(n)O(n)Each element is pushed and popped at most once in for n elements Amortized O(n)Stack holds unresolved future warmer day indices for up to n temperatures O(n)
Temperature comparisonO(1)O(1)Comparison in constant O(1)No additional memory allocation for comparison O(1)
OverallO(n)O(n)Iteration over list of n temperatures dominates, leading to O(n)Efficient pruning of colder temperatures often leads to smaller stack than forward pass but still for n elements O(n)

Solution 3: [Reverse] [Dynamic Programming] Reverse Iteration With Jump Traversal Using Dynamic Programming Building Future Warm Temperatures List [TC Opt] - Stack/Algorithm

    def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
        
        # Dynamic Programming:
        # Uses a jump approach building a next hottest day hopping path as we compute hotter days.
        # Avoids a stack of temperatures by iterating backwards and keeping 
        # track of the nearest hottest day, which can be used by other days to find 
        # their own hotter day.

        # Jump Traversal:
        # If we are trying to find the hottest day for index i, 
        # we know that our first candidate is the following day. 
        # If the following day is not the nearest hottest day, 
        # then we know that the first candidate needs a day hotter than this first candidate.
        # That implies, that the day at index i and the first candidate share the nearest hottest day.
        # By that logic, we can simply 'jump' to the candidate's own hotter day and use it as 
        # the next candidate for index i.
        
        # Essentially, days in the future help days in the past by building shortcuts to hotter days.
        
        # sc: result list for storing wait times o(n)
        n = len(temperatures)
        dp = [0] * n

        # Setting first hottest day as the last element
        hottest_day = n - 1

        # Reverse iteration to build future temperature list
        # tc: iterate over n O(n)
        for i in range(n - 2, -1, -1):

            # Check: current temperature is warmer than current hottest day
            # Implies: no future hotter day exists, no need for wait days calculation
            # Then: update the hottest day
            if temperatures[i] >= temperatures[hottest_day]:
                hottest_day = i

            # Check: current temperature is colder than current hottest day
            # Implies: current temperature has at least 1 hotter day in the future
            # Then: calculate wait days for current temperature
            else:

                # Current temperatures' first hotter day candidate is the following day
                tempCandidateIndex = i + 1

                # Check: continue while the candidate is colder than the current temperature
                while temperatures[tempCandidateIndex] <= temperatures[i]:
                    
                    # Implies: candidate is not hotter
                    # Then: to avoid checking days that are colder than the current candidate, we can
                    # skip to the candidates own hotter day, so we never decrease in temperature moving forward
                    tempCandidateIndex += dp[tempCandidateIndex]

                # Check: found a temperature hotter than current temperature
                # Then: calculate distance between hotter day and current temperature
                dp[i] = tempCandidateIndex - i 

        # overall: tc O(n)
        # overall: sc O(n)
        return dp
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Result listO(n)O(n)Storing next hottest day distance for n temperatures O(n)Storing next hottest day distance for n temperatures O(n)
Hottest day trackerO(1)O(1)Single variable used to track hottest future day O(1)Constant memory allocation O(1)
Jump pointer searchAmortized O(n)O(1)
OverallO(n)O(n)Iteration over list of temperatures n length dominates, leading to O(n)

853. Car Fleet ::2:: - Medium

Topics: Array, Stack, Sorting, Monotonic Stack

Intro

There are n cars at given miles away from the starting mile 0, traveling to reach the mile target. You are given two integer array position and speed, both of length n, where position[i] is the starting mile of the ith car and speed[i] is the speed of the ith car in miles per hour. A car cannot pass another car, but it can catch up and then travel next to it at the speed of the slower car. A car fleet is a car or cars driving next to each other. The speed of the car fleet is the minimum speed of any car in the fleet. If a car catches up to a car fleet at the mile target, it will still be considered as part of the car fleet. Return the number of car fleets that will arrive at the destination.

InputOutput
target = 10, position = [3], speed = [3]1
target = 100, position = [0,2,4], speed = [4,2,1]1
target = 12, position = [10,8,0,5,3], speed = [2,4,1,1,3]3

Constraints:

1 ≤ n ≤ 105

0 < target ≤ 1010

0 ≤ position[i] < target

All of values of position are unique

0 < speed[i] ≤ 106

Abstract

Given a list of speeds, determine how many cars will catch up to another and end up bumper to bumper (fleet) before arriving to the destination.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark

Brute Force: Stack

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug: Base Case Add if Stack Empty or New Fleet

    def carFleet(self, target: int, position: List[int], speed: List[int]) -> int:
        stack = []

        stack

        for (pos,spd) in sorted(zip(position, speed), reverse=True):
            timeToTarg = (target-pos)/spd

            # new fleet
            # INCORRECT:
            # fleet is never added to fleet stack,
            # here, we only need to check if a fleet exists
            # if grab, else initialize first fleet
            # Instead:
            # if not stack or timeToTarg > stack[-1]
            if stack and timeToTarg > stack[-1]:
                stack.append(timeToTarg)

        return len(stack)

Solution 1: [Monotonic] [Sorting] Increasing Stack of Slower/Higher Fleet Times Tracking Monotonic Pattern [TC Opt] - Stack/Monotonic Property Maintenance

    def carFleet(self, target: int, position: List[int], speed: List[int]) -> int:
        
        # Sorting By Distance To Target: 
        # We sort the cars by order of closest to target to farthest from target 
        # (higher value is closer, smaller value is further).
        
        # Sorting By Distance Allows For 2 implications:
        #  1. Farther cars with lower arrival times (faster) than the car directly in front of it
        #     will catch up with the car in front of it and form a fleet.
        #    The entire fleet will then be determined by the slower car that was just caught up with (think of traffic)
        #
        #  2. Farther cars with higher arrival times (slower) than the car directly in front of it
        #     will never catch up with the car in front of it, 
        #    thus, the slower car will be left behind and form a new fleet.
        #     This new fleet will be determined by the slower car

        # As we iterate from closest to farthest: 
        #  - Start with a new car
        #  - Compare arrival time with the most recently created fleet
        #  - Check if new car catches up with fleet (joins) or is does not catch up and is left behind (forms new fleet)
        
        # Implication Summary:
        #  1. if that car has a lower (faster) arrival time than the car in front of it, it will join the car ahead (join fleet)
        #  2. if the car has a higher (slower) arrival time than the car in front of it, it will stay behind the car ahead (form new fleet)

        # Example: Cars ordered from farthest to closest to target, with numbers representing arrival times:
        #                  
        #                                     (estimated)                                                             fleet number
        #   Time to                           no fleets exist, car 0 forms the first fleet                                 1
        #   target   1  4  3  7  1  2         car 1 has higher time than car 0, does not catch up, forms second fleet      2
        #           ------------------        car 2 has lower time than car 1, catches up, joins second fleet              2
        #            0  1  2  3  4  5         car 3 has higher time than car 2, does not catch up, forms third fleet       3 
        #       Closest             Farther   car 4 has lower time than car 3, catches up, joins third fleet               3 
        #       car        -->      car       car 5 has lower time than car 3, catches up, joins third fleet               3

        # Monotonic Stack: 
        # Maintains a monotonic increasing stack of times to target (lower times (faster) to high times (slower)).
        # Every time a higher (slower) time to target is found, it means a car is slower than the current fleet, gets left behind, and creates a new fleet (is pushed to stack)
        # Every time a lower (faster) time to target is found, it means a car catches up to the current fleet, and nothing is done as the car joins the current fleet, which is still limited by the current fleet's time

        # Monotonic Summary:
        # Implication 1: And every time monotonic increasing is broken, the current fleet is joined, no new fleets are formed
        # Implication 2: So every time monotonic increasing is kept, a new fleet is formed

        # sc: stores times for up to n fleets O(n)
        # sc: stores times for up to n fleets O(n)
        stack = []

        # Grabbing Info:
        # Pair each car's position with its speed
        cars = list(zip(position, speed))

        # Sort cars by descending position (closer/higher to farther/lower): 
        # tc: timSort cars by starting position distance descending O(n log n)
        cars.sort(reverse=True)

        # Iterate by closer to farthest cars
        # tc: iterate over n cars O(n) 
        for (pos, spd) in cars:

            # Calculate: time to target for new car
            currTimeToTarget = (target - pos) / spd 

            # top of stack[-1]: represent the current fleet's time to target, which new cars will be compared to
            
            # Check: if stack is empty, new car automatically becomes first fleet -> push()
            # Check: if stack is not empty, fleet exists -> compare time to targets
            # Implication 2. if new car is slower (new time to target > stack top), it will never catch up and thus form a new fleet
            if not stack or currTimeToTarget > stack[-1]:

                # Create new fleet at top of stack
                stack.append(currTimeToTarget)

            # Implication 1. if new car is faster (new time to target <= stack top), it will catch up with fleet and merge
            else:

                # Do not change current fleet
                continue
        
        # Total number of fleets represented by time to targets/fleets on stack
        numOfFleets = len(stack)

        # overall: tc O(n log n)
        # overall: sc O(n)
        return numOfFleets
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
SortingO(n log n)O(n)TimSort cars by descending position O(n log n)Store cars of list of n length o(n)
Fleet generationO(n)O(n)Iterate over list of cars n length O(n)Stack tracks time to target for up to n fleets O(n)
OverallO(n log n)O(n)TimSort cars by descending position dominates, leading to O(n log n)Cars and stack dominate, leading to O(n)

Solution 2: [Greedy] Track Current Fleet With Greedy Assumption [SC Opt] - Stack/Algorithm

    def carFleet(self, target: int, position: List[int], speed: List[int]) -> int:
        
        # Pointer Value:
        # The previous stack is doing 2 things:
        #   1. keeping track of the current fleet (top of the stack)
        #   2. keeping track of the number of total fleets (number of time to targets/fleets on the stack)
        # We can do both of these without using a stack and save space

        # Greedy:
        # Strategy based on our arrival times implication
        # When a new car is checked, we just compare it with the current slowest fleet.
        # So all we need to do is keep track of the current slowest fleets and the number of fleets.

        # Traffic Single Lane Road:
        # If you think of a one lane road, a single car (the slowest) will block all traffic before it.
        # So we only need to keep track of a single slowest fleet at a time, as this will be the limiting factor
        # for the entire single lane, until a slower car is found, at which point that new slower car becomes 
        # the new limiting factor for the entire road

        num_fleets = 0
        slowest_fleet_time = 0

        # Grabbing Info:
        # Pair each car's position with its speed
        cars = list(zip(position, speed))

        # Sort cars by position descending (closer/higher to farther/lower): 
        # tc: timSort cars by starting position distance descending O(n log n)
        cars.sort(reverse=True)

        # Iterate by closer to farthest cars
        # tc: iterate over n cars O(n) 
        for (pos, spd) in cars:
            
            # Calculate: time to target for new car
            currCarTimeToTarget = (target - pos)/spd 

            # Implication 2. if new car is slower (higher) (new time to target > stack top), it will never catch up and thus form a new fleet
            if currCarTimeToTarget > slowest_fleet_time: 

                # Create new fleet and update its slowest time
                num_fleets += 1
                slowest_fleet_time = currCarTimeToTarget

            # Implication 1. if new car is faster (lower) (new time to target <= stack top), it will catch up with fleet and merge
            else:

                # Do not change current fleet
                continue

        # overall: tc O(n log n)
        # overall: sc O(1)
        return num_fleets 
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
SortingO(n log n)O(n)TimSort cars by descending position O(n log n)Storing cars for list of n length O(n)
Fleet CountingO(n)O(1)Iterate over cars for list of n length O(n)No additional memory allocation for iteration
OverallO(n log n)O(n)TimSort cars by descending position dominates, leading to O(n log n)Storing cars for list of n length dominates, leading to O(n)

84. Largest Rectangle in Histogram ::2:: - Hard

Topics: Array, Stack, Monotonic Stack

Intro

Given an array of integers heights representing the histogram's bar height where the width of each bar is 1, return the area of the largest rectangle in the histogram.

InputOutput
[2,4]4
[2,1,5,6,2,3]10

Constraints:

1 ≤ heights.length ≤ 105

0 ≤ heights[i] ≤ 104

Abstract

Given array of heights, find the area of the largest rectangle in the histogram.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark

Brute Force: Stack

    def largestRectangleArea(self, heights: List[int]) -> int:
        n = len(heights)
        max_area = 0

        for i in range(n):
            min_height = heights[i]
            for j in range(i, n):
                min_height = min(min_height, heights[j])
                max_area = max(max_area, min_height * (j - i + 1))

        return max_area
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug: Did not append to stack correctly

    def largestRectangleArea(self, heights: List[int]) -> int:
        
        # monotonic increasing
        stack = []
        maxArea = 0

        # INCORRECT:
        # this creates a copy of the heights
        # does not append in place
        # Instead:
        # heights.append(0)
        heights + [0]

        for i in range(len(heights)):

            # monotonic is broken
            while stack and heights[stack[-1]] > heights[i]:
                
                # monotonic is broken
                # 
                heightIndex = stack.pop()
                tallHeight = heights[heightIndex]

                # entire stack was pop goes from 0->i
                if not stack:
                    width = i

                # left wall exists, bounded by left wall and tall
                else:
                    leftWallIndex = stack[-1]
                    width = i - leftWallIndex - 1

                currArea = tallHeight * width
                maxArea = max(maxArea, currArea)

            stack.append(i)

        return maxArea

Solution 1: [Standard] [Monotonic] Dragging Right Wall Height Over The Array And Building Area By Creating Monotonic Increasing Guaranteeing Area - Stack/Monotonic Property Maintenance

    def largestRectangleArea(self, heights: List[int]) -> int:

        # Monotonic Stack: 
        # A stack that maintains monotonic increasing heights
        # When monotonic increasing rule breaks, that implies the new height is smaller than at least 1 older heights.
        # When monotonic increasing rule is true, that implies the new height is taller than all older heights.
        # If stack is non empty, that means we have a stack full of tall bars that can serve to generating an area
        # by covering shorter walls.
        # The width of the rectangle is determined by the number of bars that are taller than the current shorter bar.

        # Taller bars covering a shorter bar and generating area A:
        #               *                                  *                    *                            
        #            *  *                               *  *                 *  *                  
        #         *  *  *          *                 *  *  *  *           A  A  A  A 
        #      *  *  *  *     +    *       ==>    *  *  *  *  *   ==>  *  A  A  A  A  
        #     ------------   new  ---  pop off   ---------------      ---------------
        #    older --> newer           taller walls               area generated by new shorter bar

        # Stack popping off taller bars:                                                                                                                                                                    
        #               *                                     *                                                
        #            *  *                                  *  *                 *                             
        #         *  *  *          *                    *  *  *   *          *  *   *          *   *          *  *
        #      *  *  *  *    +     *        ==>      *  *  *  *   *   =>  *  *  *   *   =>  *  *   *  ==>  *  *  *
        #     ------------   new  ---     pop off   ------------ ---     --------- ---     ------ ---     ---------
        #    taller bars   shorter bar    taller walls                                                final result

        # Popping Rule:
        # We keep popping as long as the stack has taller bars than the new smaller bar.
        # A taller bar implies that the new smaller bar is covered and can generate an area.
        # Once an area can be generated, we just need to calculate the width or the distance between the shorter bar and taller bar

        #  Popping Rule Implies either:
        #  1. The stack gets completely popped, which means the new height candidate
        #     is the smallest height encountered so far and was covered by all other previous tall bars, 
        #     which allows the area being generated to span from 0 -> i, so width = i
        #
        #  2. The stack does not get completely popped, which means the new height candidate
        #     becomes the new top of the stack. 
        #     This new height candidate will be tested as we find new bars, which may or may not be taller or shorter.
        
        # Sentinel: 
        # Exists so that even the taller bars (like height 99) gets its area computed if no shorter bar
        # ever appears to trigger its pop.
        # With this, every bar eventually gets processed.
        # So the 0 is to flush the remaining bars at the end
        heights.append(0)

        # sc: stores indexes for up to n heights O(n)
        stack = []
        max_area = 0

        # tc: iterate over n O(n)
        for i in range(len(heights)):

            # Monotonic increasing is broken: 
            # Check: if new bar breaks monotonic increasing order
            # Implies: A taller bar than the new bar is on top of the stack
            # Implies: The new smaller bar is covered by the taller bar and we can generate an area under the taller bar (see 'A' diagram above)       
            while stack and heights[stack[-1]] > heights[i]:
                
                # Grab taller bar index and height
                tallWallIndex = stack.pop()
                tallWallHeight = heights[tallWallIndex]

                # Check: if stack is empty
                # Implies: the new short wall is smaller than all previous heights,
                # Implies: the area of the rectangle generated extends all the way to the left boundary of the history, index 0
                if not stack:
                    # area generated extends all the way to the left boundary: 
                    # width = right wall - left end of histogram
                    # width = right wall - 0
                    width = i - 0

                # Check: Stack is not empty
                # Implies: the new wall is taller than at least 1 old wall
                # Implies: the old wall on the stack will serve as the left wall/boundary for the area being generated
                else:
                    # old left wall boundary
                    leftWallIndex = stack[-1]

                    # area generated extends from right wall to left wall, and -1 to exclude the left wall
                    # width = (right wall - left wall) - 1
                    # width = i - stack[-1] - 1 
                    width = (i - leftWallIndex) - 1

                # Check: new max area
                max_area = max(max_area, tallWallHeight * width)

            # Monotonic increasing is maintained: 
            # Curr height is taller than all walls on the stack, there is no area to generate, append to stack and grab next new wall
            stack.append(i)

        # overall: tc O(n)
        # overall: sc O(n)
        return max_area
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
Stack operationsO(n)O(n)Each index is pushed and popped at most once O(n)Stack can contain up to n indexes O(n)
SentinelO(1)O(1)Ensures all heights are processed O(1)No additional memory allocation for sentinel O(1)
Area calculationO(1)O(1)Height calculation in constant O(1)No additional memory allocation for calculation O(1)
OverallO(n)O(n)Iterate over list of n length dominates, leading to O(n)Stack index storage dominates, leading to O(n)

Solution 2: [Reverse] [Monotonic] Double Dragging Reverse Iteration Monotonic Increasing of Higher Bar Height Index - Stack/Monotonic Property Maintenance

    def largestRectangleArea(self, heights: List[int]) -> int:
        
        # Monotonic Stack: 
        # A stack that maintains monotonic increasing heights
        # When monotonic increasing rule breaks, curr height will serve as right wall for the area to be generated.
        # If stack is non empty, that means we have a stack full of taller bars that cover the current shorter bar.
        # Each popped bar acts as a valid column for the height of the area we are generating. 
        # The width of the rectangle is determined by the amount of bars that are taller than the current shorter bar,
        # as this determines the length of the area being generated. 

        # Stack popping off taller bars:                                                                                                                                                                    
        #               *                                     *                                                
        #            *  *                                  *  *                 *                             
        #         *  *  *          *                    *  *  *   *          *  *   *          *   *          *  *
        #      *  *  *  *    +     *        ==>      *  *  *  *   *   =>  *  *  *   *   =>  *  *   *  ==>  *  *  *
        #     ------------   new  ---     pop off   ------------ ---     --------- ---     ------ ---     ---------
        #    taller bars   shorter bar    taller walls                                                final result


        # Stack of taller bars that the new shorter bar and thus cover the area being generated by the shorter bar:
        #               *                                  *                    *                            
        #            *  *                               *  *                 *  *                  
        #         *  *  *          *                 *  *  *  *           A  A  A  A 
        #      *  *  *  *     +    *       ==>    *  *  *  *  *   ==>  *  A  A  A  A  
        #     ------------   new  ---  pop off   ---------------      ---------------
        #    older --> newer           taller walls               area generated by new shorter bar

        # Notice:
        # The diagram is the same for left to right iteration and right to left iteration
        # even though we are grabbing the bars on the right vs the left.
        # That is because the stack itself cannot tell the difference and just does the same algorithm.
        # Thus, all that changes is the width calculation with the indexes

        # Index Math:
        # Scanning n-1 -> 0
        # When you pop index k,
        # Left boundary: i + 1
        # Right boundary: If stack is non-empty: stack[-1] -1, If stack is empty (n-1)

        n = len(heights)
        max_area = 0

        # We process from right to left, so we reverse the indices
        stack = []

        # Sentinel: iteration through -1
        # tc: iterate over n O(n)
        for i in range(n-1, -2, -1):

            # Sentinel: append a sentinel index of -1, height of 0
            curr_height = 0 if i == -1 else heights[i]

            # maintain monotonic increasing height
            while stack and heights[stack[-1]] > curr_height:

                # monotonic increasing broken, pop until true again
                index = stack.pop()
                height = heights[index]

                # Check: Stack is empty
                # Implies: the short wall is smaller than all previous heights,
                # Implies: the area of the rectangle generated extends all the way from the left boundary: index i
                # the right boundary of the history: index (n-1)
                if not stack:
                    # area generated extends all the way to the right boundary: (right end of histogram - left wall)
                    width = (n-1) - i    

                # Check: Stack is not empty
                # Implies: the short wall is taller than at least 1 wall
                # Implies: width = (left - right)  - 1
                #                = (stack[-1] - i) - 1
                #
                # Implies: the wall on the stack will serve as the left wall/boundary for the area being generated
                else:
                    # right wall boundary
                    rightWall = stack[-1]
                    # (width - 1) to exclude the right boundary wall
                    width = (stack[-1] - i) - 1

                # check new area
                max_area = max(max_area, height * width)

            # if non-sentinel index, append
            if i >= 0:
                stack.append(i)

        # overall: tc O(n)
        # overall: sc O(n)
        return max_area
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
IterationO(n)O(1)Iterate over list of n length O(n)No additional memory allocation for iteration O(1)
Stack operationsO(n)O(n)Each index is push and popped at most once for n indexes O(n)Stack can hold up to n indexes O(n)
SentinelFinal iteration i = -1 triggers processing remaining stack, constant value O(1)No additional memory allocation for sentinel O(1)
Area CalculationO(1)O(1)Rectangle area computed in constant time per pop O(1)No additional memory allocation for area calculation O(1)
OverallO(n)O(n)Iteration over list dominates, leading to O(n)Stack storage dominates, leading to O(n)

402. Remove K Digits ::1:: - Medium

Topics: String, Stack, Greedy, Monotonic Stack

Intro

Given string num representing a non-negative integer num, and an integer k, return the smallest possible integer after removing k digits from num.

InputOutput
num = "10", k = 2"0"
num = "10200", k = 1"200"
num = "1432219", k = 3"1219"

Constraints:

1 ≤ k ≤ num.length ≤ 105

num consists of only digits

num does not have any leading zeros except for 0 itself

Abstract

Remove k digits in a way so that resulting integer is as large as possible.

Solution 1: Forward Iteration Monotonic Increasing Digits Stack - Stack/Monotonic Property Maintenance

    def removeKdigits(self, num: str, k: int) -> str:

        # Same as histogram or graph problem
        # We are removing a taller height if a smaller height follows it

        # Monotonic Stack: 
        # A stack that maintains increasing digits
        
        # Greedy:
        # Remove from top of stack if higher digit at top of stack 
        # is followed by a smaller one, as this lowers value
        
        # Inverse Greedy: 
        # Do not remove smaller digit if higher digit follows, as this increases value 

        # sc: stack holds increasing digits up to n digits O(n)
        stack = []

        # tc: iterate over list of n length O(n)
        for digit in num:

            # Check: non empty stack
            # Check: we still need to remove more digits (k > 0)
            # Check: if monotonic increasing broken
            # Implies: found smaller digits in front of larger, replace larger value at top of stack
            while stack and k > 0 and stack[-1] > digit:
                
                # remove higher digit at top of stack
                stack.pop()
                k -= 1

            # monotonic increasing valid
            stack.append(digit)

        # monotonic stack is valid

        # Check: if we still need to remove digits k
        # ignore larger digits on right side, 
        # splice and grab digits from the left side

        # "12345" [:3] -> "123"
        # "12345" [:-3] -> "12"
        # -3 removes leftmost 3 digits
        if k > 0:
            stack = stack[:-k]

        # Remove leading zeros "0200" -> "200"
        # Join the stack into final number
        result = ''.join(stack).lstrip('0')

        # Return "0" if the result is empty

        # overall: tc O(n)
        # overall: sc O(n)
        return result or "0"
AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks
IterationO(n)O(n)Each digit is pushed and popped at most once O(n)Stack stores up to n digits O(n)
PostprocessingO(k)O(1)Remove k remaining digits from endNo additional memory allocation for splicing O(1)
ConversionO(n)O(n)Build result and strip leading zeros for n length O(n)String of up to n length O(n)
OverallO(n)O(n)Iteration over n length dominates, leading to O(n)Stack storing n digits dominates, leading to O(n)