DSA DSA In Depth
0%
lesson dynamic-programmingsrtbotoptimal-substructure

Dynamic Programming as Shortest Paths on an Implicit DAG: Optimal Substructure & SRTBOT Framework

7 min read Module 6.2: Dynamic Programming Foundations & Classical Archetypes Updated Oct 23, 2026
📄 Cheat Sheet
5 Simple Steps for Solving Dynamic Programming Problems (Subproblem DAGs)
Reducible 5 Simple Steps for Solving Dynamic Programming Problems (Subproblem DAGs) Watch on YouTube ↗

Visualizes Dynamic Programming problems as finding shortest/longest paths over an explicit Directed Acyclic Graph (DAG) of subproblem transitions.

0:00 / --:--

1. The Implicit State-Space DAG & The SRTBOT Design Framework

Every Dynamic Programming problem reduces to evaluating a function on the vertices $V$ of an implicit Directed Acyclic Graph (DAG) $G = (V, E)$, where a directed edge $u \to v$ means computing state $v$ depends on the value of state $u$. MIT's 6.006 SRTBOT Framework turns DP design into a mechanical 6-step checklist:

  1. S — Subproblem Definition: Define the exact meaning of state $x \in V$ in plain English (often prefixes $A[0..i]$, suffixes $A[i..n]$, or substrings $A[i..j]$) and count $|V|$.
  2. R — Relate (Recurrence): Guess the last local decision among out-neighbors (or in-neighbors) of $x$ and express $dp[x] = \bigoplus_{u \to x} (dp[u] \otimes w(u, x))$ over a semiring $(\min, +)$, $(\max, +)$, or $(+, \times)$.
  3. T — Topological Order: Prove that the dependency graph $G$ is acyclic and specify an evaluation loop order (or DFS post-order) where every $u$ is finalized before $x$.
  4. B — Base Cases: Explicitly initialize source vertices in $G$ (in-degree $0$).
  5. O — Original Problem: Express the final answer as a single sink state $dp[t]$ or an aggregation over a subset of states.
  6. T — Time & Space Analysis: $\text{Time} = \sum_{x \in V} \text{work}(x) = O(|V| + |E|)$.

Core Invariant — Bellman's Principle of Optimality & Acyclicity

A problem exhibits Optimal Substructure if an optimal solution to state $x$ can be constructed strictly from optimal solutions to its predecessor subproblems $u \in N^-(x)$, paired with a Markovian (memoryless) state definition: how we arrived at state $u$ does not restrict which future transitions are legal from $u$. If how you reached $u$ matters (e.g., Longest Simple Path in a graph with cycles, which is NP-hard), then $u$ is missing state dimensions!

2. Pull DP ("Gather") vs. Push DP ("Scatter") & Parent Pointers

Given a topological ordering of states $u_1, u_2, \dots, u_{|V|}$, there are two dual ways to execute the relaxations:

  • Pull DP (In-Edge Relaxation): When standing at state $x$, iterate over all incoming edges $u \to x$: dp[x] = min(dp[x], dp[u] + cost(u, x)). Ideal when in-neighbors are easy to enumerate and allows immediate register accumulation of dp[x].
  • Push DP (Out-Edge Relaxation): Once dp[u] is finalized, iterate over all outgoing edges $u \to v$: dp[v] = min(dp[v], dp[u] + cost(u, v)). Ideal when unreachable states can be skipped (if (dp[u] == INF) continue;) or when outgoing transitions are cleaner to express than incoming ones.
flowchart LR subgraph Pull["Pull DP at State x (Read Finalized u)"] U1["dp[x - c_1]"] -->|"cost c_1"| X["Compute dp[x] in register"] U2["dp[x - c_2]"] -->|"cost c_2"| X end subgraph Push["Push DP from Finalized State u"] U["Finalized dp[u]"] -->|"Relax +c_1"| V1["dp[u + c_1]"] U -->|"Relax +c_2"| V2["dp[u + c_2]"] end

Hardware Reality — Pull DP Register Accumulation vs. Push DP Store-to-Load Forwarding

In a tight inner loop, Pull DP accumulates best = std::min(best, dp[u] + w) inside a single CPU register (%rax) and performs one memory write dp[x] = best at the end of the loop. In contrast, Push DP repeatedly reads and writes different memory addresses dp[u + step], which can stall the CPU's Store-to-Load forwarding buffer if addresses alias or exceed L1 cache lines. Conversely, when 95% of the state space is unreachable (dp[u] == INF), Push DP skips the inner loop entirely via a single branch, running orders of magnitude faster!

3. Complexity Matrix

DP State DAG, 0/1 Knapsack Reverse Sweep & Myers Diff
O(N·W) / O(N·D) Edit Graph

Observe why 0/1 Knapsack iterates capacity w downwards (preventing item reuse), how Patience Sorting finds LIS in O(N log N), and how Myers Diff slides along diagonal snakes k = x - y.

Step 1 / 8
Custom Input:
Timeline

Invariant & State

Item 1 (wt=3, val=4): Sweep w = 7 downTo 3 so dp[w - 3] always reads the previous item layer.
🔗 Live Pointer & State Transition Graph — DP State DAG, 0/1 Knapsack Reverse Sweep & Myers Diff Active Pointer Jump/Transition Matched/Optimal
w=0 0 [0] 0 [1] 0 [2] wt=3, v 4 [3] 4 [4] 4 [5] 4 [6] dp[7]=4 4 [7]
[0]0w=0
[1]0
[2]0
[3]4wt=3, val=4
[4]4
[5]4
[6]4
[7]4dp[7]=4
Item 1
weight=3, value=4
Sweep Direction
w = 7 → 3 (Reverse)
Best dp[7]
4
[Step 1] Initialized 1D table with Item 1 (wt=3, val=4) across capacities 3..7.
Subproblem Domain Number of States $|V|$ Transitions per State Total Time $O(|V| + |E|)$ State Space Size
1D Prefix / Suffix ($A[0..i]$) O(N) O(1) or O(N) O(N) or O(N^2) O(N)
Pair of Prefixes ($A[0..i], B[0..j]$) O(N * M) O(1) O(N * M) O(N * M)
2D Contiguous Subarray ($A[i..j]$) O(N^2) O(j - i) O(N^3) O(N^2)
Subset Bitmask ($S \subseteq \{0..N-1\}$) O(2^N) O(N) O(N * 2^N) O(2^N)

4. Reference C++20 Implementation

#include <algorithm>
#include <cstdint>
#include <limits>
#include <vector>

// Minimum Coin Change with Full Optimal Solution Reconstruction (DAG Shortest Path + Parent Pointers)
struct CoinChangeResult {
    int min_coins; // -1 if unreachable
    std::vector<int> chosen_coins;
};

CoinChangeResult solve_coin_change_with_reconstruction(const std::vector<int>& coins, int amount) {
    constexpr int INF = 1'000'000'000;
    // S (Subproblems): dp[x] = min coins to form exact sum x in [0..amount]
    std::vector<int> dp(amount + 1, INF);
    std::vector<int> parent_coin(amount + 1, -1);

    // B (Base Case): 0 coins needed to form sum 0
    dp[0] = 0;

    // T (Topological Order): x = 1 .. amount (since coins > 0, u = x - c < x is acyclic!)
    for (int x = 1; x <= amount; ++x) {
        int best = INF;
        int best_c = -1;
        // R (Relate - Pull DP): dp[x] = 1 + min_{c in coins} dp[x - c]
        for (int c : coins) {
            if (x >= c && dp[x - c] + 1 < best) {
                best = dp[x - c] + 1;
                best_c = c;
            }
        }
        dp[x] = best;
        parent_coin[x] = best_c;
    }

    if (dp[amount] >= INF) {
        return { -1, {} };
    }

    // Backpointer Traversal along the Implicit DAG from sink amount back to source 0
    std::vector<int> used;
    for (int cur = amount; cur > 0; cur -= parent_coin[cur]) {
        used.push_back(parent_coin[cur]);
    }
    return { dp[amount], std::move(used) };
}

Production Pitfall — Cyclic State Transitions & Sentinel Overflow (INF + cost)

Two errors break DP solvers in production: First, if a coin can have value $c = 0$ (or negative weight cycles exist), the transition $x \to x - 0$ is a self-loop ($G$ is no longer a DAG!). Second, initializing unreachable states to std::numeric_limits<int>::max() and evaluating dp[x - c] + 1 causes signed 32-bit integer overflow (Undefined Behavior in C++, wrapping around to INT_MIN and corrupting std::min). Always use a safe sentinel such as 1'000'000'000 (0x3f3f3f3f) so INF + cost cannot overflow.

Check yourself Question 1 of 4
Concept Check Implicit DAG Requirement in Dynamic Programming recall

In the MIT 6.006 SRTBOT Dynamic Programming framework, why must the dependency graph $G = (V, E)$ of subproblem states and transitions be a Directed Acyclic Graph (DAG)?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Why does the naive subproblem definition $dp[u] =$ 'length of the longest simple path from $s$ to $u$' fail to satisfy Optimal Substructure on a general graph (or even when trying to avoid revisiting vertices without recording the visited set)?

Key Takeaways

Every Dynamic Programming problem is equivalent to relaxing vertices in topological order on an implicit Directed Acyclic Graph (DAG) of states.
The 6-step SRTBOT framework (Subproblems, Relate, Topological order, Base cases, Original problem, Time) guarantees a complete, non-cyclic formulation.
Pull DP accumulates transitions into a local register before writing dp[x], whereas Push DP skips unreachable states with dp[u] == INF.
Reconstructing the actual optimal choices requires either storing O(|V|) argmin parent pointers or re-checking which predecessor satisfies dp[x] == dp[u] + w(u, x) backward from the sink.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue