PYTH-201 · Lesson 9 of 12 · 26 min

Trees and binary search trees

▶ Video coming soon A video version of this lesson is on the way.

Ridgeline's online shop organizes its products into categories: Coffee splits into Single Origin, Blends and Decaf; Single Origin splits again by region; Equipment splits into Brewers, Grinders and Mugs. Each category has one parent and any number of children. This branching shape is a tree, and trees are everywhere in computing: folders on a disk, the structure of a web page, an organization chart, the decisions in a game and the indexes inside databases.

This lesson introduces tree vocabulary and the standard ways to visit every node of a tree, then focuses on the binary search tree, a tree that keeps keys in sorted order so that searching, inserting and listing in order are all efficient. You will build one, see why its speed depends on its shape, and learn how balanced trees keep that shape under control.

Tree vocabulary

A tree is made of nodes connected by edges, with no loops. The single node at the top is the root. Every other node has exactly one parent, the node directly above it, and may have children below it. Nodes with no children are leaves. The depth of a node is the number of edges from the root to it, and the height of a tree is the greatest depth of any node. Any node, together with everything below it, forms a subtree, which is itself a tree: the recursive structure that makes recursion, from lesson 6, the natural way to process trees.

In Python, a general tree node can hold a value and a list of children. Ridgeline's category tree fits that shape directly.

The shop's category tree, printed recursively, and its height
class Category:
    def __init__(self, name, children=None):
        self.name = name
        self.children = children or []

shop = Category("Shop", [
    Category("Coffee", [
        Category("Single Origin", [Category("Africa"), Category("Americas"), Category("Asia")]),
        Category("Blends"),
        Category("Decaf"),
    ]),
    Category("Equipment", [Category("Brewers"), Category("Grinders"), Category("Mugs")]),
])

def show(node, depth=0):
    print("  " * depth + node.name)
    for child in node.children:
        show(child, depth + 1)

def height(node):
    if not node.children:
        return 0                                   # a leaf has height 0
    return 1 + max(height(c) for c in node.children)

show(shop)
print("height:", height(shop))
Output
Shop
  Coffee
    Single Origin
      Africa
      Americas
      Asia
    Blends
    Decaf
  Equipment
    Brewers
    Grinders
    Mugs
height: 3
The category tree drawn top to bottom: Shop at the root, with children Coffee and Equipment. Coffee has children Single Origin, Blends and Decaf; Single Origin has children Africa, Americas and Asia; Equipment has children Brewers, Grinders and Mugs. Labels point out the root, a parent and its children, the leaves along the bottom, a shaded subtree under Coffee, and depth levels 0 to 3 down the side.
Tree terms on Ridgeline's category tree

Traversals

Visiting every node of a tree is called a traversal, and the order matters. In a preorder traversal, you visit a node before its children, which is how the category tree was printed: each heading appears above its contents. In a postorder traversal, you visit the children first and the node afterward, which suits calculations that need the answers from below, such as a category's total product count or a folder's total size. A level-order traversal visits the tree one depth at a time, root first, using the queue from lesson 3.

Preorder, postorder and level-order traversals as generators
from collections import deque

class Node:
    def __init__(self, name, children=()):
        self.name, self.children = name, list(children)

tree = Node("Shop", [Node("Coffee", [Node("Blends"), Node("Decaf")]),
                     Node("Equipment", [Node("Mugs")])])

def preorder(n):
    yield n.name
    for c in n.children:
        yield from preorder(c)

def postorder(n):
    for c in n.children:
        yield from postorder(c)
    yield n.name

def level_order(root):
    queue = deque([root])
    while queue:
        n = queue.popleft()
        yield n.name
        queue.extend(n.children)

print("pre:  ", list(preorder(tree)))
print("post: ", list(postorder(tree)))
print("level:", list(level_order(tree)))
Output
pre:   ['Shop', 'Coffee', 'Blends', 'Decaf', 'Equipment', 'Mugs']
post:  ['Blends', 'Decaf', 'Coffee', 'Mugs', 'Equipment', 'Shop']
level: ['Shop', 'Coffee', 'Equipment', 'Blends', 'Decaf', 'Mugs']

Each traversal visits every node exactly once, so all three are O(n). The recursive versions use one stack frame per level, so their extra space is O(h) for a tree of height h; the level-order version holds at most one level's worth of nodes in its queue. The yield from statement, from PYTH-102, hands on every value produced by the recursive call, which turns these traversals into generators that can be used in loops and comprehensions.

Try it yourself

Count products in each category

Category nodes have a name, a list of children, and a whole number products (products listed directly in that category). Write total_products(node) that returns the number of products in the category and all of its subcategories, at every level.

This is a postorder calculation: add up the totals of the children, then add the node's own products.

def total_products(node):
    return node.products + len(node.children)

print(total_products(shop))

Binary search trees

A binary tree is a tree in which every node has at most two children, called left and right. A binary search tree (BST) adds one rule, the search property: for every node, every key in its left subtree is smaller than the node's key, and every key in its right subtree is larger. That rule turns the tree into a decision structure. To find a key, start at the root and compare: if the key is smaller, go left; if larger, go right; if equal, you have found it. Each step moves one level down, so a search costs O(h), the height of the tree.

