DSA Field Notes
Course map / 10 Graphs
NeetCode · DSA for Beginners · Lessons 28-31

Graph Field Notes

Vertices, edges, and the three ways interviewers hand you a graph. Then the two traversals that solve almost every beginner graph problem: DFS with backtracking to count or reach, BFS level by level to find the shortest path. Every diagram uses the same 4x4 grid and the same five-node graph as the NeetCode videos.

0Why graphs are not a new idea

Analogy

A city map. Intersections are dots; streets connect them. Some streets are one-way. Some intersections connect to five streets, some to one, and a parking lot with no road is still on the map. "How many different routes from home to work?" is DFS. "What is the fewest turns from home to work?" is BFS.

You already know graphs. A linked list is nodes connected by next pointers. A tree is nodes connected by left and right pointers. A graph is the general case: nodes connected by pointers, with no rules about how many pointers a node has or where they go. Cycles are allowed. Disconnected pieces are allowed.

LINKED LIST 1 2 3 each node: exactly 1 pointer TREE 1 2 3 up to 2 pointers, no cycles GRAPH A B C D F any pointers, cycles allowed, F has none
Same building block, fewer rules. A → B → D → C → A is a cycle; a tree can never do that. F has no edges and is still part of the graph. Linked lists and trees are just graphs with extra restrictions.

What you will be able to do after this page: read a graph in any of its three input formats, write matrix DFS and matrix BFS from memory, build an adjacency list from an edge list, and choose DFS or BFS on purpose instead of by habit.

1Vocabulary and the three input formats

Vertices and edges

In graph language, a node is a vertex (plural: vertices) and a pointer is an edge. There are no restrictions on where vertices are placed or how edges connect them. A vertex can have any number of edges: zero, one, or one to every other vertex. A set of vertices with no edges at all still counts as a graph.

How many edges can a graph with V vertices have? Each vertex can point to every vertex, including itself. With A, B, C: A can point to A, B, C; so can B; so can C. That is 3 × 3 = 9. In general E ≤ V² (assuming no duplicate edges). A graph that actually has V² edges is called a complete graph.

An edge with a direction (A → B, but not B → A) makes a directed graph. An edge without direction (A and B are simply connected) makes an undirected graph. Trees and linked lists are directed graphs: prev, next, left_child, right_child all point one way.

UNDIRECTED A B A - B: usable both ways DIRECTED A B A → B only; B cannot reach A COMPLETE, V = 3 A B C V = 3 → E = 3² = 9, self loops included
Count the arrows in the third panel: nine. Three pairs × two directions = 6, plus three self loops = 9 = V². That is the maximum; any graph on three vertices has E ≤ 9. Undirected edges have no arrowhead because they work both ways.

Formats: how a graph arrives in an interview

A graph is an abstract concept (an idea, not a specific object). It becomes concrete when stored in a data structure. Three formats cover almost every problem: a matrix, an adjacency matrix, and an adjacency list.

Format 1: Matrix (a 2-D grid)

A matrix is a 2-D array: a list of rows, each row a list of values. It is 0-indexed in both directions. grid[1] is the second row. grid[1][0] is the first column of the second row. Always row first, then column.

grid = [[0, 0, 0, 0],
        [1, 1, 0, 0],
        [0, 0, 0, 1],
        [0, 1, 0, 0]]

grid[1]      # [1, 1, 0, 0]  → the second row
grid[1][0]   # 1             → row 1, column 0

How is this a graph? Say every 0 is a vertex and we may move up, down, left, right between neighboring 0s. Drawing those moves as edges gives groups of connected zeros. Each group is a connected component (a set of vertices where every vertex can reach every other). Walking this graph is called matrix traversal, and it is sections 2 and 3. Space: O(n · m) for n rows and m columns.

THE GRID c0 c1 c2 c3 r0 r1 r2 r3 0 0 0 0 1 1 0 0 0 0 0 1 0 1 0 0 ← grid[1] = [1, 1, 0, 0] grid[1][0] = 1 : row 1, column 0, a wall READ AS A GRAPH 12 vertices, 12 edges, 1 connected component
The 1s are walls, the 0s are vertices, and "next to each other" is the edge. Shaded cells are not vertices at all. In this grid every 0 can reach every other 0, so there is one connected component. Edges have no arrows because you can walk both ways.

Format 2: Adjacency matrix

A less common input format, but you must recognize it. It looks like the grid, but the meaning is different. Here the index is the vertex. adjMatrix[v1][v2] = 1 means "there is an edge from vertex v1 to vertex v2"; 0 means no edge. Row = from, column = to. The edges are directed: adjMatrix[1][2] == 0 says no edge 1 → 2, and separately adjMatrix[2][1] == 0 says no edge 2 → 1.

adjMatrix = [[0, 0, 0, 0],
             [1, 1, 0, 0],
             [0, 0, 0, 1],
             [0, 1, 0, 0]]

# an edge exists from v1 to v2
adjMatrix[v1][v2] = 1
# an edge exists from v2 to v1
adjMatrix[v2][v1] = 1
# no edge exists from v1 to v2
adjMatrix[v1][v2] = 0
# no edge exists from v2 to v1
adjMatrix[v2][v1] = 0
column = to 0 1 2 3 row = from 0 1 2 3 0 0 0 0 1 1 0 0 0 0 0 1 0 1 0 0 adjMatrix[2][3] = 1 → edge from 2 to 3 THE 4 EDGES IT ENCODES 0 1 2 3 [1][0] [1][1] [3][1] [2][3] 4 vertices, 4 edges, 16 cells stored → O(V²)
Same numbers, different meaning. As a grid, row 1 was two walls. As an adjacency matrix, row 1 says "vertex 1 points to vertex 0 and to itself". Vertex 0 has an incoming edge but no outgoing ones. The problem: this graph has 4 edges but we store 16 cells. A square matrix always costs O(V²) space, even when the graph has very few edges.

