Skip to content

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:

Queue front back enqueue(value) dequeue() peek() is_empty() Stack top push(value) pop() peek() is_empty() Deque front back add_front(value) add_back(value) remove_front() remove_back() peek_front() peek_back() is_empty() List 0 1 2 3 insert(index, value) prepend(value) append(value) delete(index) get(index) find(value) length()
  • 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_back and remove_front makes it a queue, and using only add_back and remove_back makes it a stack. Python’s collections.deque, which DequeQueue was built on, implements this ADT under its own method names (appendleft, append, popleft, and pop).
  • A list allows reading, inserting, and deleting at any position, identified by an integer index, not only at the ends.

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.

empty stack push(A) A push(B) A B pop() A returns B push(C) A C pop() A returns C push(D) A D

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): add value to the top of the stack
  • pop(): remove and return the value at the top of the stack
  • peek(): return (without removing) the value at the top
  • is_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.

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) == 0

Notice 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.

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 Push each opening tag. A closing tag must match the tag on top. <p><em><b>hi</b></em></p> <p> <em> <b> Function calls Each call pushes a frame holding its local variables. Each return pops it. main calls draw_house, which calls draw_roof main() draw_house() draw_roof() running Exploring a tree The stack holds the path from the root, so the search can back up and try another branch. R A B C D E F done R A D
  • 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 main calls draw_house, which calls draw_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.

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.

Scan the string from left to right, keeping the unmatched opening brackets on a stack:

  1. Push each opening bracket.
  2. For each closing bracket, pop the top of the stack and check that it is the matching opener.
  3. 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: {[()]}

ReadActionStack afterward
{push{
[push{ [
(push{ [ (
)pop (, which matches{ [
]pop [, which matches{
}pop {, which matchesempty

Every pop matched, and the stack ends empty, so the string is balanced.

Parse: ([)]

ReadActionStack 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.

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.

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): add value at a given index, shifting everything after it over by one
  • prepend(value): add value at the front
  • append(value): add value at the end
  • delete(index): remove the value at a given index
  • get(index): return the value at a given index
  • find(value): return the index of value, or indicate that it isn’t present
  • length(): 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.

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 = next
10 next prev 20 30

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:

before A X B after A B X no longer reachable

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:

SLL A X B DLL A X B

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.

list sentinel 10 20 30 next prev last node's next sentinel's prev

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:

CasePlain DLLCircular DLL with a sentinel
Insert at the frontNew node’s prev is None; update headInsert after the sentinel
Insert at the endNew node’s next is NoneInsert after sentinel.prev
Insert into an empty listNo node to insert after; set headInsert after the sentinel, which links to itself
Delete the only nodeSet head to NoneRelink 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.sentinel

The sentinel’s next is the first real node, and its prev is the last. With no real nodes, both are the sentinel itself.

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:

sentinel prepend(X) A next prev B append(Y)

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 None

Nodes 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.

list sentinel Maine Idaho Ohio Utah node

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.

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.

data 10 20 30 40 50 0 1 2 3 4 5 6 7 size = 5 capacity = 8

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.capacity

Because 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.

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:

before 10 20 30 40 50 after 10 15 20 30 40 50

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:

old 10 20 30 40 capacity 4, full new 10 20 30 40 50 copy append capacity 8

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 numberFull before appending?Capacity afterward
1no1
2yes: grow from 1 to 22
3yes: grow from 2 to 44
4no4
5yes: grow from 4 to 88
6, 7, 8no8
9yes: grow from 8 to 1616

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 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.

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.

Try these on your own before checking the solutions.

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)

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)

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)

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)

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 3+43 + 4, and 8 2 - means 8−28 - 2. 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.

  1. Evaluate 8 2 5 * - 3 +, listing the stack after each token.
  2. What answer do you get if the evaluator instead puts the value popped first on the left?
  3. What goes wrong when the evaluator reads 4 + 2? What about 4 2 5 +?

(Solution)