Loading learning content...
When you walk through a maze, you need to remember where you've been—which turns you've taken, which paths you've explored, how deep you've ventured. Without this memory, you'd wander in circles forever.
Stacks provide exactly this kind of memory for algorithms. As you traverse a sequence or structure, the stack tracks your state: what you've seen, what context you're in, and where you need to return when you backtrack.
This pattern—using a stack to maintain state during traversal—is one of the most versatile in all of computer science. It appears in parsing, expression evaluation, tree traversal, graph algorithms, and anywhere you need to process data with awareness of surrounding context.
By the end of this page, you will understand how stacks serve as 'algorithmic memory,' recognize the state tracking pattern in various problem types, implement solutions that maintain context during traversal, and connect this pattern to parsing, matching, and hierarchical processing.
What does 'state tracking' mean?
When we traverse a sequence—whether it's characters in a string, nodes in a tree, or elements in an array—we often need to remember context from earlier in the traversal. This context might include:
A stack is the perfect data structure for this because:
Think of the stack as short-term working memory for your algorithm. As you encounter new contexts, you push them. As you complete contexts, you pop them. At any moment, the top of the stack represents your 'current state' and everything below represents the chain of states you'll return to.
The Traversal Model:
In a typical state-tracking traversal:
for each element in sequence:
if element opens a new context:
push context onto stack
if element closes a context:
pop from stack and validate/process
process element with current stack as context
The stack grows when we enter nested contexts and shrinks when we exit them. The current stack state always reflects where we are in the nesting hierarchy.
The canonical example of state tracking is matching parentheses (or brackets, braces, and other paired delimiters). Given a string like "((a + b) * (c - d))", we need to verify that every opening parenthesis has a corresponding closing one, and that they're properly nested.
Why is this a state tracking problem?
When we encounter (, we need to remember it because somewhere later we'll encounter ) that matches it. But we might encounter more ( characters before we see that ). The state we're tracking is: what opening parentheses are still waiting for their match?
The Algorithm:
(, push it onto the stack (new context opened)), pop from the stack (context closed))(s123456789101112131415161718192021222324252627282930
def is_balanced_parentheses(s: str) -> bool: """ Check if parentheses in a string are balanced. Time Complexity: O(n) - single pass through string Space Complexity: O(n) - worst case all characters are '(' """ stack = [] for char in s: if char == '(': # New context opened - push to remember it stack.append(char) elif char == ')': # Context should close - pop to match if not stack: # No opening parenthesis to match return False stack.pop() # Stack should be empty if all parentheses matched return len(stack) == 0 # Test casesprint(is_balanced_parentheses("((a + b))")) # Trueprint(is_balanced_parentheses("(a + b) * (c)")) # Trueprint(is_balanced_parentheses("((a + b)")) # False - unmatched (print(is_balanced_parentheses("(a + b))")) # False - unmatched )print(is_balanced_parentheses(")(")) # False - wrong orderTrace Through Example: "((a + b) * c)"
| Position | Character | Action | Stack State | Context Depth |
|---|---|---|---|---|
| 0 | ( | Push | [(] | 1 |
| 1 | ( | Push | [(, (] | 2 |
| 2 | a | Skip | [(, (] | 2 |
| 3-5 |
| Skip | [(, (] | 2 |
| 6 | ) | Pop | [(] | 1 |
| 7-10 |
| Skip | [(] | 1 |
| 11 | ) | Pop | [] | 0 |
| End | — | Check | [] | 0 ✓ |
The stack size represents our nesting depth at each moment. When we encounter ')', we know exactly which '(' it matches—it's always the one on top of the stack. This is the essence of LIFO matching LIFO nesting.
Real-world applications often involve multiple types of paired delimiters: (), [], {}. The state tracking pattern extends naturally—we push the type of opening bracket and verify that the closing bracket matches.
The Extended Rule:
When we see a closing bracket:
This handles cases like "(]" or "{[}]" correctly—syntactically incorrect even though counts balance.
1234567891011121314151617181920212223242526272829303132333435363738
def is_valid_brackets(s: str) -> bool: """ Check if multiple bracket types are balanced and properly nested. Handles: (), [], {} Time Complexity: O(n) Space Complexity: O(n) """ # Mapping of closing to opening brackets matching = {')': '(', ']': '[', '}': '{'} opening = set('([{') stack = [] for char in s: if char in opening: # Opening bracket - push to track stack.append(char) elif char in matching: # Closing bracket - must match top of stack if not stack: return False # Nothing to match top = stack.pop() if top != matching[char]: return False # Wrong type of bracket return len(stack) == 0 # Test cases showing different failure modesprint(is_valid_brackets("()[]{}")) # True - all matchedprint(is_valid_brackets("([{}])")) # True - nested correctlyprint(is_valid_brackets("(]")) # False - wrong typeprint(is_valid_brackets("([)]")) # False - interleaved wrongprint(is_valid_brackets("{[()]}")) # True - deeply nestedprint(is_valid_brackets("((")) # False - unclosed"([)]" has balanced counts (one of each), but is invalid because brackets are interleaved incorrectly. Stack-based validation catches this because when we see ']', the top of stack is '(' not '['. This is why counting alone is insufficient—we need LIFO state tracking.
So far, we've pushed simple characters onto the stack. But the state tracking pattern becomes even more powerful when we store rich context objects containing multiple pieces of information.
Common Context Data:
Example: Finding Matching Bracket Positions
Instead of just validating brackets, we want to locate each pair—return the indices of matching brackets.
12345678910111213141516171819202122232425262728293031323334353637383940414243
def find_bracket_pairs(s: str) -> list[tuple[int, int]]: """ Find all matching bracket pairs and return their positions. Returns list of (open_index, close_index) tuples. Example: "a(b(c)d)e" returns [(3, 5), (1, 7)] Note: pairs are returned in order of closing. """ # Stack stores (index, character) tuples - rich context stack = [] pairs = [] matching = {')': '(', ']': '[', '}': '{'} opening = set('([{') for i, char in enumerate(s): if char in opening: # Push position AND character stack.append((i, char)) elif char in matching: if stack and stack[-1][1] == matching[char]: open_pos, _ = stack.pop() pairs.append((open_pos, i)) else: # Unmatched closing bracket pass # Could raise error or track separately return pairs # Exampless = "a(b(c)d)e"print(f"String: {s}")print(f"Pairs: {find_bracket_pairs(s)}")# String: a(b(c)d)e# Pairs: [(3, 5), (1, 7)]# (3,5) matches the inner (c)# (1,7) matches the outer (b...d) s2 = "{arr[i + (j * k)]}"print(f"String: {s2}")print(f"Pairs: {find_bracket_pairs(s2)}")# Pairs: [(9, 15), (4, 16), (0, 17)]The Power of Position Tracking:
By storing positions, we can:
This is how IDEs implement bracket highlighting—when you click on a bracket, they use stack-based matching to find its partner instantly.
Another powerful application is tracking nesting depth—how many levels deep we are in nested structures. The stack size itself provides this information for free.
Problem: Maximum Nesting Depth
Given a string of parentheses, find the maximum nesting depth.
"()" → depth 1"(())" → depth 2"((()))" → depth 3"()(())" → depth 2 (two separate nesting regions)12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
def max_nesting_depth(s: str) -> int: """ Find the maximum nesting depth of parentheses. The stack size at any moment equals current depth. Track the maximum size seen. Time: O(n), Space: O(n) worst case """ stack = [] max_depth = 0 for char in s: if char == '(': stack.append(char) # Track maximum depth seen max_depth = max(max_depth, len(stack)) elif char == ')': if stack: stack.pop() return max_depth # Optimized version without actual stack (just counting)def max_nesting_depth_optimized(s: str) -> int: """ Same result but O(1) space. We only need the count, not the actual stack contents. """ current_depth = 0 max_depth = 0 for char in s: if char == '(': current_depth += 1 max_depth = max(max_depth, current_depth) elif char == ')': current_depth -= 1 return max_depth # Test casestest_cases = [ "()", # 1 "(())", # 2 "((()))", # 3 "()(())", # 2 "(()(()))", # 3 "", # 0] for tc in test_cases: print(f"'{tc}' → depth {max_nesting_depth(tc)}")For pure depth tracking, we don't need to store actual elements—a counter suffices. But if we need to know WHAT is at each level (not just how deep), the full stack is required. Recognize when you can optimize from O(n) to O(1) space by using counters instead of explicit stacks.
HTML and XML are prime examples of nested structures. Tags must be properly opened and closed, with inner tags fully contained within outer tags.
<div>
<p>Hello <strong>world</strong></p>
</div>
The Stack Approach:
<tag>, push tag onto stack</tag>, pop and verify it matchesThis is exactly how web browsers and XML parsers work internally.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
import re def is_valid_html_nesting(html: str) -> bool: """ Validate that HTML tags are properly nested. Simplified version - doesn't handle self-closing tags, attributes, or comments. Illustrates the core pattern. Time: O(n) where n is string length Space: O(d) where d is maximum nesting depth """ # Regex to find tags (simplified) tag_pattern = re.compile(r'<(/?)(w+)>') stack = [] for match in tag_pattern.finditer(html): is_closing = match.group(1) == '/' tag_name = match.group(2).lower() if is_closing: # Must match most recently opened tag if not stack or stack[-1] != tag_name: return False stack.pop() else: # Opening tag - push to track stack.append(tag_name) # All tags must be closed return len(stack) == 0 # Test casesvalid_html = """<div> <p>Hello <strong>world</strong></p></div>""" invalid_html = """<div> <p>Hello <strong>world</p></strong></div>""" print(f"Valid HTML: {is_valid_html_nesting(valid_html)}") # Trueprint(f"Invalid HTML: {is_valid_html_nesting(invalid_html)}") # FalseWhy LIFO Matches HTML Semantics:
HTML nesting is LIFO by definition:
This isn't just a parsing convenience—it's the fundamental structure of the Document Object Model (DOM). Each tag opens a context that must be closed before the enclosing context can close.
<div> ← push 'div'
<p> ← push 'p'
<strong> ← push 'strong'
</strong> ← pop, verify 'strong'
</p> ← pop, verify 'p'
</div> ← pop, verify 'div'
File system paths involve navigating into directories (going deeper) and navigating out (..). A stack naturally models this navigation.
Problem: Simplify Path
Given a Unix-style file path, simplify it to its canonical form:
/a/./b/../../c/ → /c. means current directory (no movement).. means parent directory (go back up)This is a classic state tracking problem—the stack tracks which directories we're currently inside.
12345678910111213141516171819202122232425262728293031323334353637383940414243
def simplify_path(path: str) -> str: """ Simplify a Unix-style file path to its canonical form. Stack tracks the directory components we're currently in. '..' pops (go to parent), '.' does nothing. Time: O(n) - process each component once Space: O(n) - stack stores path components """ # Split by '/' and filter empty strings components = path.split('/') stack = [] for component in components: if component == '' or component == '.': # Empty (from //) or current dir - skip continue elif component == '..': # Parent directory - pop if possible if stack: stack.pop() # If stack is empty, we're at root - can't go higher else: # Valid directory name - push stack.append(component) # Reconstruct canonical path return '/' + '/'.join(stack) # Test casestest_paths = [ "/home/", # /home "/../", # / (can't go above root) "/home//foo/", # /home/foo "/a/./b/../../c/", # /c "/a/../../b/../c//.//", # /c "/a/b/c/../../../x/y/z", # /x/y/z] for p in test_paths: print(f"'{p}' → '{simplify_path(p)}'")In this problem, the stack state represents 'where we are right now.' Each directory pushes us deeper; '..' pops us back. The final stack contents directly give the canonical path. This is exactly how file system implementations track the current working directory.
Knowing when to apply the state tracking pattern is just as important as knowing how. Here are the signals to watch for:
| Problem Domain | What Opens? | What Closes? | Stack Stores |
|---|---|---|---|
| Bracket matching | Opening brackets | Closing brackets | Bracket types |
| HTML parsing | <tag> | </tag> | Tag names |
| Expression evaluation | ( | ) | Partial results |
| File path navigation | Directory name | .. | Path components |
| Transaction management | BEGIN | COMMIT/ROLLBACK | Transaction IDs |
| Undo systems | User action | Undo command | Action records |
Ask yourself: 'Does the most recently opened thing need to be closed first?' If yes, you're looking at a state tracking problem and should reach for a stack.
State tracking during traversal is one of the most versatile stack patterns. Let's consolidate the key insights:
You now understand how stacks serve as memory during traversal. When you encounter nested structures or matching pairs, the solution pattern is clear: push on open, pop on close, validate or compute during the process.
What's Next:
In the next page, we'll explore function call simulation—how stacks model what happens when functions call other functions. This pattern underlies recursion, call stacks, and is essential for understanding how programs actually execute.