Pebellslearn by building
0%
RoadmapMatrixCatalogSandboxSign in
Trees, Heaps & Tries  ›  Lesson

Binary Search Trees & the Release Catalog

Binary Search Trees 25 minA tree that keeps smaller keys left and bigger keys right
You're building a piece ofTunebox — discovery
This piece — releases_between(): Keeps tracks ordered by release year so range queries stay fast.
Scenario Tunebox's catalog filter lets listeners pick a span of years. New releases are added every day, so the catalog lives in a binary search tree keyed by release year.
Your task
Build releases_between(releases, start, end). Insert each [year, title] pair into a binary search tree keyed by year, in the order given, with ties going right. Then walk it in-order — skipping subtrees that can't hold a match — and return the titles released from start to end inclusive, in year order. Example: releases_between([[1999, "Midnight"], [1987, "Neon"], [2012, "Tides"], [1994, "Glass"], [2005, "Static"]], 1990, 2005) → ["Glass", "Midnight", "Static"].

Binary Search Trees & the Release Catalog

Tunebox's catalog filter answers "everything released from 1990 to 2005". A sorted list answers that fast — but new releases land every day, and each one slotted into the middle of a list shifts everything after it. A binary search tree keeps the catalog in order and takes a new release by walking a single path.

The BST property

A binary search tree is a binary tree with one rule, holding at every node: every key in the left subtree is smaller, and every key in the right subtree is bigger. Plenty of Tunebox releases share a year, so we settle ties by sending equal keys right.

            1999
           /    \
        1987    2012
           \    /
          1994 2005

The rule covers whole subtrees, not just a node's two children — and that's exactly where a quick check goes wrong:

What does this print?
class Node:
    def __init__(self, key, left=None, right=None):
        self.key, self.left, self.right = key, left, right

def looks_ok(node):
    if node is None:
        return True
    if node.left and node.left.key >= node.key:
        return False
    if node.right and node.right.key < node.key:
        return False
    return looks_ok(node.left) and looks_ok(node.right)

root = Node(1999, Node(1987, None, Node(2003)), Node(2012))
print(looks_ok(root))

Search in O(h)

The rule turns search into one path. Compare with the node, then discard a whole subtree:

python

Every step drops one level, so search costs O(h), where h is the tree's height.

Insert: search until you fall off, then attach

Insertion follows the path a search would. When it steps onto None, that empty slot is where the new key belongs. Step through four inserts:

step through it
1class Node:
2 def __init__(self, key):
3 self.key, self.left, self.right = key, None, None
4 
5def insert(root, key):
6 if root is None:
7 return Node(key)
8 if key < root.key:
9 root.left = insert(root.left, key)
10 else:
11 root.right = insert(root.right, key) # ties go right
12 return root
13 
14root = None
15for year in [1999, 1987, 2012, 1994]:
16 root = insert(root, year)
17print(root.left.right.key)

Returning root from every call is what lets one line — root.left = insert(...) — attach the new node, and it handles an empty tree for free.

In-order traversal gives sorted order

An in-order walk visits the left subtree (all smaller), then the node, then the right subtree (all bigger). On a BST, that is sorted order. Equal keys come out in the order they were added, because each later one went right.

What does this print?
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)
    if key < root.key:
        root.left = insert(root.left, key)
    else:
        root.right = insert(root.right, key)
    return root

def inorder(node, out):
    if node:
        inorder(node.left, out)
        out.append(node.key)
        inorder(node.right, out)
    return out

root = None
for year in [2001, 1995, 2010, 1990, 1998]:
    root = insert(root, year)
print(inorder(root, []))

❓ Cross-question — "So inserting everything and walking in-order is a sort?" Yes — it's called tree sort. It's O(n log n) while the tree stays bushy and O(n²) when it doesn't, which is the next part.

Balanced vs degenerate: insertion order matters

Every operation so far is O(h). The same seven years can build a tree of height 3 or height 7, and only the order they arrive in decides which:

python

