Skip to main content

condor_grid/algorithms/
bidirectional_bfs.rs

1//! Static-grid [`Pathfinder`]: bidirectional unweighted BFS.
2//!
3//! Each search alternates independent start and goal frontiers until they meet, then
4//! returns the usual invalid/found/no-path outcome. Cost is unit hop count and
5//! [`Grid::traversal_cost`](crate::Grid::traversal_cost) is ignored. Prefer
6//! [`super::bfs::Bfs`] for the simpler one-frontier unweighted baseline, or
7//! [`super::astar::AStar`] when cell costs vary.
8
9use std::collections::VecDeque;
10
11use crate::{
12    grid::Grid,
13    path::Path,
14    search::{BudgetWatch, Pathfinder, SearchRequest, SearchResult},
15};
16
17/// Online [`Pathfinder`]: bidirectional BFS meeting in the middle.
18///
19/// Cost model: unit hop count; ignores `traversal_cost`. Prefer over one-sided BFS on
20/// large uniform open fields; same metric as BFS when both terminate correctly.
21#[derive(Debug, Default, Clone, Copy)]
22pub struct BidirectionalBfs;
23
24impl Pathfinder for BidirectionalBfs {
25    fn name(&self) -> &'static str {
26        "bidirectional-bfs"
27    }
28
29    fn search(&self, grid: &Grid, request: SearchRequest) -> SearchResult {
30        crate::search::validate_request(grid, request)?;
31        let Some(start_index) = grid.index_of(request.start) else {
32            return crate::search::not_found(0);
33        };
34        let Some(goal_index) = grid.index_of(request.goal) else {
35            return crate::search::not_found(0);
36        };
37
38        if !grid.is_walkable(request.start) || !grid.is_walkable(request.goal) {
39            return crate::search::not_found(0);
40        }
41
42        if !grid.is_reachable(request.start, request.goal) {
43            return crate::search::not_found(0);
44        }
45
46        if request.start == request.goal {
47            return crate::search::found(
48                Path::from_steps(vec![request.start]).expect("path contains at least one point"),
49                1,
50            );
51        }
52
53        let mut start_frontier = VecDeque::from([start_index]);
54        let mut goal_frontier = VecDeque::from([goal_index]);
55        let mut distances_from_start = vec![None; grid.cell_count()];
56        let mut distances_from_goal = vec![None; grid.cell_count()];
57        let mut parents_from_start = vec![None; grid.cell_count()];
58        let mut parents_from_goal = vec![None; grid.cell_count()];
59        let mut settled_any = vec![false; grid.cell_count()];
60        let mut visited_nodes = 0;
61        let mut expand_from_start = true;
62        let watch = BudgetWatch::start(request.budget);
63
64        distances_from_start[start_index] = Some(0);
65        distances_from_goal[goal_index] = Some(0);
66
67        while !start_frontier.is_empty() && !goal_frontier.is_empty() {
68            let meeting = if expand_from_start {
69                expand_frontier(
70                    grid,
71                    &mut start_frontier,
72                    &mut distances_from_start,
73                    &distances_from_goal,
74                    &mut parents_from_start,
75                    &mut settled_any,
76                    &mut visited_nodes,
77                )
78            } else {
79                expand_frontier(
80                    grid,
81                    &mut goal_frontier,
82                    &mut distances_from_goal,
83                    &distances_from_start,
84                    &mut parents_from_goal,
85                    &mut settled_any,
86                    &mut visited_nodes,
87                )
88            };
89
90            if let Some(meeting_index) = meeting {
91                return crate::search::found(
92                    reconstruct_path(
93                        grid,
94                        &parents_from_start,
95                        &parents_from_goal,
96                        start_index,
97                        goal_index,
98                        meeting_index,
99                    ),
100                    visited_nodes,
101                );
102            }
103
104            if let Err(reason) = watch.check(visited_nodes) {
105                return Err(crate::search::budget_error(reason));
106            }
107
108            expand_from_start = !expand_from_start;
109        }
110
111        crate::search::not_found(visited_nodes)
112    }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116struct MeetingCandidate {
117    index: usize,
118    total_cost: usize,
119}
120
121fn expand_frontier(
122    grid: &Grid,
123    frontier: &mut VecDeque<usize>,
124    distances: &mut [Option<usize>],
125    other_distances: &[Option<usize>],
126    parents: &mut [Option<usize>],
127    settled_any: &mut [bool],
128    visited_nodes: &mut usize,
129) -> Option<usize> {
130    let layer_len = frontier.len();
131    let mut best_meeting = None;
132
133    for _ in 0..layer_len {
134        let current_index = frontier
135            .pop_front()
136            .expect("frontier layer length must match queued entries");
137
138        if !settled_any[current_index] {
139            settled_any[current_index] = true;
140            *visited_nodes += 1;
141        }
142
143        let current_distance =
144            distances[current_index].expect("queued nodes must have a known distance");
145        if let Some(other_distance) = other_distances[current_index] {
146            update_meeting_candidate(
147                &mut best_meeting,
148                current_index,
149                current_distance + other_distance,
150            );
151        }
152
153        let current = grid.point_from_index(current_index);
154        for neighbor in grid.neighbors4(current) {
155            let neighbor_index = grid
156                .index_of(neighbor)
157                .expect("walkable neighbors must exist inside the grid");
158
159            if distances[neighbor_index].is_some() {
160                continue;
161            }
162
163            let next_distance = current_distance + 1;
164            distances[neighbor_index] = Some(next_distance);
165            parents[neighbor_index] = Some(current_index);
166            frontier.push_back(neighbor_index);
167
168            if let Some(other_distance) = other_distances[neighbor_index] {
169                update_meeting_candidate(
170                    &mut best_meeting,
171                    neighbor_index,
172                    next_distance + other_distance,
173                );
174            }
175        }
176    }
177
178    best_meeting.map(|candidate| candidate.index)
179}
180
181fn update_meeting_candidate(
182    best_meeting: &mut Option<MeetingCandidate>,
183    index: usize,
184    total_cost: usize,
185) {
186    let should_replace = best_meeting
187        .is_none_or(|current| (total_cost, index) < (current.total_cost, current.index));
188
189    if should_replace {
190        *best_meeting = Some(MeetingCandidate { index, total_cost });
191    }
192}
193
194fn reconstruct_path(
195    grid: &Grid,
196    parents_from_start: &[Option<usize>],
197    parents_from_goal: &[Option<usize>],
198    start_index: usize,
199    goal_index: usize,
200    meeting_index: usize,
201) -> Path {
202    let mut steps = vec![grid.point_from_index(meeting_index)];
203
204    let mut current_index = meeting_index;
205    while current_index != start_index {
206        current_index = parents_from_start[current_index]
207            .expect("meeting node must have a complete start-side parent chain");
208        steps.push(grid.point_from_index(current_index));
209    }
210    steps.reverse();
211
212    current_index = meeting_index;
213    while current_index != goal_index {
214        current_index = parents_from_goal[current_index]
215            .expect("meeting node must have a complete goal-side parent chain");
216        steps.push(grid.point_from_index(current_index));
217    }
218
219    Path::from_steps(steps).expect("path contains at least one point")
220}
221
222#[cfg(test)]
223mod tests {
224    use crate::{
225        algorithms::bidirectional_bfs::BidirectionalBfs,
226        grid::{Cell, Grid},
227        point::Point,
228        search::{Pathfinder, SearchRequest},
229    };
230
231    #[test]
232    fn finds_a_shortest_path_through_the_only_gap() {
233        let mut grid = Grid::new(5, 5).expect("grid dimensions are valid");
234        for y in 0..5 {
235            if y != 2 {
236                grid.set_cell(Point::new(2, y), Cell::Blocked)
237                    .expect("valid grid edit");
238            }
239        }
240
241        let bidirectional_bfs = BidirectionalBfs;
242        let result = bidirectional_bfs.search(
243            &grid,
244            SearchRequest::new(Point::new(0, 0), Point::new(4, 4)),
245        );
246
247        assert!(result.as_ref().expect("valid search request").is_found());
248        assert_eq!(
249            result.as_ref().expect("valid search request").cost(),
250            Some(8)
251        );
252
253        let path = result
254            .as_ref()
255            .expect("valid search request")
256            .path()
257            .expect("path should exist");
258        assert_eq!(path.start(), Point::new(0, 0));
259        assert_eq!(path.goal(), Point::new(4, 4));
260        assert!(path.steps().contains(&Point::new(2, 2)));
261    }
262
263    #[test]
264    fn reports_when_no_path_exists() {
265        let mut grid = Grid::new(3, 3).expect("grid dimensions are valid");
266        for x in 0..3 {
267            grid.set_cell(Point::new(x, 1), Cell::Blocked)
268                .expect("valid grid edit");
269        }
270
271        let bidirectional_bfs = BidirectionalBfs;
272        let result = bidirectional_bfs.search(
273            &grid,
274            SearchRequest::new(Point::new(0, 0), Point::new(2, 2)),
275        );
276
277        assert!(!result.as_ref().expect("valid search request").is_found());
278        assert_eq!(result.as_ref().expect("valid search request").cost(), None);
279        assert!(
280            result
281                .as_ref()
282                .expect("valid search request")
283                .stats()
284                .visited_nodes
285                > 0
286        );
287    }
288}