Trees and graphs: traversals, BFS and DFS, topological sort, union-find, Dijkstra
Before this lesson: lesson 01 (hash sets, two pointers) and lesson 02 (stack, queue, binary search, heap). Time: about 95 minutes, plus the notebook. This is a long lesson: you can stop after section "Lowest common ancestor" and continue with graphs later.
Why this matters #
Graph and tree problems are the most common "medium" problems in coding rounds. They test one skill: turn the story into nodes and edges, then pick the right template. Almost every problem uses one of six templates (tree recursion, BFS, DFS, topological sort, union-find, Dijkstra), and each one runs in linear or near-linear time in 15–30 lines.
The same structures are daily tools in ML. Autograd runs the backward pass in reverse topological order of the computation graph (Calculus lesson 05). A decision tree predicts with one root-to-leaf walk. Graph neural networks pass messages along edges (Deep Learning lesson 13): after layers, a node sees exactly its BFS ball of radius . A data pipeline with dependencies is a directed acyclic graph, and a scheduler runs it in topological order.
Notation. is the number of nodes of a tree, its height and its width (the largest number of nodes on one level). For a graph, and are the sets of nodes and edges, and also their sizes.
1. Predict first #
2. The picture #
Trees: solve left, solve right, combine #
A binary tree is either empty, or a node with a value and two children, left and right,
that are binary trees themselves. The top node is the root; a node without children is a
leaf. A tree with nodes has edges, because every node except the root has exactly
one parent. The depth of a node is the number of edges from the root to it, and the
height of the tree is the largest depth. A path of nodes has ; a balanced
tree has .
Because the definition is recursive, most tree code is recursive too: answer the question for the left subtree, answer it for the right subtree, combine the two answers at the node. The empty tree is the base case. A traversal visits every node once. Depth-first traversals differ only in when the node itself is visited:
| Traversal | Order | Typical use |
|---|---|---|
| pre-order | node, left, right | copy or serialize a tree |
| in-order | left, node, right | a binary search tree in sorted order |
| post-order | left, right, node | combine the children's answers: height, diameter, LCA |
| level order (BFS) | level by level, with a queue | anything "per level", the nearest leaf |
Choose an order and press Step (or Play). The blue node is the current one, the orange nodes are on the stack (for DFS this is the current path from the root) or in the queue (for level order), and the output grows below. Switch to Predict mode and click the node that you think comes next. Then try the trees "path" and "complete": the stack never holds more than nodes, while the queue of level order holds a whole level.
Graphs: one loop, three bags #
A graph has a set of nodes and a set of edges. An edge is directed () or undirected (), and it may carry a weight . A tree is a connected undirected graph without a cycle. In a general graph you can arrive at a node again by another path, so every search keeps a visited set.
You must choose how to store the graph. In interviews it is almost always an adjacency list.
| Representation | Memory | Neighbours of | Good for |
|---|---|---|---|
| adjacency list (list of lists, or dict) | almost everything | ||
| adjacency matrix | dense graphs, edge test | ||
| edge list | — | Kruskal, union-find, Bellman-Ford | |
| implicit (computed when needed) | nothing stored | depends | grids, word ladders, puzzle states |
Here is the degree of , its number of neighbours. Jeff Erickson calls the next loop whatever-first search: keep a bag of nodes to visit; take one out; if it is new, mark it and put its neighbours in the bag. Only the type of bag changes:
put s in the bag
while the bag is not empty:
take v out of the bag
if v is not marked:
mark v # v is now visited
for each neighbour u of v:
put u in the bag
- bag = stack (last in, first out) → depth-first search (DFS): go deep first, back up when stuck;
- bag = queue (first in, first out) → breadth-first search (BFS): all nodes at distance 1, then 2, …;
- bag = priority queue keyed by the distance so far → Dijkstra's algorithm (keyed by the weight of the last edge → Prim's minimum spanning tree).
Pick the bag and step through the same loop. Blue marks the node just taken out, aqua nodes are visited, and the thick edges form the search tree (each node is joined to the node that put it in the bag). The panel shows the bag and the order. Compare the depth of node in the three trees: DFS reaches it after 6 edges, BFS after 3, and Dijkstra finds the cheapest path, of weight 8. "Skipped" counts entries that were taken out but were already visited.
The tree has root 1, with left child 2 and right child 3; node 2 has a left child 4 and a right child 5. What is the post-order?
Post-order is left subtree, right subtree, then the node. The left subtree of 1 gives 4, 5, 2; the right subtree gives 3; then 1. The first option is pre-order, the second is in-order. The last one is the reverse of level order, a common confusion.
"Find the minimum number of moves for a knight to go from one square of a chessboard to another." Every move costs the same. Which template fits?
Every move has the same cost, so "fewest moves" is a shortest path in an unweighted graph, and BFS finds it in (theorem in section 3). Dijkstra is also correct but slower by a factor and more code. DFS finds a path, not a shortest one. Topological sort needs a directed graph without cycles; the board graph is undirected and has cycles.
3. Definitions and main results #
Traversals and their cost #
Each traversal visits every node once and does work per node: time. The extra memory differs:
- Depth-first (pre, in, post), recursive or with an explicit stack, keeps the current path from the root: at most nodes, so extra space. This is for a balanced tree and for a path.
- Level order keeps one level (and part of the next) in a queue: . A complete tree has about leaves on its last level, so .
So for a balanced tree DFS needs much less memory than BFS, and for a long path the opposite is
true. In Python, use collections.deque and popleft() for the queue: list.pop(0) costs
per pop. Recursion fails beyond about 1,000 levels (sys.getrecursionlimit()), so on deep trees
use an explicit stack (the iterative in-order code is in section 5).
The post-order template. A function returns one value per subtree (its height, its sum,
"does it contain ?"), computed from the values of its two children. If the answer is a path
that turns at a node, such as the diameter (the longest path between two nodes), the
function returns the best path going down, and a separate variable keeps the best turning path:
at each node, update best = max(best, height(left) + height(right) + 2) with height for an
empty subtree.
Binary search trees #
A binary tree is a binary search tree (BST) (strict: no equal values) if at every node, all values in the left subtree are smaller than the node's value and all values in the right subtree are larger. A search walks one path down, like binary search in a sorted array.
Theorem 1. A binary tree is a BST if and only if its in-order sequence is strictly increasing.
Proof (induction on the number of nodes; the empty tree is a BST with an empty sequence).
- For a root value with left subtree and right subtree , the in-order sequence is , then , then .
- If the tree is a BST, so are and . By induction and increase. All of is below and all of is above , so the whole sequence increases.
- If the sequence strictly increases, every value of is below , every value of is above , and and increase. By induction and are BSTs, so the tree is a BST.
Validate with intervals, not with the children. A common bug compares each node only with
its parent. Take root 5 with left child 3 and right child 8, and give 3 a right child 6. Every
parent–child pair looks correct (, , ), but 6 is in the left subtree of 5,
so the tree is not a BST. The in-order sequence shows it. The fix: pass down an open
interval . The root gets ; going left from value sets
, going right sets . Node 6 gets and fails. Use math.inf or None
for the open ends, never a "large number" such as .
Cost. Search and insertion walk one path: . If you insert keys in random order, stays on average; if you insert sorted keys, the tree becomes a path with . Balanced trees (AVL, red-black) keep always.
A candidate validates a BST with node.left.val < node.val < node.right.val at every node. On which tree does this check give the wrong answer?
In the second tree every parent–child pair passes (), but 6 is in the right subtree of 10, so it must be larger than 10. With intervals, node 6 gets and fails. In the third tree, 7 is in , so that tree really is a BST, and the local check also accepts it. The first and last trees are BSTs too.
Lowest common ancestor #
The lowest common ancestor (LCA) of nodes and is the deepest node that has both of them in its subtree. A node is in its own subtree, so the LCA of and a descendant of is .
Theorem 2 (LCA in a BST). Let be values in the BST. Start at the root. Go left while both values are smaller than the node, go right while both are larger, and stop otherwise. The stop node is the LCA. Time , extra space .
Proof.
- Invariant: and are in the subtree of the current node. It holds at the root. If both values are smaller than the node, the BST order puts both in the left subtree; the same holds on the right. So every move keeps the invariant.
- At the stop node we have . If equals or , the other one is in its subtree (invariant), so is the LCA.
- Otherwise : is in the left subtree and in the right one. No child of contains both, so is the deepest node that does.
In a general binary tree (both nodes present), use the post-order template. The function
returns None if the subtree contains neither node. At or it returns that node. If both
children return a node, the current node is the LCA; otherwise it passes up the one result.
Time , space .
Serialization with null markers, and tries #
To serialize a tree is to write it as a string from which the same tree can be rebuilt. Write
the pre-order, with a marker # for every missing child and a separator between tokens (so that
1,11 cannot be read as 11). Example: root 1 with children 2 and 3 gives 1,2,#,#,3,#,#.
Theorem 3. (a) The string of a tree with nodes has exactly markers. (b) Different trees give different strings.
Proof of Theorem 3
- (a) For , the nodes have child places. Every node except the root fills one
place, so places are filled and are empty, one marker each. The empty tree
() is the single marker
#. - (b) Parse a string like this: read the next token; for
#return the empty tree; otherwise create a node with that value, then parse its left subtree, then its right subtree. - By induction on the size of a tree : given the string of followed by any text, the parser returns and stops exactly at the end of the string of . (It reads the root token, then by induction exactly the left subtree, then exactly the right one.)
- If two trees and had the same string , both would equal the parser's output on , so .
Without markers, pre-order is ambiguous: 1, 2 can be root 1 with a left child 2 or with a right
child 2. In-order alone is also ambiguous. Pre-order plus in-order fixes a tree with distinct
values ("Construct Binary Tree from Preorder and Inorder Traversal").
A trie (prefix tree) stores strings: each edge adds one character, so each node is a prefix.
A node has a dict children and a flag is_word. The words to, tea, ted, ten, i, in, inn need
10 nodes: the root (the empty prefix) and t, to, te, tea, ted, ten, i, in, inn. For a word or
prefix of length , insert and search cost , and the question "does any word start with
this prefix?" also costs . With a Python set of words that question needs a scan of all
words, . A search must check is_word at the last node: te is a prefix, not a word.
BFS finds shortest paths in unweighted graphs #
In BFS, set ; when is taken out of the queue and a neighbour is not yet visited, set , mark as visited at once, and append it to the queue. Let be the true fewest-edges distance from .
Theorem 4 (BFS). Every node that is reachable from gets .
Proof.
- Every dist value is the length of a real path from (follow the nodes that discovered each other back to ). So .
- The queue always holds dist values in non-decreasing order, and the first and last differ by at most 1. Taking out a node with value and appending nodes with value keeps this true. So nodes leave the queue in non-decreasing order of dist.
- Induction on ; for , . For , let be the node before on a shortest path, so and, by induction, . If is not yet visited when is taken out, it gets . If it was discovered earlier, its discoverer left the queue no later than , so by step 2 it has dist , which gives . With step 1, .
Cost. Because a node is marked when it is put in the queue, each node enters the queue once, and each adjacency list is read once: time, extra memory. If you mark only when a node is taken out, the same node can enter the queue once per edge into it. (The figure uses Erickson's general loop, which marks when a node is taken out and skips nodes that are already marked. This is also correct, but the bag can grow to entries; the "skipped" counter shows those extra entries.)
DFS goes deep first and backs up when stuck. It finds a path, not a shortest one. Use it for reachability, connected components, flood fill, cycle detection and topological order. In Python, write it with a list as an explicit stack: one island in a grid can have more than 10,000 cells, far beyond the recursion limit.
Two BFS variants appear often in interviews:
- Multi-source BFS: put all sources in the queue at distance 0, as if one extra source were joined to all of them. Use it for "distance to the nearest X" and for "Rotting Oranges" (all rotten oranges spread at the same time; the answer is the largest distance).
- Grids as implicit graphs: each cell is a node with up to 4 neighbours. "Number of Islands" counts connected components: scan the cells, and start a BFS or DFS from every land cell that is not yet visited. Cost for rows and columns.
Two more BFS variants
- 0-1 BFS. If every weight is 0 or 1, use a deque: push a node to the front after a 0-edge and to the back after a 1-edge. The deque stays sorted by distance, as in step 2 of the proof, and the cost stays .
- Bidirectional BFS. Search from both ends and stop when the two frontiers meet. With branching factor and distance , it explores about nodes instead of ("Word Ladder").
Topological sort #
A topological order of a directed graph lists the nodes so that every edge has before . It exists if and only if the graph has no directed cycle, that is, if it is a directed acyclic graph (DAG). The in-degree of a node is the number of edges that come into it.
Kahn's algorithm. Count the in-degree of every node. Put all nodes of in-degree 0 in a queue. Repeat: take a node out, append it to the output, and decrease the in-degree of each successor by 1; when a successor's in-degree reaches 0, put it in the queue. Cost .
Theorem 5 (Kahn). Kahn's algorithm outputs all nodes if and only if the graph is a DAG, and then the output is a topological order.
Proof.
- Order. A node enters the queue only when its in-degree reaches 0, that is, after all its predecessors were output. So every edge has before .
- A cycle gives a short output. On a directed cycle, every node has a predecessor on the cycle that must be output first. No node of the cycle can be first, so none of them is ever output.
- A DAG gives the full output. While nodes remain, the remaining graph is still a DAG, so it has a node of in-degree 0. (Otherwise walk backwards along incoming edges: with finitely many nodes the walk repeats a node, and that is a cycle.) That node is in the queue, so the algorithm does not stop early.
So the cycle test is one line: len(order) < V means there is a cycle.
Run Kahn's algorithm on an ML pipeline. The number above each task is its current in-degree; tasks with in-degree 0 wait in the orange queue, and output tasks turn aqua. Then switch on the edge report → preprocess: it closes a cycle, the in-degrees on the cycle never reach 0, and the output stops after 1 of 6 tasks.
Kahn by levels. If each round takes out the whole current queue, the rounds are the batches of tasks that can run in parallel, and the number of rounds equals the number of nodes on the longest path. ("Parallel Courses" asks for this number.)
The DFS version. Run DFS and record each node when it finishes (after all its successors). The reverse of the finishing order is a topological order. For cycle detection, DFS needs three colours: white (not seen), grey (on the current DFS path) and black (finished). An edge to a grey node closes a cycle. Two colours are not enough: in the "diamond" , , node 3 is reached twice without any cycle.
Kahn's algorithm runs on the edges , , , . What does it output?
In-degrees: , (from and ), , . After is output, has in-degree 1 and never reaches 0, because its other predecessor is on the cycle . depends on , so it is blocked too. The output has 1 of 4 nodes: fewer than means a cycle.
Union-find #
Union-find (also called disjoint set union, DSU) keeps a partition of the elements
into disjoint sets. find(x) returns the representative (the root) of the set
of ; union(a, b) merges the sets of and . The sets are stored as a forest of parent
pointers; a root points to itself. Two rules make it fast:
- Union by rank: put the root of smaller rank under the root of larger rank. On a tie, put the root of under the root of and increase the rank of 's root by 1. The rank is an upper bound on the height of the tree.
- Path compression: after
find(x), make every node on the path from point directly to the root. This is correct because it only moves pointers within one tree: every node still reaches the same root, so no set changes.
Lemma (union by rank alone). A root of rank has at least nodes in its tree.
Proof.
- : a single node, and .
- A root's rank grows to only when two roots of rank are joined. By induction each tree has at least nodes, so the new tree has at least .
- A tree never loses nodes, so the bound stays true. Hence the rank, and the height, are at most
, and
findcosts .
With both rules, operations cost , where is the inverse Ackermann function (Tarjan, 1975; we state this without proof). for any you will ever store, so in an interview say "almost constant time per operation, amortized".
Step through the script, or click two nodes to union them (click one node and press find to
run find on it). The arrows are parent pointers; roots have a ring. Switch the two rules off and
run the script "chain": the tree becomes a path and find(0) follows 7 pointers. With union by rank
the same unions build a flat tree; with path compression, every find also flattens its path.
Compare the counter of pointer steps.
When to use it. Connectivity when edges arrive one by one ("are and connected now?"); cycle detection in an undirected graph (an edge whose two ends already have the same root closes a cycle: "Redundant Connection"); grouping equal things ("Accounts Merge"); and Kruskal's algorithm for a minimum spanning tree (sort the edges by weight, keep an edge when its two ends are in different sets). Union-find cannot delete edges.
Dijkstra's algorithm #
Dijkstra solves single-source shortest paths when every weight is . Keep dist
( everywhere, except ) and a min-heap of pairs
. Take out the smallest pair. If it is stale (its distance is larger
than the current ), skip it. Otherwise relax every edge : if
, set to the smaller value and push
the new pair. Python's heapq has no "decrease-key" operation, so you push a duplicate and skip the
old pair later: this is called lazy deletion. Let be the true weighted distance.
Theorem 6 (Dijkstra). If all weights are , then when a node is taken out of the heap (and the pair is not stale), .
Proof.
- Suppose not. Let be the first node taken out with , and let be a shortest path from to .
- Let be the first node on that is not yet taken out, and the node just before it on ( is taken out first, with distance 0, so exists). Since was taken out before , , and its edge to was relaxed: . The heap holds a pair for with this distance.
- All weights are , so the part of after has length . Hence .
- So . But the heap chose while the pair of was inside, so : a contradiction. (If , step 2 already gives .)
Step 3 is exactly where a negative edge breaks the proof. Cost: at most pushes, each (because ): time and memory.
Step through Dijkstra. The aqua nodes are settled (taken out of the heap, distance final); the number in each node is its current distance. Click an edge label, then move the slider to change its weight. The preset "negative edge" gives the weight : Dijkstra settles too early, and the panel compares its result with Bellman-Ford, which is correct.
What to use for other weights:
| Weights | Algorithm | Time |
|---|---|---|
| all equal | BFS | |
| 0 or 1 | 0-1 BFS (deque) | |
| all | Dijkstra with a binary heap | |
| any, on a DAG | relax the edges in topological order | |
| any, no negative cycle | Bellman-Ford: rounds that relax every edge; if a -th round still improves a distance, there is a negative cycle |
Dijkstra (each node is final when it leaves the heap) runs from on (2), (3), (), (1). Which distances does it return for and ?
leaves the heap first with distance 2 and becomes final; gets . Only later (distance 3) finds the path of cost 1, but is already final. The true distances are and (the first option), which Bellman-Ford finds. The third option mixes the two: it would need to be updated without passing the change on to .
Other path costs, and minimum spanning trees
Dijkstra works for other path costs if two conditions hold: extending a path never lowers its cost, and of two paths to the same node, the cheaper one stays at least as cheap after the same next edge. "The largest single step on the path" satisfies both ("Path With Minimum Effort": use instead of ). A cost that depends on the previous edge (a turn penalty) breaks the second condition; then the state must be (node, last edge), not the node.
A minimum spanning tree (MST) connects all nodes of a connected undirected graph with the smallest total weight. Cut property: for any split of the nodes into two groups, a lightest edge that crosses the split belongs to some MST. Kruskal (sort the edges, keep an edge when union-find joins two different sets) costs . Prim grows one tree with a heap keyed by edge weight: the whatever-first loop with a priority queue keyed by the last edge.
Answers to section 1. (1) No. DFS finds some path, which can be long. On the cycle , DFS from may reach through and (3 edges), although the edge is 1 step. In the second figure, DFS reaches after 6 edges and BFS after 3. BFS gives the fewest edges because it discovers nodes in order of distance (Theorem 4). (2) Dijkstra reports : leaves the heap with 2 and is final before is processed. The true distance is 1, through . Step 3 of the proof fails: the rest of the path after has negative length. Use Bellman-Ford. (A lazy version that lets a final node be improved again would fix this small case, but then "taken out means final" is false, and on bad inputs the work can grow exponentially; see Erickson §8.6.)
4. Worked example #
(a) One tree, all the views.
4
/ \
2 6
/ \ \
1 3 7
- Traversals. Pre-order: node, left, right gives
4 2 1 3 6 7. In-order: left, node, right gives1 2 3 4 6 7. Post-order: left, right, node gives1 3 2 7 6 4. Level order:[[4], [2, 6], [1, 3, 7]]. - BST? The in-order sequence
1 2 3 4 6 7strictly increases, so by Theorem 1 this is a BST. - Height and space. The height is edges ("maximum depth" in LeetCode counts nodes: 3). A DFS keeps at most nodes on its stack; level order keeps up to 3 nodes (the last level) in its queue.
- Serialization.
4,2,1,#,#,3,#,#,6,#,7,#,#: values and markers. - LCA in the BST. : at 4 both are smaller, go left; at 2 we have , stop: LCA 2. : at 4, , stop: LCA 4. : at 4 both are larger, go right; at 6 the node equals , stop: LCA 6.
- Diameter. At node 4, the left subtree has height 1 and the right subtree has height 1, so the path through 4 has edges: . No other node gives more.
(b) Dijkstra on a small graph. Directed edges (4), (1), (2), (1), (5), (3). Start at . Distances are listed in the order .
| Take out | Action | dist | Heap after |
|---|---|---|---|
| relax: , | |||
| relax: (improved), | |||
| relax: (improved) | |||
| stale (), skip | same | ||
| relax: | |||
| stale (), skip | same | ||
| no outgoing edges | same | empty |
The shortest-path tree is . Look at : it is first pushed with 4 (through ), then improved to 3 (through ). This is why Dijkstra must not treat a node as final when it is pushed. That wrong version keeps , and .
(c) Kahn on a pipeline. Tasks: download → preprocess; preprocess → train; preprocess → stats; train → evaluate; evaluate → report; stats → report. In-degrees: download 0, preprocess 1, train 1, stats 1, evaluate 1, report 2. The queue starts with download. Output download: preprocess drops to 0. Output preprocess: train and stats drop to 0. Output train: evaluate drops to 0. Output stats: report drops to 1. Output evaluate: report drops to 0. Output report. All 6 tasks are output, so there is no cycle. By levels: {download}, {preprocess}, {train, stats}, {evaluate}, {report}, that is 5 rounds, the number of nodes on the longest path.
5. In code #
Python's standard library plus SciPy for the check. The code reproduces the worked examples.
from collections import deque
import heapq
import math
from scipy.sparse import csr_matrix
from scipy.sparse.csgraph import dijkstra as scipy_dijkstra
class Node:
def __init__(self, val, left=None, right=None):
self.val, self.left, self.right = val, left, right
root = Node(4, Node(2, Node(1), Node(3)), Node(6, None, Node(7))) # the tree of (a)
def inorder_iterative(root):
"""In-order with an explicit stack: O(n) time, O(h) extra space."""
out, stack, node = [], [], root
while stack or node:
while node: # go left as far as possible
stack.append(node)
node = node.left
node = stack.pop() # the smallest value not visited yet
out.append(node.val)
node = node.right # its right subtree comes next
return out
def is_bst(node, low=-math.inf, high=math.inf):
"""Every value must lie in the open interval (low, high) passed down from above."""
if node is None:
return True
if not (low < node.val < high):
return False
return is_bst(node.left, low, node.val) and is_bst(node.right, node.val, high)
def serialize(node):
"""Pre-order with a marker # for every missing child."""
if node is None:
return ["#"]
return [str(node.val)] + serialize(node.left) + serialize(node.right)
def lca_bst(root, p, q):
"""Walk down while p and q are on the same side. O(h) time, O(1) space."""
node = root
while True:
if p < node.val and q < node.val:
node = node.left
elif p > node.val and q > node.val:
node = node.right
else:
return node.val
print(inorder_iterative(root), is_bst(root)) # [1, 2, 3, 4, 6, 7] True
print(",".join(serialize(root))) # 4,2,1,#,#,3,#,#,6,#,7,#,#
print(lca_bst(root, 1, 3), lca_bst(root, 3, 7), lca_bst(root, 6, 7)) # 2 4 6
bad = Node(5, Node(3, None, Node(6)), Node(8)) # 6 is in the left subtree of 5
print(inorder_iterative(bad), is_bst(bad)) # [3, 6, 5, 8] False
def kahn(n, edges):
"""Topological order of nodes 0..n-1, or [] if there is a cycle. O(V + E)."""
succ, indeg = [[] for _ in range(n)], [0] * n
for u, v in edges:
succ[u].append(v)
indeg[v] += 1
queue = deque(u for u in range(n) if indeg[u] == 0)
order = []
while queue:
u = queue.popleft()
order.append(u)
for v in succ[u]:
indeg[v] -= 1
if indeg[v] == 0: # the last prerequisite of v is done
queue.append(v)
return order if len(order) == n else []
# 0 download, 1 preprocess, 2 train, 3 stats, 4 evaluate, 5 report
pipeline = [(0, 1), (1, 2), (1, 3), (2, 4), (4, 5), (3, 5)]
print(kahn(6, pipeline)) # [0, 1, 2, 3, 4, 5]
print(kahn(6, pipeline + [(5, 1)])) # [] (report -> preprocess closes a cycle)
def dijkstra(n, edges, s):
"""Dijkstra with a binary heap and lazy deletion. Weights must be >= 0."""
adj = [[] for _ in range(n)]
for u, v, w in edges:
adj[u].append((v, w))
dist = [math.inf] * n
dist[s] = 0
heap = [(0, s)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]:
continue # stale entry: u was improved after this push
for v, w in adj[u]:
if d + w < dist[v]: # relax the edge u -> v
dist[v] = d + w
heapq.heappush(heap, (dist[v], v))
return dist
edges = [(0, 1, 4), (0, 2, 1), (2, 1, 2), (1, 3, 1), (2, 3, 5), (3, 4, 3)] # (b), A..E = 0..4
print(dijkstra(5, edges, 0)) # [0, 3, 1, 4, 7]
u, v, w = zip(*edges)
print(scipy_dijkstra(csr_matrix((w, (u, v)), shape=(5, 5)), directed=True, indices=0))
# [0. 3. 1. 4. 7.]
is_bst and serialize are recursive, which is fine for interview-size trees; for trees deeper
than about 1,000 levels, use an explicit stack as in inorder_iterative.
6. Where it shows up #
- Autograd and schedulers. The backward pass must finish a node's gradient before it passes it on: a reverse topological order of the computation graph. PyTorch's autograd engine counts the dependencies of each node and runs a node when its count reaches 0, which is Kahn's in-degree idea. Workflow tools (Airflow, Slurm job dependencies, build systems) run DAGs of tasks in the same way.
- Graph neural networks and ML for science. Message passing on molecules, meshes and particle systems (MeshGraphNets for PDE simulation, GraphCast for weather on a multi-mesh). layers of message passing see the -hop neighbourhood of a node: the BFS ball of radius .
- Trees in ML. A decision tree predicts with one root-to-leaf walk; gradient-boosted trees sum
many such walks. k-d trees and ball trees (
sklearn.neighbors.KDTree,BallTree) answer nearest-neighbour queries; Barnes-Hut octrees compute -body forces in . - Tries in language models. Tokenizers store their vocabulary in a trie for longest-match lookups, and constrained decoding (for example, generating only valid entity names) walks a prefix tree of the allowed outputs.
- Clustering and manifolds. Connected components of a k-nearest-neighbour graph give clusters; single-linkage clustering is Kruskal's algorithm stopped early. Isomap approximates geodesic distances by shortest paths (Dijkstra) on a k-NN graph.
7. Common mistakes #
- BFS marks a node when it is taken out, not when it is put in. The same node then enters the queue many times. Also: forgetting to mark the start node.
- Checking a BST only against the parent. Pass the interval down; use
math.inffor the open ends. - Recursion on deep trees or big grids gives
RecursionErrorin Python. Use an explicit stack. - BFS with
list.pop(0), which costs per pop. Usecollections.deque. - An undirected graph stored in one direction only. Add both and .
- Two colours for directed cycle detection. A diamond then looks like a cycle; use three colours, or Kahn's count.
- Dijkstra that finalizes a node when it is pushed (wrong distances, worked example (b)), that does not skip stale entries (wasted work), or that runs on negative weights.
- Union-find without either rule: a chain of unions makes one
findcost . - Grids: mixing
(row, col)with(x, y), missing bounds checks, or changing the caller's grid.
8. Check yourself #
- Give the level order and the in-order of the tree with root 8, children 3 and 10, where 3 has
children 1 and 6, and 10 has a right child 14. Is it a BST?
Answer
Level order[[8], [3, 10], [1, 6, 14]]. In-order1 3 6 8 10 14. It strictly increases, so the tree is a BST (Theorem 1). - How many
#markers does the pre-order serialization of a 10-node tree contain? Why?Answer
11. The 10 nodes have 20 child places; 9 are filled (every node except the root is a child once), so 11 are empty. - A grid has 3 rows and 4 columns, with moves up, down, left and right and no walls. How many nodes
and undirected edges does its graph have?
Answer
12 nodes. Edges: horizontal plus vertical, so 17. In general , and BFS costs . - How many topological orders does the DAG , , , have?
Answer
Two: and . Kahn's queue holds both and at the same time; either can go first. - Union-find on with union by rank (on a tie, the root of goes under the root of
). Run
union(0,1),union(2,3),union(0,2). Giveparentandrank, thenparentafterfind(3)with path compression.Answer
union(0,1): tie, 1 under 0, rank of 0 becomes 1.union(2,3): 3 under 2, rank of 2 becomes 1.union(0,2): tie at rank 1, 2 under 0, rank of 0 becomes 2. Soparent = [0, 0, 0, 2],rank = [2, 0, 1, 0].find(3)follows 3 → 2 → 0 and compresses:parent = [0, 0, 0, 0]. - All weights are 0 or 1. Which algorithm do you use, and what does it cost?
Answer
0-1 BFS with a deque (0-edges to the front, 1-edges to the back): . Dijkstra is also correct, but costs .
Extra space of a depth-first traversal and of level order on a binary tree?
Depth-first: , the current path from the root. Level order: , the largest level (about for a complete tree).
How do you validate a binary search tree?
Pass an open interval down: the root gets ; going left from sets , going right sets . Every value must lie inside its interval.
How do you find the lowest common ancestor of in a BST?
From the root, go left while both are smaller, go right while both are larger; the node where they split (or equal one of them) is the LCA. time.
Why does a pre-order serialization need null markers?
Without them the string is ambiguous (
1, 2can be a left or a right child). With a marker for every missing child, the parser rebuilds exactly one tree.In whatever-first search, what decides the algorithm?
The bag: a stack gives DFS, a queue gives BFS, a priority queue keyed by distance gives Dijkstra.
When does BFS give shortest paths, and when must it mark a node?
In unweighted graphs (all edges cost the same). Mark a node when it is put in the queue, so that each node enters once: .
How does Kahn's algorithm detect a cycle?
It outputs fewer than nodes: the nodes on a directed cycle never reach in-degree 0.
What are the two optimizations of union-find, and what do they give together?
Union by rank and path compression; together amortized per operation, almost constant.
What does Dijkstra need, and what does it cost with a binary heap?
All edge weights . time with lazy deletion.
Shortest paths with negative edge weights: which algorithm?
Bellman-Ford, : rounds of relaxing all edges; a -th round that still improves shows a negative cycle. On a DAG: relax in topological order, .
9. Interview angle #
Recognize the pattern from the wording:
| The problem says | Try |
|---|---|
| depth, balanced, diameter, path sum | post-order function that returns a value; a global best for turning paths |
| per level, right side view, zigzag, minimum depth | BFS with a deque, one level per round |
| BST: validate, -th smallest, range | sorted in-order, or pass down |
| lowest common ancestor | BST: walk down by value, ; general tree: post-order, |
| serialize, rebuild from traversals | pre-order with null markers; pre-order plus in-order |
| prefix, autocomplete, dictionary with wildcards | trie (DFS on the trie for wildcards) |
| fewest steps, moves or transformations; all steps cost the same | BFS (multi-source if there are many starts) |
| count the groups, islands, provinces; is everything reachable | DFS or BFS from every unvisited node, or union-find |
| prerequisites, build order, dependencies, is there a cycle | topological sort (Kahn) |
| edges arrive over time; which edge is redundant | union-find |
| cheapest path, weights | Dijkstra; negative weights or "at most edges" → Bellman-Ford rounds |
| connect everything at minimum cost | minimum spanning tree (Kruskal or Prim) |
The routine to say aloud. (1) Ask: directed or undirected? Weighted? Can it be disconnected? How large are and (or and )? (2) Say what a node and an edge are. (3) Name the template and its cost. (4) For a tree: say what your function returns for one subtree, and the space . (5) Test aloud on an empty input, one node, and a path or a cycle.
Typical questions and short model answers.
- "BFS or DFS: when do you use which?" — BFS when I need the fewest steps in an unweighted graph, or anything per level; it uses memory, the width of the frontier. DFS when I only need reachability, components, cycle detection or a topological order; it uses memory for the current path, and it is shorter to write. Both are . In Python I write DFS with an explicit stack, because the recursion limit is about 1,000.
- "Why does Dijkstra fail with negative weights?" — The proof says: when a node leaves the heap, any other path to it must pass through a node still in the heap, whose distance is already at least as large; the rest of that path only adds weight. A negative edge breaks the last step. For example, (2), (3), (): is finalized at 2, but the true distance is 1. With negative weights I use Bellman-Ford, , or relaxation in topological order on a DAG.
- "How do you detect a cycle in a directed graph? In an undirected graph?" — Directed: Kahn's algorithm outputs fewer than nodes, or DFS finds an edge to a grey node (three colours). Undirected: union-find (an edge whose ends already share a root), or DFS that meets a visited node that is not the parent.
- "Explain union-find and its cost." — A forest of parent pointers;
findwalks to the root,unionlinks two roots. Union by rank keeps the height at most , because a root of rank has at least nodes. Path compression points the walked nodes at the root. Together: amortized, almost constant. - "Validate a BST." — Pass an open interval down; the local parent check is wrong (I give the tree 5, 3, 8 with 6 under 3). Equivalent: the in-order sequence must strictly increase. time, space.
Typical follow-ups. "Return the path itself": keep a parent array and walk back from the target. "Without recursion": an explicit stack. "Many LCA queries": binary lifting, preparation and per query. "The graph does not fit in memory": BFS frontier by frontier, or bidirectional search. "Edges are deleted": union-find cannot do this; recompute, or process the operations in reverse order as additions.
10. Practice #
The notebook for this lesson runs in Colab. Each exercise has a check cell that compares your code with a library or a brute force on random inputs, and a folded solution:
level_order(root): the values level by level, with a deque.is_valid_bst(root): the interval method, on random trees (some BSTs, some broken ones).num_islands(grid): BFS or DFS on a grid; checked againstscipy.ndimage.label.course_order(n, prerequisites): Kahn's algorithm,[]on a cycle; checked withgraphlib.TopologicalSorter.dijkstra(n, edges, s): withheapqand lazy deletion; checked againstscipy.sparse.csgraph.dijkstra.
Timed practice (25 minutes each, talking aloud): Binary Tree Level Order Traversal, Validate Binary Search Tree, Lowest Common Ancestor of a Binary Tree, Serialize and Deserialize Binary Tree, Number of Islands, Rotting Oranges, Course Schedule II, Redundant Connection, Network Delay Time.
Say it aloud (2 minutes, no notes): prove that BFS finds shortest paths in an unweighted graph, then explain which step of the Dijkstra proof needs non-negative weights.
11. Watch and read #
- Jeff Erickson, Algorithms, free PDF: ch. 5 (graphs, whatever-first search), ch. 6 (depth-first search, topological sort), ch. 7 (minimum spanning trees), ch. 8 (shortest paths: BFS, Dijkstra also with negative edges §8.6, Bellman-Ford).
- MIT 6.006 Spring 2020, lecture notes and recitations (the course of the videos above).
- cp-algorithms, Disjoint Set Union: union by rank or size, path compression, and the complexity results.
- Sedgewick and Wayne, Algorithms, §3.2 Binary Search Trees: the cost of search in terms of the height, and the in-order traversal.
- Problem lists: Grind 75 (topics Binary Tree, Binary Search Tree, Graph) and NeetCode 150 (sections Trees, Tries, Graphs, Advanced Graphs).
Runs in your browser with nothing to install. The notebook repeats the key demos and has exercises with checks; solutions are inside, folded.
No Google account needed: Python runs inside your browser. The first start downloads about 40 MB; your changes are saved in this browser only.