A catalog imported oldest-first is already sorted, so a plain BST becomes a chain and every "fast" O(h) operation is really O(n). Self-balancing trees prevent that by reshaping themselves with small rotations after each insert or delete. AVL trees keep the heights of every node's two subtrees within 1 of each other; red-black trees colour their nodes so that no root-to-leaf path is more than twice as long as any other. Both guarantee h = O(log n). You won't implement them here — knowing they exist, and why, is the point.

Coming from Java/JS: Java's TreeMap and TreeSet are red-black trees, and so, in practice, is C++'s std::map. Python's standard library has no balanced tree; its usual answer is a sorted list with bisect (below). JavaScript has neither built in.

Range queries: prune what can't match

For "released from start to end", walk in-order but skip any subtree that can't hold an answer. This version prunes in the wrong directions:

broken — fix it

This should print the three years from 1990 to 2005, in order. It prints only [1999], because it skips subtrees that hold answers.

Fixed, the walk follows one path down each edge of the range and then touches only the m keys it returns: O(h + m).

Operation BST, balanced BST, degenerate Sorted list + bisect
Search O(log n) O(n) O(log n)
Insert O(log n) O(n) O(n) — items shift
Every key in order O(n) O(n) O(n)
Range with m results O(log n + m) O(n) O(log n + m)

Idioms & real-world patterns

A sorted list and bisect: Python's everyday answer

For a catalog that's read far more often than it changes, a sorted list is simpler and, in practice, fast. bisect_left and bisect_right find the edges of a range in O(log n):

python

Databases take the tree idea further: their indexes are B-trees, whose nodes each hold hundreds of keys, so even a billion-row index is only a few levels deep.

⚡ Advanced — deleting a key

Deletion has three cases. A leaf simply goes. A node with one child is replaced by that child. A node with two children takes the key of its in-order successor — the smallest key in its right subtree — and that successor, which has no left child, is then deleted from the right subtree. Each case keeps the BST property, and each costs O(h).

⚡ Advanced — floor and ceiling

"The latest release on or before 2000" is the floor of 2000. Walk down: when a node's key is ≤ the target it's a candidate, so remember it and look right for a closer one; otherwise go left. One path, O(h) — the same pruning as a range query.


🎯 Your turn

Write releases_between(releases, start, end). releases is a list of [year, title] pairs. Insert them into a BST keyed by year, in the order given, with ties going right. Return the titles released from start to end inclusive, in year order; same-year titles keep the order they were added:

  • releases_between([[1999, "Midnight"], [1987, "Neon"], [2012, "Tides"], [1994, "Glass"], [2005, "Static"]], 1990, 2005)["Glass", "Midnight", "Static"]
  • releases_between([], 1900, 2100)[]

Hint — insert every pair first, then run the pruned in-order walk — once you've fixed it.

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green = this piece of Tunebox is built. ✅

Practice — level up

0/5 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upIs that year in the catalog?

Write bst_contains(years, target). Insert years into a binary search tree in the order given (equal keys go right), then search it for target by walking down from the root — never by scanning the list. Return True or False.

bst-searchbst-insert
DrillHow tall does the catalog grow?

Write catalog_height(years) returning the height of the binary search tree you get by inserting years in the order given (equal keys go right). Height counts nodes on the longest root-to-leaf path; an empty tree is 0.

bst-inserttree-height
BuildThe latest release on or before a year

Write latest_by(years, target) returning the largest year that is less than or equal to target, or None if every year is later. Insert years into a BST in the order given, then find the answer with one walk down from the root.

bst-searchbst
BossAudit the catalog tree

Write is_valid_bst(levels). The tree is a level-order list with None for a missing child. Return True if, at every node, every key in its left subtree is smaller than it and every key in its right subtree is greater than or equal to it — Tunebox sends ties right. An empty tree is valid. build(levels) is provided.

bstrecursion
CapstonePull a release from the catalog

Write delete_release(years, year). Insert the distinct years into a BST in the order given, delete year, and return the tree's keys in pre-order. Delete like this: a leaf is removed; a node with one child is replaced by that child; a node with two children takes the key of its in-order successor (the smallest key in its right subtree), and that successor is then deleted from the right subtree. If year isn't there, nothing changes.

bstbst-insertrecursion
releases_between.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
releases_between(releases, start, end) → listTitles released from start to end inclusive, in year order.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.