Format 3: Adjacency list

Typically the most common representation, and the one you will build yourself. Each vertex keeps a list of its neighbors (the vertices it has an edge to). It can be a 2-D array, but it is usually a hashmap, or a class where each node holds its own neighbor list.

# GraphNode used for adjacency list
class GraphNode:
    def __init__(self, val):
        self.val = val
        self.neighbors = []      # references to other GraphNodes

An adjacency list stores only the edges that exist, so it is more space efficient than an adjacency matrix: O(V + E). Section 4 builds one from a list of edges and traverses it.

FormatWhat an index meansSpaceYou will see it in
Matrix (grid)grid[r][c] is a cell; neighbors are the 4 cells around itO(n · m)islands, flood fill, maze problems
Adjacency matrixadj[v1][v2] is 1 if edge v1 → v2O(V²)rare; dense graphs
Adjacency listadj[v] is the list of v's neighborsO(V + E)almost everything else

2Matrix DFS: count the paths

Analogy

You are in a maze with chalk. At each square you mark the floor, try every open direction one at a time, and when a direction leads nowhere you walk back and erase your mark so that a different route can use that square later. Counting the exits you find is counting the paths.

DFS on a matrix works like DFS on a tree: from a position, go as deep as you can before trying the next option. The moves are up, down, left, right. Some problems also allow diagonal moves; not here.

The problem. Count the unique paths from the top-left cell to the bottom-right cell. A path may only move along 0s and cannot visit the same cell more than once.

matrix = [[0,0,0,0],
          [1,1,0,0],
          [0,0,0,1],
          [0,1,0,0]]

This is backtracking: DFS is recursive, we try every path, and when a path cannot reach the bottom right we undo the last step and try another direction. Each call returns the number of valid paths that start from its cell, and a parent adds up what its four children return.

Base cases: when to stop

Return 0 (no path through this cell) when any of these is true:

  • Out of bounds. Row r or column c is negative, or equals the matrix length. Both must be valid; you cannot search matrix[-1][3].
  • Already visited. This coordinate is on the current path. Revisiting would count the same path many times, or loop forever.
  • Blocked. The cell holds a 1; you cannot move through 1s.

Return 1 (one path found) when you stand on the bottom-most row and right-most column: matrix[3][3] here, matrix[ROWS-1][COLS-1] in general.

The implementation

Keep a hash set visit of coordinates on the current path. A set gives O(1) insert and lookup. (A 2-D boolean array the size of the grid works too.) From (r, c), recurse on (r+1, c), (r-1, c), (r, c+1), (r, c-1). Each call returns 1 or 0; add it to count. The line that makes this backtracking: after exploring all four directions, remove the current coordinate from visit, so a different path can use this cell.

# Matrix (2D Grid)
grid = [[0, 0, 0, 0],
        [1, 1, 0, 0],
        [0, 0, 0, 1],
        [0, 1, 0, 0]]

# Count paths (backtracking)
def dfs(grid, r, c, visit):
    ROWS, COLS = len(grid), len(grid[0])
    if (min(r, c) < 0 or
        r == ROWS or c == COLS or
        (r, c) in visit or grid[r][c] == 1):
        return 0
    if r == ROWS - 1 and c == COLS - 1:
        return 1

    visit.add((r, c))

    count = 0
    count += dfs(grid, r + 1, c, visit)    # down
    count += dfs(grid, r - 1, c, visit)    # up
    count += dfs(grid, r, c + 1, visit)    # right
    count += dfs(grid, r, c - 1, visit)    # left

    visit.remove((r, c))                   # backtrack: free this cell for other paths
    return count

dfs(grid, 0, 0, set())    # → 2

The function returns 2 for this grid. The video shows it in two moves: first the DFS finds one path; then it backtracks and finds a second path that goes through matrix[0][3].

① first path → return 1 c0 c1 c2 c3 r0 r1 r2 r3 0 0 0 0 1 1 0 0 0 0 0 1 0 1 0 0 right, right, down ×3, right visit grows to 6 cells ② dead end → backtrack 0 0 0 0 1 1 0 0 0 0 0 1 0 1 0 0 × (2,2) → left to (3,0): walled in count 0, remove (3,0) (2,0) (2,1) ③ second path → return 1 0 0 0 0 1 1 0 0 0 0 0 1 0 1 0 0 via (0,3): possible only because (1,2) (2,2) (3,2) were removed
Two paths, and the second one reuses cells from the first. Path ① goes down column 2. When it returns, every cell it touched is removed from visit on the way back. That is what lets path ③ walk through (1,2), (2,2), (3,2) again after its detour through (0,3). Panel ② shows one of the dead ends in between: from (2,2) the search goes left to (2,0), down to (3,0), and finds the wall at (3,1). All three of those calls return 0 and erase themselves.

Trace: the visit set growing and shrinking

Coordinates are written as rc pairs to save space: 12 means (1, 2). The direction order is down, up, right, left, exactly as in the code. Only the important calls are shown; out-of-bounds and wall calls return 0 immediately and are summarized.