Inserting follows the same path: walk down as if searching, and when you fall off the tree, attach a new node in that empty spot. Because every insertion lands where a search would look for it, the search property is kept automatically. Jordan uses a BST to index the shop's order numbers as they arrive.

A binary search tree of order numbers with insert and get
class Node:
    __slots__ = ("key", "value", "left", "right")
    def __init__(self, key, value):
        self.key, self.value, self.left, self.right = key, value, None, None

class BST:
    def __init__(self):
        self.root, self.size = None, 0

    def insert(self, key, value):
        if self.root is None:
            self.root = Node(key, value); self.size += 1; return
        node = self.root
        while True:
            if key == node.key:
                node.value = value; return          # existing key: update
            side = "left" if key < node.key else "right"
            child = getattr(node, side)
            if child is None:
                setattr(node, side, Node(key, value)); self.size += 1; return
            node = child

    def get(self, key):
        node, steps = self.root, 0
        while node is not None:
            steps += 1
            if key == node.key:
                return node.value, steps
            node = node.left if key < node.key else node.right
        return None, steps

orders = BST()
for order_id, total in [(1050, 41.5), (1030, 18.5), (1070, 64.25), (1020, 22.0),
                        (1040, 9.5), (1060, 37.0), (1080, 19.75)]:
    orders.insert(order_id, total)
print(orders.size, orders.get(1060), orders.get(1045))
Output
7 (37.0, 3) (None, 3)
A binary search tree with root 1050. Its left child is 1030, with children 1020 and 1040; its right child is 1070, with children 1060 and 1080. The search path for 1060 is highlighted: 1050, go right because 1060 is larger, then 1070, go left because 1060 is smaller, then 1060, found in 3 steps. A note says every key on the left of a node is smaller and every key on the right is larger.
Searching a BST follows one path from the root

In-order traversal: sorted for free

A third traversal is special to binary trees. An in-order traversal visits the left subtree, then the node, then the right subtree. In a binary search tree, the search property means this produces the keys in sorted order: everything smaller comes out before the node, and everything larger after it. So a BST gives you both fast lookup and a sorted listing, and it can find the minimum (keep going left), the maximum (keep going right) and every key in a range by skipping subtrees that cannot contain it.

In-order traversal lists keys in sorted order; range search skips subtrees
class Node:
    def __init__(self, key, left=None, right=None):
        self.key, self.left, self.right = key, left, right

root = Node(1050, Node(1030, Node(1020), Node(1040)), Node(1070, Node(1060), Node(1080)))

def in_order(node):
    if node:
        yield from in_order(node.left)
        yield node.key
        yield from in_order(node.right)

def keys_between(node, low, high):
    if node is None:
        return
    if low < node.key:
        yield from keys_between(node.left, low, high)    # smaller keys may be in range
    if low <= node.key <= high:
        yield node.key
    if node.key < high:
        yield from keys_between(node.right, low, high)   # larger keys may be in range

print(list(in_order(root)))
print(list(keys_between(root, 1035, 1065)))
Output
[1020, 1030, 1040, 1050, 1060, 1070, 1080]
[1040, 1050, 1060]

Try it yourself

Insert into a binary search tree

A node has key, left and right. Write a recursive function insert(node, key) that inserts key into the BST rooted at node and returns the root. If node is None, return a new Node(key). Ignore keys that are already in the tree.

Then write contains(node, key) that returns True if the key is in the tree, following only one path from the root.

def insert(node, key):
    if node is None:
        return Node(key)
    node.right = insert(node.right, key)
    return node

def contains(node, key):
    return key in _keys(node)

root = None
for k in [50, 30, 70, 20, 40]:
    root = insert(root, k)
print(contains(root, 40))

Shape matters: balanced and degenerate trees

A BST's operations cost O(h), so everything depends on the height. If the keys arrive in a random order, the tree tends to be bushy, with a height of roughly 2 × log₂ n, and operations are O(log n). But if the keys arrive already sorted, as order numbers usually do, each new key is larger than every key before it and goes to the right of the last node. The tree becomes a long chain, a degenerate tree with height n - 1, and searching it is no better than a linked list: O(n).

The same 1,000 keys make a chain or a bushy tree depending on insertion order
import random

class Node:
    def __init__(self, key):
        self.key, self.left, self.right = key, None, None

def insert(root, key):
    if root is None:
        return Node(key)
    node = root
    while True:
        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 height(node):
    h, level = -1, [node] if node else []
    while level:                              # level by level, to avoid deep recursion
        h += 1
        level = [c for n in level for c in (n.left, n.right) if c]
    return h

keys = list(range(1, 1001))
in_order_root = None
for k in keys:
    in_order_root = insert(in_order_root, k)
random.seed(201); random.shuffle(keys)
shuffled_root = None
for k in keys:
    shuffled_root = insert(shuffled_root, k)
print("1,000 keys inserted in sorted order: height", height(in_order_root))
print("1,000 keys inserted in random order: height", height(shuffled_root))
Output
1,000 keys inserted in sorted order: height 999
1,000 keys inserted in random order: height 22

