advent of code 2025

how far can I get in Julia?

Julia
puzzles
Author

Domenic Di Francesco

Published

December 17, 2025

TLDR

Advent of code is a website created by Eric Wastl, releasing a (increasingly challenging) programming puzzle each day. This year we will not be receiving the usual 25 days worth (I have never made it that far anyway), but it will instead be finishing on 12th December (see announcement on Reddit). This is a welcome change of pace for me.

Spoiler alert: super un-optimised, but hopefully readable Julia solutions ahead, so you may only want to look after you have had a go yourself. Let’s see how we go…

Play along: https://adventofcode.com


Day 1: a password from a decoy safe 🔓

So we have this safe, with a dial set to 50, and a sequence of turns to make, see below:

seq = ["L68", "L30", "R48", "L5", "R60", "L55", "L1", "L99", "R14", "L82"]

Here an “R” means we are turning the dial to the right, and an “L” means we are turning it to the left, followed by the number indicating how many turns. “L68” means turn the dial left 68 times. The dial wraps around, so if we turn left from 0, we go to 99, 98, and so on.

We are asked:

  • part 1: how many times do we end up on zero after a turn?
  • part 2: how many times do we turn through zero in total?

Floor division fld() will count how many times we lap around zero (and in which direction).

function get_passwords(seq::Vector{String}, dial_start::Int = 50, dial_mod::Int = 100)

    dial = dial_start; pass_zeros = 0; end_zeros = 0

    for turn  seq
        direction = turn[begin]; turns = parse(Int, turn[2:end])
        
        if direction == 'R'

            pass_zeros += fld(dial + turns, dial_mod)
            dial += turns

        elseif direction == 'L'

            # we need to catch the case where the dial starts at 0 and not count that first turn
            pass_zeros -= fld(dial - turns - 1, dial_mod) - fld(dial - 1, dial_mod)
            dial -= turns
            
        end

        dial = mod(dial, dial_mod)

        if dial == 0
            end_zeros += 1
        end
    end

    return (end_zeros=end_zeros, pass_zeros=pass_zeros)
end
get_passwords (generic function with 3 methods)

A nice opener, and I did make a mistake in my first guess for part 2, forgetting the case where the dial starts on zero and we turn left. We shouldn’t count that first turn as going through zero, and so I had to add the start and end points, with that -1 offset.

It appears I wasn’t the only one…

[2025 Day 1 Part 2] Public Service Announcement
byu/StaticMoose inadventofcode
seq |> get_passwords
(end_zeros = 3, pass_zeros = 6)

⭐⭐

Day 2: fake IDs at the gift shop 🎁

We’re given these ‘product IDs’ from the gift shop, and we need to sort through them to find which ones are invalid. Problematic IDs that we are looking for are made up exclusively of repeating sequences of numbers.

product_ids = ["11-22", "95-115", "998-1012", "1188511880-1188511890", "222220-222224",
        "1698522-1698528","446443-446449","38593856-38593862","565653-565659",
        "824824821-824824827","2121212118-2121212124"]

In part 1, we were looking for a single repeated sequence, so the first half of the ID is the same as the second half. Then in part 2, the length of that sequence could change. Here’s my solution:

function is_invalid(id::Int; part_1::Bool = false)
    
    id_string = string(id); midpoint = length(id_string) ÷ 2

    if part_1
        return id_string[begin:midpoint] == id_string[midpoint+1:end]
    end

    for pattern_length  1:midpoint 
    # a pattern more than half the length can't repeat
        if id_string == id_string[begin:pattern_length] ^ (length(id_string) ÷ pattern_length)
        # is the id made *only* of this repeated pattern?
            return true
        end
    end

    return false
end
is_invalid (generic function with 1 method)

The puzzle itself felt simpler than yesterday, but only after I read the description a few times. I initially thought that we were going to be asking to find IDs that contained some (any) repeated sequence, but knowing that the entire ID had to be a repetition simplified things.

invalid_ids = Int[]
for id  product_ids

    parts = split(id, "-")
    id_start = parse(Int, parts[begin]); id_end = parse(Int, parts[end])

    for id  id_start:id_end
        if is_invalid(id)
            push!(invalid_ids, id)
        end
    end

end 

invalid_ids |> sum
4174379265

