python/volume-4/chapter-56ch 56 / 64 · 9 min

56The binary tree — hierarchy, two children at a time

In Chapter 55 we flattened a grid into one straight line of memory, and that was the last easy one. Every structure so far has lived in a row: array, list, stack, queue, even the matrix, all of it laid end to end. A tree breaks the line. It's the shape of anything that contains things that contain things — a folder of folders, a web page of elements, a company of departments. Here we build the plainest one from its scattered bytes upward: the binary tree, one root, up to two children per node, no cycles. All the way through, one question decides everything else: if nowhere in RAM is there a thing shaped like a tree, what is actually holding the hierarchy together? By the end you'll see any hierarchy as nodes joined by two pointers. You'll know why a single number — its height — is the difference between a fast tree and one that crawls or crashes. And you'll be able to walk one four different ways, each for a different job.

A tree is scattered boxes joined by pointers Height is the whole game The pointer tax, paid again at runtime Recursion is a tree's native tongue
scroll to begin

01A tree is scattered boxes joined by pointers

Let's start with the vocabulary, and pin each word down the moment it lands. A node is one box holding a value. The single node at the top is the root. A node's children are the nodes hanging directly below it. That node is their parent, and the link between them is an edge. A node with no children is a leaf. Binary means each node has at most two children, and they are named: a left child and a right child. The position carries meaning, so left and right are not interchangeable. One rule makes it a tree and not a tangle: no cycles. There is exactly one path from the root down to any node.

The cleanest way to say all of that is to say it recursively: a binary tree is either empty, or it's a node holding a value plus two smaller binary trees (its left and right subtrees). The definition contains itself. Hold that thought. It's the whole reason trees are easy to walk.

Now the memory picture, which is this volume's obsession. Nowhere in RAM is there a thing shaped like a tree. On the heap a tree is a scatter of node objects. Each one is a tiny record with three fields: a reference to its value, a reference to its left child, and a reference to its right child (or None where a child is missing). The root is nothing but a name holding one 8-byte reference (Volume 1). Follow the left and right references from box to box and you are the tree. The shape exists only as pointers.

each node = a heap object: [ value · left-ptr · right-ptr ] — addresses are scattered, the shape is only pointers root a name → ref 0x7fa0 node A A L● R● 0x7f3c node B B L● R● 0x7fc8 node C C R● 0x7f14 node D D 0x7f9e node E E 0x7f57 node F F
Fig — the same tree as it truly sits in memory: six node objects at scattered heap addresses. Blue = the value reference; amber = a child pointer; = a null (None) child. The hierarchy is nothing but arrows.
The recursive definition is the whole trick
“A node plus two smaller trees” means a tree is defined in terms of itself. That self-similarity is the human insight this structure rests on — the moment someone realized a box could point at more boxes of its own kind, hierarchy became a data type. Everything hard about trees (walking, measuring, balancing) turns out to be three lines of code, because the data is recursive so the code can be too.

How heavy is one node? Let's measure, not guess. Give the class __slots__ so its three fields pack straight into the object, and each node becomes a lean record.

memory.pypython
import sys

class Node:
    __slots__ = ('value', 'left', 'right')   # pack the 3 refs INTO the object — no per-instance dict
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left  = left
        self.right = right

root = Node('A', Node('B'), Node('C'))       # a 3-node tree, all on the heap
print(sys.getsizeof(root))                    # 56  → 16-byte header + three 8-byte references

Line 4 is the money line. __slots__ tells CPython to store value, left, and right inside the node itself, instead of in a separate instance dictionary. Ran on CPython 3.12.7, sys.getsizeof(root) returns 56 bytes: a 16-byte object header plus the three 8-byte references. Drop __slots__ and each node also drags a 296-byte instance __dict__ behind it, for 344 bytes per node, measured. Either way, that is the pointer tax. You pay two extra references per element (16 bytes) purely to encode the shape. Compare a Python list, a contiguous array of references (Volume 1): about 8 bytes per element plus one header. I measured 100,000 slotted nodes at 5,600,000 bytes against the same count in a list at 800,056 bytes, so the tree is roughly 7× heavier. A tree is not a light structure. You buy shape with bytes.

Put __slots__ on node classes by reflex
A tree can have millions of nodes, and the 288-byte-per-node difference between a slotted node (56 B) and a dict-backed one (344 B) is the difference between 56 MB and 344 MB for a million-node tree. When a class exists to be created by the million, __slots__ is not a micro-optimization — it's the design.
Wait —
if six nodes weigh only ~340 bytes, the bytes aren't the interesting cost. So why can two trees with the identical number of nodes differ by a factor of a hundred in how fast you find something inside them?

The answer isn't in the bytes. It's in one number that measures how tall the scatter of boxes stacks up — the tree's height. →

02Height is the whole game

