Graphs and Graph Algorithms Questions
Graph representations (adjacency list and adjacency matrix) and the traversal algorithms applied to general, non-tree structures: BFS, DFS, topological sort (Kahn's algorithm and DFS-based), shortest paths (Dijkstra, Bellman-Ford, A*), minimum spanning trees, cycle detection, connected components, and union-find. Covers modeling a problem as a graph even when the underlying data is not obviously graph-shaped, such as state-space search, an implicit graph over strings or grid cells (for example Word Ladder), or a task-dependency graph, and implementing these traversals with a hash map or hash set as the storage vehicle (adjacency map, visited set, memoization table), not the subject being tested. The graded skill is traversal, ordering, connectivity, or shortest-path reasoning over nodes and edges. This topic does not own: traversal, reconstruction, or serialization of a single-rooted binary tree (preorder, inorder, postorder, or level-order implementation, rebuilding a tree from traversal arrays, lowest common ancestor, binary search tree validation), which belongs to binary trees and binary search trees even though a tree is technically a graph; hash table internals such as hash function design, collision resolution, and load factor and resizing, which belong to hashing and hash tables; and deriving or comparing algorithmic complexity across graph algorithms without implementing them, such as comparing the time complexity of BFS, DFS, Dijkstra, and A*, which belongs to time and space complexity analysis. One of the highest-signal areas in senior coding interviews.
You have a directed service call graph. Propose an algorithm to identify strongly connected components (SCCs) and produce the condensed DAG where each SCC is a single node. Explain how that condensed DAG helps for safe rolling deployments, cyclic dependency detection, and treating SCC members as atomic deployment units.
Sample Answer
Direct answer
Run Tarjan's or Kosaraju's algorithm to find the strongly connected components (SCCs) of the service call graph, then build the condensation: one node per SCC, with a directed edge from SCC A to SCC B whenever some service in A calls some service in B. The condensation is always a directed acyclic graph (DAG), by definition: if it had a cycle among SCCs, those SCCs would themselves be mutually reachable and would have been merged into one larger SCC in the first place. That DAG property is exactly what makes the condensation useful for deployment ordering: a topological sort of the condensation gives a safe deployment order across service GROUPS, and any SCC containing more than one service is a signal of a real cyclic dependency between individual services that cannot be resolved by ordering alone.
Structured elaboration
Why the condensation is guaranteed acyclic. An SCC is, by definition, a MAXIMAL set of mutually reachable nodes: if two SCCs could reach each other in the condensation, every node in one could reach every node in the other and vice versa, which would mean they were never actually two separate maximal mutually-reachable sets, they were one. This is not a property that needs to be separately verified after computing SCCs; it falls directly out of SCCs being maximal.
Using the condensation for safe rolling deployments. A topological order of the condensation DAG gives an order in which SCC groups can be deployed such that no group is deployed before something it depends on has already been updated (deploy in reverse topological order relative to the "calls" direction, i.e., deploy dependencies before dependents, or dependents before dependencies, depending on whether the deployment needs the callee or the caller updated first; the graph's edge direction convention needs to be fixed explicitly and applied consistently). Crucially, this ordering guarantee ONLY holds ACROSS different SCCs; it says nothing about a safe order for deploying multiple services WITHIN the same SCC, since by definition those services have a cyclic calling relationship and no linear order can respect all of it.
Cyclic dependency detection. Any SCC with more than one member is, by definition, a cyclic dependency among those specific services. This is a strictly stronger and more precise signal than generic "does this graph have a cycle" detection (which a simple DFS back-edge check also gives), because the condensation additionally tells you EXACTLY which services participate in each cycle, grouped, which is what an engineer actually needs to act on (which services need to be looked at together to break the cycle, typically by introducing an interface, event, or async boundary that removes the hard call-time dependency).
Treating SCC members as atomic deployment units. Since no safe linear deployment order exists within an SCC, the practical operational answer is to treat every service inside a multi-member SCC as a single atomic deployment unit: they must be deployed together (in the same release, ideally the same rollout step) rather than sequenced, because any staggered order risks a window where an old version of one service is calling a new, incompatible version of another it cyclically depends on, or vice versa.
Worked example
graph LR
subgraph SCC1 ["SCC A (auth-service, session-service)"]
auth[auth-service]
session[session-service]
end
subgraph SCC2 ["SCC B (single service)"]
gateway[api-gateway]
end
subgraph SCC3 ["SCC C (single service)"]
billing[billing-service]
end
gateway --> auth
auth --> session
session --> auth
auth --> billing
Here, auth-service and session-service call each other (a genuine 2-cycle), so Tarjan's or Kosaraju's groups them into one SCC, SCC A. api-gateway and billing-service each participate in no cycle, so each is its own singleton SCC. The condensation is SCC B (gateway) -> SCC A (auth+session) -> SCC C (billing), a clean 3-node DAG with no cycle among the SCCs themselves, even though a cycle exists at the individual-service level. A safe deployment order follows this DAG directly: deploy billing-service first (nothing calls into it from within this graph that would need it not-yet-updated, assuming the deploy convention here is "deploy callees before callers"), then deploy auth-service and session-service TOGETHER as one atomic unit (never one without the other, since a partial deployment leaves one calling the other's incompatible old or new version mid-cycle), then deploy api-gateway last.
Trade-offs and pitfalls
- Common mistake: treating "no cycle detected at the whole-graph level" as sufficient assurance. A large service graph can be acyclic in aggregate while still containing exactly the kind of small, dangerous 2-or-3-service cycle shown above; the condensation surfaces these precisely instead of requiring someone to manually trace suspicious-looking call pairs.
- Common mistake: assuming the condensation alone tells you HOW to fix a cyclic dependency, not just where it is. The condensation identifies which services are entangled; resolving the entanglement (introducing an async event, splitting a shared interface out, or genuinely merging the services) is a design decision the graph alone cannot make.
- The edge-direction convention (does an edge mean "calls" or "depends on," and does deployment order follow or reverse that direction) must be fixed and applied consistently, since flipping it silently produces the reverse of the intended deployment order, a subtle bug that only surfaces as a deployment-time failure rather than a design-time error.
- Treating an SCC as an atomic unit is a necessary operational safeguard but does not by itself reduce deployment risk to the level of an acyclic dependency; a multi-service atomic deployment is inherently riskier (larger blast radius, harder to roll back a partial failure within the group) than deploying single independent services one at a time, which is exactly why identifying and eventually breaking these cycles, not just working around them operationally, is the longer-term fix.
Implement 0-1 BFS in Python for graphs with edge weights only 0 or 1. Input: adjacency list where edges are tuples (neighbor, weight). Output: dict mapping node -> shortest distance from source. Use a deque to achieve O(n + m) time. Explain when 0-1 BFS is preferable to Dijkstra.
Sample Answer
Direct answer
0-1 BFS finds shortest distances in a graph whose edge weights are only 0 or 1 by using a double-ended queue (deque) instead of a priority queue: a weight-0 edge pushes its target to the FRONT of the deque (it costs nothing extra, so it belongs at the same distance layer as the node that just relaxed it), and a weight-1 edge pushes its target to the BACK (it belongs exactly one layer later). This keeps the deque sorted by distance at all times without ever calling a heap operation, giving O(V+E) time, versus Dijkstra's general-purpose O((V+E)logV).
Structured elaboration
The key invariant is: at every point during the algorithm, the deque's contents are sorted by tentative distance, non-decreasing from front to back, with at most two distinct distance values present at once. A weight-0 edge does not disturb this because the new node has the exact same tentative distance as the node currently being processed, so placing it at the front keeps it grouped with the current layer. A weight-1 edge's target belongs to the next layer, so appending it to the back preserves the ordering relative to everything already queued. This is exactly what a priority queue would give you in general, but because there are only ever two possible "next" distances (current, or current + 1), a deque achieves the same ordering property in O(1) per push instead of O(logV).
Worked example
from collections import deque
from typing import Dict, List, Tuple
def zero_one_bfs(adj: Dict[int, List[Tuple[int, int]]], source: int) -> Dict[int, float]:
# 0-1 BFS: edges have weight 0 or 1 only. Returns a dict node -> shortest
# distance from source (float("inf") for unreachable nodes).
dist: Dict[int, float] = {u: float("inf") for u in adj}
dist[source] = 0
dq = deque([source])
while dq:
u = dq.popleft()
for v, w in adj.get(u, []):
if w not in (0, 1):
raise ValueError(f"0-1 BFS requires weights in {{0,1}}, got {w} on edge ({u},{v})")
nd = dist[u] + w
if nd < dist.get(v, float("inf")):
dist[v] = nd
if w == 0:
dq.appendleft(v)
else:
dq.append(v)
return dist
if __name__ == "__main__":
# 0 -(1)-> 1 -(0)-> 2 -(1)-> 3 : the free-shortcut route, total cost 2
# 0 -(1)-> 5 -(1)-> 6 -(1)-> 3 : a strictly longer all-weight-1 route, total cost 3
adj = {
0: [(1, 1), (5, 1)],
1: [(2, 0)],
2: [(3, 1)],
3: [],
5: [(6, 1)],
6: [(3, 1)],
}
dist = zero_one_bfs(adj, 0)
print("Distances from 0:", dist)
import heapq
def dijkstra(adj, source):
d = {u: float("inf") for u in adj}
d[source] = 0
pq = [(0, source)]
while pq:
du, u = heapq.heappop(pq)
if du > d[u]:
continue
for v, w in adj.get(u, []):
nd = du + w
if nd < d[v]:
d[v] = nd
heapq.heappush(pq, (nd, v))
return d
ref = dijkstra(adj, 0)
print("Reference Dijkstra distances:", ref)
print("0-1 BFS matches Dijkstra exactly:", dist == ref)
print("Shortest distance to node 3 uses the weight-0 shortcut (2), not the longer all-weight-1 route (3):", dist[3] == 2)
Output (actually executed with python3):
Distances from 0: {0: 0, 1: 1, 2: 1, 3: 2, 5: 1, 6: 2}
Reference Dijkstra distances: {0: 0, 1: 1, 2: 1, 3: 2, 5: 1, 6: 2}
0-1 BFS matches Dijkstra exactly: True
Shortest distance to node 3 uses the weight-0 shortcut (2), not the longer all-weight-1 route (3): True
The reference dijkstra implementation is a completely independent heap-based algorithm, deliberately not reusing any of zero_one_bfs's logic; the two agreeing on every node's distance is a genuine correctness cross-check, not just a plausible-looking single run. Node 3 is reached at distance 2 via 0 -> 1 -> 2 -> 3 (weights 1, 0, 1), strictly cheaper than the all-weight-1 alternative 0 -> 5 -> 6 -> 3 (weights 1, 1, 1, totaling 3), which confirms the algorithm is genuinely finding the cheapest route by total weight, not merely the route with the fewest hops.
Complexity
Time O(V+E): every node is popped from the deque at most once (each successful relaxation, not each edge inspection, triggers at most one push), and every edge is inspected at most once from its source. Space O(V) for dist and the deque itself, on top of the graph's own O(V+E) adjacency storage.
Edge cases
- Unreachable node: stays at
float("inf")indist, never popped from the deque. - Weight outside
{0, 1}: raisesValueErrorimmediately, since the whole deque-ordering invariant depends on there being only two possible next distances. - A long chain of weight-0 edges: all chained nodes end up tied at the same distance, correctly grouped at the front of the deque together, exactly as the front/back rule intends.
- Source with no outgoing edges:
dist[source] = 0, the deque drains after one pop with nothing to relax, and every other node correctly stays unreachable.
Trade-offs and pitfalls
- When 0-1 BFS is preferable to Dijkstra. Whenever every edge weight is guaranteed to be either 0 or 1, 0-1 BFS gives the exact same answer as Dijkstra in O(V+E) instead of O((V+E)logV), a real win on large sparse graphs where the log factor is not negligible. This shape shows up more often than it looks: unit-cost moves with occasional "free" transitions (a free re-route, a wildcard match, an already-cached lookup) is a common pattern in state-space search problems modeled as graphs.
- Common mistake: using a plain FIFO queue (regular BFS) on a graph that actually has mixed 0/1 weights, silently treating every edge as cost 1. This gives a wrong answer whenever a weight-0 edge exists on the true shortest path, since plain BFS would count it as a full extra step.
- Common mistake: pushing to the back for weight-0 edges and the front for weight-1 edges (the offsets reversed). This breaks the sorted-deque invariant immediately: a weight-0 discovery would then queue behind nodes that are actually farther away, and the algorithm can finalize a node's distance before a cheaper route through the misplaced node has been considered.
- Generalizing beyond {0, 1}. For small integer weight ranges more generally (say, weights in {0,1,…,k} for small fixed k), a bucket-queue (also called dial's algorithm) generalizes this same idea, using k+1 buckets instead of a two-ended deque, still avoiding a full comparison-based heap. Once weights are unbounded or real-valued, none of these tricks apply and a standard priority-queue-based Dijkstra (or Bellman-Ford, if negative weights are possible) is the right tool.
- The value of proving equivalence to Dijkstra on a concrete example (as done above) is that 0-1 BFS is easy to get subtly backward (front versus back) in a way that still produces plausible-looking, sometimes even correct-by-luck, output on small hand-traced examples; an independent Dijkstra cross-check catches the front/back swap that a hand trace alone might not.
Implement Kruskal's algorithm to compute the Minimum Spanning Tree (MST) for an undirected weighted graph. Provide a Python function: def kruskal(n: int, edges: List[Tuple[int,int,int]]) -> List[Tuple[int,int,int]] that returns the list of edges in the MST. Use Union-Find for cycle detection, and discuss sorting complexity and overall runtime.
Sample Answer
Direct answer
Kruskal's algorithm builds a minimum spanning tree (MST) by sorting all edges by weight ascending, then greedily adding each edge to the MST as long as it does not create a cycle with edges already added, using Union-Find to detect cycles in near-constant time per check. The algorithm stops once n−1 edges have been added (a spanning tree on n nodes always has exactly n−1 edges), and total runtime is dominated by the sort: O(ElogE), with the Union-Find operations contributing an additional O(E⋅α(n)), which is asymptotically dominated by the sort.
Structured elaboration
Why greedy-by-weight, skip-cycles is correct. This is the cut property in action: for any partition of the nodes into two non-empty sets, the minimum-weight edge crossing that partition must belong to SOME minimum spanning tree. Processing edges in ascending weight order and adding each one that does not close a cycle is exactly equivalent to repeatedly picking the lightest crossing edge for whatever partition the current partial MST implicitly defines (the current set of already-connected components on one "side," everything else on the other), which is why the greedy approach provably produces a GLOBALLY minimum tree, not just a locally reasonable one.
Role of Union-Find. Two nodes already in the same Union-Find set means a path already connects them using edges already accepted into the MST; adding another edge between them would necessarily create a cycle. Checking find(u) != find(v) before accepting an edge is an O(α(n)) amortized cycle check, far cheaper than an explicit DFS/BFS cycle check per candidate edge, which is what makes Kruskal's practical at scale.
Sorting complexity dominates. Sorting E edges is O(ElogE). Since E≤V2 for a simple graph (and typically E=O(V) for sparse graphs common in practice), O(ElogE) is also frequently written as O(ElogV) (because log(V2)=2logV, a constant factor difference). The Union-Find operations across all E edges cost O(E⋅α(n)), which is asymptotically smaller than the sort, so the sort is the bottleneck: Kruskal's overall complexity is O(ElogE).
Worked example
from typing import List, Tuple
class DSU:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]]
x = self.parent[x]
return x
def union(self, x, y) -> bool:
rx, ry = self.find(x), self.find(y)
if rx == ry:
return False
if self.rank[rx] < self.rank[ry]:
rx, ry = ry, rx
self.parent[ry] = rx
if self.rank[rx] == self.rank[ry]:
self.rank[rx] += 1
return True
def kruskal(n: int, edges: List[Tuple[int, int, int]]) -> List[Tuple[int, int, int]]:
dsu = DSU(n)
mst = []
for u, v, w in sorted(edges, key=lambda e: e[2]):
if dsu.union(u, v):
mst.append((u, v, w))
if len(mst) == n - 1:
break
return mst
if __name__ == "__main__":
n = 6
edges = [
(0, 1, 4), (0, 2, 4), (1, 2, 2),
(2, 3, 3), (2, 5, 2), (2, 4, 4), (3, 4, 3), (5, 4, 3), (5, 3, 1)
]
mst = kruskal(n, edges)
total_weight = sum(w for _, _, w in mst)
print("MST edges:", mst)
print("total weight:", total_weight)
print("edge count:", len(mst), "(expected n-1 =", n - 1, ")")
Output:
MST edges: [(5, 3, 1), (1, 2, 2), (2, 5, 2), (3, 4, 3), (0, 1, 4)]
total weight: 12
edge count: 5 (expected n-1 = 5 )
Trace by hand: edges sorted by weight are (5,3,1), (1,2,2), (2,5,2), (2,3,3), (3,4,3), (5,4,3), (0,1,4), (0,2,4), (2,4,4). Processing in that order: (5,3,1) accepted (new edge, new component {5,3}). (1,2,2) accepted ({1,2}). (2,5,2) accepted, merging {1,2} and {5,3} into {1,2,5,3}. (2,3,3) rejected, 2 and 3 are already in the same component. (3,4,3) accepted ({1,2,5,3,4}). (5,4,3) rejected, same component already. (0,1,4) accepted, merging in node 0, now 5 edges total, stop. Sum: 1+2+2+3+4=12, matching the printed output exactly.
Trade-offs and pitfalls
- Common mistake: forgetting the early-stop condition. Once n−1 edges are accepted, the MST is complete and any further edges are guaranteed to create a cycle if added; continuing to scan the remaining sorted edges (which Union-Find would correctly reject anyway) wastes work, though it does not produce a WRONG answer, only a slower one on a graph where E≫V.
- Kruskal's requires the graph to be CONNECTED to produce a true spanning tree. On a disconnected graph, the algorithm terminates having accepted fewer than n−1 edges (once no more cycle-free edges remain to add), producing a minimum spanning FOREST, one tree per connected component, rather than a single tree; a correct implementation should check
len(mst) == n - 1at the end and treat a shortfall as a signal the graph was disconnected, not silently return a forest labeled as a tree. - Common mistake: implementing the cycle check with a slower structure (a plain visited-array BFS/DFS per candidate edge, O(V+E) each) instead of Union-Find, which turns an O(ElogE) algorithm into something closer to O(E⋅(V+E)) in the worst case, since every one of the E edges could require a full traversal to check.
- Kruskal's is a better fit than Prim's when the edge list is already available and sorting is cheap relative to the graph's density (sparse graphs); Prim's, which grows a single tree outward using a priority queue keyed on the CHEAPEST edge crossing the current tree's boundary, tends to be preferred on dense graphs where an adjacency-matrix-backed Prim's can run in O(V2) without ever needing to sort the full edge list.
Explain the formal differences between a tree and a general graph. Describe properties that define a tree (connected, acyclic, exactly n-1 edges for n nodes), implications such as unique simple path between nodes, and how those properties simplify algorithms (e.g., no need for visited set in some traversals). Give concrete examples of when you'd model a problem as a tree versus as a general graph.
Sample Answer
Direct answer
A tree is a special case of a graph: connected, acyclic, and with exactly n−1 edges for n nodes. Those three properties are not independent facts to memorize separately, any two of them imply the third for a graph on n nodes (connected and acyclic implies exactly n−1 edges; connected with n−1 edges implies acyclic; acyclic with n−1 edges implies connected). The single most useful consequence is that a tree has EXACTLY ONE simple path between any two nodes, which is what lets several algorithms drop bookkeeping that a general graph requires.
Structured elaboration
The defining properties.
- Connected: every node is reachable from every other node.
- Acyclic: no sequence of edges returns to a starting node without repeating an edge.
- Exactly n−1 edges: the minimum number of edges that can connect n nodes (fewer would leave the graph disconnected), and simultaneously the maximum a connected graph can have before a cycle becomes unavoidable.
Why unique simple paths follow. If two different simple paths existed between the same pair of nodes u and v, combining them (going from u to v along one path and back along the other) would trace out a cycle, since the two paths, taken together, revisit u without repeating any single edge twice. This contradicts acyclicity, so at most one simple path can exist; connectivity guarantees at least one exists; together, exactly one.
How this simplifies algorithms. A general graph traversal (breadth-first search (BFS) or depth-first search (DFS)) needs a visited set specifically to avoid two things a tree traversal never has to worry about: re-processing a node reached by more than one route (impossible in a tree, since only one route exists), and looping forever around a cycle (impossible, since there are none). A tree traversal from a chosen root still benefits from tracking "the parent I came from" to avoid immediately walking back the edge just traversed, but that is a much lighter requirement than a full visited set, since it only needs to exclude one specific neighbor (the parent), not remember an arbitrarily large set of everywhere already visited. This is also why tree algorithms can often be written as a clean recursive function of "process this node, then recurse into every neighbor except the parent," with no separate visited-tracking data structure at all.
Worked example
Model as a tree: an organizational reporting hierarchy (each employee has exactly one manager, forming a rooted tree), a filesystem directory structure (each file or folder has exactly one parent directory, ignoring symbolic links), or a binary decision tree used for classification (each internal node has exactly one path down from the root). In each case, "does A report (transitively) to B" or "what is the path from the root to this file" has exactly one answer, and that answer can be found by walking up parent pointers without ever needing to consider an alternative route.
Model as a general graph: a social network (a person can be connected to many others through multiple independent paths, and cycles like mutual-friend triangles are common and meaningful), a service-dependency graph (multiple services may all depend on a shared cache, creating multiple paths between two other services through it), or a road network (there are almost always multiple ways to get from one intersection to another). Forcing any of these into a tree structure would either lose real information (dropping legitimate alternate paths) or require artificially picking one "true" parent per node, discarding the graph's actual shape.
A concrete boundary case worth naming: a graph can look tree-like in casual description ("each task depends on its predecessor") while actually being a directed acyclic graph (DAG), not a tree, the moment a task has more than one direct dependency (two edges pointing INTO the same node). A DAG is still acyclic, but multiple parents mean it is not a tree, and it does not get the "exactly one path between any two nodes" guarantee, a build system's dependency graph is the standard example: a shared library can be a prerequisite for many independent components at once, giving that library multiple incoming edges from unrelated parts of the graph.
Trade-offs and pitfalls
- Common mistake: treating "acyclic" and "tree" as synonyms. A DAG is acyclic but can have nodes with multiple parents (multiple incoming edges), which breaks the unique-simple-path property that trees rely on; algorithms that assume tree structure (a plain parent-pointer walk with no visited set) will double-count or infinite-loop-free-but-redundantly-revisit shared nodes in a DAG, even though the DAG has no cycle to get stuck in.
- Common mistake: assuming "connected with no cycles found so far" is the same as "is a tree," without separately checking the edge count. A connected graph with n nodes and MORE than n−1 edges necessarily contains a cycle (by the same counting argument that shows n−1 is the connectivity-preserving maximum), so if a traversal reports "no cycle found" on a graph that was never checked for edge count, that traversal may simply not have reached the cycle-forming edge yet, especially in a partial or early-terminated search.
- Real systems often START as trees and DRIFT into general graphs as requirements grow: an initial single-parent category hierarchy in a product catalog is a tree, but the moment a product needs to belong to two categories at once (multiple parents), the structure is a DAG, and any code written assuming a tree (recursive descent with no cycle guard, path-uniqueness assumptions in a breadcrumb trail) needs to be revisited, not just extended.
- When in doubt about which structure a system actually has, the edge-count check is cheap and decisive: count edges, count nodes, and confirm ∣E∣=∣V∣−1 AND connectivity before relying on any tree-only simplification; skipping this and assuming "it's basically a tree" is a common source of subtle graph-traversal bugs once the data eventually violates the assumption.
Solve the maximum weight independent set on a tree: given a tree where each node has a non-negative weight, select a set of nodes with no adjacent nodes maximizing total weight. Implement in Python with O(N) time using tree DP. Provide signature: def max_independent_set(adj: Dict[int, List[int]], weights: Dict[int,int]) -> int and explain your DP states.
Sample Answer
Direct answer
This is tree DP: root the tree at any node, and for each node compute two values, the best achievable weight of an independent set in that node's subtree WHEN the node itself is included, and the best when it is EXCLUDED. If a node is included, none of its direct children may be included (so each child contributes its own "excluded" value), and if a node is excluded, each child is free to contribute whichever of its own two values is larger. The answer is the max of the root's two values, computed bottom-up in a single post-order depth-first search (DFS), giving O(N) time since each node is visited once and does O(1) work beyond its recursive calls.
Structured elaboration
Why "included" and "excluded" as the two DP states. An independent set on a tree forbids any two ADJACENT nodes both being chosen. On a tree, adjacency is exactly the parent-child relationship, so the only constraint that ever needs enforcing at any node is between that node and its direct children, not any deeper relationship (a node and its grandchild are never adjacent, so no constraint links them directly; the constraint propagates only through the recursive excluded values, correctly capturing that a grandchild's INCLUSION is not blocked by the root's inclusion, only a direct child's is).
Recurrence. For node u with children c_1, ..., c_k:
incl(u)=w(u)+∑iexcl(ci)
excl(u)=∑imax(incl(ci),excl(ci))
The base case (a leaf with no children) is incl(leaf) = w(leaf), excl(leaf) = 0 (both sums over an empty child list are 0, so the general recurrence already handles leaves correctly without a separate base case).
Why a single post-order pass suffices, no memoization table needed beyond the two per-node values. Each node's incl/excl values depend only on its CHILDREN's incl/excl values, never on anything computed later in a different subtree, so a straightforward post-order DFS (compute all children first, then the current node) naturally produces every value exactly once, in the right dependency order, with no need for a separate memo dictionary keyed by subproblem, unlike DP on a general DAG where multiple paths to the same subproblem can require explicit memoization to avoid recomputation.
Worked example
import sys
from typing import Dict, List
def max_independent_set(adj: Dict[int, List[int]], weights: Dict[int, int]) -> int:
sys.setrecursionlimit(10000)
root = next(iter(adj))
visited = {root}
def dfs(u):
incl = weights[u]
excl = 0
for v in adj.get(u, []):
if v in visited:
continue
visited.add(v)
child_incl, child_excl = dfs(v)
incl += child_excl
excl += max(child_incl, child_excl)
return incl, excl
incl_root, excl_root = dfs(root)
return max(incl_root, excl_root)
if __name__ == "__main__":
# 0(w=6)
# / \
# 1(w=8) 2(w=5)
# | \
# 3(w=3) 4(w=9)
adj = {0: [1, 2], 1: [0, 3], 2: [0, 4], 3: [1], 4: [2]}
weights = {0: 6, 1: 8, 2: 5, 3: 3, 4: 9}
result = max_independent_set(adj, weights)
print("weights:", weights)
print("max independent set weight:", result)
Output:
weights: {0: 6, 1: 8, 2: 5, 3: 3, 4: 9}
max independent set weight: 18
Trace: leaves 3 and 4 have incl=3, excl=0 and incl=9, excl=0 respectively. Node 1: incl = 8 + excl(3) = 8 + 0 = 8, excl = max(incl(3), excl(3)) = max(3, 0) = 3. Node 2: incl = 5 + excl(4) = 5 + 0 = 5, excl = max(incl(4), excl(4)) = max(9, 0) = 9. Root 0: incl = 6 + excl(1) + excl(2) = 6 + 3 + 9 = 18, excl = max(incl(1),excl(1)) + max(incl(2),excl(2)) = max(8,3) + max(5,9) = 8 + 9 = 17. Final answer max(18, 17) = 18, matching the printed output; the optimal set turns out to be {0, 3, 4} (root plus both "excluded child" leaves, weight 6+3+9=18), correctly skipping the higher-weight node 4's parent 2 in favor of taking node 4 itself rather than its parent.
Trade-offs and pitfalls
- Correctness cross-check performed against brute force: for this 5-node tree, every one of the 25=32 possible node subsets was enumerated, independence was checked against all 4 tree edges, and the maximum-weight valid subset was found by brute force to also be 18, confirming the DP's answer exactly matches exhaustive search on this instance.
- Common mistake: writing
excl(u) = max of all children's incl, or all children's excl(picking one branch for ALL children uniformly) instead of taking the max INDEPENDENTLY per child. Each child's contribution toexcl(u)should bemax(incl(c_i), excl(c_i))computed separately for each child, since different children can independently be in their own best state; forcing a single uniform choice across all children would understate the achievable weight whenever the best choice differs child to child. - Recursion depth is O(height), which is O(N) in the worst case (a tree that degenerates into a long chain); a production implementation accepting untrusted or adversarially shaped trees should convert this to an iterative post-order traversal with an explicit stack to avoid a
RecursionErroron a deep, path-like tree. - This recurrence is specific to TREES (a graph with no cycles and a single path between any two nodes). On a general graph, maximum weight independent set is NP-hard; the polynomial-time DP here relies entirely on the tree structure guaranteeing that removing the root splits the problem into completely independent subproblems (the children's subtrees), a decomposition that does not exist for a graph with cycles.
Unlock Full Question Bank
Get access to all Graphs and Graph Algorithms interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.