Robot Motion
Chapter 06PART IIClassical Planners — Search, Potentials, Roadmaps, CellsDifficulty: FoundationalEstimated reading time: 60 min

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.
Howie Choset, Kevin Lynch, Seth Hutchinson, George Kantor, Wolfram Burgard, Lydia Kavraki, and Sebastian ThrunPrinciples of Robot Motion (2005), Appendix H.2

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 Qfree\Qfree 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 gg, Dijkstra; by a guess hh alone, greedy search; by g+hg + h with an optimistic hh, 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.

t=0
Figure The goal-seeking controller. Rusty drives straight at a goal two rooms away and wedges against the party wall between them. Nothing in 'drive toward the goal' knows that the doorway is behind it.

It wedges into the first wall. The goal is 2.22.2 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 1.41.4 and an axial one costs 11, is not the cheapest path.
  • Dijkstra ranks by gg, 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 hh, 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 f=g+εhf = g + \varepsilon h. With ε=1\varepsilon = 1 it expands only the cells it must and matches Dijkstra's cost to the last digit.

Now slide ε\varepsilon. Below one, the A* lane's cost does not move; only its expansion count does, climbing toward Dijkstra's as ε→0\varepsilon \to 0 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 ε\varepsilon 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

Notation used in this chapter
SymbolMeaningNote
G=(V,E),  c(n1,n2)≥0G = (V, E),\; c(n_1, n_2) \ge 0a graph with non-negative edge costs (weights)
Star(n)\mathrm{Star}(n)the set of nodes adjacent to n — Choset's name for the neighbor set
g(n),  h(n),  f(n)g(n),\; h(n),\; f(n)cost-to-come along the back-pointer path; heuristic estimate of the cost-to-go; the prioritybook-wide
O,  CO,\; Cthe open set (priority queue) and the closed set (expanded nodes)
b(n)b(n)back pointer: the parent of n on the best known path from q_start
ℓ(π)\ell(\pi)link length of a path — number of edges, weights ignored
k(X),  t(X),  r(X,Y)k(X),\; t(X),\; r(X, Y)D*: minimum key, tag ∈ {NEW, OPEN, CLOSED}, measured arc cost
rhs(s),  kmrhs(s),\; k_mD* Lite: one-step lookahead value, key modifier
ε≥1\varepsilon \ge 1heuristic inflation factor (weighted A*, ARA*)
π⋆:V→V\pi^\star : V \to Vuniversal plan (policy): the successor to take from every vertex
O(⋅),o(⋅),Ω(⋅),Θ(⋅)O(\cdot), o(\cdot), \Omega(\cdot), \Theta(\cdot)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 11 for an axial step and 1.41.4 for a diagonal — his approximation of 2\sqrt 2, chosen so that hand arithmetic comes out in tenths. This is not the Euclidean metric: two cells sideways and one up costs 2.42.4, not 5=2.24\sqrt 5 = 2.24. 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 2\sqrt 2 changes one constant.

The link length ℓ(π)\ell(\pi) 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 1/1.41/1.4 grid they disagree in a concrete way: four axial steps cost 4.04.0 and three diagonals cost 4.24.2, 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 nn (Choset §H.2.1) moves it from the open set OO to the closed set CC and, for every x∈Star(n)∖Cx \in \mathrm{Star}(n) \setminus C, either inserts xx into OO with g(x)=g(n)+c(n,x)g(x) = g(n) + c(n, x) and b(x)=nb(x) = n, or — if xx is already open and the new route is cheaper — updates g(x)g(x) and b(x)b(x). 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:

f(n)  =  g(n)  +  ε h(n),O∪C is what the search has looked at,qstart=bk(qgoal),…,b(qgoal),qgoal is what it returns.f(n) \;=\; \htmlClass{term-start}{g(n)} \;+\; \varepsilon\, \htmlClass{term-goal}{h(n)}, \qquad \htmlClass{term-graph}{O \cup C} \text{ is what the search has looked at}, \qquad \htmlClass{term-path}{\qstart = b^k(\qgoal), \ldots, b(\qgoal), \qgoal} \text{ is what it returns.}

