RogerBW's Blog

Advent of Code 2024 26 December 2024

I did Advent of Code again, and had a really good time again. Spoilers.

Language choices:

  • 2018: very beginner Rust, floundered a lot, gave up after five days.
  • 2019: Perl, completed
  • 2020: Rust, but switching to Perl after two days; completed
  • 2021: Rust, completed
  • 2022: PostScript, not quite completed
  • 2023: Rust, completed
  • 2024: Rust, completed

Although there are some fairly core bits of Rust I still don't understand, this year I felt comfortable with it more than I was learning new things; it remains the language I most enjoy writing in, especially when a program is more than trivial. (And I've taken to doing Weekly Challenges solutions in it first, then translating them to other languages.)

I didn't use winnow for parsing this time since I hadn't touched it since last year and when I started these I was also doing the last few Everybody Codes challenges too. Some days needed regexp parsing; most worked simply on string splitting.

I'll talk here about each day and my solutions. Should you want to follow along, my code is at Codeberg.

Day 1

A fairly straightforward start; the multiple spaces in the input surprised me slightly, but no problem parsing. I'm a little surprised to see a hash (hashmap, dict, associative array) needed on day 1, but I'm old enough in wickedness that they still seem like a neat new thing to me.

Day 2

The first approach that came to mind was to establish a set of differences between adjacent entries. Then check all diffs for possible failure states:

  • abs(diff) < 1
  • abs(diff) > 3
  • diff × (first diff) < 1

the last one picking up a change in direction.

Part 2 still needs to check the full report, because a report that's safe in full may not be safe with one entry removed. So I moved the checker out into a function.

Day 3

Well, I'm glad I'm not doing this in PostScript this year. Yeah, it's obviously possible to do this kind of thing without regular expressions, but it's very tedious.

And I know, I should write a regexp engine in PostScript.

I don't know the Rust regexp library as well as I might, so part 2 ended up being in two stages: one to match a valid operator definition (do, don't, or mul), the second to extract the parameters from the mul.

Day 4

I was able to recycle code for part 1 from a The Weekly Challenge problem that hadn't been published yet when this came out. (Though having to stick to a single direction, and a guarantee of no repeated letters in a word, meant I could remove many of the checks.)

Then for part 2 it got simpler again. Rather than run the part 1 code and analyse the results, I just look for each A, then examine its diagonal neighbours in clockwise order. If there are four of them, then a valid set will be MMSS, SMMS, etc.; so I double the string and search for MMSS in it.

Day 5

