Home/SWE Hub/Trees & Graphs
Back
๐ŸŒณ

Algorithms

Trees & Graphs

Trees and graphs are the most heavily tested data structure category at FAANG โ€” Google especially. BFS and DFS are the two fundamental traversal strategies, and every tree/graph problem reduces to one of them or a combination. Understanding WHEN to use BFS (shortest path in unweighted graphs, level-order), WHEN to use DFS (structural analysis, cycle detection, backtracking), and WHEN to use Dijkstra (shortest path in weighted graphs) is the core skill.

Recursion and call stackBasic Python/Java syntaxHash maps and queuesBig-O notationBFSDFSBinary TreeBSTGraphTopological SortDijkstraUnion-FindLCATrie

Core Concepts

  • 1BFS โ€” why it finds shortest paths in unweighted graphs: BFS explores nodes in order of their distance from the source. It uses a queue (FIFO) to ensure that all nodes at distance d are processed before any node at distance d+1. This layer-by-layer expansion is precisely why BFS discovers the shortest path first โ€” the first time BFS reaches a node, it has taken the fewest possible edges to get there. In a weighted graph where all edges have equal weight, BFS gives shortest paths. In trees, BFS gives level-order traversal. Key implementation: use a deque (collections.deque) and a 'visited' set (or modify the grid/matrix in-place) to avoid revisiting nodes. O(V+E) time and space.
  • 2DFS โ€” structure and the call stack: DFS explores as deep as possible before backtracking. Recursive DFS uses the implicit call stack; iterative DFS uses an explicit stack. In trees, DFS gives preorder (root, left, right), inorder (left, root, right), and postorder (left, right, root) traversals. Inorder of a BST gives sorted order โ€” this is the key BST property. Preorder is useful for serialization (you can reconstruct the tree). Postorder is useful when you need children's results before the parent's (e.g., computing subtree heights, aggregating values up the tree). DFS is O(V+E) time and O(V) space for the call stack (O(h) for trees where h is height).
  • 3BST operations and the BST invariant: every node in a BST satisfies: all nodes in its left subtree have smaller values, all nodes in its right subtree have larger values. This invariant enables O(h) search, insertion, and deletion (h = tree height; O(log n) for balanced trees, O(n) worst case for degenerate trees). Validation (LC 98) requires passing min/max bounds down the recursion โ€” checking left.val < node.val is NOT sufficient because the constraint must hold for the entire subtree. Deletion is the trickiest operation: if the node has two children, replace it with the inorder successor (smallest node in right subtree) and delete the successor from the right subtree.
  • 4Lowest Common Ancestor (LCA): for a binary tree (not necessarily BST), the LCA of nodes p and q is the deepest node that has both p and q as descendants. The recursive approach: if root is None, p, or q โ€” return root. Recurse on left and right subtrees. If both return non-None, root is the LCA. If only one returns non-None, that subtree contains both p and q (they are nested). This bottom-up DFS is O(n). For BSTs specifically, use the BST property: if both p and q are less than root, go left; if both greater, go right; otherwise root is the LCA โ€” O(h) time.
  • 5Serialize and deserialize binary tree: the key insight is that preorder traversal with null markers uniquely identifies the tree. Serialization: preorder DFS, output node values with 'N' for null, comma-separated. Deserialization: process values left-to-right with a recursive function that consumes the next value for the current node. If the value is 'N', return None (null node). The recursive call structure mirrors the preorder construction. BFS-based serialization is also valid and matches LeetCode's display format. O(n) time and space for both operations.
  • 6Topological sort โ€” Kahn's algorithm (BFS) vs DFS: topological sort orders nodes such that for every directed edge uโ†’v, u appears before v. Only possible in DAGs (directed acyclic graphs). Kahn's algorithm: compute in-degrees of all nodes. Add all nodes with in-degree 0 to a queue. Repeatedly dequeue a node, add to result, and decrement neighbors' in-degrees โ€” if any reach 0, enqueue them. If result size < total nodes, a cycle exists. DFS approach: DFS on all unvisited nodes; after fully processing a node (all descendants visited), push it to a stack. Result is the reverse of the stack's pop order. Cycle detection: if you encounter a node in the 'currently being visited' set (gray node), a cycle exists. O(V+E) for both.
  • 7Dijkstra's algorithm โ€” why it works: Dijkstra finds shortest paths from a source to all nodes in a weighted graph with non-negative edge weights. It uses a min-heap (priority queue) keyed by tentative distance. The key invariant: when a node is popped from the heap, its distance is finalized (no shorter path can exist). This works because all edge weights are non-negative โ€” relaxing an edge can only make distances smaller, but we've already confirmed the popped node's distance is minimal. For negative weights, Bellman-Ford is needed. O((V+E) log V) with a binary heap. In Python: use heapq with (distance, node) tuples. Always check if the popped distance matches the current best distance โ€” skip if stale.
  • 8Union-Find (Disjoint Set Union): efficiently manages a partition of elements into disjoint sets with two operations: find(x) returns the representative (root) of x's set; union(x, y) merges the sets containing x and y. Two key optimizations: (1) Path compression: during find, make every node on the path point directly to the root. (2) Union by rank: always attach the shorter tree under the taller tree's root. Together, these give amortized O(ฮฑ(n)) per operation where ฮฑ is the inverse Ackermann function โ€” effectively O(1). Use cases: connected components, cycle detection in undirected graphs (if union returns false โ€” nodes already connected โ€” there's a cycle), Kruskal's MST algorithm.
  • 9BFS vs DFS vs Dijkstra โ€” when to use which: BFS for shortest path in UNWEIGHTED graphs (or graphs where all edge weights are equal) โ€” O(V+E). Dijkstra for shortest path in WEIGHTED graphs with non-negative weights โ€” O((V+E) log V). DFS for: tree structure problems (height, diameter, path sums), cycle detection, connected components, topological ordering, backtracking. Union-Find for: dynamic connectivity queries, cycle detection in undirected graphs, minimum spanning tree (Kruskal's). A common mistake: using DFS to find shortest paths โ€” DFS finds A path, not necessarily the shortest. Another mistake: using Dijkstra on graphs with negative weights โ€” use Bellman-Ford or SPFA instead.

Key Patterns & Templates

BFS template: queue=deque([start]); visited={start}; while queue: node=queue.popleft(); for nb in graph[node]: if nb not in visited: visited.add(nb); queue.append(nb)
DFS recursive: def dfs(node): if not node: return base_val; left=dfs(node.left); right=dfs(node.right); return combine(node.val, left, right)
DFS iterative: stack=[root]; while stack: node=stack.pop(); process(node); stack.extend(children)
Dijkstra: dist={src:0}; heap=[(0,src)]; while heap: d,u=heappop(heap); if d>dist[u]: continue; for v,w in adj[u]: if dist[u]+w<dist.get(v,inf): dist[v]=dist[u]+w; heappush(heap,(dist[v],v))
Union-Find: parent=[i for i in range(n)]; rank=[0]*n; find with path compression; union by rank
Topological sort (Kahn's): indegree=[0]*n; compute indegrees; q=deque(nodes with indegree 0); while q: u=q.popleft(); result.append(u); for v in adj[u]: indegree[v]-=1; if indegree[v]==0: q.append(v)
Level-order BFS: result=[]; queue=deque([root]); while queue: level=[]; for _ in range(len(queue)): node=queue.popleft(); level.append(node.val); add children; result.append(level)
LCA: if not root or root==p or root==q: return root; left=lca(root.left,p,q); right=lca(root.right,p,q); return root if left and right else left or right

Worked Examples

There are n courses labeled 0 to n-1. Given a list of prerequisite pairs [course, pre], determine if it is possible to finish all courses (i.e., the graph is a DAG with no cycles).

1Step 1 โ€” Model as a directed graph: create an adjacency list and an in-degree array. For each [course, pre] pair, add edge pre โ†’ course (completing 'pre' enables 'course'). Increment in-degree of course.
2Step 2 โ€” Initialize Kahn's BFS: add all courses with in-degree 0 to the queue. These are courses with no prerequisites โ€” they can be taken immediately.
3Step 3 โ€” Process the queue: dequeue course u, add to result, and for each neighbor v (course that requires u as a prerequisite), decrement in-degree of v. If in-degree of v reaches 0, add v to the queue.
4Step 4 โ€” Detect cycle: if the result list contains all n courses, a valid ordering exists (return True). If result length < n, some courses were never reachable (they are in a cycle where all remaining nodes have in-degree > 0 and no one can go first).
5Step 5 โ€” Complexity: O(V+E) where V=n courses and E=number of prerequisites. Space O(V+E) for the adjacency list and in-degree array.
โœ“ Time O(V+E), Space O(V+E). The key insight: in a cycle, every node has at least one incoming edge from within the cycle, so their in-degrees never reach 0 and they are never added to the queue. The cycle acts as a 'deadlock' that prevents those nodes from ever being processed. The same logic extends to LC 210 (return the actual order) and LC 2115 (find all possible recipes).

Common Mistakes

โš 

Using DFS to find shortest paths: DFS finds a path, not necessarily the shortest path. Always use BFS (unweighted) or Dijkstra (weighted non-negative) for shortest path problems. The only exception: in DAGs you can use topological sort + dynamic programming for shortest/longest paths.

โš 

Forgetting the 'stale entry' check in Dijkstra: Python's heapq doesn't support decrease-key, so when you update a distance, the old (larger) distance entry remains in the heap. Without 'if d > dist[u]: continue', you process the same node multiple times โ€” it's still correct but O(E log E) instead of O(E log V). Always add this check.

โš 

Validating BST by comparing only adjacent nodes: checking node.val > node.left.val and node.val < node.right.val at each node is WRONG. The value 3 in the right subtree of a node with value 5 must be > 5, not just > its direct parent. The correct approach passes (min_val, max_val) bounds down the recursion and validates each node against its inherited bounds.

โš 

Not adding to 'visited' before enqueuing in BFS: add nodes to the visited set WHEN YOU ENQUEUE THEM, not when you dequeue them. If you add on dequeue, the same node can be enqueued multiple times before it's processed, causing O(V^2) behavior in dense graphs or infinite loops in graphs with cycles.

โš 

Union-Find: forgetting path compression or using it without the recursive form: iterative path compression (two-pass: find root, then set all nodes to point to root) works but is verbose. The one-line recursive form 'self.parent[x] = self.find(self.parent[x])' achieves path compression automatically. Without path compression, union-find degrades to O(log n) per operation instead of O(ฮฑ(n)).

Only works in the Electron app

<webview> is an Electron-only tag. Run npm run electron:dev to use this.

25:00Focus
0

25 min focus ยท 5 min break ยท long break every 4 sessions

The Ultimate Grind