BenchmarkTools.jl tells me this runs in 459.768 ms, with lots of allocations 606.35 MiB, presumably due to all of the string splitting, concatenation, and going back and forth to integers. I don’t think I’ll have time to think about smarter ways to do this, but I’m sure I’ll be forced to in the coming days. Happy to be brute-forcing for now.

My young children happened to wake me up super early today, so I will have scored highly on the leaderboard, but I’m in it for the puzzles, not the glory.

⭐⭐

Day 3: maximum joltage ⚡

We find batteries, each with their own joltage rating, arranged in banks (rows).

Going from left to right, which batteries should we connect up to get the maximum joltage possible from each row?

joltages = [
    "987654321111111"
    "811111111111119"
    "234234234234278"
    "818181911112111"
    ]

For instance, if we were asked to connect 4 batteries in the third row, we can’t make a 4 digit number higher than 4478 (selecting batteries 3 (joltage = 4), 6 (joltage = 4), 14 (joltage = 7), and 15 (joltage = 8)).

In part 1 we were asked to select 2 batteries from each bank, and in part 2 we were to select 12. Here’s a solution:

function max_joltage_for_bank(bank::String; n_batteries::Int = 2)

    digits = parse.(Int, collect(bank)) 

    max_joltage = []; index = 1

    for k  1:n_batteries

        digits_needed = n_batteries - k
        search_end = length(digits) - digits_needed

        max_val = -1; max_idx = -1
        for i  index:search_end
            if digits[i] > max_val
                # keep track of the biggest digit in this range and it's index...
                max_val = digits[i]; max_idx = i
            end
        end

        # ...so we can update the new starting point
        index = max_idx + 1
        push!(max_joltage, max_val)
    
    end

    return max_joltage |> join |> x -> parse(Int, x)
end
max_joltage_for_bank (generic function with 1 method)

This is based on selecting the maximum digit we can, whilst still leaving space for however many remaining batteries we need to select.

joltages |> x -> max_joltage_for_bank.(x, n_batteries = 12) |> sum
3121910778619

Don’t forget to then update your starting index accordingly, rather than just adding 1, otherwise you’ll keep selecting the same battery over and over - although seeing this helpded me fix it.

Check out some smarter solutions than mine:

-❄️- 2025 Day 3 Solutions -❄️-
byu/daggerdragon inadventofcode

⭐⭐

Day 4: Dunder Mifflin warehouse 📄

There are only so many types of AoC puzzles, and today I recognised bts of code I write every year. Namely, checking where I can safely move in in a grid while staying within bounds.

We’re given the layout of warehouse floor - specifically, where a reams of paper are located @, and where there is an empty space ..

grid = [
    "..@@.@@@@."
    "@@@.@.@.@@"
    "@@@@@.@.@@"
    "@.@@@@..@."
    "@@.@@@@.@@"
    ".@@@@@@@.@"
    ".@.@.@.@@@"
    "@.@@@.@@@@"
    ".@@@@@@@@."
    "@.@.@@@.@."
    ]

We are asked:

  • part 1: If we can only move reams with less than 4 neighbouring reams - how many can we move?
  • part 2: Once we’ve removed them, we might be able to move more. How many are left that can never be moved?

Here’s a solution:

function find_accessible(warehouse::Matrix{Char}; n_aux::Int = 4)

    n_rows, n_cols = size(warehouse); labelled_warehouse = copy(warehouse)
    
    # all 8 directions: (row_offset, col_offset)
    directions = [
        (-1, -1), (-1, 0), (-1, 1),
        ( 0, -1),          ( 0, 1),
        ( 1, -1), ( 1, 0), ( 1, 1)
    ]
    
    for row  1:n_rows
        for col  1:n_cols
            
            warehouse[row, col] != '@' && continue # we're only checking the papers
            
            neighbouring_papers = 0
            for (Δr, Δc)  directions
                check_row, check_col = row + Δr, col + Δc
                
                if 1  check_row  n_rows && 1  check_col  n_cols # stay in the grid!
                    if warehouse[check_row, check_col] == '@'
                        neighbouring_papers += 1
                    end
                end
            end
            
            if neighbouring_papers < n_aux
                labelled_warehouse[row, col] = 'x'
            end
        end
    end
    
    return labelled_warehouse
end
find_accessible (generic function with 1 method)