"Aha", I thought, "topological sorting, I know this". But actually no, because at least my main puzzle input has cycles in it if you try to resolve it completely (e.g. A before B, B before C, C before A), which is fine if you never have all of A, B and C in the same update (as you don't here), but if you do a full toposort on the input, it can't complete.

So instead a priority pair is a Map of earlier to a Set of later. (I used this pattern quite a bit this year, for many to many mappings.) For part 1, I check each pair of pages in the update for orderedness: if the later one doesn't appear in a priority list, or the earlier one doesn't appear in the later one's priority list, they are at least not out of order.

For part 2, I write a custom sort function. checking both page numbers and returning any ordering information we can find.

Day 6

Part 1: I had some direction code from last year's day 21, so I recycled some of that.

Part 2: the only places I try obstacles are on the traversed path, and then it's just a matter or storing (location, direction) tuples to find a repeat.

Day 7

That was rather fun. Though I confess I stumbled a bit in implementing an operator priority order version first, rather than left to right.

Part 1 was obviously a job for a bitmask (each interstitial place holds either a * or a +), but for part 2 I used the excellent multi_cartesian_product in itertools, which seems built for situations like this. Then all that was needed was a reasonably effective concatenation function; even "0" should produce a ×10 for the first operand, because 12 || 0 would be 120.

Day 8

I'm starting to think that the most important thing about parsing a grid input is that one doesn't necessarily put it into a list of lists. Today it was a HashMap of char to Vec of coordinates, thus for the test input:

{'0': [(8, 1), (5, 2), (7, 3), (4, 4)], 'A': [(6, 5), (8, 8), (9, 9)]}

Then it's a matter of taking every pair of coordinates in each frequency set and extending (and checking that they lie inside the grid).

Part 2 is much the same, only with a two-sided "repeat until you fall off the edge".

Day 9

Part 1 uses a pair of moving pointers, stopping when they cross over.

For part 2 I went for Range rather than tracking each individual block, which worked reasonably well. I know that removing an entry from a Vec is expensive, but it looks as though this was slightly faster than leaving an empty range to be tested against each future file.

Then it's just a matter of checking for the case where the start of the file is already earlier than the block it might fit into. (Grr.)

Day 10

Several people accidentally solved part 2 while writing a naïve solution to part 1. Not I: I threw part 1 at a standard Dijkstra, which worked beautifully, but the implementation I'm using doesn't give you all the distinct paths, only one shortest path. (The pathfinding crate can actually do this, but not in its dijkstra function.) I could have dug into the details for part 2, but it seemed easier to write a DFS.

Day 11

Now that's a classic AoC. Part 1 is a nice solid list manipulator, and obviously I think about maybe using linked lists (this is usually an error, as in this case) but a standard vector will handle it no problem.

Then part 2 will clearly explode, but… I don't need to do the calculations repeatedly. Each separate "1000" will become a separate "10 00" pair. So I just need a count of how many 1000s I had…

This is basically the same solution as 2021 day 6, "Lanternfish".

Day 12

First one this year for which part 1 seemed more than trivial, though of course I may be misremembering. My algorithm, given the grid, is:

  • initialise startpoints to all valid locations

  • infinite loop while startpoints is non-empty

  • start at a random point in startpoints

  • find all its neighbours with same letter

  • put all of these in region set

  • for each point in region, in all directions, add 1 to Perimeter if there is no member of region in that direction.

  • area is number of points in region

  • delete region from startpoints

For part 2 obviously I remove the perimeter code, Instead, file each failing edge (what could previously have been a perimeter segment) under the direction I was traversing in.

Then for east- and west-facing edges, segment the list by equal X coordinate (these edges might in theory form a single side), sort by Y coordinate, and scan the list for gaps, which add to the side count.

Similarly for north and south with X and Y reversed.

Day 13

This is not a computing problem, it's a maths problem. It looks like an exhaustive search BUT.

Known values:

  • (ax, ay) are the numbers for button A.
  • (bx, by) are the numbers for button B
  • (x, y) are the numbers for the prize

Unknown values:

  • (a, b) are the number of presses of a and b respectively

so the result at prize point is:

  • x = a * ax + b * bx (1)
  • y = a * ay + b * by (2)

And everything except a and b are known.

So we have two equations in two variables. Which means there is only one solution. Let's rearrange:

  • x * ay = a * ax * ay + b * bx * ay (3) (1) * ay
  • y * ax = a * ay * ax + b * by * ax (4) (2) * ax
  • x * ay - y * ax = b * (bx * ay - by * ax) (5) (3) - (4)
  • b = (x * ay - y * ax) / (bx * ay - by * ax) (6) rearrange (5)
  • a = y - b * by / ay (7) rearrange (2)
  • cost = 3 * a + b (8)

Never mind the minimum, there is only one value for costxs for each machine. (Visually, consider the construction of a vector from zero to the desired endpoint using only the vectors A and B; the counts of each must be fixed, and the order won't matter. If any parameter could e negative, that would be another matter.)

However a and b can only have integer values, so I undo each division to make sure it gets the right result.

Day 14

For part 1 it seemed most straightforward to build a position output for N cycles rather than traversing one at a time. (Note that Rust has the rem_euclid() method for a non-negative modulus.)

For part 2 I found the image crate and generated a series of images to skim through. It became clear that there were two repeating elements: several horizontal elements appeared every 103 frames (not starting at zero, of course) and several vertical elements every 101 frames. That let me generate formulae for a frame number in which each type of element would occur, and finally I brute-force intersect those to get a valid answer.

Day 15

Nice to see a grid problem that isn't pathfinding. Part 1 was fairly straightforward, particularly once I noticed I only had to change three cells (where the robot was, where the robot will be, the empty space at the end of the row).

Part 2 threw away that optimisation of course, and I ended up building a stack of "movers", spaces from which a move will be made. Items are added to the stack based on contents of this space and, potentially, contents of the next space (but avoiding duplicate entries); there is probably a better way to do this than the one I found, which seems inefficient. Then I run over the movers list in reverse to copy their contents to the destination space.

Day 16

Part 1 can be handled by extending the grid into the third dimension: direction on entry is part of the node identifier. (I did something similar for 2023 day 17 "Clumsy Crucible".) So from a node where you're facing east you can move east (cost 1), turn north (cost 1000) or turn south (cost 1000). but to the pathfinding algorithm these are all separate nodes.

For part 2, horrors, Dijkstra doesn't have a way of outputting all best paths. But A* does. So all I need is a heuristic (Manhattan distance is fine, I ignore directions here), then I simply extract all best paths and count the distinct node coordinates (ignoring direction).

Day 17

I liked Intcode. (It was in my first successful Advent of Code in 2019.) This was less fun but still good.

Part 1 is a fairly straightforward interpreter.

Part 2 is obviously a bit more work but after inspecting the program I solved it with an incremental search. The last value depends only on the top (most significant) three-bit tuple of the input. The next-to-last value depends on those and the next three. And so on.

So I build the input from a notional vector of 3-bit numbers. First search 0 to 7 for a match in the last place of the output to get the last three bits. Then multiply that by 8, and search that plus 0 to 7 to get the next three, And so on.

Day 18

I was expecting this to be a path traversal problem with a changing graph: take one step, a block falls, etc. But it wasn't that at all. Might be fun though.

Part 1: I build a full grid, take out the relevant number of nodes, then Dijkstra what's left.

Part 2: I move the Dijkstra into the grid loop and break when it fails. This takes a bit longer, but still only about three seconds so it doesn't seem worth optimising further. (The obvious approach would be a binary chop: the grid at time 0 is passable, at time 2000 it isn't, so try it at time 1000, etc.)

Day 19

This was clearly a memoisation problem. Rust has memoize for this, but I had great trouble getting it to work at first, so for part 1 I did it by hand.

Part 2 choked up with that; I ported my code to Perl, added Memoize, and it worked perfectly. So then I worked back and removed all the things that seemed to be giving Memoize trouble until it finally worked. (Oh boy a global static variable for towels would have been easier, though.)

Day 20

This was quite tricky, firstly because I misunderstood part 1 to mean that you could remove two walls in succession. Originally I was going to re-Dijkstra each case to look for savings. But since there is only one possible path, any cheat must start and end on that path… so I don't need to search the whole grid.

But with a lot of flailing around my correct part 1 is more complicated than it needs to be. Take every point on the path, find the neighbours that are walls, find the neighbours of that that are on the path, make sure they are later on the path, make sure we haven't seen them before, then calculate the saving.

Part 2, with a bit more thought, came out much more cleanly: take every ordered pair of path points (and their indices in the path). Calculate the Manhattan distance between them. If it's less then the difference in indices, and no higher than 20, that's a valid saving.

Day 21

Total enthusiasm failure on this one (it happens sometimes). Lots of flailing about with special cases. I thought I was being so clever setting both incomplete rows to be row 0, but one is at the top and the other is at the bottom of the pad…

But at least my part 2 only needed me to change one parameter from part 1 and memoise it.

Day 22

Ah now that's a bit more like it.

Part 1 is a fairly straightforward shift-mod sequence.

Then part 2 is an exhaustive search, but I save some time by only storing the information I need: for each starting number, the first time I see a particular changeset, I add its value to the total for that changeset. Then it's just a matter of pulling out the highest total. For later calculations, it doesn't matter which or how many changesets participate, so I don't store that information.

Day 23

Quite a lot of false starts on this one; for part 1, I thought the question was asking for sets of three that weren't connected to the larger network and tried to use graph partitioning.

Then for part 2 it became clear that pathfinding wasn't going to be a consideration, but my order-free sets turned out to be the wrong approach. In the end I work internally on the node indices (assigned ad-hoc as they come up in the connection list), and when considering something to add to an existing set only look at nodes with higher indices than any of those already in the set.

(In fact this is the clique problem and there are better algorithms for it than the one I invented.)

Also it's much easier in Rust to encode the node names as base-36 numbers, which can be transparently copied, than to work with strings, which can't.

Day 24

Ooh, now this was a really enjoyable one.

Part 1 was quite fun in itself, mostly in terms of deriving a reasonably efficient data structure. (All right, I'm not sure I might not have done better by deleting gates after they were activated. Or built a queue. Or something.)

Part 2 came apart into three programs.

Given that this is a reasonably efficient binary adder, any Z value must be the result of an XOR. Any gate that doesn't produce a Z value, and doesn't take an X or Y value as input, cannot be a XOR. So any gate that doesn't satisfy those constraints is clearly dodgy. ba lists those. I note that I have six wrong nodes out of the eight I need. With a bit more analysis I could have sorted out these pairs automatically, but…

bb takes the input, applies any swaps, and produces a GraphViz file which I then plot with neato. Given those swappablel nodes from ba, I can see by looking at the output which nodes are near each other and therefore need to be exchanged. (Populating swaps lets me redraw the diagram to check.)

Just one more swap to find. bc steps through possible input values: in binary, 1 + 1 = 10, 10 + 10 = 100, etc. It stops when it gets a wrong answer. (I might have used more complex patterns involving the incoming carry, e.g. 11 + 11 = 110, but at least with my input I didn't have to.) That shows me where in the diagram I need to look, and thus what the fourth swap pair should be. And the first thing bc does is check that the official inputs add up correctly, so it's a verifier for the final pair too.

Day 25

Well, clearly this is more a parsing problem than a coding problem. But it's a sort of parsing problem I've done a lot before.

Conclusions

I didn't end up using flashy new techniques this year, just built with the Rust I already had. Mostly the hard bits were writing a good algorithm; days 15 and 21 involved lots of worrying away at special cases.

Crates used:

  • regex (5 times, input parsing)
  • pathfinding (4 times)
  • counter (3 times, not a necessary crate but a handy one)
  • radix_fmt (twice, for turning numbers back to base-36 strings)
  • memoize (twice)
  • itertools (once)
  • image (once)

(I've written Dijkstra before. I've written it in PostScript! So I'm happy to use a library for it now.)

My revised list of things to be able to do in your language of choice if preparing for an AoC or Everybody Codes:

  • string parsing, with regexps or combinators or whatever works, especially breaking down a string into specific arguments;

  • multidimensional arrays (or whatever your language uses to substitute for them);

  • more generally than that, coordinates. Yeah, a basic list of rows each containing a list of cells will work, but it would have been handy to have a prepared way of dealing with directions, direction changes, bounds checking, and so on. I still haven't actually written a Grid class, because it gets tweaked quite a bit for each puzzle;

  • depth-first and breadth-first search;

  • Dijkstra or A* pathfinding including edge cases like multiple best paths. Probably graph theory in general really.

  • General algebra, usually seems to come up at least once.

Some regulars that haven't been about this year or last:

  • Conway's Life;

  • Chinese Remainder Theorem.

Puzzles sorted by Part 2 difficulty: this percentage is the proportion of people who have successfully solved part 1, but not part 2, which is the only public information about completion rates. (Based on a snapshot at the time of writing, so values may have changed.)

  • 10: 2%
  • 18: 2%
  • 8: 4%
  • 7: 5%
  • 1: 7%
  • 4: 8%
  • 14: 10%
  • 19: 10%
  • 3: 10%
  • 13: 11%
  • 23: 12%
  • 5: 12%
  • 11: 14%
  • 20: 15%
  • 22: 15%
  • 9: 16%
  • 16: 18%
  • 2: 20%
  • 12: 21%
  • 21: 22%
  • 15: 23%
  • 6: 26%
  • 17: 30%
  • 24: 47%

(Day 25 doesn't count, as part 2 is essentially "have the other 49 stars".)

That was not entirely consistent with my experience; 17 and 24 were good fun though I grant 24 was a bit of work, and 6 didn't trouble me much, while 21 was the one I found hardest. (For me "hard" means "I don't really know what to do next" rather than "I know what to do but it'll take a while".)

Thanks as always to Eric Wastl and the AoC team.

Add A Comment

Your Name
Your Email
Your Comment

Your submission will be ignored if any field is left blank, but your email address will not be displayed. Comments will be processed through markdown.

Search
Archive
Tags 1920s 1930s 1940s 1950s 1960s 1970s 1980s 1990s 2000s 2010s 2300ad 3d printing action advent of code aeronautics aikakirja anecdote animation anime army astronomy audio audio tech base commerce battletech bayern beer boardgaming book of the week bookmonth chain of command children chris chronicle church of no redeeming virtues cold war comedy computing contemporary cornish smuggler cosmic encounter coup covid-19 crime crystal cthulhu eternal cycling dead of winter doctor who documentary drama driving drone ecchi economics en garde espionage essen 2015 essen 2016 essen 2017 essen 2018 essen 2019 essen 2022 essen 2023 essen 2024 existential risk falklands war fandom fanfic fantasy feminism film firefly first world war flash point flight simulation food garmin drive gazebo genesys geocaching geodata gin gkp gurps gurps 101 gus harpoon historical history horror hugo 2014 hugo 2015 hugo 2016 hugo 2017 hugo 2018 hugo 2019 hugo 2020 hugo 2021 hugo 2022 hugo 2023 hugo 2024 hugo-nebula reread in brief avoid instrumented life javascript julian simpson julie enfield kickstarter kotlin learn to play leaving earth linux liquor lovecraftiana lua mecha men with beards mpd museum music mystery naval noir non-fiction one for the brow opera parody paul temple perl perl weekly challenge photography podcast politics postscript powers prediction privacy project woolsack pyracantha python quantum rail raku ranting raspberry pi reading reading boardgames social real life restaurant reviews romance rpg a day rpgs ruby rust scala science fiction scythe second world war security shipwreck simutrans smartphone south atlantic war squaddies stationery steampunk stuarts suburbia superheroes suspense television the resistance the weekly challenge thirsty meeples thriller tin soldier torg toys trailers travel type 26 type 31 type 45 vietnam war war wargaming weather wives and sweethearts writing about writing x-wing young adult
Special All book reviews, All film reviews
Produced by aikakirja v0.1