AoC 2023 Day 23: Looong Mazinator

Source: Day 23: A Long Walk

Full solution for today (spoilers!)

Part 1

Find the longest non-overlapping path through a maze with walls (#) and one way paths (^v<>).

Don’t even need types or parsing this time, just use Grid:

fn main() -> Result<()> {
    let stdin = io::stdin();
    let input = io::read_to_string(stdin.lock())?;

    let grid = Grid::read(input.as_str(), |c| match c {
        '#' => Some(Object::Wall),
        '^' => Some(Object::Slope(Slope::North)),
        'v' => Some(Object::Slope(Slope::South)),
        '>' => Some(Object::Slope(Slope::East)),
        '<' => Some(Object::Slope(Slope::West)),
        _ => None,
    });

    #[derive(Debug)]
    struct State {
        position: Point,
        path: Vec<Point>,
    }

    let mut queue = Vec::new();
    queue.push(State {
        position: Point::new(1, 0),
        path: Vec::with_capacity(1024),
    });

    let mut complete = Vec::new();

    while let Some(state) = queue.pop() {
        for direction in &[
            Point::new(0, 1),
            Point::new(0, -1),
            Point::new(1, 0),
            Point::new(-1, 0),
        ] {
            let next_position = state.position + *direction;

            // If we're at the exit, we've found a complete path
            if next_position == Point::new(grid.bounds.max_x - 1, grid.bounds.max_y) {
                complete.push(state.path.clone());
                continue;
            }

            // If we're out of bounds, we've found an invalid path
            if !grid.bounds.contains(&next_position) {
                continue;
            }

            // If we're on a slope, we can only go in the direction of the slope
            if let Some(Object::Slope(s)) = grid.get(&state.position) {
                if direction != &Point::from(*s) {
                    continue;
                }
            }

            // Cannot go through walls
            match grid.get(&next_position) {
                Some(Object::Wall) => continue,
                _ => (),
            }

            // Cannot visit the same point more than once
            if state.path.contains(&next_position) {
                continue;
            }

            // Otherwise, queue it up
            let new_state = State {
                position: next_position,
                path: {
                    let mut path = state.path.clone();
                    path.push(next_position);
                    path
                },
            };
            queue.push(new_state);
        }
    }

    // Find the longest path
    // Add 1 to account for leaving the grid
    let result = 1 + complete
        .iter()
        .max_by(|a, b| a.len().cmp(&b.len()))
        .unwrap()
        .len();

    println!("{result}");
    Ok(())
}

We can’t really use a-star, since ’longest’ path is actually a much harder problem than shortest path–it’s NP-hard. You basically have to find all of the paths.

So that’s what we do. Keep a queue of points to examine along with the path we took to get there and explore each valid (non-overlapping + correct direction on slopes) neighboring point.

Each time we get to the exit, record the path; at the end, record the longest and we’re done.

It’s not the fastest solution, but for part 1 it’s fine?

Edit 1: Petgraph

I haven’t yet had a chance to look at the petgraph crate. Let’s remedy that.

Essentially, we can represent our map as a graph. To do that, we’ll want to generate a list of nodes (each walkable tile, including slopes) and edges (which nodes you can walk to from a given node).

One interesting thing about petgraph is that everything you do uses NodeIndexes. When you insert a node into a graph, you get the index back. When you add an edge, you add it between 2 NodeIndex. And in the end, when we want to find all paths, we’ll need to pass those back.

So we need to return 3 things: the graph and the NodeIndex for the start and end points.

Let’s do it.

let (graph, start, end) = {
    let mut g = DiGraph::new();
    let mut nodes = Vec::new();
    let mut start = None;
    let mut end = None;

    for y in 0..=grid.bounds.max_y {
        for x in 0..=grid.bounds.max_x {
            let p = Point::new(x, y);

            if let Some(Object::Wall) = grid.get(&p) {
                continue;
            }

            let node = g.add_node(p);
            nodes.push(node);

            if x == 1 && y == 0 {
                start = Some(node);
            }
            if x == grid.bounds.max_x - 1 && y == grid.bounds.max_y {
                end = Some(node);
            }
        }
    }

    for node in &nodes {
        let p = *g.node_weight(*node).unwrap();

        for direction in &[
            Point::new(0, 1),
            Point::new(0, -1),
            Point::new(1, 0),
            Point::new(-1, 0),
        ] {
            let next_position = p + *direction;

            // If we're out of bounds, we've found an invalid path
            if !grid.bounds.contains(&next_position) {
                continue;
            }

            // If we're on a slope, we can only go in the direction of the slope
            if let Some(Object::Slope(s)) = grid.get(&p) {
                if direction != &Point::from(*s) {
                    continue;
                }
            }

            // Cannot go through walls
            match grid.get(&next_position) {
                Some(Object::Wall) => continue,
                _ => (),
            }

            // Otherwise, queue it up
            let next_node = nodes
                .iter()
                .find(|n| *g.node_weight(**n).unwrap() == next_position)
                .unwrap();

            g.add_edge(*node, *next_node, ());
        }
    }

    (g, start.unwrap(), end.unwrap())
};

Once we have the graph, there’s a perfect algorithm for us in petgraph::algo::all_simple_paths:

let result = all_simple_paths(&graph, start, end, 0, None)
    .map(|path: Vec<&NodeIndex>| path.len() - 1)
    .max()
    .unwrap();

A simple_path is exactly what we want: a path that doesn’t visit any node in the graph more than once. So we can just go through all of them, find the longest, and get the length of that.

One gotcha that I had to deal with was that we needed to type the a and b in closure as &Vec<NodeIndex>. Alternative, you can turbofish the call itself:

let result = all_simple_paths(&graph, start, end, 0, None)
    .map(|path: Vec<NodeIndex>| path.len() - 1)
    .max()
    .unwrap();

I think I actually like that one better.

So how does it actually compare?

$ hyperfine --warmup 3 'just run 23 1' 'just run 23 1-petgraph'

Benchmark 1: just run 23 1
  Time (mean ± σ):     412.3 ms ±  12.5 ms    [User: 319.7 ms, System: 20.6 ms]
  Range (min … max):   392.1 ms … 442.0 ms    10 runs

Benchmark 2: just run 23 1-petgraph
  Time (mean ± σ):     188.4 ms ±   4.3 ms    [User: 98.9 ms, System: 19.8 ms]
  Range (min … max):   182.5 ms … 199.4 ms    15 runs

Summary
  just run 23 1-petgraph ran
    2.19 ± 0.08 times faster than just run 23 1

That’s actually pretty impressive.

Part 2

Ignore slopes.

Solution 1: Brute force

There it is, that makes things far longer. The only real change we need is loading the grid a bit differently:

let grid = Grid::read(input.as_str(), |c| match c {
    '#' => Some(true),
    _ => None,
});

And then drop the slope code and use grid.get(&p).is_some() to detect walls. That’s really it.

And… it’s pretty slow.

$ just run 23 2-brute

cat data/$(printf "%02d" 23).txt | cargo run --release -p day$(printf "%02d" 23) --bin part2-brute
   Compiling day23 v0.1.0 (/Users/jp/Projects/advent-of-code/2023/solutions/day23)
    Finished release [optimized] target(s) in 0.18s
     Running `target/release/part2-brute`
100000 710.137333ms
200000 1.426019042s
300000 2.131656583s
400000 2.850222125s
500000 3.651914042s
...

That’s the number of states we’ve examined. We’re doing hundreds of thousands a second, but it’s just not long enough.

We need to go faster!

Solution 2: A better Path

So one thing that I wanted to try was to avoid all of the clones we’re doing for path. What if we had a functional programming style immutable path, where ’extending’ a path uses the same memory for any previous steps and only adds a new reference. So branching paths end up far more efficient.

Let’s try it!

#[derive(Debug)]
struct PathData {
    points: Vec<Point>,
    froms: Vec<Option<usize>>,
}

#[derive(Debug, Clone)]
pub struct Path {
    path: Rc<RefCell<PathData>>,
    index: usize,
    length: usize,
}

impl Path {
    pub fn new(p: Point) -> Self {
        Path {
            path: Rc::new(RefCell::new(PathData {
                points: vec![p],
                froms: vec![None],
            })),
            index: 0,
            length: 1,
        }
    }

    pub fn extend(&mut self, p: Point) -> Path {
        self.path.borrow_mut().points.push(p);
        self.path.borrow_mut().froms.push(Some(self.index));

        Path {
            path: self.path.clone(),
            index: self.path.borrow().points.len() - 1,
            length: self.length + 1,
        }
    }

    pub fn len(&self) -> usize {
        self.length
    }

    pub fn contains(&self, p: Point) -> bool {
        // Check the current point
        if self.path.borrow().points[self.index] == p {
            return true;
        }

        // Check previous points until we reach the start
        let mut index = self.index;
        while let Some(from) = self.path.borrow().froms[index] {
            if self.path.borrow().points[index] == p {
                return true;
            }
            index = from;
        }
        false
    }
}

What we have here is a ‘hidden’ shared state in PathData, where Path is the actual interface to the data. All that does is store an index into the PathData, which has synchronized lists of Points and indexes (for the previous node). Basically linked lists. Entirely too many linked lists.

All that changes in our solution is extending states:

// Otherwise, queue it up
let new_state = State {
    position: next_position,
    path: state.path.extend(next_position),
};
queue.push(new_state);

And… how’s it do?

$ just run 23 2-path

cat data/$(printf "%02d" 23).txt | cargo run --release -p day$(printf "%02d" 23) --bin part2-path
   Compiling day23 v0.1.0 (/Users/jp/Projects/advent-of-code/2023/solutions/day23)
    Finished release [optimized] target(s) in 0.20s
     Running `target/release/part2-path`
100000 1.014593291s
200000 1.977192458s
300000 2.961558458s
400000 3.901192916s
500000 4.940979875s
...

Unfortunately… it’s about 30% slower.

What’s happening is that we’ve fixed the problem with copying lots of memory by… jumping around a lot in memory. A lot more following references, especially in contains (I already optimized length to just store it in Path.

So… a neat idea, but not what we needed.

Solution 3: Finding points of interest

If you actually look at the input (either test or real), you’ll notice that the map is almost entirely corridors. There are only a handful of decision points (where you can go 3 or even 4 different directions).

What if we replace the map with a just a list of those points and distances between them?

First, find them:

// Find 'points of interest'
log::info!("Finding splits");
let mut splits = vec![];

splits.push(Point::new(1, 0));
splits.push(Point::new(walls.bounds.max_x - 1, walls.bounds.max_y));

for y in 0..=walls.bounds.max_y {
    for x in 0..=walls.bounds.max_x {
        let p = Point::new(x, y);

        if walls.get(&p).is_some() {
            continue;
        }

        // Splits are anything with 3 or 4 non-walls
        // Or alternatively, less than 2 walls
        if DIRECTIONS
            .iter()
            .filter(|d| walls.get(&(p + **d)).is_some())
            .count()
            < 2
        {
            splits.push(p);
        }
    }
}

Then find the distance between each pair:

// Calculate distances between splits
log::info!("Calculating split distances");
let mut split_distances: FxHashMap<(Point, Point), usize> = FxHashMap::default();

for split in splits.iter() {
    'found: for direction in DIRECTIONS {
        let mut position = *split + direction;
        let mut distance = 1; // count the first 'direction' step
        let mut path = vec![*split, *split + direction];

        // Make sure the initial move is not out of the map or into a wall
        if !walls.bounds.contains(&position) {
            continue;
        }
        if walls.get(&position).is_some() {
            continue;
        }

        // Keep going until we find the next split in that direction
        'searching: loop {
            // If we found a split, record the distance and move on
            if splits.contains(&position) {
                split_distances.insert((*split, position), distance);
                continue 'found;
            }

            distance += 1;

            // Find the one direction (should always be one) we haven't come from
            for direction in DIRECTIONS {
                let next_position = position + direction;

                // Don't run into walls
                if walls.get(&next_position).is_some() {
                    continue;
                }

                // And don't backtrack
                if path.contains(&next_position) {
                    continue;
                }

                path.push(next_position);
                position = next_position;
                continue 'searching;
            }

            // If we didn't find a direction, this is a dead end
            break 'found;
        }
    }
}

