Advent of Code 2021: A Julia Journal - Part 2
Advent of Code is an advent calendar for programming puzzles. I decided to tackle this year’s set of 50 puzzles in Julia and journal my experiences along the way. I’m a beginner in Julia so I thought this would help me improve my skills.
This post covers days 9 through 16.
Day 9: Bracket matching
Syntax error in navigation subsystem on line: all of them
I over-engineered the heck out of this puzzle. I can’t justify making this struct
just to represent a bracket:
struct Bracket
left::AbstractString
right::AbstractString
orientation::AbstractString
function Bracket(left::AbstractString, right::AbstractString, orientation::AbstractString)
if orientation ∉ ["left", "right"]
throw(ArgumentError("orientation must be \"left\" or \"right\", got: $orientation"))
end
new(left, right, orientation)
end
end
Although it does mean I can flip a bracket with -bracket
by defining the unary operation:
function -(bracket::Bracket)
if bracket.orientation == "left"
return (Bracket(bracket.left, bracket.right, "right"))
end
Bracket(bracket.left, bracket.right, "left")
end
Overall I enjoyed this puzzle. And the overengineering did help. A key component was defining a function to iteratively remove the matching brackets, and being able to refer to the orientation
of a bracket let me do this:
function strip_matching_brackets(brackets::Vector{Bracket})
for i = 1:length(brackets)-1
if brackets[i].orientation == "left" && brackets[i] == -brackets[i+1]
stripped = [brackets[j] for j in 1:length(brackets) if j ∉ [i, i + 1]]
return strip_matching_brackets(stripped)
end
end
return brackets
end
And for the second part, in which I had to identify the right brackets needed to close a sequence of incomplete brackets, I could do this:
right_half = [-bracket for bracket in reverse(stripped)]
Day 11: Bioluminescent octopuses
They seem to not like the Christmas lights on your submarine, so you turn them off for now.
I had a sense of satisfaction for writing code for part 1 that’s so general that it took me about a minute to solve part 2. It’s just a good feeling.
I designed an increment
function that increased all entries of a matrix by 1, a rollover
function that converted all values of a matrix greater than 9 to 0. These are neat one-liners in Julia thanks to my old friend loop fusion and the @.
macro that makes it apply across the whole line:
increment(octopuses::Matrix{Int64}) = octopuses .+ 1
rollover(octopuses::Matrix{Int64}) = @. mod(min(octopuses, 10), 10)
The main work is done in the recursive flash
function:
function flash(
octopuses::Matrix{Int64},
flashed::Vector{CartesianIndex{2}} = Vector{CartesianIndex{2}}()
)
above_9 = findall(x -> x > 9, octopuses)
new_flashes = above_9[(!in).(above_9, Ref(flashed))]
if length(new_flashes) == 0
return rollover(octopuses), flashed
end
octopuses += neighbours_matrix(new_flashes)
octopuses, flashed = flash(octopuses, [flashed; new_flashes])
end
The neighbourhood
function here calculates a 10*10 matrix of all 0 except for the neighbours of a given coordinate, which have value 1. When given a vector of coordinates, as what happens here, it sums the neighbour matrices of each coordinate. This is how a flashing octopus charges its neighbours.
It’s not immediately clear to me what above_9[(!in).(above_9, Ref(flashed))]
does; I need to think it through. The !in
function is vectorised (Julia users tend to prefer the word broadcasting), but the Ref
function is used to treat flashed
as a scalar. This means that the each value of above_9
is compared to the singular flashed
value. The result is a Boolean vector, used to filter the elements of above_9
to those that are not in flashed
.
With all of this in place the step
function was a nice one-liner:
step(octopuses::Matrix{Int64}) = octopuses |> increment |> flash
This function returns both the modified octopuses
matrix and the vector of octopuses that flashed during the step. This would also work if the function returned only the number that flashed, but I’m not sure how type stability works with recursive functions, so I played it safe. With this return value the actual solutions could iterate through steps and use the returned vector of flashing octopuses to calculate whatever answer is needed.
Day 12: Plotting a course
the only way to know if you’ve found the best path is to find all of them.
Graphs! I love it. Apart from a few stack overflow errors this was relatively straightforward. I decided to implement a special Graph
struct with vertices and edges, although this is arguably not necessary.
The main function — traverse
— has a Boolean keyword argument allow_small_cave_revisit
. The function is recursive, branching out along every possible path and taking the set union of all paths ending at “end”.
Part 2 of this puzzle allows a single small cave to be visited once. To make this work I include the following piece of logic that checks for a duplicate small cave in the path travelled thus far. If one is detected, it switches instead to allow_small_cave_revisit = false
:
if allow_small_cave_revisit && has_visited_a_small_cave_twice(updated_path)
return traverse(graph, path_so_far, next_vertex; allow_small_cave_revisit = false)
end
This works because the allow_small_cave_revisit = false
argument forbids the function from adding a duplicate small cave to the path, but it doesn’t enforce this constraint on the path travelled so far. Note also that we only need to check for dead ends if allow_small_cave_revisit = false
, because in the true
case we can always travel back along the path we came. The full function is shown below:
function traverse(
graph::Graph,
path_so_far = Vector{String}(),
next_vertex = "start";
allow_small_cave_revisit = false
)
updated_path = [path_so_far; next_vertex]
if next_vertex == "end"
return Set([updated_path])
end
if allow_small_cave_revisit && has_visited_a_small_cave_twice(updated_path)
return traverse(graph, path_so_far, next_vertex; allow_small_cave_revisit = false)
end
valid_neighbours = filter(candidate -> candidate != "start", neighbours(graph, next_vertex))
if !allow_small_cave_revisit
valid_neighbours = filter(
candidate -> cave_size(candidate) == "large" || candidate ∉ updated_path,
valid_neighbours
)
if length(valid_neighbours) == 0 # dead end
return Set{Vector{String}}()
end
end
branches = [
traverse(graph, updated_path, neighbour; allow_small_cave_revisit = allow_small_cave_revisit)
for neighbour in valid_neighbours
]
return ∪(branches...)
end
I did notice in the graphs that large caves were always connected by small caves. This suggests that the graph can be represented another way, in which large caves are vertices and small caves are edges. I wasn’t sure how that would simplify the solution, so I didn’t pursue the idea.
Day 13 - Paper folding
Congratulations on your purchase! To activate this infrared thermal imaging camera system, please enter the code found on page 1 of the manual.
Up until now I thought that the solutions to Advent of Code puzzles were always integers! For day 13 I had to print out coordinates that formed letters. This was new.
This is one of those days where even though my code worked, I feel like I’m missing an elegant way to do it. I implemented two functions — horizontal_fold
and vertical_fold
— that are almost identical. But the few differences make it such that combining the two would lead to some hideous branching and spaghetti code.
function horizontal_fold(coordinates::Vector{Tuple{Int64,Int64}}, fold_line::Int64)
# fold up
below_fold = [coordinate for coordinate in coordinates if coordinate[2] > fold_line]
above_fold = coordinates[(!in).(coordinates, Ref(below_fold))]
below_fold_x = [coordinate[1] for coordinate in below_fold]
below_fold_y = [coordinate[2] for coordinate in below_fold]
mirrored_y_coordinates = [fold_line - (y - fold_line) for y in below_fold_y]
mirrored = collect(zip(below_fold_x, mirrored_y_coordinates))
unique([above_fold; mirrored])
end
Having created a function to print the coordinates with unicode blocks, it was satisfying to see my answer to part 2 pop up:
julia> solve(13, 2)
███ █ █ ██ █ ███ ██ ███ ██
█ █ █ █ █ █ █ █ █ █ █ █ █ █ █
█ █ ████ █ █ █ █ █ █ █ █ █ █
███ █ █ ████ █ ███ █ ███ ████
█ █ █ █ █ █ █ █ █ █ █ █ █ █ █
█ █ █ █ █ █ ████ █ █ ██ █ █ █ █
Day 14: This polymer grows quickly
“Simple pattern substitution,” I thought to myself, deciding straight away that this would be an easy day.
It was not.
First of all, I defined an Insertion
struct that would define the rules:
struct Insertion
pattern::AbstractString
insert::AbstractString
end
My first solution was naïve. I calculated the full string, and then counted the letters. For 10 iterations this was sufficient. The substitute
function first determined which letter insertions were needed. It then iterated through the insertions, keeping track of an offset (as each new letter increased the positions of the remaining letters by 1):
function substitute(template::AbstractString, insertions::Vector{Insertion})
characters_to_insert = Vector{Tuple{Int64,AbstractString}}()
for i = 1:length(template)-1
current_pair = template[i] * template[i+1]
for insertion in insertions
if current_pair == insertion.pattern
push!(characters_to_insert, (i, insertion.insert))
break
end
end
end
for i = 1:length(characters_to_insert)
index, to_insert = characters_to_insert[i]
offset = i - 1
index_with_offset = index + offset
template = insert_after(template, to_insert, index_with_offset)
end
return template
end
But of course, I ran out of memory for 40 iterations. I thought about compressing the template in some way: perhaps counting letters and then their occurrences. But the key realisation is that the positions of the pairs of letters does not matter. I thus tracked only the pairs themselves, along with the letter occurrences:
mutable struct CompressedTemplate
pairs::Dict{Tuple{Char,Char},Int64}
letter_counts::Dict{Char,Int64}
end
These pairs overlap, but that’s okay. The new substitute
function now takes a pair and — if it matches an insertion rule — splits it, increments the count of the two new pairs and decreases the count of the former pair. It also tracks the new letters each time:
function substitute(template::CompressedTemplate, insertions::Vector{Insertion})
new_template = deepcopy(template)
for insertion in insertions
current_pattern = pair(insertion)
occurrences = get(template.pairs, current_pattern, 0)
pair_left, pair_right = new_pairs(insertion)
new_template.pairs[pair_left] = get(new_template.pairs, pair_left, 0) + occurrences
new_template.pairs[pair_right] = get(new_template.pairs, pair_right, 0) + occurrences
new_template.pairs[current_pattern] = get(new_template.pairs, current_pattern, 0) - occurrences
new_letter = only(insertion.insert)
new_template.letter_counts[new_letter] = get(new_template.letter_counts, new_letter, 0) + occurrences
end
new_template
end
I went from running out of memory to solving part 2 so quickly that I didn’t notice the execution time.
Intermission: A gripe about Julia
I’m pretty positive when I speak of Julia but here’s one annoyance: I have to restart the kernel very often. If I defined a struct
and then later chose to redefine it? Restart. If I accidentally defined a pair
variable but then needed to define a pair
function? Restart (that’s right, you can’t delete variables in Julia). And every restart means:
- activating the project environment
- loading the package
- recovering the state of the REPL as it was before the restart
- recovering my test input
I use the Revise
package for automatically changing my environment in response to changes to my code, but it doesn’t solve these problems. Actually, Revise
isn’t very useful at all. I’m working in submodules and I don’t export
many objects. I don’t want to type AOC2021.Day14.CompressedTemplate
every time when I can just type CompressedTemplate
. So I define the working functions straight in the REPL.
What I’m missing in Julia is the equivalent of devtools::load_all
in R. When I’m developing a package I want to load every object, even if it isn’t exported, and the fewer letters I need to type the better. I want a single, definitive action to reload differences, because that way I know if the reload fails and why. And then I can bind this to a keyboard shortcut like in R and focus on developing my code.
Day 15 - Suddenly needing to learn Dijkstra’s algorithm
the walls of the cave are covered in chitons, and it would be best not to bump any of them.
This is the first day that I really struggled. I had enough knowledge of the fundamentals of computer science to know that Dijkstra’s algorithm existed, and that it could be used to find the shortest path between two vertices in an edge-weighted graph. But I had never used it before, let alone implemented it.
I tried implementing the algorithm from Wikipedia. I was keeping track of all unchecked vertices with a vector, and distances with dictionaries. I initialised every distance to 2^63-1
(the largest value of a 64-bit integer), except for the distance from the source vertex which I set to 0. On each iteration I would find the vertex with the smallest distance and calculate the distance to its neighbours, continuing until I hit the target vertex.
This worked for part 1, but for part 2 the graph was 25 times as large. And it didn’t look like my algorithm would finish in my lifetime.
I needed a way to store the distances such that I could easily retrieve the minimum one. Looking around the internet, I found a min binary heap would work here. Despite my weak understanding of, well, everything, I managed to implement it. Note in the code below that neighbours
is a helper function that finds all in-bounds coordinates around a given point in a matrix:
function dijkstra(heatmap::Matrix{Int64})
source = (1, 1) # top-left corner
target = size(heatmap) # bottom-right corner
risk_from_source = BinaryMinHeap{Tuple{Int64,Int64,Int64}}()
push!(risk_from_source, (0, source...))
counted = Vector{Tuple{Int64,Int64}}()
while true
risk, row, column = pop!(risk_from_source)
vertex = (row, column)
if vertex in counted
continue
end
push!(counted, vertex)
if vertex == target
return risk
end
uncounted_neighbours = filter(v -> v ∉ counted, neighbours(heatmap, vertex))
for neighbour in uncounted_neighbours
neighbour_risk = risk + heatmap[neighbour...]
push!(risk_from_source, (neighbour_risk, neighbour...))
end
end
end
This worked, but it was still fairly slow. It took 1–2 minutes to solve part 2. This is good enough to get the stars, but I’m still disappointed.
Profiling the code tells me that there’s a slowness somewhere in a getindex
operation, but that’s still quite vague. Is it still the minimum distance retrieval that’s slowing this down? The if vertex in counted
branch?
At this point I had a solution in a reasonable time frame and so I decided to give it a rest. Maybe this is something to revisit another day.
Day 16 - decoding BITS
The transmission was sent using the Buoyancy Interchange Transmission System (BITS), a method of packing numeric expressions into a binary sequence
This puzzle wasn’t necessarily hard, but it did require hard work. I went from my usual 2 unit tests each day to a total of 18 unit tests for day 16.
My approach for processing the bits was to make extract
functions that returned a packet, along with the remaining bits once that packet had been removed from the start. For example, for literal operators:
function extract_literal_packet(packet_bitstring::AbstractString)
last_bit = 11 # header + one group
while packet_bitstring[last_bit-4] != '0'
last_bit += 5
end
content = packet_bitstring[7:last_bit]
groups = [content[i:min(i + 4, end)] for i = 1:5:length(content)]
decoded = [group[2:end] for group in groups] |> join |> decimal
packet = LiteralPacket(
decoded,
packet_bitstring[1:last_bit],
last_bit,
version(packet_bitstring[1:last_bit])
)
return packet, packet_bitstring[last_bit+1:end]
end
Julia’s struct system was helpful here. I defined an abstract Packet
type, of which LiteralPacket
and OperatorPacket
were concrete subtypes:
abstract type Packet end
struct LiteralPacket <: Packet
decoded::Int64
raw::String
length::Int64
version::Int64
end
struct OperatorPacket <: Packet
subpackets::Vector{Packet}
raw::String
length::Int64
version::Int64
type_id::Int64
end
Defining a vector of a bespoke type in R is unthinkable without diving into C, but in Julia it’s as easy as Vector{Packet}()
.
My approach also lead to some neat ways to tackle the puzzle calculations. For part 1:
sum_versions(packet::LiteralPacket) = packet.version
function sum_versions(packet::OperatorPacket)
packet.version + sum(sum_versions(subpacket) for subpacket in packet.subpackets)
end
And for part 2:
decode(packet::LiteralPacket) = packet.decoded
function decode(packet::OperatorPacket)
operation = type_id_function[packet.type_id]
operation([decode(subpacket) for subpacket in packet.subpackets])
end
That type_id_function
dictionary was a constant I used to link the type ID of a packet to a Julia function. To be consistent, I made it such that all of the functions accepted vectors (even if only a vector length 2). It pleases the mathematician in me to be able to use both prefix and infix notation like this:
const type_id_function = Dict(
0 => sum,
1 => prod,
2 => minimum,
3 => maximum,
# omit 4, which is reserved for literals
# the following only ever contain two packets
5 => x -> Int(>(x...)),
6 => x -> Int(<(x...)),
7 => x -> Int(==(x...)),
)
I helped myself debug the puzzle solution by defining a custom show
method for my packet structs. This makes the tree-structure of the packets more obvious:
= <v4>
+ <v2>
1 <v2>
3 <v4>
× <v6>
2 <v0>
2 <v2>
The image at the top of this page is by Dom J and is used under the terms of the the Pexels License.