The time has come to keep the promise: implement the stack. And we will do it twice. In module 1 we distinguished the ADT (the contract: which operations, at what cost) from the implementation (the how), with ListStack and DictStack as an example of one contract admitting several "hows". Today that idea stops being theoretical: we will write a Stack class on top of Python's list and a LinkedStack on top of the nodes from module 2, verify that both fulfill exactly the same contract and the same invariants, measure them with timeit, and discuss which one suits each situation. To close, TaskFlow gets its ActionHistory: the stack that records every user action along with the data needed to revert it. This lesson is the heart of the module: by the end you will have not just a stack, but the judgment to choose between equivalent implementations — a skill you will use with every structure in the rest of the course.
Contents
- The contract both implementations must fulfill
- Implementation 1:
Stackon top oflist - Implementation 2:
LinkedStackon top of nodes - Same contract, same tests
- Cost comparison: amortized vs guaranteed, and memory
- Measuring with
timeit - Which one to choose, and why?
- TaskFlow: the
ActionHistoryclass
The contract both implementations must fulfill
Let's put in writing what we agreed in the previous lessons. Every stack in this course must offer:
| Operation | Behavior | Empty stack | Required cost |
|---|---|---|---|
push(e) |
Pushes e onto the top |
(not applicable) | O(1) |
pop() |
Removes and returns the top | IndexError |
O(1) |
peek() |
Returns the top without removing it | IndexError |
O(1) |
is_empty() |
True/False |
True |
O(1) |
size() |
Number of elements | 0 |
O(1) |
Whoever uses the stack may depend only on this table: never on the internal details. That gives us total freedom to implement the inside however we want... as long as the table holds.
Implementation 1: Stack on top of list
In the previous lesson we used a bare list as a provisional stack and spotted its flaw: nothing prevents bypassing the contract (insert(0, ...), access by index...). The solution is encapsulation: keep the list as an internal attribute and expose only the five operations.
class Stack:
"""Stack (LIFO) implemented on top of Python's list.
The top is the BACK of the internal list: append/pop at the back are O(1).
"""
def __init__(self):
self._items = [] # internal list; the _ signals "private"
def push(self, element):
self._items.append(element) # push = append at the back
def pop(self):
if not self._items:
raise IndexError("pop from an empty stack")
return self._items.pop() # pop() with no argument = from the back
def peek(self):
if not self._items:
raise IndexError("peek at an empty stack")
return self._items[-1] # read-only look at the top
def is_empty(self):
return len(self._items) == 0
def size(self):
return len(self._items)
def __len__(self): # enables len(stack)
return len(self._items)
def __str__(self): # top on the left, like the traces
items = ", ".join(repr(e) for e in reversed(self._items))
return f"Stack(top -> [{items}])"Detailed explanation:
self._items: the leading underscore is Python's convention for "internal attribute: don't touch it from outside". Python doesn't technically forbid it, but the whole ecosystem respects the signal. This line is the encapsulation: the user ofStackno longer sees a list, they see a stack.- The top is the back of the list: a decision inherited from the previous lesson.
appendandpop()at the back are the dynamic array's cheap operations; withinsert(0)/pop(0)we would have paid O(n). popandpeekraiseIndexErrorwith our own message: thelistwould already raiseIndexError, but checking it ourselves lets us give a clear message ("pop from an empty stack" instead of "pop from empty list") and, above all, leaves the design decision written in the code rather than delegated by accident.len(stack)andprint(stack): small Pythonic luxuries, just as we did with__str__inLinkedList.__str__prints the top first, so it matches our trace tables.
Let's try it with TaskFlow actions:
s = Stack()
s.push({"type": "create", "task": 7})
s.push({"type": "change_priority", "task": 7})
print(s.peek()) # {'type': 'change_priority', 'task': 7}
print(s.size()) # 2
print(s.pop()["type"]) # change_priority
print(s) # Stack(top -> [{'type': 'create', 'task': 7}])Implementation 2: LinkedStack on top of nodes
Now the version promised at the end of module 2: a stack on top of linked nodes, "in a few lines". The whole idea fits in one sentence: the top of the stack is the head of a linked list; push is insert_front and pop is removing the head — the two O(1) operations you already command.
We reuse the Node from module 2 (data + next):
class Node:
"""The same Node from module 2: a data item and the reference to the next."""
def __init__(self, data):
self.data = data
self.next = None
class LinkedStack:
"""Stack (LIFO) implemented on top of linked nodes.
The top is the HEAD of the chain of nodes: push/pop at the head are O(1).
"""
def __init__(self):
self._top = None # reference to the topmost node (or None)
self._size = 0 # counter, so size() is O(1)
def push(self, element):
new_node = Node(element) # 1. create the node
new_node.next = self._top # 2. the new node points to the old top
self._top = new_node # 3. the new node IS the top
self._size += 1
def pop(self):
if self._top is None:
raise IndexError("pop from an empty stack")
node = self._top # 1. hold on to the node being removed
self._top = node.next # 2. the top becomes the next node
self._size -= 1
return node.data # 3. return the data (the node frees itself)
def peek(self):
if self._top is None:
raise IndexError("peek at an empty stack")
return self._top.data
def is_empty(self):
return self._top is None
def size(self):
return self._size
def __len__(self):
return self._size
def __str__(self):
items, current = [], self._top
while current is not None:
items.append(repr(current.data))
current = current.next
return f"LinkedStack(top -> [{', '.join(items)}])"Step-by-step analysis of the two key operations:
pushis exactlyLinkedList'sinsert_front, with the head renamed_top. Three steps, no loop: O(1) always, whether there are 3 elements or 3 million. The order of steps 2 and 3 matters: if you didself._top = new_nodebefore wiringnew_node.next, you would lose the reference to the old stack (the same ordering mistake we saw when inserting into linked lists).popis removing the head: set the node aside, advance the top, return the data. Also loop-free: O(1) always. The removed node is no longer referenced, and Python's garbage collector frees it._sizeas a counter: counting by traversal would be O(n); maintaining the counter on eachpush/popgivessize()in O(1). Same technique as inLinkedList.
flowchart LR
subgraph BEFORE["Before push(D)"]
direction LR
top1["_top"] --> C1["C"] --> B1["B"] --> A1["A"] --> N1["None"]
end
subgraph AFTER["After push(D)"]
direction LR
top2["_top"] --> D2["D"] --> C2["C"] --> B2["B"] --> A2["A"] --> N2["None"]
end
BEFORE -->|"new_node.next = old top"| AFTER
Same contract, same tests
The definitive proof that ADT and implementation are properly separated: a single set of checks (the invariants from lesson 03-02) must pass with either of the two classes, without changing a single line:
def check_contract(StackClass):
"""Verifies the ADT's invariants on any implementation."""
s = StackClass()
assert s.is_empty() and s.size() == 0 # invariant 1
s.push("a")
assert s.peek() == "a" and s.size() == 1 # invariant 2
s.push("b")
assert s.pop() == "b" and s.peek() == "a" # invariant 3: pop undoes push
before = s.size()
s.peek()
assert s.size() == before # invariant 4: peek modifies nothing
s.pop()
try:
s.pop() # empty stack: must fail
assert False, "should have raised IndexError"
except IndexError:
pass
print(f"{StackClass.__name__}: contract OK")
check_contract(Stack) # Stack: contract OK
check_contract(LinkedStack) # LinkedStack: contract OKThe function takes the class as a parameter and works with both. To client code (and to TaskFlow) the two stacks are interchangeable: that is programming against the contract.
Cost comparison: amortized vs guaranteed, and memory
If both fulfill the contract, how do they differ? In the finer points of performance and memory:
| Aspect | Stack (on list) |
LinkedStack (on nodes) |
|---|---|---|
push |
O(1) amortized | O(1) guaranteed |
pop / peek |
O(1) | O(1) |
| Memory per element | Compact: contiguous references in the array | Higher: each item pays for an extra Node object (data + next) |
| Memory locality | Good (contiguity → happy cache) | Worse (nodes scattered across memory) |
| After heavy draining | The array may keep spare capacity | Frees each node on pop |
| Lines of code | Fewer (delegates to list) |
More (manages nodes and counter) |
The new nuance is amortized vs guaranteed, and it deserves an explanation:
- Python's
listis a dynamic array (module 1): when it fills up, it reserves a bigger block and copies every element. That particularappendcosts O(n). - Since capacity grows geometrically (always by a percentage), those copies become rarer and rarer. Spread out ("amortized") across all the
appendcalls, the average cost per operation is O(1). But an individualpush, every once in a while, is slow. LinkedStacknever copies anything: eachpushcreates a node and moves two references. O(1) in the worst case, no exceptions.
When does this difference matter? Almost never in ordinary applications... and enormously in systems with stable latency requirements (real-time audio, embedded systems), where an occasional "spike" is unacceptable. It is the first time in the course that two correct options differ not in average cost but in its distribution: note the concept down, it will come back.
Measuring with timeit
Let's back the claims with data, as we did in module 1. We measure pushing and popping 100,000 elements with each implementation:
import timeit
def drill(StackClass, n=100_000):
s = StackClass()
for i in range(n):
s.push(i)
while not s.is_empty():
s.pop()
for StackClass in (Stack, LinkedStack):
t = timeit.timeit(lambda: drill(StackClass), number=10)
print(f"{StackClass.__name__:14}: {t:.3f} s (10 rounds of 100k push+pop)")Indicative results (the exact numbers depend on your machine; the proportions don't):
Two important readings, seemingly contradictory:
- Both scale the same: double
nand you will see both times roughly double — linear behavior for n operations, i.e. O(1) per operation. The theory is confirmed for both. - The constant matters:
Stackis several times faster. Didn't we sayLinkedStackhad the better guarantee? Yes: a better worst case, but a worse constant, because eachpushcreates aNodeobject in Python (expensive) whileappendis implemented in C over contiguous memory. Big O speaks of growth, not absolute speed: two O(1)s can differ by a factor of 5.
This is a hugely valuable general lesson: measuring complements reasoning. Asymptotic analysis tells you what scales; timeit tells you what it really costs on your platform.
Which one to choose, and why?
A practical criterion for Python:
- By default:
Stackon top oflist. Less code, less memory, faster in practice, and it delegates to an ultra-optimized piece of the language. It is the right choice for TaskFlow and for 95% of cases. LinkedStackwhen... you need guaranteed O(1) per operation (stable latency), or when the stack shares nodes with other linked structures. And, above all, it is the implementation you will see in books and interviews, and the one used in languages without a built-in dynamic array: understanding it is not optional.- The key point: since both fulfill the contract, switching from one to the other touches not a single line of client code. Choosing the implementation is a local, reversible decision; choosing the wrong ADT, on the other hand, is paid for across the whole program.
TaskFlow: the ActionHistory class
Let's close by putting the stack to work. TaskFlow's history records actions: dictionaries with the action type, the id of the affected task, and the previous data needed to revert it:
ActionHistory wraps a Stack and offers domain vocabulary (record/undo) instead of structure vocabulary (push/pop):
class ActionHistory:
"""TaskFlow's undo history: a stack of revertible actions."""
def __init__(self):
self._stack = Stack() # composition: it contains a Stack
def record(self, action_type, task_id, prev_data):
action = {"type": action_type, "task": task_id, "prev_data": prev_data}
self._stack.push(action)
def undo(self):
"""Removes and returns the last recorded action (so it can be reverted)."""
if self._stack.is_empty():
return None # here None IS correct: "there was nothing"
return self._stack.pop()
def next_to_undo(self):
"""Text for the interface, e.g. the Undo button's tooltip."""
if self._stack.is_empty():
return "Nothing to undo"
action = self._stack.peek()
return f"Undo: {action['type']} (task {action['task']})"
def has_actions(self):
return not self._stack.is_empty()
def total(self):
return self._stack.size()And a usage session on our usual task:
task = {"id": 7, "title": "Review budget", "priority": 2, "status": "pending"}
history = ActionHistory()
# The user changes the priority to 1: we save the PREVIOUS value before touching anything
history.record("change_priority", 7, {"priority": task["priority"]})
task["priority"] = 1
# The user marks it in progress
history.record("change_status", 7, {"status": task["status"]})
task["status"] = "in_progress"
print(history.next_to_undo()) # Undo: change_status (task 7)
# They press Undo: we retrieve the action and restore the previous data
action = history.undo()
task.update(action["prev_data"])
print(task["status"]) # pending
print(history.total()) # 1Design details worth underlining:
prev_datais captured before modifying the task: it is the minimum information needed to revert. Recording afterward would be too late: the old value would already be gone.- Reverting is
task.update(action["prev_data"]): since the previous data is a dict with the old fields, restoring them is one line. undoreturnsNonewhen there is nothing: does that contradict the previous lesson? No: the stack still raisesIndexError; it isActionHistory, the domain layer, that decides "undoing with no history" is not a programming error but a normal interface case. The exception lives in the structure; the tolerance, in the application.- Notice there is still no "redo": if the user undoes and regrets it, there is no way back. Solving that requires a second stack cooperating with this one — it is the first application of the next lesson.
Common Mistakes and Tips
- Breaking encapsulation: accessing
stack._itemsorstack._topfrom outside "because it's faster". You have just turned your stack into a list with no contract; the day you change the implementation, all that code dies. - Reversing the steps of the linked
push: assigningself._top = new_nodebeforenew_node.next = self._toploses the entire previous stack. If yourLinkedStack"only remembers the last element", this is why. - Forgetting the
_sizecounter: without it, eithersize()traverses the stack (O(n), contract broken) or it returns wrong data. Everypushadds one; every successfulpopsubtracts one (don't subtract before checking for emptiness). - Recording the action in the history after modifying the task:
prev_datawould already capture the new value, and undo would undo nothing. Capture first, then modify. - Comparing implementations without measuring: "the linked one will be faster because it's guaranteed O(1)" — you have just seen that it isn't. Reason with Big O, decide with
timeit. - Tip: always write a
check_contract-style function when you have two implementations of the same thing. It is the safety net that lets you switch implementations without fear.
Exercises
Exercise 1: BoundedStack
TaskFlow doesn't want an infinite history. Create a BoundedStack class that takes capacity in the constructor and behaves like Stack, except that push on a full stack raises OverflowError("stack full"). Add an is_full() method. Build it by inheriting from Stack (hint: super().__init__() and super().push(...)).
Exercise 2: __iter__ for LinkedStack
Add to LinkedStack an __iter__ method (a generator, like LinkedList's in module 2) that walks the data from top to bottom without modifying the stack. With it, list(stack) must return the elements in popping order. Extra question: why is "traversing a stack" strictly speaking a step outside the ADT, and why is it useful in practice anyway?
Exercise 3: reverting by action type
Write the function revert(action, tasks) for TaskFlow, where tasks is a dict of tasks by id (like the board in module 2) and action is a history dict. It must handle three types: "change_status" and "change_priority" (restore prev_data onto the task) and "create" (reverting a creation = deleting the task; in that case prev_data is {}).
Solutions
Solution 1:
class BoundedStack(Stack):
def __init__(self, capacity):
super().__init__() # initializes Stack's internal list
self._capacity = capacity
def is_full(self):
return self.size() >= self._capacity
def push(self, element):
if self.is_full():
raise OverflowError("stack full")
super().push(element) # delegates the actual push to StackInheritance reuses everything (pop, peek, is_empty, size, __str__); only push adds the guard. Test: with capacity=2, the third push raises OverflowError. (In 03-05 we will see a more useful policy for a history: instead of failing, discard the oldest action.)
Solution 2:
class LinkedStack(LinkedStack): # or add the method to the original class
def __iter__(self):
current = self._top
while current is not None:
yield current.data # yields data from top to bottom
current = current.nextIt is the same generator pattern as LinkedList.__iter__: a current cursor advancing along next. list(stack) returns [top, ..., bottom], the exact order they would come out in with pop, but without removing them. On the extra question: the stack's contract only grants access to the top, so iterating is "cheating" on the pure ADT; in practice it is accepted as an inspection operation (debugging, displaying the history on screen) because it doesn't modify state. The red line is modifying during iteration.
Solution 3:
def revert(action, tasks):
task_id = action["task"]
if action["type"] == "create":
# Undoing a creation means removing the task from the board
del tasks[task_id]
elif action["type"] in ("change_status", "change_priority"):
# Restore the previous fields onto the existing task
tasks[task_id].update(action["prev_data"])
else:
raise ValueError(f"unknown action type: {action['type']}")Quick test:
tasks = {7: {"id": 7, "title": "Review budget", "priority": 1, "status": "in_progress"}}
action = {"type": "change_status", "task": 7, "prev_data": {"status": "pending"}}
revert(action, tasks)
print(tasks[7]["status"]) # pendingThe else with ValueError is deliberate: if TaskFlow adds an action type tomorrow and we forget its reversal, better a loud error than an undo that doesn't undo.
Conclusion
You now have two complete, verified stacks: Stack, which encapsulates a list with the top at the back, and LinkedStack, which reincarnates module 2's insert_front/remove-the-head with the head renamed as the top. You have verified with a single test suite that both fulfill an identical contract — the essence of the ADT — and you have learned to tell them apart where they truly differ: amortized O(1) versus guaranteed O(1), memory consumption, and real constants measured with timeit (with the moral that two O(1)s can differ by a factor of 5). TaskFlow, for its part, now records and undoes actions with ActionHistory and its "capture the previous data, then modify" pattern. But it is missing something every user expects: regretting an undo. In the next lesson we will build the full undo/redo with two cooperating stacks, and we will see that stacks also validate parentheses, evaluate expressions, and hold up every function call Python executes.
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
