Advent of Code 2023
Published November 30, 2023
Where does the time go?? Surely it can't be time for another Advent of Code challenge? Alas, it's once again December, which means 25 more mini coding challenges for the next twenty five days. This will be my sixth year particpating in advent of code, and my fourth writing about it - you can check out my previous years' Advent of Code posts as well, if you like.
As in years before, I'll be writing my solutions to run in PyScript right here in the browser window. Feel free to enter your own AoC input and test your answers right here on this page! All the computations run in your own browser window, so no AoC data is transmitted to me or any server, and there's zero overhead on my end, so test as many times as you like.
Personally, I have three goals in presenting these solutions: learn form the challenges themselves, dogfood the use of PyScript to learn where our gaps in functionality are, and demonstrate the use of Python in the browser. Here's hoping we can achieve all three.
Table of Contents
Day 0: Testing the Machinery
As with past years, I'm making use of Hugo's templating system to allow me to quickly write and share each day's code. The setup will look much like this, with a brief explanation here, the code below, and an option to run live demos. The get_input
function handles getting input from the textarea or file upload.
Paste Input Here:
Or upload file:
- day0.py
|
|
Day 1: Trebuchet?! (Part 1)
In most years, the first day's problems aren't too complicated - they tend to focus on reading input and parsing strings, and this year's is no exception.
I opted to pursue a solution purely using regex, since it's always good to stretch those muscles early on in Advent of Code. I couldn't quite find the right pattern to match both the case where there are are multiple numerals in the input line or only one, so I cheated a bit and split them into two separate cases.
Ultimately, this solution probably could have looked more like the solution to part 2 - using re.finditer()
to find all the digits in a given line, then sorting that collection to find the earliest and latest digits, but it felt good to refresh on regex matching.
Paste Input Here:
Or upload file:
- day1_1.py
|
|
Day 1: Trebuchet?! (Part 2)
"The strings which represent a given numeral" feels like something that should be included in Python's 'batteries included', but as far as I can tell, it isn't. Rather than pull something in from PYPI, I created a tiny dictionary matching numerals to strings. After finding all the literal numerals and 'spelled-out' numerals in each line, we find the earliest and latest match, grab their value with the tiny get_value()
function, and concatenate them to get the value of that line.
Paste Input Here:
Or upload file:
- day1_2.py
|
|
Day 2: Cube Conundrum (Part 1)
Happy once again that I picked up the regex track early this year! The specific syntax around things like named group, non-capturing groups, findall
vs finditer
is easy to forget if you haven't touched it in a few months.
Today's challenge was pretty simple - iterating over lines of input text and, based on some condition, adding a sentinel number to a given total. There's probably a cleaner way to break out of that nest-inner-loop than using a flag, but it works.
Paste Input Here:
Or upload file:
- day2_1.py
|
|
Day 2: Cube Conundrum (Part 2)
The second part of today's challenge isn't too different from the first; we iterate over all the parts of each line of the input and, for each line, calculate the appropriate product of the minmum number of cubes of each color.
In both parts of today's challenge, I've found it useful to run my code locally for testing before popping it into PyScript. You can see my strategy for this in both parts of today's solution - similar to what Pyodide recommends, we can check if the js
module is in sys.modules
. If it is, we can assume we're running in PyScript/in the Browser; if not, we're likely in "normal" desktop python.
The need to override display()
was the original reason I had implemented the output
attribute of PyScript tags in previous releases of PyScript... perhaps I'll want to do that again.
Paste Input Here:
Or upload file:
- day2_2.py
|
|
Day 3: Gear Ratios (Part 1)
Advent of Code has a history of requiring the parsing of data in grids, and this year we're starting early. While it's tempting to build a data structure that represents each position in the grid and is querable, for sparsely populated grid I've ususally found it's easier and faster to build a set
of the relevant points and query for presence in that set. This handles a lot of edge cases automatically - like in this case, a part number
being adjacent to the edge of the grid.
Once we have a collection of the symbols and numbers ('parts') in the input, we can iterate over each number and determine whether any of the coordinates corresponding to 'symbols' are adjacent to their positions, then sum them up.
Paste Input Here:
Or upload file:
- day3_1.py
|
|
Day 3: Gear Ratios (Part 2)
I do like dataclasses - having autocomplete on the handful of elements in a simple "data-like" object is an easy way to prevent confusion and typos. You'll see on this day's problem I've actually combined both Dataclasses
and namedtuples
; the latter being easier to use as the keys of a dictionary. But if there's any Mutable data - in my case, a flag which helps track whether a specific gear has seen zero, one, or two or more times already - dataclass is quite useful.
With that change to the parsing, the solution is to again iterate over all of the numbers, find which correspond to the given criteria, and sum them up.
Paste Input Here:
Or upload file:
- day3_2.py
|
|
Day 4 (Part 1): Scratchcards
Python's set operations really shine here - one we've parsed each line of input into its winning numbers and scratchcard numbers, we can use isdisjoint()
to determine if there's any overlap between the two sets, and and winning_numbers & my_numbers
to find it if it is. From there, we calculate 2 to the power of the length of this set of numbers, and sum up those values.
Paste Input Here:
Or upload file:
- day4_1.py
|
|
Day 4 (Part 2): Scratchcards
I like to look at the Advent of Code subreddit after finishing a day's problems, to look at alternate solutions and see what pitfulls I shared/avoided. In this case, most of Day 4 Part 2's solutions talked about using recurssion, which honestly haden't occured to me. Since each Scratchcard can only propogate copies to higher indices, my impulse was to just walk the data once and pile up the additional scratchcards with each additional index processed, in a functional-programming way. I do think this is faster.
Paste Input Here:
Or upload file:
- day4_2.py
|
|
Day 5 (Part 1): If You Give A Seed A Fertilizer
If programming is thinking-written-down, I find a significant amount of my thinking occurs in devising the data structure and object format to encapsulate a given problem. In this case, the almanac_op
function which applies a mapping rule to a given number. I tend to write out full docstrings for these 'problem-critical' code sections, and not worry about them for more procedural code like a main method.
One of the opporunties to stub your toe on a challenge like Day 5 Part 1 is remembering that, once you've applied a mapping rule successfully to a given location, you need to skip the remainder of the rules and cary on to the next set of mappings immediately. Otherwise, you risk applying multiple rules from a single set, which is incorrect.
Paste Input Here:
Or upload file:
- day5_1.py
|
|
Day 5 (Part 2): If You Give A Seed A Fertilizer
I fell prey to a classic Advent of Code trick: "We see your Part 1 solution works for single numbers... now make it work for MILLIONS OF NUMBERS!" Still, not too complicated of a solution, even if it did take me some time to work out the logic around which relationships of sources/destinations/ranges cause which effects to happen to the resulting ranges.
There's also the possibility here to get stuck in the track of modifying a collection (say, the current list of ranges) while also iterating over that collection. The old new_collection += result
, collection = new_collection
pattern helps here.
I'm also leaving my assert
startments in at the end of this code, as they were hugely helpful in debugging my primary algorithm.
Paste Input Here:
Or upload file:
- day5_2.py
|
|
Day 6 (Part 1): Day 6: Wait For It
The utility of grabbing numbers out of a list of text is now fully evident. It gracefully handles line endings, spaces, comma, etc. Not that I won't be back to (and indeed, already am using) split()
and friends, but the time regexes have saved on input so far has been enlightening.
Paste Input Here:
Or upload file:
- day6_1.py
|
|
Day 6 (Part 2): Day 6: Wait For It
Having been bit yesterday, I was pretty confident that part 2 of day 6 would have a similar explosion in time/space requirements. And it did... only it doesn't seem to have mattered, as the 'naive' solution here still runs in about a second. Just lucky I guess.
Paste Input Here:
Or upload file:
- day6_2.py
|
|
Day 7 (Part 1): Camel Cards
The complexity of the problems is ramping up at this point, if not quite the difficulty. Since today is ultimately a sorting problem, I was looking for a way to transform the input into a sortable form; by tranforming the "face card" labels ("AKQJT") into Hexadeical characters with the matching rank, then evaluating the hand's strength using collections.Counter
and appending that as another Hex character to the front, the sorting is then straightforward.
Paste Input Here:
Or upload file:
- day7_1.py
|
|
Day 7 (Part 2): Camel Cards
A relatively benign change to part one, invovling adjusting the ranking algorithm to incorporate Jokers and adjusting the translation table. One of the (minor) pain points in using PyScript is that if you're using main-thread tags, you get exactly on interpreter and one global namespace, so name collisions across multiple problems are a given. Hence the clunky names in both parts of this problem.
Perhaps I should switch to worker
tags...
Paste Input Here:
Or upload file:
- day7_2.py
|
|
Day 8 (Part 1): Haunted Wasteland
We've seen challenges like this in previous years' advents of code, and I suspect we'll see more like it this year. The input is a list of transformations on some input point, as indexed by some other part of the index. The only real trick is to make sure you're starting and ending points correctly identified; with Python's str.startswith()
and str.endswith()
, this is pretty trivial.
Paste Input Here:
Or upload file:
- day8_1.py
|
|
Day 8 (Part 2): Haunted Wasteland
The niave solution here would be to simply set up an array of locations to track, and let them all run for a long time until they all simultaneously were on locations that end in 'Z'. However, with the ghosts having periods that are large and co-prime, the resulting overall period can (and in my case, is) over 14 digits long.
The solution is to recognize that you can identify when each ghost has reached it's 'destination' (location that ends in 'Z') to find the period of that ghost. Then, the overall period is the least-common-multiple of all the ghosts' periods. Again, thanks to Python 3.9's addition of math.lcm
, this is straightforward.
Paste Input Here:
Or upload file:
- day8_2.py
|
|
Day 9 (Part 1): Mirage Maintenance
I'm sure there have been earlier days that could have been solved recursively. Indeed, my first catastrophic attempt for Day 5 Part 2 used some lazy recursion. But today's problem - repeatedly doing the same opeartion until hitting a base case - just screams for recurssion.
In truth, the problem description essentially lays out the algorithm itself, step by step. All that's left is implementation.
Paste Input Here:
Or upload file:
- day9_1.py
|
|
Day 9 (Part 2): Mirage Maintenance
Part 2's solution is very similar to part 1's, with a change of indices, and appending to the start of our list-of-differences instead of the end.
On most day's, there's a seductively easy solution to part 1 (usually brute force) that blows up for part 2. But I'm struggling to see how the solutions to part 1 and part 2 would be significantly different... perhaps for a different choice of language, inserting a number at the beginning of a list is significantly more challenging?
Paste Input Here:
Or upload file:
- day9_2.py
|
|
Day 10 (Part 1): Pipe Maze
Ah, back to processing input on a grid. As with earlier puzzles, I usually find it easier to create a running dictionary of data[(row, column)]
instead of tested dicts data[row][column]
, since then a single (x, y) in data
suffices to check for precence.
Paste Input Here:
Or upload file:
- day10_1.py
|
|
Day 10 (Part 2): Pipe Maze
Part two is a bit of a curveball, in that it asks you to find an entirely new property of the curve/data from part one. My initial impulse was to use something like a flood fill algorith, but the second set of example problems show that some "outside" areas might be trapped and "unfloodable".
I ultimately used a version of the Raycasting/Jordan Curve agorithm, iterating along each line and determining whether a given position was "inside" or "outside" the curve. Whenenver a |
is crossed, one passes from inside to outside; corners are a little harder: "L----7"
and "F----J"
count as crossings, but "L----J"
and "F----7"
do not. Finally, the original starting point S
has to be replaced with the appropriate symbol before processing, or everything after it on that line may go sideways.
Paste Input Here:
Or upload file:
- day10_2.py
- day10_2-viz.py
|
|
|
|
Day 11 (Part 1): Cosmic Expansion
What I've taken away from the first chunk of Advent of Code this year is:
- Inserting additional features into your original data structure is often a mistake, as insertions don't scale well.
- When faced with a task that involves a grid, think carefully about whether you need to store the full structure of the grid, or just some important coordinates.
Paste Input Here:
Or upload file:
- day11_1.py
|
|
Day 11 (Part 2): Cosmic Expansion
The only critical hint I'd give about part to is: beware of off-by-one errors. Otherwise, the solution to part 2 is very similar to part 1.
Paste Input Here:
Or upload file:
- day11_2.py
|
|
Day 13 (Part 1): Point of Incidence
Today's problem involves finding lines of symmetry in the given data, either horizontally or vertically. In cases like this, I find Python's shortcutting any()
builtin quite easy - when comparing if the two halves of data across a potential mirror line don't match in any position, immediately move on.
Paste Input Here:
Or upload file:
- day13_1.py
|
|
Day 13 (Part 2): Point of Incidence
Having established a general logic for part 1, part two is not too terribly different - but sadly, we can't immediately bail out when any single position between the two "mirrored" halves differ. Rather, we track in how many positions they differ - if it's ever more than one, we can bail out immediately; otherwise, if we get to the end of a potential mirror line and exactly one position differs, we've found our mirror.
Paste Input Here:
Or upload file:
- day13_2.py
|
|
Day 14 (Part 1): Parabolic Reflector Dish
A rolling stone gathers no moss, and today's stones are no exception. For part 1, we can iterate over a list of strings an compare them, then "roll" the stone characters down each list as necessary.
In both part 1 and part 2 of today's challenge, I started with a more complex data structure to hold the state of the grid, before reverting to simply using a string as-is. But in the switch, I forgot that some of the operations I was doing - like comparisons across multiple indices, and doing a deepcopy
of the data each time, we unnecessary with (immutable) strings. Whoops! That's some free performance gain, I suppose.
Paste Input Here:
Or upload file:
- day14_1.py
|
|
Day 14 (Part 2): Parabolic Reflector Dish
Yet another Advent of Code challenge that takes a quantity in part 1 (roll once), and amps it way up in part 2 (roll a billion times).
The key insight is to glean (hope) that the stones will fall into a repeating pattern at some point; after which, we can calculate where in that repeating cycle the stones will be after a billion steps, and calculate the load at that point. For me, after 124 cycles of North/West/South/East movement, the stones enter a repeating pattern of 26 cycles.
Paste Input Here:
Or upload file:
- day14_2.py
|
|
Day 15 (Part 1): Lens Library
A description for this part of the day's problem is coming soon.
Paste Input Here:
Or upload file:
- day15_1.py
|
|
Day 15 (Part 2): Lens Library
A description for this part of the day's problem is coming soon.
Paste Input Here:
Or upload file:
- day15_2.py
|
|
Day 16 (Part 1): The Floor Will Be Lava
A description for this part of the day's problem is coming soon.
Paste Input Here:
Or upload file:
- day16_1.py
|
|
Day 16 (Part 2): The Floor Will Be Lava
A description for this part of the day's problem is coming soon.
Paste Input Here:
Or upload file:
- day16_2.py
|
|
Day 18 (Part 1): Lavaduct Lagoon
A description for this part of the day's problem is coming soon.
Paste Input Here:
Or upload file:
- day18_1.py
|
|
Day 18 (Part 2): Lavaduct Lagoon
A description for this part of the day's problem is coming soon.
Paste Input Here:
Or upload file:
- day18_2.py
|
|
Day 19 (Part 1): Aplenty
A description for this part of the day's problem is coming soon.
Paste Input Here:
Or upload file:
- day19_1.py
|
|
Day 19 (Part 2): Aplenty
A description for this part of the day's problem is coming soon.
Paste Input Here:
Or upload file:
- day19_2.py
|
|
Day 20 (Part 1): Pulse Propagation
A description for this part of the day's problem is coming soon.
Paste Input Here:
Or upload file:
- day20_1.py
|
|
Day 21 (Part 1): Step Counter
A description for this part of the day's problem is coming soon.
Paste Input Here:
Or upload file:
- day21_1.py
|
|
Day 24 (Part 1): Never Tell Me The Odds
A description for this part of the day's problem is coming soon.
Paste Input Here:
Or upload file:
- day24_1.py
|
|