#CallWhat happensvisit afterReturns
1dfs(0,0)add 00. down (1,0) is a wall → 0, up is out of bounds → 0, try right{00}…
2dfs(0,1)add 01. down (1,1) wall, up OOB, try right{00 01}…
3dfs(0,2)add 02. try down first{00 01 02}…
4dfs(1,2)add 12. try down{00 01 02 12}…
5dfs(2,2)add 22. try down{00 01 02 12 22}…
6dfs(3,2)add 32. down OOB, up visited, try right{00 01 02 12 22 32}…
7dfs(3,3)target: return 1 without adding to visitsame1
8dfs(3,2) resumesleft (3,1) wall. count = 1. remove 32{00 01 02 12 22}1
9dfs(2,2) resumesup visited, right (2,3) wall, try left → dfs(2,1) → dfs(2,0) → dfs(3,0){00 01 02 12 22 21 20 30}…
10dfs(3,0)down OOB, up visited, right (3,1) wall, left OOB. dead end. remove 30{00 01 02 12 22 21 20}0
11dfs(2,0), dfs(2,1) resumenothing else open. remove 20, then remove 21{00 01 02 12 22}0, 0
12dfs(2,2) resumescount = 1 + 0. remove 22{00 01 02 12}1
13dfs(1,2) resumesup visited, right → dfs(1,3) → up → dfs(0,3): every side is OOB, wall or visited. dead end, both removed. left (1,1) wall. remove 12{00 01 02}1
14dfs(0,2) resumescount = 1. up OOB. right → dfs(0,3) → down dfs(1,3) → left dfs(1,2) → down dfs(2,2) → down dfs(3,2) → right dfs(3,3) = 1{00 01 02 03 13 12 22 32}…
15unwind to dfs(0,2)each call removes itself on the way back; count at (0,2) = 1 + 1{00 01}2
16dfs(0,1), dfs(0,0) finishleft is visited / OOB. remove 01, remove 00{ }2
Keep in mind

visit is empty again when the top call returns. That is the signature of correct backtracking: every add has a matching remove. Step 14 could only reuse (1,2), (2,2), (3,2) because steps 8, 12 and 13 removed them.

Python trap: negative indexes do not crash

The lesson says you "cannot search matrix[-1][3]". In C or Java that is an error. In Python matrix[-1] silently returns the last row. Without the min(r, c) < 0 check your DFS would wrap around from the top row to the bottom row and count fake paths. This bug produces no error message. Check for negatives explicitly, every time.

Time and space

If we only counted cells, the worst case visits every coordinate: O(n · m). But this problem counts paths, and from every coordinate there are four choices. Draw it as a decision tree: each node has at most 4 children (a branching factor of 4) and the tree can be n · m levels deep (a path can pass through every cell). That gives O(4n·m) time in the worst case.

(0,0) down up right left (1,0) (-1,0) (0,1) (0,-1) wall → 0 OOB → 0 OOB → 0 (1,1) (-1,1) (0,2) (0,0) wall OOB ⋮ visited 4 choices per level, up to n·m levels deep → 4^(n·m) leaves in the worst case
The bound is about the tree's shape, not this grid. Every node has 4 children and the depth can reach n · m, so the tree can have 4n·m leaves. In practice the three base cases prune most branches immediately (the faded boxes), which is why the code finishes instantly on a 4x4 grid. Interviewers want the honest worst case.

Space: the recursive call stack can be n · m frames deep (one per cell on the current path), and the visit set can hold n · m coordinates. Both O(n · m).

3Matrix BFS: the shortest path

Analogy

Drop a stone in a pond. The ripple reaches every point 1 step away, then every point 2 steps away, then 3. The first ripple that touches the far shore tells you the distance, and it cannot be wrong, because a closer ripple would have arrived earlier.

BFS on a matrix is most commonly used to find the shortest path: the smallest number of moves from one cell to another. It is the same as tree BFS with two differences: more edge cases (bounds, walls, revisits) and a different setup (a start coordinate instead of a root). Four directions only.

The problem. Find the length of the shortest path from the top-left cell to the bottom-right cell of the same grid. DFS could work (find every path, keep the shortest), but BFS is more efficient because it stops the moment the target appears.

Setup

Read the grid dimensions for bounds checks. A hash set for visited vertices. A deque (double-ended queue, O(1) at both ends) to hold the vertices of the current level. Put (0, 0) in the deque and mark it visited before the loop starts.

from collections import deque

def bfs(grid):
    ROWS, COLS = len(grid), len(grid[0])
    visit = set()
    queue = deque()
    queue.append((0, 0))
    visit.add((0, 0))

The level loop

Initialize length = 0. The outer while queue runs once per level. The inner for i in range(len(queue)) pops exactly the vertices that were in the queue when the level started (the length is read once, so vertices added during the level wait for the next one). Popping gives (r, c). If it is the bottom-right cell, return length. Otherwise, instead of visiting children like a tree, visit the four neighbors.

Neighbor positions come from a directions array: neighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]] = right, left, down, up. Add each direction to the current position to get the new position. The if inside is the same check as DFS: out of bounds, already visited, or a wall means continue. Otherwise append the neighbor to the queue and add it to visit immediately, at append time rather than pop time, so the same cell never sits in the queue twice.

# Shortest path from top left to bottom right
def bfs(grid):
    ROWS, COLS = len(grid), len(grid[0])
    visit = set()
    queue = deque()
    queue.append((0, 0))
    visit.add((0, 0))

    length = 0
    while queue:
        for i in range(len(queue)):          # exactly one level
            r, c = queue.popleft()
            if r == ROWS - 1 and c == COLS - 1:
                return length

            neighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]]
            for dr, dc in neighbors:
                if (min(r + dr, c + dc) < 0 or
                    r + dr == ROWS or c + dc == COLS or
                    (r + dr, c + dc) in visit or grid[r + dr][c + dc] == 1):
                    continue
                queue.append((r + dr, c + dc))
                visit.add((r + dr, c + dc))     # mark when queued, not when popped
        length += 1

bfs(grid)    # → 6
(r-1, c) (r, c-1) (r, c) (r, c+1) (r+1, c) [ 0, 1] right same row, column + 1 [ 0, -1] left same row, column - 1 [ 1, 0] down row + 1 (rows grow downward) [-1, 0] up row - 1 new position = (r + dr, c + dc) one loop over 4 pairs replaces 4 copied lines
The directions array is the four DFS lines folded into a loop. [1, 0] is "down" because row numbers grow as you go down the grid. Memorize the array as a unit; every grid problem starts with it.

Trace: level by level

The queue column shows the queue at the moment the level starts, which is exactly the set of cells at distance length. Everything appended during the level has distance length + 1.

lengthQueue at start of levelPopped → neighbors addedSkipped (wall / OOB / visited)
0[(0,0)](0,0) → (0,1)(1,0) wall; left and up OOB
1[(0,1)](0,1) → (0,2)(1,1) wall; (0,0) visited
2[(0,2)](0,2) → (0,3), (1,2)(0,1) visited
3[(0,3), (1,2)](0,3) → (1,3); (1,2) → (2,2)(1,3) visited when (1,2) looks right; (1,1) wall
4[(1,3), (2,2)](1,3) → nothing; (2,2) → (2,1), (3,2)(2,3) wall, twice
5[(2,1), (3,2)](2,1) → (2,0); (3,2) → (3,3)(3,1) wall, twice; (1,1) wall
6[(2,0), (3,3)](2,0) → (3,0); then pop (3,3): target, return 6(1,0) wall
DISTANCE FROM (0,0) c0 c1 c2 c3 r0 r1 r2 r3 0 1 2 3 1 1 3 4 6 5 4 1 7 1 5 6 shaded = wall, never a vertex QUEUE AT EACH LEVEL length 0 [(0,0)] length 1 [(0,1)] length 2 [(0,2)] length 3 [(0,3), (1,2)] length 4 [(1,3), (2,2)] length 5 [(2,1), (3,2)] length 6 [(2,0), (3,3)] → pop (3,3), return 6 (3,0) is queued at distance 7 but never popped
Cells with the same number are one ripple. The video draws these as circles of the same color. Each level of the queue is one ring; the ring that contains (3,3) is level 6, so the shortest path has 6 moves. Compare with DFS, which found paths of length 6 and 8 and had no way to know which was shortest until it tried everything.
Mark at append time

If you mark a cell visited only when you pop it, two different cells in the same level can both append the same neighbor before either is popped. That neighbor is now in the queue twice, and on a big grid the duplicates multiply. Add to visit in the same line you append.

Time and space

A coordinate is never visited twice, so each of the n · m cells is popped at most once and checks at most 4 neighbors: O(n · m) time. Space is also O(n · m): the queue and the hash set can each hold every cell. No exponential term, because BFS never re-explores a cell for a different path the way backtracking does.

4Adjacency list: build it, then DFS and BFS it

Analogy

A contact list. Under each person's name is the list of people they can call. To find everyone Bob can reach, you look up "Bob" and read his list; you never scan the whole phone book, and you never worry about "going out of bounds".

The adjacency list is probably the easiest format to traverse. Finding the neighbors of a vertex is one lookup, and there are no bounds to check because a neighbor list only contains real vertices.

Building it from a list of directed edges

Use a hashmap: key = vertex, value = list of that vertex's neighbors. A hashmap works because keys are unique, so each vertex has exactly one list. For each edge [src, dst], make sure both vertices have a key (a vertex with only incoming edges still needs an empty list), then append dst to src's list. Only src's list changes, because the edge is directed.

# GraphNode used for adjacency list
class GraphNode:
    def __init__(self, val):
        self.val = val
        self.neighbors = []

# Or use a HashMap
adjList = { "A": [], "B": [] }

# Given directed edges, build an adjacency list
edges = [["A", "B"], ["B", "C"], ["B", "E"], ["C", "E"], ["E", "D"]]

adjList = {}

for src, dst in edges:
    if src not in adjList:
        adjList[src] = []
    if dst not in adjList:
        adjList[dst] = []
    adjList[src].append(dst)

# adjList == {"A": ["B"], "B": ["C", "E"], "C": ["E"], "D": [], "E": ["D"]}
DIRECTED GRAPH A B C E D D has no outgoing edges but is still a vertex ADJACENCY LIST (HASHMAP) A B B C E C E D [ ] empty, but the key exists E D key = vertex, value = its outgoing neighbors
Every arrow on the left is one entry in a list on the right. 5 edges, 5 list entries. B's list is ["C", "E"] in the order the edges were given, and that order decides which neighbor DFS explores first. Vertex 0 in the adjacency matrix earlier and D here show the same point: a vertex with no outgoing edges is still a vertex.

DFS on an adjacency list: count paths from source to target

Same backtracking shape as the matrix version, with the base cases simplified. If the node is already on the current path, return 0. If the node is the target, return 1. Otherwise add the node to visit, sum the results from every neighbor, then remove the node (backtrack) and return the sum.

# Count paths (backtracking)
def dfs(node, target, adjList, visit):
    if node in visit:
        return 0
    if node == target:
        return 1

    count = 0
    visit.add(node)
    for neighbor in adjList[node]:
        count += dfs(neighbor, target, adjList, visit)
    visit.remove(node)

    return count