The cost-to-come gg is measured from the green start; the heuristic hh 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 — h=1.4min⁡(Δx,Δy)+∣Δx−Δy∣h = 1.4\min(\Delta x, \Delta y) + |\Delta x - \Delta y|, the exact cost-to-go on an empty 1/1.41/1.4 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 2\sqrt 2.

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 qstart\qstart — 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 dd followed by nodes of depth d+1d + 1, and nothing else: the start is at depth 00, and dequeuing a depth-dd node enqueues only depth-(d+1)(d+1) nodes behind the remaining depth-dd ones.

Step 2 — first discovery. A node is enqueued the first time it is seen, from a parent at depth dd, and is assigned depth d+1d + 1; later sightings are ignored.

Step 3 — no shorter path exists. Suppose xx were reachable in d′<d+1d' < d + 1 links. Then its parent on that path has depth d′−1≤d−1d' - 1 \le d - 1 and, by the invariant, was dequeued before any depth-dd node — at which moment xx would have been enqueued at depth d′≤dd' \le d. 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 1/1.41/1.4 weights it is not: see the paragraph above. ■\blacksquare

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 qgoal\qgoal or empties OO; in the latter case no path exists (Choset §H.2.2).

Step 1. Each expansion moves a node into CC. Under a consistent heuristic a node is never removed from CC again, so there are at most ∣V∣|V| expansions. Without consistency a closed node may be reopened when a cheaper path to it is found; but each reopening strictly decreases its gg, and gg 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. VV is finite, so by Step 1 the number of expansions is finite and the loop terminates.

Step 3. While a path from qstart\qstart to qgoal\qgoal exists, OO contains some node on it: the start is on it and in OO initially; whenever a path node is expanded, its successor on the path is inserted or already present. So OO cannot empty while a path exists, and if OO empties, no path exists. ■\blacksquare

DerivationA* is optimal under admissibility

Statement. If hh is admissible, then the first time qgoal\qgoal is popped, g(qgoal)=c⋆g(\qgoal) = c^\star, the cost of a cheapest path (formalizing Choset §H.2.2).

Step 1 — suppose not. Assume qgoal\qgoal is popped with g(qgoal)>c⋆g(\qgoal) > c^\star.

Step 2 — a frontier node on an optimal path. Fix an optimal path qstart=v0,v1,…,vm=qgoal\qstart = v_0, v_1, \dots, v_m = \qgoal. Let vv be the first node on it not yet expanded. Its predecessor has been expanded with the optimal gg (induction along the path), so v∈Ov \in O with g(v)=g⋆(v)g(v) = g^\star(v).

Step 3 — bound its priority. f(v)=g⋆(v)+h(v)≤g⋆(v)+h⋆(v)=c⋆f(v) = g^\star(v) + h(v) \le g^\star(v) + h^\star(v) = c^\star, using admissibility and the fact that vv lies on an optimal path.

Step 4 — compare with the goal. f(qgoal)=g(qgoal)+0>c⋆≥f(v)f(\qgoal) = g(\qgoal) + 0 > c^\star \ge f(v).

Step 5 — contradiction. The queue pops the smallest ff, so it would pop vv before qgoal\qgoal. Hence the assumption fails and g(qgoal)=c⋆g(\qgoal) = c^\star. ■\blacksquare

Reconciling two stopping rules. Alg. 24 exits when the goal is popped. Choset's §H.2 walk-through keeps expanding nodes whose ff is below the pushed goal's and discards the rest. These are the same rule: a node is popped only when nothing in OO beats it, so the walk-through's "discard everything with f≥f \ge the goal's" is what the pop does implicitly.

DerivationConsistency means no node is ever reopened

Statement. With a consistent hh, the popped ff-values are non-decreasing, and every node's gg is already optimal when it enters CC.

