Skip to content

Trees and depth-first search

Queues, stacks, and lists are linear data structures: each holds its items in a sequence, with a first item, a next item, and so on. A great deal of data is instead hierarchical. Folders on a computer contain files and other folders, which contain more files and folders. An HTML page contains a body, which contains lists, which contain list items. A company has a president, who has vice presidents, who have managers. The tree of life, a sports tournament, and the chapters, sections, and subsections of a book are all hierarchies too.

A tree is the data structure for hierarchical data. These notes introduce trees, how to represent them in Python, and depth-first search, the basic method for visiting every item in a tree.

A tree is built from nodes. Most of the vocabulary for trees comes from family trees:

  • The top node is the root. By convention in computer science, trees grow downward, so the root is drawn at the top.
  • Each node has zero or more children, drawn below it. An edge connects a node to each of its children.
  • Every node except the root has exactly one parent, the node it is a child of. The root has no parent.
  • Nodes with the same parent are siblings. A node’s descendants are its children, their children, and so on; its ancestors are its parent, its parent’s parent, and so on up to the root.
  • A node with no children is a leaf. A node with children is an internal node.
  • The subtree rooted at a node consists of that node and all of its descendants. A subtree is itself a tree, with that node as its root.
  • The depth of a node is the number of edges on the path from the root down to it; the root has depth 0. The height of a tree is the greatest depth of any node in it.
subtree rooted at D A B C D E F G H root leaves (no children) depth 0 depth 1 depth 2

In the tree above, A is the root. B, C, and D are A’s children, and siblings of one another. C, E, F, G, and H are leaves, and A, B, and D are internal nodes. G’s ancestors are D and A. The tree has height 2, since its deepest nodes, E, F, G, and H, are at depth 2.

Each node of a linked list has at most one next node, so a linked list is a tree in which every node has at most one child. What makes a tree more than a list is that a node can have several children. That is also what makes working with trees more interesting: from a node there is not just one way forward, but several.

A binary tree is a tree in which every node has at most two children, called its left and right child. A single-elimination tournament is a binary tree. The final is at the root; its two children are the semifinals, whose winners played in the final; their children are the quarterfinals, and so on. The leaves are the players in the first round. An ancestry chart is a binary tree too, since each person has two parents (who are, confusingly, the person’s children in the tree).

Like a linked list, a tree is made of node objects that refer to one another. A linked list node refers to its one next node; a tree node keeps a list of its children:

class Node:
def __init__(self, data):
self.data = data
self.children = [] # the child Nodes, in order; empty for a leaf

The tree itself is just a reference to its root node, in the same way that a linked list is a reference to its first node. Here is the tree from the figure above:

a, b, c, d, e, f, g, h = (Node(label) for label in "ABCDEFGH")
a.children = [b, c, d]
b.children = [e, f]
d.children = [g, h]
root = a

A binary tree node usually has two fields, left and right, instead of a list of children, with None for a missing child:

class BinaryNode:
def __init__(self, data, left=None, right=None):
self.data = data
self.left = left
self.right = right

An HTML page can be represented by a tree of the first kind, with one node per element of the page: the html element at the root, with children head and body, and so on down to the text.

To do almost anything with a tree, such as print it, count its nodes, or find a node holding a particular value, a program has to visit every node, one at a time. For a linked list, that’s a single loop that follows next until it runs out. A tree has no single “next,” though. After visiting B in the example tree, the program could go on to B’s children, E and F, or to B’s siblings, C and D. Whichever it chooses, it has to remember the others, so that it can come back to them.

Depth-first search (DFS) explores a tree the way you might explore a maze: keep going deeper, taking one branch at each fork, until you reach a dead end, then back up to the most recent fork with a branch you haven’t tried, and take that one. The nodes still waiting to be explored go on a stack. Since a stack is last-in, first-out, the node taken next is always the most recently discovered one, which is what sends the search deeper before it goes wider.

def dfs_print(root):
stack = [root]
while stack:
node = stack.pop()
print(node.data)
for child in reversed(node.children):
stack.append(child)

Each time through the loop, DFS pops one node, visits it (here, by printing its data), and pushes its children, so that they will be visited later. The loop ends when the stack is empty, meaning every node has been visited. For the example tree, it prints the nodes in the order A, B, E, F, C, D, G, H. Notice that B’s whole subtree is finished before C is visited: DFS goes deep first.

A 1 B 2 C 5 D 6 E 3 F 4 G 7 H 8 visit order: A, B, E, F, C, D, G, H

