Computing and the Command Line

Trees and Graphs

Structures for things connected to other things. Trees, with a root, parents, children, and leaves, from the filesystem to git; binary search trees, which find a key by going left or right at each node, built in Python and measured: 12.3 visits on average among 1,000 keys inserted at random, 500.5 when they arrive sorted and the tree degenerates into a chain; balanced trees and the B-trees inside filesystems and databases. Graphs, their uses, and two ways to store them; breadth-first search, which finds the fewest hops across a small network using a queue; and depth-first search, which uses a stack.

  • 12 min
  • 8 steps
  • 3 questions
  • Lesson 77 of 80

In this lesson

  1. Trees
  2. Binary search trees
  3. Graphs
  4. Storing a graph
  5. Breadth-first search
  6. Depth-first search
  7. Your turn
  8. So

Trees

A tree connects items, called nodes, without loops. A rooted tree has one root at the top; every other node has exactly one parent, may have children, and a node with no children is a leaf. A node’s depth is the length of its path up to the root, and the tree’s height is the depth of its deepest node 1.

You’ve met several:

  • the filesystem: / is the root, directories are nodes with children, and files are leaves (Shell course);
  • the process tree: systemd at the root, every process a child of the one that forked it (pstree in the Shell course, fork last module);
  • git: each commit points to a tree object for the project’s top directory, whose entries point to blobs and smaller trees (Git course) 2.

There’s exactly one path from the root to any node, which is what makes a path like /home/me/notes.txt unambiguous.

Left, a binary search tree, smaller keys left and larger right: 50 at the root, 30 and 70 below it, then 20 and 40 under 30 and 60 and 80 under 70. The search for 60 is highlighted, 50 then 70 then 60: 3 visits. Below: with 1,000 keys, random order, 12.3 visits on average, worst 20; sorted order, 500.5 on average, worst 1,000, drawn as a chain of nodes 1, 2, 3, 4, 5: sorted input makes a chain, no better than a list. Right, a graph searched breadth-first: everything 1 hop away, then 2, then 3, so the first time it reaches a device is by the fewest hops. A made-up network: laptop, 0 hops; router, 1; phone, NAS, and modem, 2; ISP, 3; exchange and backbone, 4; CDN and server, 5. The path laptop, router, modem, ISP, backbone, server is highlighted.
A tree has one path to each node; a graph can have many, and breadth-first search finds the shortest. Credit: StudyCorner diagram · CC BY 4.0 · Source

Binary search trees

In a binary tree each node has at most two children, a left and a right 1. A binary search tree adds a rule: for every node, all the keys in its left subtree are smaller than its own, and all the keys in its right subtree are larger 1. To find a key, start at the root and compare: smaller, go left; larger, go right; equal, found. A search follows a single path down, never the whole tree 1.

This program builds one, then measures how many nodes searches visit when 1,000 keys go in shuffled and when they go in already sorted. Save it as bst.py:

# bst.py: a binary search tree, and what insertion order does to its shape.
import random

class Node:
    def __init__(self, key):
        self.key = key
        self.left = None                # smaller keys go down here
        self.right = None               # larger keys go down here

def insert(root, key):
    """Add key to the tree and return its root."""
    if root is None:
        return Node(key)
    node = root
    while True:                         # walk down until there's an empty spot
        side = "left" if key < node.key else "right"
        child = getattr(node, side)
        if child is None:
            setattr(node, side, Node(key))
            return root
        node = child

def visits_to_find(root, key):
    """Count the nodes a search looks at before it reaches key."""
    node, visits = root, 1
    while node.key != key:
        node = node.left if key < node.key else node.right
        visits += 1
    return visits

def in_order(node):
    """Left subtree, this node, right subtree: the keys come out sorted."""
    if node is None:
        return []
    return in_order(node.left) + [node.key] + in_order(node.right)

small = None
for k in [50, 30, 70, 20, 40, 60, 80]:
    small = insert(small, k)
print("in order:", in_order(small))
print("visits to find 60:", visits_to_find(small, 60))

n = 1000
keys = list(range(n))
shuffled = keys[:]
random.seed(1)
random.shuffle(shuffled)
for name, order in [("random order", shuffled), ("sorted order", keys)]:
    root = None
    for k in order:
        root = insert(root, k)
    visits = [visits_to_find(root, k) for k in keys]
    print(f"{n} keys, {name:<13} average {sum(visits) / n:6.1f} visits, worst {max(visits)}")

getattr(node, side) reads the attribute named by the string side, and setattr sets it, so one line handles both directions. random.seed(1) makes the shuffle the same every run.

me@linuxbox:~$ python3 bst.py
in order: [20, 30, 40, 50, 60, 70, 80]
visits to find 60: 3
1000 keys, random order  average   12.3 visits, worst 20
1000 keys, sorted order  average  500.5 visits, worst 1000