Step 1. For an edge (n,x)(n, x), f(x)=g(n)+c(n,x)+h(x)≥g(n)+h(n)=f(n)f(x) = g(n) + c(n, x) + h(x) \ge g(n) + h(n) = f(n) by consistency. A node's children enter the queue with priority at least their parent's.

Step 2. Hence the sequence of popped ff-values is monotone non-decreasing: whatever is popped next was inserted by some earlier pop with no smaller ff.

Step 3. Suppose a closed node xx later received a cheaper gg via some node yy popped after xx. Then f(x)new=gnew(x)+h(x)<f(x)old≤f(y)f(x)_{\text{new}} = g_{\text{new}}(x) + h(x) < f(x)_{\text{old}} \le f(y), contradicting monotonicity. So closed nodes stay closed and their gg is optimal. ■\blacksquare

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 εh\varepsilon h with ε>1\varepsilon > 1 is not consistent — but it still satisfies c≤ε c⋆c \le \varepsilon\, c^\star, since the argument of the previous derivation goes through with f(v)≤g⋆(v)+εh⋆(v)≤εc⋆f(v) \le g^\star(v) + \varepsilon h^\star(v) \le \varepsilon c^\star. 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 ε\varepsilon 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 SS–AA: 44, AA–GG: 44, SS–BB: 33, BB–GG: 22. Heuristic h(S)=2h(S) = 2, h(A)=3h(A) = 3, h(B)=10h(B) = 10, h(G)=0h(G) = 0. A* returns S,A,GS, A, G at cost 88; the optimum S,B,GS, B, G costs 55.

Step 1 — expand SS. f(A)=g(A)+h(A)=4+3=7f(A) = g(A) + h(A) = 4 + 3 = 7; f(B)=3+10=13f(B) = 3 + 10 = 13.

Step 2 — expand AA, the smaller ff. f(G)=(4+4)+0=8f(G) = (4 + 4) + 0 = 8.

Step 3 — pop GG. 8<138 < 13, so the goal is popped before BB is ever expanded. A* returns the back-pointer path S,A,GS, A, G with g(G)=8g(G) = 8.

Step 4 — the culprit. h(B)=10>h⋆(B)=2h(B) = 10 > h^\star(B) = 2. In Step 3 of the optimality proof the inequality f(v)≤c⋆f(v) \le c^\star needed h(v)≤h⋆(v)h(v) \le h^\star(v) at the frontier node v=Bv = B on the optimal path; here f(B)=13>5=c⋆f(B) = 13 > 5 = c^\star, and the chain of inequalities snaps. One bad estimate, anywhere on an optimal path, suffices. ■\blacksquare

Repair h(B)h(B) to its true value 22 and A* pops BB at f=5f = 5 before GG at 88, finds GG via BB at f=5f = 5, and returns the optimum; the chapter's check runs both versions.

The hand trace: five pops on a 4×34 \times 3 grid

Here is the micro-example the tests pin, in Choset's §H.2.4 style. An 8-connected 4×34 \times 3 grid, cells (col,row)(\mathrm{col}, \mathrm{row}) with col∈{1,…,4}\mathrm{col} \in \{1, \dots, 4\} and row∈{1,2,3}\mathrm{row} \in \{1, 2, 3\}; obstacle cells (2,1)(2, 1) and (2,2)(2, 2) are deleted from the graph; qstart=(1,1)\qstart = (1, 1), qgoal=(4,1)\qgoal = (4, 1); axial step 11, diagonal 1.41.4; octile heuristic. Ties in ff break toward smaller hh, then lexicographically.

popexpandedgghhffopen set after expansion (ff: cell)
1(1,1)(1,1)003.03.03.03.04.44.4: (1,2)(1,2)
2(1,2)(1,2)1.01.03.43.44.44.45.25.2: (2,3)(2,3) · 5.85.8: (1,3)(1,3)
3(2,3)(2,3)2.42.42.82.85.25.25.25.2: (3,2)(3,2) · 5.85.8: (1,3)(1,3), (3,3)(3,3)
4(3,2)(3,2)3.83.81.41.45.25.25.25.2: (4,1)(4,1) · 5.85.8: (1,3)(1,3), (3,1)(3,1), (3,3)(3,3), (4,2)(4,2) · 7.27.2: (4,3)(4,3)
5(4,1)(4,1)5.25.2005.25.2goal popped — done

