← Back to Tutorial Index

Chapter 3: Stacks & Queues

Master stack and queue operations, monotonic stacks, priority queues, and BFS traversal. Learn when and where to use each data structure.

1. Stack Fundamentals

📌 When and Where to Use

  • LIFO operations: Last In First Out - most recent element processed first
  • Expression evaluation: Parsing and evaluating mathematical expressions
  • Parenthesis matching: Checking balanced parentheses, brackets, braces
  • Backtracking: Undo operations, DFS traversal
  • Real-world example: Browser back button, function call stack, undo/redo in text editors, or matching HTML tags

Stack Operations

class Stack:
    """Stack implementation using list"""
    def __init__(self):
        # Step 1: Initialize empty list
        # List will store stack elements
        self.items = []
    
    def push(self, item):
        # Step 2: Add element to top of stack
        # Append adds to end of list (top of stack)
        self.items.append(item)
    
    def pop(self):
        # Step 3: Remove and return top element
        # Pop removes from end of list (LIFO)
        if self.is_empty():
            return None
        return self.items.pop()
    
    def peek(self):
        # Step 4: Return top element without removing
        if self.is_empty():
            return None
        return self.items[-1]
    
    def is_empty(self):
        # Step 5: Check if stack is empty
        return len(self.items) == 0

Step-by-Step Explanation:

Step 1 - Initialize: Create empty list to store stack elements.
Step 2 - Push: Add element using append() - adds to end (top of stack).
Step 3 - Pop: Remove and return last element using pop() - LIFO behavior.
Step 4 - Peek: View top element without removing it.
Step 5 - Empty Check: Verify if stack has elements.

2. Valid Parentheses

Example: Check Balanced Parentheses

def is_valid(s):
    """
    Check if string has valid parentheses using stack.
    
    Args:
        s: String containing only '(', ')', '{', '}', '[', ']'
    
    Returns:
        True if parentheses are balanced, False otherwise
    """
    # Step 1: Initialize stack and mapping
    stack = []
    # Map closing brackets to their corresponding opening brackets
    mapping = {')': '(', '}': '{', ']': '['}
    
    # Step 2: Iterate through each character
    for char in s:
        # Step 3: Check if it's a closing bracket
        if char in mapping:
            # Step 3a: It's a closing bracket
            # Check if stack is empty or top doesn't match
            if not stack or stack.pop() != mapping[char]:
                return False
        else:
            # Step 3b: It's an opening bracket
            # Push it onto stack
            stack.append(char)
    
    # Step 4: Check if stack is empty
    # If empty, all brackets were matched
    return len(stack) == 0

Step-by-Step Code Walkthrough:

Step 1 - Initialize:
  • Create empty stack to store opening brackets
  • Create mapping: closing → opening brackets
Step 2 - Iterate: Process each character in the string.
Step 3 - Process Character:
  • Step 3a - Closing Bracket: If it's a closing bracket, check if stack top matches. If not, invalid.
  • Step 3b - Opening Bracket: Push onto stack (we'll match it later)
Step 4 - Final Check: If stack is empty, all brackets were matched. Otherwise, some opening brackets weren't closed.

Example:

Input: "()[]{}"

  • '(': push → stack = ['(']
  • ')': pop '(' → matches! → stack = []
  • '[': push → stack = ['[']
  • ']': pop '[' → matches! → stack = []
  • '{': push → stack = ['{']
  • '}': pop '{' → matches! → stack = []
  • Stack empty → Valid!

3. Monotonic Stack

📌 When and Where to Use

  • Next greater/smaller element: Finding next greater or smaller element for each position
  • Largest rectangle: Finding largest rectangle in histogram
  • Trapping rainwater: Calculating trapped rainwater between bars
  • Real-world example: Finding next higher stock price, calculating maximum area in bar charts, or finding next warmer day

Algorithm Explanation

A monotonic stack maintains elements in monotonic (increasing or decreasing) order:

  • Monotonic increasing: Elements increase from bottom to top
  • Monotonic decreasing: Elements decrease from bottom to top
  • Key insight: When we see an element that breaks monotonicity, we can process previous elements

Example: Next Greater Element

def next_greater_element(nums):
    """
    Find next greater element for each position using monotonic stack.
    
    Args:
        nums: List of integers
    
    Returns:
        List where result[i] is next greater element of nums[i], or -1 if none
    """
    # Step 1: Initialize result and stack
    n = len(nums)
    result = [-1] * n  # Default: no greater element
    stack = []  # Monotonic decreasing stack (stores indices)
    
    # Step 2: Iterate through array
    for i in range(n):
        # Step 3: While current element breaks monotonicity
        # Current element is greater than stack top
        while stack and nums[stack[-1]] < nums[i]:
            # Step 3a: Found next greater for stack top
            # Pop index from stack
            idx = stack.pop()
            # Step 3b: Set result for that index
            result[idx] = nums[i]
        
        # Step 4: Push current index
        # Current element might be next greater for future elements
        stack.append(i)
    
    return result

Step-by-Step Code Walkthrough:

Step 1 - Initialize:
  • Result array initialized with -1 (no greater element found)
  • Stack stores indices (monotonic decreasing)
Step 2 - Iterate: Process each element.
Step 3 - Process Stack: While current element is greater than stack top:
  • Step 3a: Pop index from stack
  • Step 3b: Set result[idx] = current element (found next greater!)
Step 4 - Push: Add current index to stack (waiting for its next greater element).

Example:

nums = [4, 5, 2, 10]

  • i=0, num=4: stack=[0]
  • i=1, num=5: 5 > 4 → result[0]=5, stack=[1]
  • i=2, num=2: stack=[1, 2]
  • i=3, num=10: 10 > 2 → result[2]=10, 10 > 5 → result[1]=10, stack=[3]
  • Result: [5, 10, 10, -1]

4. Queue Fundamentals

📌 When and Where to Use

  • FIFO operations: First In First Out - oldest element processed first
  • BFS traversal: Level-order traversal in trees/graphs
  • Task scheduling: Processing tasks in order
  • Real-world example: Print queue, message queues, customer service queues, or BFS pathfinding

Queue Operations

from collections import deque

class Queue:
    """Queue implementation using deque"""
    def __init__(self):
        # Step 1: Initialize deque (double-ended queue)
        # Deque provides O(1) operations on both ends
        self.items = deque()
    
    def enqueue(self, item):
        # Step 2: Add element to rear (end) of queue
        self.items.append(item)
    
    def dequeue(self):
        # Step 3: Remove and return front (beginning) element
        # popleft removes from left (FIFO)
        if self.is_empty():
            return None
        return self.items.popleft()
    
    def peek(self):
        # Step 4: Return front element without removing
        if self.is_empty():
            return None
        return self.items[0]
    
    def is_empty(self):
        # Step 5: Check if queue is empty
        return len(self.items) == 0

Step-by-Step Explanation:

Step 1 - Initialize: Use deque for efficient O(1) operations.
Step 2 - Enqueue: Add to end using append().
Step 3 - Dequeue: Remove from front using popleft() - FIFO behavior.
Step 4 - Peek: View front element without removing.
Step 5 - Empty Check: Verify if queue has elements.

5. BFS (Breadth-First Search) with Queue

Example: Level-Order Traversal

from collections import deque

def level_order_traversal(root):
    """
    Traverse binary tree level by level using BFS.
    
    Args:
        root: Root node of binary tree
    
    Returns:
        List of lists, where each inner list is a level
    """
    # Step 1: Handle empty tree
    if root is None:
        return []
    
    # Step 2: Initialize queue and result
    queue = deque([root])  # Start with root node
    result = []
    
    # Step 3: Process each level
    while queue:
        # Step 3a: Get current level size
        # This tells us how many nodes are in current level
        level_size = len(queue)
        level_nodes = []
        
        # Step 3b: Process all nodes in current level
        for _ in range(level_size):
            # Step 3c: Dequeue node
            node = queue.popleft()
            # Step 3d: Add to current level
            level_nodes.append(node.val)
            
            # Step 3e: Enqueue children
            # Add left and right children for next level
            if node.left:
                queue.append(node.left)
            if node.right:
                queue.append(node.right)
        
        # Step 3f: Add level to result
        result.append(level_nodes)
    
    # Step 4: Return all levels
    return result

Step-by-Step Code Walkthrough:

Step 1 - Edge Case: If tree is empty, return empty list.
Step 2 - Initialize: Queue starts with root, result stores levels.
Step 3 - Process Levels:
  • Step 3a: Get current level size (nodes at this level)
  • Step 3b: Process all nodes in current level
  • Step 3c: Dequeue node
  • Step 3d: Add value to current level
  • Step 3e: Enqueue children (for next level)
  • Step 3f: Add completed level to result
Key Insight: By processing level_size nodes at a time, we ensure we complete one level before moving to the next.

6. Priority Queue (Heap)

📌 When and Where to Use

  • K largest/smallest: Finding k largest or smallest elements
  • Merge k sorted lists: Efficiently merging multiple sorted lists
  • Dijkstra's algorithm: Finding shortest paths
  • Real-world example: Task scheduler (priority-based), finding top K products by sales, or merging sorted logs from multiple servers

Example: Find K Largest Elements

import heapq

def find_k_largest(nums, k):
    """
    Find k largest elements using min heap.
    
    Args:
        nums: List of integers
        k: Number of largest elements to find
    
    Returns:
        List of k largest elements
    """
    # Step 1: Initialize min heap
    # Min heap of size k (smallest at top)
    heap = []
    
    # Step 2: Process each number
    for num in nums:
        # Step 3: Add to heap
        heapq.heappush(heap, num)
        
        # Step 4: Maintain heap size k
        # If heap exceeds k, remove smallest
        if len(heap) > k:
            # Step 4a: Remove smallest element
            # This keeps only k largest elements
            heapq.heappop(heap)
    
    # Step 5: Return k largest elements
    # Heap now contains k largest (convert to list)
    return list(heap)

Step-by-Step Code Walkthrough:

Step 1 - Initialize: Create empty min heap (Python's heapq is min heap).
Step 2 - Iterate: Process each number in the array.
Step 3 - Push: Add number to heap using heappush().
Step 4 - Maintain Size: If heap size > k:
  • Step 4a: Remove smallest element using heappop()
  • This keeps only the k largest elements
Step 5 - Return: Heap now contains k largest elements.
Why Min Heap: We want to easily remove the smallest element when heap exceeds k, keeping only the largest.

Time Complexity:

O(n log k) - n elements, each heap operation is O(log k)

Space Complexity:

O(k) - Heap stores at most k elements

📚 Chapter Summary

  • Stack (LIFO): Use for parentheses matching, expression evaluation, backtracking
  • Monotonic Stack: Use for next greater/smaller element problems
  • Queue (FIFO): Use for BFS, task scheduling, level-order traversal
  • Priority Queue/Heap: Use for k largest/smallest, merging sorted lists, Dijkstra's

Key Takeaway: Stacks and queues are fundamental for many algorithms. Choose based on processing order needed (LIFO vs FIFO) and whether you need priority-based processing.

← Previous: Chapter 5 Next: Chapter 7 - Backtracking →