Binary search trees and traversals
Insert, search and delete in a sorted tree, walk it four ways, and see why balance and B-trees matter.
Create a free account Try part of it here
Why it matters
Binary search finds a name in a sorted list fast, but adding a name to that list means shifting everything after it. A binary search tree keeps the fast search and makes adding cheap too, and the same idea, grown wider, is what sits under every database index. Trees are also the favorite data structure of coding interviews: "validate a search tree", "the k-th smallest item", "print it level by level". Once you can walk a tree four ways, most of those questions become short.
What you'll be able to do
- Explain the binary search tree rule, and insert and search by walking down from the root
- Visit every node in pre-order, in-order, post-order and level order, and say what each is for
- Delete a node in each of the three cases
- Explain why a lopsided tree is slow, and what balanced trees and database B-trees do about it
What it covers
- A tree that keeps itself sorted
- Walking a tree: four orders
- How tall is a tree?
- Deleting a node: three cases
- When the tree is lopsided
- Trees you already use
- Worked example: is this really a search tree?
A tree that keeps itself sorted
A binary tree is made of nodes, and each node has at most two children, a left one and a right one. A binary search tree (BST) adds one rule: everything in a node's left subtree is smaller than the node, and everything in its right subtree is bigger. To find a value, start at the top (the root) and go left or right at each node, exactly as binary search halves a list:
class Node: def __init__(self, key): self.key = key self.left = None self.right = None def insert(node, key): """Insert key below node; return the subtree's root (a new node if the subtree was empty).""" if node is None: return Node(key) if key < node.key: node.left = insert(node.left, key) elif key > node.key: node.right = insert(node.right, key) return node # equal keys are ignored: no duplicates def contains(node, key): while node is not None: if key == node.key: return True node = node.left if key < node.key else node.right return False root = None for km in [50, 30, 70, 20, 40, 60, 80]: # trail lengths in km root = insert(root, km) print(contains(root, 60), contains(root, 65)) print(root.key, root.left.key, root.right.key, root.left.right.key)
Each step down rules out a whole subtree, so in a tree that's well spread out, finding one value among a million takes about 20 steps, the same as binary search. Adding a value is the same walk, plus hanging a new node where the walk falls off the bottom.
Watch node move down the tree: one comparison per level, never visiting the other side.
contains visit before giving up?Show the answer
50, 30, 40
45 is less than 50, so go left to 30; more than 30, so go right to 40; more than 40, so go right, and there's nothing there. Three visits, then False.
Walking a tree: four orders
To visit every node, recursion is natural: do something with this node, and walk the left and right subtrees. Where "this node" goes gives three orders, and a queue gives a fourth:
- Pre-order (node, left, right): a parent before its children. Good for copying or saving a tree, since you can rebuild it in the same order.
- In-order (left, node, right): for a BST this gives the keys sorted, for free.
- Post-order (left, right, node): children before their parent. Good when a parent depends on its children, like a folder's size, or deleting a tree from the bottom up.
- Level order: row by row from the root, using a queue, the breadth-first search from the trees and graphs lesson. Good for "what's closest to the top".
from collections import deque class Node: def __init__(self, key, left=None, right=None): self.key, self.left, self.right = key, left, right root = Node(50, Node(30, Node(20), Node(40)), Node(70, Node(60), Node(80))) def pre_order(n): return [] if n is None else [n.key] + pre_order(n.left) + pre_order(n.right) def in_order(n): return [] if n is None else in_order(n.left) + [n.key] + in_order(n.right) def post_order(n): return [] if n is None else post_order(n.left) + post_order(n.right) + [n.key] def level_order(n): out, queue = [], deque([n]) while queue: node = queue.popleft() if node: out.append(node.key) queue.extend([node.left, node.right]) return out print("pre: ", pre_order(root)) print("in: ", in_order(root)) print("post:", post_order(root)) print("level:", level_order(root))
class Node: def __init__(self, key, left=None, right=None): self.key, self.left, self.right = key, left, right def pre_order(n): return [] if n is None else [n.key] + pre_order(n.left) + pre_order(n.right) root = Node(8, Node(3, Node(1), Node(6)), Node(10, None, Node(14))) print(pre_order(root))
Show the answer
[8, 3, 1, 6, 10, 14]
Pre-order visits each node before its children: 8, then all of 8's left side (3, 1, 6), then its right side (10, 14). The sorted list would be in-order, and the last option is level order.
How tall is a tree?
Write height(node): the number of nodes on the longest path from node down to a leaf. An empty tree (None) has height 0 and a single node has height 1. It's a post-order question: you need both children's heights first.
class Node: def __init__(self, key, left=None, right=None): self.key, self.left, self.right = key, left, right def height(node): return 1 root = Node(50, Node(30, Node(20), Node(40, Node(35))), Node(70)) print(height(root))
Deleting a node: three cases
Removing a value has to keep the search-tree rule true. Find the node first, then:
- No children (a leaf): just remove it.
- One child: let that child take the node's place.
- Two children: the node's value has to be replaced by one that still sits between its left and right sides. The smallest value in its right subtree (its in-order successor) works: copy that value up, then delete it from the right subtree, where it has at most one child, so it's case 1 or 2.
class Node: def __init__(self, key, left=None, right=None): self.key, self.left, self.right = key, left, right def in_order(n): return [] if n is None else in_order(n.left) + [n.key] + in_order(n.right) def delete(node, key): if node is None: return None if key < node.key: node.left = delete(node.left, key) elif key > node.key: node.right = delete(node.right, key) else: if node.left is None: return node.right # cases 1 and 2 if node.right is None: return node.left successor = node.right # case 3: the smallest key on the right while successor.left: successor = successor.left node.key = successor.key node.right = delete(node.right, successor.key) return node root = Node(50, Node(30, Node(20), Node(40)), Node(70, Node(60), Node(80))) root = delete(root, 20) # a leaf root = delete(root, 30) # now has one child, 40 root = delete(root, 50) # two children: 60 takes its place print(root.key, in_order(root))
Show the answer
55
The in-order successor is the smallest value in the right subtree: start at 70 and keep going left, 60, then 55. Nothing is smaller than 55 on that side, so 55 replaces 50.
When the tree is lopsided
The fast search depends on the tree being bushy. Insert values that are already sorted and every new node goes to the right of the last one: the "tree" is a chain, and searching it means walking every node, like a list.
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: # a loop, so a long chain can't overflow the call stack 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): return 0 if node is None else 1 + max(height(node.left), height(node.right)) keys = list(range(400)) shuffled = random.Random(5).sample(keys, len(keys)) for name, order in [("sorted order", keys), ("random order", shuffled)]: root = None for k in order: root = insert(root, k) print(f"{name}: height {height(root)} for {len(order)} keys")
A height of 400 means 400 steps to find the last key; a random order gives around 20. Balanced trees, with names like AVL and red-black trees, fix this by rotating nodes during each insert so the height stays close to log₂ n whatever the order. You'll rarely write one, but they're behind sorted maps in many languages.
Databases go a step further. A B-tree node holds hundreds of keys instead of one, so even a table of a billion rows needs only three or four levels, and each level is one read from disk. That's what CREATE INDEX builds in the SQL lesson, and why a lookup by an indexed column stays fast as the table grows. In everyday Python, reach for a dict or set (hash tables) for lookups, and a sorted list with the bisect module when you need order.
Trees you already use
Binary search trees are one kind of a shape you meet everywhere in software, so the four walks pay off far beyond interviews:
- Folders and files are a tree. Adding up a folder's size is a post-order walk, and
os.walkhands you the folders from the top down, like pre-order. - A web page is a tree of elements, the DOM. When you scraped the practice shop with Beautiful Soup,
find_allwas walking that tree for you. - JSON from an API is a tree of dictionaries and lists, which is why nested data needs a recursive function to search it.
- Code itself becomes a tree before Python runs it, a syntax tree, and tools like linters and formatters walk it.
- Decision trees, in the machine-learning phase, are binary trees whose nodes ask questions about the data.
The habit to take away is the question you ask before writing any tree code: does each node need its children's answers first (post-order), or does it pass something down to them (pre-order, like the bounds in the next example)?
Worked example: is this really a search tree?
A favorite interview question: check whether a binary tree follows the search-tree rule. The tempting answer compares each node only with its two children, and it's wrong.
class Node: def __init__(self, key, left=None, right=None): self.key, self.left, self.right = key, left, right def looks_valid(node): """The tempting version: only checks each node against its own children.""" 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_valid(node.left) and looks_valid(node.right) def is_valid(node, low=float("-inf"), high=float("inf")): """Every key must fit between the bounds its ancestors set.""" if node is None: return True if not (low < node.key < high): return False return is_valid(node.left, low, node.key) and is_valid(node.right, node.key, high) tricky = Node(50, Node(30, None, Node(60)), Node(70)) # 60 is right of 30 but left of 50 print(looks_valid(tricky), is_valid(tricky))
60 is a fine right child for 30, so the tempting check passes. But 60 sits in 50's left subtree, where every key must be below 50, so the tree is broken: a search for 60 would go right at 50 and never find it. The fix passes the allowed range down the tree. Going left, the node's key becomes the new upper bound; going right, it becomes the new lower bound. Each node is checked once, so it's O(n). A second correct answer: do an in-order walk and check the keys come out strictly increasing.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Which traversal visits a folder only after all the files and folders inside it, as you'd need to add up its size?