And finally, do the same search as part 1, just using these nodes (and the distances between them):

// Now search for the longest path using splits
#[derive(Debug)]
struct State {
    position: Point,
    path: Vec<Point>,
    distance: usize,
}

let mut queue = Vec::new();
queue.push(State {
    position: Point::new(1, 0),
    path: Vec::new(),
    distance: 0,
});

let mut complete = Vec::new();

log::info!("Searching for longest path");
let start = std::time::Instant::now();
let mut count = 0;
while let Some(state) = queue.pop() {
    count += 1;
    if count % 1_000_000 == 0 {
        log::info!("- {:?} paths examined in {:?}", count, start.elapsed());
    }

    // Which nodes can we go to next?
    let nexts = splits.iter().filter_map(|dst| {
        if let Some(distance) = split_distances.get(&(state.position, *dst)) {
            Some((*dst, *distance))
        } else {
            None
        }
    });

    for (next, distance) in nexts {
        // If we're at the exit, we've found a complete path
        if next == Point::new(walls.bounds.max_x - 1, walls.bounds.max_y) {
            complete.push((state.path.clone(), state.distance + distance));
            continue;
        }

        // If we've already hit this split, we've found a loop
        if state.path.contains(&next) {
            continue;
        }

        // Otherwise, queue it up
        let new_state = State {
            position: next,
            path: {
                let mut path = state.path.clone();
                path.push(next);
                path
            },
            distance: state.distance + distance,
        };
        queue.push(new_state);
    }
}

