Trees and Graphs

SA
StudyAI
AI-generated study notes
· Published Updated Pro Plus model

From the Leetcode problems (python) curriculum

TL;DR

You'll know how to traverse any tree or graph using DFS and BFS, when to pick recursion versus an explicit stack, and how to detect cycles or find shortest paths. You'll see the four traversal patterns that solve 80% of Leetcode tree/graph problems. You'll also know the classic bugs that break submissions on edge cases.

1. The Mental Model

A tree is just a graph with no cycles and one path between any two nodes. Every traversal problem is really a question: "do I need to go deep first, or spread out level by level?" DFS answers questions about paths and structure; BFS answers questions about distance and layers. If the question mentions "shortest" or "level," think BFS; if it mentions "path" or "all combinations," think DFS.

2. The Core Material

2.1 DFS — recursion, two stacks, and the three orders

Close-up of multiple computer CPUs stacked on a wooden surface, showcasing technology components.
Photo by Shawn Stutzman on Pexels

DFS goes as deep as possible before backtracking. On trees, it comes in three flavors depending on when you process the current node relative to its children.

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

def preorder(root):   # root, left, right — good for copying/serializing
    if not root:
        return []
    return [root.val] + preorder(root.left) + preorder(root.right)

def inorder(root):    # left, root, right — gives sorted order on a BST
    if not root:
        return []
    return inorder(root.left) + [root.val] + inorder(root.right)

def postorder(root):  # left, right, root — good for deletion/aggregation
    if not root:
        return []
    return postorder(root.left) + postorder(root.right) + [root.val]

Recursion is clean but costs call-stack space — O(h) where h is tree height. On a skewed tree, h = n, and Python's default recursion limit (1000) will blow up. The iterative version with an explicit stack avoids that:

def preorder_iterative(root):
    if not root:
        return []
    stack, out = [root], []
    while stack:
        node = stack.pop()
        out.append(node.val)
        if node.right:   # push right first so left pops first
            stack.append(node.right)
        if node.left:
            stack.append(node.left)
    return out

On graphs, DFS needs a visited set because cycles exist. Forgetting it is the single most common bug in this topic.

def dfs_graph(graph, start):
    visited = {start}
    stack = [start]
    order = []
    while stack:
        node = stack.pop()
        order.append(node)
        for nxt in graph[node]:
            if nxt not in visited:
                visited.add(nxt)
                stack.append(nxt)
    return order

2.2 BFS — the shortest-path workhorse

BFS explores level by level using a queue (collections.deque, never a plain list — popping from the front of a list is O(n)). It's the only correct tool when you need shortest path in an unweighted graph, because it guarantees you visit nodes in increasing distance order.

from collections import deque

def bfs_shortest_path(graph, start, target):
    if start == target:
        return 0
    visited = {start}
    queue = deque([(start, 0)])
    while queue:
        node, dist = queue.popleft()
        for nxt in graph[node]:
            if nxt == target:
                return dist + 1
            if nxt not in visited:
                visited.add(nxt)
                queue.append((nxt, dist + 1))
    return -1

Key detail: mark nodes visited when you enqueue them, not when you dequeue them. If you mark on dequeue, the same node can be pushed multiple times by different neighbors before it's processed, wasting work and in dense graphs causing real slowdowns.

2.3 Graph representations and cycle detection

A person points at graph on laptop, analyzing stock trends for investment insights.
Photo by AlphaTradeZone on Pexels

Most Leetcode graph problems give you an edge list; you convert it to an adjacency list first — this is non-negotiable for anything beyond trivial size.

from collections import defaultdict

def build_graph(n, edges, directed=False):
    graph = defaultdict(list)
    for u, v in edges:
        graph[u].append(v)
        if not directed:
            graph[v].append(u)
    return graph

