← all topics

13 · Graphs: BFS and DFS on grids and adjacency lists Code

Grids as graphs, visited sets, flood fill, connected components · BFS for fewest hops · topological sort and Dijkstra as optional depth

Why it matters for NPE. Grid DFS/BFS is what Minesweeper and island-style questions need, and a Meta employee lists 'simple bfs/dfs/tree/array problems' as in scope. Shortest-path algorithms are rarely asked; know them conceptually because networks are graphs.

Primer: a graph is "things plus who touches whom", and you only need two walks

A graph is a set of nodes and a set of edges between them. Switches and links. Courses and prerequisites. Grid cells and their neighbours. Every problem on this page is one of two walks over that structure: DFS (go as deep as you can, then back up) and BFS (visit everything one hop away, then two hops, then three). DFS answers "what is connected to this" and "how big is this blob". BFS answers the same questions and also "fewest hops". Minesweeper, named in two firsthand NPE reports, is a DFS or BFS on a grid with a stopping rule. Number of Islands is the same walk with a counter. A Meta employee describes the coding bar as "simple bfs/dfs/tree/array problems", so these two walks are the ceiling you are preparing for, not the floor.

The sentence to say before typing: "Nodes are X, edges are Y, I keep a visited set, I mark a node the moment I enqueue or push it, and the walk costs O(V + E) because every node and edge is touched once." Then say whether you may mutate the input. That sentence is most of the structure score.

Grid indexing, the in_bounds helper, DIRS4 / DIRS8 and the [[0] * c] * r aliasing trap are in topic 11. This page assumes them and starts where topic 11 handed off: "when ships can touch, count connected components".

Watch

Graphs: Edge List, Adjacency Matrix, Adjacency List, DFS, BFS - DSA Course in Python Lecture 11Greg Hogg · 32:11

3:54 edge list, 5:10 matrix, 6:39 adjacency list, 7:49 recursive DFS, 11:32 iterative DFS with a stack, 14:18 BFS with a queue, 17:27 complexity. 22:20 onward is live Python: type along.

NUMBER OF ISLANDS - Leetcode 200 - PythonNeetCode · 11:41

0:00 the idea, 5:00 code. The component-counting template that Max Area, Provinces and the Battleships follow-up all reuse. Attempt it first.

Rotting Oranges - Leetcode 994 - PythonNeetCode · 12:19

2:05 drawing, 6:05 code. Multi-source BFS by levels: seed the queue with every rotten cell, one level per minute.

Breadth First Search grid shortest path | Graph TheoryWilliamFiset · 16:51

1:36 why you never build an adjacency list for a grid, 3:55 direction vectors, 7:51 BFS on the grid step by step. Pseudocode at 11:07; the Python is below.

Graph Algorithms for Technical Interviews - Full CoursefreeCodeCamp.org · 2:12:18

Best explained, but JavaScript, so translate. Use the chapters: 7:10-29:13 traversal, 1:00:44-1:39:36 components and shortest path, 1:39:36 island count. Skip the rest.

Network Delay Time - Dijkstra's algorithm - Leetcode 743NeetCode · 19:47

Optional depth. 4:37 drawing, 14:37 heapq code. The only weighted-graph video you need.

Topological Sort | Kahn's Algorithm | Graph TheoryWilliamFiset · 13:32

Optional depth. 5:36 the in-degree intuition, 6:05 two worked examples, 11:15 pseudocode. Course Schedule I and II are this.

Representing a graph

FormPythonMemory"Is u-v an edge?""Neighbours of u"Use when
Adjacency listdict[node, list[node]]O(V + E)O(deg u)O(deg u)default; sparse graphs, anything you BFS/DFS
Adjacency matrixm[u][v] 0/1 or weightO(V²)O(1)O(V)dense, small V; the input in Number of Provinces
Edge list[(u, v), …]O(E)O(E)O(E)only as input; convert it first
Implicit gridgrid[r][c] plus a delta listO(rc), already givenO(1)O(4) or O(8)any matrix question; never build a list for it
from collections import defaultdict, deque

def build(edges, directed=False):
    graph = defaultdict(list)
    for u, v in edges:
        graph[u].append(v)
        if not directed:
            graph[v].append(u)            # undirected = store the edge both ways
    return graph

# nodes with no edges never appear as keys. Iterate the node list, not the dict:
for u in nodes:
    for v in graph.get(u, ()):             # .get, not graph[u]: a defaultdict read inserts a key
        ...

# a grid is a graph you never materialise: node = (r, c), neighbours = cells that pass the bounds check
rows, cols = len(grid), len(grid[0])
DIRS4 = [(-1, 0), (1, 0), (0, -1), (0, 1)]
DIRS8 = [(dr, dc) for dr in (-1, 0, 1) for dc in (-1, 0, 1) if (dr, dc) != (0, 0)]
def neighbours(r, c, dirs=DIRS4):
    for dr, dc in dirs:
        nr, nc = r + dr, c + dc
        if 0 <= nr < rows and 0 <= nc < cols:
            yield nr, nc

Directed vs undirected. An undirected edge is two directed edges, so build appends both ways. A grid is undirected (if I can step to you, you can step to me). Prerequisites, "A can reach B through a one-way link", and "u calls v" are directed. Direction changes three things: you add each edge once instead of twice, in-degree becomes meaningful (topological sort needs it), and cycle detection changes (in an undirected graph "I see my parent again" is not a cycle; in a directed graph a back edge to any node on the current path is).

Weights ride along as graph[u].append((v, w)). Nothing on this page needs them until the optional Dijkstra section.

Visited: a set, or marking the grid

MethodCodeCostWhen
Set of nodesseen = set(); seen.add((r, c))O(V) extra, hashing a tuple per checkdefault; works for any node type; does not touch the input
Boolean gridseen = [[False] * cols for _ in range(rows)]O(rc) extra, no hashinglarge grids where the tuple-set is measurably slow
Mark the inputgrid[r][c] = '0' after visiting a '1'O(1) extraonly after asking "may I modify the input?" The caller may need it again. Say you would copy it otherwise.
Distance dictdist = {start: 0}O(V)BFS when you need hop counts anyway; the dict is the visited set

Minesweeper has a fourth option built in: the board itself records state. Writing 'B' or a digit into a cell is what marks it visited, and the problem asks you to mutate the board, so no extra structure is needed.

DFS: recursive, iterative, and the recursion limit

def dfs(graph, u, seen):                   # recursive: shortest to write, cleanest to read
    seen.add(u)
    for v in graph[u]:
        if v not in seen:
            dfs(graph, v, seen)

def dfs_iter(graph, start):                # iterative: same reachability, no recursion limit
    seen = {start}
    stack = [start]
    while stack:
        u = stack.pop()
        for v in graph[u]:
            if v not in seen:
                seen.add(v)                # mark on push
                stack.append(v)
    return seen

Python's default recursion limit is about 1000 frames. Recursive DFS goes one frame deeper per step of the path, so a snake-shaped island of 5000 cells on a 1000×1000 grid raises RecursionError. Three answers, in the order you should give them: (1) switch to the iterative version with an explicit stack, which is a two-line change; (2) sys.setrecursionlimit(10**6), which lifts the Python limit but can still crash the interpreter on the C stack, so it is a hack and you should call it one; (3) BFS, which never recurses. In a 45-minute screen write the recursive version if it is shorter, then say out loud that it has a depth limit and how you would fix it. That is the fluency point for free.

Cost of either version: O(V + E) time, every node once and every edge once (twice if undirected), and O(V) space for the visited set plus the stack. On a grid V = rc and E ≤ 4rc, so say "O(rc)". The iterative version visits in a slightly different order from the recursive one and may hold a node on the stack more than once if you mark on pop instead of on push. Mark on push.

BFS: a deque, and why it gives the fewest hops

from collections import deque