let result = complete.iter().map(|(_, d)| d).max().unwrap();

It’s a decent bit more code, but … does it work?

$ just time 23 2

hyperfine --warmup 3 'just run 23 2'
Benchmark 1: just run 23 2
  Time (mean ± σ):      9.702 s ±  0.093 s    [User: 9.231 s, System: 0.166 s]
  Range (min … max):    9.555 s …  9.906 s    10 runs

Victory (of sorts)!

It’s still 10x my worst case goal of 1 second, but at least we actually have a solution.

And since it’s the weekend, that’s where I’ll leave it for now, but I’m probably going to have to come back and try this one again!

Edit 1: More petgraph!

As is the case in part 1, we can speed this up significantly with petgraph. And the conversion is actually even easier once we’ve already done splits:

// Build a petgraph graph from these splits
let (graph, start, end) = {
    let mut g = DiGraph::new();
    let mut nodes = Vec::new();
    let mut start = None;
    let mut end = None;

    // Add all splits as nodes
    for split in splits.iter() {
        let node = g.add_node(*split);
        nodes.push(node);

        if *split == Point::new(1, 0) {
            start = Some(node);
        }
        if *split == Point::new(walls.bounds.max_x - 1, walls.bounds.max_y) {
            end = Some(node);
        }
    }

    // Add weighted edges between each split that's connected
    for ((src, dst), d) in split_distances.iter() {
        let src = nodes
            .iter()
            .find(|n| *g.node_weight(**n).unwrap() == *src)
            .unwrap();
        let dst = nodes
            .iter()
            .find(|n| *g.node_weight(**n).unwrap() == *dst)
            .unwrap();

        g.add_edge(*src, *dst, *d);
    }

    (g, start.unwrap(), end.unwrap())
};