Cycle detection differs for directed vs undirected graphs — this trips people up constantly:

  • Undirected graph: a cycle exists if, during DFS, you reach an already-visited node that is not the immediate parent.
  • Directed graph: you need three states (white/unvisited, gray/in current recursion path, black/fully done). A cycle exists if you ever hit a gray node — that means you've looped back into your own current path.
def has_cycle_directed(graph, n):
    WHITE, GRAY, BLACK = 0, 1, 2
    color = [WHITE] * n

    def dfs(node):
        color[node] = GRAY
        for nxt in graph[node]:
            if color[nxt] == GRAY:
                return True
            if color[nxt] == WHITE and dfs(nxt):
                return True
        color[node] = BLACK
        return False

    return any(color[i] == WHITE and dfs(i) for i in range(n))

This same gray/black idea underlies topological sort (Kahn's algorithm using in-degrees + BFS, or DFS with a postorder reversal) — if you can detect a cycle this way, you already understand why topological sort fails on cyclic graphs: there's no valid linear order.

flowchart TD
    A["Problem mentions tree or graph"] --> B{"Need shortest path / levels?"}
    B -->|Yes| C["BFS with deque"]
    B -->|No| D{"Need all paths / combinations / backtracking?"}
    D -->|Yes| E["DFS, often with backtracking"]
    D -->|No| F{"Directed graph with dependencies?"}
    F -->|Yes| G["Topological sort (Kahn's or DFS postorder)"]
    F -->|No| H{"Need connectivity / cycle check?"}
    H -->|Yes| I["DFS or Union-Find"]

3. Worked Example

Problem: "Number of Islands" — given a 2D grid of '1's (land) and '0's (water), count connected land regions (4-directionally connected).

This is a graph problem in disguise: each cell is a node, edges connect to its 4 neighbors, and you need connected components — a classic DFS-from-each-unvisited-node pattern.

def num_islands(grid):
    if not grid:
        return 0
    rows, cols = len(grid), len(grid[0])
    visited = set()

    def dfs(r, c):
        stack = [(r, c)]
        while stack:
            cr, cc = stack.pop()
            for dr, dc in [(-1,0),(1,0),(0,-1),(0,1)]:
                nr, nc = cr + dr, cc + dc
                if (0 <= nr < rows and 0 <= nc < cols
                        and (nr, nc) not in visited
                        and grid[nr][nc] == '1'):
                    visited.add((nr, nc))
                    stack.append((nr, nc))

    count = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == '1' and (r, c) not in visited:
                visited.add((r, c))
                count += 1
                dfs(r, c)
    return count

Trace through grid = [["1","1","0"],["0","1","0"],["0","0","1"]]:

  • (0,0) is land, unvisited → new island #1. DFS floods (0,0) → (0,1) → (1,1). All three marked visited.
  • (0,2) is water, skip.
  • (2,2) is land, unvisited → new island #2. DFS finds no connected land neighbors.

Result: count = 2. Time complexity is O(rows * cols) since every cell is visited at most once; space is O(rows * cols) for the visited set in the worst case (all land, iterative stack depth up to grid size).

4. Production Pitfalls & Best Practices

4.1 Real-World Best Practices

  • Always build an adjacency list (defaultdict(list)) from edge lists before traversing — don't scan the edge list repeatedly inside your loop.
  • Use collections.deque for BFS queues, never list.pop(0).
  • Mark nodes visited at enqueue/push time for BFS to avoid duplicate queue entries; for DFS with recursion, mark at the start of the call.
  • For very deep trees, prefer the iterative stack-based traversal over recursion to dodge RecursionError.
  • When the grid/graph nodes don't have natural IDs (e.g. grid coordinates), use tuples (r, c) as dictionary/set keys — they're hashable and readable.
  • For "shortest path with weights," reach for Dijkstra (heapq) instead of BFS — BFS only guarantees shortest path when all edges weigh the same.
  • Use Union-Find (disjoint set with path compression and union by rank) instead of repeated DFS when you need many "are these connected?" queries — it's near O(1) amortized per query versus O(n) per DFS.

4.2 Common Bugs & Anti-Patterns

Bug 1 — forgetting visited on a graph (infinite loop on cycles):

# BAD
def dfs(graph, node):
    print(node)
    for nxt in graph[node]:
        dfs(graph, nxt)   # loops forever if graph has a cycle
# FIXED
def dfs(graph, node, visited=None):
    if visited is None:
        visited = set()
    visited.add(node)
    print(node)
    for nxt in graph[node]:
        if nxt not in visited:
            dfs(graph, nxt, visited)

Bug 2 — mutable default argument reused across calls:

# BAD — visited persists across separate top-level calls!
def dfs(node, visited=set()):
    ...
# FIXED — create a fresh set each call
def dfs(node, visited=None):
    if visited is None:
        visited = set()
    ...

Bug 3 — marking visited on dequeue instead of enqueue in BFS:

# BAD — same node can be queued many times
while queue:
    node = queue.popleft()
    if node in visited:
        continue
    visited.add(node)
    for nxt in graph[node]:
        queue.append(nxt)
# FIXED — check and mark before pushing
visited = {start}
queue = deque([start])
while queue:
    node = queue.popleft()
    for nxt in graph[node]:
        if nxt not in visited:
            visited.add(nxt)
            queue.append(nxt)

Bug 4 — treating a directed graph's cycle check like an undirected one:

# BAD — this only checks "visited," which falsely flags diamonds
# (A -> B, A -> C, B -> D, C -> D) as cycles in a directed graph
def has_cycle(graph, node, visited):
    if node in visited:
        return True
    visited.add(node)
    return any(has_cycle(graph, nxt, visited) for nxt in graph[node])
# FIXED — use three-color (white/gray/black) states, shown in 2.3

4.3 When to Use What

Situation Approach
Shortest path, unweighted edges BFS
Shortest path, weighted edges, non-negative Dijkstra (heapq)
All root-to-leaf paths / combinations DFS with backtracking
Detect cycle, undirected graph DFS tracking parent
Detect cycle, directed graph / build build order Topological sort (Kahn's BFS or 3-color DFS)
Many repeated "connected?" queries Union-Find
Tree is very deep / might hit recursion limit Iterative DFS with explicit stack

5. Now Try It

Write a function course_order(num_courses, prerequisites) that returns a valid order to take num_courses courses given a list of [course, prereq] pairs, or an empty list if it's impossible (cycle exists). Use Kahn's algorithm: build an adjacency list and in-degree array, start a BFS queue with all in-degree-0 nodes, and pop them off, decrementing neighbors' in-degrees. Test it on num_courses=4, prerequisites=[[1,0],[2,0],[3,1],[3,2]] and on a version with an added edge [0,3] that creates a cycle.

Success looks like: your function returns a valid 4-element order (e.g. [0,1,2,3] or [0,2,1,3]) for the first case, and returns [] for the cyclic case.

Frequently asked about Trees and Graphs

You'll know how to traverse any tree or graph using DFS and BFS, when to pick recursion versus an explicit stack, and how to detect cycles or find shortest paths. You'll see the four traversal patterns that solve 80% of Leetcode tree/graph problems. Read the full notes above for the details.

Trees and Graphs is a core topic in Leetcode problems (python). Most exam papers test it via a mix of definitions, worked examples, and applied problems. The notes above cover the high-yield sub-topics, common pitfalls, and the kind of questions examiners typically set.

Yes — every note in the StudyAI Campus Hub is free to read in full, right here on this page, with no account needed. If you clone the plan into your own dashboard, the free plan shows a preview of each note there; Basic and above unlock the full notes in your dashboard, along with practice quizzes, flashcards and offline study. You can always come back here to read the complete note for free.

Study this next


Get the full Leetcode problems (python) curriculum

Clone the complete plan to your dashboard for unlimited AI-generated notes, practice quizzes, and a personalised revision schedule.

Save this course free