dfs("A", "E", adjList, set())    # → 2   (A-B-E and A-B-C-E)
A B C E D A→B shared by both path 1: A-B-E path 2: A-B-C-E E is the target, so E → D is never followed CALL TREE dfs(A) → 2 └ dfs(B) → 2 ├ dfs(C) → 1 │ └ dfs(E) target → 1 └ dfs(E) target → 1 C comes before E because adjList[B] = ["C", "E"]
The count is the number of leaves that hit the target. B has two neighbors, so its call fans out into two branches, and both reach E. Reaching E returns 1 immediately, without adding E to visit, so the second branch can reach E too.

Time. Counting paths by backtracking is exponential. In the worst case every vertex connects to every other (E ≤ V²). If each vertex has N edges and the decision tree has height V, the work is NV. In the worst case N = V, giving O(VV). That is the honest bound for "count all paths"; do not confuse it with plain DFS reachability, which is O(V + E) because it visits each vertex once and never removes it from visit.

BFS on an adjacency list: shortest path from node to target

Shortest path here means reaching the destination through the fewest vertices (equivalently, the fewest edges). The code is matrix BFS without the edge cases: no bounds, no walls. Increase length after each level until the target is popped.

# Shortest path from node to target
def bfs(node, target, adjList):
    length = 0
    visit = set()
    visit.add(node)
    queue = deque()
    queue.append(node)

    while queue:
        for i in range(len(queue)):
            curr = queue.popleft()
            if curr == target:
                return length

            for neighbor in adjList[curr]:
                if neighbor not in visit:
                    visit.add(neighbor)
                    queue.append(neighbor)
        length += 1
    return length

bfs("A", "E", adjList)    # → 2
lengthQueue at start of levelPoppedNeighbors added
0[A]A, not the targetB
1[B]B, not the targetC, E
2[C, E]C, not the target; then E: target, return 2C's neighbor E is already visited, skip
length 0 length 1 length 2 length 3 (never runs) A B C E D E popped at length 2 → return 2. The path A → B → E has 2 edges.
Level = distance. C → E is dashed because E was already visited (queued by B) when C looked at it. BFS finds the 2-edge path first and never considers A-B-C-E, which is 3 edges.

Time: O(V + E). In the worst case BFS visits every vertex once and looks at every edge once. This assumes a normal graph: no self loops and not the maximal V² edges, so V and E are counted separately instead of collapsing into V².

A B C E D pop ① pop ② pop ③ pop ④ pop ⑤ e1 e2 e3 e4 e5 5 vertices popped once = V 5 edges checked once = E total work = V + E = 10 not V × E, not V². Each edge is scanned exactly when its source vertex is popped.
Why it is a sum, not a product. The visit set guarantees each vertex is popped once. When a vertex is popped, its neighbor list is scanned once, and every edge belongs to exactly one neighbor list. So the total edge work across the whole run is E, added to the V pops. The same argument gives O(V + E) for plain DFS reachability.
A quirk in the lesson's BFS

If the target is unreachable, the loop ends and the function returns the current length, which is a meaningless number (for bfs("D", "A", adjList) it returns 1). In an interview, return -1 after the loop so "not found" cannot be confused with a real distance. The patterns below do this.

This page only scratches the surface. Weighted graphs, Dijkstra, topological sort, and union-find are in NeetCode's Advanced Algorithms course. Everything there builds on the two traversals above.

5Moves that solve most graph problems

The lessons teach two traversals on two formats. Interviews dress them up in stories about islands, oranges, courses, and mazes. These six moves cover the stories.

Move 1: The grid DFS template with a bounds check

Every grid problem starts with the same skeleton: read ROWS and COLS, write the directions array, write a nested dfs that rejects out-of-bounds, blocked, and visited cells, then loop over neighbors. A small in_bounds helper keeps the check readable.

directions = [[0, 1], [0, -1], [1, 0], [-1, 0]]   # right, left, down, up

def in_bounds(grid, r, c):
    return 0 <= r < len(grid) and 0 <= c < len(grid[0])

def grid_dfs(grid, r, c, visit):
    if (not in_bounds(grid, r, c) or
            grid[r][c] == 1 or (r, c) in visit):
        return
    visit.add((r, c))
    for dr, dc in directions:
        grid_dfs(grid, r + dr, c + dc, visit)
    # no visit.remove here: this is reachability, not path counting

When to use: any time the input is a 2-D list and the question is about connected cells. Note the chained comparison 0 <= r < len(grid): it is the Python idiom for "in range" and it catches the negative-index trap in one expression.

Move 2: Flood fill and Number of Islands

An island is a connected component of land cells. To count them, scan every cell with two nested loops. When you find land that is not yet visited, you have discovered a new island: run DFS from it to mark the whole component, and add 1 to the count. The DFS is called a flood fill because it spreads like water until it hits the edge of the component.

def num_islands(grid):                 # LeetCode 200: cells are "1" (land) or "0" (water)
    ROWS, COLS = len(grid), len(grid[0])
    visit = set()

    def dfs(r, c):
        if (r < 0 or c < 0 or r >= ROWS or c >= COLS or
                grid[r][c] != "1" or (r, c) in visit):
            return
        visit.add((r, c))
        for dr, dc in directions:
            dfs(r + dr, c + dc)

    count = 0
    for r in range(ROWS):
        for c in range(COLS):
            if grid[r][c] == "1" and (r, c) not in visit:
                dfs(r, c)                  # floods the whole island
                count += 1
    return count


def max_area(grid):                    # LeetCode 695: same shape, dfs returns a size
    ROWS, COLS = len(grid), len(grid[0])
    visit = set()

    def dfs(r, c):
        if (r < 0 or c < 0 or r >= ROWS or c >= COLS or
                grid[r][c] == 0 or (r, c) in visit):
            return 0
        visit.add((r, c))
        return 1 + sum(dfs(r + dr, c + dc) for dr, dc in directions)

    return max((dfs(r, c) for r in range(ROWS) for c in range(COLS)), default=0)


def flood_fill(image, sr, sc, color):  # LeetCode 733: recolor one component
    ROWS, COLS = len(image), len(image[0])
    old = image[sr][sc]
    if old == color:
        return image                   # already that color: recoloring would never stop

    def dfs(r, c):
        if r < 0 or c < 0 or r >= ROWS or c >= COLS or image[r][c] != old:
            return
        image[r][c] = color            # the new color doubles as the visited mark
        for dr, dc in directions:
            dfs(r + dr, c + dc)

    dfs(sr, sc)
    return image
c0 c1 c2 c3 c4 r0 r1 r2 r3 1 1 0 0 0 1 1 0 0 0 0 0 1 0 0 0 0 0 1 1 ① ② ③ shaded = water ("0") outer loops scan row by row, left to right first unvisited "1" → dfs floods its island → count += 1, then the scan continues ① (0,0): floods 4 cells ② (2,2): floods 1 cell ③ (3,3): floods 2 cells num_islands → 3
Count the number of times DFS starts, not the number of land cells. The orange cells are where the scan first touches each island. Cells (0,1), (1,0), (1,1) are land but never start a DFS because island ① already visited them. Diagonal neighbors do not connect: (1,1) and (2,2) are separate islands.

When to use: "count the regions", "size of the largest region", "recolor a region", "is this region enclosed". The outer double loop plus an inner flood fill is the whole idea. Total time O(n · m): each cell is visited by the scan once and by at most one DFS.

Move 3: BFS shortest path with the level loop

Any "minimum number of steps" question on an unweighted graph is BFS. Keep the level loop, mark at append time, and return -1 after the loop for "unreachable". For problems with many starting points (Rotting Oranges, Walls and Gates) put all of them in the queue before the loop starts: this is a multi-source BFS, and it costs nothing extra.

def shortest_path(grid, start, target):
    ROWS, COLS = len(grid), len(grid[0])
    queue = deque([start])
    visit = {start}
    length = 0
    while queue:
        for _ in range(len(queue)):        # one level = one unit of distance
            r, c = queue.popleft()
            if (r, c) == target:
                return length
            for dr, dc in directions:
                nr, nc = r + dr, c + dc
                if (0 <= nr < ROWS and 0 <= nc < COLS and
                        grid[nr][nc] == 0 and (nr, nc) not in visit):
                    visit.add((nr, nc))
                    queue.append((nr, nc))
        length += 1
    return -1                                # never reached the target


def rotting_oranges(grid):               # LeetCode 994: multi-source BFS
    ROWS, COLS = len(grid), len(grid[0])
    queue = deque()
    fresh = 0
    for r in range(ROWS):
        for c in range(COLS):
            if grid[r][c] == 2:
                queue.append((r, c))         # every rotten orange is a source
            elif grid[r][c] == 1:
                fresh += 1
    minutes = 0
    while queue and fresh:
        for _ in range(len(queue)):
            r, c = queue.popleft()
            for dr, dc in directions:
                nr, nc = r + dr, c + dc
                if 0 <= nr < ROWS and 0 <= nc < COLS and grid[nr][nc] == 1:
                    grid[nr][nc] = 2            # marking in place = visited
                    fresh -= 1
                    queue.append((nr, nc))
        minutes += 1
    return minutes if fresh == 0 else -1

When to use: the words "shortest", "minimum steps", "nearest", "how many minutes until". If edges have different weights, BFS is wrong and you need Dijkstra (advanced course).

Move 4: Building adjacency lists from edge lists

LeetCode rarely hands you an adjacency list. It hands you n and edges. The first thing you write is the build loop. collections.defaultdict(list) removes the "if key not in dict" lines. For an undirected graph, add the edge in both directions. For numeric vertices 0..n-1, a list of lists is faster than a dict.

from collections import defaultdict

def build_directed(edges):
    adj = defaultdict(list)
    for src, dst in edges:
        adj[src].append(dst)
        adj[dst]                       # touch it so a pure sink like D still gets a key
    return adj

def build_undirected(edges):
    adj = defaultdict(list)
    for u, v in edges:
        adj[u].append(v)
        adj[v].append(u)               # both directions: each edge stored twice
    return adj

def build_undirected_list(n, edges):  # vertices are 0 .. n-1
    adj = [[] for _ in range(n)]
    for u, v in edges:
        adj[u].append(v)
        adj[v].append(u)
    return adj

build_undirected_list(3, [[0, 1], [1, 2]])    # → [[1], [0, 2], [1]]

When to use: every adjacency-list problem, first five lines. Ask the interviewer "directed or undirected?" before writing the loop. Sum of all list lengths is E for directed, 2E for undirected.

Move 5: Visited set vs marking the grid in place

A visit set is the safe default: it works for any format, never damages the input, and is required when you must backtrack (remove) or when the grid is read-only. Marking in place (overwriting a visited land cell with "0", or a fresh orange with 2) saves the O(n · m) set and one lookup per cell, but it destroys the input. Say which one you are choosing and why.

def num_islands_in_place(grid):
    ROWS, COLS = len(grid), len(grid[0])

    def sink(r, c):
        if r < 0 or c < 0 or r >= ROWS or c >= COLS or grid[r][c] != "1":
            return
        grid[r][c] = "0"                 # the grid itself is the visit set
        for dr, dc in directions:
            sink(r + dr, c + dc)

    count = 0
    for r in range(ROWS):
        for c in range(COLS):
            if grid[r][c] == "1":
                sink(r, c)
                count += 1
    return count                       # grid is now all "0"