grid |> 
    lines ->  [lines[r][c] for r  eachindex(lines), c  1:length(lines[1])] |>
    warehouse -> count(==('x'), find_accessible(warehouse))
13

Not super elegeant. I replace the reams that can be moved with an x and then count them. It was important to make this change to a copy of the warehouse grid, otherwise we would be changing the grid as we go, and thinking there are less neighbouring reams than there actually are.

…and for part 2, we can just keep calling this function until there are no more reams that can be moved:

function remove_all_reams(grid::Matrix{Char})
    labelled_warehouse = copy(grid)
    while true
        new_labelled_warehouse = find_accessible(labelled_warehouse)
        new_labelled_warehouse == labelled_warehouse && break
        labelled_warehouse = new_labelled_warehouse
    end
    return count(==('x'), labelled_warehouse)
end

loevely visualisation on the reddit of clearing out the warehouse:

[2025 Day 4 Part 2]
byu/EverybodyCodes inadventofcode

⭐⭐

Day 5: fresh ingredients 🥗

Part 2 got me today.

Our inputs are a list of ranges, followed by ingredient id’s.

ingredients_db = [
    "3-5"
    "10-14"
    "16-20"
    "12-18"

    "1"
    "5"
    "8"
    "11"
    "17"
    "32"
]

An ingredient is fresh if its id falls within any of the ranges given. After a little pre-processing, we can collect all of the ranges that fresh ingredient fall into

…because we suspect we will need that for part 2, even though they end up going in a different, more complicated direction.

fresh_ingredients = Dict{Int, Vector{UnitRange{Int}}}()
for i  ingredients
    for r  ranges
        if i  r
            push!(get!(fresh_ingredients, i, UnitRange{Int}[]), r)
        end
    end
end

and then returning the number of fresh ingredients.

fresh_ingredients |> length
3

Part 2 instead asks to find the number of possible fresh ingredients i.e. how many unique ingredient IDs fall within any of the given ranges.

…and in the real input data, the ranges were large, and overlapped. So, in cases where there was overlap, I combined them into a single new range (with the smaller of the start values, and larger of the end values).

sorted_ranges = sort(ranges, by = first)

fresh_ingredients_merged = [sorted_ranges[begin]]
for range  sorted_ranges[2:end]
    # start a new range when the lower value is outside the largest value of the merge
    if last(fresh_ingredients_merged[end]) < first(range)
        push!(fresh_ingredients_merged, range)
    else
    # the start of the new range stays the same, but the end needs to be extended if it's bigger for the new range
    new_max_range = [last(fresh_ingredients_merged[end]), last(range)] |> maximum
    fresh_ingredients_merged[end] = first(fresh_ingredients_merged[end]):new_max_range
    end    
end

…and then summed the lengths of the now non-overlapping ranges (without duplicate ingredient IDs):

[length(x) for x  fresh_ingredients_merged] |> sum
14

⭐⭐

Day 6: Cephalopod Maths 🐙

I googled cephalopod - it means a creature with a head and tentacles.

In the advent of code universe, they get maths homework, and this is what it looks like:

sums = [
    "123 328  51 64 ",
    " 45 64  387 23 ",
    "  6 98  215 314",
    "*   +   *   +  "
]

This is asking us to apply the operator in the last row, to the numbers above it. In part 1, the numbers above the first (leftmost) * are 123, 45, 6. In part 2 we are told the numbers are written in coluns (not rows) so we instead have to multiply 356, 24, and 1.

So the difference is in fiddling the parsing of the numbers. Here’s a solution:

function solve_sums(lines::Vector{String}; part_2::Bool = false)
    
    n_cols = lines[end] |> length
    # need to break until the next sum when there is a column of spaces
    no_sum = [all(line[c] == ' ' for line  lines) for c  1:n_cols]

    results = Int[]; col = 1
    while col <= n_cols
        # skip the separator columns that split the sums
        while col <= n_cols && no_sum[col]
            col += 1
        end
        col > n_cols && break
        
        # otherwise we start a new sum
        start_sum = col
        
        # and keep going until the next separator
        while col <= n_cols && !no_sum[col]
            col += 1
        end

        # the operator is always in the first col of each sum, in the last row
        op = lines[end][start_sum]
        
        if part_2
            # pt.2: each column is a number (read top-down)
            numbers = Int[]
            for nums_col  start_sum:col-1
                digits = [line[nums_col] for line  lines[1:end-1] if line[nums_col] != ' ']
                if !isempty(digits)
                    push!(numbers, parse(Int, String(digits)))
                end 
            end
        else
            # pt.1: each row is a number, but they can have leading spaces
            numbers = [parse(Int, strip(line[start_sum:col-1])) for line  lines[1:end-1]]
        end
        
        # an excuse to use Julia metaprogramming :)
        push!(results, op |> Symbol |> eval |> x -> reduce(x, numbers))
    end
        
    return results
