The expeditions needs to cross a valley that’s full of strong blizzards.
Today’s input is the starting state of the valley.
An example input looks like this:
The valley is surrounded by walls (marked as #).
On the perimeter, only the entrance at the top, and the exit at the bottom are not walls.
A flat ground spot without a blizzard is marked with a ..
A flat ground spot with a blizzard is marked with an arrow:
< for a blizzard that moves left
> for a blizzard that moves right
^ for a blizzard that moves up
> for a blizzard that moves down
In one minute, each blizzard moves one position.
When a blizzard would move into a wall, it appears on the other side of the map (they’re Pacman blizzards or something, I don’t know).
Blizzards can move through each other.
Consider this line of blizzards, moving 1 step each minute:
>.<
.2.
<.>
The two blizzards occupied the same spot for a minute!.
You start at the entrance and want to reach the exit of the valley.
Each minute:
You can move one position in the 4 directions (up, right, down, left).
You can choose not to move.
You can never share a position with a blizzard.
You and the blizzards move at the same time.
That means that in a turn you can go to the right while a blizzard that was there can go to the left.
You and the blizzard effectively swap positions.
Consider this state, where you are represented as E (for Elf, because they’re coming too!):
E<
<E
That’s a valid move.
You go right, and the left-moving blizzard moved left.
Parsing
A 2D grid?
If you have been following along, you know what that means, Coord!
I stored the original state of the input by keeping track of 3 things:
The coordinates of walls
The coordinates and directions of blizzards
The size bounds of the rectangular grid
Part 1
The question asks what the shortest time you can reach the goal while avoiding the blizzards is.
This is a shortest path graph question, so I’m going with Dijkstra’s algorithm again.
Before I fill it in, some skeleton code:
Helpers
The Node that goes into the priority queue.
Sorted so Nodes with the lowest cost get popped off the queue first.
The neighbours method on Coord takes into account the bounds of the map.
The add_dir method is useful too when moving a blizzard in a Direction.
Getting the set of walls so the pseudocode where we check if there’s a wall at a coordinate is fairly straightforward.
Getting the set of blizzard coordinates is trickier.
They’re dependant on the time.
We get their coordinates at time 0, and their directions.
Combined with the wrapping rules they can be simulated for every time.
Luckily, the blizzard positions repeat:
The horizontal ones repeat every cols - 2 turns.
The vertical ones repeat every rows - 2 turns.
(the -2 because we are considering the inner field, without the walls)
So the entire map of blizzard coordinates repeats every least common multiple of those 2 numbers.
This means we can compute all possible blizzard positions ahead of time.
We run a simulation for “least common multiple” amount of minutes, and record the blizzard coordinates at every minute.
Some mathy helper functions to first find the greatest common divisor, and then the least common multiple.
A helper that given the original state, records the blizzard coordinates of every minute increment.
It does this for max_time minutes.
The blizzard position maps repeat after lcm minutes, so in the solution we pass in lcm as an argument to this function:
Later, we can access the blizzards at any specific time.
By looking up the remainder of a division with lcm in the map.
Then the check in that pseudocode works, as blizzards is a HashSet<Coord>:
blizzards.contains(coord)
Final code
Part 2
The minute you reach the goal, an Elf tells you they forgot their snacks at the starting point.
You go through the blizzard again, then back to the end.
The question asks what the smallest numberof minutes is to travel from start to goal, back to start, back to goal.
Because of the moving blizzards, the return journey will not be the same as the initial journey.
I made the part with the Dijkstra algorithm a seperate function.
It also takes an argument for the starting time of the search.
For part1, the starting time was 0.
For the first start-end trip: starting time is still 0
For the end-start trip: starting time is the endtime for the first start-end trip.
For the second start-end trip: starting time is the endtime for the end-start trip.
Because that function would take a bunch of arguments, I created a struct to group a few of them: MapInfo.
The function that does the shortest path calculation:
The solution to part2 then consists of gathering the info in MapInfo and running the shortest function three times.
Each time:
swapping the starting and ending points of the search
feeding in the endtime of the previous search as starttime for the new one.
Final code
Make it go faster
For my next trick, I’ll make this solution about 30% faster with 5 lines of code (ish)!
A* is Dijkstra in a tophat.
They’re nearly identical.
A* uses something called a heuristic.
Which is a fancy way of saying: something that affects how nodes are sorted in the priority queue.
In Dijkstra, nodes in the priority queue are sorted based on cost.
It doesn’t care about how close a node is to the goal, only about how much it costs.
In A*, nodes in the priority queue are sorted based on the cost and the heuristic combined.
It cares about how close a node is to the goal.
In summary, A* is Dijkstra that includes a heuristic that says “We’re this far away from the goal”
This means that a node that costs more can get popped from the priority queue before a cheaper node, because it is closer to the goal.
Computerphile did a great video on it:
In this case, the nodes in the queue are sorted based on cost so far plus distance to go.
Written differently: cost + heuristic.
A reasonable measure of how far a node is from the goal is the amount of steps it would take if there were no blizzards.
It’s the shortest distance possible between the current node position and the goal.
That’s the Manhattan distance
So, a method on Coord calculating that distance between 2 Coords:
Each Node now stores an extra field with the heuristic:
That heuristic field contains the manhattan() from the current position to the goal.
The sorting of the Nodes is now not only based on the cost, but on the combined cost and heuristic:
Every time we push a Node onto the priority queue, we include the heuristic.
We don’t use it in the function in any other way, its only purpose is affecting how nodes are sorted inside the priority queue.
I made part1 also use the shortest function.
This was done on my local computer, a single run at a time, so take those numbers with a grain of salt.
The point is: A-Star is significantly faster for a marginal code-change.