AI EngineeringZero to ProductionHome·About·Contact
Appendix · Data Structures & Algorithms · Part 14

Advanced graphs & flows

ds5 gave you the graph toolkit — representation, BFS/DFS, topological sort, Dijkstra and Bellman-Ford. This capstone goes past shortest-path-from-one-source into the algorithms that appear in compilers, schedulers, network routing and assignment problems: all-pairs distances, heuristic search, strong connectivity, network flow with the max-flow/min-cut duality, bipartite matching, and the connectivity structure (articulation points and bridges) that tells you where a network breaks. Every implementation here is runnable and its complexity is stated without hand-waving.

⏱️ ~3 hours🎯 Advanced → Industry🕸️ flows & connectivityrunnable

Learning objectives

  • Compute all-pairs shortest paths with Floyd-Warshall and detect negative cycles.
  • Run A* with an admissible heuristic and explain why it beats Dijkstra on informed search.
  • Find strongly connected components two ways — Kosaraju (two DFS passes) and Tarjan (one pass).
  • Model a routing/assignment problem as max-flow and read off the min-cut (max-flow min-cut theorem).
  • Solve maximum bipartite matching with augmenting paths.
  • Locate articulation points and bridges — the single points of failure in a network.
Builds on ds5This lesson assumes the graph basics from D5 · Graphs: adjacency lists, BFS/DFS, topological sort, and single-source Dijkstra/Bellman-Ford. We do not repeat those — we extend them.

1 · Floyd-Warshall — all-pairs shortest paths

Dijkstra answers "shortest path from one source." When you need the distance between every pair of vertices — routing tables, a distance matrix for clustering, transitive closure — Floyd-Warshall does it in one triple loop. The idea: dist[i][j] using only intermediate vertices numbered < k, grown one k at a time. It handles negative edges (unlike Dijkstra) and detects a negative cycle when any dist[i][i] goes below zero.

choose k intermediate relax all i,j O(V²) pairs via k cheaper? dik+dkj update dist[i][j] keep min
Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it — Floyd-Warshall + negative-cycle check
INF = float("inf")

def floyd_warshall(n, edges):
    """All-pairs shortest paths. edges: list of (u, v, w), directed.
    Returns dist matrix; dist[i][j] = cheapest i->j (INF if unreachable)."""
    dist = [[INF] * n for _ in range(n)]
    for i in range(n):
        dist[i][i] = 0
    for u, v, w in edges:
        dist[u][v] = min(dist[u][v], w)      # keep the cheapest parallel edge
    # k is the "highest intermediate vertex allowed" — grow it one at a time
    for k in range(n):
        dk = dist[k]
        for i in range(n):
            dik = dist[i][k]
            if dik == INF:                   # no path i..k, skip the whole row
                continue
            di = dist[i]
            for j in range(n):
                through_k = dik + dk[j]
                if through_k < di[j]:
                    di[j] = through_k
    return dist

def negative_cycle(dist):
    """A negative cycle exists iff some vertex can improve its own 0 distance."""
    return any(dist[i][i] < 0 for i in range(len(dist)))

# 4 nodes; note the cheaper 0->1->2 (=3) beats direct 0->2 (=10)
edges = [(0, 1, 1), (1, 2, 2), (0, 2, 10), (2, 3, 1), (3, 1, 4)]
d = floyd_warshall(4, edges)
print("0->2 :", d[0][2])          # 3, via node 1
print("0->3 :", d[0][3])          # 4
print("negative cycle?", negative_cycle(d))
0->2 : 3
0->3 : 4
negative cycle? False
AspectDijkstra (all sources)Floyd-Warshall
TimeO(V·(V+E) log V)O(V³)
SpaceO(V+E)O(V²)
Negative edgesnoyes (no negative cycle)
Best whensparse graphdense graph / small V / need the matrix
When to reach for itFor dense graphs or when V is small (say ≤ 500), the flat triple loop with no heap overhead often beats running Dijkstra V times in practice, even though both are polynomial.

2 · A* — heuristic (informed) search

Dijkstra expands nodes purely by distance-from-start. A* adds a heuristic h(n) — an estimate of the remaining cost to the goal — and orders the frontier by f(n) = g(n) + h(n). If h is admissible (never overestimates) and consistent, A* is optimal and expands no more nodes than Dijkstra — usually far fewer. With h = 0 it is Dijkstra; with a perfect heuristic it walks straight to the goal.

Try it — A* on a grid with a Manhattan heuristic
import heapq

def astar(graph, start, goal, h):
    """A* on a weighted graph. graph: {node: [(nbr, cost), ...]}.
    h(n) = admissible heuristic (never overestimates the true remaining cost).
    Returns (path, cost) or (None, inf)."""
    # frontier holds (f = g + h, g, node); g is the real cost from start
    frontier = [(h(start), 0, start)]
    best_g = {start: 0}
    parent = {start: None}
    while frontier:
        f, g, node = heapq.heappop(frontier)
        if node == goal:
            path = []
            while node is not None:
                path.append(node)
                node = parent[node]
            return path[::-1], g
        if g > best_g.get(node, float("inf")):
            continue                          # stale heap entry, skip
        for nbr, cost in graph[node]:
            ng = g + cost
            if ng < best_g.get(nbr, float("inf")):
                best_g[nbr] = ng
                parent[nbr] = node
                heapq.heappush(frontier, (ng + h(nbr), ng, nbr))
    return None, float("inf")

# A 3x3 grid; move cost 1; heuristic = Manhattan distance (admissible on a grid)
def grid_graph(n):
    g = {}
    for r in range(n):
        for c in range(n):
            g[(r, c)] = [((r+dr, c+dc), 1) for dr, dc in ((1,0),(-1,0),(0,1),(0,-1))
                         if 0 <= r+dr < n and 0 <= c+dc < n]
    return g

goal = (2, 2)
manhattan = lambda p: abs(p[0]-goal[0]) + abs(p[1]-goal[1])
path, cost = astar(grid_graph(3), (0, 0), goal, manhattan)
print("path :", path)
print("cost :", cost)             # 4 (Manhattan distance corner to corner)
path : [(0, 0), (0, 1), (0, 2), (1, 2), (2, 2)]
cost : 4
Admissibility is the whole gameAn overestimating heuristic makes A* fast but wrong — it can return a suboptimal path. Manhattan distance on a 4-connected grid is admissible (it never exceeds the true steps). A heuristic that guesses too high breaks optimality; one that is 0 just degrades to Dijkstra.

3 · Strongly connected components — Kosaraju

In a directed graph, a strongly connected component (SCC) is a maximal set of vertices where every vertex can reach every other. SCCs reveal cyclic dependency clusters (mutually-recursive modules, deadlock cycles). Kosaraju is the easiest to reason about: DFS the graph recording finish order, reverse every edge, then DFS in decreasing finish order — each tree is one SCC. Two linear passes, O(V+E).

Try it — Kosaraju's two-pass SCC
from collections import defaultdict

def kosaraju(n, adj):
    """Strongly connected components via Kosaraju's two-pass DFS.
    adj: {u: [v, ...]} directed. Returns list of components (lists of nodes)."""
    visited = [False] * n
    order = []                                # finish-time order (post-order)

    def dfs1(u):                              # iterative to dodge recursion limits
        stack = [(u, iter(adj[u]))]
        visited[u] = True
        while stack:
            node, it = stack[-1]
            for v in it:
                if not visited[v]:
                    visited[v] = True
                    stack.append((v, iter(adj[v])))
                    break
            else:
                order.append(node)            # all children done -> record finish
                stack.pop()

    for u in range(n):
        if not visited[u]:
            dfs1(u)

    radj = defaultdict(list)                   # reverse every edge
    for u in range(n):
        for v in adj[u]:
            radj[v].append(u)

    comp = [-1] * n
    def dfs2(u, cid):
        stack = [u]
        comp[u] = cid
        while stack:
            node = stack.pop()
            for v in radj[node]:
                if comp[v] == -1:
                    comp[v] = cid
                    stack.append(v)

    cid = 0
    for u in reversed(order):                  # decreasing finish time
        if comp[u] == -1:
            dfs2(u, cid); cid += 1
    groups = defaultdict(list)
    for node, c in enumerate(comp):
        groups[c].append(node)
    return list(groups.values())

