From 18edf844dbc9698bcc26ee66dfeaef3ec6b3f397 Mon Sep 17 00:00:00 2001 From: tachsin Date: Fri, 11 Sep 2026 21:10:29 +0300 Subject: [PATCH] feat: let the caller break ties in astar and idastar Ties matter: on an open 120x120 grid with an exact heuristic, the order chosen between nodes of equal estimated cost is the difference between expanding 238 nodes and 14399, for the same path. With a fifth of the grid blocked it is 1046 against 7016. `astar` already lets the caller decide, and needed only to say so. It takes ties in order of decreasing cost from the start, which is the usual recommendation, and any other preference can be folded into the heuristic: express costs in units with room beneath a single step and put the tie-break in that room, where it can reorder the queue but never outweigh a real difference in cost. The documentation now shows that, since it is not something a reader would guess. `idastar` explored successors of equal estimated cost in whatever order an unstable sort left them. That happens to preserve the caller's order today, because the sort falls back to insertion sort on short slices, but nothing promises it. A stable sort makes the caller's order the tie-break by contract rather than by accident. It costs nothing measurable: 0.011 ms per run either way on a 65x65 grid, since both sorts are insertion sort at these lengths. No public API changes. --- src/directed/astar.rs | 39 ++++++++++++++ src/directed/idastar.rs | 9 +++- tests/tiebreak.rs | 110 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 tests/tiebreak.rs diff --git a/src/directed/astar.rs b/src/directed/astar.rs index d6503d46..8baeefc6 100644 --- a/src/directed/astar.rs +++ b/src/directed/astar.rs @@ -77,6 +77,45 @@ use crate::FxIndexMap; /// |&p| p == GOAL); /// assert_eq!(result.expect("no path found").1, 4); /// ``` +/// # Tie-breaking +/// +/// Nodes whose estimated total cost is equal are taken in order of decreasing cost from the +/// start. That is the usual recommendation, because it drives the search towards the goal +/// rather than spreading it evenly: on an open 120x120 grid with an exact heuristic it is the +/// difference between expanding 238 nodes and 14399, for the same path. +/// +/// A different preference can be expressed through `heuristic`, with no change here. Give costs +/// enough room beneath a single step and put the tie-break in that room: it reorders the queue +/// without altering which path is cheapest, since any real difference in cost is a whole step +/// or more. +/// +/// ``` +/// use pathfinding::prelude::astar; +/// +/// const STEP: u64 = 1024; // one move, leaving 1023 units of room beneath it +/// +/// let successors = |&(x, y): &(u64, u64)| { +/// [(1, 0), (0, 1)] +/// .into_iter() +/// .map(move |(dx, dy)| ((x + dx, y + dy), STEP)) +/// .filter(|&((nx, ny), _)| nx <= 2 && ny <= 2) +/// .collect::>() +/// }; +/// let distance = |&(x, y): &(u64, u64)| STEP * ((2 - x) + (2 - y)); +/// +/// // Every way across this grid costs the same, so the tie decides which one comes back. +/// let (over_the_top, cost) = astar(&(0, 0), successors, distance, |&n| n == (2, 2)) +/// .expect("no path found"); +/// assert_eq!(over_the_top, vec![(0, 0), (1, 0), (2, 0), (2, 1), (2, 2)]); +/// +/// // Preferring a smaller column costs a fraction of a step, so it can only break ties. +/// let keep_left = |n: &(u64, u64)| distance(n) + n.0; +/// let (down_the_side, same_cost) = astar(&(0, 0), successors, keep_left, |&n| n == (2, 2)) +/// .expect("no path found"); +/// assert_eq!(down_the_side, vec![(0, 0), (0, 1), (0, 2), (1, 2), (2, 2)]); +/// assert_eq!(cost, same_cost); +/// ``` +/// #[expect(clippy::missing_panics_doc)] pub fn astar( start: &N, diff --git a/src/directed/idastar.rs b/src/directed/idastar.rs index f3d18983..dc5468ea 100644 --- a/src/directed/idastar.rs +++ b/src/directed/idastar.rs @@ -71,6 +71,11 @@ use std::{hash::Hash, ops::ControlFlow}; /// |&p| p == GOAL); /// assert_eq!(result.expect("no path found").1, 4); /// ``` +/// # Tie-breaking +/// +/// Successors whose estimated total cost is equal are explored in the order `successors` +/// returned them, so the choice belongs to the caller: yield the preferred one first. +/// pub fn idastar( start: &N, mut successors: FN, @@ -140,7 +145,9 @@ where }) }) .collect::>(); - neighbs.sort_unstable_by_key(|(_, _, c1)| *c1); + // A stable sort, so that successors of equal estimated cost stay in the order the + // caller produced them: that order is the caller's way of breaking the tie. + neighbs.sort_by_key(|(_, _, c1)| *c1); neighbs }; let mut min = None; diff --git a/tests/tiebreak.rs b/tests/tiebreak.rs new file mode 100644 index 00000000..f1565135 --- /dev/null +++ b/tests/tiebreak.rs @@ -0,0 +1,110 @@ +//! Choosing between nodes of equal estimated cost, from outside the library. + +use pathfinding::prelude::{astar, idastar}; +use std::cell::Cell; + +/// One move, leaving room beneath it for a tie-break that cannot outweigh a real step. +const STEP: u64 = 1024; + +const SIDE: u64 = 12; + +fn grid(&(x, y): &(u64, u64)) -> Vec<((u64, u64), u64)> { + [(1, 0), (0, 1)] + .into_iter() + .map(move |(dx, dy)| ((x + dx, y + dy), STEP)) + .filter(|&((nx, ny), _)| nx < SIDE && ny < SIDE) + .collect() +} + +const fn distance(&(x, y): &(u64, u64)) -> u64 { + STEP * ((SIDE - 1 - x) + (SIDE - 1 - y)) +} + +/// Every monotone route across the grid costs the same, so which one comes back is decided +/// entirely by the tie-break, and a term smaller than a step can only affect that. +#[test] +fn a_term_beneath_one_step_chooses_between_equal_paths() { + let goal = (SIDE - 1, SIDE - 1); + + let (default_path, cost) = astar(&(0, 0), grid, distance, |&n| n == goal).expect("no path"); + let (left_path, left_cost) = + astar(&(0, 0), grid, |n| distance(n) + n.0, |&n| n == goal).expect("no path"); + let (top_path, top_cost) = + astar(&(0, 0), grid, |n| distance(n) + n.1, |&n| n == goal).expect("no path"); + + // Same length, because the tie-break never outweighs a step. + assert_eq!(cost, left_cost); + assert_eq!(cost, top_cost); + assert_eq!(cost, STEP * 2 * (SIDE - 1)); + + // Preferring a smaller column hugs one edge, a smaller row the other. + assert_ne!(default_path, left_path); + assert!( + left_path[1] == (0, 1), + "preferring a smaller column should step down first, got {:?}", + left_path[1] + ); + assert!( + top_path[1] == (1, 0), + "preferring a smaller row should step across first, got {:?}", + top_path[1] + ); +} + +/// The tie-break decides how much of the graph is looked at, which is the reason to care. +#[test] +fn the_tie_break_changes_how_much_is_expanded() { + let goal = (SIDE - 1, SIDE - 1); + + let count = |heuristic: &dyn Fn(&(u64, u64)) -> u64| { + let expansions = Cell::new(0u32); + let counted = |n: &(u64, u64)| { + expansions.set(expansions.get() + 1); + grid(n) + }; + let (_, cost) = astar(&(0, 0), counted, |n| heuristic(n), |&n| n == goal).expect("no path"); + assert_eq!(cost, STEP * 2 * (SIDE - 1), "the path must stay optimal"); + expansions.get() + }; + + // The default prefers the node furthest from the start among equals, which walks straight + // at the goal. Forcing the opposite preference is admissible and still optimal, but it + // spreads the search over the whole grid instead. + let default_expansions = count(&distance); + let reversed = count(&|n| (distance(n) / STEP) * (STEP - 1)); + + assert!( + reversed > default_expansions * 4, + "expected the reversed tie-break to expand far more, got {reversed} against \ + {default_expansions}" + ); +} + +/// `idastar` explores successors of equal estimated cost in the order they were given, so the +/// caller decides simply by yielding the preferred one first. +#[test] +fn idastar_follows_the_order_successors_are_given_in() { + // Two routes of identical cost, so their estimates tie the whole way down. + let routes = |reversed: bool| { + move |&n: &char| { + let mut out: Vec<(char, u32)> = match n { + 'S' => vec![('A', 2), ('B', 2)], + 'A' | 'B' => vec![('G', 2)], + _ => vec![], + }; + if reversed { + out.reverse(); + } + out + } + }; + + let (through_a, cost) = + idastar(&'S', routes(false), |_| 0, |&n| n == 'G').expect("no path found"); + let (through_b, reversed_cost) = + idastar(&'S', routes(true), |_| 0, |&n| n == 'G').expect("no path found"); + + assert_eq!(through_a, vec!['S', 'A', 'G']); + assert_eq!(through_b, vec!['S', 'B', 'G']); + assert_eq!(cost, reversed_cost); +}