Three things to see:

  • An in-order traversal, visiting each node after its left subtree and before its right 1, lists the keys sorted, for free.
  • With keys in random order, a search among 1,000 keys visited about 12 nodes. For a randomly built tree, search paths are at most about 2 ln n long 1; 2 ln 1000 is 13.8.
  • With keys in sorted order, every new key was larger than everything before it, so each went right, and the “tree” became a chain 1,000 nodes deep: no better than a linked list, O(n) per search 1.

Real data often arrives sorted, so practical search trees balance themselves, rearranging nodes as keys are added so the height stays proportional to log n; red-black trees are one widely used kind 1. A related design, the B-tree, gives each node many children, sized so that one node fits in one disk block, and is the main data structure in filesystems such as Linux’s ext4, in every major database, and in key-value stores 1.

Quick check

In a binary search tree, where is a key smaller than the root?

Quick check

Why did inserting 1,000 keys in sorted order make searches so slow?

Graphs

A graph is the general form: a set of vertices and a set of edges connecting pairs of them. Edges can be directed, one way only, or undirected. A path follows edges from vertex to vertex, and a path that returns to where it started is a cycle 1. A tree is just a graph with no cycles 1.

Graphs model anything defined by pairwise connections 1: computers and network links, intersections and streets, or courses that share a student and so can’t have exams at the same time. Two more you’ve seen: git’s history, where a merge commit has two parents, so the history is a graph rather than a list; and package dependencies, which apt follows to install everything a program needs (Linux course).

Storing a graph

There are two standard ways 1:

  • An adjacency matrix: an n × n table with a 1 where there’s an edge. Checking whether two vertices are connected is O(1), but the table takes n² space even when most entries are 0.
  • Adjacency lists: for each vertex, a list of its neighbors. Space is proportional to the number of vertices plus edges, and listing a vertex’s neighbors is direct.

Most real graphs are sparse, each vertex linked to only a few others, so adjacency lists are the usual choice. In Python, a dictionary of lists does it.

Breadth-first search (BFS) explores outward from a starting vertex: first its neighbors, then their neighbors, and so on. It keeps a queue of vertices to visit and a record of which ones it has already seen, so nothing is visited twice 1. Because it finishes everything one hop away before anything two hops away, the first time it reaches a vertex is by a shortest path, counted in edges; remembering which vertex led to each one lets you read that path back 1. It takes time proportional to the vertices plus the edges 1.

This program runs BFS over a small made-up network, stored as adjacency lists. Save it as bfs.py:

# bfs.py: breadth-first search on a small made-up network, for fewest hops.
from collections import deque

links = {                               # adjacency lists: each device's neighbors
    "laptop":   ["router"],
    "phone":    ["router"],
    "router":   ["laptop", "phone", "nas", "modem"],
    "nas":      ["router"],
    "modem":    ["router", "isp"],
    "isp":      ["modem", "exchange", "backbone"],
    "exchange": ["isp", "backbone", "cdn"],
    "backbone": ["isp", "exchange", "server"],
    "cdn":      ["exchange"],
    "server":   ["backbone"],
}

def bfs(graph, start):
    """Visit nodes nearest-first; return each one's hop count and the node before it."""
    hops = {start: 0}
    came_from = {start: None}
    queue = deque([start])
    while queue:
        node = queue.popleft()          # first in, first out
        for neighbor in graph[node]:
            if neighbor not in hops:    # not seen yet
                hops[neighbor] = hops[node] + 1
                came_from[neighbor] = node
                queue.append(neighbor)
    return hops, came_from

def path(came_from, end):
    steps = []
    while end is not None:
        steps.append(end)
        end = came_from[end]
    return " -> ".join(reversed(steps))

hops, came_from = bfs(links, "laptop")
print("hops from laptop, in the order found:")
for node, h in hops.items():
    print(f"  {node:<9} {h}")
print(path(came_from, "server"))
print(path(came_from, "cdn"))
me@linuxbox:~$ python3 bfs.py
hops from laptop, in the order found:
  laptop    0
  router    1
  phone     2
  nas       2
  modem     2
  isp       3
  exchange  4
  backbone  4
  cdn       5
  server    5
laptop -> router -> modem -> isp -> backbone -> server
laptop -> router -> modem -> isp -> exchange -> cdn

All three structures of this module are working together: a deque as the queue, since it removes from the front in O(1) 3; a dict as the record of what’s been seen, with O(1) lookups; and lists of neighbors. Since a dict keeps the order its keys were added 4, hops lists the devices in the order BFS found them, nearest first.

Quick check

Why does breadth-first search find the route with the fewest hops?

Depth-first search (DFS) does the opposite: it follows one path as far as it can, then backs up to the last branch it hasn’t explored. It’s breadth-first search with a stack instead of a queue, and is most naturally written as a recursive function 1. DFS doesn’t find shortest paths, but it visits everything reachable just as well, and the order it works in makes it the tool for detecting cycles 1.

Your turn

