Advent of Code 2023 Day 3 Highlights

2023-12-03
aoc
aoc2023
julia
elixir

Day 3 was fun! I ended up re-writing my solution almost completely after the first part, because I figured that I can do it much better. That unlocked a couple more simplifications, and (mostly) trivialized part 2. This is also the first day I used Julia this year.

Here’s my (rewritten) solution. And here’s the first version of my approach for part 1 (which worked, but I didn’t like).

Julia, being a scientific computing/array language naturally is amazing for tasks where you need to do something with coordinates and 2/3/4/etc. dimensional grids. So I decided to use it today.

One very useful type to know for dealing with this kind of tasks in Julia is CartesianIndex. I knew that, so that’s how I modelled neighbour coordinate shifts from the get go:

neighbour_deltas = [
  CartesianIndex(-1, 0), CartesianIndex(1, 0), CartesianIndex(0, -1), CartesianIndex(0, 1),
  CartesianIndex(-1, -1), CartesianIndex(-1, 1), CartesianIndex(1, -1), CartesianIndex(1, 1)
]

You can sum cartesian indices and do a bunch of other useful things with them, including, naturally, using them to index into an array of matching dimensions.

When I do AoC in Julia, I allow myself using packages that don’t trivialize the task too much. One of my go-tos is a package called Pipe. It provides a @pipe macro that allows using _ to specify in which position an argument should be piped into a function. Just a syntactic sugar, but I like it.

Initially, I went through each row of the grid collecting numbers until they were broken by a symbol or a line break and maintaining a boolean value that signified if I detected a neighbouring symbol. Even though it worked, it was not very Julia-like and I didn’t like the solution too much. Mid-way through writing it, I figured that I can do much better by doing some set intersections. Additionally, Julia provides some useful functions such as findall.

findall in particular allows us to easily create vectors with all digit positions and all symbol positions:

symbol_positions = findall(issymbol, inp) |> Set
# sort them in a row-by-row order, so we can rely on digits of one number following each other
digit_positions = @pipe findall(isdigit, inp) |> sort(_, by=x -> (x[1], x[2]))

You can see that I also sort digit positions in such a way that adjacent digits will follow each other.

If I was writing this in Elixir, that would’ve been a point where I would’ve used Enum.reduce, but sadly fold in Julia is much less pleasant to use, so instead we extract a State type:

mutable struct State
  digits::Vector{Char}
  positions::Vector{CartesianIndex{2}}

  State() = new([], [])
end

function add_digit!(s::State, digit::Char, pos::CartesianIndex{2})
  push!(s.digits, digit)
  push!(s.positions, pos)
end

We also define an empty constructor for it and a method that allows us to add one digit character together with its CartesianIndex.

We also have the following helper function:

function push_if_part_number!(
  part_numbers::Vector{Tuple{Int64,Set{CartesianIndex}}},
  state::State,
  symbol_positions::Set{CartesianIndex{2}}
)
  candidate = @pipe String(state.digits) |> parse(Int64, _)
  all_neighbour_positions = map(pos -> [pos] .+ neighbour_deltas, state.positions) |> Iterators.flatten
  neighbouring_symbol_positions = intersect(symbol_positions, all_neighbour_positions)
  if !isempty(neighbouring_symbol_positions)
    push!(part_numbers, (candidate, neighbouring_symbol_positions))
  end
end

This updates a part_numbers vector of part number and the set of their neighbour symbol coordinates tuples with the number we can parse from the state.digits if we find that their neighbourhood contains symbols. We don’t need that set of neighbour symbol coordinates yet, but we’ll need that for part 2 :)

You can also see some delightful features of Julia here: broadcasting and adding pos cartesian index to all neighbour_deltas so that we get all neighbours for each position; the aforementioned @pipe trick; built-in set operators such as intersect.

Anyhow, we can now put it all together and create a function that will return us all part numbers (and sets of their neighbouring symbol coordinates):

function part_numbers(inp)
  part_numbers = Vector{Tuple{Int64,Set{CartesianIndex}}}()
  symbol_positions = findall(issymbol, inp) |> Set
  digit_positions = @pipe findall(isdigit, inp) |> sort(_, by=x -> (x[1], x[2]))

  state = State()
  for digit_position in digit_positions
    # if we have digits and the current digit doesn't directly follow them, we can push the number and clear the state
    if !isempty(state.digits) && (digit_position != last(state.positions) + CartesianIndex(0, 1))
      push_if_part_number!(part_numbers, state, symbol_positions)
      state = State()
    end

    add_digit!(state, inp[digit_position], digit_position)
  end

  if !isempty(state.digits)
    push_if_part_number!(part_numbers, state, symbol_positions)
  end

  part_numbers
end

The if condition inside the for loop walking through all digit_positions was initially this:

if isempty(state.digits) || (digit_position == last(state.positions) + CartesianIndex(0, 1))
  add_digit!(state, inp[digit_position], digit_position)
else
  push_if_part_number!(part_numbers, state, symbol_positions)
  state = State()

  add_digit!(state, inp[digit_position], digit_position)
end

It’s easy to see that we call add_digit! no matter what here, so we can move it below the if, and just do push_if_part_number! call followed by the state clear in case the reverse condition holds.

Then, the first part’s solution is straightforward:

p1(inp) = part_numbers(inp) .|> first |> sum

Part #2

Given a solution for the part 1 that didn’t originally track the set of neighbouring symbol coordinates, it was easy to adapt it to do it. Then, we can do this:

function gear_numbers(inp)
  gear_symbol_positions = findall(ch -> ch == '*', inp)

  candidate_gears = Dict{CartesianIndex{2},Vector{Int64}}()
  for (part_number, adjacent_symbol_positions) = part_numbers(inp)
    adjacent_gear_symbol_positions = intersect(adjacent_symbol_positions, gear_symbol_positions)

    for candidate_gear_position = adjacent_gear_symbol_positions
      pns = get(candidate_gears, candidate_gear_position, [])
      push!(pns, part_number)

      candidate_gears[candidate_gear_position] = pns
    end
  end

  candidate_gears = values(candidate_gears) |> collect
  filter(numbers -> length(numbers) == 2, candidate_gears)
end

Here, we again use findall to find all * symbol coordinates. Then for each part number, we find all its adjacent * symbol positions with the set intersection (we get all neighbouring symbol coords from the part_numbers function), and create a dictionary candidate_gears that maps each candidate gear position with a vector of its part numbers. We know that valid gears will have 2 part numbers adjacent to it, so we can just filter on this.

And then, the part 2 is solved easily:

p2(inp) = gear_numbers(inp) .|> prod |> sum

Finally, I like to use @show macro for both debugging and for showing my answers. When I run this code with julia d03.jl, that’s what I see:

➜  aoc2023 git:(main) julia d03.jl
p1(input) == 540212 = true
p2(input) == 87605697 = true

Funny Stuff

So, I traveled through time and ended up naming my repo aoc2024 xD Fixed that, the new repo is located here now: https://github.com/Lakret/aoc2023. Thanks, @Isti115 for pointing this out!

Also, I got a response from the official Elixir language account for the gripe I had with the Regex.scan function during the first day. So, here’s my first PR to Elixir, let’s see how that goes :)

If you enjoyed this content, you can sponsor me on Github to produce more videos / educational blog posts.

And if you're looking for consulting services, feel free to contact me .