DSA DSA In Depth
0%
design state-space-graphlayered-dijkstramodeling

Modeling Problems as State-Space Graphs: Layered Graphs ((u, k) States) & Bitmask Dijkstra

7 min read Module 5.2: Disjoint Sets, Minimum Spanning Trees & Shortest Paths Updated Oct 23, 2026
📄 Cheat Sheet
State-Space Graph Shortest Paths & Multi-State BFS/Dijkstra Modeling
NeetCodeIO State-Space Graph Shortest Paths & Multi-State BFS/Dijkstra Modeling Watch on YouTube ↗

Shows how augmenting vertex definitions with auxiliary state variables turns constrained optimization problems into standard Dijkstra.

0:00 / --:--

The Product Graph Lifting Principle

Suppose we want the shortest path from $s$ to $t$ in $G = (V, E, w)$, given at most $K$ uses of a special transition (e.g., halving or zeroing an edge's weight). In the base graph $G$, reaching $u$ with cost $10$ and $0$ coupons left is incomparable to reaching $u$ with cost $15$ and $2$ coupons left. We restore total ordering per state by defining the $(K+1)$-Layer Graph:

Formal Construction — Layered State-Space Graph $G_K = (V \times \{0..K\}, E_K)$

For each original vertex $u \in V$ and layer $k \in \{0, 1, \dots, K\}$, create state $(u, k)$ indexed in a flat 1D array as $\text{id}(u, k) = u \cdot (K + 1) + k$. For each original edge $(u, v)$ of weight $w$:

  • Intra-Layer Transition (Normal Edge): For all $0 \le k \le K$, add edge $(u, k) \to (v, k)$ with weight $w$.
  • Inter-Layer Transition (Coupon / State Upgrade): For all $0 \le k < K$, add edge $(u, k) \to (v, k + 1)$ with discounted weight $0$ (or $\lfloor w / 2 \rfloor$).

Then $\min_{0 \le k \le K} \text{dist}[(t, k)]$ is the exact optimal answer in $O(K(V + E) \log(KV))$ time!

Common State-Space Modeling Patterns

  1. Fuel Tank / Battery Capacity ($V \times [0..F]$): At state $(u, f)$, either buy $1$ unit of fuel at price $p[u]$ via transition $(u, f) \to (u, f+1)$ of weight $p[u]$, or drive an edge $(u, v)$ of length $L \le f$ via transition $(u, f) \to (v, f - L)$ of weight $0$.Notice that splitting fuel refilling into $+1$ increments keeps out-degree $O(\text{deg}(u) + 1)$ instead of $O(\text{deg}(u) + F)$!
  2. Bitmask Keys & Locks ($V \times [0..2^K - 1]$): When a maze has $K \le 12$ keys and locked doors, state $(u, \text{mask})$ transitions to $(v, \text{mask} \mid \text{keysAt}[v])$ whenever $(\text{mask} \,\&\, \text{requiredDoor}[u][v]) == \text{requiredDoor}[u][v]$.
  3. Edge Parity / Modulo-$M$ Path Length ($V \times [0..M-1]$): Finding the shortest path of even/odd length or weight divisible by $M$ uses state $(u, r)$ where $r \in [0, M-1]$ is the remainder modulo $M$.
Layered Graph: Normal Intra-Layer Edges (w) vs. Discounted Inter-Layer Jumps (0) Layer k = 0 (s,0) (u,0) (t,0) +w(s,u) +w(u,t) Layer k = 1 (s,1) (u,1) (t,1) +w(u,t) coupon: +0
flowchart LR Pop["Pop (d, u, k) from Min-Heap"] --> Guard{"d > dist[u][k]?"} Guard -- "Yes" --> Pop Guard -- "No" --> SameLayer["Relax (u, k) -> (v, k) with cost +w"] SameLayer --> NextLayer{"k < K?"} NextLayer -- "Yes" --> Upgrade["Relax (u, k) -> (v, k+1) with discounted cost"] NextLayer -- "No" --> Continue["Next neighbor"]
Shortest Paths: Dijkstra, A* Consistent Potentials & OSRM Contraction Hierarchies
O((V+E) log V) · A* f=g+h · CH Shortcuts

Compare Dijkstra’s uniform radial frontier against A* directional pruning f(u) = g(u) + h(u) and OSRM Contraction Hierarchies (shortcut insertion w(u,w)=w(u,v)+w(v,w) + upward rank-monotonic bidirectional search).

Step 1 / 8
Custom Input:
Timeline

Invariant & State

Pop S (g=0, h=6): relax B (g=2, h=4 ⇒ f=6) and C (g=5, h=4 ⇒ f=9). Admissible heuristic h(u) ≤ d(u,T) pulls B ahead of C!
w=2 w=5 w=2 w=2 w=4 S g=0,f=6 B g=2,f=6 C g=5,f=9 D g=∞ T g=∞
Priority Queue
[(B, f=6), (C, f=9)]
Pruned Detour
Vertex C (f=9 > d*(T)=6)
Consistency
w_h(u,v) = w(u,v) + h(v) - h(u) ≥ 0
[Step 1] Pop S (g=0, h=6): relax B (g=2, h=4 ⇒ f=6) and C (g=5, h=4 ⇒ f=9). Admissible heuristic h(u) ≤ d(u,T) pulls B ahead of C!
Hardware Reality — Implicit Transitions vs. Materializing $G_K$ in RAM

Never allocate a physical adjacency list for $G_K$! Generating $K$ copies of $E$ edges wastes $O(K \cdot E)$ memory and pollutes L3 cache. Instead, keep the original graph $G$ read-only in L1/L2 cache, flatten the distance table to dist[u * (K + 1) + k], and generate the intra-layer and inter-layer transitions on-the-fly inside the Dijkstra loop.

Complexity Matrix

State Space Model Expanded Vertices $|V'|$ Expanded Edges $|E'|$ Total Time Complexity
$K$-Coupon Layered Graph V · (K + 1) 2 · E · K O(K(V + E) log(KV))
Fuel Tank ($+1$ Unit Refill) V · (F + 1) (V + E) · F O(F(V + E) log(FV))
Bitmask Keys ($K$ Keys) V · 2^K E · 2^K O(2^K (V + E) log(V 2^K))

Production Implementation: Implicit Layered Dijkstra ($K$ Free Edges)

#include <vector>
#include <queue>
#include <limits>
#include <algorithm>

struct FlightEdge {
    int to;
    long long cost;
};

long long shortestPathWithKCoupons(int n, int kMax, int src, int dst,
                                   const std::vector<std::vector<FlightEdge>>& adj) {
    constexpr long long INF = std::numeric_limits<long long>::max() / 4;
    const int stride = kMax + 1;
    std::vector<long long> dist(n * stride, INF);

    using State = std::pair<long long, int>; // (cost, encodedState = u * stride + k)
    std::priority_queue<State, std::vector<State>, std::greater<State>> pq;

    dist[src * stride + 0] = 0;
    pq.emplace(0, src * stride + 0);

    while (!pq.empty()) {
        auto [d, state] = pq.top();
        pq.pop();
        if (d > dist[state]) continue;

        int u = state / stride;
        int k = state % stride;
        if (u == dst) return d; // First time any layer of dst is popped is global min!

        for (const auto& e : adj[u]) {
            // 1. Normal edge on layer k
            int nextSame = e.to * stride + k;
            if (d + e.cost < dist[nextSame]) {
                dist[nextSame] = d + e.cost;
                pq.emplace(dist[nextSame], nextSame);
            }
            // 2. Coupon transition to layer k + 1 (if available)
            if (k < kMax) {
                int nextUp = e.to * stride + (k + 1);
                if (d < dist[nextUp]) {
                    dist[nextUp] = d;
                    pq.emplace(dist[nextUp], nextUp);
                }
            }
        }
    }
    return -1;
}
Failure Mode — Dominance Pruning Neglect & State Explosion

In layered graphs where a larger resource $k$ is strictly worse (i.e., $k$ counts coupons used), if you already have $\text{dist}[v][k'] \le d_{\text{new}}$ for some $k' < k$, then state $(v, k)$ is Pareto-dominated by $(v, k')$ and never needs to be pushed! Checking dominance or propagating downward bounds across layers prevents redundant heap expansions.

Check yourself Question 1 of 4
Napkin Math · Capacity Flattened State Space Size for K Discounts recall
Section: Layered Graph Sizing

You want to find the shortest path in a graph with $V = 500$ vertices and up to $K = 5$ free-edge coupons (using 0, 1, 2, 3, 4, or 5 coupons). How many total state-vertices `(u, c)` exist in the layered product graph?

states ±0% accepted
Show Napkin Formula / Estimation Hint
Multiply V by the number of layers (K + 1, since c ranges from 0 to K inclusive).
⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

In the Weight-Constrained Shortest Path problem (minimize toll cost subject to travel time $\le T_{\max}$), why don't you need to expand a state `(u, time2)` of cost `cost2` if you have already settled `(u, time1)` with `time1 <= time2` and `cost1 <= cost2`?

Key Takeaways

Whenever path validity or cost depends on history (coupons used, fuel remaining, keys collected, path parity), augment the vertex state space V' = V x S so transitions become memoryless.
Keep the base graph G read-only and generate state transitions implicitly inside Dijkstra's loop to avoid O(|S| * E) memory overhead.
Splitting resource recharges into unit transitions (u, f) -> (u, f+1) keeps vertex out-degree O(1) instead of O(F).
Because Dijkstra pops states in non-decreasing order of cost, the first time any target state (dst, k) is popped is guaranteed to be the minimum across all layers.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue