This lesson closes the stacks module and is entirely practical: six progressive exercises, with no new theory, consolidating everything you have learned — the ADT contract (03-01 and 03-02), the Stack and LinkedStack implementations (03-03) and the application patterns (03-04) — and several of them extend TaskFlow directly. Work each exercise for real before looking at the solution: try the trace on paper first (as in 03-02), then the code, and only then compare. The solutions are complete and commented, with the reasoning that leads to them, because the goal is not "getting it to work" but knowing why it works. In every exercise you can use the Stack class from lesson 03-03 (or a list with append/pop — you know by now why at the back).
Contents
- Exercise 1: reversing a string (and a list of tasks)
- Exercise 2: processing keyboard backspaces
- Exercise 3: validating push/pop sequences
- Exercise 4: min-stack — the minimum in O(1)
- Exercise 5: bounded undo history
- Exercise 6: from recursion to iteration with an explicit stack
Exercise 1: reversing a string (and a list of tasks)
Level: basic. The "hello world" of stacks: since elements come out in the reverse order they went in, pushing everything and popping it all reverses any sequence.
- Write
reverse_string(text)that returns the reversed text using a stack (no[::-1]and noreversed, obviously: the goal is the pattern). - Write
reverse_tasks(tasks)that takes a list of task dicts and returns a new list in reverse order — TaskFlow will use it to show "the most recently created tasks first". - Analysis question: what is the cost in time and in space? And what if, instead of push/pop, you did two passes with
pop(0)?
Solution
def reverse_string(text):
stack = Stack()
for char in text: # phase 1: push everything (goes in, in order)
stack.push(char)
result = []
while not stack.is_empty(): # phase 2: pop everything (comes out reversed)
result.append(stack.pop())
return "".join(result)
def reverse_tasks(tasks):
stack = Stack()
for task in tasks:
stack.push(task)
reversed_tasks = []
while not stack.is_empty():
reversed_tasks.append(stack.pop())
return reversed_tasks
print(reverse_string("TaskFlow")) # wolFksaT
print(reverse_tasks([{"id": 1, "title": "A", "priority": 2, "status": "pending"},
{"id": 2, "title": "B", "priority": 1, "status": "pending"}]))
# [{'id': 2, ...}, {'id': 1, ...}]Comments:
- The pattern is the double dump: everything in, everything out. One pass of
push(n O(1) operations) plus one ofpop(another n): O(n) time, O(n) space (the stack ends up holding all n elements). - Note the echo of 03-04 (exercise 1): one reversal changes the order; two reversals restore it. That is why undo+redo with two stacks brings back the chronological order.
- On question 3: traversing with
pop(0)on alistwould be O(n) per element → O(n²) total. It is module 1'sinsert(0)/pop(0)trap all over again; with 100,000 tasks, the difference between milliseconds and minutes.
Exercise 2: processing keyboard backspaces
Level: basic. TaskFlow's "task title" field receives the sequence of keys pressed, where # represents the backspace key (it deletes the last character typed, if there is one). Write final_text(keys) that returns the resulting text.
Examples: final_text("Review####draft") → "Redraft"; final_text("##Hello#") → "Hell" (backspaces on empty text do nothing).
Solution
def final_text(keys):
stack = Stack()
for key in keys:
if key == "#":
if not stack.is_empty(): # backspace with no text: ignored
stack.pop() # deletes the last character typed
else:
stack.push(key)
# The stack holds the text from top to bottom = reversed: flip it back
chars = []
while not stack.is_empty():
chars.append(stack.pop())
return "".join(reversed(chars))
print(final_text("Review####draft")) # Redraft
print(final_text("##Hello#")) # HellComments:
- "Delete the last thing typed" is the operational definition of
pop: the text being edited is a stack of characters (you already identified this in exercise 2 of 03-01). - The
is_empty()guard before thepopis mandatory: without it,"##Hello#"would raiseIndexErroron the first#. It is 03-02's number-one common mistake in its purest form. - At the end the stack contains the text reversed (the top is the last character): you have to dump it and re-reverse. Total cost O(n).
Exercise 3: validating push/pop sequences
Level: intermediate. You are given two lists: pushes (the order in which n distinct elements were pushed) and pops (a popping order someone claims to have observed). Write is_valid_sequence(pushes, pops) that returns True if that output sequence is possible on a stack, interleaving pushes and pops however you like.
Examples with pushes = [1, 2, 3, 4]:
pops = [2, 1, 4, 3]→True(push 1, push 2, pop 2, pop 1, push 3, push 4, pop 4, pop 3).pops = [3, 1, 2]withpushes = [1, 2, 3]→False: to get 3 out first, 1 and 2 remain stacked with 2 on top; it is impossible for 1 to come out before 2.
Hint: simulate. Push the inputs in order and, after each push, pop greedily while the top matches the next expected element of pops.
Solution
def is_valid_sequence(pushes, pops):
if len(pushes) != len(pops):
return False
stack = Stack()
i = 0 # index of the next expected pop
for element in pushes: # we simulate the pushes in their order
stack.push(element)
# Greedy popping: while the top is exactly what's expected, pop
while not stack.is_empty() and i < len(pops) and stack.peek() == pops[i]:
stack.pop()
i += 1
return stack.is_empty() # everything came out in the requested order
print(is_valid_sequence([1, 2, 3, 4], [2, 1, 4, 3])) # True
print(is_valid_sequence([1, 2, 3], [3, 1, 2])) # False
print(is_valid_sequence([1, 2, 3], [1, 2, 3])) # True (pop after each push)
print(is_valid_sequence([1, 2, 3], [3, 2, 1])) # True (all pushes, then pops)Comments:
- The deep idea: there is no need to reason over every possible interleaving; it is enough to simulate the one sensible strategy (pop as soon as the top matches what's expected). If that strategy can't produce the sequence, none will: delaying a possible pop only buries the element deeper.
peekdecides andpopexecutes: the "look before you leap" pattern from 03-02 and from 03-04's shunting-yard.- At the end, if the stack didn't end up empty, some element couldn't come out when its turn came:
False(whichis_empty()returns directly). - Cost O(n): each element is pushed once and popped at most once, even though there is a
whileinside thefor(count total operations, not nesting: an amortized cost argument like the one in 03-03).
Exercise 4: min-stack — the minimum in O(1)
Level: upper-intermediate. TaskFlow wants to show at all times "the highest-priority task in the history" (remember: priority 1 = highest, so we are looking for the minimum value). Scanning the history on every query would be O(n). Design MinStack with the usual contract (push, pop, peek, is_empty, size) plus a minimum() operation that returns the minimum value on the stack, everything in O(1).
Hint: an auxiliary stack that, in parallel with the main one, stores "the minimum in effect up to this level". Think about what must be pushed onto the auxiliary stack on each push, and what happens on each pop.
Solution
class MinStack:
"""Stack with minimum() in O(1) via an auxiliary stack of running minimums."""
def __init__(self):
self._stack = Stack() # the actual data
self._mins = Stack() # running minimums, in parallel level by level
def push(self, value):
self._stack.push(value)
if self._mins.is_empty():
self._mins.push(value)
else:
# The minimum in effect after this push: the lesser of new and previous
self._mins.push(min(value, self._mins.peek()))
def pop(self):
self._mins.pop() # both stacks grow and shrink together
return self._stack.pop()
def peek(self):
return self._stack.peek()
def minimum(self):
return self._mins.peek() # O(1): the current minimum sits on top
def is_empty(self):
return self._stack.is_empty()
def size(self):
return self._stack.size()
s = MinStack()
for priority in [3, 1, 2]:
s.push(priority)
print(s.minimum()) # 1
s.pop() # the 2 comes out
print(s.minimum()) # 1 (the 1 is still inside)
s.pop() # the 1 comes out
print(s.minimum()) # 3 (the previous minimum "revives" on its own!)Comments:
- The trick is the parallel invariant:
_minsalways has the same size as_stack, and its top is "the minimum of everything currently in_stack". Eachpushpushes onto_minstheminof the new value and the previous minimum; eachpoppops from both. - The magic is in the last
print: when the 1 is popped, the minimum goes back to 3 without recomputing anything, because the auxiliary stack remembers the minimum of each historical level. A simple counter ("the minimum is 1") couldn't: on popping the 1 it wouldn't know what the previous one was. - Cost: all operations O(1); O(n) extra space for the auxiliary stack. It is a space-for-time trade — the most common currency in data structures.
- Trace of the two stacks after pushing 3, 1, 2 (top up):
_stack |
_mins |
|---|---|
2 |
1 |
1 |
1 |
3 |
3 |
- Connection with TaskFlow: if actions are pushed and
_minsstores the minimum priority seen,minimum()answers "the highest-priority task touched this session" instantly. (To always extract the highest-priority task from the board — not just query it — the right structure is the priority queue, arriving in module 4.)
Exercise 5: bounded undo history
Level: upper-intermediate. The infinite history of UndoRedoManager (03-04) burns memory without restraint. TaskFlow decides: "at most the last k undoable actions are kept". Careful: unlike 03-03's BoundedStack (which rejected the push), here, when the limit is exceeded, the oldest action (the one at the bottom) must be discarded — not the new one rejected.
- Can a pure stack (top only) discard from the bottom in O(1)? Reason your answer.
- Implement
BoundedHistorywithrecord(action),undo()(→ action orNone) andsize(), honoring the limit. You may lean on the internallist(accepting whatever cost that has) or propose something better.
Solution
Part 1. No. The stack's contract only grants access to the top; the bottom is, by definition, unreachable without popping everything (O(n)). We need a structure with access to both ends: insert/remove at the top and discard at the bottom. That structure exists and is called a deque (double-ended queue); module 2 mentioned it (collections.deque, a block-based doubly linked list) and module 4 develops it. This exercise makes you feel why it is needed.
Part 2. An honest version with list, documenting the cost:
class BoundedHistory:
"""Undo history that keeps only the last k actions."""
def __init__(self, limit):
self._limit = limit
self._actions = [] # top = back of the list, as always
def record(self, action):
self._actions.append(action) # normal push: amortized O(1)
if len(self._actions) > self._limit:
self._actions.pop(0) # discard the OLDEST: O(n), ouch!
def undo(self):
if not self._actions:
return None # domain layer: None, as in 03-03
return self._actions.pop() # the most recent: O(1)
def size(self):
return len(self._actions)
h = BoundedHistory(3)
for n in (1, 2, 3, 4):
h.record({"type": "change_status", "task": n, "prev_data": {}})
print(h.size()) # 3 (task 1's action was discarded)
print(h.undo()["task"]) # 4 (the most recent is still the top)
print(h.undo()["task"]) # 3
print(h.undo()["task"]) # 2
print(h.undo()) # None (1 no longer exists: it was discarded)Comments:
- LIFO behavior is preserved intact at the top; the limit only acts at the bottom, and only once
kis exceeded. - That
pop(0)is our old friend the O(n) trap. Is it acceptable? It depends: withlimit = 50actions, shifting 50 references is negligible; withlimit = 1,000,000, it isn't. Knowing how to quantify when an O(n) is tolerable is engineering too (small, bounded n's don't hurt). - The elegant solution replaces the
listwithcollections.deque(maxlen=limit): withmaxlen, the deque itself automatically discards from the bottom onappend, all in O(1). Write it in your head and save it: we will do it properly in module 4, when the deque stops being a mention and becomes a protagonist.
Exercise 6: from recursion to iteration with an explicit stack
Level: advanced. In 03-04 you saw that the recursive sum_ids blows up with RecursionError on long chains. Let's generalize the cure. TaskFlow now organizes projects with nested subtasks: a task can hold a "subtasks" list of tasks, which in turn can have their own, to any depth.
- Write
count_pending_rec(task)(recursive): it counts how many tasks in the subtask tree (root included) have"status": "pending". - Rewrite it as
count_pending_iter(task)without recursion, using an explicit stack of "tasks pending a visit". - Verify that the recursive version fails on a chain of 100,000 nested subtasks and the iterative one doesn't.
Solution
Part 1: recursive version.
def count_pending_rec(task):
total = 1 if task["status"] == "pending" else 0
for subtask in task.get("subtasks", []): # get: there may be no subtasks
total += count_pending_rec(subtask) # one stack frame per level
return totalPart 2: iterative version. The general conversion recipe: wherever the recursion left pending work on the call stack, we leave pending work on a Stack of our own.
def count_pending_iter(task):
total = 0
pending = Stack() # tasks not yet visited
pending.push(task) # we start at the root
while not pending.is_empty():
current = pending.pop() # visit the most recently pending one
if current["status"] == "pending":
total += 1
for subtask in current.get("subtasks", []):
pending.push(subtask) # its children await their visit
return totalPart 3: the acid test.
# We build a chain of 100,000 nested tasks (each with one subtask)
root = {"id": 0, "title": "T0", "priority": 2, "status": "pending"}
current = root
for i in range(1, 100_000):
child = {"id": i, "title": f"T{i}", "priority": 2, "status": "pending"}
current["subtasks"] = [child]
current = child
print(count_pending_iter(root)) # 100000: without breaking a sweat
print(count_pending_rec(root)) # RecursionError: maximum recursion depth exceededComments:
- Compare the skeletons: they are the same algorithm. The recursive one says "count this one and delegate the children to the call stack"; the iterative one says "count this one and leave the children on my stack". The difference is who holds the pending work: an implicit stack limited to ~1000 frames, or an explicit stack limited only by your RAM.
- The loop
while not pending.is_empty(): current = pending.pop()is 03-02's draining pattern, now with the subtlety that the loop itself adds elements: the stack grows and shrinks until the work runs out. - The visit order changes relative to the recursive version (pushed children come out in reverse order), but for counting, order is irrelevant. When order matters, you control the pushing order — exactly what you will do in tree traversals (module 6) and graph DFS (module 7): this function is a DFS over the subtask tree, even if we don't call it that yet.
Common Mistakes and Tips
- Looking at the solution at the first snag: the snag is where the learning happens. Give yourself at least 15 minutes and one paper trace per exercise before comparing.
- Forgetting the
is_empty()guard: it appeared in exercises 2, 3 and 5. If your solution raisesIndexErroron odd inputs (empty ones, all backspaces...), a guard is almost certainly missing. - In exercise 3, trying to enumerate interleavings: the combinatorial explosion is enormous; the greedy simulation is O(n). Faced with an "is this sequence possible?" problem, think simulate before enumerate.
- In the min-stack, storing a single global minimum: it fails as soon as the minimum is popped. If your
minimum()goes stale after apop, this is the mistake: you need the minimum per level. - In the iterative conversion, forgetting to push the root or failing to consume with
pop: a loop that never starts, or an infinite one. Mental template: push seed → while not empty → pop → process → push children. - Final tip: come back to these exercises in a week and try to solve them from memory. Stacks are fixed with the hands, not the eyes.
Conclusion
Stacks module complete. In five lessons you have gone from a promise ("TaskFlow's undo is waiting for you") to an arsenal: the LIFO contract and its invariants, two interchangeable implementations measured with timeit, TaskFlow's real undo/redo with two stacks, balance validation, expression evaluation, and — in this lesson — the patterns of double dumping, greedy simulation, the parallel auxiliary stack (min-stack) and converting recursion into iteration, which is a DFS that doesn't know it yet. Along the way, two bright signals were left pointing at the next module: the bounded history needed to discard at the bottom while operating at the top (two ends: a deque), and the min-stack could query the minimum but not always extract the highest-priority item (a priority queue). Both needs, together with the policy opposite to LIFO — FIFO, first in is first out, like TaskFlow's tasks waiting to be processed in fair arrival order — are exactly the program of module 4: queues. See you there.
Data Structures Course
Module 1: Introduction to Data Structures
- What Are Data Structures?
- The Importance of Data Structures in Programming
- Types of Data Structures
- Algorithmic Complexity and Big O Notation
- Arrays and Memory: the Foundation of Data Structures
Module 2: Lists
Module 3: Stacks
- Introduction to Stacks
- Basic Stack Operations
- Stack Implementation
- Stack Applications
- Stack Exercises
Module 4: Queues
- Introduction to Queues
- Basic Queue Operations
- Circular Queues
- Priority Queues
- Double-Ended Queues (Deques)
- Queue Exercises
Module 5: Hash Tables and Dictionaries
- Introduction to Hash Tables
- Hash Functions and Collision Resolution
- Dictionaries and Sets in Practice
- Hash Table Exercises
Module 6: Trees
- Introduction to Trees
- Binary Trees
- Tree Traversals
- Binary Search Trees
- AVL Trees
- B-Trees
- Heaps
- Tree Exercises
Module 7: Graphs
- Introduction to Graphs
- Graph Representation
- Graph Search Algorithms
- Shortest Path Algorithms
- Minimum Spanning Trees
- Graph Applications
- Graph Exercises
