Привет, Хабр! Сегодня у нас задачка из мира природы: представьте, что маленький воробушек потерялся в городе. Ему нужно срочно найти путь домой, а дороги кишат кошками, людьми и прочими препятствиями. Разумеется, вручную искать маршрут — не вариант. Нам нужен алгоритм, а лучше параллельный, чтобы воробушек не ждал вечность.
Какой алгоритм взять? Конечно же, A*. Он и кратчайший путь найдёт, и с умом его построит. Но в одиночку он справляется медленно. Поэтому подключаем Rayon — библиотеку для многопоточных вычислений в Rust.
Как работает A
Итак, представим карту города как сетку:
Воробушек стартует с точки S.
Домик — это точка G.
Между ними — опасные кошки (стены) и всякие препятствия.
A* ищет путь, оценивая клетки по функции:
Где:
— реальная стоимость пути от старта до текущей клетки.
— эвристическая оценка оставшегося расстояния (например, Манхэттенская дистанция).
Алгоритм держит приоритетную очередь клеток, которые он хочет проверить. На каждом шаге:
Берёт самую «дешёвую» клетку (по f(n)).
Проверяет соседей.
Добавляет их в очередь и повторяет шаг 1, пока не дойдёт до цели.
Но есть проблема: всё это работает последовательно. Каждый шаг занимает время, а если карта большая — воробушек может умереть от старости, пока найдёт путь. Это нужно ускорить.
Где можно распараллелить?
Обход соседей — можно параллельно проверять все доступные направления.
Вычисление эвристики — если есть миллион клеток, считаем их эвристику не по одной, а пачками.
Обновление структуры данных — очередь узлов должна быть потокобезопасной.
Реализация A с параллельной обработкой на Rust*
Определяем узлы
Для начала создадим структуру для узлов, которые будут представлять клетки сетки:
use std::collections::{BinaryHeap, HashMap}; use std::cmp::Ordering; use rayon::prelude::*; #[derive(Debug, Clone, Eq, PartialEq)] struct Node { position: (i32, i32), g: f32, h: f32, } impl Node { fn new(position: (i32, i32), g: f32, h: f32) -> Self { Node { position, g, h } } fn f(&self) -> f32 { self.g + self.h } } impl Ord for Node { fn cmp(&self, other: &Self) -> Ordering { other.f().partial_cmp(&self.f()).unwrap() } } impl PartialOrd for Node { fn partial_cmp(&self, other: &Self) -> Option<Ordering> { Some(self.cmp(other)) } }
Что тут происходит:
Node — узел сетки, в котором живёт воробушек.
f(n) = g + h — чем меньше, тем лучше.
Приоритетная очередь использует
Ord, чтобы сортировать узлы поf(n).
Пишем эвристику
Манхэттенское расстояние — быстро, просто, но не всегда эффективно. Можно ускорить его расчёт с Rayon:
fn heuristic(a: (i32, i32), b: (i32, i32)) -> f32 { let dx = (a.0 - b.0).abs() as f32; let dy = (a.1 - b.1).abs() as f32; dx + dy } fn batch_heuristic(nodes: &[(i32, i32)], goal: (i32, i32)) -> Vec<f32> { nodes.par_iter() .map(|&node| heuristic(node, goal)) .collect() }
Если у нас 1000 узлов, то с par_iter() все 1000 эвристик считаются в несколько потоков, а не по одной.
Параллельный обход соседей
Теперь нужно, чтобы воробушек выбирал новые клетки одновременно.
fn get_neighbors(position: (i32, i32)) -> Vec<(i32, i32)> { vec![ (position.0 - 1, position.1), (position.0 + 1, position.1), (position.0, position.1 - 1), (position.0, position.1 + 1), ] } fn process_neighbors( current: &Node, goal: (i32, i32), open_set: &mut BinaryHeap<Node>, came_from: &mut HashMap<(i32, i32), (i32, i32)>, cost_so_far: &mut HashMap<(i32, i32), f32> ) { let neighbors = get_neighbors(current.position); let new_nodes: Vec<_> = neighbors.par_iter() .filter(|&&neighbor| !cost_so_far.contains_key(&neighbor)) .map(|&neighbor| { let new_g = current.g + 1.0; let new_h = heuristic(neighbor, goal); Node::new(neighbor, new_g, new_h) }) .collect(); for node in new_nodes { open_set.push(node.clone()); cost_so_far.insert(node.position, node.g); came_from.insert(node.position, current.position); } }
Теперь соседние клетки обрабатываются одновременно.
Собираем A*
fn a_star(start: (i32, i32), goal: (i32, i32)) -> Vec<(i32, i32)> { let mut open_set = BinaryHeap::new(); let mut came_from = HashMap::new(); let mut cost_so_far = HashMap::new(); open_set.push(Node::new(start, 0.0, heuristic(start, goal))); cost_so_far.insert(start, 0.0); while let Some(current) = open_set.pop() { if current.position == goal { let mut path = vec![goal]; let mut pos = goal; while let Some(&prev) = came_from.get(&pos) { path.push(prev); pos = prev; } path.reverse(); return path; } process_neighbors(¤t, goal, &mut open_set, &mut came_from, &mut cost_so_far); } vec![] }
Что в итоге? Проверяем параллельный A* в деле
Теперь проверим, как работает наша реализация на конкретном примере. Допустим, есть сетка 10×10, и воробушку нужно добраться из (0, 0) в (9, 9), минуя препятствия.
Вот код, который запустит алгоритм и выведет путь:
fn main() { let start = (0, 0); let goal = (9, 9); let path = a_star(start, goal); if path.is_empty() { println!("Путь не найден!"); } else { println!("Найден путь ({} шагов):", path.len() - 1); for step in &path { println!("{:?}", step); } } }
Если путь найден, получим что‑то вроде этого:
Найден путь (18 шагов): (0, 0) (1, 0) (2, 0) (3, 0) (4, 1) (5, 2) (6, 3) (7, 4) (8, 5) (9, 6) (9, 7) (9, 8) (9, 9)
Если путь заблокирован (например, всё завалено кошками), то вывод будет:
Путь не найден!
Теперь воробушек летит домой в несколько потоков. А как вы применяете A* в своих проектах?
В завершение всем Rust-разработчикам рекомендую к посещению открытые уроки, которые пройдут в Otus бесплатно в феврале:
11 февраля — «Разбираем анатомию парсера на Rust»
Рассмотрим, какие особенности языка делают код надёжным и производительным, а также изучим архитектурные решения, лежащие в основе многих современных Rust-проектов. Записаться18 февраля — «Полиморфизм в Rust. Возможности и компромиссы»
Посмотрим на возможности языка, позволяющие писать обобщённый код и разберёмся со способами компиляции и выполнения полиморфного кода. Записаться