The children are pushed in reverse order so that the first child ends up on top of the stack, and is popped first. If they were pushed in their usual order, the search would still visit every node, but would take each node’s children last to first: A, D, H, G, C, B, F, E.

Here is the search on the example tree, one loop iteration per row. The top of the stack is on the right.

Stack before poppingVisitPush (reversed)Stack after
AAD, C, BD C B
D C BBF, ED C F E
D C F EE(leaf)D C F
D C FF(leaf)D C
D CC(leaf)D
DDH, GH G
H GG(leaf)H
HH(leaf)(empty)

The stack always holds the nodes that have been discovered but not yet visited. After B is visited, for example, the stack holds F and E, B’s children, above C and D, the siblings of B that still have to be explored once B’s subtree is finished.

Each node is pushed onto the stack exactly once, by its parent (or, for the root, at the start), and popped exactly once. Pushing and popping at the end of a Python list take constant time. So if the tree has nn nodes, DFS takes Θ(n)\Theta(n) time, plus whatever the visit itself costs at each node.

Memory is another question. The stack holds the nodes that have been discovered but not yet visited, and how many of those there are at once depends on the shape of the tree. How large can the stack get? To start, try a tree whose root has 100 children.

Changing what happens at each visit changes what DFS computes. To count the nodes in a tree, add one at each visit:

def size(root):
count = 0
stack = [root]
while stack:
node = stack.pop()
count += 1
for child in node.children:
stack.append(child)
return count

Since the order of the visits doesn’t affect the count, there is no need to reverse the children here.

To find a node holding a particular value, stop as soon as it’s found:

def find(root, target):
stack = [root]
while stack:
node = stack.pop()
if node.data == target:
return node
for child in node.children:
stack.append(child)
return None

If the target isn’t in the tree, the search visits every node before it gives up and returns None.

Some questions need more than the node itself at each visit. To find the height of a tree, the search needs the depth of each node it visits. A node’s depth is one more than its parent’s, so push each child together with its depth, as a pair:

def height(root):
tallest = 0
stack = [(root, 0)]
while stack:
node, depth = stack.pop()
tallest = max(tallest, depth)
for child in node.children:
stack.append((child, depth + 1))
return tallest

The same idea, with the depth used to indent each line, prints a tree as an indented outline.

The search needs a stack because a tree branches: each node visited may add several new nodes to explore, and the search has to remember all of them while it follows one. The stack holds exactly the forks in the maze that haven’t been fully explored.

Swapping the stack for a queue gives a different search, breadth-first search, which visits the nodes in order of their depth: first the root, then all the nodes at depth 1, then all those at depth 2. It spreads out from the root one layer at a time, like the wave in wavefront planning, which also keeps its squares in a queue. There is also another way to get the stack: write the search as a recursive function, and let Python’s own call stack keep track of the nodes still to be explored.

The introduction, the vocabulary of trees, and the tournament example are adapted from the CS 10 lecture notes developed by Drysdale, Bailey-Kellogg, and Pierson, “Hierarchies,” and the maze analogy for depth-first search from “Graph traversal.”

The rest of these notes were drafted by Claude (Anthropic) at Balkcom’s direction and then edited by Balkcom. This includes the representation of a tree in Python, depth-first search with an explicit stack, its running time, the examples of computing with depth-first search, 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, chapter 8.

  • os.walk: which searches the tree of folders and files on a computer
  • ast: Python’s own representation of a program as a tree

Try these on your own before checking the solutions.

In the example tree at the top of these notes, list: the ancestors of F; the descendants of D; the siblings of C; the nodes of the subtree rooted at B. What is the depth of E?

(Solution)

Draw the tree built by this code, then write out the order in which dfs_print prints its nodes, and the contents of the stack after each node is visited.

p, q, r, s, t, u = (Node(label) for label in "PQRSTU")
p.children = [q, r]
q.children = [s]
r.children = [t, u]

(Solution)

Write a function count_leaves(root) that returns the number of leaves in a tree of Nodes, using depth-first search with a stack.

(Solution)

size and find push each node’s children in their usual order, not reversed. Does find still find the target, if it’s in the tree? Does it return the same node as it would with reversed pushes, if more than one node holds the target? Explain.

(Solution)

Write a function depth_of(root, target) that uses depth-first search with a stack to find a node whose data equals target, and returns that node’s depth, or None if no node holds target. In the example tree at the top of these notes, depth_of(root, "G") returns 2.

(Solution)