def bfs(graph, start):
    dist = {start: 0}                      # doubles as the visited set
    q = deque([start])
    while q:
        u = q.popleft()
        for v in graph[u]:
            if v not in dist:              # mark when you ENQUEUE, never when you dequeue
                dist[v] = dist[u] + 1
                q.append(v)
    return dist                            # every reachable node and its hop count

The queue holds nodes in non-decreasing distance order. Everything at distance d is dequeued before anything at distance d + 1, so the first time you reach a node you reached it by a shortest path. That is the whole proof, and the interviewer wants you to say it, not to cite it. It holds only when every edge costs the same. The moment edges have different weights, see the Dijkstra section.

Two shapes you need from this one template. Distance per node: the dict above. Level by level (when you need "how many rounds" rather than "how far is each node"): drain the queue one layer at a time.

level = 0
while q:
    for _ in range(len(q)):                # exactly the nodes that were in the queue at the start of this level
        u = q.popleft()
        ...
    level += 1

Marking on dequeue instead of enqueue is the single most expensive mistake on this page. A node with k already-queued parents is pushed k times, each push fans out again, and on a dense grid that is exponential duplicate work and a queue that never drains in time. Mark on enqueue.

Connected components: Number of Islands, Max Area, Provinces

A connected component is a maximal set of nodes that can all reach each other. Counting them is: scan every node, and when you hit one you have not seen, count one component and flood from it so you never count its members again. The flood is DFS or BFS, your choice.

Number of Islands (LC 200), worked in full

c0 c1 c2 c3 c4 r0 1 1 0 0 0 scan hits (0,0): island 1, flood marks (0,0) (0,1) (1,0) (1,1) r1 1 1 0 0 0 (0,1) (1,0) (1,1) already seen: skip r2 0 0 1 0 0 (2,2): island 2 r3 0 0 0 1 1 (3,3): island 3, flood marks (3,4) => 3
def num_islands(grid):
    rows, cols = len(grid), len(grid[0])
    seen = set()

    def flood(r, c):                       # iterative DFS; marks every land cell connected to (r, c)
        stack = [(r, c)]
        seen.add((r, c))
        while stack:
            r, c = stack.pop()
            for dr, dc in DIRS4:
                nr, nc = r + dr, c + dc
                if (0 <= nr < rows and 0 <= nc < cols
                        and grid[nr][nc] == '1' and (nr, nc) not in seen):
                    seen.add((nr, nc))
                    stack.append((nr, nc))

    islands = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == '1' and (r, c) not in seen:
                islands += 1
                flood(r, c)
    return islands

O(rc) time: the outer loop touches every cell once, and the floods together touch every land cell once more. O(rc) space for seen and the stack in the worst case (an all-land grid). If you may overwrite, replace seen with grid[nr][nc] = '0' and the extra space drops to the stack. Note the cells are strings '1' here, as on LeetCode; say that you checked.

Follow-ups you should pre-empt: "diagonals count" (swap DIRS4 for DIRS8, one word), "return the sizes" (have flood count and return cells, then sizes.append(...)), "largest island" is Max Area below, "label each cell with its island id" is the Battleships bomb pattern from topic 11.

Max Area of Island (LC 695)

def max_area(grid):
    rows, cols = len(grid), len(grid[0])
    seen = set()
    best = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == 1 and (r, c) not in seen:
                area = 0
                stack = [(r, c)]; seen.add((r, c))
                while stack:
                    cr, cc = stack.pop()
                    area += 1
                    for dr, dc in DIRS4:
                        nr, nc = cr + dr, cc + dc
                        if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1 and (nr, nc) not in seen:
                            seen.add((nr, nc)); stack.append((nr, nc))
                best = max(best, area)
    return best

Same walk, the flood returns a size instead of nothing. Still O(rc).

Number of Provinces (LC 547): components from an adjacency matrix

def find_circle_num(is_connected):
    n = len(is_connected)
    seen = [False] * n
    provinces = 0
    for s in range(n):
        if seen[s]:
            continue
        provinces += 1
        stack = [s]; seen[s] = True
        while stack:
            u = stack.pop()
            for v in range(n):                 # neighbours of u are the 1s in row u
                if is_connected[u][v] and not seen[v]:
                    seen[v] = True
                    stack.append(v)
    return provinces