Balanced search trees solve this by restructuring themselves as keys are added and removed. AVL trees and red-black trees perform small local rearrangements called rotations whenever one side becomes too much taller than the other, guaranteeing a height of O(log n) and therefore O(log n) search, insertion and deletion whatever the order of the data. B-trees, a wide, shallow relative with many keys per node, are the standard index structure inside databases and file systems.

Python's standard library does not include a balanced search tree, because a sorted list with bisect, a dict and a heap cover most needs. When you truly need a sorted collection that changes often, the widely used third-party sortedcontainers package provides SortedList and SortedDict with O(log n) operations. Writing a full balanced tree is a classic exercise in data structures courses but rarely necessary in practice.

Choosing a structure for keys that must stay sorted
OperationBalanced BSTDegenerate BSTSorted list + bisectdict
SearchO(log n)O(n)O(log n)O(1) average
InsertO(log n)O(n)O(n)O(1) average
Minimum or maximumO(log n)O(n)O(1)O(n)
All keys in orderO(n)O(n)O(n)O(n log n) to sort
Range query (k results)O(log n + k)O(n)O(log n + k)O(n)

Worked problem

A tree for the price filter

The shop's website lets customers filter about 300 products by price range, and the product list changes a few times a week. Sam suggests a balanced BST keyed by price. Is it worth it?

Show solution
  1. The required operations are range queries (many per day) and insertions or deletions (a few per week).
  2. A sorted list with bisect answers range queries in O(log n + k), the same as a balanced tree.
  3. Insertions into the sorted list cost O(n), but with 300 products and a few changes a week, that is negligible.
  4. A balanced tree would add a dependency or hundreds of lines of tricky code for no measurable gain.
  5. If the product list grew to millions of items changing every second, a balanced tree or a database index would become worthwhile.

Answer: Not yet: a sorted list with bisect gives the same query speed with far less complexity at this size.

Try it yourself

Height of a binary tree

Write a recursive function height(node) for a binary tree whose nodes have left and right. The height of an empty tree (None) is -1 and the height of a single node is 0; otherwise it is 1 plus the larger of the heights of the two subtrees.

def height(node):
    if node is None:
        return -1
    return 1 + height(node.left)

Common mistakes

  • MistakeConfusing depth (from the root down to a node) with height (the longest path down from a node).

    InsteadDepth counts edges above a node; height counts edges below it.

  • MistakeForgetting to assign the result of a recursive insert back to node.left or node.right.

    InsteadWrite node.left = insert(node.left, key).

  • MistakeAssuming a BST is always O(log n).

    InsteadOnly a balanced tree is; sorted insertions make a degenerate O(n) chain.

  • MistakeSearching both subtrees of a BST.

    InsteadFollow one path: go left for smaller keys and right for larger.

  • MistakeUsing recursion to process a very deep, degenerate tree.

    InsteadUse a loop or an explicit stack, or keep the tree balanced.

  • MistakeWriting a balanced tree from scratch for a small, rarely changing dataset.

    InsteadUse a sorted list with bisect, or sortedcontainers when you really need one.

Summary and key terms

  • A tree has a root, and every other node has one parent; nodes without children are leaves.
  • Depth counts edges from the root to a node; height is the greatest depth in the tree.
  • Preorder, postorder and level-order traversals each visit every node once, in O(n).
  • A binary search tree keeps smaller keys on the left and larger keys on the right of every node.
  • Search and insertion follow one path, costing O(h); an in-order traversal lists keys in sorted order.
  • Random insertions give height near log n, but sorted insertions make a degenerate O(n) chain.
  • Balanced trees (AVL, red-black, B-trees) guarantee O(log n); in Python, a sorted list or sortedcontainers usually suffices.
tree
A structure of nodes connected by edges, with one root and no loops.
root
The top node of a tree, the only node without a parent.
leaf
A node with no children.
subtree
A node and all of its descendants.
height
The number of edges on the longest path from a node down to a leaf.
traversal
A systematic visit of every node: preorder, in-order, postorder or level order.
binary tree
A tree in which each node has at most a left and a right child.
binary search tree
A binary tree with smaller keys to the left and larger keys to the right of every node.
degenerate tree
A tree in which each node has at most one child, behaving like a linked list.
balanced tree
A tree that keeps its height O(log n) by restructuring itself.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. In the category tree, what is the depth of Africa and the height of Coffee?

    Show answer

    Africa has depth 3; Coffee has height 2.

  2. Which traversal suits printing a table of contents, and which suits totaling sizes?

    Show answer

    Preorder for the table of contents; postorder for totals.

  3. Insert 40, 20, 60, 10, 30 into an empty BST. What are the root's children?

    Show answer

    20 on the left and 60 on the right.

  4. What does an in-order traversal of that tree produce?

    Show answer

    10, 20, 30, 40, 60.

  5. What is the height of a BST built by inserting 1, 2, 3, 4, 5 in that order?

    Show answer

    4: a chain of five nodes.

  6. Name two kinds of self-balancing search tree.

    Show answer

    AVL trees and red-black trees (B-trees are used in databases).

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz