Graphs: visit once, then track prerequisitesLESSON 1.11 · 11 OF 23 IN CHAPTER
PART A / Data structures and algorithms
Step 13 of 252
LESSON 1.11 · 11 OF 23 IN CHAPTERWorked lesson

Graphs: visit once, then track prerequisites

“A deployment must fetch before parsing and parse before saving. Return a legal order, or explain why a newly added dependency makes one impossible.”

An isolated task also belongs in the output. Draw prerequisites before code; distinguish visiting a node from proving all its dependencies have finished.

The coding-practice chapter will apply this tool to complete problems. Here, focus on the mechanism and trace how its state changes.

Working example: Count four-connected islands; then return one valid prerequisite order.

Graphs: visit once, then track prerequisites

The idea: BFS marks on enqueue. Dependency scheduling admits only vertices with zero remaining prerequisites.

Represent connections, then choose a traversal

A graph has nodes (tasks or cells) and edges (relationships). For fetch → parse → save, each arrow means the left task must finish before the right one can run. Breadth-first search (BFS) visits neighbors a layer at a time using the FIFO queue from the earlier lesson.

from collections import deque
queue = deque(["fetch"])
queue.append("parse")      # ["fetch", "parse"]
print(queue.popleft())     # "fetch"; "parse" is next

For an island grid, enqueue a land cell only if it has not already been seen; mark it at enqueue time, because two neighboring cells may discover it before either is processed. For dependency ordering, count how many prerequisites remain for each node. Only a zero count makes it ready:

remaining = {"fetch": 0, "parse": 1, "save": 1}
ready = deque([task for task, n in remaining.items() if n == 0])
# After fetch finishes, decrement parse to 0; then enqueue parse.

The diagram below is a different graph: D cannot start after B alone because it also waits for C. Try crossing off completed tasks and updating each remaining count. A cycle A → B → A has no first ready task; return an explicit cycle result instead of waiting forever.

Diagram: Represent connections, then choose a traversal

BFS and DFS: change the frontier order

An adjacency map stores each node's neighbors. This example is directed: A can reach B and C, and both can reach D.

from collections import deque

graph = {"A": ["B", "C"], "B": ["D"], "C": ["D"], "D": []}
frontier = deque(["A"])
visited = {"A"}
order = []
while frontier:
    node = frontier.popleft()
    order.append(node)
    for neighbor in graph[node]:
        if neighbor not in visited:
            visited.add(neighbor)
            frontier.append(neighbor)
print(order)  # ["A", "B", "C", "D"]

After A, the queue is [B, C]. Processing B adds D: [C, D]. Processing C does not add D again because D was marked when discovered. Storing each node's distance when first discovered gives shortest hop counts in an unweighted graph.

Depth-first search (DFS) follows one branch before returning to alternatives. A stack supplies that order:

frontier = ["A"]
visited = set()
order = []
while frontier:
    node = frontier.pop()
    if node in visited:
        continue
    visited.add(node)
    order.append(node)
    frontier.extend(reversed(graph[node]))
print(order)  # ["A", "B", "D", "C"]

Reversing neighbors makes the leftmost neighbor run first in this stack example. Multiple pending entries can exist, but each node is expanded only once; the stack can hold O(E) entries. A recursive DFS instead retains the active call path, up to O(V) frames. Neither traversal's visited set alone detects a directed cycle: cycle detection needs active-versus-finished state, or the dependency-count method above. A DFS order is not generally a shortest path or a valid prerequisite order.

Both traversals take O(V+E) time over reachable nodes and edges with an adjacency list. To traverse a disconnected graph completely, start again from every unvisited node. The weighted-path lesson later changes the frontier from arrival order to cost order.

Check the mechanism

Predict each expected result, then trace the state that produces it. Explain the boundary case before opening the reference.

Cost: BFS/topological order: O(V+E) time and O(V+E) space. A grid has O(rows × cols) vertices and edges.

01 · Try this input

Input / starting state
Grid [["1", "0"], ["0", "1"]]
Expected result
2 islands

Reason: Diagonals are not four-connected.

02 · Try this input

Input / starting state
Empty grid []
Expected result
0 islands

Reason: No land nodes exist.

03 · Try this input

Input / starting state
Tasks A→C, B→C
Expected result
C after both A and B

Reason: Remaining count starts at 2.

04 · Try this input

Input / starting state
Tasks A→B, B→A
Expected result
Cycle / no valid order

Reason: Neither can start.

05 · Try this input

Input / starting state
Duplicate dependency A→B twice
Expected result
B has one unique prerequisite

Reason: Dedupe edges before counting.

06 · Try this input

Input / starting state
Tasks A with no edges, B→C
Expected result
Order includes A, B, C

Reason: Isolated tasks are still nodes.

V counts nodes and E counts edges. Visiting each node and edge a bounded number of times costs O(V+E). A grid of r × c cells has at most r × c nodes and four neighbor checks per cell, giving O(r×c) time and up to O(r×c) queue/visited space. State whether your input graph is directed; an undirected friendship link does not mean “must finish first.”

Pass before moving on: Show the queue after each step. Detect a cycle by unfinished vertices, not by a guessed timeout.

Add different edge weights. Why can FIFO traversal now return an expensive route first?

After attempting: reference and explanation

Compare islands, course_order in algorithms.py (download file, source below). Use pattern notes for the invariant and contracts for complexity edge cases. Reimplement tomorrow without copying.

algorithms.py · algorithms.py
"""Reference solutions. Try the exercises in README.md before opening this file."""
from collections import Counter, OrderedDict, deque
from heapq import heappop, heappush, nlargest


def two_sum(nums, target):
    visited = {}
    for j, value in enumerate(nums):
        complement = target - value
        if complement in visited:
            return visited[complement], j
        visited.setdefault(value, j)
    return None


def longest_unique(text):
    left = best = 0
    last_seen = {}
    for right, char in enumerate(text):
        left = max(left, last_seen.get(char, -1) + 1)
        best = max(best, right - left + 1)
        last_seen[char] = right
    return best


def subarray_sum(nums, target):
    prefix_counts = {0: 1}
    prefix = result = 0
    for value in nums:
        prefix += value
        result += prefix_counts.get(prefix - target, 0)
        prefix_counts[prefix] = prefix_counts.get(prefix, 0) + 1
    return result


def lower_bound(nums, target):
    lo, hi = 0, len(nums)
    while lo < hi:
        mid = lo + (hi - lo) // 2
        if nums[mid] < target:
            lo = mid + 1
        else:
            hi = mid
    return lo


def merge_intervals(intervals):
    """Closed intervals; touching endpoints merge. Does not mutate input."""
    out = []
    for start, end in sorted(intervals):
        if start > end:
            raise ValueError('reversed interval')
        if out and start <= out[-1][1]:
            out[-1][1] = max(out[-1][1], end)
        else:
            out.append([start, end])
    return out


def top_k_frequent(nums, k):
    if k < 0:
        raise ValueError('k must be nonnegative')
    counts = Counter(nums)
    # Higher count wins; smaller value breaks ties deterministically.
    return nlargest(k, counts, key=lambda value: (counts[value], -value))


def islands(grid):
    """Rectangular list of lists of '0'/'1'; input remains unchanged."""
    if not grid:
        return 0
    cols = len(grid[0])
    if any(len(row) != cols or any(x not in ('0', '1') for x in row) for row in grid):
        raise ValueError('expected rectangular binary grid')
    seen = set()
    count = 0
    for r, row in enumerate(grid):
        for c, value in enumerate(row):
            if value != '1' or (r, c) in seen:
                continue
            count += 1
            seen.add((r, c))
            queue = deque([(r, c)])
            while queue:
                x, y = queue.popleft()
                for a, b in ((x-1, y), (x+1, y), (x, y-1), (x, y+1)):
                    if 0 <= a < len(grid) and 0 <= b < cols and grid[a][b] == '1' and (a, b) not in seen:
                        seen.add((a, b))  # Mark on enqueue, not dequeue.
                        queue.append((a, b))
    return count


def course_order(n, prerequisites):
    """Each pair is (course, prerequisite); [] means a cycle or no courses."""
    if n < 0:
        raise ValueError('negative course count')
    adjacency = [set() for _ in range(n)]
    degree = [0] * n
    for course, prerequisite in prerequisites:
        if not 0 <= course < n or not 0 <= prerequisite < n:
            raise ValueError('course outside graph')
        if course not in adjacency[prerequisite]:
            adjacency[prerequisite].add(course)
            degree[course] += 1
    queue = deque(i for i, d in enumerate(degree) if d == 0)
    order = []
    while queue:
        node = queue.popleft()
        order.append(node)
        for neighbor in adjacency[node]:
            degree[neighbor] -= 1
            if degree[neighbor] == 0:
                queue.append(neighbor)
    return order if len(order) == n else []


def shortest_paths(n, edges, source):
    """Directed nonnegative weighted graph; unreachable distances are infinity."""
    if not 0 <= source < n:
        raise ValueError('invalid source')
    graph = [[] for _ in range(n)]
    for u, v, weight in edges:
        if not 0 <= u < n or not 0 <= v < n or weight < 0:
            raise ValueError('invalid edge')
        graph[u].append((v, weight))
    distance = [float('inf')] * n
    distance[source] = 0
    heap = [(0, source)]
    while heap:
        cost, node = heappop(heap)
        if cost != distance[node]:
            continue
        for neighbor, weight in graph[node]:
            candidate = cost + weight
            if candidate < distance[neighbor]:
                distance[neighbor] = candidate
                heappush(heap, (candidate, neighbor))
    return distance


class LRU:
    """Capacity in entries; None denotes a miss. Not thread-safe."""
    def __init__(self, capacity):
        if capacity < 0:
            raise ValueError('negative capacity')
        self.capacity = capacity
        self.data = OrderedDict()

    def get(self, key):
        if key not in self.data:
            return None
        self.data.move_to_end(key)
        return self.data[key]

    def put(self, key, value):
        if self.capacity == 0:
            return
        self.data[key] = value
        self.data.move_to_end(key)
        if len(self.data) > self.capacity:
            self.data.popitem(last=False)


def min_coins(coins, amount):
    if amount < 0 or any(c <= 0 for c in coins):
        raise ValueError('nonnegative amount and positive coins required')
    dp = [0] + [amount + 1] * amount
    for total in range(1, amount + 1):
        for coin in coins:
            if coin <= total:
                dp[total] = min(dp[total], dp[total-coin] + 1)
    return -1 if dp[amount] > amount else dp[amount]


def daily_temperatures(temperatures):
    answer = [0] * len(temperatures)
    stack = []
    for i, temp in enumerate(temperatures):
        while stack and temperatures[stack[-1]] < temp:
            previous = stack.pop()
            answer[previous] = i - previous
        stack.append(i)
    return answer


def word_exists(board, word):
    """4-neighbor word search; do not reuse a cell or mutate the board."""
    if not word:
        return True
    if not board:
        return False
    cols = len(board[0])
    if any(len(row) != cols for row in board):
        raise ValueError('ragged board')
    def visit(r, c, index, used):
        if not (0 <= r < len(board) and 0 <= c < cols) or (r, c) in used or board[r][c] != word[index]:
            return False
        if index == len(word) - 1:
            return True
        used.add((r, c))
        found = any(visit(a, b, index+1, used) for a, b in ((r+1,c),(r-1,c),(r,c+1),(r,c-1)))
        used.remove((r, c))
        return found
    return any(visit(r, c, 0, set()) for r in range(len(board)) for c in range(cols))


class Trie:
    END = object()

    def __init__(self):
        self.root = {}

    def insert(self, word):
        node = self.root
        for char in word:
            node = node.setdefault(char, {})
        node[self.END] = True

    def contains(self, word):
        node = self.root
        for char in word:
            if char not in node:
                return False
            node = node[char]
        return self.END in node