ChoiceExtra spaceInput afterWorks with backtracking?Use when
visit set of (r, c)O(n · m)unchangedyes (add / remove)default; counting paths; read-only input
Overwrite the cellO(1)destroyednoislands, flood fill, rotting oranges; interviewer allows mutation

Move 6: DFS for reachability and counting, BFS for shortest path

The choice is usually decided by one word in the problem statement.

The question saysUseVisit set behaviorTime
"is there a path", "how many components", "fill the region"DFS (reachability)add only, never removeO(V + E) or O(n · m)
"how many distinct paths", "all routes"DFS with backtrackingadd, recurse, removeexponential
"shortest", "fewest steps", "nearest", "minimum time"BFS with the level loopadd at append time, never removeO(V + E) or O(n · m)
"can it be ordered", "detect a cycle in a directed graph"DFS with three states (or BFS by in-degree)visiting / visitedO(V + E)
def has_path(adj, src, dst, visit=None):     # LeetCode 1971 shape: reachability, no backtracking
    if visit is None:
        visit = set()
    if src == dst:
        return True
    visit.add(src)
    for nb in adj[src]:
        if nb not in visit and has_path(adj, nb, dst, visit):
            return True
    return False


def count_components(n, edges):            # NeetCode 323 shape: islands, but on an adjacency list
    adj = build_undirected_list(n, edges)
    visit = set()

    def dfs(node):
        visit.add(node)
        for nb in adj[node]:
            if nb not in visit:
                dfs(nb)

    count = 0
    for node in range(n):
        if node not in visit:
            dfs(node)
            count += 1
    return count

count_components(5, [[0, 1], [1, 2], [3, 4]])    # → 2
Keep in mind

The only difference between "count paths" (exponential) and "is there a path" (linear) is the visit.remove line. Remove it and each vertex is explored once. Keep it and each vertex can be explored once per path through it. Know which one the question needs before you type.

6Big-O summary

n and m are grid dimensions; V and E are vertex and edge counts. Say the assumption behind each bound out loud: "each cell visited once" or "each vertex and each edge once".

OperationTimeSpaceWhy
Store a grid-O(n · m)one cell per value
Store an adjacency matrix-O(V²)a cell for every possible edge, present or not
Store an adjacency list-O(V + E)one key per vertex, one entry per edge
Build adjacency list from edgesO(V + E)O(V + E)one append per edge
Matrix DFS, count all pathsO(4n·m)O(n · m)4-way branching, depth n · m; stack + visit set
Matrix DFS, reachability / flood fillO(n · m)O(n · m)each cell visited once, never removed
Matrix BFS, shortest pathO(n · m)O(n · m)each cell queued at most once
Adjacency list DFS, count all pathsO(VV)*O(V)N edges per vertex, depth V, worst N = V
Adjacency list DFS, reachabilityO(V + E)O(V)each vertex once, each edge once
Adjacency list BFS, shortest pathO(V + E)O(V)each vertex popped once, each edge scanned once
Neighbor lookup, adjacency listO(1) + len(list)-one hash lookup
Neighbor lookup, adjacency matrixO(V)-must scan the whole row

* The VV bound assumes the densest possible graph. On a grid the same idea gives 4n·m because the branching factor is fixed at 4. Both are "count every path" bounds; the moment you stop removing from visit, the exponent disappears and you are back to V + E.

7Mistakes to watch for

Forgetting the negative-index check

Python's grid[-1] is the last row, not an error. A DFS that only checks r >= ROWS wraps around the grid and counts phantom paths with no crash to warn you. Always check r < 0 or c < 0 (or min(r, c) < 0) too.

Using len(grid) for columns

On a non-square grid, c == len(grid) is the wrong bound. Read ROWS, COLS = len(grid), len(grid[0]) at the top and use each for its own axis. The lesson's grid is 4x4, which hides this bug until the interview.

Marking visited at pop time in BFS

Two cells in the same level both append the same neighbor. It is now in the queue twice, and each copy appends its own neighbors. Mark in visit on the same line you append.

Backtracking when you should not (or not when you should)

visit.remove in a reachability or islands problem turns O(n · m) into exponential time and times out. Leaving it out in a count-paths problem returns too few paths (the lesson grid gives 1 instead of 2). Decide from the question before you write the line.

KeyError on a sink vertex

adjList["D"] raises KeyError if you only created keys for sources. The build loop must add dst as a key too, or use defaultdict(list). NeetCode's loop handles this with the second if.

Adding an undirected edge one way

adj[u].append(v) alone makes the graph directed. Components and paths come out wrong. For undirected input, always write the second adj[v].append(u).

Using a list as the queue

queue.pop(0) is O(n): every element shifts. On a 1000x1000 grid that is a million shifts per pop. Use collections.deque and popleft().

Returning length when the target is unreachable

The lesson's BFS falls out of the loop and returns whatever length is. That number looks like a real distance. Return -1 after the loop so the caller can tell.

Hitting Python's recursion limit

Python allows about 1000 nested calls by default. A DFS that snakes through a 1000x1000 grid of all-land cells goes a million deep and raises RecursionError. Call sys.setrecursionlimit(10**6), or rewrite DFS with an explicit stack (a list used with append / pop).