Two more words, precisely. The depth of a node is how many edges you cross to reach it from the root (the root has depth 0). The height of a tree is the depth of its deepest node — the length of the longest root-to-leaf path. Height is the number that governs cost. Here's why: to reach any node, you start at the root and follow one child pointer per level down. So finding, inserting, or deleting a node costs O(height) pointer hops. It's never more than the height, because that's the longest any path can be. Derive the cost from the layout and it's forced: the work is the walk, and the walk is bounded by the height.

That turns the whole question into one: how tall does n nodes have to stack? There are two answers, and they could not be further apart.

Balanced. Fill the tree level by level and each level holds twice the one above: 1 root, then 2, then 4, then 8, …, 2k at level k. So k levels hold up to 2k+1−1 nodes. Invert that, and n nodes need only about log₂n levels. This is the same doubling that makes binary search O(log n) in Volume 3, now standing up in three dimensions. Measured: a balanced tree of 1,000 nodes has height 9, and of 1,000,000 nodes, height 19. Nineteen hops to reach any node among a million.

Degenerate. Now insert values already in sorted order into a tree that always sends the larger value right. Every node gets only a right child, and the tree collapses into a single diagonal spine. Its height is n−1: measured, 1,000 nodes give height 999. That is not a tree in any useful sense. It's the same linked list we built a few chapters back, wearing a tree costume, and every search is O(n).

BALANCED — 7 nodes, height 2 DEGENERATE — 7 nodes, height 6 4 2 6 1 3 5 7 reach any node in ≤ 2 hops · O(log n) 1 2 3 4 5 6 reach the last node = 6 hops · a linked list in disguise · O(n)
Fig — identical node count, opposite fates. Balance packs n nodes into ~log₂n levels; sorted insertion unspools them into a spine of height n−1. Height, not node count, is what your search pays.
InteractiveGrow the tree — watch balanced crawl while degenerate races
height = pointer hops to reach the deepest node (same n nodes in both) balanced 4 hops degenerate 15 hops
16
Balanced height is ⌊log₂ n⌋; degenerate height is n−1. The gap between the bars is the gap between O(log n) and O(n).
↺ The thing people get backwards
People hear “tree” and think “fast — O(log n), like binary search.” Backwards. O(log n) is a property of the tree's shape, not of its being a tree. A binary tree gives you the possibility of logarithmic height; it does not guarantee it. Feed one sorted data and it degenerates into an O(n) spine — the exact worst case it was supposed to save you from. The whole engineering problem of the next chapters (self-balancing BSTs, complete heaps) is a single sentence: keep the height near log n no matter what order the data arrives.
A tall tree doesn't just run slow — it can crash
Because the natural way to walk a tree is recursion, and recursion depth follows the tree's height, a degenerate spine can blow the call stack. On CPython 3.12.7 (default recursion limit ≈ 1000), a recursive traversal of a 5,000-deep spine dies with RecursionError: maximum recursion depth exceeded — quoted from a real run. On a balanced million-node tree that same walk recurses only ~19 deep. Height is a correctness concern, not only a speed one.

To reach a node you followed pointers, one per level. But each pointer lands at a random address in RAM — and that randomness carries a cost that Big-O never prints on the label. →

03The pointer tax, paid again at runtime

