List and stack ADTs
The queue was our first abstract data type: a description of what operations a structure supports (enqueue, dequeue, peek, is_empty), independent of how it is implemented. This page adds three more. Side by side, and in roughly increasing order of capability, the four look like this:
- A queue adds values at one end and removes them from the other.
- A stack adds and removes values at the same end, called the top.
- A deque (short for double-ended queue, pronounced “deck”) adds and removes values at both ends. A deque can do everything a queue or a stack can do: using only
add_backandremove_frontmakes it a queue, and using onlyadd_backandremove_backmakes it a stack. Python’scollections.deque, whichDequeQueuewas built on, implements this ADT under its own method names (appendleft,append,popleft, andpop). - A list allows reading, inserting, and deleting at any position, identified by an integer index, not only at the ends.
The stack ADT
Section titled “The stack ADT”A stack holds a sequence of values and allows access at only one end, called the top. Values are added to the top and removed from the top, so the value removed is always the one added most recently. The following snapshots show a stack that starts empty and then receives six operations: two pushes, a pop, a push, a pop, and a final push.
Throughout the sequence, A stays at the bottom, because every value pushed after it is popped first. Each pop returns the most recently pushed value still on the stack. This order is called LIFO (last in, first out). The stack ADT has four operations:
push(value): addvalueto the top of the stackpop(): remove and return the value at the top of the stackpeek(): return (without removing) the value at the topis_empty(): whether the stack has anything in it
A stack of plates behaves the same way: new plates go on top, plates are taken from the top, and a plate in the middle cannot be reached without first removing the plates above it. This is the opposite of a queue, in which the first value added is the first value removed.
Implementing a stack with a Python list
Section titled “Implementing a stack with a Python list”A stack only ever adds and removes at one end, and the end of a Python list is exactly where adding and removing are fast. (How a Python list manages to grow at its end is the subject of the last part of this page.) Let the end of the list be the stack’s top:
class PyListStack: def __init__(self): self.data = []
def push(self, value): self.data.append(value)
def pop(self): if self.is_empty(): return None return self.data.pop()
def peek(self): if self.is_empty(): return None return self.data[-1]
def is_empty(self): return len(self.data) == 0Notice that PyListStack is a thin wrapper: push and pop just call the list’s own append and pop (Python’s list.pop() with no argument already removes and returns the last element — exactly the stack’s top). The point of writing the wrapper class isn’t to add new capability; it’s to expose only the four stack operations, hiding the fact that a Python list happens to support a lot more than that (you could accidentally call .insert(0, x) on a bare list and break the LIFO discipline — PyListStack doesn’t let you).
pop and peek both check is_empty() first and return None rather than letting the underlying list raise an error. A bare Python list would raise an exception if you called .pop() on an empty list; PyListStack instead gives callers a value they can check (if stack.pop() is None:), without requiring any error-handling machinery to use it.
Where stacks show up
Section titled “Where stacks show up”Stacks appear whenever something nested must be resolved in the reverse of the order in which it was opened. The figure below shows three examples.
- Checking HTML. An HTML document nests tags inside one another, as in
<p><em><b>hi</b></em></p>. A program that checks the document pushes each opening tag onto a stack. Each closing tag must match the tag on top, which is then popped. After reading<p><em><b>hi, the stack holds<p>,<em>, and<b>, so the next closing tag must be</b>. Compilers check the nesting of brackets and blocks in program code the same way. - Function calls. When
maincallsdraw_house, which callsdraw_roof, Python must remember where to resume each function when the function it called returns. Each call pushes a frame holding the function’s local variables and its place in the code, and each return pops that frame. The frame on top belongs to the function that is currently running. This stack is called the call stack. - Exploring a tree. A search through a branching structure, such as a tree or a maze, can keep the path from the starting point on a stack. Moving deeper pushes a node, and reaching a dead end pops the most recent node, which returns the search to the last place where another branch remains to be tried.
A text or graphics editor uses a stack for undo. Each edit, such as typing a word or drawing a shape, is pushed onto an undo stack, and the Undo command pops the most recent edit and reverses it. A browser’s Back button works the same way with visited pages.
Example: matching brackets with a stack
Section titled “Example: matching brackets with a stack”Given a string, are its brackets balanced? A string is balanced if every (, [, or { has a matching closer, in the right order, with no leftovers. For example, (a + b) * [c - d] is balanced. (a + b] is not balanced, because it has the wrong closer, and ((a + b) is not balanced, because one opener is never closed.
A closing bracket must match the most recently opened, not-yet-closed bracket. That is exactly the order in which a stack returns values.
Matching brackets by hand with a stack
Section titled “Matching brackets by hand with a stack”Scan the string from left to right, keeping the unmatched opening brackets on a stack:
- Push each opening bracket.
- For each closing bracket, pop the top of the stack and check that it is the matching opener.
- At the end, the string is balanced only if the stack is empty.
In the tables below, the stack is written bottom first, with the top at the right.
Parse: {[()]}
| Read | Action | Stack afterward |
|---|---|---|
{ | push | { |
[ | push | { [ |
( | push | { [ ( |
) | pop (, which matches | { [ |
] | pop [, which matches | { |
} | pop {, which matches | empty |
Every pop matched, and the stack ends empty, so the string is balanced.
Parse: ([)]
| Read | Action | Stack afterward |
|---|---|---|
( | push | ( |
[ | push | ( [ |
) | pop [, which does not match | ( |
The ) closes (, but the most recent opener is [, so the string is not balanced. The brackets are all present in the right numbers; they are simply closed in the wrong order, and the stack is what detects that.
Matching brackets in code: is_balanced
Section titled “Matching brackets in code: is_balanced”The function is_balanced follows the same three rules:
def is_balanced(text): pairs = {")": "(", "]": "[", "}": "{"} stack = PyListStack()
for char in text: if char in "([{": stack.push(char) elif char in ")]}": if stack.pop() != pairs[char]: return False
return stack.is_empty()Every opening bracket is pushed. Every closing bracket pops the stack and checks that the value returned is the matching opener. If the stack is already empty, pop returns None, which cannot equal any bracket, so the comparison correctly fails instead of crashing. At the end, the string is balanced only if every opener found a closer, which is exactly the condition stack.is_empty() checks.
The list ADT
Section titled “The list ADT”A queue, a stack, and a deque all restrict access to the ends of a sequence. A list supports far more: adding, removing, or reading at any position, identified by its integer index.
insert(index, value): addvalueat a given index, shifting everything after it over by oneprepend(value): addvalueat the frontappend(value): addvalueat the enddelete(index): remove the value at a given indexget(index): return the value at a given indexfind(value): return the index ofvalue, or indicate that it isn’t presentlength(): how many values the list holds
Python’s own list implements this ADT — .insert(i, value), .append(value), del my_list[i], my_list[i], .index(value), and len(my_list) are all list-ADT operations, just under Python’s own names.
The rest of this page implements the list ADT in two ways. The first is linked: each value lives in a separate node on the heap, and the nodes are connected by references, extending the linked list that implemented the queue. The second is sequential: the values sit next to each other, in order, in one block of memory. Both implement the same ADT with the same operations, but they organize the values in memory differently.
Implementing a list with a linked list
Section titled “Implementing a list with a linked list”The doubly linked list data structure
Section titled “The doubly linked list data structure”We could implement the list ADT with a singly linked list (SLL), like the queue’s, in which each node has only a next reference. A general list must support deleting at any position, however, and deleting a node means changing its predecessor’s next reference to skip over it. In an SLL, the only way to find a node’s predecessor is to walk the list from the front. (The queue never needed a node’s predecessor, which is why an SLL was sufficient there.)
A doubly linked list (DLL) gives each node a prev reference as well as next, so the predecessor is one step away:
class Node: def __init__(self, value, prev=None, next=None): self.value = value self.prev = prev self.next = nextDeleting a node from a doubly linked list
Section titled “Deleting a node from a doubly linked list”With prev available, deleting a node requires no search. The node’s own prev and next identify its two neighbors, so prev.next and next.prev can each be pointed at the other, skipping over the deleted node:
Inserting a node after a known node
Section titled “Inserting a node after a known node”In either an SLL or a DLL, inserting a new node after a known node changes only the neighboring references. A DLL also updates the matching prev references:
Circular doubly linked lists with sentinels
Section titled “Circular doubly linked lists with sentinels”Relinking neighbors still leaves special cases at the edges of the list. The first node has no prev node to update, and an empty list has no node to insert after. A sentinel, a permanent node that never holds a real value, removes both. The list keeps a reference to the sentinel rather than to the first real node, and the sentinel’s next is the first real node. The list is also circular: the last node’s next is the sentinel, and the sentinel’s prev is the last node.
Why the sentinel removes special cases
Section titled “Why the sentinel removes special cases”In a circular list with a sentinel, every node, including the sentinel, always has a real prev and a real next, so no reference is ever None. Each edge case of a plain DLL becomes an ordinary case:
| Case | Plain DLL | Circular DLL with a sentinel |
|---|---|---|
| Insert at the front | New node’s prev is None; update head | Insert after the sentinel |
| Insert at the end | New node’s next is None | Insert after sentinel.prev |
| Insert into an empty list | No node to insert after; set head | Insert after the sentinel, which links to itself |
| Delete the only node | Set head to None | Relink its neighbors, which are both the sentinel |
Insertion and deletion therefore run the same few lines of code at every position.
Creating an empty list: the sentinel alone
Section titled “Creating an empty list: the sentinel alone”An empty list is just the sentinel, linked to itself in both directions:
class LinkedList: def __init__(self): self.sentinel = Node(None) self.sentinel.prev = self.sentinel self.sentinel.next = self.sentinelThe sentinel’s next is the first real node, and its prev is the last. With no real nodes, both are the sentinel itself.
prepend and append built on insert_after
Section titled “prepend and append built on insert_after”Every operation of a sentinel-based list builds on one method, insert_after(node, value), which splices a new node in right after a given node by pointing the new node at its two neighbors and pointing those neighbors back at it. insert_after takes a node rather than an index; it is part of the implementation, not an operation of the list ADT. Once it exists, prepend and append need no logic of their own. The position before the first real node is always the sentinel, and the position before the end is always sentinel.prev, which is the last real node, or the sentinel itself when the list is empty. Both operations are therefore insert_after at a fixed position:
Finding a node by value: find_node
Section titled “Finding a node by value: find_node”insert_after needs a node to insert after, but the ADT’s find(value) returns an index. A LinkedList method, find_node, walks the list from the sentinel and returns the node itself, or None if no node holds the value:
def find_node(self, value): node = self.sentinel.next while node is not self.sentinel: if node.value == value: return node node = node.next return NoneNodes are part of the implementation, not the ADT, so find_node is a method of LinkedList rather than an operation of the list ADT, just as insert_after is. Together they let client code insert at a position it found by value. For example, the following code builds a list of three states and then inserts Ohio after Idaho:
states = LinkedList()for name in ["Maine", "Idaho", "Utah"]: states.append(name)
node = states.find_node("Idaho")states.insert_after(node, "Ohio")The list now holds Maine, Idaho, Ohio, and Utah. The figure below shows the result, with the new node and the four references that changed highlighted: Idaho’s next, Utah’s prev, and the new node’s prev and next. No other reference changes, however long the list is.
Index-based operations walk from the sentinel
Section titled “Index-based operations walk from the sentinel”Deleting a node works the same way whether the node is in the middle of the list, at either end, or the list’s only remaining node. It is worth tracing through what happens when node.prev and node.next are both the sentinel.
The operations that take an index (insert, delete, and get) first have to find the node at that index, and find and length have to visit the nodes in order. All of them follow the same pattern: start from self.sentinel.next and follow next references, counting steps, comparing values, or both, until reaching the right node or arriving back at the sentinel. Unlike the sequential list below, a linked list cannot jump straight to a position; it has to walk there.
Implementing a list with a growing array
Section titled “Implementing a list with a growing array”Arrays: a fixed block of slots
Section titled “Arrays: a fixed block of slots”A sequential list stores its values next to each other, in order, in one contiguous block of memory called an array. An array has a fixed number of slots, its capacity, which is chosen when the block is allocated and never changes afterward. The list also keeps track of its size, the number of slots actually in use; the values occupy slots 0 through size - 1, and the remaining slots are free.
Python has no separate array type in its core language, so we can model a block of slots with a Python list that is created at its full capacity and never grows or shrinks on its own:
class ArrayList: def __init__(self): self.capacity = 1 self.size = 0 self.data = [None] * self.capacityBecause the slots are contiguous and every slot is the same size, the location of slot i can be computed directly from i: it is the location of the block, plus i slots. get(i) therefore reads self.data[i] immediately, with no walking. This is the key difference from a linked list, which has no way to reach the node at index i except by following i references.
Inserting and deleting by shifting values
Section titled “Inserting and deleting by shifting values”The values in a sequential list must stay packed together, in order, with no gaps. Inserting a value at index i therefore requires first moving every value at index i or later one slot to the right, to open a gap:
The shifting has to proceed from the right end toward the left: 50 moves first, into the free slot, then 40 moves into the slot 50 just vacated, and so on. Shifting from left to right instead would overwrite 30 with 20 before 30 had been moved. Deleting at index i is the reverse: every value after index i moves one slot to the left, closing the gap, and the size decreases by one. prepend is simply insert at index 0, and append is insert at index size, which requires no shifting at all.
Growing a full array by doubling its capacity
Section titled “Growing a full array by doubling its capacity”Every insertion needs a free slot. When the block is full (size == capacity), there is nowhere to put a new value, and an array cannot be extended in place, since the memory right after it may already be in use. The list instead allocates a new, larger block, copies every existing value into it, and discards the old block. The insertion then proceeds as usual, in the new block:
The new block is twice the size of the old one. Starting from a capacity of 1, a sequence of appends grows the block as follows:
| Append number | Full before appending? | Capacity afterward |
|---|---|---|
| 1 | no | 1 |
| 2 | yes: grow from 1 to 2 | 2 |
| 3 | yes: grow from 2 to 4 | 4 |
| 4 | no | 4 |
| 5 | yes: grow from 4 to 8 | 8 |
| 6, 7, 8 | no | 8 |
| 9 | yes: grow from 8 to 16 | 16 |
Most appends find a free slot and simply write the value into it; only an append that finds the block full pays for allocating a new block and copying.
Python’s list is a growing array
Section titled “Python’s list is a growing array”Python’s own list is a growing array of exactly this kind: it keeps some spare capacity at the end and, when that runs out, allocates a larger block and copies its contents over. That is why a Python list supports my_list[i] directly, and why PyListStack above can grow without limit even though every block has a fixed capacity.
A natural question is why the capacity should double, rather than grow by some fixed number of slots, such as 10, each time. The answer depends on how much copying each strategy requires over a long sequence of appends, and it is a good question to think through with a small example.
Source material for these notes
Section titled “Source material for these notes”The circular, doubly linked list with a sentinel follows the design in the CS 1 notes by Balkcom et al., chapter 17, “Linked lists”.
The rest of these notes were drafted by Claude (Anthropic) at Balkcom’s direction and then edited by Balkcom. This includes the stack and list ADTs, the implementation of a stack with a Python list, the bracket-matching example, the implementation of a list with a growing array and the doubling strategy, the figures, and the practice problems. Claude’s drafts reflect the standard treatment of these topics, which can also be found in Goodrich, Tamassia, and Goldwasser, Data Structures and Algorithms in Python, chapters 5 through 7, and in Cormen, Leiserson, Rivest, and Stein, Introduction to Algorithms, in the chapter on elementary data structures.
Further reading
Section titled “Further reading”- The Python Tutorial: Using Lists as Stacks
- How are lists implemented in CPython?: Python’s
listis a growing array - TimeComplexity: the running times of operations on Python’s lists, deques, sets, and dictionaries
Practice problems
Section titled “Practice problems”Try these on your own before checking the solutions.
Problem 1: Trace is_balanced
Section titled “Problem 1: Trace is_balanced”For each input, list the contents of the stack after each bracket is processed, and give the value that is_balanced returns. If it returns False, say at which character, and why.
{[()()]}[{]}(()
(Solution)
Problem 2: A deque as a stack and as a queue
Section titled “Problem 2: A deque as a stack and as a queue”A deque supports add_front, add_back, remove_front, remove_back, peek_front, peek_back, and is_empty. Which of these operations would you use to implement each stack operation? Which would you use to implement each queue operation? Is there more than one correct answer?
(Solution)
Problem 3: Trace a sentinel-based list
Section titled “Problem 3: Trace a sentinel-based list”Starting from an empty LinkedList (as described on this page), trace through this sequence of calls:
lst = LinkedList()lst.append(10)lst.prepend(5)lst.append(20)Afterward, what are the values in the list, in order? What does lst.sentinel.next.value equal? What does lst.sentinel.prev.value equal?
(Solution)
Problem 4: Why no sentinel for the queue
Section titled “Problem 4: Why no sentinel for the queue”The linked list that implemented the queue, LinkedQueue, doesn’t use a sentinel, even though a sentinel would eliminate its one special case: resetting back to None when the queue becomes empty. In a few sentences, explain why a sentinel is worth its overhead for a general list but not for a queue.
(Solution)
Problem 5: A circular list without a sentinel
Section titled “Problem 5: A circular list without a sentinel”A doubly linked list could be made circular without a sentinel: the last node’s next would be the first node, the first node’s prev would be the last node, and the list would keep a head reference to the first node. Which operations would then need special cases that the sentinel version avoids? For each, say what the extra code would have to do.
(Solution)
Problem 6: Shifting in a sequential list
Section titled “Problem 6: Shifting in a sequential list”An ArrayList has capacity 6 and size 4, and its slots hold [10, 20, 30, 40, None, None]. Show the slots and the size after insert(1, 15), and then after delete(0). During the insertion, which value must be moved first, and why?
(Solution)
Problem 7: Counting resizes
Section titled “Problem 7: Counting resizes”An ArrayList starts empty, with capacity 1, and doubles its capacity whenever an append finds the block full. After 10 appends, what is its capacity? How many times did it allocate a new block, and at which appends?
(Solution)
Problem 8: Evaluating a postfix expression
Section titled “Problem 8: Evaluating a postfix expression”In postfix notation, each operator comes after its two operands: 3 4 + means , and 8 2 - means . A stack evaluates a postfix expression, reading tokens left to right. A number is pushed. An operator pops two values, applies the operator with the value pushed earlier on the left, and pushes the result. At the end, the one value left on the stack is the answer.
- Evaluate
8 2 5 * - 3 +, listing the stack after each token. - What answer do you get if the evaluator instead puts the value popped first on the left?
- What goes wrong when the evaluator reads
4 + 2? What about4 2 5 +?
(Solution)