The result calculation is a bit more complicated this time, since we want to rebuild the lengths of each path:

// Get the length of the longest path
let result = all_simple_paths::<Vec<_>, _>(&graph, start, end, 0, None)
    .map(|path| 
        path
            .iter()
            .tuple_windows()
            .map(|(a, b)| 
                split_distances.get(&(
                    *graph.node_weight(*a).unwrap(),
                    *graph.node_weight(*b).unwrap(),
                )).unwrap())
            .sum::<usize>()
    )
    .max()
    .unwrap();

I feel like it’s a bit weird that all_simple_paths doesn’t return anything about the sum of weights of a path, but perhaps there’s another method I’m missing? It’s not the worst to write though.

Side note, I did pull in itertools as well, just for tuple_windows.

And it’s faster:

$ hyperfine --warmup 3 'just run 23 2' 'just run 23 2-petgraph'

Benchmark 1: just run 23 2
  Time (mean ± σ):      9.386 s ±  0.167 s    [User: 8.964 s, System: 0.125 s]
  Range (min … max):    9.108 s …  9.607 s    10 runs

Benchmark 2: just run 23 2-petgraph
  Time (mean ± σ):      1.859 s ±  0.145 s    [User: 1.641 s, System: 0.024 s]
  Range (min … max):    1.741 s …  2.077 s    10 runs

Summary
  just run 23 2-petgraph ran
    5.05 ± 0.40 times faster than just run 23 2

Still not quite 1 second, but we’re getting there!

Performance

Overall (even though it’s just above us), this is where we are right now:

$ just time 23 1

hyperfine --warmup 3 'just run 23 1'
Benchmark 1: just run 23 1
  Time (mean ± σ):     423.8 ms ±   8.4 ms    [User: 337.5 ms, System: 21.9 ms]
  Range (min … max):   410.7 ms … 435.2 ms    10 runs

$ just time 23 2

hyperfine --warmup 3 'just run 23 2'
Benchmark 1: just run 23 2
  Time (mean ± σ):      9.702 s ±  0.093 s    [User: 9.231 s, System: 0.166 s]
  Range (min … max):    9.555 s …  9.906 s    10 runs

Edit 1: Petgraph performance

Now that we’ve updated part 1 and part 2, we have better overall performance:

$ just time 23 1-petgraph

hyperfine --warmup 3 'just run 23 1-petgraph'
Benchmark 1: just run 23 1-petgraph
  Time (mean ± σ):     190.9 ms ±   7.4 ms    [User: 100.6 ms, System: 20.2 ms]
  Range (min … max):   180.0 ms … 210.7 ms    16 runs

$ just time 23 2-petgraph

hyperfine --warmup 3 'just run 23 2-petgraph'
Benchmark 1: just run 23 2-petgraph
  Time (mean ± σ):      1.799 s ±  0.110 s    [User: 1.599 s, System: 0.023 s]
  Range (min … max):    1.698 s …  1.999 s    10 runs

Not bad!