end
solve_sums (generic function with 1 method)

Not completed on the day for the first time, but life is very hectic.

Oh, and an excuse to use Julia metaprogramming! We could have put in some conditional logic to take products when the operator is * and sums when it’s +, but instead we can directly convert to a symbol and evaluate it. Not at all required for this problem, but hey…

And then add up the results:

sums |> solve_sums |> sum
4277556

I’m inferring from the below meme, but I guess some people created matrices for all the numbers in each sum, rather than just strip the whitespace 🤔

Neat - wish I did it that way 🤷🏻

[2025 Day 6 (Part 2)] The solution be like…
byu/HotTop7260 inadventofcode

⭐⭐

Day 7: Tachyon beam splitting ⚛️

After yesterday, we’ve got 12 ⭐’s on the board

we’re 1/2 way there [Jon Bon Jovi voice]

A beam of tachyons, | flows from a starting point, S down a grid. When it hits a splitter, ^, two beams emerge (one either side).

tachyon_manifold = [
    ".......S.......",
    "...............",
    ".......^.......",
    "...............",
    "......^.^......",
    "...............",
    ".....^.^.^.....",
    "...............",
    "....^.^...^....",
    "...............",
    "...^.^...^.^...",
    "...............",
    "..^...^.....^..",
    "...............",
    ".^.^.^.^.^...^.",
    "..............."
]

In part 1, we are asked how many times the beam is split, and in part 2 we are asked how many parallel timelines (possible paths) exist for the beam.

Here’s my solution:

function analyse_beams(beam_rows::Vector{String})

    n_rows = beam_rows |> length
    n_cols = beam_rows[end] |> length
    grid = [beam_rows[row][col] for row  1:n_rows, col  1:n_cols]

    # find starting position
    start_pos = findfirst(==('S'), grid)
    start_row, start_col = start_pos[1], start_pos[2]

    # initialise beams and timelines...
    splits = 0; timelines = Dict(start_col => 1)
    
    for row  (start_row + 1):n_rows
        # ...and then keep track of new ones
        new_timelines = Dict{Int, Int}()
        
        for (col, count)  timelines
            if grid[row, col] == '^'
                # for part 1, we count the splits
                splits += 1 
                # for part 2, we "do" the split and count the timelines (add beams to the left and right - after an in-bounds check)
                col - 1 >= 1 && (new_timelines[col - 1] = get(new_timelines, col - 1, 0) + count)
                col + 1 <= n_cols && (new_timelines[col + 1] = get(new_timelines, col + 1, 0) + count)
            else
                new_timelines[col] = get(new_timelines, col, 0) + count
            end
        end

        timelines = new_timelines

    end

    return ("splits" => splits, "timelines" => timelines)
end
analyse_beams (generic function with 1 method)

This is a tidied up version, because I was initially separately tracking a variable beams, which I ended up separately recording in my part 2 dictionary. Also, I’ve got no idea if I needed the in-bounds checks (where I discount any beams that stray off the edges of the tachyon manifold).

tachyon_manifold |> analyse_beams
("splits" => 21, "timelines" => Dict(5 => 10, 13 => 1, 15 => 1, 7 => 11, 11 => 2, 9 => 11, 12 => 1, 3 => 2, 1 => 1))

Day 8: Connecting junction boxes 🔌

We are given 3-d (x, y, z) coordinates of electrical junction boxes and ask to connect the closest ones (shortest Euclidean distance) together to form circuits.

junction_boxes = [
    "162,817,812",
    "57,618,57",
    "906,360,560",
    "592,479,940",
    "352,342,300",
    "466,668,158",
    "542,29,236",
    "431,825,988",
    "739,650,466",
    "52,470,668",
    "216,146,977",
    "819,987,18",
    "117,168,530",
    "805,96,715",
    "346,949,466",
    "970,615,88",
    "941,993,340",
    "862,61,35",
    "984,92,344",
    "425,690,689"
]

