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
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.
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.
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.
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
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.
| Format | What an index means | Space | You will see it in |
|---|---|---|---|
| Matrix (grid) | grid[r][c] is a cell; neighbors are the 4 cells around it | O(n · m) | islands, flood fill, maze problems |
| Adjacency matrix | adj[v1][v2] is 1 if edge v1 → v2 | O(V²) | rare; dense graphs |
| Adjacency list | adj[v] is the list of v's neighbors | O(V + E) | almost everything else |
2Matrix DFS: count the paths
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
ror columncis negative, or equals the matrix length. Both must be valid; you cannot searchmatrix[-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].
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.
| # | Call | What happens | visit after | Returns |
|---|---|---|---|---|
| 1 | dfs(0,0) | add 00. down (1,0) is a wall → 0, up is out of bounds → 0, try right | {00} | … |
| 2 | dfs(0,1) | add 01. down (1,1) wall, up OOB, try right | {00 01} | … |
| 3 | dfs(0,2) | add 02. try down first | {00 01 02} | … |
| 4 | dfs(1,2) | add 12. try down | {00 01 02 12} | … |
| 5 | dfs(2,2) | add 22. try down | {00 01 02 12 22} | … |
| 6 | dfs(3,2) | add 32. down OOB, up visited, try right | {00 01 02 12 22 32} | … |
| 7 | dfs(3,3) | target: return 1 without adding to visit | same | 1 |
| 8 | dfs(3,2) resumes | left (3,1) wall. count = 1. remove 32 | {00 01 02 12 22} | 1 |
| 9 | dfs(2,2) resumes | up visited, right (2,3) wall, try left → dfs(2,1) → dfs(2,0) → dfs(3,0) | {00 01 02 12 22 21 20 30} | … |
| 10 | dfs(3,0) | down OOB, up visited, right (3,1) wall, left OOB. dead end. remove 30 | {00 01 02 12 22 21 20} | 0 |
| 11 | dfs(2,0), dfs(2,1) resume | nothing else open. remove 20, then remove 21 | {00 01 02 12 22} | 0, 0 |
| 12 | dfs(2,2) resumes | count = 1 + 0. remove 22 | {00 01 02 12} | 1 |
| 13 | dfs(1,2) resumes | up 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 |
| 14 | dfs(0,2) resumes | count = 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} | … |
| 15 | unwind to dfs(0,2) | each call removes itself on the way back; count at (0,2) = 1 + 1 | {00 01} | 2 |
| 16 | dfs(0,1), dfs(0,0) finish | left is visited / OOB. remove 01, remove 00 | { } | 2 |
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.
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.
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
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
[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.
length | Queue at start of level | Popped → neighbors added | Skipped (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 |
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
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"]}
["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)
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
length | Queue at start of level | Popped | Neighbors added |
|---|---|---|---|
| 0 | [A] | A, not the target | B |
| 1 | [B] | B, not the target | C, E |
| 2 | [C, E] | C, not the target; then E: target, return 2 | C's neighbor E is already visited, skip |
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².
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
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"
| Choice | Extra space | Input after | Works with backtracking? | Use when |
|---|---|---|---|---|
visit set of (r, c) | O(n · m) | unchanged | yes (add / remove) | default; counting paths; read-only input |
| Overwrite the cell | O(1) | destroyed | no | islands, 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 says | Use | Visit set behavior | Time |
|---|---|---|---|
| "is there a path", "how many components", "fill the region" | DFS (reachability) | add only, never remove | O(V + E) or O(n · m) |
| "how many distinct paths", "all routes" | DFS with backtracking | add, recurse, remove | exponential |
| "shortest", "fewest steps", "nearest", "minimum time" | BFS with the level loop | add at append time, never remove | O(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 / visited | O(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
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".
| Operation | Time | Space | Why |
|---|---|---|---|
| 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 edges | O(V + E) | O(V + E) | one append per edge |
| Matrix DFS, count all paths | O(4n·m) | O(n · m) | 4-way branching, depth n · m; stack + visit set |
| Matrix DFS, reachability / flood fill | O(n · m) | O(n · m) | each cell visited once, never removed |
| Matrix BFS, shortest path | O(n · m) | O(n · m) | each cell queued at most once |
| Adjacency list DFS, count all paths | O(VV)* | O(V) | N edges per vertex, depth V, worst N = V |
| Adjacency list DFS, reachability | O(V + E) | O(V) | each vertex once, each edge once |
| Adjacency list BFS, shortest path | O(V + E) | O(V) | each vertex popped once, each edge scanned once |
| Neighbor lookup, adjacency list | O(1) + len(list) | - | one hash lookup |
| Neighbor lookup, adjacency matrix | O(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
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.
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.
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.
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.
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.
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).
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().
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.
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.
| # | Problem | Move it trains | Level |
|---|---|---|---|
| 733 | Flood Fill | grid DFS template, marking in place | Easy |
| 1971 | Find if Path Exists in Graph | build undirected adjacency list, DFS reachability | Easy |
| 200 | Number of Islands | outer scan + flood fill, count DFS starts | Medium |
| 695 | Max Area of Island | flood fill that returns a size | Medium |
| 1091 | Shortest Path in Binary Matrix | grid BFS with the level loop (8 directions) | Medium |
| 994 | Rotting Oranges | multi-source BFS, minutes = levels | Medium |
| 133 | Clone Graph | GraphNode with neighbors, hashmap old → new, DFS | Medium |
| 417 | Pacific Atlantic Water Flow | DFS from the borders inward, two visited sets | Medium |
| 207 | Course Schedule | directed adjacency list, cycle detection with DFS | Medium |
| 323 | Number of Connected Components in an Undirected Graph (premium; on the NeetCode practice list) | islands idea on an adjacency list | Medium |
| 286 | Walls and Gates (premium; on the NeetCode practice list) | multi-source BFS from every gate | Medium |
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.