Back pointers give (1,1)→(1,2)→(2,3)→(3,2)→(4,1)(1,1) \to (1,2) \to (2,3) \to (3,2) \to (4,1) at cost 1+1.4+1.4+1.4=5.21 + 1.4 + 1.4 + 1.4 = \mathbf{5.2} in five expansions. Dijkstra on the same grid, h≡0h \equiv 0, expands all ten free cells — every other free cell has g<5.2g < 5.2, so all of them are popped before the goal — and returns the same cost. Greedy, f=hf = h, 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 qgoal\qgoal labels every node with its cost-to-goal h(X)h(X) and a back pointer b(X)b(X) — 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 h(X)=min⁡Y[c(X,Y)+h(Y)]h(X) = \min_Y [c(X, Y) + h(Y)] with h(qgoal)=0h(\qgoal) = 0: a deterministic Bellman equation. The policy is the arg min.

Step 3 — raised states. Increase the cost of an arc (Xc,Y)(X_c, Y). Only nodes whose back-pointer chain passes through that arc have an hh that is now too small. D* re-inserts the affected states; when such a state is popped its key kk — the smallest hh it has had since insertion — is less than its current hh. 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 hh and can offer a better route: in Choset's gate example, cell (3,2)(3,2) is raised when the gate (4,3)(4,3) closes, and its neighbor (4,1)(4,1), whose path never used the gate, offers h(4,1)+1.4=6.2+1.4=7.6h(4,1) + 1.4 = 6.2 + 1.4 = 7.6 (figure H.26). A state popped with k=hk = h 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 h(Xc)h(X_c) (Alg. 25, line 20): nothing left on the queue could lower the robot's cost, so its back-pointer path is optimal again. ■\blacksquare

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, gg and a one-step lookahead rhsrhs, a queue of inconsistent vertices, and a key modifier kmk_m that absorbs the robot's own motion so the heap never needs re-keying. In the sister book's Chapter 21 the min⁡\min in Step 2 becomes an expectation and the plan becomes a value function; nothing else changes.

Complexity and completeness

EXPTIME: Def. G.2.6 — decided by an algorithm whose running time is O(2^(n^c)) for some constant c.EXPTIMEPSPACE: Def. G.2.11 — decided by an algorithm using polynomial space. Generalized motion planning for many bodies is PSPACE-hard (Canny), which is why Chapter 9 can say the roadmap bound is tight.PSPACENP: Def. G.2.7 — a candidate solution (certificate) of polynomial length can be verified in polynomial time. Whether P = NP is open.NPP: Def. G.2.5 — decided in polynomial time: sorting, shortest paths, A* on an explicit graph.PP ⊆ NP ⊆ PSPACE ⊆ EXPTIME (Choset §G.2) — inclusions proven, strictness open except P ⊊ EXPTIMEcomplete: Def. G.3.1 — in finite time, finds a solution if one exists, otherwise reports that none does. A* on a finite graph.completeresolution complete: Def. G.3.2 — the same guarantee, for a fixed resolution step ε > 0: a grid planner is complete relative to its grid.resolution completeprobabilistically complete: Def. G.3.3 — the probability of finding a solution, if one exists, goes to 1 as running time goes to infinity. PRM and RRT, Part III.probabilistically completethree completeness notions (§G.3)weaker as you go down; hover for definitions
Figure Choset Appendix G in one picture. Left: the complexity classes, nested, with the inclusions that are proven and the strictness that is open. Right: the three completeness notions this book uses as law; hover for the definitions.

Running time is measured asymptotically (Choset Def. G.1.1): f∈O(g)f \in O(g) if f(n)≤c g(n)f(n) \le c\,g(n) for all large nn; Ω\Omega is the lower bound, Θ\Theta both, oo and ω\omega the strict versions. A* with a binary heap runs in O((V+E)log⁡V)O((V + E)\log V) 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.

