DSA DSA In Depth
0%
build build-labastarbidirectional-dijkstra

Build Lab: A* and Bidirectional Dijkstra Pathfinding Engine with Consistent Potentials

7 min read Module 5.2: Disjoint Sets, Minimum Spanning Trees & Shortest Paths Updated Oct 23, 2026
📄 Cheat Sheet
A* Pathfinding Algorithm: Heuristics, Open/Closed Priority Queues & Optimality
Sebastian Lague A* Pathfinding Algorithm: Heuristics, Open/Closed Priority Queues & Optimality Watch on YouTube ↗

Demonstrates frontier expansion, stopping criteria, and path reconstruction in heuristic and bidirectional routers.

0:00 / --:--

Engineering Invariants for Sub-Millisecond Routing

When a routing engine serves thousands of queries per second over a graph of $V = 10^6$ vertices, each individual query might touch only $k = 500$ vertices. Calling std::fill(dist.begin(), dist.end(), INF) at the start of every query touches $8\text{ MB}$ of RAM ($O(V)$ work), destroying the sub-linear advantage of A* and Bidirectional Search!

Formal Invariant — Generation Timestamp Array & Bidirectional Stopping Rule
  1. $O(1)$ Generation-Counter Reset: Maintain a global queryId counter and an array seenGen[0..V-1]. Whenever vertex $u$ is accessed during query queryId, if seenGen[u] != queryId, lazily initialize dist[u] = INF; parent[u] = -1; seenGen[u] = queryId; on-the-fly in $O(1)$ time! Total memory touched per query is strictly $O(k)$ where $k$ is the number of visited vertices.
  2. Exact Bidirectional Dijkstra Stopping Criterion: Let $\mu$ be the minimum total path weight $d_F[w] + d_B[w]$ observed across any vertex $w$ reached by both directions so far (updated whenever an edge $(u, v)$ is relaxed and $v$ was already reached by the opposite search). The search can safely terminate if and only if: $$\min_{x \in Q_F} d_F[x] + \min_{y \in Q_B} d_B[y] \ge \mu$$ At that moment, no unexpanded path can possibly have total weight $< \mu$.

Consistent Symmetric Potentials for Bidirectional A*

In Bidirectional A*, the forward search targets $t$ using heuristic $h_F(u) \approx \delta(u, t)$, while the backward search targets $s$ using $h_B(u) \approx \delta(s, u)$. If we use $h_F$ and $h_B$ independently, their reduced edge weights $w_F(u, v) = w(u, v) - h_F(u) + h_F(v)$ and $w_B(u, v) = w(u, v) - h_B(v) + h_B(u)$ do not match! Goldberg and Harrelson (2005) proved that defining the average potential:

$$p_F(u) = \frac{h_F(u) - h_B(u)}{2}, \quad p_B(u) = -p_F(u) = \frac{h_B(u) - h_F(u)}{2}$$

makes both directions run on the exact same consistent reduced weight function while preserving admissibility!

Why Bidirectional Dijkstra Cannot Stop on First Frontier Contact s A B C t +5 +5 First contact at A: μ = 5+5 = 10 +4 +1 (bridge!) +4 True shortest path s→B→C→t = 9 (found before topF + topB ≥ 10)
flowchart LR Increment["++queryId (O(1) array reset)"] --> Step["Expand min(pqF.top(), pqB.top())"] Step --> Relax["On edge (u, v, w): update dF[v] & bestMu = min(bestMu, dF[u] + w + dB[v])"] Relax --> StopCheck{"pqF.top() + pqB.top() >= bestMu?"} StopCheck -- "No" --> Step StopCheck -- "Yes" --> Reconstruct["Splice s -> meetVertex -> t path"]
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 — Zero-Fill Elimination via Epoch Stamps

On a graph of $V = 10^6$ vertices, std::vector dist(V, INF) allocates and zeroes $8\text{ MB}$ of heap memory, taking ~1.8 milliseconds per call just in memset page faults. Reusing a persistent workspace struct with uint32_t epoch and vector stamp(V, 0) reduces per-query initialization time from $1.8\text{ ms}$ to $0.4\text{ nanoseconds}$ (a single ++epoch instruction)!

Complexity Matrix

Design Choice Initialization Time / Query Search Space ($D$ hops) Correctness Guarantee
Naive Re-allocated Dijkstra O(V) (memset bottleneck) O(b^D) Exact
Epoch-Stamped A* (Consistent $h$) O(1) (++epoch) Focused beam Exact
Epoch-Stamped Bidirectional Dijkstra O(1) (++epoch) O(2 · b^(D/2)) Exact (with $\text{top}_F + \text{top}_B \ge \mu$)

Complete Implementation: Epoch-Stamped Bidirectional Router with Path Splicing

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

struct RouterEdge { int to; long long weight; };