# 0->1->2->0 is one cycle (an SCC); 3 hangs off it alone
adj = {0: [1], 1: [2], 2: [0, 3], 3: []}
print(sorted(sorted(c) for c in kosaraju(4, adj)))   # [[0,1,2],[3]]
[[0, 1, 2], [3]]

4 · Strongly connected components — Tarjan

Tarjan finds SCCs in a single DFS using a discovery index and a low-link (the lowest index reachable from a vertex’s subtree via at most one back-edge). When a vertex’s low == index, it is the root of an SCC and everything above it on the stack forms the component. Same O(V+E) as Kosaraju but one pass and no reversed graph.

Try it — Tarjan's single-pass SCC
def tarjan_scc(n, adj):
    """Tarjan's single-pass SCC. Returns list of components.
    index = DFS discovery time; low = lowest index reachable from the subtree."""
    index = [-1] * n
    low = [0] * n
    on_stack = [False] * n
    stack, comps = [], []
    counter = [0]

    def strongconnect(v):
        # explicit-stack DFS carrying an iterator per frame
        work = [(v, iter(adj[v]))]
        index[v] = low[v] = counter[0]; counter[0] += 1
        stack.append(v); on_stack[v] = True
        while work:
            node, it = work[-1]
            advanced = False
            for w in it:
                if index[w] == -1:
                    index[w] = low[w] = counter[0]; counter[0] += 1
                    stack.append(w); on_stack[w] = True
                    work.append((w, iter(adj[w])))
                    advanced = True
                    break
                elif on_stack[w]:
                    low[node] = min(low[node], index[w])   # back-edge
            if advanced:
                continue
            work.pop()
            if work:                                        # fold child low into parent
                low[work[-1][0]] = min(low[work[-1][0]], low[node])
            if low[node] == index[node]:                    # node is an SCC root
                comp = []
                while True:
                    w = stack.pop(); on_stack[w] = False
                    comp.append(w)
                    if w == node:
                        break
                comps.append(comp)

    for v in range(n):
        if index[v] == -1:
            strongconnect(v)
    return comps

adj = {0: [1], 1: [2], 2: [0, 3], 3: []}
print(sorted(sorted(c) for c in tarjan_scc(4, adj)))   # [[0,1,2],[3]]
[[0, 1, 2], [3]]
KosarajuTarjan
Passes2 DFS1 DFS
Needs reversed graphyesno
Extra statefinish-order listindex + low + on-stack
Feeleasiest to proveone-pass, interview favourite

5 · Max-flow and the min-cut duality

A flow network has a source, a sink, and edges with capacities. Ford-Fulkerson repeatedly finds an augmenting path with spare capacity and pushes flow along it, adding a residual back-edge so later paths can "undo" earlier choices. Picking the path with BFS (shortest augmenting path) is Edmonds-Karp, which runs in O(V·E²) — polynomial and independent of capacity magnitudes. The max-flow min-cut theorem: the maximum flow equals the minimum total capacity you must cut to disconnect source from sink.

BFS augmenting path shortest find bottleneck min residual push + residual back-edge no path left = max flow
Try it — Edmonds-Karp + min-cut extraction
from collections import defaultdict, deque

class MaxFlow:
    """Edmonds-Karp: Ford-Fulkerson using BFS to find augmenting paths.
    Runs in O(V * E^2) — polynomial, unlike naive DFS Ford-Fulkerson."""
    def __init__(self, n):
        self.n = n
        self.cap = defaultdict(int)          # (u, v) -> residual capacity
        self.adj = defaultdict(set)          # neighbours in the residual graph

    def add_edge(self, u, v, c):
        self.cap[(u, v)] += c
        self.adj[u].add(v); self.adj[v].add(u)   # reverse edge starts at 0

    def _bfs(self, s, t, parent):
        parent.clear(); parent[s] = s
        q = deque([s])
        while q:
            u = q.popleft()
            for v in self.adj[u]:
                if v not in parent and self.cap[(u, v)] > 0:
                    parent[v] = u
                    if v == t:
                        return True
                    q.append(v)
        return False

    def max_flow(self, s, t):
        flow, parent = 0, {}
        while self._bfs(s, t, parent):
            # bottleneck = smallest residual capacity along the found path
            v, bottleneck = t, float("inf")
            while v != s:
                u = parent[v]
                bottleneck = min(bottleneck, self.cap[(u, v)])
                v = u
            v = t
            while v != s:                    # push flow, add residual back-edge
                u = parent[v]
                self.cap[(u, v)] -= bottleneck
                self.cap[(v, u)] += bottleneck
                v = u
            flow += bottleneck
        return flow

    def min_cut(self, s):
        """After max_flow, vertices reachable from s in the residual graph are
        the source side of a minimum cut (max-flow min-cut theorem)."""
        seen = {s}; q = deque([s])
        while q:
            u = q.popleft()
            for v in self.adj[u]:
                if v not in seen and self.cap[(u, v)] > 0:
                    seen.add(v); q.append(v)
        return seen

mf = MaxFlow(4)
for u, v, c in [(0,1,3),(0,2,2),(1,2,1),(1,3,2),(2,3,3)]:
    mf.add_edge(u, v, c)
print("max flow 0->3 :", mf.max_flow(0, 3))    # 5 (both source edges saturate)
print("source side of min cut :", sorted(mf.min_cut(0)))
max flow 0->3 : 5
source side of min cut : [0]
Why the residual back-edge mattersWithout the reverse edge, a greedy early path can permanently block a better global assignment. The back-edge lets a later augmenting path reroute flow — it is what makes Ford-Fulkerson correct, not just a heuristic.

6 · Bipartite matching

A bipartite graph splits into two sides (workers/tasks, applicants/jobs) with edges only across. Maximum matching pairs as many as possible with no vertex used twice. It is a special case of max-flow (unit capacities), but Kuhn’s algorithm expresses it directly: for each left vertex, try to find an augmenting path that either lands on a free right vertex or bumps an existing match to a different slot. O(V·E).

