To reach the elves a heightmap of the surrounding area is scanned on the handheld device.
The goal is to reach the highest position (E
) on the map from the starting point (S
).
Each square on the grid has an elevation level, where a move from one square to a neighboring square
is only allowed when either the target level is lower or at max one level higher.
Movement is restricted to one of the four neighboring squares, left, right, above or below from the current one.
Levels are given in a range of a
(lowest) and z
(highest).
The start square is on the lowest level a
, while the end square has the highest level z
.
Sabqponm
abcryxxl
accszExk
acctuvwj
abdefghi
Assuming the grid starts at 0,0
in the upper left corner, the start square S
has level a
.
To reach the end square E
there are number of possible ways to go, but eventually the path
spirals around until E
is hit.
v..v<<<<
>v.vv<<^
.>vv>E^^
..v>>>^^
..>>>>>^
The example above marks the way to find the end square in 31 steps.
Part 1
The goal is to find the shortest possible path from S
to E
in the given map of elevation levels.
This challenge is a good place to introduce a new crate. There are a few excellent crates available to handle graph problems, e.g. finding the shortest path in a graph, by applying different algorithms. Of course it's also possible to implement one of the existing algorithms on our own.
To start things the given input is parsed into a Grid
struct that contains all squares, the Pos
struct from
earlier challenges are the coordinates into the Grid
.
#[derive(Debug, Hash, PartialEq, Eq, Copy, Clone, PartialOrd)]
struct Pos {
x: i32,
y: i32,
}
impl Pos {
pub const fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
}
struct Grid {
start: Pos,
end: Pos,
squares: Vec<char>,
width: usize,
height: usize,
}
impl Grid {
pub fn new() -> Self {
Self {
squares: Vec::new(),
start: Pos::new(0, 0),
end: Pos::new(0, 0),
width: 0,
height: 0,
}
}
pub fn add_line(mut self, row: impl Iterator<Item = char>) -> Self {
let mut row = row.collect::<Vec<_>>();
self.width = row.len();
if let Some(index) = row.iter().position(|&c| c == 'S') {
self.start = Pos::new(index as i32, self.height as i32);
row[index] = 'a';
};
if let Some(index) = row.iter().position(|&c| c == 'E') {
self.end = Pos::new(index as i32, self.height as i32);
row[index] = 'z';
}
self.squares.append(&mut row);
self.height += 1;
self
}
}
fn part1(grid: &Grid) -> u32 {
todo!()
}
fn parse(input: &str) -> Grid {
input
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.fold(Grid::new(), |grid, line| grid.add_line(line.chars()))
}
fn main() {
let grid = parse(include_str!("input.txt"));
println!("Part 1: {}", part1(&grid));
}
#[cfg(test)]
mod tests {
use super::*;
const INPUT: &str = r#"
Sabqponm
abcryxxl
accszExk
acctuvwj
abdefghi
"#;
#[test]
fn check_part1() {
assert_eq!(31, part1(&parse(INPUT)));
}
}
The parse
function builds the Grid
struct by adding line by line, it forwards an iterator of char
to the
Grid#add_line
method, which appends each row & determines the start and end position inside the grid.
The start (S
) & end (E
) squares are replaced with their associated levels a
& z
.
The fields width
& height
are the dimensions of the Grid
, they are used later to index
into the 1-dimensional Vec
of squares.
The single test currently fails as the part1
function is not implemented yet.
Time to pick an appropriate crate to handle the pathfinding for us instead of implementing it ourselves. One option is the petgraph
crate that is a graph data structure library to build different types of graph. It also supports a set of different path algorithms.
Instead of using petgraph
we add the pathfinding
crate to the dependencies in the Cargo.toml
. Or alternatively install the crate via
cargo add pathfinding
This library supports a number of different directed graph algorithms which are quite easy to initialize. There are different
algorithms to pick from, let's try dijkstra
using the Dijkstra's Search Algorithm that finds the shortest path between
two nodes in a weighted graph.
The function signature of pathfinding::directed::dijkstra::dijkstra
looks like:
pub fn dijkstra<N, C, FN, IN, FS>(
start: &N,
successors: FN,
success: FS
) -> Option<(Vec<N>, C)>
where
//..
{
// ..
}
The function takes a number of trait types to define what can be passed into the function. For further details please check the documentation. The parameters for the function are, a start node, a lambda or function to determine all successors for a given node (for the traversal) and the end node as third parameter.
From the input the start and end squares are known. What is missing is the function to determine all successor nodes which determine
the nodes that are directly accessible from a given node. The successor function expects weights as well, but as there are no weights
we pass in the value of 1
. The function returns Option<(Vec<N>, C)>
where N
in our case will be Pos
, while C
is a u32
.
Using a weight of 1
results in the nice property that the total weight is the length of the found path.
The parts that are missing is to determine what the neighboring squares that can be moved to are. We know it's possible to move
from one square to another when the target square has a lower elevation level, but it's only allowed to move one elevation level up, e.g. from a
to b
, but not from b
to d
. Initially I made a mistake to check when given two levels if it's possible to move from one to the other.
After some trial & a number of useful tests I came up with the following helper function.
fn can_move(l: char, r: char) -> bool {
let (l, r) = (l as i32, r as i32);
(l - r).abs() <= 1 || l > r
}
which is the heart of it all. The function returns true
if it's possible to move from elevation l
to r
.
A few more helper methods are necessary to prepare the algorithm, one is to get all neighbor squares from a given square, the other is to get the elevation level for a position.
impl Grid {
const DIRECTIONS: [Pos; 4] = [
Pos::new(1, 0), // right
Pos::new(0, 1), // down
Pos::new(-1, 0), // left
Pos::new(0, -1), // up
];
fn neighbors(&self, pos: Pos) -> Vec<Pos> {
Self::DIRECTIONS
.iter()
.map(|dir| pos + *dir)
.filter(|neighbor| self.get(neighbor).is_some())
.collect_vec()
}
fn get(&self, &Pos { x, y }: &Pos) -> Option<char> {
if 0 <= x && x < self.width as i32 && 0 <= y && y < self.height as i32 {
Some(self.squares[(y * self.width as i32 + x) as usize])
} else {
None
}
}
}
The neighbors
method only returns neighboring squares that exist on the grid.
Now there is everything set up to implement the path finding algorithm using dijkstra
.
impl Grid {
pub fn find_shortest_path(&self, start: &Pos) -> Option<u32> {
let result = dijkstra::dijkstra(
start,
|&pos| {
let height = self.get(&pos).expect("Failed to get height");
self.neighbors(pos)
.iter()
.filter(|neighbor| can_move(height, self.get(neighbor).unwrap()))
.map(|neighbor| (*neighbor, 1))
.collect_vec()
},
|&p| p == self.end,
);
result.map(|(_path, length)| length)
}
}
fn part1(grid: &Grid) -> u32 {
grid.find_shortest_path(&grid.start).unwrap()
}
The first parameter to the dijkstra
function is the start
position. The second parameter is a closure that
gets the height of the current position, then determines which neighboring squares can be reached from it by checking
the elevation levels. The last parameter is the end
position.
The test should pass and running the program should output the first solution.
Part 2
Instead of using the current start position the elves surely would like to know which is the most scenic hiking trail
The best scenic route is a path from one of the squares with elevation level a
to the end position. The trail
should still be direct, taking the fewest steps required to reach the end. The goal is to find the best starting position
on a square with elevation level a
that results overall in the shortest path.
Instead of checking the current path, the list of all squares is determined first that have an elevation level a
. Then
for each start position the shortest path is determined, finally the shortest number of steps overall is then returned as
the second solution. The given example input results in a scenic path of length 29.
impl Grid {
pub fn find_scenic_path(&self) -> Vec<u32> {
todo!()
}
}
fn part2(grid: &Grid) -> u32 {
let result = grid.find_scenic_path();
*result.iter().min().expect("Failed to get length")
}
fn main() {
let grid = parse(include_str!("input.txt"));
println!("Part 1: {}", part1(&grid));
println!("Part 2: {}", part2(&grid));
}
#[cfg(test)]
mod tests {
// ..
#[test]
fn check_part2() {
assert_eq!(29, part2(&parse(INPUT)));
}
}
Last thing to implement is method Grid#find_scenic_path
that returns a list of lengths for all shortest paths.
impl Grid {
pub fn find_scenic_path(&self) -> Vec<u32> {
let scenic_spots = (0..self.width as i32)
.cartesian_product(0..self.height as i32)
.filter(|(x, y)| self.squares[(y * self.width as i32 + x) as usize] == 'a')
.collect::<Vec<_>>();
scenic_spots
.iter()
.filter_map(|&(x, y)| self.find_shortest_path(&Pos::new(x, y)))
.collect()
}
}
The method find_scenic_path
uses Itertools#cartesian_product to generate of all grid coordinates. The variable scenic_spots
consists of all squares with elevation level a
.
These are the starting points for which the shortest paths will be determined.
The second part iterates over all these starting positions and determines their shortest path, reusing find_shortest_path
method.
We are not necessarily interested in the paths itself, only in their lengths.
Once returned function part2
finds the min
value of this list, which is the overall shortest path & therefore the best scenic route.
All tests should pass now, running the program again should also give the second solution. Voila.