AlgorithmBEST-FIRST(G, q_start, q_goal, h, priority) — Choset Alg. 24 with the knob exposedCostO((V + E) log V) with a binary heap; the heuristic shrinks the constant
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
  1. g(qstart)←0g(\qstart) \leftarrow 0; O←{qstart}O \leftarrow \{\qstart\} keyed on ff; C←∅C \leftarrow \emptyset
  2. repeat
  3.     pick nbestn_{best} from OO with f(nbest)≤f(n)f(n_{best}) \le f(n) for all n∈On \in O  — ties: smaller hh, then the graph's order
  4.     remove nbestn_{best} from OO and add it to CC
  5.     if nbest=qgoaln_{best} = \qgoal then EXIT with the back-pointer path
  6.     for all x∈Star(nbest)x \in \mathrm{Star}(n_{best}) not in CC do
  7.         if x∉Ox \notin O then g(x)←g(nbest)+c(nbest,x)g(x) \leftarrow g(n_{best}) + c(n_{best}, x); b(x)←nbestb(x) \leftarrow n_{best}; add xx to OO
  8.         else if g(nbest)+c(nbest,x)<g(x)g(n_{best}) + c(n_{best}, x) < g(x) then update g(x)g(x), b(x)b(x) and xx's priority  — Alg. 24 prints only the back pointer
  9. until OO is empty — report that no path exists
  10. where f(n)=f(n) = link length (breadth-first) · g(n)g(n) (Dijkstra) · h(n)h(n) (greedy) · g(n)+εh(n)g(n) + \varepsilon h(n) (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 CC receives a cheaper gg — which Derivation 4 says cannot happen under a consistent heuristic, and which does happen with ε>1\varepsilon > 1 — it is reopened: removed from CC and pushed again. Correctness over speed, and a counter the widget shows.

AlgorithmPROCESS-STATE(O, L) — the heart of D* (Choset Alg. 31, Stentz's structure)CostO(deg · log |O|) per call; a repair runs it until k_min ≥ h(X_c)
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
  1. X←X \leftarrow the state in OO with minimum kk; kold←k(X)k_{old} \leftarrow k(X); delete XX from OO, t(X)←CLOSEDt(X) \leftarrow \mathrm{CLOSED}
  2. if kold<h(X)k_{old} < h(X) then  — RAISE: the old route got worse; look for a rescuer
  3.     for each neighbor YY with h(Y)≤koldh(Y) \le k_{old} and h(X)>h(Y)+c(Y,X)h(X) > h(Y) + c(Y, X): b(X)←Yb(X) \leftarrow Y; h(X)←h(Y)+c(Y,X)h(X) \leftarrow h(Y) + c(Y, X)
  4. if kold=h(X)k_{old} = h(X) then  — LOWER: good news for the descendants
  5.     for each neighbor YY: if t(Y)=NEWt(Y) = \mathrm{NEW}, or b(Y)=Xb(Y) = X and h(Y)≠h(X)+c(X,Y)h(Y) \ne h(X) + c(X, Y), or b(Y)≠Xb(Y) \ne X and h(Y)>h(X)+c(X,Y)h(Y) > h(X) + c(X, Y) then b(Y)←Xb(Y) \leftarrow X; INSERT(Y,h(X)+c(X,Y))(Y, h(X) + c(X, Y))
  6. else  — still RAISE after the fix-up
  7.     for each neighbor YY: if t(Y)=NEWt(Y) = \mathrm{NEW}, or b(Y)=Xb(Y) = X and h(Y)≠h(X)+c(X,Y)h(Y) \ne h(X) + c(X,Y) then b(Y)←Xb(Y) \leftarrow X; INSERT(Y,h(X)+c(X,Y))(Y, h(X) + c(X,Y))
  8.         else if b(Y)≠Xb(Y) \ne X and h(Y)>h(X)+c(X,Y)h(Y) > h(X) + c(X,Y) then INSERT(X,h(X))(X, h(X))  — re-open X: it can still improve Y later
  9.         else if b(Y)≠Xb(Y) \ne X and h(X)>h(Y)+c(X,Y)h(X) > h(Y) + c(X,Y) and t(Y)=CLOSEDt(Y) = \mathrm{CLOSED} and h(Y)>koldh(Y) > k_{old} then INSERT(Y,h(Y))(Y, h(Y))  — re-open a possible rescuer
  10. return kmink_{min} of OO  — INSERT (Alg. 27): k(X)←hnewk(X) \leftarrow h_{new} if NEW, min⁡(k,hnew)\min(k, h_{new}) if OPEN, min⁡(h,hnew)\min(h, h_{new}) if CLOSED; then h(X)←hnewh(X) \leftarrow h_{new}, t(X)←OPENt(X) \leftarrow \mathrm{OPEN}
AlgorithmD*-LITE — Koenig & Likhachev's ComputeShortestPath, backward from the goalCosteach vertex expanded at most twice per repair; repair cost proportional to the affected region
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
  1. while TopKey(U)≤(U) \le Key(sstart)(s_{start}) or rhs(sstart)≠g(sstart)rhs(s_{start}) \ne g(s_{start}) do  — ties processed too, so the whole f≤g(sstart)f \le g(s_{start}) band is consistent
  2.     u←u \leftarrow Pop(U)(U), kold←k_{old} \leftarrow its key
  3.     if kold<k_{old} < Key(u)(u) then re-insert uu with Key(u)(u)  — stale because kmk_m grew
  4.     else if g(u)>rhs(u)g(u) > rhs(u) then g(u)←rhs(u)g(u) \leftarrow rhs(u); UpdateVertex on every predecessor  — overconsistent: commit the good news
  5.     else g(u)←∞g(u) \leftarrow \infty; UpdateVertex on every predecessor and on uu  — underconsistent: forget, re-derive
  6. UpdateVertex(u)(u): if u≠sgoalu \ne s_{goal}: rhs(u)←min⁡s′[c(u,s′)+g(s′)]rhs(u) \leftarrow \min_{s'} [c(u, s') + g(s')]; remove uu from UU; if g(u)≠rhs(u)g(u) \ne rhs(u) insert uu with Key(u)(u)
  7. on edge changes: km←km+h(slast,sstart)k_m \leftarrow k_m + h(s_{last}, s_{start}); slast←sstarts_{last} \leftarrow s_{start}; UpdateVertex on both endpoints of every changed arc; run lines 1–5
  8. move: sstart←arg⁡min⁡s′[c(sstart,s′)+g(s′)]s_{start} \leftarrow \arg\min_{s'} [c(s_{start}, s') + g(s')]
AlgorithmUNIVERSAL-PLAN(G, q_goal) — Choset §H.4Costone full Dijkstra, O((V + E) log V); every later query is a lookup
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
  1. h(qgoal)←0h(\qgoal) \leftarrow 0; push qgoal\qgoal; all other h←∞h \leftarrow \infty
  2. while the queue is non-empty do pop the cheapest XX
  3.     for each Y∈Star(X)Y \in \mathrm{Star}(X): if h(X)+c(X,Y)<h(Y)h(X) + c(X, Y) < h(Y) then h(Y)←h(X)+c(X,Y)h(Y) \leftarrow h(X) + c(X, Y); π⋆(Y)←X\pi^\star(Y) \leftarrow X; push YY
  4. return (h,π⋆)(h, \pi^\star)  — hh satisfies h(X)=min⁡Y[c(X,Y)+h(Y)]h(X) = \min_Y [c(X,Y) + h(Y)] everywhere; following π⋆\pi^\star from any XX costs exactly h(X)h(X)

Replanning, watched

With Choset's gate world the numbers are the ones in his figures. The initial backward Dijkstra labels the start (2,1)(2,1) with h=7.0h = 7.0 — five diagonals — and (4,1)(4,1) with h=6.2h = 6.2, and Rusty sets off along (2,1) (3,2) (4,3) (5,4) (6,5) (7,6)(2,1)\,(3,2)\,(4,3)\,(5,4)\,(6,5)\,(7,6). The gate closes; at (3,2)(3,2) Rusty is adjacent and notices. D* re-inserts the gate and its neighbors, pops (3,2)(3,2) as a RAISE state — its hh has jumped above Choset's obstacle cost of 1000010000 while its key stays at the old 7.07.0 — and the LOWER neighbor (4,1)(4,1) rescues it at 6.2+1.4=7.66.2 + 1.4 = 7.6. The repair pops 15 states, five of them raised, and Rusty drives (3,2) (4,1) (5,2) (6,3) (7,4) (7,5) (7,6)(3,2)\,(4,1)\,(5,2)\,(6,3)\,(7,4)\,(7,5)\,(7,6) at cost 7.67.6. Redoing the backward Dijkstra from scratch on the changed map would pop 32; a from-scratch A* from (3,2)(3,2) 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 h(X)=min⁡Y[c(X,Y)+h(Y)]h(X) = \min_Y [c(X, Y) + h(Y)] at the cell under the cursor, term by term, with the minimizing YY being the arrow. The chapter's check asserts the equation holds at every labelled cell to 10−1510^{-15} 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.

crates/search/src/space.rs
/// 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.

crates/search/src/best_first.rs
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 kmk_m; everything else is bookkeeping.

crates/search/src/dstar_lite.rs
/// 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.

crates/search/src/policy.rs
/// 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

crates/search/examples/astar_trace.rs
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);
}
cargo run -p search --example astar_trace
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.2
cargo run -p search --example nonoptimistic · cargo run -p search --example dstar_gate
A* 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 Dijkstra

astar_trace_matches_text asserts the pop order, the g/h/fg/h/f triples to 10−910^{-9}, 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 88 and 55. dstar_gate_repairs_to_7_6 asserts the initial path, h(4,1)=6.2h(4,1) = 6.2, and the repaired k(3,2)=7.6k(3,2) = 7.6 with back pointer (4,1)(4,1); 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 10−910^{-9} 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 11s and 2\sqrt 2s — can be left stale, and on one grid in five hundred the greedy descent stepped onto such a vertex and reported a cost gg 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 0.250.25 m, plan from room A to the bedroom with astar under Choset's 1/1.41/1.4 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

  1. Foundation exerciseDifficulty 2 of 3Octile is consistent — for the right diagonal

    Prove that h(n)=1.4min⁡(Δx,Δy)+∣Δx−Δy∣h(n) = 1.4\min(\Delta x, \Delta y) + |\Delta x - \Delta y| is consistent on the 8-connected grid with Choset's 1/1.41/1.4 metric: show h(n)≤c(n,x)+h(x)h(n) \le c(n, x) + h(x) for each of the eight moves and h(qgoal)=0h(\qgoal) = 0. Then give a two-cell example showing that the same formula is not admissible if diagonals cost 2\sqrt 2 instead of 1.41.4.

  2. 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 1000010000 to enter. Show that on any grid with fewer than 10000/1.410000 / 1.4 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.

  3. Conceptual exerciseDifficulty 1 of 3Predict the weighted-A* bound, then verify
    Predict 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?

  4. 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)?

  5. Practical exerciseDifficulty 2 of 3A true FIFO

    Implement Priority::LinkLength as 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 1/1.41/1.4 and write the test that shows where they disagree, with the four-axial-versus-three-diagonal example as its smallest witness.

  6. Practical exerciseDifficulty 3 of 3ARA*

    Implement anytime repairing A* in src/ara.rs with the schedule ε=3,2,1.5,1\varepsilon = 3, 2, 1.5, 1, 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

  1. 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).

  2. 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.

  3. 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'.

  4. 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.

  5. 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'.

  6. 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.

  7. 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.