A Python list is contiguous: element i lives at base + i·8, so scanning it streams whole cache lines the CPU prefetched (Volume 3's memory hierarchy). A tree's nodes were allocated one at a time, whenever you inserted them, so they sit at unrelated addresses. Look back at the memory diagram's scattered 0x7f… values. Walking the tree means chasing a pointer to a random location every single step. The prefetcher cannot guess where you'll jump next, so each hop risks a cache miss and a stall while the machine waits on RAM. It's the same O(n) node count as a list scan, but wildly different real speed. The exponent ties; the constant, set by the memory layout, decides.

a list: consecutive cells — the prefetcher streams them, every access a cache hit a[0] a[1] a[2] a[3] a[4] a[5] one smooth stream a tree: nodes scattered across RAM — every child pointer is a leap to somewhere unpredictable A B C D E each red leap can miss the cache and stall on RAM
Fig — same amount of data, two memory patterns. The array streams; the tree hops. The prefetcher rewards the first and stalls on the second.

Measured, and hedged as machine-dependent: summing a million values by walking a balanced tree in Python took about 312 ms on this machine. Summing the same million from a contiguous list in an equivalent Python loop took about 82 ms, roughly 3.8× faster. (The built-in sum(), running in C over that same packed array, did it in ~6.6 ms.) Part of the tree's penalty is the explicit stack and the attribute lookups; part is the cache misses of pointer-chasing. Both are the price of scatter, and both are invisible in the O(n) that the two share.

InteractiveThe tax Big-O never prints — count the instructions the CPU forfeits waiting on RAM
Visit every node once — same n, same O(n). Array streams; tree pointer-chases. 343 M instructions the CPU could have retired — forfeited to waiting on RAM array streamed 1.0 ms tree scattered 105 ms The bar gap is a fixed ~100× — a constant factor, not an exponent. It never closes as n grows.
1.0 M
Order-of-magnitude, machine-dependent: a cache hit off the streamed array ≈ 1 ns/node; a cache miss chasing a scattered node ≈ 100 ns of DRAM latency. At ~3.3 GHz that 99 ns stall is ~330 instructions the core sits idle for — per node. Slide n up: the exponent stays O(n) for both, but the 100× constant — the thing Big-O hides — turns a 1-second array scan into a two-minute tree walk.

Myth

“Walking a tree and scanning an array are both O(n) — so they run at the same speed.”

Reality

O(n) counts operations; the machine bills by the cache line. The array streams through prefetched cache; the tree stalls on a fresh miss at every scattered node. Here that gap was ~3.8× in equivalent Python — and it only widens as the data outgrows the cache.
Wait —
if scattering the nodes is what costs us, why not just store the whole tree inside one contiguous array and get an array's cache behavior with a tree's shape?

You can — and that exact trick is the secret of the heap, a couple of chapters ahead. But first: how do you visit every node at all, when the shape exists only as pointers? The structure hands you the tool. →

04Recursion is a tree's native tongue

Volume 3 taught recursion: solve a big problem by solving smaller versions of the same problem, down to a base case that needs no thought. A binary tree is that shape frozen into data — a node whose two children are themselves smaller trees. So the walk writes itself. To do something to a tree, handle this node, then recurse on the left subtree, then recurse on the right subtree. The base case is the empty tree (None), which needs no work at all. No loops, no manual bookkeeping: the data's self-similarity becomes the code's.

The smallest possible example is measuring the height itself:

recursion.pypython
def height(node):
    if node is None:          # BASE CASE: an empty tree is -1 edges tall
        return -1
    return 1 + max(height(node.left), height(node.right))   # 1 edge + the taller subtree

Lines 2–3 are the floor: an empty tree contributes −1, so a lone leaf (whose two children are empty) comes out to 0. Line 4 is the shrink: this node adds one edge on top of whichever subtree is taller, and each subtree is measured by the very same function on smaller data. It visits every node exactly once, so it is O(n). Ran on the six-node tree above, height returns 2, matching the diagram.

A B C D E the path A→B→D is 2 edges deep the call stack while visiting D (Vol 1): height(A) — waiting on left height(B) — waiting on left height(D) — deepest frame frames alive = 3 = height + 1 recursion depth tracks the tree's height — which is why a spine overflows it
Fig — the call stack is the tree's memory of “where am I?”. The deepest stack equals the height — nothing on a balanced tree, everything on a spine.

Notice the three moves inside every tree walk: touch this node, go left, go right. The only real decision is when you touch the node relative to the two descents — and that single choice spawns four different walks, each with a different job. →

05Four walks, four jobs

A traversal is a rule for visiting every node exactly once. Three of the four are depth-first: they plunge to the bottom before backing up. They differ only by where the “touch the node” step sits among the two recursive descents. Slide that one line and you get three orders:

The fourth is breadth-first. Level-order abandons recursion for a queue (the structure from the queue chapter; this is exactly BFS from Volume 3): visit the root, enqueue its children, pop the next node, enqueue its children, and repeat. You sweep the tree one level at a time, nearest first. It's the walk you want when “closest to the root” means “most important.”

traversals.pypython
from collections import deque

def inorder(node, out):
    if node is None: return         # base case: empty subtree, nothing to do
    inorder(node.left, out)         # LEFT subtree first
    out.append(node.value)          # THEN this node
    inorder(node.right, out)        # THEN the RIGHT subtree
# pre-order: move the append ABOVE the two calls; post-order: move it BELOW both.

def levelorder(root):
    out, q = [], deque([root])      # a queue (Vol 1 deque) — this is BFS
    while q:
        node = q.popleft()          # take the shallowest waiting node
        out.append(node.value)
        if node.left:  q.append(node.left)    # its children go to the BACK
        if node.right: q.append(node.right)
    return out

The top function is the whole depth-first family. Those three lines, with append in the middle slot, are in-order. Lift it above both recursive calls and you have pre-order; drop it below both and you have post-order. The bottom function swaps the call stack for an explicit queue — pull from the front, push children to the back — and that single change turns depth-first into level-by-level. Ran on the six-node tree A(B(D,E), C(·,F)), CPython 3.12.7 gave exactly: in-order D B E A C F, pre-order A B D E C F, post-order D E B F C A, level-order A B C D E F.

one recursion body · slide the “visit node” line to one of three slots PRE-ORDER visit node go left go right A B D E C F serialize / copy IN-ORDER go left visit node go right D B E A C F sorted (on a BST) POST-ORDER go left go right visit node D E B F C A delete / evaluate
Fig — the three depth-first walks are one body with the visit step in three slots — before, between, or after the two descents. Level-order is the odd one out: swap the stack for a queue.
InteractivePick a walk — watch the order light up
A B C D E F D → B → E → A → C → F in-order (left · node · right) → a BST yields sorted order
Same tree, four rules. Only the placement of “touch the node” changes the order — except level-order, which trades the stack for a queue.
Where you meet this — including the page you're reading right now
The DOM is a tree: every web page is a hierarchy of elements, and your browser built this page by walking one. querySelector is a tree search; React and every virtual-DOM framework works by diffing two trees. Your computer's file system is a tree of folders — find descends it pre-order, du sums sizes post-order (a folder's size needs its children's first). Compilers parse source into an expression/syntax tree and evaluate it post-order (operands before the operator). JSON and XML are trees on the wire; org charts, family trees, and machine-learning decision trees are trees by name. You navigate a dozen trees before breakfast.
In-order only means “sorted” on a BST
The famous line “in-order traversal gives sorted output” is true only when the tree is ordered — a binary search tree (the next chapter), where left descendants are smaller and right descendants larger. On the general tree here, in-order is just left·node·right — D B E A C F — with no sorted meaning. The order in the output comes from the order in the tree, not from the traversal.
The deeper cut — walking without recursion

Recursion is the natural way, but it borrows the call stack, and on a deep or degenerate tree that stack overflows (the RecursionError above). Production tree code often carries its own explicit stack instead: push the root, then loop — pop a node, do its work, push its children — turning depth-first recursion into an ordinary while loop with a Python list as the stack. Same visit order, no recursion-limit wall. There's an even slyer method — Morris traversal — that temporarily rewires leaf pointers to thread the tree and walks it in-order using O(1) extra space, no stack at all. Both are the same lesson: the traversal order is a choice; the machinery that remembers “where was I?” is a separate, swappable choice.

Four walks and a two-pointer node: that's the entire general binary tree. Its power comes alive the moment you add one rule to the shape — and each rule you can add is a whole chapter. →

06When to reach for a tree

Strip everything away and a binary tree earns its keep in two situations. First, when your data is a hierarchy — a thing that contains things that contain things — the tree isn't a clever choice, it's the honest shape of the data: files, the DOM, an org chart. Second, when you need logarithmic reach — insert, find, and delete all in O(log n) — a tree delivers it, but only while its height stays near log n. That “only while” is the entire craft. A bare binary tree gives you the shape; it does not defend the balance. The specialized trees ahead each add exactly one rule to earn the log back and never let it slip.

BINARY TREE a node · two child pointers BST — ch 9 + order: left < node < right → search & sorted walk HEAP — ch 10 + complete · array-backed → instant min / max TRIE — ch 11 + branch by symbol → prefix lookup
Fig — the binary tree is the substrate. Add order and it becomes a BST; add completeness and an array and it becomes a heap; branch by symbol and it becomes a trie. Each “+ one rule” is a chapter.

That is the 1% move, and it is most of what being “good at data structures” actually is: match the access pattern to the layout. See a hierarchy → a tree. Need ordered data with fast search → a BST. Need the smallest (or largest) item again and again → a heap. Need to look things up by prefix → a trie. All four are this chapter's node-with-two-pointers wearing one extra discipline. Choosing the right discipline on sight — before you write a line — is the skill; the code is the easy part that follows.

Next — the binary search tree: add a single rule to the shape you just learned — every value in the left subtree smaller than the node, every value on the right larger. Suddenly the in-order walk prints in sorted order for free, and finding a value becomes binary search running on a tree. That is the binary search tree — and the whole fight to keep it from degenerating into the spine you met here. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 56, in working code

A tree is nothing but boxes scattered across the heap, each holding a value and two pointers — so let's build one from those boxes, measure why its height is the whole game, and walk it four different ways.

Boxes joined by pointers
A node is one value and two references; nowhere in RAM is there a thing shaped like a tree — the shape exists only in where those pointers point.
Height is the whole game
Every find, insert, and delete is a walk from the root down one pointer per level, so a tree's cost is exactly how tall it stands — log n when balanced, n when it collapses into a spine.
Recursion is its native tongue
A node whose two children are themselves smaller trees is a recursive definition frozen into data — so counting, summing, and walking all write themselves against the empty tree as the base case.
Four walks, four jobs
Slide the single 'touch the node' line among the two recursive descents for three depth-first orders; swap the call stack for a queue and the same tree comes out level by level.
end of chapter 56 · six sections
iolinked.com
Written by Ajai Raj