The key insight: We don’t need to track every box in each circuit, just whether two boxes share a common “root”. This is a Union-Find (∪ + 🔎) problem.

Each box starts as it’s own standalone circuit. When we connect two boxes, one joins the other’s (the root’s) circuit. To check if two boxes are in the same circuit, we can then follow the chain and check if they share the same root.

coords = junction_boxes |>
    boxes -> split.(boxes, ",") |>
    boxes -> [parse.(Int, b) for b  boxes]

# each box starts as its own root
n_boxes = length(coords); parent = collect(1:n_boxes)

# follow the chain upward to find the root
function find_circuit_root(box_index)
    while parent[box_index] != box_index
        box_index = parent[box_index]
    end
    return box_index
end

# connect two circuits by making one root point to the other
function connect_boxes!(box_from, box_to)
    root_from = find_circuit_root(box_from)
    root_to = find_circuit_root(box_to)
    
    # already in same circuit?
    root_from == root_to && return false
    
    # otherwise => merge
    parent[root_from] = root_to
    return true
end

Here I check the distance between every pair of boxes, and arrange shortest to longest:

distances = []
for box  1:n_boxes
    for alt  (box+1):n_boxes  # avoid double-counting pairs
        dist = (coords[box] .- coords[alt]).^2 |> sum |> sqrt
        push!(distances, (dist = dist, from = box, to = alt))
    end
end
sort!(distances, by = first)

Part 1 asks us to attempt to connect the 1000 shortest connections and part 2 asks wants us to keep going until we end up with 1 big circuit and a final connection. Here’s my solution:

function size_circuits(distances; n_attempts::Union{Int, Nothing} = nothing)
    
    # each box starts as is its own circuit
    parent = collect(1:n_boxes)
    last_connection = nothing

    for (attempt, d)  enumerate(distances)
        
        if connect_boxes!(d.from, d.to)
            last_connection = d
        end

        # part 1: stop after N attempts
        if !isnothing(n_attempts) && attempt >= n_attempts
            break
        end
        
        # part 2: stop when everything is connected
        roots = [find_circuit_root(i) for i  1:n_boxes]
        length(unique(roots)) == 1 && break
    end

    roots = [find_circuit_root(i) for i  1:n_boxes]
    circuit_sizes = [count(==(r), roots) for r  unique(roots)]
    sort!(circuit_sizes, rev=true)

    return Dict("circuit_sizes" => circuit_sizes, 
                "last_connection" => last_connection)
end
size_circuits (generic function with 1 method)

There are some lovely visualisations of the growing mega-circuit of part 2:

[2025 Day 8 (Part 2)] Visualisation
byu/Derailed_Dash inadventofcode
size_circuits(distances)["last_connection"] |>
    last -> coords[last.from][1] * coords[last.to][1]
25272

The puzzles are getting tricky. This is possibly the first one that can’t be brute-forced? My naive approach of adding new boxes to sets of circuits was pretty slow (O(n)). This is also the first puzzle I was unable to solve them on the day of release due to other commitments, but I’m not fussed about that.

⭐⭐

Day 9: Red and Green tiles 🟥 🟩

We are provided with coordinates (in 2-d today) of red tiles:

red_tiles = [
    "7,1",
    "11,1",
    "11,7",
    "9,7",
    "9,5",
    "2,5",
    "2,3",
    "7,3"
]

We are asked to find the largest (by area) rectangle that can be made, if we took two red tiles as opposite corners. Part 1 didn’t have any further restrictions and so was one of the simplest stars to get so far this year. Part 2 was not…

In part 2, we connect sequential red tiles using green tiles. We then have a border of green tiles since, adjacent tiles are always in the same row or column (thankfully!) and we also fill in the inside of this shape with green tiles. The large rectangles we are trying to form cannot include any coordinates that are not either red or green tiles.

How do we know which tiles are “inside” the loop?

I think there are some clever ways to do this by flooding the outer grid with empty tiles or the inner grid with green tiles, until we hit the border.

I used a simpler approach. For each empty tile, walk left and count how many times you cross vertical boundaries. After an odd number of crossings, you must be inside (green tile). 🪄 Trick: be carefull to ignore horizontal boundaries, as they will just run parallel to our walk (we won’t cross them). This is why I added the above check.

