advent of code 2025

how far can I get in Julia?

Julia
puzzles
Author

Domenic Di Francesco

Published

December 8, 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 in 1:n_rows, col in 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: ⏳

Citation

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