Skip to main content

condor_grid/algorithms/
dijkstra.rs

1//! Static-grid [`Pathfinder`]: weighted 4-connected Dijkstra.
2//!
3//! Each search is independent and returns the standard invalid/found/no-path outcome.
4//! Cost sums per-cell [`traversal_cost`](crate::Grid::traversal_cost) with no heuristic,
5//! making this the weighted correctness baseline. Prefer [`super::astar::AStar`] for
6//! the normal one-shot weighted entrypoint.
7//!
8//! # Examples
9//!
10//! ```
11//! use condor_grid::{Dijkstra, Grid, Pathfinder, Point, SearchRequest};
12//!
13//! let grid = Grid::new(5, 1).expect("grid dimensions are valid");
14//! let request = SearchRequest::new(Point::new(0, 0), Point::new(4, 0));
15//! let result = Dijkstra.search(&grid, request).expect("endpoints are walkable");
16//! assert!(result.is_found());
17//! assert_eq!(result.cost(), Some(4));
18//! ```
19use std::{cmp::Ordering, collections::BinaryHeap};
20
21use crate::{
22    grid::Grid,
23    path::Path,
24    search::{BudgetWatch, Pathfinder, SearchRequest, SearchResult},
25};
26
27/// Weighted-grid [`Pathfinder`]: uniform-priority Dijkstra.
28///
29/// Cost model: sums per-cell `traversal_cost` on 4-connected edges; no heuristic.
30/// Prefer as the exact weighted baseline against A*/JPS; A* is usually faster with
31/// admissible heuristics on the same model.
32#[derive(Debug, Default, Clone, Copy)]
33pub struct Dijkstra;
34
35impl Pathfinder for Dijkstra {
36    fn name(&self) -> &'static str {
37        "dijkstra"
38    }
39
40    fn search(&self, grid: &Grid, request: SearchRequest) -> SearchResult {
41        crate::search::validate_request(grid, request)?;
42        let Some(start_index) = grid.index_of(request.start) else {
43            return crate::search::not_found(0);
44        };
45        let Some(goal_index) = grid.index_of(request.goal) else {
46            return crate::search::not_found(0);
47        };
48
49        if !grid.is_walkable(request.start) || !grid.is_walkable(request.goal) {
50            return crate::search::not_found(0);
51        }
52
53        if !grid.is_reachable(request.start, request.goal) {
54            return crate::search::not_found(0);
55        }
56
57        if request.start == request.goal {
58            return crate::search::found(
59                Path::from_steps(vec![request.start]).expect("path contains at least one point"),
60                1,
61            );
62        }
63
64        let mut frontier = BinaryHeap::from([FrontierEntry {
65            cost_so_far: 0,
66            index: start_index,
67        }]);
68        let mut best_costs = vec![None; grid.cell_count()];
69        let mut parents = vec![None; grid.cell_count()];
70        let mut visited_nodes = 0usize;
71        let watch = BudgetWatch::start(request.budget);
72
73        best_costs[start_index] = Some(0);
74
75        while let Some(entry) = frontier.pop() {
76            if best_costs[entry.index] != Some(entry.cost_so_far) {
77                continue;
78            }
79
80            visited_nodes += 1;
81            if entry.index == goal_index {
82                break;
83            }
84
85            if let Err(reason) = watch.check(visited_nodes) {
86                return Err(crate::search::budget_error(reason));
87            }
88
89            let current = grid.point_from_index(entry.index);
90            for neighbor in grid.neighbors4(current) {
91                let neighbor_index = grid
92                    .index_of(neighbor)
93                    .expect("walkable neighbors must exist inside the grid");
94                let edge_cost = grid
95                    .traversal_cost(neighbor)
96                    .expect("walkable neighbors must have a traversal cost");
97                let Some(next_cost) = entry.cost_so_far.checked_add(edge_cost) else {
98                    continue;
99                };
100
101                if best_costs[neighbor_index].is_some_and(|best_cost| next_cost >= best_cost) {
102                    continue;
103                }
104
105                best_costs[neighbor_index] = Some(next_cost);
106                parents[neighbor_index] = Some(entry.index);
107                frontier.push(FrontierEntry {
108                    cost_so_far: next_cost,
109                    index: neighbor_index,
110                });
111            }
112        }
113
114        let Some(goal_cost) = best_costs[goal_index] else {
115            return crate::search::not_found(visited_nodes);
116        };
117
118        crate::search::found(
119            reconstruct_path(grid, &parents, start_index, goal_index, goal_cost),
120            visited_nodes,
121        )
122    }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126struct FrontierEntry {
127    cost_so_far: usize,
128    index: usize,
129}
130
131impl Ord for FrontierEntry {
132    fn cmp(&self, other: &Self) -> Ordering {
133        other
134            .cost_so_far
135            .cmp(&self.cost_so_far)
136            .then_with(|| other.index.cmp(&self.index))
137    }
138}
139
140impl PartialOrd for FrontierEntry {
141    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
142        Some(self.cmp(other))
143    }
144}
145
146fn reconstruct_path(
147    grid: &Grid,
148    parents: &[Option<usize>],
149    start_index: usize,
150    goal_index: usize,
151    total_cost: usize,
152) -> Path {
153    let mut current_index = goal_index;
154    let mut steps = vec![grid.point_from_index(goal_index)];
155
156    while let Some(parent_index) = parents[current_index] {
157        steps.push(grid.point_from_index(parent_index));
158        current_index = parent_index;
159    }
160
161    steps.reverse();
162    debug_assert_eq!(current_index, start_index);
163    Path::from_steps_with_cost(steps, total_cost).expect("path contains at least one point")
164}
165
166#[cfg(test)]
167mod tests {
168    use crate::{
169        algorithms::dijkstra::Dijkstra,
170        grid::{Cell, Grid},
171        point::Point,
172        search::{Pathfinder, SearchRequest},
173    };
174
175    #[test]
176    fn finds_a_shortest_path_through_the_only_gap() {
177        let mut grid = Grid::new(5, 5).expect("grid dimensions are valid");
178        for y in 0..5 {
179            if y != 2 {
180                grid.set_cell(Point::new(2, y), Cell::Blocked)
181                    .expect("valid grid edit");
182            }
183        }
184
185        let dijkstra = Dijkstra;
186        let result = dijkstra.search(
187            &grid,
188            SearchRequest::new(Point::new(0, 0), Point::new(4, 4)),
189        );
190
191        assert!(result.as_ref().expect("valid search request").is_found());
192        assert_eq!(
193            result.as_ref().expect("valid search request").cost(),
194            Some(8)
195        );
196        let path = result
197            .as_ref()
198            .expect("valid search request")
199            .path()
200            .expect("path should exist");
201        assert!(path.steps().contains(&Point::new(2, 2)));
202    }
203
204    #[test]
205    fn reports_when_no_path_exists() {
206        let mut grid = Grid::new(3, 3).expect("grid dimensions are valid");
207        for x in 0..3 {
208            grid.set_cell(Point::new(x, 1), Cell::Blocked)
209                .expect("valid grid edit");
210        }
211
212        let dijkstra = Dijkstra;
213        let result = dijkstra.search(
214            &grid,
215            SearchRequest::new(Point::new(0, 0), Point::new(2, 2)),
216        );
217
218        assert!(!result.as_ref().expect("valid search request").is_found());
219        assert_eq!(result.as_ref().expect("valid search request").cost(), None);
220        assert!(
221            result
222                .as_ref()
223                .expect("valid search request")
224                .stats()
225                .visited_nodes
226                > 0
227        );
228    }
229
230    #[test]
231    fn prefers_a_cheaper_weighted_detour() {
232        let mut grid = Grid::new(5, 3).expect("grid dimensions are valid");
233        assert_eq!(grid.set_traversal_cost(Point::new(1, 1), 5), Ok(()));
234        assert_eq!(grid.set_traversal_cost(Point::new(2, 1), 5), Ok(()));
235        assert_eq!(grid.set_traversal_cost(Point::new(3, 1), 5), Ok(()));
236
237        let dijkstra = Dijkstra;
238        let result = dijkstra.search(
239            &grid,
240            SearchRequest::new(Point::new(0, 1), Point::new(4, 1)),
241        );
242
243        assert!(result.as_ref().expect("valid search request").is_found());
244        assert_eq!(
245            result.as_ref().expect("valid search request").cost(),
246            Some(6)
247        );
248        let path = result
249            .as_ref()
250            .expect("valid search request")
251            .path()
252            .expect("path should exist");
253        assert!(
254            path.steps().contains(&Point::new(0, 0)) || path.steps().contains(&Point::new(0, 2))
255        );
256        assert_eq!(path.cost(), 6);
257    }
258
259    #[test]
260    fn supports_maximum_single_edge_cost() {
261        let mut grid = Grid::new(2, 1).expect("grid dimensions are valid");
262        assert_eq!(
263            grid.set_traversal_cost(Point::new(1, 0), usize::MAX),
264            Ok(())
265        );
266
267        let result = Dijkstra.search(
268            &grid,
269            SearchRequest::new(Point::new(0, 0), Point::new(1, 0)),
270        );
271
272        assert!(result.as_ref().expect("valid search request").is_found());
273        assert_eq!(
274            result.as_ref().expect("valid search request").cost(),
275            Some(usize::MAX)
276        );
277        assert_eq!(
278            result
279                .as_ref()
280                .expect("valid search request")
281                .path()
282                .expect("path should exist")
283                .cost(),
284            usize::MAX
285        );
286    }
287}