# connect sequential red tiles with green tiles
function draw_green_line!(grid, from, to)
    x_min, x_max = minmax(from[1], to[1])
    y_min, y_max = minmax(from[2], to[2])
    
    for x  x_min:x_max
        for y  y_min:y_max
            grid[x, y] == 0 && (grid[x, y] = 2)  # only fill empties
        end
    end
end
draw_green_line! (generic function with 1 method)

# are we inside the shape?
function is_inside(grid, x, y)
    grid[x, y] != 0 && return false  # already filled
    
    crossings = 0
    for check_x  1:(x-1)
        # counting vertical boundaries
        at_y = grid[check_x, y]  (1, 2)
        above = y > 1 && grid[check_x, y-1]  (1, 2)
        at_y && above && (crossings += 1)
    end
    
    return isodd(crossings)
end
is_inside (generic function with 1 method)

Turns out the grid is enormous - 9655610113 tiles.

Since we only care about red tiles, we can index their sparse coordinates. I eventually remembered to use the original (enormous) grid coordinates to calculate how big the rectangle areas are. Here’s my solution:

# check rectangle contains only red/green tiles
function is_valid_rectangle(grid, c1, c2)
    x_min, x_max = minmax(c1[1], c2[1])
    y_min, y_max = minmax(c1[2], c2[2])
    
    for x  x_min:x_max
        for y  y_min:y_max
            grid[x, y] == 0 && return false
        end
    end
    return true
end
is_valid_rectangle (generic function with 1 method)

function max_area(red_tiles_coords::Vector{Vector{Int}}; part_2::Bool = false)
    
    n_tiles = length(red_tiles_coords)
    max_area = 0
    best_pair = nothing

    if part_2
    # coordinate compression
        all_x = sort(unique(c[1] for c  red_tiles_coords))
        all_y = sort(unique(c[2] for c  red_tiles_coords))
        
        # big grid => compressed index
        x_to_idx = Dict(x => i for (i, x)  enumerate(all_x))
        y_to_idx = Dict(y => i for (i, y)  enumerate(all_y))
        
        # Compress all red tile coordinates
        compressed_coords = [[x_to_idx[c[1]], y_to_idx[c[2]]] for c  red_tiles_coords]
        
        # using reduced grid dims...
        max_x = length(all_x)
        max_y = length(all_y)
        grid = zeros(Int, max_x, max_y)
        
        # ...mark red tiles and connect with greens
        for c  compressed_coords
            grid[c[1], c[2]] = 1
        end
        
        for i  1:n_tiles
            next_i = i == n_tiles ? 1 : i + 1
            draw_green_line!(grid, compressed_coords[i], compressed_coords[next_i])
        end
        
        # and paint the interior green too
        for x  1:max_x
            for y  1:max_y
                is_inside(grid, x, y) && (grid[x, y] = 2)
            end
        end
    end

    for i  1:n_tiles
        for j  (i+1):n_tiles
            
            # part 2: check validity using compressed coords
            if part_2 && !is_valid_rectangle(grid, compressed_coords[i], compressed_coords[j])
                continue
            end

            # ...but then calculate the area on the big grid
            width = abs(red_tiles_coords[i][1] - red_tiles_coords[j][1]) + 1
            height = abs(red_tiles_coords[i][2] - red_tiles_coords[j][2]) + 1
                        
            if width * height > max_area
                max_area = width * height
                best_pair = (i, j)
            end
        end
    end

    return max_area
end
max_area (generic function with 1 method)

which matches the example answer:

red_tiles |>
    tiles -> split.(tiles, ",") |>
    tiles -> [parse.(Int, t) for t  tiles] |> 
    coords -> max_area(coords, part_2 = true)
24

A hodgepodge of a solution, which may have some breaking edge cases, but got me there. This is the first time I have been 3 puzzles away from completing advent of code…

⭐⭐

Day 10: ⏳

Citation

BibTeX citation:
@online{di_francesco2025,
  author = {Di Francesco, Domenic},
  title = {Advent of Code 2025},
  date = {2025-12-17},
  url = {https://allyourbayes.com/posts/AoC/},
  langid = {en}
}
For attribution, please cite this work as:
Di Francesco, Domenic. 2025. “Advent of Code 2025.” December 17, 2025. https://allyourbayes.com/posts/AoC/.