u → v with weight w, check whether dist[u] + w beats v's current tentative distance. If so, update v and push it into the heap.h(v)v to the target. A* orders the queue by f(v) = g(v) + h(v) instead of just g(v).prefixCost + h[spurNode] — a provable minimum for any path built off this spur. If it already exceeds the cheapest candidate we've found, the spur cannot win and is pruned without running.dF(v)dF(v), the minimum number of edges to get there. Sandwich mode draws a cyan dashed halo around every node the forward BFS reaches.dB(v)dB(v), the minimum number of edges to reach a target from there. Drives AllDirectedPaths' edge decoration.u → v is retained iff u is forward-reachable from a source AND dF(u) + 1 + dB(v) ≤ budget. Both checks are necessary; together they are sufficient for the edge to lie on at least one feasible source → target walk of length ≤ budget. (Also known as the “sandwich” condition because the edge must fit between forward and backward reachability.)
Both panels enumerate the K cheapest simple paths from source (blue) to
target (purple), in order of cost.
K.The A and B selectors in the controls bar choose the algorithm rendered in each pane. Five variants are wired up, matching the JGraphT JMH grid:
YenKShortestPath: a fresh Dijkstra at
every spur, no deferral, no impossible-spur skip.BoundedPrunedYenKShortestPath with
setBoundedPruning(false). Same task structure as classical Yen but the
spur engine is A* using the reverse-Dijkstra heuristic. Most of the headline speedup
on the Andorra bench comes from this engine alone.δ(u, v) = w(u, v) + dB(v) − dB(u), then enumerates the k lowest-cost
walks from source to target. Walks may repeat vertices, so on graphs with cycles the
answers differ from the Yen-family (which returns simple paths).Default selection — Yen + Dijkstra (left) vs BPYen + A* (right) — mirrors the original visualization: the right pane is dramatically faster on the chain example (zero spur searches after the initial path) and visibly tighter on the grid (A* heads straight at the target). Set both panes to A* engines (one with the bounded-prune layer, one without) to see how small the bounded layer's marginal win is once A* is in play; set one pane to Eppstein to compare a fundamentally different algorithm.
AllDirectedPaths enumerates every directed s → t walk of length at most
maxPathLength. Before enumeration starts, it pre-decorates each edge with the
minimum number of edges to a target through that edge — anything not on a short-enough
route to a target is dropped from the search space.
A reverse BFS from the target set (here, just T) walks backwards along
incoming edges. For each edge u → v encountered while popping v,
we record dB(edge) = 1 + dB(v), the minimum backward distance from u
to the target through this edge.
Mark every edge whose head is backward-reachable to a target within budget
hops. Nothing checks whether the edge's tail is itself reachable from a source, or whether
a feasible source → target walk through this edge could even fit within the budget.
That includes "orphan" branches that reach the target but have no incoming connection
from the source — wasted bookkeeping.
Run a one-time forward BFS from the sources first; cache dF(v) for every
vertex it reaches (highlighted with a cyan dashed halo). Then, during the backward BFS,
gate each candidate edge u → v by two conditions:
u isn't in the forward-BFS map at all,
no source can reach this edge — drop it.dF(u) + 1 + dB(v) > budget, no walk
through this edge can stay within the length limit — drop it.Edges retained satisfy both conditions, so each one lies on at least one feasible source → target walk of length ≤ budget. Every simple path is also a walk, so the prune preserves the exact answer in both simple-only and all-walks modes.
x → y → T is happily
marked by the left pane but dropped on the right (those edges turn red, never green) —
their source side is unreachable from S.maxPathLength = 2, the right pane
drops the long route S → b → c → T entirely. The left pane retains parts
of it because individual edges fit under the backward budget, even though no length-2
walk can use them.Both panels answer the same question: what is the cheapest path from source (blue) to target (purple), and how many nodes did the algorithm have to look at to find it?
Both maintain a priority queue (the open set) of nodes whose tentative cost from the source is known. On each step the algorithm pops the cheapest entry — we say it expands that node — and relaxes each outgoing edge: for every neighbour, if the new tentative cost through the just-expanded node beats the current best, the neighbour is updated and (re)pushed into the queue. The green tint marks nodes that have been expanded; that set is the algorithm's effort.
Priority key is g(v), the cost from source to v. Dijkstra
always expands the cheapest unsettled node in the whole graph, regardless of where the
target sits. That's why its frontier grows as a roughly circular wave outward from the
source. On the grid, Dijkstra ends up settling almost every node before it touches the
target.
Priority key is f(v) = g(v) + h(v), where h(v) is an estimate of
the remaining cost from v to the target. Because h must be
admissible (never overestimate), the heuristic used here is computed by a one-time
reverse Dijkstra from the target on the original graph — that's exact on the
unconstrained graph. With an exact heuristic, A* expands only nodes on a shortest path
and walks straight from source to target.
A*'s win isn't free: that reverse Dijkstra is itself a full Dijkstra from the target. If you
only ever need a single shortest path, plain Dijkstra is usually the better choice. The
heuristic earns its keep when it's reused across many searches — exactly what
happens inside BoundedPrunedYenKShortestPath, where it heuristic-guides every
spur and acts as the lower-bound oracle for pruning.