Try it — maximum bipartite matching (Kuhn's algorithm)
def hopcroft_style_matching(left, adj):
    """Maximum bipartite matching via repeated augmenting paths (Hungarian /
    Kuhn's algorithm). left = list of left-side nodes; adj[l] = allowed rights.
    Returns dict right -> left. O(V * E)."""
    match_r = {}                              # right node -> matched left node

    def try_augment(u, seen):
        for v in adj[u]:
            if v in seen:
                continue
            seen.add(v)
            # v is free, or its current partner can be rematched elsewhere
            if v not in match_r or try_augment(match_r[v], seen):
                match_r[v] = u
                return True
        return False

    for u in left:
        try_augment(u, set())
    return match_r

# workers -> tasks they can do; find the largest set of disjoint assignments
adj = {"w1": ["t1", "t2"], "w2": ["t1"], "w3": ["t2", "t3"]}
m = hopcroft_style_matching(["w1", "w2", "w3"], adj)
pairs = sorted((l, r) for r, l in m.items())
print("assignments :", pairs)
print("matching size :", len(m))              # 3 — everyone gets a task
assignments : [('w1', 't2'), ('w2', 't1'), ('w3', 't3')]
matching size : 3
Read the outputThe printed pairs are sorted by task for stability; the matching itself is right→left, so each task appears once. Size 3 means everyone is placed: the augmenting-path search pushed w1 onto t2 so that w2 could keep t1 — that reroute is the matching version of a residual back-edge.

7 · Articulation points and bridges

An articulation point (cut vertex) is a node whose removal disconnects the graph; a bridge is an edge whose removal does. These are the single points of failure in a network topology. A single DFS with discovery times and low-links finds both: a non-root vertex u is an articulation point if some child’s subtree cannot reach above u (low[child] ≥ disc[u]); the edge is a bridge if it strictly cannot (low[child] > disc[u]). O(V+E).

Try it — articulation points & bridges in one DFS
def articulation_and_bridges(n, adj):
    """Find articulation points (cut vertices) and bridges (cut edges) in one
    DFS using discovery times and low-links. Undirected graph. O(V + E)."""
    disc = [-1] * n
    low = [0] * n
    timer = [0]
    aps = set()
    bridges = []

    def dfs(root):
        stack = [(root, -1, iter(adj[root]))]
        disc[root] = low[root] = timer[0]; timer[0] += 1
        child_count = 0                       # children of the DFS root
        while stack:
            u, parent, it = stack[-1]
            for w in it:
                if disc[w] == -1:
                    if parent == -1:
                        child_count += 1
                    disc[w] = low[w] = timer[0]; timer[0] += 1
                    stack.append((w, u, iter(adj[w])))
                    break
                elif w != parent:
                    low[u] = min(low[u], disc[w])   # back-edge
            else:
                stack.pop()
                if stack:
                    pu = stack[-1][0]
                    low[pu] = min(low[pu], low[u])
                    # non-root u's parent is an AP if u can't reach above it
                    if stack[-1][1] != -1 and low[u] >= disc[pu]:
                        aps.add(pu)
                    if low[u] > disc[pu]:           # nothing below reaches pu -> bridge
                        bridges.append(tuple(sorted((pu, u))))
        if child_count > 1:                          # root is an AP iff 2+ children
            aps.add(root)

    for v in range(n):
        if disc[v] == -1:
            dfs(v)
    return sorted(aps), sorted(bridges)

# path 0-1-2 with a triangle 2-3-4-2; node 2 and edge (1,2) are cut points
adj = {0: [1], 1: [0, 2], 2: [1, 3, 4], 3: [2, 4], 4: [2, 3]}
aps, bridges = articulation_and_bridges(5, adj)
print("articulation points :", aps)     # [1, 2]
print("bridges :", bridges)             # [(0, 1), (1, 2)]
articulation points : [1, 2]
bridges : [(0, 1), (1, 2)]

✓ Checkpoint — you can move on when you can…

  • Explain when Floyd-Warshall beats running Dijkstra from every source.
  • State the admissibility condition for A* and why it guarantees optimality.
  • Contrast Kosaraju and Tarjan for finding SCCs — passes, reversed graph, state.
  • Describe an augmenting path and why the residual back-edge is required.
  • Reduce a worker/task assignment to either bipartite matching or max-flow.
  • Find the articulation points and bridges of a small graph by hand using low-links.
✓ Knowledge check

You run Edmonds-Karp and get a max flow of 12. Without any extra computation, what is the minimum-cut capacity, and what does that number physically mean?

Show answer
Exactly 12 — the max-flow min-cut theorem says they are equal. Physically it is the cheapest total edge capacity you would have to sever to fully disconnect the source from the sink; the bottleneck of the whole network.
✓ Knowledge check

A colleague sets A*'s heuristic to 2 × the true straight-line distance to make it “faster.” Search does get faster but paths are sometimes longer than optimal. Why?

Show answer
Doubling makes the heuristic inadmissible (it overestimates remaining cost). A* only guarantees an optimal path when h never overestimates; an over-eager heuristic prunes the frontier so aggressively that a genuinely cheaper path can be discarded before it is expanded.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Transitive closureBeginner

Context: Before all-pairs distances, the simpler question is all-pairs reachability — which shows up in dependency analysis and database query planning.

Your task: Compute the reachability matrix of a directed graph: can vertex i reach vertex j?

Requirements:

  • Use the Floyd-Warshall structure but with boolean OR instead of min-plus
  • reach[i][i] is True; seed direct edges
  • O(V³) time
  • Verify on a graph where some pairs are unreachable

💡 Hint: Replace dist[i][k] + dist[k][j] with reach[i][k] and reach[k][j].

Show solution
Solution
def transitive_closure(n, edges):
    """Reachability matrix: reach[i][j] = can i reach j? Floyd-Warshall variant."""
    reach = [[False] * n for _ in range(n)]
    for i in range(n):
        reach[i][i] = True
    for u, v in edges:
        reach[u][v] = True
    for k in range(n):
        for i in range(n):
            if reach[i][k]:
                for j in range(n):
                    if reach[k][j]:
                        reach[i][j] = True
    return reach

edges = [(0, 1), (1, 2), (3, 3)]
r = transitive_closure(4, edges)
print("0 reaches 2 :", r[0][2])    # True (0->1->2)
print("2 reaches 0 :", r[2][0])    # False
print("3 reaches 1 :", r[3][1])    # False
ComplexityO(V³) time, O(V²) space — same triple loop as Floyd-Warshall over booleans.
Exercise 2 · Floyd-Warshall with path reconstructionIntermediate

Context: A distance matrix tells you how far but not which way. Real routing needs the actual hop sequence.

Your task: Extend Floyd-Warshall to also return a next-hop matrix and reconstruct the path between any two vertices.

Requirements:

  • Store nxt[i][j] = the first hop on the cheapest i→j path
  • Update nxt[i][j] = nxt[i][k] whenever relaxing through k
  • Reconstruct by following next-hops until you reach j
  • Return an empty path when j is unreachable

💡 Hint: When a path through k wins, the first hop from i is the same as the first hop of the i→k path.

Show solution
Solution
INF = float("inf")

def floyd_with_paths(n, edges):
    """All-pairs distances plus path reconstruction via a next-hop matrix."""
    dist = [[INF] * n for _ in range(n)]
    nxt = [[None] * n for _ in range(n)]
    for i in range(n):
        dist[i][i] = 0
    for u, v, w in edges:
        if w < dist[u][v]:
            dist[u][v] = w
            nxt[u][v] = v
    for k in range(n):
        for i in range(n):
            if dist[i][k] == INF:
                continue
            for j in range(n):
                if dist[i][k] + dist[k][j] < dist[i][j]:
                    dist[i][j] = dist[i][k] + dist[k][j]
                    nxt[i][j] = nxt[i][k]      # first hop toward j goes via k's path
    return dist, nxt

def reconstruct(nxt, u, v):
    if nxt[u][v] is None:
        return []
    path = [u]
    while u != v:
        u = nxt[u][v]
        path.append(u)
    return path

edges = [(0, 1, 1), (1, 2, 2), (0, 2, 10), (2, 3, 1)]
dist, nxt = floyd_with_paths(4, edges)
print("0->3 cost :", dist[0][3])            # 4
print("0->3 path :", reconstruct(nxt, 0, 3))   # [0, 1, 2, 3]
ComplexityO(V³) to fill both matrices; each reconstruction is O(path length).
Exercise 3 · A* vs Dijkstra node-expansion countAdvanced

Context: The claim “A* explores fewer nodes” is worth measuring, not just asserting — and it makes the role of the heuristic concrete.

Your task: Instrument both searches to count expanded nodes on the same grid, once with a Manhattan heuristic and once with h = 0 (which is Dijkstra).

Requirements:

  • Count a node as expanded when popped with an up-to-date g
  • Run with the real heuristic and with the zero heuristic
  • Show the informed search expands strictly fewer nodes
  • Confirm both return the same optimal cost

💡 Hint: h = lambda _n: 0 turns your A* into Dijkstra with no code duplication — reuse the same function.

Show solution
Solution
import heapq

def astar_vs_dijkstra(graph, start, goal, h):
    """Return (nodes_expanded_astar, nodes_expanded_dijkstra) to show A* explores
    fewer nodes when the heuristic is informative. h=0 makes A* == Dijkstra."""
    def search(heur):
        frontier = [(heur(start), 0, start)]
        best = {start: 0}
        expanded = 0
        while frontier:
            f, g, node = heapq.heappop(frontier)
            if g > best.get(node, float("inf")):
                continue
            expanded += 1
            if node == goal:
                return expanded
            for nbr, c in graph[node]:
                ng = g + c
                if ng < best.get(nbr, float("inf")):
                    best[nbr] = ng
                    heapq.heappush(frontier, (ng + heur(nbr), ng, nbr))
        return expanded
    return search(h), search(lambda _n: 0)

def grid(n):
    g = {}
    for r in range(n):
        for c in range(n):
            g[(r, c)] = [((r+dr, c+dc), 1) for dr, dc in ((1,0),(-1,0),(0,1),(0,-1))
                         if 0 <= r+dr < n and 0 <= c+dc < n]
    return g

goal = (4, 4)
man = lambda p: abs(p[0]-goal[0]) + abs(p[1]-goal[1])
a, d = astar_vs_dijkstra(grid(5), (0, 0), goal, man)
print("A* expanded    :", a)
print("Dijkstra expanded:", d)
print("A* explored fewer:", a < d)
ComplexityBoth are O(E log V) with a heap; the heuristic changes the constant factor (nodes expanded), not the asymptotic class.
Exercise 4 · Condensation graph (SCC → DAG)Expert

Context: Collapsing each SCC to a super-node turns any directed graph into a DAG — the standard preprocessing before topological scheduling of cyclic module graphs.

Your task: Compute SCC ids, then build the condensed graph and confirm it is acyclic.

Requirements:

  • Assign each vertex an SCC id (Tarjan or Kosaraju)
  • Add a condensed edge only between different components, deduped
  • The result must be a DAG (no self-loops on the condensation)
  • Return both the id map and the condensed adjacency

💡 Hint: Iterate original edges once; keep (comp[u], comp[v]) only when the two ids differ.

Show solution
Solution
from collections import defaultdict

def condensation(n, adj):
    """Collapse each SCC into one super-node; the result is always a DAG.
    Uses Tarjan-style SCC ids, then dedupes cross-component edges."""
    index = [-1] * n; low = [0] * n; on = [False] * n
    stack, comp_id = [], [-1] * n
    counter = [0]; cid = [0]

    def sc(v):
        work = [(v, iter(adj[v]))]
        index[v] = low[v] = counter[0]; counter[0] += 1
        stack.append(v); on[v] = True
        while work:
            node, it = work[-1]
            adv = False
            for w in it:
                if index[w] == -1:
                    index[w] = low[w] = counter[0]; counter[0] += 1
                    stack.append(w); on[w] = True
                    work.append((w, iter(adj[w]))); adv = True; break
                elif on[w]:
                    low[node] = min(low[node], index[w])
            if adv:
                continue
            work.pop()
            if work:
                low[work[-1][0]] = min(low[work[-1][0]], low[node])
            if low[node] == index[node]:
                while True:
                    w = stack.pop(); on[w] = False; comp_id[w] = cid[0]
                    if w == node:
                        break
                cid[0] += 1

    for v in range(n):
        if index[v] == -1:
            sc(v)
    dag = defaultdict(set)
    for u in range(n):
        for v in adj[u]:
            if comp_id[u] != comp_id[v]:
                dag[comp_id[u]].add(comp_id[v])
    return comp_id, {k: sorted(v) for k, v in dag.items()}

adj = {0: [1], 1: [2], 2: [0, 3], 3: [4], 4: [3]}   # {0,1,2} and {3,4} are SCCs
comp_id, dag = condensation(5, adj)
print("component of each node :", comp_id)
print("condensed DAG edges    :", dict(dag))
ComplexityO(V+E) for the SCC pass plus O(E) to build the condensation.
Exercise 5 · Project selection via min-cutProfessional

Context: A classic industry reduction: choose a profitable subset of projects where picking one forces picking its prerequisites. This is maximum-weight closure, solved by min-cut.

Your task: Given per-project profits (positive or negative) and prerequisite edges, return the maximum achievable total profit.

Requirements:

  • Source→project (capacity = profit) for profitable projects
  • Project→sink (capacity = |loss|) for costly ones
  • Prerequisite edges get ∞ capacity so they are never cut
  • Max profit = sum of positive profits − min cut

💡 Hint: The min cut separates “take” from “drop”; infinite prerequisite edges make an inconsistent selection cost infinity.

Show solution
Solution
from collections import defaultdict, deque

def project_selection(profits, prereqs):
    """Maximum-weight closure via min-cut: pick a subset of projects to maximise
    profit, where selecting a project forces selecting its prerequisites.
    Classic reduction of a scheduling problem to max-flow/min-cut."""
    n = len(profits)
    S, T = n, n + 1                            # super source / sink
    cap = defaultdict(int); adj = defaultdict(set)
    def edge(u, v, c):
        cap[(u, v)] += c; adj[u].add(v); adj[v].add(u)

    total_gain = 0
    for i, pr in enumerate(profits):
        if pr > 0:
            edge(S, i, pr); total_gain += pr   # profit if kept on source side
        elif pr < 0:
            edge(i, T, -pr)                     # cost if kept on source side
    INF = 10 ** 9
    for i, deps in enumerate(prereqs):
        for d in deps:
            edge(i, d, INF)                     # can't take i without prerequisite d

    def bfs(parent):
        parent.clear(); parent[S] = S; q = deque([S])
        while q:
            u = q.popleft()
            for v in adj[u]:
                if v not in parent and cap[(u, v)] > 0:
                    parent[v] = u
                    if v == T:
                        return True
                    q.append(v)
        return False

    flow, parent = 0, {}
    while bfs(parent):
        v, b = T, INF
        while v != S:
            b = min(b, cap[(parent[v], v)]); v = parent[v]
        v = T
        while v != S:
            u = parent[v]; cap[(u, v)] -= b; cap[(v, u)] += b; v = u
        flow += b
    return total_gain - flow                    # max profit = gains - min cut

# project 0 (+10) needs 1 (-4); project 2 (+3) needs 1 too -> take 0,1,2 = 9
profit = project_selection([10, -4, 3], [[1], [], [1]])
print("max profit :", profit)                  # 9
ComplexityDominated by the max-flow step: Edmonds-Karp is O(V·E²).
Exercise 6 · On-call shift assignment as max-flowIndustry scenario

Context: On-call scheduling with per-engineer capacity and skill constraints is a real bipartite-flow problem — the same shape as ad allocation and cloud bin-packing.

Your task: Model engineers (with a shift-count cap) and skill-limited shifts as a flow network and compute the maximum number of shifts that can be covered.

Requirements:

  • Source→engineer with capacity = that engineer’s shift limit
  • Engineer→shift (capacity 1) only for shifts they are skilled for
  • Shift→sink with capacity 1 (each shift needs one person)
  • The max flow is the number of covered shifts

💡 Hint: Per-engineer capacity lives on the source edge, not on the skill edges — that is what enforces the workload cap.

Show solution
Solution
from collections import defaultdict, deque

class FlowNetwork:
    """Edmonds-Karp with capacity scaling awareness kept simple. Used here to
    solve a real assignment: route on-call requests to engineers with capacity
    and skill constraints, maximising covered shifts."""
    def __init__(self, n):
        self.n = n
        self.cap = defaultdict(int); self.adj = defaultdict(set)
    def add_edge(self, u, v, c):
        self.cap[(u, v)] += c; self.adj[u].add(v); self.adj[v].add(u)
    def max_flow(self, s, t):
        flow, parent = 0, {}
        while self._bfs(s, t, parent):
            v, b = t, float("inf")
            while v != s:
                b = min(b, self.cap[(parent[v], v)]); v = parent[v]
            v = t
            while v != s:
                u = parent[v]; self.cap[(u, v)] -= b; self.cap[(v, u)] += b; v = u
            flow += b
        return flow
    def _bfs(self, s, t, parent):
        parent.clear(); parent[s] = s; q = deque([s])
        while q:
            u = q.popleft()
            for v in self.adj[u]:
                if v not in parent and self.cap[(u, v)] > 0:
                    parent[v] = u
                    if v == t:
                        return True
                    q.append(v)
        return False

# 2 engineers (cap 2 shifts each) cover 3 shifts they are skilled for.
# Nodes: S=0, engineers 1..2, shifts 3..5, T=6
net = FlowNetwork(7)
S, T = 0, 6
net.add_edge(S, 1, 2); net.add_edge(S, 2, 2)     # per-engineer capacity
skills = {1: [3, 4], 2: [4, 5]}                   # which shifts each can take
for eng, shifts in skills.items():
    for sh in shifts:
        net.add_edge(eng, sh, 1)
for sh in (3, 4, 5):
    net.add_edge(sh, T, 1)                        # each shift needs 1 engineer
print("shifts covered :", net.max_flow(S, T))    # 3 — full coverage possible
ComplexityEdmonds-Karp: O(V·E²); on unit-capacity bipartite graphs it behaves like O(E·√V) in practice.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in