Trees Field Notes
Binary trees, binary search trees, insert and remove, the three depth-first orders, breadth-first search, and tree-backed sets and maps. Every diagram uses the same two trees as the NeetCode videos, so you can trace along.
0Why trees exist
Think of the folders on your laptop. Home contains Photos and Work. Photos contains Trip. Every folder has exactly one parent, and you can only go down from a folder into its children. A binary tree is the same picture with one rule added: each folder may hold at most two subfolders.
A linked list is a chain: one node, then the next, then the next. A tree is the same idea, nodes and pointers, but each node can point to more than one node below it. That single change gives you a hierarchy (levels of parents and children), and, when the values are kept in order, a structure you can search in O(log n) while still inserting and deleting in O(log n). A sorted array cannot do the second part.
next. A binary tree node has left and right. The arrows only go down, so from the top node you can reach everything, and you can never walk in a circle.Six lessons, one page. Lesson 16 is vocabulary. Lesson 17 adds the sorted rule (a BST). Lesson 18 changes the tree. Lessons 19 and 20 walk the whole tree in two different orders. Lesson 21 uses a BST to build a sorted set and a sorted map.
1Binary trees
The node: one value, two pointers
Like linked lists, binary trees are built from nodes and pointers. A linked list node points to the next node. A binary tree node has at most two pointers, called the left child and the right child. The first node, the one at the top, is the root. NeetCode draws the pointers going downward, and so does every diagram here.
class ListNode: # from the linked list lessons
def __init__(self, val):
self.val = val
self.next = None
class TreeNode:
def __init__(self, val):
self.val = val
self.left = None # left child, or None
self.right = None # right child, or None
next (it holds a node object or None, you must check for None before you use it) applies to left and right. The nodes below a node are called its children; the node above is its parent.Building a tree by hand
Create the nodes, then attach them. This is the tree NeetCode uses for the BST search lesson: root 2, left child 1, right child 3, and 4 under 3 on the right.
root = TreeNode(2)
root.left = TreeNode(1)
root.right = TreeNode(3)
root.right.right = TreeNode(4) # 4 is the right child of 3
LeetCode and NeetCode describe a tree as a list, level by level, left to right, with null for a missing child. This tree is [2,1,3,null,null,null,4]: level 1 is 2; level 2 is 1, 3; level 3 is the children of 1 (null, null) then the children of 3 (null, 4). Trailing nulls are dropped. A small helper turns that list into real nodes so you can test locally.
from collections import deque
def build_tree(values):
"""[2,1,3,None,None,None,4] -> root of that tree (LeetCode format)."""
if not values or values[0] is None:
return None
root = TreeNode(values[0])
queue = deque([root])
i = 1
while queue and i < len(values):
node = queue.popleft()
if i < len(values) and values[i] is not None:
node.left = TreeNode(values[i])
queue.append(node.left)
i += 1
if i < len(values) and values[i] is not None:
node.right = TreeNode(values[i])
queue.append(node.right)
i += 1
return root
Leaves, and why one always exists
A node with no children is a leaf. A node with even one child, left or right, is a non-leaf node. Unlike a linked list, tree pointers go only one way, down, and cycles are not allowed. Mathematically a binary tree is a connected, undirected graph with no cycles: every node is reachable, and there is no way to walk back to where you started. Because there are no cycles, walking down must end somewhere, so a leaf node is always guaranteed to exist.
The words interviewers use
Lesson 16 is mostly definitions, but every tree question is asked in these words, so they must be automatic.
- Root node: the highest node; it has no parent, and every node is reachable from it.
- Leaf nodes: nodes with no children. Nodes on the last level are guaranteed to be leaves, but leaves can sit on other levels too (node 1 above).
- Children: a node's left child and right child.
- Height: measured from the root down to the lowest leaf. A single-node tree has height 1 if you count nodes, or 0 if you count edges. When counting edges, height = n - 1 where n is the number of nodes on the path from the root to the lowest leaf. Ask which one a problem wants; LeetCode 104 counts nodes.
- Depth: measured from a node up to the root. The root has depth 1, and depth grows as you go down. Depth of a node = how many nodes are above it, itself included.
- Ancestor: a node connected to all the nodes below it. The root is an ancestor of every node.
- Descendant: a child of the node, or a child of some other descendant of the node. Everything below.
- Number of edges: always n - 1 for n nodes, because every node except the root has exactly one pointer coming into it.
def height_nodes(root): # count nodes on the longest path
if not root:
return 0
return 1 + max(height_nodes(root.left), height_nodes(root.right))
def height_edges(root): # count edges: one less than nodes
if not root:
return -1 # so a single node gives 1 + (-1) = 0
return 1 + max(height_edges(root.left), height_edges(root.right))
def count_nodes(root):
if not root:
return 0
return 1 + count_nodes(root.left) + count_nodes(root.right)
# for [2,1,3,None,None,None,4]: height_nodes = 3, height_edges = 2,
# count_nodes = 4, edges = 4 - 1 = 3
Every tree function starts with the same line: if not root: return <something small>. That line handles the empty tree, and it also handles every missing child, because root.left of a leaf is None. Forget it and you get AttributeError: 'NoneType' object has no attribute 'left'.
2Binary search trees
A dictionary. You open it in the middle. If your word comes earlier in the alphabet, you ignore the whole right half and never look at it again. A BST stores that "left is smaller, right is bigger" rule inside the structure itself, so every step throws away half of what is left.
Binary tree vs BST
A binary search tree (BST) is a binary tree with a sorted property: every node in the left subtree is smaller than the root, and every node in the right subtree is greater than the root. The property is recursive: it applies at every node, not only at the top. Node 6 below has 5 on its left and 7 on its right, for the same reason. A BST is the tree version of a sorted array.
Why a BST instead of a sorted array?
Both give you search in O(log n). The difference is what happens when the data changes. Inserting into a sorted array means shifting everything after the new spot, O(n). Inserting into a balanced BST is a walk down the tree plus one pointer change, O(log n). Same for deletion. So BSTs are often preferred when you must search and keep adding and removing.
BST search
Trees are recursive (a subtree is a smaller tree), so we search with recursion. The tree is [2,1,3,null,null,null,4] and the target is 3. Same idea as binary search: target smaller than the current node, go left; target bigger, go right. Reach the target node: return True. Reach None: return False.
def search(root, target):
if not root:
return False
if target > root.val:
return search(root.right, target)
elif target < root.val:
return search(root.left, target)
else:
return True
Nonenode: return False. The target is not in this subtree. Base case.- Target greater than the current value: search the right subtree.
- Target smaller than the current value: search the left subtree.
- Target equals the current value: return True. Base case.
In steps 2 and 3 the recursive call's return value is the current call's return value. There is nothing to do after the call returns, so you write return search(...) and let the answer flow straight back up.
| Call | root.val | Compare | Action | Returns |
|---|---|---|---|---|
search(2, 3) | 2 | 3 > 2 | return search(root.right, 3) - root.right is node 3 | whatever the next call returns |
search(3, 3) | 3 | 3 == 3 | found | True |
| unwind | - | - | True flows back to search(2, 3), which returns it | True |
The same walk without recursion. Interviewers like this version because it uses no call stack.
def search_iter(root, target):
cur = root
while cur:
if target > cur.val:
cur = cur.right
elif target < cur.val:
cur = cur.left
else:
return True
return False
Time: O(log n) if balanced, O(n) if skewed
A tree is balanced when, at every node, the height of the left subtree equals the height of the right subtree, or differs by 1. In a balanced tree each comparison throws away about half of the remaining nodes, exactly the reasoning from binary search and merge sort, so search takes O(log n) steps.
The worst case is a skewed tree, where every node has only one child and the height equals the number of nodes. Then the search walks a straight line: O(n). A skewed tree is a linked list wearing a tree costume.
3Insert and remove in a BST
The main benefit of a BST over a sorted array is here: insertion and removal in O(log n), assuming the tree is balanced. Both are "search for the spot, then change one or two pointers".
Insertion: walk down, attach a leaf
Traverse to find the right position, then insert while keeping the sorted property. Start with the BST [4] and insert 6. Two results are valid: [4,null,6] (6 as the right child of 4) or [6,4,null] (6 as the new root with 4 on its left). The first one adds 6 as a leaf, which is easier, and it is what the code does. Then insert 5 into [4,null,6]: 5 is bigger than 4, so go right; 5 is smaller than 6, so go left; that spot is empty, so 5 becomes the left child of 6, giving [4,null,6,5,null].
None. Step ① finds the empty right slot of 4. Step ② finds the empty left slot of 6. No existing node moves; the tree only grows at the bottom.# Insert a new node and return the root of the BST.
def insert(root, val):
if not root:
return TreeNode(val)
if val > root.val:
root.right = insert(root.right, val)
elif val < root.val:
root.left = insert(root.left, val)
return root
- Current node is
None: return a brand new node holdingval. This is the line that adds the node. valgreater than the current value: recurse on the right child. Smaller: recurse on the left child.- Return the current node after the recursive call. The return value of each recursive call is assigned to
root.leftorroot.right. That assignment is how the tree structure is maintained: the parent re-attaches whatever the child call hands back, which is either the same child or a brand new node.
| Call | Compare | Does | Returns |
|---|---|---|---|
insert(4, 5) | 5 > 4 | root.right = insert(6, 5) | node 4 (unchanged root) |
insert(6, 5) | 5 < 6 | root.left = insert(None, 5) | node 6 |
insert(None, 5) | base case | creates TreeNode(5) | new node 5 |
| unwind | - | 6.left = node 5, then 4.right = node 6 (same node as before) | root 4, now [4,null,6,5] |
Time is O(log n) when balanced: we walk until we reach a leaf, so the number of visited nodes is proportional to the height. Equal values are ignored by this code (neither branch fires, the node is returned unchanged), which is what a set wants. NeetCode's video: "6 is greater than the root 4, so it goes in the right subtree. 5 is greater than 4 but less than 6, so it goes in the left subtree of the tree rooted at 6."
Removal: two cases
Removing is harder because the node may have children that must stay in the tree. Count the target's children.
Case 1: the target has 0 or 1 child. Delete node 2, which has no children: the left pointer of 3 now points to None. Delete node 3, which has one child: the left pointer of the root now points to 2 instead of 3. The child moves up into the hole. Nothing else changes.
Case 2: the target has 2 children. Delete node 6. You cannot point 4 at both 5 and 7. Instead, replace 6 with its in-order successor: the left-most node in the right subtree of the target, which is the smallest node among all nodes greater than the target. Here that is 7. Copy 7 into the target node, then remove the original 7 from the right subtree (it is a leaf, so that is case 1). Every node left of the new 7 is smaller than it and every node right of it is bigger, so the tree stays a valid BST.
# Return the minimum value node of the BST.
def minValueNode(root):
curr = root
while curr and curr.left:
curr = curr.left
return curr
# Remove a node and return the root of the BST.
def remove(root, val):
if not root:
return None
if val > root.val:
root.right = remove(root.right, val)
elif val < root.val:
root.left = remove(root.left, val)
else: # found the node to delete
if not root.left:
return root.right # 0 or 1 child: hand the other side up
elif not root.right:
return root.left
else: # 2 children
minNode = minValueNode(root.right) # in-order successor
root.val = minNode.val # copy it up
root.right = remove(root.right, minNode.val) # delete the copy below
return root
Read the middle branch slowly. if not root.left: return root.right covers two situations at once: no children at all (then root.right is None, and the parent's pointer becomes None), and only a right child (the parent's pointer becomes that child). The parent re-attaches whatever is returned, the same trick as in insert.
| Call | Branch | Does | Returns |
|---|---|---|---|
remove(4, 6) | 6 > 4 | root.right = remove(6, 6) | node 4 |
remove(6, 6) | found, two children | minValueNode(7) gives 7; root.val = 7; root.right = remove(7, 7) | the node, now holding 7 |
remove(7, 7) | found, no left child | return root.right, which is None | None |
| unwind | - | node(7).right = None; 4.right = node(7) | root 4, now [4,3,7,2,null,5] |
Time and space
Time is proportional to the height, essentially a binary search down the tree: O(log n) balanced, O(n) worst case (unbalanced). Space is the recursion: the number of recursive calls on the stack is also proportional to the height, so O(log n) best, O(n) worst.
root.right = remove(root.right, minNode.val). After copying the successor's value up, the successor still exists lower in the tree. If you skip this line the value appears twice and the tree is no longer a set. The recursive call is safe because the successor has no left child, so it is always case 1.
4Depth-first search
Exploring a cave with one rule: always take the left tunnel until it dead-ends, then walk back to the last fork and take the right tunnel. You go as deep as possible before you back up. That is depth-first.
DFS is one of the most common interview algorithms. It is used to traverse trees and graphs; here, trees. To traverse an entire tree we must visit every node. DFS picks a direction, say left, and keeps following pointers as far down-left as possible until it hits None. Then it backtracks (returns to the parent) and goes right. Repeat until every node has been visited. "Go as deep as possible before backtracking."
There are three DFS orders, named after when the parent is visited relative to its children: inorder, preorder, postorder. All three are best written with recursion; each can also be written with an explicit stack (section 7). The tree for this lesson is [4,3,6,2,null,5,7]: root 4, left child 3 with left child 2, right child 6 with children 5 and 7.
print line sits: between the two recursive calls (inorder), before them (preorder), or after them (postorder). The orange numbers are the print order.Inorder: left subtree, then the node, then right subtree
def inorder(root):
if not root:
return
inorder(root.left)
print(root.val)
inorder(root.right)
Visit order: [2, 3, 4, 5, 6, 7], which is sorted. Inorder prints sorted order only if the tree is a BST. All values to the left are smaller, so we hit the base case at the left-most (smallest) node first, print it, then its parent, then the parent's right subtree, and so on. Run the same function on a non-BST with the same shape, like [1,2,3], and you get [2, 1, 3].
| Step | Call stack (deepest on the right) | What happens | Printed so far |
|---|---|---|---|
| 1 | in(4) | call inorder(4.left) | - |
| 2 | in(4) > in(3) | call inorder(3.left) | - |
| 3 | in(4) > in(3) > in(2) | call inorder(2.left) | - |
| 4 | in(4) > in(3) > in(2) > in(None) | base case, return | - |
| 5 | in(4) > in(3) > in(2) | print 2; call inorder(2.right) = None, returns; in(2) done | 2 |
| 6 | in(4) > in(3) | print 3; call inorder(3.right) = None, returns; in(3) done | 2 3 |
| 7 | in(4) | print 4; call inorder(4.right) | 2 3 4 |
| 8 | in(4) > in(6) > in(5) > in(None) | slide down-left to 5, hit None, return | 2 3 4 |
| 9 | in(4) > in(6) > in(5) | print 5; right is None; in(5) done | 2 3 4 5 |
| 10 | in(4) > in(6) | print 6; call inorder(6.right) | 2 3 4 5 6 |
| 11 | in(4) > in(6) > in(7) | left None; print 7; right None; in(7) done | 2 3 4 5 6 7 |
| 12 | (empty) | in(6) returns, in(4) returns. Done. | 2 3 4 5 6 7 |
Preorder: the node first, then left subtree, then right subtree
def preorder(root):
if not root:
return
print(root.val)
preorder(root.left)
preorder(root.right)
Visit order: [4, 3, 2, 6, 5, 7]. The root always comes out first. Preorder is the order you would use to copy a tree or serialize it, because you see each parent before its children.
Postorder: left subtree, right subtree, the node last
def postorder(root):
if not root:
return
postorder(root.left)
postorder(root.right)
print(root.val)
Visit order: [2, 3, 5, 7, 6, 4]. The root always comes out last. Postorder is the order for "finish the children, then use their results", such as computing a height or deleting a tree from the bottom.
Time and space (all three)
Time O(n): every node is visited exactly once, regardless of the height. Note: an inorder traversal of a BST builds a sorted array, which is the cheap way to get "all values in order" out of a BST. Space O(h) where h is the height, because that is how deep the call stack gets: O(log n) for a balanced tree, O(n) for a skewed one.
In an interview you usually collect instead of print. Pass a list down, or use a nested helper: def dfs(node): if not node: return; dfs(node.left); out.append(node.val); dfs(node.right). The order rule is identical.
5Breadth-first search
Reading a family tree out loud: first the grandparent, then all the parents, then all the grandchildren. You finish one generation before you say a single name from the next one. That is breadth-first.
DFS prioritizes depth. BFS prioritizes breadth: visit all nodes on one level before moving to the next. For trees BFS is also called level-order traversal.
Implementation: a queue, level by level
BFS is written iteratively. We need a data structure that lets us finish a whole level before starting the next. That is a queue, specifically a deque, because it removes from the head and appends to the tail in O(1). Append children to the tail, remove parents from the head, and each level comes out left to right.
from collections import deque
def bfs(root):
queue = deque()
if root:
queue.append(root)
level = 0
while len(queue) > 0:
print("level: ", level)
for i in range(len(queue)): # exactly the nodes of this level
curr = queue.popleft()
print(curr.val)
if curr.left:
queue.append(curr.left)
if curr.right:
queue.append(curr.right)
level += 1
- Append the root to the queue (if there is one).
- While the queue is not empty: print the level number.
- Loop
len(queue)times. That number is the size of the current level, and it is read once, before the loop starts, so children appended during the loop are not counted as part of this level. - For each removed node, append its children, left then right.
- After the level is done,
level += 1. - The queue empties when every node has been visited, and the while loop ends.
level: 0, 4, level: 1, 3, 6, level: 2, 2, 5, 7.| While turn | Queue at start | len(queue) | Popped (printed) | Appended | Queue at end |
|---|---|---|---|---|---|
| level 0 | [4] | 1 | 4 | 3, 6 | [3, 6] |
| level 1 | [3, 6] | 2 | 3, then 6 | 2 (from 3); 5, 7 (from 6) | [2, 5, 7] |
| level 2 | [2, 5, 7] | 3 | 2, 5, 7 | nothing (all leaves) | [] |
| check | [] | 0 | - | - | while loop exits |
Time and space
Time O(n): each node is added to the queue once and removed once. Space O(n): the queue holds an entire level at a time, and in a full tree the last level holds roughly half of all nodes. Compare with DFS, whose stack holds one root-to-leaf path, O(h).
If the question mentions levels, nearest, shortest, left/right side view, or zigzag, think BFS. If it asks for a property built from children (height, sum, is it balanced, does a path exist), think DFS. Both visit every node, O(n); they differ in order and in what they keep in memory.
6BST sets and maps
A paper phone book. Each name appears once (a set of names), each name has a number next to it (a map from name to number), and the pages are sorted, so you can flip to "K" without reading "A" to "J".
Sets and maps, like stacks and queues, are interfaces: a list of operations, not a fixed way of storing things. One way to implement them is with a tree, and then insertion, deletion, and search all run in O(log n).
Sets
A phone book with the names Alice, Brad, Collin. A set ensures unique values: adding "Alice" twice keeps one Alice. Implementing it with a BST also keeps the keys sorted (alphabetically here), because inorder traversal gives sorted order. This is a TreeSet.
Maps
A map works on key-value pairs. Built with a tree it is a TreeMap. The phone book maps names to phone numbers, sorted by key: {'Alice': 123, 'Brad': 345, 'Collin': 678}. The key could be the last name; the value can be a number, an object, any type. The only requirement is that keys must be comparable, because the tree needs an order to decide left or right. TreeMap operations run in O(log n).
Here is a small TreeMap written on top of the insert and search you already know. Keys stay unique because equal keys overwrite the value instead of adding a node.
class TreeMap:
class Node:
def __init__(self, key, value):
self.key, self.value = key, value
self.left = self.right = None
def __init__(self):
self.root = None
def put(self, key, value):
def ins(node):
if not node:
return TreeMap.Node(key, value)
if key < node.key:
node.left = ins(node.left)
elif key > node.key:
node.right = ins(node.right)
else:
node.value = value # same key: overwrite, keys stay unique
return node
self.root = ins(self.root)
def get(self, key):
cur = self.root
while cur:
if key < cur.key:
cur = cur.left
elif key > cur.key:
cur = cur.right
else:
return cur.value
return None
def items(self): # inorder = sorted by key
out = []
def walk(node):
if not node:
return
walk(node.left)
out.append((node.key, node.value))
walk(node.right)
walk(self.root)
return out
phone = TreeMap()
phone.put("Collin", 678)
phone.put("Alice", 123)
phone.put("Brad", 345)
phone.get("Brad") # 345
phone.items() # [('Alice', 123), ('Brad', 345), ('Collin', 678)]
In different languages
Java and C++ ship a TreeMap in the standard library. Python and JavaScript do not; you install one.
# Python: pip install sortedcontainers
from sortedcontainers import SortedDict
treemap = SortedDict({'c': 3, 'a': 1, 'b': 2})
list(treemap.keys()) # ['a', 'b', 'c'] - always sorted
# Java: TreeMap treeMap = new TreeMap();
# JavaScript: const TreeMap = require("treemap-js"); let map = new TreeMap();
# C++: map<string, string> treeMap;
sortedcontainers also gives you SortedList (a sorted list with O(log n) insert, index, and bisect_left) and SortedSet (the TreeSet). Internally they are not trees but lists of lists; the interface and the Big-O are what matter in an interview.
Python's built-in dict and set are the hash-based alternative: O(1) average for get, put, and remove, but no order. Reach for a tree-based map only when you need "smallest key greater than x", "keys in a range", or sorted iteration. Hashing is the topic of a later lesson; trees are one way to build sets and maps, hashing is the other.
7Moves that solve most tree problems
The lessons teach the structure and the traversals. Interview questions are those traversals with a small twist. Learn these five moves.
Move 1: the recursive template, "combine(left, right)"
Almost every tree function has this shape: handle None, ask both children, combine their two answers with the current node, return one value. Height, node count, sum, maximum path, "is it the same tree", "invert it" all fit.
def solve(root):
if not root:
return base # 0, True, None, ... whatever an empty tree answers
left = solve(root.left)
right = solve(root.right)
return combine(left, right, root)
def max_depth(root): # LeetCode 104 (counts nodes)
if not root:
return 0
left = max_depth(root.left)
right = max_depth(root.right)
return 1 + max(left, right)
def invert(root): # LeetCode 226: swap every left and right
if not root:
return None
root.left, root.right = invert(root.right), invert(root.left)
return root
def same_tree(p, q): # LeetCode 100: two trees at once
if not p and not q:
return True
if not p or not q or p.val != q.val:
return False
return same_tree(p.left, q.left) and same_tree(p.right, q.right)
When the answer is not the same thing the function returns, keep a side variable. Diameter (longest path, in edges) and "is balanced" both return a height upward while recording something else.
def diameter(root): # LeetCode 543
best = 0
def height(node):
nonlocal best
if not node:
return 0
l = height(node.left)
r = height(node.right)
best = max(best, l + r) # longest path THROUGH this node, in edges
return 1 + max(l, r) # but hand the height upward
height(root)
return best
def is_balanced(root): # LeetCode 110
def check(node): # returns height, or -1 if anything below is unbalanced
if not node:
return 0
l = check(node.left)
if l == -1:
return -1
r = check(node.right)
if r == -1:
return -1
if abs(l - r) > 1:
return -1
return 1 + max(l, r)
return check(root) != -1
Move 2: DFS with an explicit stack
Same order as the recursive version, but you manage the stack yourself. Preorder is the easy one: pop a node, visit it, push right then left so that left is popped first. Inorder needs a "slide down-left, then pop, then step right" loop.
def preorder_iter(root):
out, stack = [], [root] if root else []
while stack:
node = stack.pop()
out.append(node.val)
if node.right: # push right first so left is popped first
stack.append(node.right)
if node.left:
stack.append(node.left)
return out # [4, 3, 2, 6, 5, 7]
def inorder_iter(root):
out, stack, cur = [], [], root
while cur or stack:
while cur: # slide down-left, remembering the path
stack.append(cur)
cur = cur.left
cur = stack.pop() # nothing further left: visit
out.append(cur.val)
cur = cur.right # then go right
return out # [2, 3, 4, 5, 6, 7]
The inorder stack version is also the tool for kth smallest in a BST: stop after k pops.
def kth_smallest(root, k): # LeetCode 230
stack, cur = [], root
while cur or stack:
while cur:
stack.append(cur)
cur = cur.left
cur = stack.pop()
k -= 1
if k == 0:
return cur.val
cur = cur.right
Move 3: BFS with the level-size loop
The lesson's bfs prints. Interview versions return a list per level. Freeze size = len(queue) before the inner loop; that number is the whole trick.
def level_order(root): # LeetCode 102
levels = []
queue = deque([root] if root else [])
while queue:
size = len(queue) # freeze the size: only this level
level = []
for _ in range(size):
node = queue.popleft()
level.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
levels.append(level)
return levels # [[4], [3, 6], [2, 5, 7]]
def right_side_view(root): # LeetCode 199: last node of every level
return [level[-1] for level in level_order(root)]
Move 4: validate a BST with min / max bounds
The trap: checking only left.val < node.val < right.val at each node is not enough. In [5,4,7,null,6], node 6 is a valid right child of 4, but it sits in the left subtree of 5, where everything must be below 5. Carry a range down instead: every node must be strictly between low and high, and each step narrows one side.
def is_valid_bst(root, low=float("-inf"), high=float("inf")): # LeetCode 98
if not root:
return True
if not (low < root.val < high):
return False
return (is_valid_bst(root.left, low, root.val) and
is_valid_bst(root.right, root.val, high))
Move 5: use the BST order to prune
On a BST, do not visit both children. Compare and go one way, like search. Lowest common ancestor is the classic: the first node where the two targets split to different sides (or one of them equals the node) is the answer.
def lca_bst(root, p, q): # LeetCode 235, p and q are values
cur = root
while cur:
if p < cur.val and q < cur.val:
cur = cur.left
elif p > cur.val and q > cur.val:
cur = cur.right
else:
return cur.val # they split here (or one is here)
8Big-O summary
Say the word "balanced" every time you say O(log n). h is the height: about log n for a balanced tree, up to n for a skewed one.
| Operation | Balanced BST | Skewed BST | Sorted array | Hash map / set |
|---|---|---|---|---|
| Search | O(log n) | O(n) | O(log n) | O(1)* |
| Insert | O(log n) | O(n) | O(n) | O(1)* |
| Remove | O(log n) | O(n) | O(n) | O(1)* |
| Min or max | O(log n) | O(n) | O(1) | O(n) |
| All values in sorted order | O(n) inorder | O(n) inorder | O(n) | O(n log n) sort first |
| DFS (any order), time | O(n) | O(n) | - | - |
| DFS space (call stack or explicit stack) | O(log n) | O(n) | - | - |
| BFS time | O(n) | O(n) | - | - |
| BFS space (queue = widest level) | O(n) | O(1) | - | - |
| Space of the structure | O(n) | O(n) | O(n) | O(n) |
The hash map star means "average case"; hashing is a later topic. BFS space is O(n) on a balanced tree because the last level of a full tree holds about n/2 nodes; on a skewed tree each level has one node, so the queue never grows, which is the one place a skewed tree wins. DFS space is the opposite: it holds one root-to-leaf path, which is short on a balanced tree and the whole tree on a skewed one.
9Mistakes to watch for
AttributeError: 'NoneType' object has no attribute 'left'. First line of every recursive tree function: if not root: return .... It covers the empty tree and every missing child at once.
insert(root, 5) alone does nothing visible when the tree is empty, and remove(root, 4) cannot delete the root. Always write root = insert(root, 5), and inside the function always write root.left = insert(root.left, val). The return value is how the parent re-attaches the subtree.
[5,4,7,null,6] passes a local check but is not a BST: 6 lives in the left subtree of 5. Carry low and high bounds down the recursion and tighten one side at each step.
Inorder gives sorted output only when the tree is a BST. On [1,2,3] it gives 2, 1, 3. Check what the problem promises before you rely on it.
A single node has height 1 (nodes) or 0 (edges). LeetCode 104 wants nodes; diameter (543) wants edges. Read the example in the problem and match it, then keep one convention through the whole solution.
Writing while queue: node = queue.popleft(); ... with no inner loop visits nodes in level order but never knows where a level ends. Freeze size = len(queue) and loop exactly that many times. And never use list.pop(0) as the queue; it is O(n) per pop. Use deque.popleft().
After root.val = minNode.val the value exists twice. You must also run root.right = remove(root.right, minNode.val). The successor has no left child, so that second removal is always the easy case.
A plain BST built from sorted input is a linked list: every operation is O(n). Say "O(log n) if the tree is balanced, O(n) in the worst case". Interviewers listen for that sentence.
A stack is LIFO. If you want the left child processed first, push it last. Push right, then left.
Interview one-liners
- "A binary tree node is a linked list node with two pointers, left and right, pointing down; no cycles, so a leaf always exists."
- "A BST keeps every left descendant smaller and every right descendant bigger, at every node, so search, insert, and delete are O(log n) if the tree is balanced, O(n) if skewed."
- "To delete a node with two children, I replace it with its in-order successor, the left-most node of the right subtree, then delete that successor."
- "Inorder, preorder, postorder are the same left-first DFS; only the position of the visit line changes. Inorder on a BST is sorted."
- "DFS costs O(n) time and O(h) stack; BFS costs O(n) time and O(n) queue, because it holds a whole level."
- "I validate a BST by passing min and max bounds down, not by comparing a node with its direct children."
- "A TreeMap is a BST of keys with values attached, O(log n) per operation and sorted iteration; Python's
dictis the hash version, O(1) average and unordered. For a tree-like map in Python I usesortedcontainers.SortedDict."
10Practice, in this order
Do these on LeetCode with the notes closed. The first five are the recursive template with different combine steps; do not move on until they feel like one problem.
| # | Problem | Move it trains | Level |
|---|---|---|---|
| 104 | Maximum Depth of Binary Tree | combine(left, right): 1 + max | Easy |
| 226 | Invert Binary Tree | combine: swap children, return root | Easy |
| 100 | Same Tree | recursion on two trees at once | Easy |
| 572 | Subtree of Another Tree | same_tree called at every node | Easy |
| 543 | Diameter of Binary Tree | return height, record best on the side | Easy |
| 110 | Balanced Binary Tree | return height or -1 sentinel | Easy |
| 94 | Binary Tree Inorder Traversal | inorder, recursive then with a stack | Easy |
| 700 | Search in a Binary Search Tree | BST search (lesson 17) | Easy |
| 701 | Insert into a Binary Search Tree | BST insert, re-attach the return value | Medium |
| 102 | Binary Tree Level Order Traversal | BFS level-size loop | Medium |
| 199 | Binary Tree Right Side View | BFS, keep the last node per level | Medium |
| 235 | Lowest Common Ancestor of a BST | prune with the BST order | Medium |
| 230 | Kth Smallest Element in a BST | inorder with early stop | Medium |
| 98 | Validate Binary Search Tree | min / max bounds | Medium |
| 450 | Delete Node in a BST | three removal cases, in-order successor | Medium |
Your next step today: LeetCode 104. Write it from memory, then draw the return value next to every node of [4,3,6,2,null,5,7] and check that the root gets 3.
11Self-check
Answer out loud before opening. If you cannot answer in one breath, reread that section.
Q1How is a TreeNode different from a ListNode, and what is the same?
Same: a value plus pointers to other nodes, and "no node" is None. Different: a ListNode has one pointer, next, pointing forward; a TreeNode has two, left and right, pointing down to its children, and the pointers never form a cycle.
Q2In the tree [2,1,3,null,null,null,4], what are the root, the leaves, the height in nodes, and the height in edges?
Root 2. Leaves 1 and 4 (1 is on depth 2, 4 on depth 3). Height 3 counting nodes on the path 2, 3, 4; height 2 counting the edges between them. The tree has 4 nodes and therefore 3 edges.
Q3State the BST property precisely. Why is "left child smaller, right child bigger" not enough?
Every node in the whole left subtree is smaller than the node, and every node in the whole right subtree is bigger, and this holds at every node. Checking only direct children misses cases like [5,4,7,null,6], where 6 is a fine right child of 4 but violates "everything under 5's left is below 5".
Q4Why prefer a BST over a sorted array, when both search in O(log n)?
Insert and delete. A sorted array shifts elements, O(n). A balanced BST walks down and changes a pointer, O(log n).
Q5Trace it: search for 4 in [2,1,3,null,null,null,4]. List every call and its comparison.
search(2, 4): 4 > 2, go right. search(3, 4): 4 > 3, go right. search(4, 4): equal, return True. Three calls; node 1 is never visited. True is passed back through the two outer calls.
Q6In insert, why is the recursive call written as root.left = insert(root.left, val) and not just insert(root.left, val)?
When the walk reaches None, the call returns a brand new node. The only way that node becomes part of the tree is if the parent stores the returned value in its pointer. The assignment re-attaches the subtree every time, unchanged or grown.
Q7Trace it: remove 6 from [4,3,6,2,null,5,7]. What is the tree after each step?
6 has two children. Its in-order successor is the left-most node of its right subtree: 7. Copy: node 6 now holds 7, tree is [4,3,7,2,null,5,7] with a duplicate. Then remove(right subtree, 7): the leaf 7 has no left child, return its right (None). Final: [4,3,7,2,null,5], still a valid BST.
Q8Write inorder, preorder, and postorder for [4,3,6,2,null,5,7] without looking.
Inorder [2,3,4,5,6,7] (sorted, because it is a BST). Preorder [4,3,2,6,5,7] (root first). Postorder [2,3,5,7,6,4] (root last).
Q9What is the time and space complexity of DFS on a tree, and why does space depend on the shape?
Time O(n): each node once. Space O(h): the call stack holds the current root-to-leaf path. Balanced tree, h is about log n. Skewed tree, h is n.
Q10In BFS, what does for i in range(len(queue)) guarantee, and what breaks if you drop it?
len(queue) is evaluated once, before the loop, so the inner loop processes exactly the nodes of the current level even though children are appended during it. Without it you still visit nodes in level order, but you cannot tell where one level ends and the next begins, so per-level answers (level lists, right side view) are impossible.
Q11Trace it: BFS on [4,3,6,2,null,5,7]. Give the queue at the start of each while turn.
Turn 1: [4], pop 4, append 3 and 6. Turn 2: [3, 6], pop 3 (append 2), pop 6 (append 5, 7). Turn 3: [2, 5, 7], pop all three, nothing appended. Queue is empty, loop ends. Printed order 4, 3, 6, 2, 5, 7.
Q12What is a TreeMap, what must be true about its keys, and what is the Python equivalent?
A map (key to value) stored as a BST of keys, so get, put, and remove are O(log n) and iteration is sorted by key. Keys must be comparable so the tree can decide left or right; values can be anything. Python has no built-in TreeMap: use sortedcontainers.SortedDict, or the built-in dict if you only need O(1) average lookups and do not care about order.