I woke up today’s Reindeer Olympics optimistic - probably quite like Sweden felt at the end of the first period of the Floorball finals yesterday leading 3-0 against Finland. But just like Finland upset Sweden in the end (🎉), so did this puzzle to me.
I’ve been solving a ton of these grid path finding puzzles through the past 10 years of Advent of Code puzzles and I have started to feel quite confident.
So when I saw a maze like this
I thought to myself: piece of cake.
Read input
Before we go into navigating the maze, let’s parse it in.
Thanks to my utility library, all we have to do is tell it which cells we want to skip (.
means empty space in today’s grid) and then extract the start and end positions.
I also defined three type aliases:
Part 1
In the first part, our goal is to find the cheapest path from S
tart to E
nd. The scoring is as follows:
1
point for every movement- +
1000
points if we also need to turn
We start at position S
facing right (R
).
I decided to use A* search algorithm that is one that I kind of understand and can follow (although I still don’t quite understand it deeply enough that I’d be able to modify it). I’m going to explain it as I understand it but it’s possible there are misunderstandings so don’t take everything as full truth.
To implement A* algorithm, we need a couple of helpers:
First, we need a heuristic function. It’s basically an estimate of how much it costs for us to reach the end from our current location. Since we don’t know where the walls are as we move through the grid, we use a Manhattan distance to calculate how much would it cost to get there if every move would just cost 1:
Next thing we need when moving along the grid is to be able to get all the possible next positions:
I calculate all cardinal neighbours and then take away all walls and positions where we’ve been before.
Then, we need a way to find the cheapest option from a set:
It finds the smallest (position, direction)
pair of the positions in open_set
based on their f_score
values. f_score
is where we keep track of the estimated costs from point A to the target.
We also need to get the next direction based on where we want to go so we can account for the turns:
This function before refactoring was 50 lines longer… I took such an overcomplicated route in the beginning. When I got to the right solution for the entire part 1, I came back to refactor and realised that the new direction did not depend at all on what our starting direction was. Silly me.
I also have this factory function that I learned years ago and have been copy-pasting through different Advent of Codes with me:
It’s a function that returns a function that returns the original value.
We use it later to create a defaultdict
that defaults to a specific number instead of 0
which is the default when using int
.
Let’s talk about the actual A* implementation. The variable names I’ve picked up from the Wikipedia article for A* to help me follow along.
The path finding function gets four arguments: start
and target
are the positions we start from and end to. grid
is our map and direction
is our starting direction.
We keep track of all the places where we haven’t been yet as a set
of tuples of (position, direction)
. Originally, I did not keep track of the directions which worked with examples (and with some of the other people’s actual inputs) but then found a nasty bug with my actual input. More of that later.
We keep track of the best path so far as a dictionary:
g_score
keeps track of how much it cost to get to a specific position and we define it as a default dictionary where a missing position gets a value of infinity
to make sure any real score is always lower than it.
f_score
keeps track of the estimated cost to reach the goal:
As long as we have some positions we haven’t visited and we haven’t reached the goal yet, we execute this loop:
First, we find the cheapest position we have left. This ensures we’re always exploring all the paths until they become more expensive than the furthest we’ve already been to.
If we reach the goal, we return the path from start to end and the final score.
If we don’t reach the goal, we remove the current position as we’ve now visited it. Then we check for each possible neighbour.
We get the new direction and calculate a possible g_score
. It starts as 1
and increases by 1000
if we have to turn.
We then check if the new possible score is lower than our previous best for this position+direction pair. If it’s not, we stop following this path and check rest of the neighbours.
If it is, we update our path, we update the g_score
and f_score
for the new position and we add it to the open_set
to navigate to later.
Eventually, this will find us the cheapest route or return None
if no path to target exists.
The elusive bug
I mentioned earlier that I ran into a bug when running with my actual input. My score was 12
points higher than the real one so my path took an small extra long route somewhere.
I wasn’t delighted. I knew that debugging this one was gonna be a pain, for two reasons:
- The search is so large: a 140x140 grid with so many possible paths
- Debugging graph algorithms that branch out and then come back and branch again is so hard because it’s hard to print out the paths. Especially with A* where we stop following one path, go back and then eventually we return to the previous unfinished path.
What I ended up doing is I printed out my full grid with path and spent an hour or so staring at it, trying to find a spot where I can manually find a cheaper route. Thanks to the help from others (running their correct solution with my input), I knew the score was only 12 off which helped a lot because I knew I was looking for a really short detour.
I also knew that all I needed to really calculate from other possible routes was the amount of turns. If there were more turns than in the calculated path, I knew it could never be 12 points cheaper.
I finally did find one case. To be able to manually debug it, I had to simplify it to a smaller size. I eventually ended up with this:
The path marked with o
is what my algorithm gave me and one marked with -
and |
was what I found to be cheaper.
It happened because I was keeping track of only (x, y)
positions instead of a state of ((x,y), direction)
combo. So when the top path reached the intersection (the left-most o
next to E
), it had a cost around 3000
because it had turned three times before that intersection. It would then have a cost around 4000
because it has to turn the next time.
The path from bottom costs around 4000
when it reaches the intersection but then continues without an extra turn so it eventually became a few steps cheaper.
Thanks for the help from our lovely Koodiklinikka Advent of Code community who helped me figure out how to avoid this by keeping track of the proper state.
Once I knew that, it still took me a good hour or so to adjust the code to the new one. At one point, my score jumped to ~230 000 and at one point I managed to end up in a loop.
Eventually, all the pieces fell into their spots and I got the correct result and a star.
Part 2
In part 2, we need to find all the shortest paths instead of just one.
As I used the A* which specifically finds one shortest path and my understanding of the algorithm is not very deep, I had no clue how to change it to find all of the paths.
One thing I considered was taking the path from Part 1 and changing each step into a wall one at a time and see if I can reach the goal for each of these iterations. But then I realised that whenever I had found a new path, I’d have to do it again, this time blocking one step in each path and that sounded like a complexity nightmare.
Day 16 marks the first 1 star day. The first part took me out hard and after a bit of tinkering on the second part, I decided I’ll rather not spend all my waking hours today on Advent of Code.