This is part of the series Advent of Code 2022.
Day 12 - Advent of Code 2022

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.