DP on DAGs
State is a vertex; process vertices in topological order so every predecessor is finalized before its successors.
Overview
Every DP is secretly a DAG of subproblems; DP on DAGs makes that explicit when the input is a graph. If a directed graph has no cycles, Topological Sort gives an order in which every edge goes forward, and any quantity defined as "best/count over paths ending at v" can be computed with one relaxation pass over the edges in that order: dp[v] = best over (u → v) of dp[u] + w(u, v). This solves longest path, shortest path with negative edges, number of paths, and reachability-with-constraints in O(V + E) — problems that are NP-hard or need heavier algorithms on general graphs.
Members: longest path in a DAG (critical path / project scheduling), number of paths from s to t, shortest path in a DAG with negative weights (no Bellman-Ford needed), Longest Increasing Path in a Matrix (edges to strictly larger neighbours), course-schedule-style "minimum semesters", and any problem whose state transitions form a graph given in the input.
Typical constraints: V, E ≤ 10^5–10^6. Both bottom-up (Kahn's BFS order, see Kahn's Algorithm) and top-down (memoized Depth-First Search (DFS), see DFS Topological Sort) work; Kahn's avoids recursion depth issues.
Intuition
A mental model before the formal terms.
A project plan: each task has a duration and prerequisites. The earliest finish time of a task is its duration plus the latest finish among its prerequisites. If you handle tasks in an order where prerequisites always come first, every task can be finalized in one look at its incoming arrows. The latest finish overall is the project length — the longest path.
Compare with a general graph: cycles would let "earliest finish" depend on itself. Acyclicity is exactly what makes a single pass sufficient.
How it works
- State:
dp[v]= optimal value over paths ending atv(or starting atvfor the reverse formulation). - Transition: for each edge
u → v:dp[v] = combine(dp[v], dp[u] + w(u, v))—maxfor longest,minfor shortest,+for counting. - Base cases: sources (in-degree 0):
dp[s] = 0(or1for counting paths from a specific source,0elsewhere). - Order: topological — Kahn's algorithm pushes in-degree-0 vertices to a queue, pops them, relaxes outgoing edges, and pushes successors whose in-degree hits 0. Alternatively, memoized DFS computes
dp[v]on demand. - Answer:
max over v of dp[v]for longest path;dp[t]for a target; also recordparent[v]to reconstruct the path.
Why it works
In a topological order, all predecessors of v appear before v, so when v is processed every dp[u] it reads is final. Induction over the order proves each dp[v] correct.
Each vertex and edge is touched a constant number of times, hence O(V + E). Without cycles there is no need for repeated relaxation rounds (as in Bellman-Ford) or priority queues (as in Dijkstra).
Recognition
How to tell a problem wants this.
- Directed graph explicitly stated to be acyclic, or a dependency/prerequisite structure, or edges defined by a strict order (smaller → larger value, earlier → later time).
- Asks for longest path, number of paths, or an optimal path with negative weights on such a graph.
- A memoized DFS "naturally" solves it with no visited-set trouble because there are no cycles.
Interactive visualization
Play, step, change the input. ← → and space work too.
Showing the closely related Kahn's Algorithm visualization.
1indeg[v] = number of incoming edges2queue = [v for v in nodes if indeg[v] == 0]3while queue not empty:4 u = queue.popleft(); order.append(u)5 for v in neighbors(u):6 indeg[v] -= 17 if indeg[v] == 0: queue.append(v)8if len(order) < n: cycle detectedPseudocode
1# longest path in a DAG (edge weights w), Kahn's order2indeg[v] = number of incoming edges3dp[v] = 0 for all v; queue = all v with indeg 04while queue not empty:5 u = pop()6 for (v, w) in adj[u]:7 dp[v] = max(dp[v], dp[u] + w)8 indeg[v] -= 19 if indeg[v] == 0: push(v)10return max(dp) # if not all vertices popped, the graph has a cycleImplementations
1import math2from collections import deque3 4# DP on a DAG: acyclicity means a topological order exists, and processing5# vertices in that order guarantees every predecessor is final before it is6# read. That is the whole trick — no memo table, no recursion, one sweep.7# Representative example: longest path, path counting, and shortest path with8# negative weights (which Dijkstra cannot do but a DAG sweep can).9 10 111 · Kahn's algorithm produces the order the sweep will follow12def topo_order(adj: list[list[tuple[int, int]]]) -> list[int]:13 n = len(adj)14 indeg = [0] * n15 for row in adj:16 for v, _ in row:17 indeg[v] += 118 ready = deque(u for u, d in enumerate(indeg) if d == 0)19 order: list[int] = []20 while ready:21 u = ready.popleft()22 order.append(u)23 for v, _ in adj[u]:24 indeg[v] -= 125 if indeg[v] == 0:26 ready.append(v)27 return order if len(order) == n else []28 29 302 · Longest path: relax forward in topological order, taking the maximum31def longest_path_from(adj: list[list[tuple[int, int]]], src: int) -> list[float]:32 dist: list[float] = [-math.inf] * len(adj)33 dist[src] = 034 for u in topo_order(adj):35 if dist[u] == -math.inf:36 continue # unreachable from src37 for v, w in adj[u]:38 if dist[u] + w > dist[v]:39 dist[v] = dist[u] + w40 return dist41 42 433 · Shortest path: identical sweep, min instead of max. Negative edges are44# fine here, which is exactly what Dijkstra cannot handle.45def shortest_path_from(adj: list[list[tuple[int, int]]], src: int) -> list[float]:46 dist: list[float] = [math.inf] * len(adj)47 dist[src] = 048 for u in topo_order(adj):49 if dist[u] == math.inf:50 continue51 for v, w in adj[u]:52 if dist[u] + w < dist[v]:53 dist[v] = dist[u] + w54 return dist55 56 574 · Counting paths: the same sweep with addition instead of min or max58def path_counts(adj: list[list[tuple[int, int]]], src: int) -> list[int]:59 ways = [0] * len(adj)60 ways[src] = 161 for u in topo_order(adj):62 if ways[u] == 0:63 continue64 for v, _ in adj[u]:65 ways[v] += ways[u]66 return ways67 68 695 · The longest path over the WHOLE dag: seed every vertex at 070def longest_path_anywhere(adj: list[list[tuple[int, int]]]) -> int:71 dist = [0] * len(adj)72 best = 073 for u in topo_order(adj):74 for v, w in adj[u]:75 if dist[u] + w > dist[v]:76 dist[v] = dist[u] + w77 best = max(best, dist[v])78 return bestfor v, _ in rowunpacks the pair and discards the weight with the conventional underscore.collections.dequegives an O(1) ready queue with no head-cursor workaround.- The explicit
if dist[u] + w > dist[v]comparison instead ofmax(...)avoids a Python-level function call in the innermost loop. path_countsreturnslist[int]and is *exact* at any magnitude — Python integers are unbounded, so exponential path counts are never truncated.-math.infandmath.infare the sentinels, and both saturate under addition.
Path counting is exact here at any size, unlike JS/TS (2^53) and C++ (long long overflow).
graphlib.TopologicalSorter(3.9+) is the standard-library topological sort and raisesCycleErrorrather than returning a sentinel.- Using
math.infmakes the distance listslist[float]even for integer weights; a large integer sentinel keeps themlist[int]. networkx.dag_longest_pathanddag_longest_path_lengthimplement the longest-path sweep directly.- Explicit comparisons beat
max()/min()in a hot Python loop because they avoid a function call per candidate.
- Using a
listwithpop(0)for the ready queue. - Leaving
math.infin a list that downstream code expects to hold integers. - Sweeping a cyclic graph without checking that the order covers every vertex.
- Path counting exposes the integer story: Python is exact at any magnitude, C++ overflows
long longeventually, and JS/TS silently lose precision past 2^53 unless moved toBigInt. - The ready queue splits as always:
collections.dequeandstd::queueare O(1) at the front, JS/TS need the head-cursor array. - Sentinels:
Infinity/math.infsaturate safely, while C++ needsLLONG_MAX / 4headroom so an accidental relaxation cannot overflow. - Only Python ships a topological sort in the standard library (
graphlib), and it is also the only one whose built-in version signals a cycle by raising rather than by returning a short or empty order.
Complexity
Topological sort and the relaxation pass are both linear. Memoized DFS has the same bound plus recursion depth up to V.
Compare growth rates in the Complexity Explorer →When to use — and when not to
- Longest/shortest path or path counting on a graph known to be acyclic.
- Dependency scheduling: critical path, minimum number of rounds/semesters.
- Implicit DAGs: strictly increasing moves on a grid, states ordered by a monotone key.
- The graph may contain cycles — topological order does not exist; use Dijkstra's Algorithm / Bellman-Ford for shortest paths (longest path is NP-hard on general graphs).
- Unweighted shortest path from one source — plain Breadth-First Search (BFS) is simpler.
- You need all-pairs answers on a dense graph — Floyd-Warshall may be more direct.
Alternatives
Common mistakes
- Skipping the cycle check — a cycle silently leaves vertices unprocessed and the answer wrong.
- Initializing
dpto0for all vertices when paths must start at a specific source — use-INFfor non-sources (longest) or0ways (counting). - Relaxing edges in input order instead of topological order — predecessors may not be final.
- Deep recursion in the memoized-DFS variant on long chains; prefer Kahn's for
V ≥ 10^5.
Interview patterns
- Longest Increasing Path in a Matrix (memoized DFS on the implicit DAG).
- Parallel Courses / minimum semesters (longest path in prerequisite DAG).
- All Paths From Source to Target (count/enumerate paths), Number of Ways to Arrive at Destination (Dijkstra + DAG counting).
- Course Schedule with additional constraints; Alien Dictionary follow-ups.
- Course ScheduleIntermediate
- Coin ChangeIntermediate