Interview one-liners

  • "A graph is vertices plus edges with no restrictions; trees and linked lists are just directed graphs with rules."
  • "E is at most V squared, because every vertex can point to every vertex including itself; that is a complete graph."
  • "An adjacency matrix costs O(V²) space no matter how few edges there are; an adjacency list costs O(V + E), so I default to the list."
  • "For a grid I write the directions array once and check out of bounds, blocked, and visited before every recursive call."
  • "Counting paths is backtracking: add to visit, recurse, remove from visit. That makes it exponential, 4 to the n·m on a grid."
  • "For the shortest path I use BFS with a level loop, mark cells visited when I enqueue them, and return the level when I pop the target."
  • "BFS and plain DFS are O(V + E): each vertex is popped once and each edge is scanned once when its source is popped."

8Practice, in this order

Do them with no notes open. After each one, check your bounds check, your visited marking, and your choice of DFS or BFS against the moves in section 5.

#ProblemMove it trainsLevel
733Flood Fillgrid DFS template, marking in placeEasy
1971Find if Path Exists in Graphbuild undirected adjacency list, DFS reachabilityEasy
200Number of Islandsouter scan + flood fill, count DFS startsMedium
695Max Area of Islandflood fill that returns a sizeMedium
1091Shortest Path in Binary Matrixgrid BFS with the level loop (8 directions)Medium
994Rotting Orangesmulti-source BFS, minutes = levelsMedium
133Clone GraphGraphNode with neighbors, hashmap old → new, DFSMedium
417Pacific Atlantic Water FlowDFS from the borders inward, two visited setsMedium
207Course Scheduledirected adjacency list, cycle detection with DFSMedium
323Number of Connected Components in an Undirected Graph (premium; on the NeetCode practice list)islands idea on an adjacency listMedium
286Walls and Gates (premium; on the NeetCode practice list)multi-source BFS from every gateMedium

Your next step today: LeetCode 200, Number of Islands. Write it with a visited set first, then rewrite it marking in place, and compare the two against Move 5.

9Self-check

Answer out loud before opening. If you cannot answer in one breath, reread that section.

Q1What is the maximum number of edges in a graph with V vertices, and why?

V². Each of the V vertices can have an edge to each of the V vertices, including itself. A graph that reaches this maximum is a complete graph.

Q2Same list of lists, two meanings. What does the value at [1][0] mean in a grid, and what does it mean in an adjacency matrix?

In a grid it is the cell at row 1, column 0 (a wall if it is 1). In an adjacency matrix it says whether there is a directed edge from vertex 1 to vertex 0: row is "from", column is "to".

Q3Why is an adjacency list usually preferred over an adjacency matrix?

Space. The matrix stores V² cells whether or not the edges exist. The list stores one entry per real edge, O(V + E). Neighbor lookup is also faster: one hash lookup instead of scanning a row of length V.

Q4List the three "return 0" base cases of matrix DFS and the one "return 1" case.

Return 0 if out of bounds (r or c negative, or equal to ROWS or COLS), if (r, c) is already in visit, or if grid[r][c] == 1. Return 1 if r == ROWS - 1 and c == COLS - 1.

Q5Why does the path-counting DFS call visit.remove((r, c)) before returning?

So a different path can pass through this cell. Without it, the second path through (1,2), (2,2), (3,2) would be blocked by the first path's marks and the lesson grid would return 1 instead of 2.

Q6Explain O(4n·m) for matrix DFS in one sentence.

Each cell has 4 possible next moves (branching factor 4) and a path can be n · m cells long (tree height n · m), so the decision tree has up to 4n·m leaves.

Q7In matrix BFS, when do you add a neighbor to the visit set, and what goes wrong if you do it later?

At the moment you append it to the queue. If you wait until it is popped, two cells in the same level can each append the same neighbor, and the queue fills with duplicates.

Q8What does the directions array [[0, 1], [0, -1], [1, 0], [-1, 0]] mean, and how do you use it?

Right, left, down, up as (row change, column change). For each pair, the neighbor is (r + dr, c + dc). [1, 0] is down because row numbers increase going down.

Q9Given edges [["A","B"], ["B","C"], ["B","E"], ["C","E"], ["E","D"]], write the adjacency list. Why must D appear?

{"A": ["B"], "B": ["C", "E"], "C": ["E"], "D": [], "E": ["D"]}. D has no outgoing edges but it is a vertex; without the key, adjList["D"] raises KeyError the first time a traversal reaches D.

Q10Why is BFS on an adjacency list O(V + E) and not O(V × E)?

The visit set means each vertex is popped from the queue once. When it is popped, its own neighbor list is scanned once, and every edge appears in exactly one neighbor list. So the total is V pops plus E edge checks, a sum.

Q11Trace it: matrix BFS on the lesson grid. What is in the queue at the start of level 3, and what is in it at the start of level 5?

Level 3: [(0,3), (1,2)]. Level 5: [(2,1), (3,2)]. The full sequence is [(0,0)], [(0,1)], [(0,2)], [(0,3),(1,2)], [(1,3),(2,2)], [(2,1),(3,2)], [(2,0),(3,3)], and (3,3) is popped at level 6.

Q12Trace it: dfs("A", "E", adjList, set()). In what order are the recursive calls made, and what does each return?

dfs(A) adds A, calls dfs(B). dfs(B) adds B, calls dfs(C) first (because adjList[B] = ["C", "E"]). dfs(C) adds C, calls dfs(E) which returns 1 (target). dfs(C) removes C, returns 1. dfs(B) then calls dfs(E), returns 1. dfs(B) removes B, returns 2. dfs(A) removes A, returns 2.

Q13"Is there a path from u to v?" versus "How many paths from u to v?" What is the one-line code difference and the complexity difference?

Reachability never removes from visit: O(V + E). Counting paths removes on the way back (backtracking): exponential, O(VV) in the densest case, 4n·m on a grid.