I participated in Advent of Code for the second time this year. My notes on last year’s challenge were one of the first things I posted here, so let’s call this post a tentative continuation of that nascent series.
The most substantial change introduced in this year’s challenge was its shortening. The global leaderboards were also removed, but that wasn’t as meaningful to me, since I don’t care about leaderboards. The organizer, Eric Wastl, posted comments explaining this decision, and while some participants seemed quite put out, I fully support the decision to continue an ongoing project in a more sustainable form. Judging by some of the public negativity, I think Eric would have been more than justified in shutting the project down altogether—running a fun public project for free doesn’t obligate him to keep it going in any form—so I appreciate him taking steps to keep things going.
I have written more code in the past year than any period since I stopped working professionally as a software engineer. In contrast, last year’s challenge represented a return to programming for me, and it’s remarkable how different it felt. Last year, solving even the simplest problems brought me satisfaction, much of which came from focusing on doing things in the most Pythonic1 way.
This time around, I still found satisfaction in figuring out how to crack the problems. I think this is because programming challenges present a much higher ratio of problem to boilerplate than real world programming does. Honestly, nobody would do programming challenges if they were more realistic in that way. I can’t imagine anyone wants to spend any more time than necessary negotiating data format conversions, handling obscure protocol issues, or any of the other gruntwork that comes with real world software engineering.
What felt most different was being more familiar with Python convention. This meant that while I spent less time working out the best way to express my logic in the language, I also didn’t get as much enjoyment out of reaching those conclusions. For working programmers, that’s no loss. You literally don’t want to spend a lot of time reasoning through the right way to express yourself unless you’re writing something novel and distinctive. The point of idiom and convention is to allow a community to make correct assumptions about what a particular chunk of code does. But for hobbyists and dabblers, in whose company I count myself, it’s certainly less fun.
Thoughts on LLM Coding Assistants
I wrote all of my code myself, but because I use a modern editor, I received inline suggestions from an LLM coding assistant. If you’re unfamiliar, think of it as a kind of autocomplete for programming. Editors have offered support tools like these for decades to help programmers fill in variable names or get function signatures correct, and bringing LLMs to bear on this problem makes a lot of sense, given how unreasonably effective LLMs have proven at digesting and generating code.
Earlier this year when I was working on a different project, I found these suggestions annoying and unhelpful. In part, this was because I was doing some fairly technical processing that the assistant was clearly familiar with, but whose logic it could not accurately generate. That experience actually lowered my estimation of the usefulness of coding assistants in general, but in retrospect, I was probably coming at the problem in the wrong way.
Unlike coding agents like Claude Code, whose purpose is to build working software according to a user’s specification, inline coding assistants really do need to be approached like autocomplete. They provide a potential end for something the author has begun, and much of the time, they are incorrect, because it’s often hard to know for certain what should come next. However, when the next thing really is an obvious extension of what came before, it’s quite useful to have the editor handle the rote typing after the thinking is done.
An Example
The problem for Day 4 this year involved looking at items laid out on a grid and checking for adjacent items. My solution for Part 1 processes the input and stores the locations of the items as coordinate pairs like (1,2) to represent an item in column 1 and row 2. To look for adjacencies, my code checks all of the coordinate pairs that are one away from at least one of the coordinates, so in this case, (0,2), (2,2), (0,1), (1,1), and so on. I decided to do this by storing a list of offsets that could be added to each coordinate pair. Once I started the expression adjacency_offsets = [, the correct inline suggestion appeared:
That’s a pretty helpful suggestion, and a substantial improvement over earlier systems that could complete token names but not much else. These autocompletions don’t directly save a ton of time, but they can help programmers stay focused on problem solving by obviating some of the toil that accompanies it.
One Minor Issue
As I mentioned, I fully support shortening Advent of Code, especially if doing so makes it more likely to remain an ongoing annual event. However, I would like to suggest one adjustment to the new format for future years.
In previous years, it seems to have been typical for the final day’s challenge to be simpler than the rest. I think of it as a kind of Christmas gift to participants, and respectful of the likelihood that even dedicated participants would prefer to spend Christmas Day doing holiday things other than coding.
This year, the final challenge was released on December 12, but it seems the organizers still decided to treat it as a “gift” day, providing a challenge that seemed tricky but was actually trivially solvable without any coding. In my case, I examined the puzzle input with a spreadsheet that ended up generating the answer for me. I saw quite a few disappointed comments from participants online, and I tend to agree that Day 12 didn’t feel great. With the last day of the event no longer falling on Christmas, I think it makes sense to retire the gift day tradition and make all 12 challenges more or less equally real.
My Most/Least Favorite Problem
For me, the most notable problem this year bears striking similarities to something I wrote a bit about last year. For the second year in a row, one of the challenges boiled down to a linear algebra problem that nearly cost me a star. Much as I tried, I struggled to write a solution that could calculate answers for Part 2 of Day 10 efficiently enough.
I ended up breaking my own rule about sticking to standard libraries and brought in SciPy to handle the linear programming, rather than stumble my way through implementing Gaussian elimination by hand. On one hand, I wish I had been able to meet the challenge myself, but it’s not so bad to have a chance to refresh my memory and dip back into an extremely useful Python library. At least this year, I recognized the structure of the program at the outset instead of banging my head against it futilely for far too long.
I’ll conclude by encouraging anyone who’s interested to review my best effort using only standard libraries. Let me know if I’m missing any ways to further reduce the search space. I’m reasonably confident that the code I’ve written is correct, but it’s just not efficient enough to come up with solutions for larger cases in a reasonable time on my laptop.
In fact, just for kicks, I posted all of my solutions, which you’re welcome to check out. Until next year!
For non-coders, the different capabilities afforded by different programming languages tend to coalesce into a shared style that the community views as being most consistent with the language’s strengths and weaknesses. The community for Python, the language I used, calls this style “Pythonic.” ↩
If you’re not familiar with it, Advent of Code is a programming challenge that runs every year over the first 25 days of December. Each day, the organizer, Eric Wastl, posts a prompt. Each participant gets a unique bit of input and has to provide a solution generated using their unique input. Then, once you have provided the correct solution, a second phase of the problem is revealed. There’s never any new input, but a complication is introduced that requires more solving and generates a new solution. Each part of the problem earns you a “star,” for a total of 50 possible stars each year.
The two-phase design of each puzzle is the thing I find most interesting about AoC. Coding up solutions to little toy problems is fun (and the tougher coding challenges remind me of the undergrad problem sets I cut my teeth on, thanks Stan), but, as any working programmer can tell you, having to go back over working code and make it do something else is a much more realistic slice of coding life.
I’m no expert on AoC. 2024 was my first year, and there are people who have solved every problem for ten straight years at this point. But I did solve both parts of all 25 problems, so I wrote up these notes on my takeaways in general and my favorite 2024 problem in particular.
General Thoughts
Especially because of AoC’s surprise reveal structure, you’re generally better off taking a direct approach. Getting fancy, and especially trying to optimize prematurely, can land you in a world of regret. I fell prey to this a couple of times early this year.
However, once you get the second part of the problem, it is important to reconsider your approach. The closest I came to missing a second star was Day 13. My initial solution was a simple greedy algorithm, but it was unworkable for the second phase. I spent longer than I would like to admit trying to break the problem down into something that would still allow me to use my original solution. I had to step away and give myself a brain break before I realized that I was actually looking at a (quite simple) bit of linear algebra. Two equations, two unknowns, one embarrassed programmer.
I know a lot of people use AoC and other coding challenges as opportunities to learn new programming languages or to challenge themselves by using unconventional or even counterproductive tools and technologies. For example, bsky user Corvus completed AoC using GameMaker to generate some cool visualizations. I used Python, which is kind of easy mode, even if you restrict yourself to standard libraries, as I did. Maybe next year will be a good opportunity to venture into Go or Rust.
Honorable Mention - Swapped Ripple Carry Adder
Before I write about my favorite problem this past year, I want to give an honorable mention to Day 24. The first part of the problem involved computing the state of a network of binary logic gates (AND, OR, and XOR) based on a set of inputs. That’s a pretty straightforward setup.
Then came the twist. Part two revealed that the inputs were supposed to be interpreted as the binary digits (that is, the 1s and 0s) of two large numbers. The network itself was supposed to operate as an adder producing their sum (also in binary). However, somewhere in the network, five wires had been swapped, and the problem was to identify the five pairs which, when switched back, would make the adder work properly.
At this point, I noticed something interesting: there were only 222 gates in the network. That seemed small for a 45-bit adder, so I started working out how many gates you’d actually need. I found a five-gate module for handling each bit position with carries, and for the least significant bits, which don’t have a carry, two gates suffice. Adding it all up, 222 was exactly the right number. That couldn’t be a coincidence. Instead of searching blindly, I built a static analyzer that looked for that five-gate pattern for each cluster of input and carry bits and complained about the ones that didn’t match. I used that to swap the gates manually in the input file and ran it through my original solution to validate the results. Definitely not the fastest way to solve, but I’d like to think my old computer architecture professors would be proud.
My Favorite Problem - Imaginary Disassembly
The problem I enjoyed most this year was undoubtedly Day 17. The setup presented a simple machine language for a hypothetical 3-bit computer featuring eight instructions, three registers, and no addressable memory. The instruction set included:
A conditional jump
A modulo-8 operation
An operation to XOR two registers
A set of operations that divide registers by a power of 2
An output operation
The input consisted of the initial state for the machine and a sequence of numbers to be used as a program. Unsurprisingly, the first part of the problem involved writing a little emulator to generate the output stream for the program. As I was working on the first part, I couldn’t help thinking ahead to the second part. With no way to change the program or initial state, I honestly did not know what to expect until I submitted my solution (which, incidentally, would have made those computer architecture professors proud, because it worked on the first go).
Then came the reveal: the program was supposed to be a quine—a program that generates its own source code as output. However, one of the initial register values needed to be changed to get the desired behavior (which, if you’re being a stickler, is bending the rules a bit for a quine), and the task was to find the smallest working value. Of course, you could try to approach this by brute forcing the register value, but that’s going to break down pretty rapidly. So, I started by “disassembling” the program into assembly by hand. The resulting pseudocode looked like this:
B := A mod 8
B := B XOR 6
C := A / (2^B)
B := B XOR C
B := B XOR 4
OUTPUT B
A := A / 8
IF A != 0 THEN JUMP 0
The code processes the value in register A three bits at a time. Each iteration, it’s doing some bit munging between A’s three least significant bits and some of its other bits. Then it outputs the result of that munging, shifts A right by three bits, and repeats the loop until there’s nothing left.
So, the solution turns out to be pretty simple. The basic plan is to work backwards, building up a solution three bits at a time. The first iteration looks at which values of A from zero to seven will yield the final number in the program. Then, those values get shifted three bits to the left and the process is repeated for the next-to-last number, and so on.
Of all the AoC problems this year, this was the one that put the biggest smile on my face. It was clever, challenging, and ultimately not all that complicated. Thanks and congratulations to the organizers for ten years of challenges!
I’ve provided my actual code below. As proud as I am of it, I want to note that my priority was speed and efficacy. Under other circumstances, I would structure this code differently. I stored the emulator state in global variables for Part One, and it would have been a pain for me to do the right thing for Part Two and move that state into a structure that could be passed into the emulator for each run. So, no points for beauty here, but it did get the job done.
importsyssrc_reg_A=int(sys.stdin.readline()[12:])src_reg_B=int(sys.stdin.readline()[12:])src_reg_C=int(sys.stdin.readline()[12:])sys.stdin.readline()# Skip blank line
txt_program=sys.stdin.readline()[9:].rstrip().split(',')program=[int(x)forxintxt_program]reg_IP=0output=[]reg_A=Nonereg_B=Nonereg_C=NonedefresetState():globalreg_Aglobalreg_Bglobalreg_Cglobalreg_IPglobaloutputreg_A=src_reg_Areg_B=src_reg_Breg_C=src_reg_Creg_IP=0output=[]defdecode_combo(operand):val=Noneifoperand<4:val=operandelifoperand==4:val=reg_Aelifoperand==5:val=reg_Belifoperand==6:val=reg_Creturnvaldefdiv_A(operand):divisor=2**decode_combo(operand)returnreg_A//divisordefexecute(opcode,operand):globalreg_Aglobalreg_Bglobalreg_Cglobaloutputmatchopcode:case0:# adv
reg_A=div_A(operand)case1:# bxl
reg_B=reg_B^operandcase2:# bst
reg_B=decode_combo(operand)%8case3:#jnz
ifreg_A:returnoperandcase4:# bxc
reg_B=reg_B^reg_Ccase5:# out
output.append(str(decode_combo(operand)%8))case6:# bdv
reg_B=div_A(operand)case7:# cdv
reg_C=div_A(operand)defrunSim():globalreg_IPwhilereg_IP<len(program)-1:new_IP=execute(program[reg_IP],program[reg_IP+1])ifnew_IP!=None:reg_IP=new_IPelse:reg_IP+=2base_vals=[0]whilebase_vals:base=base_vals.pop()solutions=[]foriinrange(8):checkA=(base<<3)+iresetState()reg_A=checkArunSim()ifoutput==txt_program[-len(output):]:iflen(output)==len(txt_program):print(f'SOLUTION FOUND {checkA}')else:solutions.append(checkA)ifsolutions:base_vals.extend(reversed(solutions))