This year, like the last, I participated in Advent of Code, an online event where a new algorithmics puzzle is unlocked every day of december before and on the 25th. For each day, the same puzzle idea yields two small exercises, one typically easier than the other.
I chose Rust, like last year, to solve those problems. You can find my repository here.
A bit of write-up for the days I found interesting
Most puzzles were interesting, to very interesting. The first week or so is easy to solve as long as you have a good handling of simple data structures and logic branching.
A word on boilerplate
In order to have a faster time coding and testing my code, I spent the first couple days perfecting a boilerplate system, learning about Rust's workspace crate mechanism. The code I wrote was very simple :
- A shell day crate that could be copied and which content could be changed to deploy the simple boilerplate code for the day
- A benchmarking area
- A lot of testing that the code gave the right results for tests and answers
The shell crates I made had the basic bin/lib structure, where all of my logic code resided in methods called solve_part_one
and solve_part_two
in src/lib.rs
, which could be imported by the higher-level crate for testing, or src/main.rs
to be executed / test sample inputs.
Day 1 : Slice Windows
Day 1 had me a little stumped, but not for any valid reason. I just had never thought about the concept of a window iterator in Rust. I was sure there must have
been something like that in the standard library, I just did not know where. It turns out, the slice
primitive type is equipped with a windows
method that yields a special iterator over a given amount of elements in the original slice.
Day 5 : HashMap is good for grids (with moderation)
Continuing from a theme I had last year, using std::collections::HashMap
instead of a fixed size grid can turn out to be a life saving spark of genius if it turns
out your puzzle input is larger than expected, your data is lacunar, or your coordinate space expands to negative coordinates. Last year, the various versions of
Conway's Game of Life had us play around with multi-dimensional grids that could expand to unreasonable sizes in any direction, and I found that storing one type
of state information as coordinates in a HashMap
(or HashSet
), and assuming that absence from that structure means another state information, can be fairly useful.
Day 23 : Hashing is good, with moderation (and well)
A caveat of the previous point is that hashing still comes at a cost, particularly for complicated data structures that you define yourself. I had an example
in day 23 where I needed to store the states of my puzzle state space that I had already explored in order to avoid needlessly repeating computations, and filling
memory. To do so, I chose to use a HashSet
and implemented std::hash::Hash
on a type that was essentially an enum and 30-ish integers in a trench coat.
My first implementation iterated all over every single one of these values, and hashed them all individually.
#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord, Hash)]
enum Amphipod {
Amber,
Bronze,
Copper,
Desert
}
#[derive(Clone, Copy, Debug)]
struct AmphipodPuzzleState {
cost: usize,
hallway: [Option<Amphipod>; 11],
chambers: [[Option<(Amphipod, bool)>; 4]; 4],
part_one: bool
}
impl std::hash::Hash for AmphipodPuzzleState {
fn hash<H: Hasher>(&self, state: &mut H) {
for potential in self.hallway {
if let Some(amp) = potential {
// Room no yields a number between 0 and 3 depending
// on the value of the enum
amp.room_no().hash(state);
} else {
(-1).hash(state);
}
}
// .. You get the idea
}
}
This was, as I can see now, horrendously slow. Indeed, having to endure the overhead of a single hashing for every single number in the structure was absolutely too much.
A second version of this implementation created a single number by pushing a base 5 number into a number stack and hashing it (you will see that it's a theme this year). However, that was still quite slow.
In the end, just creating a method that yielded a fixed-length array of numbers that itself could be automatically hashed worked better. The overhead of hashing still slowed down the code, but I managed to get sub-1s in release
target.
Day 24 : Sometimes it's not about simulating, it's about reading and counting
On day 6, we were presented with a problem that seemingly asked us to simulate an unreasonable amount of data. It turns out we only needed to realize that some states were independent of the others, and count.
On day 14, we were once again tricked. The polymerization exercise demanded outright impossible simulation. People who had working short-term memory remembered the lanternfish exercise from a week prior, and simply figured out you could use dynamic programming to cut down on simulation, and count.
On day 24, we were presented with assembly instructions. I spent a lot of time building a robust, modular instruction simulator, confident that we would need to simulate this program.
Nope.
It turns out, and don't read if you don't want it spoiled, that the program performed a 14-cycle parametric "hashing" process that essentially pushed and popped a base 26 number (see, I said it would be a theme), computed from a test input and some of the aforementionned parameters. The goal was to get the end value to 0. Solving day 24 required way more program structure analysis than pure programming skill.
Day 22 : Papers aren't really always helpful
I spent a large amount of my time on day 22 trying to see if someone smarter than me had figured out a general algorithm to compute the real volume of a set of axis-aligned, intersecting cuboids in 3D space. I read a couple brain-melting papers that solved this efficiently for cubes, and more general versions that were really above my understanding.
I just ended up creating a system where the intersection of an added cuboid was computed against every cuboid already in the set, and the added cuboid was split into smaller cuboids accordingly in order to shave off and split the added cuboid into a set of smaller, non intersecting cuboids. Computing the difference of volume and adding them then became trivial.
Binary optimization is madness
The worst runtime I had this year was day 19, before a large optimization I made. In debug
target, it ran for about 70 seconds before spitting out the right
answer on my input. However, building in the release
target reduced the runtime to a mere 3 and a half seconds.
More generally, I have to say that the binary optimization on the release
target for Rust is mind-bogglingly fast. Repeated operations seem to fly by like the
compiled itself predicted your data and shrunk the binary to just solve what could change. The world of compiler optimization looks fascinating, and very complex.
More general things I learned
- Rust is very powerful, yet,
- Rust is not very well suited for a lot of fast, dirty problem solving.
- Zero-cost abstraction is wonderful. I could write entire structures to describe my problems very clearly (like
Amphipod
/AmphipodPuzzleState
for day 23, and have close to no overhead from it) - The people who strive for leaderboard on this event are all generally american and do not work a job where they have to wake up early
And that's about all I can come up with for now! I want to thanks Eric Wastl, creator of Advent of Code, along with the whole team, for the work they put into the event every year. Hopefully, I'll see y'all for the next one!
And since this post comes out January 1st 2022 I also want to wish everyone a better 2022 than 2021 was.