Exercises

  1. Draw the binary search tree you get by inserting 40, 20, 60, 10, 30, in that order. Which nodes does a search for 30 visit?
  2. In bst.py, insert the keys in reverse sorted order, range(999, -1, -1). What happens, and why?
  3. Run bfs starting from "cdn", and print the path to "laptop".
  4. Turn bfs into a depth-first search by changing queue.popleft() to queue.pop(), making the deque a stack. In what order are the devices found now?
  5. How many entries would an adjacency matrix for the network in bfs.py have, and how many of them would be 1?
Answers
  1. 40 at the root, 20 and 60 as its children, 10 and 30 under 20. A search for 30 visits 40, 20, 30: 3 visits.
  2. Average 500.5 visits, worst 1,000 again: now every key is smaller than all before it, so each goes left, and the tree is a chain the other way.
  3. cdn -> exchange -> isp -> modem -> router -> laptop, 5 hops.
  4. The same, except server is found before cdn: from isp the search goes deep through backbone to server before coming back for exchange’s cdn. On this network the hop counts still come out right, because most devices have only one route; on a network with several routes, a depth-first search can reach a device by a longer route first, so use breadth-first search for fewest hops.
  5. 10 × 10 = 100 entries; each of the 10 links appears twice, once in each direction, so 20 are 1 and 80 are 0.

So

Trees connect nodes without loops, with one path from the root to each; the filesystem, the process tree, and git’s snapshots are trees. Binary search trees keep smaller keys left and larger right, so a search follows one path: about 12 visits among 1,000 random keys, but 500 on average when sorted input turns the tree into a chain, which is why practical trees balance themselves, and filesystems and databases use B-trees. Graphs are vertices and edges in general, usually stored as adjacency lists. Breadth-first search uses a queue to explore nearest-first and finds the fewest hops; depth-first search uses a stack to go deep first.

Lesson complete

Nice work.

1day streak
0/1today's goal
–correct

Up next · 10 min

Searching and Big-O

Next lesson
Sources for this lesson
  1. 1
    Pat Morin. Open Data Structures (in pseudocode), Edition 0.1G beta. opendatastructures.org (paperback by AU Press). verifiedFree textbook under a Creative Commons Attribution license. Used: ch. 1, the need for efficiency (a million searches of a million items is 10^12 inspections, over 16 minutes at a billion operations a second), interfaces versus implementations, the Queue (FIFO), Stack (LIFO), and Deque; ch. 2, array-based lists (constant-time get and set, add and remove at i costing O(n - i) for the shifting, growing by allocating an array of twice the size and copying, amortized O(1) growth); ch. 3, singly and doubly linked lists (nodes with references; get(i) walks from the head; insert or delete next to a known node in constant time); ch. 5, hash tables with chaining (an array of lists, hash(x) choosing the list, n kept at most the table length so lists average one item, doubling and reinserting when full) and hash codes (equal objects must have equal hash codes, unequal ones should rarely collide); ch. 6, binary trees (root, parent, child, leaf, depth, height) and the binary search tree property, O(n) worst case when unbalanced; ch. 7, random binary search trees, with search paths of length at most about 2 ln n; ch. 12, graphs (vertices, directed edges, paths, cycles; adjacency matrix with O(n^2) space versus adjacency lists with O(n + m); breadth-first search with a queue and a seen array, O(n + m), visiting vertices in order of distance and giving shortest paths; depth-first search, breadth-first search with a stack instead of a queue, used to detect cycles); ch. 14, B-trees, whose nodes have between B and 2B children and fit in one external-memory block, the primary data structure in file systems including ext4, NTFS, and HFS+, every major database, and cloud key-value stores.
  2. 2
    Scott Chacon, Ben Straub. Pro Git, 2nd edition. Apress; free online at git-scm.com. 2014. verifiedFree CC BY-NC-SA 3.0 book, maintained online. Ch. 1: version control; Git's 2005 origin when the Linux kernel lost free use of BitKeeper; snapshots, not differences (unchanged files stored once); nearly every operation local; integrity through 40-character SHA-1 checksums; the three states (modified, staged, committed) and three areas (working tree, staging area or index, .git directory); first-time setup with system/global/local config levels, user.name and user.email baked into commits, core.editor, git config --list --show-origin. Ch. 2: git init, status (and -s), add, diff and diff --staged, commit (-m, -a), .gitignore, log options, amending, undoing, remotes, tags, aliases. Ch. 3: branches as movable pointers, HEAD, merging and conflicts, remote branches, rebasing and its rule. Ch. 7: reset demystified, stashing, revision selection. Ch. 8: core.autocrlf true on Windows, input on Linux and macOS. Ch. 10: objects (blob, tree, commit) and references.
  3. 3
    collections: Container datatypes (Python documentation). Python Software Foundation. verifieddeque, a double-ended queue, generalizes stacks and queues, with appends and pops from either side in approximately O(1) time; lists are optimized for fixed-length operations and incur O(n) memory movement for pop(0) and insert(0, v).
  4. 4
    Built-in Types (Python documentation). Python Software Foundation. verifiedMapping types, dict: dictionary order is guaranteed to be insertion order (a language guarantee since 3.7, a CPython implementation detail from 3.6).