O(n²) because reading a row of the matrix is O(n) and every row is read once. The interviewer may ask for union-find instead; it is below under Accounts Merge, and it is the right answer when edges arrive one at a time and you are asked "how many groups now?" after each.

Flood Fill (LC 733): the one-trap easy

def flood_fill(image, sr, sc, color):
    old = image[sr][sc]
    if old == color:
        return image                       # without this, every repainted cell still equals old: infinite loop
    rows, cols = len(image), len(image[0])
    stack = [(sr, sc)]
    image[sr][sc] = color                  # the repaint IS the visited mark
    while stack:
        r, c = stack.pop()
        for dr, dc in DIRS4:
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and image[nr][nc] == old:
                image[nr][nc] = color
                stack.append((nr, nc))
    return image

Flood Fill mutates by definition, so the painted value is the visited mark and no set is needed. The trap is the old == color guard: with it missing, a cell painted color still matches old and gets pushed forever. Dry-run that case out loud before the interviewer asks.

Worked in full: Minesweeper (LC 529)

Named in two firsthand NPE reports. The board holds 'M' (hidden mine), 'E' (hidden empty), 'B' (revealed blank), '1'-'8' (revealed, that many mines among the 8 neighbours) and 'X' (a revealed mine). You are given one click. Return the board after the reveal.

The rules, said out loud before coding.

  1. Click a mine: it becomes 'X'. Done.
  2. Click an 'E': count the mines among its 8 neighbours. If the count is nonzero, write the digit and stop. A number is a wall.
  3. If the count is zero, write 'B' and reveal every 'E' neighbour by the same rules. Only zero cells expand.
  4. Clicking an already revealed cell changes nothing. Mines you did not click stay 'M'.
before (mine at (1,2)), click (3,0) after c0 c1 c2 c3 c0 c1 c2 c3 r0 E E E E r0 B 1 E E (0,2) (0,3) (1,3) stay hidden: r1 E E M E r1 B 1 M E every neighbour they have is a r2 E E E E r2 B 1 1 1 digit or the mine, and digits r3 E E E E r3 B B B B never expand

Walk it: (3,0) has zero mines around it, so it becomes 'B' and reveals (2,0), (2,1), (3,1). (2,1) touches the mine at (1,2) diagonally, so it becomes '1' and stops. (2,0) and (3,1) are zeros, so they keep going: up the left column to (0,0), along the bottom row to (3,3). (3,3) reveals (2,2) and (2,3), both '1'. The right-hand 'E's are sealed off by the ring of digits, which is exactly the behaviour you expect from the game.

DFS version (recursive)

DIRS8 = [(dr, dc) for dr in (-1, 0, 1) for dc in (-1, 0, 1) if (dr, dc) != (0, 0)]

def update_board(board, click):
    rows, cols = len(board), len(board[0])
    r, c = click
    if board[r][c] == 'M':
        board[r][c] = 'X'
        return board

    def mines_around(r, c):
        n = 0
        for dr, dc in DIRS8:
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and board[nr][nc] == 'M':
                n += 1
        return n

    def reveal(r, c):                      # precondition: board[r][c] == 'E'
        n = mines_around(r, c)
        if n:
            board[r][c] = str(n)
            return                         # a number is a wall: do not expand past it
        board[r][c] = 'B'                  # mark BEFORE recursing, so neighbours see it as revealed
        for dr, dc in DIRS8:
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and board[nr][nc] == 'E':
                reveal(nr, nc)

    if board[r][c] == 'E':
        reveal(r, c)
    return board

The board is the visited set: once a cell is 'B' or a digit it is no longer 'E', so the == 'E' check stops revisits. The recursion depth is the length of the longest chain of zero cells, which on a 50×50 LeetCode board is fine. Say that on a 1000×1000 board you would switch to the BFS below.

BFS version (iterative, no depth limit)

