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.
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.
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.
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
| Aspect | Dijkstra (all sources) | Floyd-Warshall |
|---|---|---|
| Time | O(V·(V+E) log V) | O(V³) |
| Space | O(V+E) | O(V²) |
| Negative edges | no | yes (no negative cycle) |
| Best when | sparse graph | dense graph / small V / need the matrix |
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.
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
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).
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.
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]]
| Kosaraju | Tarjan | |
|---|---|---|
| Passes | 2 DFS | 1 DFS |
| Needs reversed graph | yes | no |
| Extra state | finish-order list | index + low + on-stack |
| Feel | easiest to prove | one-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.
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]
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).
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
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).
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.
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
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
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.
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
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
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
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]
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
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)
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
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))
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
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
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
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