algorithms

Trees and Prefix Structures

Use hierarchy to separate what flows down from what returns up.

Published 2026-04-08 · Updated 2026-10-07
On this page

A rooted tree gives every nonroot node one parent. Recursing into a child solves a smaller tree; returning a summary lets the parent combine those results. A trie uses the same hierarchy to share work across strings with common prefixes.

Choose the state direction before the traversal: ancestor constraints travel downward, subtree facts return upward, and level grouping uses a queue. Those are different responsibilities even when the code visits the same nodes.

A child is the root of another tree

A binary tree has at most two children per node. It is not automatically a binary search tree. General graphs can contain cycles or shared nodes, so their traversal needs separate visited-state rules.

class TreeNode:
    def __init__(self, value=0, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

A tree can represent a document hierarchy or directory view. A real file system with symbolic links can introduce graph-like relationships; the model should state which edges are being traversed.

Balanced search-tree families constrain height so lookup remains logarithmic. AVL constrains local height differences; red-black trees enforce color/black-height rules instead. Red-black trees do not promise that every pair of child heights differs by at most one.

What should a subtree return?

h(∅)=0h(v)=1+max⁡ ⁣(h(vleft),h(vright))\begin{aligned}h(\varnothing)&=0\\h(v)&=1+\max\!\bigl(h(v_{\mathrm{left}}),h(v_{\mathrm{right}})\bigr)\end{aligned}
A finite binary tree without cycles or shared-child ambiguity. Height counts nodes on a longest downward path, so a leaf has height1. Height in edges requires a different base convention; the returned height is not the global diameter.
def height(node):
    if node is None:
        return 0
    left = height(node.left)
    right = height(node.right)
    return 1 + max(left, right)

A diameter is not the returned height. At a node with child heights L,R, a path joining the two sides has L+RL+R edges. Track the largest such candidate anywhere; return 1+max⁡(L,R)1+\max(L,R) to the parent. Returning the global diameter instead would corrupt the parent's height calculation.

Compute diameter while returning heights
def diameter_of_binary_tree(root):
    diameter = 0
    def height(node):
        nonlocal diameter
        if node is None: return 0
        left = height(node.left)
        right = height(node.right)
        diameter = max(diameter, left + right)
        return 1 + max(left, right)
    height(root)
    return diameter

The final diameter counts edges. An empty or single-node tree has diameter zero. The accumulator is local to each call, so repeated calls do not retain an earlier answer.

For AVL-style balance checking, combine both child heights and their balance flags. A sentinel can encode failure, but its meaning must not be confused with a valid height. For maximum path sum, return the best single downward branch; a path joining both sides cannot continue upward without branching again.

When to record versus when to return

Same DFS structure, different recording point
OrderPosition of workUseful interpretation
PreorderBefore left and rightRoot-first output, cloning or prefix notation
InorderAfter left, before rightSorted output only if BST ordering holds
PostorderAfter both childrenHeights, bottom-up aggregation or safe child-first release

An iterative stack can express these orders too. To reproduce recursive return work, store a frame phase or push a separate exit event. A stack of nodes with only entry work does not automatically compute postorder child summaries.

The call stack and returned heights

Fixed tree A(B(D,E),C). Choose when visits are recorded. Height still needs child results and is computed on return.

ABCDE

Return height 3 from node A: 1+max(2,1). Record after both children.

Active calls, bottom → top: empty. Recorded order: D → E → B → C → A.

Returned subtree heights, counted in nodes
ABCDE
32111

Blue-filled nodes have returned heights. The blue outline marks the current event; returned nodes need not remain on the active stack.

Exact small input, not a speed benchmark. Step order follows the algorithm; playback is optional. Blue outlines mark active input positions, index labels remain visible.

Finish a branch before its siblings

Ordered general tree, not a binary tree. Letters identify nodes; visit numbers are traversal positions, not stored values.

ABCDEFGHIJ

Visited: A → B → C → D → E → F → G → H → I → J. Next-first pending stack: empty.

Visit J, then push children right-to-left so the leftmost child is popped next. Blue fill means visited; the outline marks the current visit.

Exact small input, not a speed benchmark. Step order follows the algorithm; playback is optional. The pending stack is displayed next-first, not in its storage order.

DFS preorder on the ordered general tree
BranchVisit positions and nodesWhy the next branch waits
Root, then left chain1:A → 2:B → 3:C → 4:DReturn from D, C and B before E
Middle branch5:E → 6:F → 7:G → 8:HFinish F/G before E’s other child H
Right branch9:I → 10:JI waits until both earlier root branches finish

This root has three children, so binary-tree inorder does not define this traversal. Preorder visits a node before its children in the specified left-to-right order. The pending stack stores unexplored siblings; recursive execution also has active ancestor calls. Those are different views of the same visit order.

Maze exploration uses the same depth-first discipline. Cyclic passages require visited-state or path-local rules from Graph Algorithms; a tree-only walk does not need them.

Ancestor state flows downward

For path sums, pass the accumulated sum to each child. For nodes at least as large as every ancestor, pass the maximum seen on that root-to-node path; update it before entering the children. Root value or −∞ can initialize the maximum depending on the contract.

Count nodes that meet every ancestor value
def count_good_nodes(root):
    def visit(node, path_max):
        if node is None: return 0
        good = int(node.value >= path_max)
        next_max = max(path_max, node.value)
        return good + visit(node.left, next_max) + visit(node.right, next_max)
    return visit(root, root.value) if root is not None else 0

Equality qualifies. Each child receives its own path maximum; sibling values cannot change the other branch. This function uses the TreeNode.value field defined above.

Branch-local immutable arguments avoid one branch overwriting another's state. If a shared list stores the path, append before descent and pop after return. Copy only when recording an output that must survive later mutation.

Node depth counts edges from the root; subtree height counts remaining depth below the node. Name the convention, especially when the diameter or longest path uses edge counts.

A queue groups one depth at a time

Breadth-first search processes nodes by root distance. Capture the queue length before each level, then pop exactly that many nodes while appending their children. Otherwise the same loop can accidentally consume the next level too.

Return values grouped by tree level
def level_order(root):
    from collections import deque
    if root is None: return []
    queue, result = deque([root]), []
    while queue:
        level = []
        for _ in range(len(queue)):
            node = queue.popleft()
            level.append(node.value)
            if node.left is not None: queue.append(node.left)
            if node.right is not None: queue.append(node.right)
        result.append(level)
    return result

The function returns complete level lists and imports deque itself. A binary-tree traversal visits n nodes in O(n) time. Recursive pending calls use O(h) space; BFS uses O(w) frontier space, where w is maximum level width. A skewed tree can make h=n.

Finish a level before the next

Fixed complete binary tree with node values 1…7. The queue is displayed front-first.

1234567

Visited: 1 → 2 → 3 → 4 → 5 → 6 → 7. Next-first queue: empty.

Visit 7, then append children left-to-right behind nodes already waiting. Blue fill means visited; the outline marks the current visit.

Exact small input, not a speed benchmark. Step order follows the algorithm; playback is optional. Both visited order and remaining queue are available as text.

Level grouping is fixed before children are appended
Root distance in edgesNodes processedQueue after the whole level
012,3
12,34,5,6,7
24,5,6,7empty

Node 3 stays ahead of nodes 4 and 5 even after visiting node 2. That queue order explains why BFS finishes the current level first. The displayed tree numbers happen to match visit order; numeric sorting is not the algorithm.

A game-state graph can use BFS to enumerate reachable positions by move count. Chess decision-making is not simply shortest-path search: opponent responses require a different objective, such as minimax. For equal-cost edges, BFS finds minimum edge count; unequal edge costs require another algorithm.

Worked tree shapes and visit sequences: Top 7 Algorithms for Coding Interviews Explained Simply ↗. The diagrams add explicit node identities and pending frontiers. Playback timing is illustrative.

BST order constrains entire subtrees

Under a distinct-key policy, every left-subtree value is below the node and every right-subtree value is above it. Checking only direct children misses a descendant that violates an ancestor's bound.

For example, root 10 with right child 15 and that child's left child 6 passes local child comparisons at 15 but violates root 10's right-subtree bound. Pass an open lower/upper interval down; every node must satisfy all inherited bounds.

A duplicate policy can instead permit equality on a specified side or store multiplicities at one node. Inorder is then nondecreasing, but the strict-bound validator must change. State the policy rather than mixing strict insertion with nonstrict validation.

Search, insertion and deletion cost O(h), not unconditionally O(log n). Balanced height makes that logarithmic. Random-input assumptions can produce an average-case statement; an arbitrary plain BST can become a chain.

The minimum is leftmost and maximum rightmost. A node with a right subtree has the right subtree's minimum as successor. Otherwise its successor is the first ancestor reached from a left branch; the maximum has none. Inorder selection finds the k-th key by traversal count, or subtree sizes can support rank/select.

An ancestor joins target evidence

In a general binary tree, a returned target or subtree candidate tells the parent which side contains evidence. One target from each child makes the current node the lowest common ancestor. If the current node is one target and the other is below it, the current node itself can be the answer.

The usual compact recursion assumes both targets exist. If existence is not guaranteed, also return presence counts/flags; otherwise one existing target may be incorrectly reported as a complete answer. Compare identity when distinct nodes can have equal values.

In a BST with known present distinct keys, both targets below or above the current value choose one branch. When they split—or one matches—the current node is their LCA. The ordering rule is an optimization of the same ancestor question.

Structure needs nulls or another contract

A preorder list of values alone does not determine an arbitrary binary tree. Include explicit null-child markers, or use another representation with enough structural information. Deserialization consumes tokens in the same order and validates premature endings and leftover data.

Distinct-valued preorder plus inorder can reconstruct a binary tree under their shared-node contract. Duplicates need more information. Do not infer uniqueness from a traversal that intentionally discarded structure.

Strings share prefix work

Each edge consumes one character and each node represents the consumed prefix. Exact lookup requires both reaching the node and seeing its terminal marker. Prefix lookup needs only a successful path.

Insert and query a complete prefix structure
class TrieNode:
    def __init__(self):
        self.children = {}
        self.is_word = False

class Trie:
    def __init__(self):
        self.root = TrieNode()
    def insert(self, word):
        node = self.root
        for char in word:
            if char not in node.children:
                node.children[char] = TrieNode()
            node = node.children[char]
        node.is_word = True
    def _find_node(self, text):
        node = self.root
        for char in text:
            if char not in node.children: return None
            node = node.children[char]
        return node
    def search(self, word):
        node = self._find_node(word)
        return node is not None and node.is_word
    def starts_with(self, prefix):
        return self._find_node(prefix) is not None

The class is shown together so the read and write operations share one node contract. It stores Unicode code points without case folding or normalization. Repeated insertion does not add multiplicity; it records membership. Empty-prefix lookup succeeds even before inserting any word.

The read-only _find_node method never creates nodes. Insertion creates missing edges and marks the final node terminal. A stored empty string marks the root. A word can be a prefix of another word, so a terminal node may still have children.

Insert/exact/prefix lookup takes O(L) character-edge operations under bounded child lookup cost. Memory follows stored prefix nodes, not simply the number of words. Sparse child maps save unused alphabet slots; fixed arrays trade that memory for direct access. Alphabet/normalization policies remain explicit.

A prefix is not necessarily a stored word

root
├─ c ─ a ─ r* ─ t*
│       └─ t*
└─ d ─ o ─ g*

* marks a complete stored word; a prefix node can have children and still be terminal.

Query “ca”: path root → c → ca. All characters have an edge.

Different questions
Prefix exists?Exact stored word?
yesno

Stored words: car, cart, cat, dog. Looking up a query never adds a new node.

Wildcards and boards branch through prefixes

For an ordinary query character, follow its matching child. For a wildcard, explore every permitted child and succeed if one full query path ends at a terminal node. Search cost can grow with branching; it is not the exact lookup's O(L) guarantee.

Search a dot-wildcard pattern
def wildcard_search(root, word):
    def visit(index, node):
        if index == len(word): return node.is_word
        char = word[index]
        if char == '.':
            return any(visit(index + 1, child)
                       for child in node.children.values())
        if char not in node.children: return False
        return visit(index + 1, node.children[char])
    return visit(0, root)

A dot matches exactly one code point, not an arbitrary-length suffix. Query end checks is_word, so a matching nonterminal prefix is not enough. The function reads the trie without changing terminal markers or children.

Autocomplete walks the prefix once, then enumerates terminal descendants. Its cost includes output size. Terminal metadata can hold a word identifier, frequency or ranked candidate; storing complete strings at every internal node is often unnecessary.

For a board searched against many words, advance the trie alongside the board path. Reject a missing trie edge before further exploration. Mark a board cell used on the current path, then restore it on return. A global board visited set would incorrectly forbid reuse by a different starting path.

Emitting one terminal once can prevent duplicate output, but clearing its marker mutates the dictionary state. Restore or rebuild it if repeated queries must have the same behavior. Finding a shorter dictionary root can stop at the first terminal encountered during a word walk.

Choose the meaning of every piece of state

Before recursion, name the empty return value, the summary returned to a parent, the ancestor arguments and any global answer. Reset globals between independent calls. A traversal count, path state and subtree summary are not interchangeable.

Test empty and one-node trees, deep chains, broad levels, negative values, duplicate keys, an ancestor-bound violation and a missing LCA target. For tries, test a stored word that prefixes another, a prefix not inserted as a word, a missing edge and a wildcard that branches but never reaches a terminal.

Graph visited/frontier state → extends traversal to arbitrary connectivity. Dynamic Programming → shares the return-contract discipline but merges repeated logical states rather than assuming each subtree is unique.

Sources and related patterns

BST subtree order and rank operations ↗ · Prefix structures and wildcard branching ↗

Backtracking explains board-path ownership and restoring visited cells.