def update_board_bfs(board, click):
    rows, cols = len(board), len(board[0])
    r, c = click
    if board[r][c] == 'M':
        board[r][c] = 'X'
        return board
    if board[r][c] != 'E':
        return board                       # already revealed: nothing to do

    def mines_around(r, c):
        return sum(1 for dr, dc in DIRS8
                   if 0 <= r + dr < rows and 0 <= c + dc < cols and board[r + dr][c + dc] == 'M')

    def open_cell(r, c):                   # reveal one 'E'; return True if it is a zero that should expand
        n = mines_around(r, c)
        board[r][c] = str(n) if n else 'B'
        return n == 0

    q = deque()
    if open_cell(r, c):
        q.append((r, c))
    while q:
        r, c = q.popleft()
        for dr, dc in DIRS8:
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and board[nr][nc] == 'E':
                if open_cell(nr, nc):      # revealing it is the visited mark; only zeros join the queue
                    q.append((nr, nc))
    return board

Complexity: each cell is revealed at most once and each reveal looks at 8 neighbours, so O(rc) time; the queue or stack holds O(rc) in the worst case. Follow-ups to expect: "what if the click is out of bounds" (validate and return the board), "count how many cells the click revealed" (a counter inside open_cell), "did the player win" (no 'E' left on the board), "do not mutate the input" (copy with [row[:] for row in board] first), "why 8 neighbours and not 4" (the game's rule; say you asked). The recruiter-side comment that this is "easy type" means they expect it clean in under 15 minutes.

Multi-source BFS: Rotting Oranges (LC 994)

Grid of 0 (empty), 1 (fresh), 2 (rotten). Every minute each rotten orange rots its 4 fresh neighbours. Return the minutes until no fresh orange remains, or -1. The trick: seed the queue with every rotten orange at once, then count levels. One BFS from a "virtual source" connected to all of them, instead of one BFS per source.

def oranges_rotting(grid):
    rows, cols = len(grid), len(grid[0])
    q = deque()
    fresh = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == 2:
                q.append((r, c))           # every source starts at level 0
            elif grid[r][c] == 1:
                fresh += 1
    minutes = 0
    while q and fresh:
        for _ in range(len(q)):            # one level = one minute
            r, c = q.popleft()
            for dr, dc in DIRS4:
                nr, nc = r + dr, c + dc
                if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
                    grid[nr][nc] = 2       # mark on enqueue; the grid state is the visited set
                    fresh -= 1
                    q.append((nr, nc))
        minutes += 1
    return 0 if fresh == 0 and minutes == 0 else (minutes if fresh == 0 else -1)

O(rc) time and space. The fresh counter is what gives you -1 without a second scan, and the while q and fresh condition is what stops you from counting an extra minute after the last orange rots. Dry-run the all-rotten grid (answer 0) and a fresh orange walled off by zeros (answer -1). Network framing: this is failure propagation across a fabric, or a broadcast storm spreading one hop per tick from several sources at once. "How many ticks until every device has the update" is this exact code.

Shortest Path in Binary Matrix (LC 1091): 8-direction BFS with a distance

n×n grid of 0 (open) and 1 (blocked). Move in 8 directions through open cells from the top-left to the bottom-right. Return the number of cells on the shortest path, or -1. BFS with the distance carried in the queue, and 8 directions because the problem says so. Read that sentence of the prompt twice.

def shortest_path_binary_matrix(grid):
    n = len(grid)
    if grid[0][0] or grid[n - 1][n - 1]:
        return -1
    seen = {(0, 0)}                        # or write 1 into the grid, if you are allowed to
    q = deque([(0, 0, 1)])                 # (r, c, cells on the path so far); start cell counts as 1
    while q:
        r, c, d = q.popleft()
        if r == n - 1 and c == n - 1:
            return d                       # first time we dequeue the target is the shortest path
        for dr, dc in DIRS8:
            nr, nc = r + dr, c + dc
            if 0 <= nr < n and 0 <= nc < n and grid[nr][nc] == 0 and (nr, nc) not in seen:
                seen.add((nr, nc))
                q.append((nr, nc, d + 1))
    return -1

O(n²) time and space. Check the target on dequeue, not only when enqueuing neighbours, or the 1×1 grid [[0]] (answer 1) falls through to -1. The same template with DIRS4 and a (r, c) target is "fewest hops from the core switch to this rack".

Clone Graph (LC 133): a hashmap from old node to new node

def clone_graph(node):
    if not node:
        return None
    copies = {node: Node(node.val)}        # old → new; doubles as the visited set
    q = deque([node])
    while q:
        u = q.popleft()
        for v in u.neighbors:
            if v not in copies:
                copies[v] = Node(v.val)
                q.append(v)
            copies[u].neighbors.append(copies[v])   # wire the copy of u to the copy of v
    return copies[node]

The dict answers two questions at once: "have I been here" and "where is the copy". Without it, a cycle loops forever and shared neighbours get duplicated. O(V + E). The recursive version is the same with copies as a closure; say the depth caveat.

Accounts Merge (LC 721), and union-find in brief

Each account is [name, email, email, …]. Two accounts are the same person if they share an email. Output merged accounts with sorted emails. This is "connected components where the nodes are emails", plus parsing and sorting. BFS over an email graph works; union-find is shorter and is the structure to name when edges arrive incrementally.

def accounts_merge(accounts):
    parent = {}
    owner = {}                             # email → name

    def find(x):
        parent.setdefault(x, x)
        while parent[x] != x:
            parent[x] = parent[parent[x]]  # path halving: keeps trees flat
            x = parent[x]
        return x

    def union(a, b):
        parent[find(a)] = find(b)

    for name, *emails in accounts:
        for e in emails:
            owner[e] = name
            union(e, emails[0])            # every email in the account joins the first one

    groups = defaultdict(list)
    for e in owner:
        groups[find(e)].append(e)
    return [[owner[root]] + sorted(es) for root, es in groups.items()]

Near-linear: each find is amortised almost O(1) with path compression, plus the sort per group. Union-find answers "same component?" and "how many components?" in O(α(n)) per operation after each new edge; BFS would have to re-walk. Number of Provinces with union-find is the same five lines over the matrix.

Optional depth: topological sort and Dijkstra, with the network framing

Rarely asked at this screen. Know them well enough to explain in two minutes and code in ten, because networks are weighted directed graphs and the interviewer who hears "I would BFS the routing table" will want to know why that is wrong.

Topological sort: Course Schedule (LC 207, 210) with Kahn's algorithm

A topological order of a directed acyclic graph lists every node after all of its prerequisites. Kahn's algorithm: compute in-degrees, queue every node with in-degree 0, pop one, append it to the order, decrement its children, enqueue any child that hits 0. If the order is shorter than V, a cycle exists.

def find_order(num_courses, prerequisites):
    graph = defaultdict(list)
    indeg = [0] * num_courses
    for course, pre in prerequisites:      # edge pre → course
        graph[pre].append(course)
        indeg[course] += 1
    q = deque(i for i in range(num_courses) if indeg[i] == 0)
    order = []
    while q:
        u = q.popleft()
        order.append(u)
        for v in graph[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                q.append(v)
    return order if len(order) == num_courses else []   # LC 207: return len(order) == num_courses

O(V + E). It is BFS with a counter instead of a visited set. Network use: pushing config changes in dependency order (VLAN before the SVI before the routing adjacency), or ordering service restarts. The DFS alternative uses three colours (unvisited, on the current path, done) and reports a cycle when it meets a grey node.

Dijkstra: Network Delay Time (LC 743) with heapq

Directed edges (u, v, w) with w = latency. Signal starts at node k. Return the time until every node has heard it, or -1. Hop count and cost are different things. BFS minimises hops; Dijkstra minimises the sum of weights. In the diagram, BFS says A reaches B in one hop, cost 10. Dijkstra says go through C, two hops, cost 2. Any network with a slow direct link and a fast two-hop path breaks BFS.

10 A ---------- B BFS from A: B is 1 hop away (cost 10) \ / Dijkstra from A: B via C costs 1 + 1 = 2 1 \ / 1 \ / BFS orders the queue by hops. Dijkstra orders a heap by cost so far, C and pops the cheapest unsettled node first. Same shape, different ordering.
import heapq

def network_delay_time(times, n, k):
    graph = defaultdict(list)
    for u, v, w in times:
        graph[u].append((v, w))
    dist = {}                              # node → settled shortest cost
    heap = [(0, k)]                        # (cost so far, node); cost FIRST so the heap orders by it
    while heap:
        d, u = heapq.heappop(heap)
        if u in dist:
            continue                       # stale entry: u was settled earlier with a smaller cost
        dist[u] = d
        for v, w in graph[u]:
            if v not in dist:
                heapq.heappush(heap, (d + w, v))
    return max(dist.values()) if len(dist) == n else -1

O(E log E) with this lazy-deletion style, which is the one to write under time pressure: push duplicates freely and skip them on pop. Dijkstra requires non-negative weights; a negative edge could make an already-settled node cheaper, and you would need Bellman-Ford. Replace the heap with a deque and you have BFS; that is the clearest way to say the relationship. If all weights are equal, Dijkstra and BFS give the same answer and BFS is faster. If weights are only 0 and 1, a deque with appendleft for the 0 edges works ("0-1 BFS"). Prim's algorithm for Min Cost to Connect All Points is Dijkstra with "edge weight" instead of "cost so far" in the heap tuple.

How real routing relates

OSPF is this section running on every router. The link-state database is an adjacency list with costs, every router runs Dijkstra rooted at itself over it, and the resulting shortest-path tree is what fills the routing table. Hop count is RIP's metric, which is why RIP picks a slow direct link over a fast two-hop path and why nobody runs it at scale. BGP is deliberately not a shortest-path algorithm: it ranks paths by policy attributes first and AS-path length only later. Details, timers and the LSDB are in topic 12; the BGP path selection order is in topic 14. When the interviewer asks "what does OSPF compute", the answer is "Dijkstra over the LSDB, one run per router, re-run when an LSA changes", and the code above is what it looks like.

Interview questions

1. BFS or DFS: when do you pick which?Both visit every reachable node in O(V + E). BFS when the question is about fewest hops, levels, or "how many rounds", because it discovers nodes in distance order. DFS when the question is about connectivity, component size, paths with backtracking or cycle detection, because it is shorter to write. On a grid I default to iterative DFS for components and BFS for distances.
2. Why mark a node visited when you enqueue it rather than when you dequeue it?Between enqueue and dequeue, other nodes can see it as unvisited and enqueue it again. Each duplicate fans out, so on a dense grid the queue grows far beyond V and the work is no longer O(V + E). Marking on enqueue guarantees each node enters the queue exactly once.
3. Complexity of BFS and DFS on an adjacency list, and on a grid?O(V + E) time and O(V) space for the visited set and the queue or stack. On a grid V is rc and each cell has at most 4 or 8 neighbours, so E is O(rc) and the whole walk is O(rc). Say "O(cells)" rather than "O(n²)" unless the grid is square and n is its side.
4. Your recursive DFS is given a 1000×1000 grid that is one long snake. What happens?Python's recursion limit is about 1000 frames and the snake is up to a million cells deep, so RecursionError. Fix: rewrite with an explicit stack, which is the same code with stack.pop() in a loop. sys.setrecursionlimit is a hack that can still segfault the interpreter. BFS also avoids the problem.
5. Number of Islands: what changes if diagonal cells count as connected? If you may not modify the grid?Diagonals: swap the 4-direction delta list for the 8-direction one, nothing else. No mutation: use a seen set of (r, c) tuples or a boolean grid instead of overwriting cells, at O(rc) extra space. Always ask both questions before coding.
6. Minesweeper: walk me through the reveal rules and why a numbered cell does not expand.Clicking a mine reveals it as X and stops. Clicking an empty cell counts mines in the 8 neighbours; a nonzero count becomes that digit and stops because the player now has information and the cells beyond could be mines. A zero count becomes B and recursively reveals its hidden neighbours, since none of them can be a mine. The board itself is the visited set because revealed cells are no longer E.
7. Rotting Oranges: why start BFS from every rotten orange at once, and how do you count minutes?All rotten oranges spread at the same time, so they are all at level 0 of the same BFS; one BFS per source would recompute overlapping regions and give wrong times. Count minutes by processing the queue one level at a time: for _ in range(len(q)) drains exactly the current level. Track the fresh count to return -1 without rescanning.
8. Why can't you use BFS to find the lowest-latency path in a network?BFS orders nodes by hop count and assumes every edge costs the same. With latencies, a one-hop 10 ms link loses to a two-hop 2 ms path, and BFS would settle the wrong one first. Dijkstra replaces the queue with a min-heap keyed by cost so far, so the cheapest unsettled node is always popped next. With equal weights the two give the same answer.
9. Does Dijkstra work with negative edge weights?No. It settles a node the first time it pops, assuming no later path can be cheaper, and a negative edge breaks that assumption. Bellman-Ford handles negative edges in O(VE) and detects negative cycles. Routing metrics are non-negative, so Dijkstra is what OSPF and IS-IS run.
10. How do you detect a cycle in a directed graph? In an undirected one?Directed: Kahn's algorithm, and a cycle exists if the topological order has fewer than V nodes; or DFS with three colours, where reaching a grey node on the current path is a back edge. Undirected: DFS that treats seeing any visited node other than the parent as a cycle, or union-find where an edge joining two nodes already in the same set closes a cycle.
11. Clone Graph: how do you avoid infinite loops and duplicated nodes?A dict from original node to its copy. Before creating a copy, check the dict; if the node is there, reuse the copy. The dict is both the visited set and the lookup for wiring neighbours, so every original node gets exactly one copy and cycles terminate. O(V + E).
12. Given links between switches, which switches become unreachable if switch X fails?Build an undirected adjacency list, drop X and its edges (or just skip X in the visited check), BFS from the core switch, and return every switch not in the visited set. O(V + E) per failure. "Which single failure strands the most switches" is that loop over every X, O(V · (V + E)); the linear-time answer is Tarjan's articulation points, which I would name and not code in a screen.

Traps

Do before marking this topic done

  1. Blank editor, 10 minutes: the BFS template with distances and the iterative DFS template, once for an adjacency list and once for a grid. Dry-run BFS on a 3×3 grid and write the queue contents after each level.
  2. Number of Islands, then Max Area of Island, then the variant where diagonals connect, 15 minutes total. State the complexity and whether you mutated the grid before each one.
  3. Minesweeper from memory, DFS version first, then the BFS version. Dry-run both on the 4×4 board in the diagram with click (3,0) and confirm you get the same "after" board, including the three cells that stay hidden. Then answer: "count the revealed cells" and "did the player win".
  4. Network: given links = [('core1', 'agg1'), ('core1', 'agg2'), ('agg1', 'tor1'), ('agg1', 'tor2'), ('agg2', 'tor2'), ('agg2', 'tor3')], write unreachable_if_down(links, root, x) returning the switches that cannot reach root when x fails. Check: agg1 down strands tor1 only; agg2 down strands tor3 only. Then "which single failure strands the most switches", then "hop count from core1 to every switch".
  5. Rotting Oranges with the fresh counter; dry-run the all-rotten grid (0) and a walled-off fresh orange (-1). Then say in one sentence how it models a failure spreading across a fabric.
  6. Optional depth: Network Delay Time with heapq from memory, then explain on the A-B-C diagram why BFS gets it wrong and what OSPF would compute.

Practice: linked problems

Logged attempts feed the tracker. Open the problem in a new tab, solve in a blank editor, then log honestly.

← 12 · Routing fundamentals, OSPF (vs BGP) and IS-IS · all topics · 14 · BGP in depth →