class BidirectionalRouter {
public:
    explicit BidirectionalRouter(int n)
        : numVertices(n), adj(n), revAdj(n),
          dF(n), dB(n), pF(n), pB(n), stampF(n, 0), stampB(n, 0) {}

    void addDirectedEdge(int u, int v, long long w) {
        adj[u].push_back({v, w});
        revAdj[v].push_back({u, w});
    }

    std::pair<long long, std::vector<int>> findShortestPath(int s, int t) {
        if (s == t) return {0, {s}};
        constexpr long long INF = std::numeric_limits<long long>::max() / 4;
        ++epoch;

        using State = std::pair<long long, int>;
        std::priority_queue<State, std::vector<State>, std::greater<State>> pqF, pqB;

        setF(s, 0, -1); pqF.emplace(0, s);
        setB(t, 0, -1); pqB.emplace(0, t);

        long long mu = INF;
        int meetNode = -1;

        while (!pqF.empty() && !pqB.empty()) {
            if (pqF.top().first + pqB.top().first >= mu) break;

            if (pqF.top().first <= pqB.top().first) {
                auto [d, u] = pqF.top(); pqF.pop();
                if (d > getF(u)) continue;
                for (const auto& e : adj[u]) {
                    if (d + e.weight < getF(e.to)) {
                        setF(e.to, d + e.weight, u);
                        pqF.emplace(d + e.weight, e.to);
                        if (stampB[e.to] == epoch && d + e.weight + dB[e.to] < mu) {
                            mu = d + e.weight + dB[e.to];
                            meetNode = e.to;
                        }
                    }
                }
            } else {
                auto [d, u] = pqB.top(); pqB.pop();
                if (d > getB(u)) continue;
                for (const auto& e : revAdj[u]) {
                    if (d + e.weight < getB(e.to)) {
                        setB(e.to, d + e.weight, u);
                        pqB.emplace(d + e.weight, e.to);
                        if (stampF[e.to] == epoch && dF[e.to] + d + e.weight < mu) {
                            mu = dF[e.to] + d + e.weight;
                            meetNode = e.to;
                        }
                    }
                }
            }
        }

        if (meetNode == -1) return {-1, {}};
        std::vector<int> path;
        for (int cur = meetNode; cur != -1; cur = pF[cur]) path.push_back(cur);
        std::reverse(path.begin(), path.end());
        for (int cur = pB[meetNode]; cur != -1; cur = pB[cur]) path.push_back(cur);
        return {mu, path};
    }

private:
    int numVertices;
    uint32_t epoch = 0;
    std::vector<std::vector<RouterEdge>> adj, revAdj;
    std::vector<long long> dF, dB;
    std::vector<int> pF, pB;
    std::vector<uint32_t> stampF, stampB;

    long long getF(int u) const noexcept {
        return stampF[u] == epoch ? dF[u] : std::numeric_limits<long long>::max() / 4;
    }
    long long getB(int u) const noexcept {
        return stampB[u] == epoch ? dB[u] : std::numeric_limits<long long>::max() / 4;
    }
    void setF(int u, long long d, int p) noexcept { stampF[u] = epoch; dF[u] = d; pF[u] = p; }
    void setB(int u, long long d, int p) noexcept { stampB[u] = epoch; dB[u] = d; pB[u] = p; }
};
Failure Mode — Using Outgoing Edges Instead of Reversed Edges in the Backward Search

On a directed graph (e.g., road networks with one-way streets), the backward search from $t$ must traverse edges backward (in the transpose graph revAdj where edge $(u, v)$ is traversed from $v$ to $u$). Traversing forward outgoing edges from $t$ finds paths $t \leadsto m$ instead of $m \leadsto t$, routing cars the wrong way down one-way streets!

Check yourself Question 1 of 4
Concept Check O(1) Distance Array Reset Across Repeated Queries recall
Section: Epoch Stamping

In a high-throughput routing server handling $50{,}000$ queries per second on a graph with $V = 2 \times 10^6$ vertices, why does our router use an `epoch` counter and `stamp[u] == epoch` instead of `std::fill(dist.begin(), dist.end(), INF)`?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

Once Bidirectional Dijkstra terminates with optimal meeting vertex `meetNode`, how is the complete $s \leadsto t$ vertex sequence reconstructed from forward predecessors `pF[]` and backward predecessors `pB[]`?

Key Takeaways

Epoch-stamped distance arrays eliminate O(V) per-query initialization overhead, reducing reset cost to O(1) (`++epoch`) so sub-linear searches stay sub-linear.
Bidirectional Dijkstra must continue until pqF.top().dist + pqB.top().dist >= bestMu; stopping when the frontiers first intersect can miss shorter bridge edges.
On directed graphs, the backward search from t must expand along the reversed adjacency list revAdj.
Splicing the forward predecessor chain s ~> meetNode with the backward successor chain meetNode ~> t reconstructs the exact shortest path in O(|path|) time.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue