Graph Search for Planners
Best-first search is one algorithm with one knob — breadth-first, Dijkstra, greedy and A* — with the optimality proof, the counterexample that breaks it, D* and D* Lite for repairing a search instead of redoing it, and universal plans as the reader's first value function.
To distinguish between these forms of optimality, let us reserve the term optimality to measure the path and efficiency to measure the search, i.e., the number of nodes visited to determine the path.
In this chapter
Every planner in Parts II and III ends the same way: with a graph and a question — which vertices lead from here to there, cheapest? This chapter builds the one tool they all share. The reader knows what is and how to test a configuration for collision. None of that tells Rusty which way to drive.
The idea the chapter unpacks fits in one sentence. Best-first search is one algorithm with one knob. Priority by edge count gives breadth-first search; by cost-to-come , Dijkstra; by a guess alone, greedy search; by with an optimistic , A* — optimal and lazy at once. Push the knob past optimism and optimality quietly breaks, and the chapter shows exactly where, on a graph small enough to trace by hand.
Two consequences follow and get their own widgets. A search can be repaired rather than redone when the world changes — D*, and its modern descendant D* Lite — and a search run backward from the goal yields a plan for every vertex at once, the reader's first value function, which the sister book turns into Markov decision processes.
The problem: a goal is not a plan
Put Rusty in room A of the Apartment and the goal in room B, and write the first controller anyone writes: drive toward the goal.
It wedges into the first wall. The goal is m away through the plaster, the doorway is two metres behind and to the left, and nothing in the controller can know that, because the controller only looks at where it is and where it wants to be. Something must look ahead — must consider positions Rusty is not in, order them by how promising they are, and remember which one led to which. That something is a graph search, and the rest of the chapter is what the words "promising" and "remember" mean precisely.
Building intuition: one search, one knob
Rasterize the Apartment's south rooms and corridor into a grid of half-metre cells, connect each cell to its eight neighbors, and search it four ways at once.
The four lanes run the same loop. Pop the most promising node, mark it done, look at its neighbors, push the ones not yet done. The only thing that differs is what "most promising" means:
- Breadth-first ranks by link length, the number of edges from the start. It floods outward in rings, and the first time it reaches the goal it has found the path with the fewest edges — which, on a grid where a diagonal step costs and an axial one costs , is not the cheapest path.
- Dijkstra ranks by , the cost of the cheapest known path from the start. It floods in cost rings, and the first time it pops the goal it has the cheapest path — at the price of expanding every cell cheaper than the goal.
- Greedy ranks by , a guess at the cost from each node to the goal. It darts toward the goal and often arrives in a handful of expansions with a path that is visibly bad.
- A* ranks by . With it expands only the cells it must and matches Dijkstra's cost to the last digit.
Now slide . Below one, the A* lane's cost does not move; only its expansion count does, climbing toward Dijkstra's as and the heuristic switches off. Above one the expansion count keeps falling — and the path cost drifts above Dijkstra's. The badge marks the first at which that happens. This is the misconception the widget exists to kill: a better heuristic always means a better path. A stronger heuristic means fewer expansions only while it stays optimistic; past that, it buys speed with path quality, and the chapter's derivations say how much of each.
The mathematics
| Symbol | Meaning | Note |
|---|---|---|
| a graph with non-negative edge costs (weights) | ||
| the set of nodes adjacent to n — Choset's name for the neighbor set | ||
| cost-to-come along the back-pointer path; heuristic estimate of the cost-to-go; the priority | book-wide | |
| the open set (priority queue) and the closed set (expanded nodes) | ||
| back pointer: the parent of n on the best known path from q_start | ||
| link length of a path — number of edges, weights ignored | ||
| D*: minimum key, tag ∈ {NEW, OPEN, CLOSED}, measured arc cost | ||
| D* Lite: one-step lookahead value, key modifier | ||
| heuristic inflation factor (weighted A*, ARA*) | ||
| universal plan (policy): the successor to take from every vertex | ||
| asymptotic bounds (Choset Def. G.1.1) |
Graphs, grids, and two kinds of length
The weights on a grid are the chapter's first honesty item. Choset's §H.2.4 charges for an axial step and for a diagonal — his approximation of , chosen so that hand arithmetic comes out in tenths. This is not the Euclidean metric: two cells sideways and one up costs , not . It is a perfectly good metric on the graph, and the design of this chapter keeps it, because it is what makes the examples traceable. Code that wants changes one constant.
The link length of a path is its number of edges, weights ignored. Breadth-first search minimizes link length; Dijkstra minimizes weighted cost; the two coincide only when all weights are equal. On the grid they disagree in a concrete way: four axial steps cost and three diagonals cost , so a path with more links can be cheaper. This is why the Search Theater's breadth-first lane reports a different cost from Dijkstra's, and why the Rust port relaxes a node by depth in breadth-first mode and by cost everywhere else.
Expansion, open and closed, and what a heuristic promises
Expanding a node (Choset §H.2.1) moves it from the open set to the closed set and, for every , either inserts into with and , or — if is already open and the new route is cheaper — updates and . The closed set is what stops a search from walking in circles; the open set is the frontier between what is known and what is not. In the colors every figure on this page uses:
The cost-to-come is measured from the green start; the heuristic is an estimate toward the red goal; the blue explored set is the price of the search; the purple back-pointer path is its product. Point at a term and the matching layer in the Search Theater comes forward.
Consistency is not in Choset; it is the standard strengthening that makes the no-reopening theorem below true, and the octile heuristic — , the exact cost-to-go on an empty grid — has it. Exercise 1 asks for the proof, and for the slightly embarrassing fact that the same formula is not admissible if diagonals cost .
DerivationBreadth-first search is optimal in link length
Statement. With a FIFO queue, a node's depth when it is first dequeued is its shortest link length from — on a grid, the Manhattan (4-connected) or chessboard (8-connected) shortest path (Choset §H.1–H.2).
Step 1 — the queue invariant. At every moment the queue holds nodes of some depth followed by nodes of depth , and nothing else: the start is at depth , and dequeuing a depth- node enqueues only depth- nodes behind the remaining depth- ones.
Step 2 — first discovery. A node is enqueued the first time it is seen, from a parent at depth , and is assigned depth ; later sightings are ignored.
Step 3 — no shorter path exists. Suppose were reachable in links. Then its parent on that path has depth and, by the invariant, was dequeued before any depth- node — at which moment would have been enqueued at depth . Contradiction.
Step 4 — grids. On a unit-weight grid link length is the grid metric, so the first path to the goal is also cheapest. With weights it is not: see the paragraph above.
Why depth-first is different. A stack pushes and pops on the same end, so the search commits to a branch and backs up only at a dead end. It is complete on a finite graph — every node is visited at most once — and optimal in nothing (Choset's figures H.8–H.9). The chapter's check confirms the first claim on two hundred random grids and does not bother with the second.
DerivationA* terminates and is complete
Statement. On a finite graph, A* (Alg. 24) either pops or empties ; in the latter case no path exists (Choset §H.2.2).
Step 1. Each expansion moves a node into . Under a consistent heuristic a node is never removed from again, so there are at most expansions. Without consistency a closed node may be reopened when a cheaper path to it is found; but each reopening strictly decreases its , and takes values among the costs of acyclic paths, of which a finite graph has finitely many. Choset's phrasing: A* searches a tree, a tree has finitely many acyclic paths, so the work is finite.
Step 2. is finite, so by Step 1 the number of expansions is finite and the loop terminates.
Step 3. While a path from to exists, contains some node on it: the start is on it and in initially; whenever a path node is expanded, its successor on the path is inserted or already present. So cannot empty while a path exists, and if empties, no path exists.
DerivationA* is optimal under admissibility
Statement. If is admissible, then the first time is popped, , the cost of a cheapest path (formalizing Choset §H.2.2).
Step 1 — suppose not. Assume is popped with .
Step 2 — a frontier node on an optimal path. Fix an optimal path . Let be the first node on it not yet expanded. Its predecessor has been expanded with the optimal (induction along the path), so with .
Step 3 — bound its priority. , using admissibility and the fact that lies on an optimal path.
Step 4 — compare with the goal. .
Step 5 — contradiction. The queue pops the smallest , so it would pop before . Hence the assumption fails and .
Reconciling two stopping rules. Alg. 24 exits when the goal is popped. Choset's §H.2 walk-through keeps expanding nodes whose is below the pushed goal's and discards the rest. These are the same rule: a node is popped only when nothing in beats it, so the walk-through's "discard everything with the goal's" is what the pop does implicitly.
DerivationConsistency means no node is ever reopened
Statement. With a consistent , the popped -values are non-decreasing, and every node's is already optimal when it enters .
Step 1. For an edge , by consistency. A node's children enter the queue with priority at least their parent's.
Step 2. Hence the sequence of popped -values is monotone non-decreasing: whatever is popped next was inserted by some earlier pop with no smaller .
Step 3. Suppose a closed node later received a cheaper via some node popped after . Then , contradicting monotonicity. So closed nodes stay closed and their is optimal.
Consequences. Octile and Euclidean heuristics are consistent, so the "reopened" column in the Search Theater reads zero for them, and the chapter's check asserts it on two hundred random grids. An inflated with is not consistent — but it still satisfies , since the argument of the previous derivation goes through with . That is the weighted-A* bound, which anytime planners such as ARA* (Likhachev, Gordon and Thrun, 2003; see the literature) exploit by running a decreasing schedule of and reusing the open set between runs.
Where optimism fails: the four-node counterexample
DerivationThe non-optimistic counterexample
Statement (Choset §H.2.5, figure H.20, with the numbers fixed). Edges –: , –: , –: , –: . Heuristic , , , . A* returns at cost ; the optimum costs .
Step 1 — expand . ; .
Step 2 — expand , the smaller . .
Step 3 — pop . , so the goal is popped before is ever expanded. A* returns the back-pointer path with .
Step 4 — the culprit. . In Step 3 of the optimality proof the inequality needed at the frontier node on the optimal path; here , and the chain of inequalities snaps. One bad estimate, anywhere on an optimal path, suffices.
Repair to its true value and A* pops at before at , finds via at , and returns the optimum; the chapter's check runs both versions.
The hand trace: five pops on a grid
Here is the micro-example the tests pin, in Choset's §H.2.4 style. An 8-connected grid, cells with and ; obstacle cells and are deleted from the graph; , ; axial step , diagonal ; octile heuristic. Ties in break toward smaller , then lexicographically.
| pop | expanded | open set after expansion (: cell) | |||
|---|---|---|---|---|---|
| 1 | : | ||||
| 2 | : · : | ||||
| 3 | : · : , | ||||
| 4 | : · : , , , · : | ||||
| 5 | goal popped — done |
Back pointers give at cost
in five expansions. Dijkstra on the same grid, ,
expands all ten free cells — every other free cell has , so all of them are popped
before the goal — and returns the same cost. Greedy, , happens to find the same path here in
five expansions, with no guarantee that it would anywhere else. All three facts are asserted by
astar_trace_matches_text and the TypeScript check that mirrors it.
Repairing a search: D*, and universal plans
DerivationD* repairs locally; backward Dijkstra is a universal plan
Statement (Choset §H.3–H.4). Dijkstra from labels every node with its cost-to-goal and a back pointer — a policy. When an arc cost changes, only nodes whose best path used that arc need new labels.
Step 1. Backward Dijkstra is forward Dijkstra on the reversed graph; for the symmetric graphs this chapter searches, the same graph.
Step 2 — the fixed point. Its labels satisfy with : a deterministic Bellman equation. The policy is the arg min.
Step 3 — raised states. Increase the cost of an arc . Only nodes whose back-pointer chain passes through that arc have an that is now too small. D* re-inserts the affected states; when such a state is popped its key — the smallest it has had since insertion — is less than its current . Choset calls it a RAISE state: its old route got worse.
Step 4 — lowered states. A neighbor whose chain avoided the change has a trustworthy and can offer a better route: in Choset's gate example, cell is raised when the gate closes, and its neighbor , whose path never used the gate, offers (figure H.26). A state popped with is a LOWER state: it has good news for its descendants.
Step 5 — when to stop. Propagation stops when the smallest key on the open list is at least the robot's own (Alg. 25, line 20): nothing left on the queue could lower the robot's cost, so its back-pointer path is optimal again.
PROCESS-STATE (Alg. 31) is the case analysis of Steps 3–4 made exact; the Rust dstar.rs and the
TypeScript port follow Stentz's original structure, in which a state that is lowered by the RAISE
fix-up still propagates in the same call (Choset's typesetting has an else if there — see the
honesty notes). D* Lite (Koenig and Likhachev) reaches the same behavior from Lifelong Planning
A*: two values per vertex, and a one-step lookahead , a queue of inconsistent vertices,
and a key modifier that absorbs the robot's own motion so the heap never needs re-keying. In
the sister book's
Chapter 21 the
in Step 2 becomes an expectation and the plan becomes a value function; nothing else changes.
Complexity and completeness
Running time is measured asymptotically (Choset Def. G.1.1): if for all large ; is the lower bound, both, and the strict versions. A* with a binary heap runs in in the worst case, the same as Dijkstra, and the heuristic buys a smaller constant — fewer expansions — not a better exponent.
The algorithm
Choset's Algorithm 24 is the master template; everything else in this chapter is a special case, a repair of it, or a run of it in reverse.
- In
- a graph G with Star(n) and costs c ≥ 0, start and goal, a heuristic h, a priority rule
- Out
- a back-pointer path from q_start to q_goal and its cost, or failure when O empties
- ; keyed on ;
- repeat
- pick from with for all — ties: smaller , then the graph's order
- remove from and add it to
- if then EXIT with the back-pointer path
- for all not in do
- if then ; ; add to
- else if then update , and 's priority — Alg. 24 prints only the back pointer
- until is empty — report that no path exists
- where link length (breadth-first) · (Dijkstra) · (greedy) · (A*, weighted A*)
Two implementation notes that the proofs depend on. The open set is a lazy binary heap: improving a node pushes a fresh entry and the stale one is discarded when it surfaces, so line 8 never needs a decrease-key. And if a node already in receives a cheaper — which Derivation 4 says cannot happen under a consistent heuristic, and which does happen with — it is reopened: removed from and pushed again. Correctness over speed, and a counter the widget shows.
- In
- the open list O keyed on k, all states L with h, k, t, b
- Out
- the new k_min, with h, b and O updated
- the state in with minimum ; ; delete from ,
- if then — RAISE: the old route got worse; look for a rescuer
- for each neighbor with and : ;
- if then — LOWER: good news for the descendants
- for each neighbor : if , or and , or and then ; INSERT
- else — still RAISE after the fix-up
- for each neighbor : if , or and then ; INSERT
- else if and then INSERT — re-open X: it can still improve Y later
- else if and and and then INSERT — re-open a possible rescuer
- return of — INSERT (Alg. 27): if NEW, if OPEN, if CLOSED; then ,
- In
- g, rhs tables (∞ except rhs(goal) = 0), a queue U keyed on [min(g,rhs) + h(start, s) + k_m ; min(g,rhs)]
- Out
- g(start) = cost-to-goal, with the greedy descent of g an optimal path
- while TopKey Key or do — ties processed too, so the whole band is consistent
- Pop, its key
- if Key then re-insert with Key — stale because grew
- else if then ; UpdateVertex on every predecessor — overconsistent: commit the good news
- else ; UpdateVertex on every predecessor and on — underconsistent: forget, re-derive
- UpdateVertex: if : ; remove from ; if insert with Key
- on edge changes: ; ; UpdateVertex on both endpoints of every changed arc; run lines 1–5
- move:
- In
- a graph with symmetric non-negative costs and a goal
- Out
- cost-to-go h(X) and a successor π*(X) for every reachable X
- ; push ; all other
- while the queue is non-empty do pop the cheapest
- for each : if then ; ; push
- return — satisfies everywhere; following from any costs exactly
Replanning, watched
With Choset's gate world the numbers are the ones in his figures. The initial backward Dijkstra labels the start with — five diagonals — and with , and Rusty sets off along . The gate closes; at Rusty is adjacent and notices. D* re-inserts the gate and its neighbors, pops as a RAISE state — its has jumped above Choset's obstacle cost of while its key stays at the old — and the LOWER neighbor rescues it at . The repair pops 15 states, five of them raised, and Rusty drives at cost . Redoing the backward Dijkstra from scratch on the changed map would pop 32; a from-scratch A* from needs only 7 expansions on this tiny grid, which is Exercise 4's point — repair is not always cheaper than redo, and the raised/lowered classification says when it is.
A plan that is a field
A path is a plan for one start. Run the backward Dijkstra to exhaustion and every free cell of the Apartment carries a cost-to-go and an arrow; kick Rusty anywhere and the arrows bring it home. The equal-cost contours are wave fronts — Chapter 7's wave-front planner is this picture with unit costs — and the panel under the canvas evaluates at the cell under the cursor, term by term, with the minimizing being the arrow. The chapter's check asserts the equation holds at every labelled cell to and that following the arrows from sixty random cells reaches the goal at exactly Dijkstra's cost for that start.
Implementation in Rust
The search crate is introduced here and imported by Chapters 7, 8, 10, 16, 18, 21 and 22. Its
interface is the smallest thing best-first search needs.
/// Anything best-first search can walk. Costs are non-negative edge weights
/// (Choset App. H.1); `Node` must be hashable so open/closed sets are maps.
pub trait SearchSpace {
type Node: Copy + Eq + std::hash::Hash;
/// Star(n) with edge costs c(n, x) — Choset's notation, made an iterator.
fn neighbors(&self, n: Self::Node) -> impl Iterator<Item = (Self::Node, f64)> + '_;
}
/// 8-connected lattice over a rasterized Q_free (Chapter 4's `cspace::Raster`). `diag` is
/// 1.4 for Choset's grid metric (figure H.16) or SQRT_2 for the exact one. Obstacle cells
/// are deleted from the graph: a neighbor that is occupied is simply not yielded.
pub struct Grid8<'a> { pub raster: &'a cspace::Raster, pub diag: f64 }
impl SearchSpace for Grid8<'_> {
type Node = (i32, i32);
fn neighbors(&self, (i, j): (i32, i32)) -> impl Iterator<Item = ((i32, i32), f64)> + '_ {
const STEPS: [(i32, i32); 8] = [(1,0), (-1,0), (0,1), (0,-1), (1,1), (1,-1), (-1,1), (-1,-1)];
STEPS.iter().filter_map(move |&(di, dj)| {
let n = (i + di, j + dj);
if !self.raster.is_free(n) { return None; }
let diag = di != 0 && dj != 0;
Some((n, if diag { self.diag } else { 1.0 } * self.raster.cell_size()))
})
}
}The engine is one function. Priority is the knob; Trace records the pop order so the examples
can print Choset-style tables and the widgets can paint the frontier.
use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap, HashSet};
/// The one knob: how a node's priority is formed from g, h and depth.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Priority { LinkLength, CostToCome, HeuristicOnly, Sum { eps: f64 } }
impl Priority {
fn f(self, g: f64, h: f64, depth: usize) -> f64 {
match self {
Priority::LinkLength => depth as f64,
Priority::CostToCome => g,
Priority::HeuristicOnly => h,
Priority::Sum { eps } => g + eps * h,
}
}
}
/// One row of the trace table and the result of a search.
pub struct Pop<N> { pub node: N, pub g: f64, pub h: f64, pub f: f64 }
pub struct Trace<N> { pub pops: Vec<Pop<N>>, pub path: Vec<N>, pub cost: f64, pub reopened: usize }
/// Heap entries ordered by (f, h, seq): ties toward smaller h, then insertion order.
/// `Reverse` semantics are written out so the comparison is readable.
struct Entry<N> { f: f64, h: f64, seq: u64, g: f64, node: N }
impl<N> PartialEq for Entry<N> { fn eq(&self, o: &Self) -> bool { self.cmp(o) == Ordering::Equal } }
impl<N> Eq for Entry<N> {}
impl<N> PartialOrd for Entry<N> { fn partial_cmp(&self, o: &Self) -> Option<Ordering> { Some(self.cmp(o)) } }
impl<N> Ord for Entry<N> {
fn cmp(&self, o: &Self) -> Ordering {
// BinaryHeap is a max-heap: reverse every component so the smallest f pops first.
o.f.total_cmp(&self.f).then(o.h.total_cmp(&self.h)).then(o.seq.cmp(&self.seq))
}
}
/// BFS, Dijkstra, greedy, and (weighted) A* are this function with four `Priority` values.
/// Returns `None` when the open set empties — the completeness half of Derivation 2.
pub fn best_first<S: SearchSpace>(
space: &S, start: S::Node, goal: S::Node,
h: impl Fn(S::Node) -> f64, prio: Priority,
) -> Option<Trace<S::Node>> {
let mut g: HashMap<S::Node, f64> = HashMap::from([(start, 0.0)]);
let mut depth: HashMap<S::Node, usize> = HashMap::from([(start, 0)]);
let mut parent: HashMap<S::Node, S::Node> = HashMap::new();
let mut closed: HashSet<S::Node> = HashSet::new();
let mut open = BinaryHeap::new();
let (mut seq, mut pops, mut reopened) = (0u64, Vec::new(), 0usize);
open.push(Entry { f: prio.f(0.0, h(start), 0), h: h(start), seq, g: 0.0, node: start });
while let Some(Entry { node, g: gn, .. }) = open.pop() {
// Lazy deletion: a stale entry (better g pushed later, or already closed) costs nothing.
if closed.contains(&node) || gn > g[&node] + 1e-12 { continue; }
closed.insert(node);
let d = depth[&node];
pops.push(Pop { node, g: gn, h: h(node), f: prio.f(gn, h(node), d) });
if node == goal {
let mut path = vec![goal];
while let Some(&p) = parent.get(path.last().unwrap()) { path.push(p); }
path.reverse();
return Some(Trace { pops, path, cost: gn, reopened });
}
for (x, c) in space.neighbors(node) {
let tentative = gn + c;
// Breadth-first relaxes on link length (first discovery wins); the rest on cost.
let better = match prio {
Priority::LinkLength => d + 1 < *depth.get(&x).unwrap_or(&usize::MAX),
_ => tentative < *g.get(&x).unwrap_or(&f64::INFINITY) - 1e-12,
};
if !better { continue; }
if closed.remove(&x) { reopened += 1; } // only an inconsistent h gets here
g.insert(x, tentative); depth.insert(x, d + 1); parent.insert(x, node);
seq += 1;
open.push(Entry { f: prio.f(tentative, h(x), d + 1), h: h(x), seq, g: tentative, node: x });
}
}
None
}
pub fn astar<S: SearchSpace>(s: &S, a: S::Node, b: S::Node, h: impl Fn(S::Node) -> f64) -> Option<Trace<S::Node>> {
best_first(s, a, b, h, Priority::Sum { eps: 1.0 })
}
pub fn dijkstra<S: SearchSpace>(s: &S, a: S::Node, b: S::Node) -> Option<Trace<S::Node>> {
best_first(s, a, b, |_| 0.0, Priority::CostToCome)
}D* Lite is the default replanner. The whole algorithm is the key, the two update cases, and the modifier ; everything else is bookkeeping.
/// Incremental replanner (Koenig & Likhachev 2002/2005): g/rhs tables plus the key
/// modifier k_m, so the robot's own motion never forces a heap rebuild.
pub struct DStarLite<S: SearchSpace> {
space: S,
g: HashMap<S::Node, f64>,
rhs: HashMap<S::Node, f64>,
queue: KeyedHeap<S::Node, [f64; 2]>, // lazy; stale entries detected by key mismatch
start: S::Node, last: S::Node, goal: S::Node,
km: f64,
h: fn(S::Node, S::Node) -> f64,
}
impl<S: SearchSpace> DStarLite<S> {
fn key(&self, s: S::Node) -> [f64; 2] {
let m = self.g[&s].min(self.rhs[&s]);
[m + (self.h)(self.start, s) + self.km, m]
}
fn update_vertex(&mut self, u: S::Node) {
if u != self.goal {
let best = self.space.neighbors(u).map(|(v, c)| c + self.g[&v]).fold(f64::INFINITY, f64::min);
self.rhs.insert(u, best);
}
self.queue.remove(u);
if self.g[&u] != self.rhs[&u] { let k = self.key(u); self.queue.insert(u, k); }
}
pub fn compute_shortest_path(&mut self) -> usize {
let mut pops = 0;
// Process ties with the start's key as well: a stale vertex left at an equal key
// can win a tie in the greedy descent and misreport the path's cost.
while self.queue.top_key().is_some_and(|k| k <= self.key(self.start)) || self.rhs[&self.start] != self.g[&self.start] {
let Some((u, k_old)) = self.queue.pop() else { break };
pops += 1;
let k_new = self.key(u);
if k_old < k_new { self.queue.insert(u, k_new); }
else if self.g[&u] > self.rhs[&u] {
self.g.insert(u, self.rhs[&u]); // overconsistent: commit
for (p, _) in self.space.neighbors(u) { self.update_vertex(p); }
} else {
self.g.insert(u, f64::INFINITY); // underconsistent: re-derive
for (p, _) in self.space.neighbors(u) { self.update_vertex(p); }
self.update_vertex(u);
}
}
pops
}
/// Report observed cost changes; returns how many vertices' values moved (w6.2's statistic).
pub fn update_edges(&mut self, changed: &[(S::Node, S::Node)]) -> usize {
self.km += (self.h)(self.last, self.start);
self.last = self.start;
let mut touched = HashSet::new();
for &(u, v) in changed { for s in [u, v] { self.update_vertex(s); touched.insert(s); } }
self.compute_shortest_path();
touched.len()
}
pub fn next_step(&mut self) -> Option<S::Node> {
if self.g[&self.start].is_infinite() { return None; }
let (s, _) = self.space.neighbors(self.start)
.map(|(v, c)| (v, c + self.g[&v]))
.min_by(|a, b| a.1.total_cmp(&b.1))?;
self.start = s;
Some(s)
}
}The universal plan is Dijkstra with the early exit removed and the direction reversed.
/// Choset §H.4: Dijkstra run backward from the goal yields cost-to-go and a successor
/// for *every* node — a universal plan. The sister book's Chapter 21 generalizes `min` to E[·].
pub struct UniversalPlan<N> { pub cost_to_go: HashMap<N, f64>, pub next: HashMap<N, N> }
pub fn universal_plan<S: SearchSpace>(space: &S, goal: S::Node) -> UniversalPlan<S::Node> {
let mut cost_to_go = HashMap::from([(goal, 0.0)]);
let mut next = HashMap::from([(goal, goal)]);
let mut closed = HashSet::new();
let mut heap = BinaryHeap::new();
heap.push(MinEntry { key: 0.0, node: goal });
while let Some(MinEntry { key, node: x }) = heap.pop() {
if !closed.insert(x) || key > cost_to_go[&x] + 1e-12 { continue; }
for (y, c) in space.neighbors(x) { // symmetric edges: neighbors = predecessors
let cand = cost_to_go[&x] + c;
if cand < *cost_to_go.get(&y).unwrap_or(&f64::INFINITY) - 1e-12 {
cost_to_go.insert(y, cand);
next.insert(y, x);
heap.push(MinEntry { key: cand, node: y });
}
}
}
UniversalPlan { cost_to_go, next }
}
/// The deterministic Bellman residual: max |h(X) − min_Y [c(X,Y) + h(Y)]|, zero for a correct plan.
pub fn bellman_residual<S: SearchSpace>(space: &S, plan: &UniversalPlan<S::Node>) -> f64 {
plan.cost_to_go.iter().filter(|(x, _)| plan.next[x] != **x).map(|(&x, &hx)| {
let best = space.neighbors(x).filter_map(|(y, c)| plan.cost_to_go.get(&y).map(|hy| c + hy)).fold(f64::INFINITY, f64::min);
(hx - best).abs()
}).fold(0.0, f64::max)
}The worked example, and its printed output
fn main() {
let raster = cspace::Raster::from_ascii(&["....", ".#..", ".#.."]); // first row is the top
let grid = Grid8 { raster: &raster, diag: 1.4 };
let (start, goal) = ((0, 0), (3, 0)); // (col,row) = (1,1), (4,1)
let h = |(i, j): (i32, i32)| { let (dx, dy) = ((3 - i).abs() as f64, (0 - j).abs() as f64); 1.4 * dx.min(dy) + (dx - dy).abs() };
let t = astar(&grid, start, goal, h).expect("a path exists");
println!("pop node g h f");
for (k, p) in t.pops.iter().enumerate() {
println!("{:>3} ({},{}) {:.1} {:.1} {:.1}", k + 1, p.node.0 + 1, p.node.1 + 1, p.g, p.h, p.f);
}
println!("path: {}", t.path.iter().map(|(i, j)| format!("({},{})", i + 1, j + 1)).collect::<Vec<_>>().join(" "));
println!("cost = {:.1}", t.cost);
let d = dijkstra(&grid, start, goal).unwrap();
println!("dijkstra: expansions = {}, cost = {:.1}", d.pops.len(), d.cost);
}pop node g h f
1 (1,1) 0.0 3.0 3.0
2 (1,2) 1.0 3.4 4.4
3 (2,3) 2.4 2.8 5.2
4 (3,2) 3.8 1.4 5.2
5 (4,1) 5.2 0.0 5.2
path: (1,1) (1,2) (2,3) (3,2) (4,1)
cost = 5.2
dijkstra: expansions = 10, cost = 5.2A* returned cost 8 via A; optimal is 5 via B
initial path: (2,1) (3,2) (4,3) (5,4) (6,5) (7,6) cost 7.0
gate (4,3) closed at (3,2): repaired k(3,2) = h(3,2) = 7.6 via (4,1) — 15 states popped (5 raised) vs 32 for a fresh backward Dijkstraastar_trace_matches_text asserts the pop order, the triples to , the path, and
both expansion counts. astar_equals_dijkstra_on_random_grids runs two hundred seeded SmallRng
grids and asserts equal costs, zero reopenings, and equality with pathfinding::prelude::astar
(the crate is a test dependency only; no search crate is in the library path).
nonoptimistic_loses asserts and . dstar_gate_repairs_to_7_6 asserts the initial path,
, and the repaired with back pointer ; the work counts are
regression-locked, as the design doc asks, because they depend on the choice to re-insert both
endpoints of a changed arc. dstar_lite_equals_fresh_astar flips random cells on forty grids as the
robot walks and asserts, after every flip and every step, that D* Lite's cost-to-goal equals a
fresh A*'s and that its greedy descent has that cost. universal_plan_is_bellman_fixed_point
asserts the residual is below on the Apartment and that following the policy from sixty
random cells reaches the goal at Dijkstra's cost. The TypeScript port in web/lib/search/ runs
the same eleven checks, and every widget on this page is that port.
The D* Lite check caught a real bug worth recording. Koenig and Likhachev's loop stops when the top
key is no longer less than the start's. A vertex whose key ties the start's — common on a grid,
where keys are sums of s and s — can be left stale, and on one grid in five hundred the
greedy descent stepped onto such a vertex and reported a cost had never promised. Processing
ties, with a tolerance for the ulp-level rounding of those sums, fixed it. The loop in the listing
and in the port both say ≤.
Putting it together: a door closes
The integration lab is the Apartment scenario of the D* Replanner. Rasterize the Apartment at
half-metre cells for disc-Rusty with the Chapter 4 inflation of m, plan from room A to the
bedroom with astar under Choset's metric, and let Rusty drive. At the chosen step the cell
on the plan about halfway along — in the Apartment that is almost always a doorway — becomes an
obstacle, a door closing while Rusty is en route. When Rusty is adjacent it notices, and D* Lite
repairs: it updates the cells around the door, propagates until the band of keys below Rusty's own
is consistent again, and hands back a path through a different door. The blue footprint is the
repair; the readout compares its size with the expansions a from-scratch A* needs from the same
cell. In the open corridor the two are comparable, because the repair has to re-derive the whole
region whose cost-to-go flowed through the closed door; deep inside a room, where a closed door
changes only a few cells' routes, the repair is a tenth of the redo. Exercise 4 asks you to find the
other kind of case.
Three pointers forward close the chapter. Chapter 7's brushfire and wave-front planners are breadth-first search from a different seed set — all obstacle cells, or the goal — and its navigation functions are universal plans with a smoothness requirement. Chapter 8 runs A* on the visibility graph and the generalized Voronoi diagram, and Chapter 10 on a cell decomposition's adjacency graph; none of them re-implement anything on this page. And the sister book's Chapter 21 takes the deterministic Bellman equation of the universal plan, replaces the minimum over successors by an expectation over outcomes, and arrives at value iteration — the same table, now a value function.
Exercises
- Foundation exerciseDifficulty 2 of 3Octile is consistent — for the right diagonal
Prove that is consistent on the 8-connected grid with Choset's metric: show for each of the eight moves and . Then give a two-cell example showing that the same formula is not admissible if diagonals cost instead of .
- Foundation exerciseDifficulty 2 of 3Obstacles as 10000-cost nodes
Choset's grid A* (§H.2.4) keeps obstacle pixels in the graph as nodes that cost to enter. Show that on any grid with fewer than cells this returns the same path as deleting them. Then exhibit a grid where the two differ — and say which answer a robot should prefer.
- Conceptual exerciseDifficulty 1 of 3Predict the weighted-A* bound, then verifyPredict first
In the Search Theater set ε = 2. Before pressing Run: can the A* lane's path cost exceed Dijkstra's by more than a factor of 2 on any layout?
- Conceptual exerciseDifficulty 2 of 3When repair costs more than redo
In the D* Replanner, find a gate position — on Choset's grid or in the Apartment — for which D* Lite touches more cells than a from-scratch A* expands. Explain, using the raised/lowered classification, why this is rare: which cells must a repair touch, which cells must a redo touch, and when is the first set the larger?
On Choset's gate grid, how many states does the D* repair pop after the gate closes with Rusty at (3,2)?
- Practical exerciseDifficulty 2 of 3A true FIFO
Implement
Priority::LinkLengthas a true first-in-first-out queue — not a heap with constant keys — and add a property test that breadth-first search and Dijkstra agree on every unit-weight grid over 200 seeded trials: same link length, same cost. Then switch the weights to and write the test that shows where they disagree, with the four-axial-versus-three-diagonal example as its smallest witness. - Practical exerciseDifficulty 3 of 3ARA*
Implement anytime repairing A* in
src/ara.rswith the schedule , reusing the open set between iterations and keeping the inconsistent list that the algorithm uses to seed the next round. Add a fifth lane to the Search Theater that shows each iterate's path, and assert in a test that the final cost equals Dijkstra's. The paper is Likhachev, Gordon and Thrun's; the design is three pages, and Derivation 4 is the part of this chapter you will need.
References
- Choset, H., Lynch, K. M., Hutchinson, S., Kantor, G., Burgard, W., Kavraki, L. E., and Thrun, S. (2005) Principles of Robot Motion: Theory, Algorithms, and Implementations. MIT Press.link to Principles of Robot Motion: Theory, Algorithms, and Implementations (opens in a new tab)
Appendices G and H are this chapter's source: the by-example pedagogy, Algorithm 24, the non-optimistic figure H.20, the D* gate walk-through and Algorithms 25–31, and the universal plans of §H.4. D* itself is Stentz's (Choset's reference [397], ICRA 1994).
- Dijkstra, E. W. (1959) A Note on Two Problems in Connexion with Graphs. Numerische Mathematik 1, 269–271.doi:10.1007/BF01386390 (opens in a new tab)
The cost-to-come search; three pages. The priority rule f = g in this chapter's one loop.
- Hart, P. E., Nilsson, N. J., and Raphael, B. (1968) A Formal Basis for the Heuristic Determination of Minimum Cost Paths. IEEE Transactions on Systems Science and Cybernetics 4(2), 100–107.doi:10.1109/TSSC.1968.300136 (opens in a new tab)
A*, with the admissibility condition and the optimality theorem that Derivation 3 restates; consistency appears there as the 'consistency assumption'.
- Koenig, S. and Likhachev, M. (2005) Fast Replanning for Navigation in Unknown Terrain. IEEE Transactions on Robotics 21(3), 354–363.doi:10.1109/TRO.2004.838026 (opens in a new tab)
D* Lite, the chapter's default replanner: g, rhs, the queue of inconsistent vertices, and the key modifier k_m. The AAAI 2002 paper of the same authors introduced it; this is the journal version.
- LaValle, S. M. (2006) Planning Algorithms. Cambridge University Press.link to Planning Algorithms (opens in a new tab)
Chapter 2 presents discrete planning as the same forward-search template with a swappable priority queue, and is the best second reading for this chapter's 'one search, one knob'.
- Russell, S. and Norvig, P. (2020) Artificial Intelligence: A Modern Approach. Pearson, 4th edition.link to Artificial Intelligence: A Modern Approach (opens in a new tab)
Chapter 3 has the cleanest textbook statements of admissibility, consistency and the no-reopening property, with the proofs this chapter follows.
- Thrun, S., Burgard, W., and Fox, D. (2005) Probabilistic Robotics. MIT Press.link to Probabilistic Robotics (opens in a new tab)
Where the universal plan goes next: value iteration over MDPs, which the sister volume's Chapter 21 derives as this chapter's backward Dijkstra with an expectation in place of the minimum.
