Author Archives: Julia on Eric Burden

Advent of Code 2021 – Day 24

By: Julia on Eric Burden

Re-posted from: https://www.ericburden.work/blog/2022/01/05/advent-of-code-2021-day-24/

It’s that time of year again! Just like last year, I’ll be posting my solutions to the Advent of Code puzzles. This year, I’ll be solving the puzzles in Julia. I’ll post my solutions and code to GitHub as well. I had a blast with AoC last year, first in R, then as a set of exercises for learning Rust, so I’m really excited to see what this year’s puzzles will bring. If you haven’t given AoC a try, I encourage you to do so along with me!

Day 24 – Arithmetic Logic Unit

Find the problem description HERE.

The Puzzle – The Whole Enchilada

MONAD imposes additional, mysterious restrictions on model numbers, and legend says the last copy of the MONAD documentation was eaten by a tanuki. You’ll need to figure out what MONAD does some other way.

Today’s puzzle took a bit of effort on my part just to figure out exactly what it was we were trying to do. Essentially, we are given a set of instructions (with some explanation about how to parse them), then informed that somehow this set of instructions can be used to validate a 14 digit number. 14 digits, eh? You know, after looking at that input program, it looks like there are 14 different chunks there, as well…

Table comparing the 14 chunks of the puzzle input

In fact, among those 14 chunks, there are only three lines that differ from one chunk to the next. Those must be the important bits! Let’s give each of them a name. I opted for optype, correction, and offset, for reasons that might make sense as we continue. Working through each chunk, we get the following pseudocode which expresses the operation of each ALU code chunk:

x = ((z mod 26) + correction) != w
z = floor(z / optype)
z = z * ((25 * x) + 1)
z = z + ((w + offset) * x)

You’ll either need to trust me on that or work through one of the chunks yourself (it’s a bit tedious, but not too bad). As you can see, y ends up being unnecessary to keep track of (mul x 0 and mul y 0 reset x and y to zero, respectively) and we only need w to determine whether x is a 1 or 0, a boolean value. The last line modifies z depending on whether or not w is true or false. If that sounds a bit like a conditional expression, then you’re right! The chunk above can then be simplified to:

if (z mod 26) + correction != w
    z = floor(z / optype)
    z = z * 26
    z = z + (w + offset)
else
    z = floor(z / optype)
end

Another look at our input program reveals that optype can only be 1 or 26, which will determine what type of operation we perform (optype, get it?).

optype == 1                             |     optype == 26
if (z mod 26) + correction != w         |     if (z mod 26) + correction != w
    z = z * 26                          |          z = floor(z / 26) 
    z = z + (w + offset)                |          z = z * 26
end                                     |          z = z + (w + offset)
                                        |     else
                                        |          z = floor(z / 26)
                                        |     end

Ok, so what does this mean, exactly? For one, we were able to drop the else clause from the optype == 1 case, since z == floor(z / 1) for all integers. For another, let’s consider our input again, particularly chunks 3 and 4, which have an optype of 1 and 26, respectively. What, then, do those chunks do? Let’s try them and see! We’ll assume, for the sake of experimentation, that z is 0 going into chunk 3.

chunk 3: optype => 1;  correction => 14; offset => 8

Assume z == 0 (it doesn't really matter, as you'll see, but it does simplify 
the expressions a bit. For fun, try working through this with any positive 
integer value for z and see what happens)

(0 mod 26) + 14 -> 14 != w  # Since 1 <= w <= 9, w _can't_ == 14
    z = 0 * 26 -> 0
    z = (w + 8)
chunk 4: optype => 26; correction => -5; offset => 5

We'll use `w3` as a standin for the w from chunk 3, to differentiate. I'm 
leaving the variable in to demonstrate what's really being compared. So, to 
start chunk 4, z = (w3 + 8)

(z mod 26)   -> (w3 + 8)
(w3 + 8) - 5 -> (w3 + 3)

There are two scenarios here. Either w3 + 3 == w4, or it doesn't...

w3 + 3 != w4                            |     w3 + 3 == w4
    # w3 + 8 is less than 26            |         z = floor((w3 + 8) / 26) -> 0
    z = floor((w3 + 8) / 26) -> 0       |
    z = 0 * 26 -> 0                     |
    z = (w4 + 5)                        |

So, the only thing chunk 3 can do here is multiply z by 26 and add w<3> + offset<3>. Chunk 4 is going to remove w<3> + offset<3> from z regardless, but if w<4> != w<3> + offset<3> + correction<4>, then chunk 4 will add w<4> + offset<4> back to z. This means that z is a base-26 number, and each time optype == 1, z is shifted to the left and the value of w + offset is tacked on to the end. Each time optype = 26, depending on the w (input) values, either the last value tacked on to z is erased, or the last value is erased and replaced with a different w + offset. Since our goal is to have z == 0 at the end of this process, we should then strive to identify values of w that can be ‘canceled out’ from one chunk to the next. This also means that, for every optype == 1 chunk, there should be a corresponding optype == 26 chunk. And, wouldn’t you know, there are 7 of each kind of chunk, for 14 total digits. Let’s try to pair them off:

chunk  1: optype == 1  ──────┐
chunk  2: optype == 1  ────┐ │
chunk  3: optype == 1  ┐   │ │
chunk  4: optype == 26 ┘   │ │
chunk  5: optype == 1  ──┐ │ │
chunk  6: optype == 1  ─┐│ │ │
chunk  7: optype == 1  ┐││ │ │     ________________
chunk  8: optype == 26 ┘││ │ │    < That's a stack! >
chunk  9: optype == 26 ─┘│ │ │     ----------------
chunk 10: optype == 1  ┐ │ │ │            \   ^__^
chunk 11: optype == 26 ┘ │ │ │             \  (oo)\_______
chunk 12: optype == 26 ──┘ │ │                (__)\       )\/\
chunk 13: optype == 26 ────┘ │                    ||----w |
chunk 14: optype == 26 ──────┘                    ||     ||

You know what, cow, you’re right! That does look exactly like a stack. And, based on what we learned earlier, it’s a stack that gets pushed to every time optype == 1, and that get’s popped (and optionally pushed) every time optype == 26. Based on these pairings, we can now deduce the relationships between each of the digits in our final number. I’ll be filling in values for offset and correction from the table above.

offset<1>  + correction<14> == -1
w<1>  - 1 == w<14> <|> w<14> + 1 == w<1>

offset<2>  + correction<13> == -6
w<2>  - 6 == w<13> <|> w<13> + 6 == w<2>

offset<3>  + correction<4>  == +3
w<3>  + 3 == w<4>  <|> w<4>  - 3 == w<3>

offset<5>  + correction<12> == +8
w<5>  + 8 == w<12> <|> w<12> - 8 == w<5>

offset<6>  + correction<9>  == +1
w<6>  + 1 == w<9>  <|> w<9>  - 1 == w<6>

offset<7>  + correction<8>  == -8
w<7>  - 8 == w<8>  <|> w<8>  + 8 == w<7>

offset<10> + correction<11> == +2
w<10> + 2 == w<11> <|> w<11> - 2 == w<10>

Well, now, I daresay we know everything we need to start filling in digits, and solving this puzzle! Again, looking at the pair between chunks 3 and 4, we see that w<3> + 3 == w<4>. The largest digits we can place into w<3> and w<4> that will satisfy this constraint (while keeping each digit in the range from 1 -> 9), are w<3> = 6 and w<4> = 9. Do this for the rest of the digit pairs, and you have your answer to part one of the puzzle!

Since part two asks for the smallest valid model number, we don’t need to deduce anything new. Instead, we just need to fill in the smallest digits that will meet our constraints. For the chunk 3/chunk 4 pairing, that works out to w<3> = 1 and w<4> = 4. Fill out the rest of the digits, and we’ve earned ourselves another gold star.

Some Code – You Complete Me

There wasn’t any way that I was going to finish up this blog post without any code, however. While I solved the puzzle without any code, I did write the function below which could be used to validate model numbers using the method we described above. Fun fact: there are only two valid model numbers anyway! (Can you tell why that is? Hint: check out chunks 5/12 and chunks 7/8).

#(optype, correction, offset)
const PARAMS = [
    ( 1,  13,  0),  # d[1]  +  0 -  1 == d[14] -> d[1] -  1 == d[14]   | 9  | 2
    ( 1,  11,  3),  # d[2]  +  3 -  9 == d[13] -> d[2] -  6 == d[13]   | 9  | 7
    ( 1,  14,  8),  # d[3]  +  8 -  5 == d[4]  -> d[3] +  3 == d[4]    | 6  | 1
    (26,  -5,  5),  # d[4]  -  8 +  5 == d[3]  -> d[4] -  3 == d[3]    | 9  | 4
    ( 1,  14, 13),  # d[5]  + 13 -  5 == d[12] -> d[5] +  8 == d[12]   | 1  | 1
    ( 1,  10,  9),  # d[6]  +  9 -  8 == d[9]  -> d[6] +  1 == d[9]    | 8  | 1
    ( 1,  12,  6),  # d[7]  +  6 - 14 == d[8]  -> d[7] -  8 == d[8]    | 9  | 9
    (26, -14,  1),  # d[8]  -  6 + 14 == d[7]  -> d[8] +  8 == d[7]    | 1  | 1
    (26,  -8,  1),  # d[9]  -  9 +  8 == d[6]  -> d[9] -  1 == d[6]    | 9  | 2
    ( 1,  13,  2),  # d[10] +  2 -  0 == d[11] -> d[10] + 2 == d[11]   | 7  | 1
    (26,   0,  7),  # d[11] -  2 +  0 == d[10] -> d[11] - 2 == d[10]   | 9  | 3
    (26,  -5,  5),  # d[12] - 13 +  5 == d[5]  -> d[12] - 8 == d[5]    | 9  | 9
    (26,  -9,  8),  # d[13] -  3 +  9 == d[2]  -> d[13] + 6 == d[2]    | 3  | 1
    (26,  -1, 15)   # d[14] -  0 +  1 == d[1]  -> d[14] + 1 == d[1]    | 8  | 1
] 


# This function can be used to check whether a given number is a valid model
# number, according to the MONAD program.
function isvalid(n::Int64)
    ndigits = reverse(digits(n))

    # Valid model numbers don't contain `0` as a digit
    0  ndigits && return false
    stack = []; sizehint!(stack, length(ndigits))

    for (w, (optype, correction, offset)) in zip(ndigits, PARAMS)
        if optype == 1
            push!(stack, w + offset)
            continue
        end
        stack[end] + correction == w && pop!(stack)
    end

    # In a valid model number, half the digits will be "canceled" out by the
    # other half, leaving an empty stack
    return isempty(stack)
end

Wrap Up

At first, I wasn’t sure how to feel about this puzzle. On the one hand, I do AoC primarily to practice my coding skills (in Julia, this year). On the other hand, this required a fair bit of deduction and mental flexing to suss out, which is massively satisfying once the puzzle is complete. You know what? I had fun. I suppose that’s pretty important, too, now and again. Only one more day (and one more puzzle) left to go!

If you found a different solution (or spotted a mistake in one of mine), please drop me a line!

Advent of Code 2021 – Day 23

By: Julia on Eric Burden

Re-posted from: https://www.ericburden.work/blog/2022/01/04/advent-of-code-2021-day-23/

It’s that time of year again! Just like last year, I’ll be posting my solutions to the Advent of Code puzzles. This year, I’ll be solving the puzzles in Julia. I’ll post my solutions and code to GitHub as well. I had a blast with AoC last year, first in R, then as a set of exercises for learning Rust, so I’m really excited to see what this year’s puzzles will bring. If you haven’t given AoC a try, I encourage you to do so along with me!

Day 23 – Amphipod

Find the problem description HERE.

The Input – Swipe Left

This time around, I’ve skipped parsing the input from the text file, in large part because I ended up re-factoring my solution (and the input data type) several times in an attempt to reduce run times. So, instead of text parsing, I present the data structure(s) I have settled on.

#= Data Structures -------------------------------------------------------------
| These are the data structures used to represent the problem space
=#

# The different kinds of Amphipod
abstract type Amphipod end
struct Amber  <: Amphipod end
struct Bronze <: Amphipod end
struct Copper <: Amphipod end
struct Desert <: Amphipod end

const MaybeAmphipod = Union{Nothing,Amphipod}

# A space in the burrow where an Amphipod can be
abstract type Location end
struct Hallway <: Location
    idx::Int64
    occupant::MaybeAmphipod
end
Hallway(idx) = Hallway(idx, nothing)

struct Room{T} <: Location
    amphitype::Type{T}
    idx::Int64
    occupant::MaybeAmphipod
end
Room(T, idx) = Room(T, idx, nothing)


# The burrow is represented by a statically sized Tuple of `Location`s
struct Burrow{N}
    locations::NTuple{N, Location}
end
Burrow(L...) = Burrow(L)

Base.getindex(burrow::Burrow, idx::Int64) = burrow.locations[idx]
Base.isempty(location::Location)          = isnothing(location.occupant)
Base.length(burrow::Burrow)               = length(burrow.locations)
matches(room::Room)                       = room.occupant isa room.amphitype

The actual inputs are just constructed in the script as constants like so:

#= Test input burrow state
| #############
| #...........#
| ###B#C#B#D###
|   #A#D#C#A#
|   #########
=#
const TESTBURROW = Burrow(
    Hallway(1),
    Hallway(2),                Hallway(3),
    Room(Amber, 1,  Bronze()), Room(Amber, 2,  Amber()),
    Hallway(4),                Hallway(5),
    Room(Bronze, 1, Copper()), Room(Bronze, 2, Desert()),
    Hallway(6),                Hallway(7),
    Room(Copper, 1, Bronze()), Room(Copper, 2, Copper()),
    Hallway(8),                Hallway(9),
    Room(Desert, 1, Desert()), Room(Desert, 2, Amber()),
    Hallway(10),               Hallway(11)
)

I could have gone back after finally settling on a data type and written code to parse it from the input file, but I don’t wanna!

Solution – One Code to Rule Them All

Since we’re changing things up, and the code I’ll be presenting is the result of several re-factors, I’ll share the final, generalized code that solves both parts. Today’s puzzle has us rearranging amphipods in their burrow, in a very Tower of Hanoi or Rush Hour sort of fashion. Initially, I thought an A* algorithm would be my best bet, but after much fiddling, I couldn’t settle on a heuristic that would actually help on both parts. There were some versions that really helped the test input caused the real input to not converge on a solution (or to take longer to find a solution), and many versions that caused both the test and real inputs to result in answers close to the correct solution, but not quite there. So, no heuristic! And the performance is bad (~10s for both parts on my machine), but I can live with that. Here’s the generalized solving code:

# Shared code between modules Part1 and Part2 ==================================
# Part1 and Part2 are split into modules to help with namespacing 

using DataStructures: BinaryMinHeap


#= Movement Rules --------------------------------------------------------------
| Use the puzzle rules to determine whether or not an amphipod can move from
| one space to another with the following functions...
=#

# Get the index of the hallway space that leads into a `Room`.
doorway(room::Room) = DOORWAYS[typeof(room)]

# Iterate over all the rooms of the same type following the `Room` in `RoomIter`
# Note that every Tuple is already an iterator over its contents, but because
# We're implementing `iterate()` for a _more specific_ type of Tuple, this 
# method will be preferred for iterating over `RoomIter` Tuples.
const RoomIter = Tuple{Room,Burrow}
function Base.iterate(iter::RoomIter, idx::Int = 0)
    (room, burrow) = iter
    doorwayidx     = doorway(room)
    roomidx        = idx > 0 ? idx : doorwayidx + room.idx + 1
    nextroom       = burrow[roomidx]
    nextroom isa typeof(room) || return nothing
    return (nextroom, roomidx + 1)
end

# It is illegal to move from one hallway space to another hallway space
canmove(::Hallway, ::Hallway, ::Burrow) = false

# An amphipod will only leave their room if they do not match the room OR
# they are between the hallway and another amphipod who does not match the room
function canmove(room::Room, ::Hallway, burrow::Burrow) 
    # Can leave the room if the amphipod doesn't match the room type.
    matches(room) || return true

    # Can move from the first space in the room to the hallway if...
    # - the amphipod type does not match the room type (already checked)
    # - an amphipod in a later space does not match the room type
    for nextroom in (room, burrow)
        matches(nextroom) || return true
    end

    # If all the following room spaces are filled with the right kind of
    # amphipod (or `room` is the last space in the Room), then stay put
    return false
end

# There are multiple rules associated with moving from a hallway space into 
# a room...
function canmove(hallway::Hallway, room::Room, burrow::Burrow)
    # Can't move into any of the room spaces if the amphipod type doesn't
    # match the type of room.
    hallway.occupant isa room.amphitype || return false

    # Can move from the hallway into the first `Room` space if...
    # - the amphipod type matches the room type (already checked)
    # - all the later spaces are occupied by an amphipod of the
    # appropriate type
    for nextroom in (room, burrow)
        matches(nextroom) || return false
    end

    # If all the following room spaces are filled with the right kind of
    # amphipod (or `room` is the last space in the Room), then `amphipod` 
    # can move into the room space
    return true
end

# Rules for moving from one room space to another room space
function canmove(room1::Room, room2::Room, burrow::Burrow)
    # Can't move from one space to another in the same room
    typeof(room1) == typeof(room2) && return false

    # In order to move from one room to another, the amphipod must *not* match
    # the room it is in and it *must* match the room it is moving to
    room1.occupant isa room1.amphitype && return false
    room1.occupant isa room2.amphitype || return false

    # Can't move into a room space if any of the following spaces are empty
    # or occupied by the wrong kind of amphipod
    for nextroom in (room2, burrow)
        matches(nextroom) || return false
    end

    return true
end


#= Swapping --------------------------------------------------------------------
| The following functions enable swapping the occupants at two locations in the
| burrow. Since the `Burrow` is immutable, this involves creating a new `Burrow`
| by iteration, while making the change. It would be easier to just use a 
| mutable `Burrow`, but I'm hoping that the immutable struct can be passed on
| the stack instead of the heap.
=#

# Given a room and a possible Amphipod, return a version of that room occupied
# by the possible amphipod
replace(R::Room{Amber},  MA::MaybeAmphipod) = Room(Amber,  R.idx, MA)
replace(R::Room{Bronze}, MA::MaybeAmphipod) = Room(Bronze, R.idx, MA)
replace(R::Room{Copper}, MA::MaybeAmphipod) = Room(Copper, R.idx, MA)
replace(R::Room{Desert}, MA::MaybeAmphipod) = Room(Desert, R.idx, MA)
replace(H::Hallway,      MA::MaybeAmphipod) = Hallway(H.idx, MA)

# Iterator for swapping the possible amphipod in one location with the 
# possible amphipod in another location
struct BurrowSwap{N}
    locations::NTuple{N,Location}
    swap1::Pair{Int64,MaybeAmphipod}
    swap2::Pair{Int64,MaybeAmphipod}
end

# Needed to make the iterator work
Base.eltype(::BurrowSwap)     = Location
Base.length(iter::BurrowSwap) = length(iter.locations)

# Iterator implementation. This is mostly used to produce a new `Burrow` where
# the occupants of two locations are swapped.. Just returns the other locations
# as they are, in order. The locations to be swapped are replaced with a copy
# containing the intended occupant.
function Base.iterate(iter::BurrowSwap, state = 1)
    (swap1, swap2, locations) = (iter.swap1, iter.swap2, iter.locations)
    state > length(locations) && return nothing

    (idx1, idx2) = (swap1.first, swap2.first)
    location = locations[state]
    state == idx1 && return (replace(location, swap1.second), state + 1)
    state == idx2 && return (replace(location, swap2.second), state + 1)
    return (location, state + 1)
end

# Convenience function to conduct the swap, producing a new Burrow with the
# occupants in the locations indicated by `idx1` and `idx2` swapped.
function swap(burrow::Burrow, idx1::Int64, idx2::Int64)
    (occupant1, occupant2) = (burrow[idx1].occupant, burrow[idx2].occupant)
    constructor = BurrowSwap{length(burrow.locations)}
    swapper = constructor(burrow.locations, idx1 => occupant2, idx2 => occupant1)
    return Burrow(Tuple(l for l in swapper))
end


#= Generating States -----------------------------------------------------------
| This section defines functionality for determining the next possible `Burrow`
| states
=#

# A `Move` is a Tuple of movement cost and ending index
const Move = NTuple{2,Int64}
const COST = Dict(Amber() => 1, Bronze() => 10, Copper() => 100, Desert() => 1000)

# For a given burrow and location (indicated by `idx`), return the `Move`s that
# can be reached by the amphipod in the indicated location.
function nextmoves(burrow::Burrow, idx::Int64)
    amphipod = burrow[idx].occupant
    isnothing(amphipod) && return []

    # Starting at the indicated location, perform a breadth-first search for
    # all the spaces that can be reached from the given location. 
    movecost = COST[amphipod]
    queue    = Vector{Move}([(0, idx)])
    visited  = Set{Int64}()
    moves    = Set{Move}()

    while !isempty(queue)
        (cost, current) = pop!(queue)

        for neighbor in NEIGHBORS[current]
            neighbor  visited && continue
            isempty(burrow[neighbor]) || continue

            nextcost = cost + movecost
            pushfirst!(queue, (nextcost, neighbor))

            # Don't keep any moves that would stop in a doorway.
            neighbor  values(DOORWAYS) && continue
            canmove(burrow[idx], burrow[neighbor], burrow) || continue
            push!(moves, (nextcost, neighbor))
        end
        push!(visited, current)
    end
    return moves
end

# Starting with a given `Burrow`, try moving all the `Amphipod`s and return 
# all the new `Burrow`s (new states) that can be produced by moving the
# amphipods. We'll keep up with the `Burrow` states, the cost to get there,
# and the estimated distance from the desired `Burrow` state. We'll sort
# the `MetaBurrow`s by accrued movement cost in our algorithm.
const MetaBurrow = Tuple{Int,Burrow} # (cost, burrow)
Base.isless(lhs::MetaBurrow, rhs::MetaBurrow) = lhs[1] < rhs[1]

function nextburrows(burrow::Burrow)
    burrows = Set{MetaBurrow}()

    for idx in 1:length(burrow)
        moves = nextmoves(burrow, idx)
        for (movecost, nextidx) in moves
            nextburrow = swap(burrow, idx, nextidx)
            push!(burrows, (movecost, nextburrow))
        end
    end
    return burrows
end

# Check all the `Room` spaces. If all of them hold a matching amphipod, then
# we've found the ideal `Burrow` state
function isdone(burrow::Burrow)
    for location in burrow.locations
        location isa Hallway && continue
        matches(location)    || return false
    end
    return true
end


#= Solve the puzzle ------------------------------------------------------------
| Solve using a "best-first" search strategy, where all the states that can
| be reached from current state are added to a BinaryMinHeap, and the state
| with the lowest cost is taken as the next state to check. Repeat until the
| final state is found. The distance heuristic is useful for skipping
| neighbors to check, reducing the total search space.
=#

function solve(initial)
    heap = BinaryMinHeap([(0, initial)])
    seen = Set{Burrow}()

    while !isempty(heap) 
        (cost, current) = pop!(heap)
        current  seen  && continue
        isdone(current) && return cost
        push!(seen, current)

        for (burrowcost, nextburrow) in nextburrows(current)
            nextcost = cost + burrowcost
            push!(heap, (nextcost, nextburrow))
        end

    end
    error("Could not solve this input!")
end

That is, in fact, quite a bit of code that doesn’t include all the failed heuristic code I tried to write. Hoo boy!

Part One – Holes

So, as we know, we’ve got a smaller Burrow size for part one. The actual code that solves part one (mostly constant definitions, at this point) is below:

# Constants --------------------------------------------------------------------

#= Test input burrow state
| #############
| #...........#
| ###B#C#B#D###
|   #A#D#C#A#
|   #########
=#
const TESTBURROW = Burrow(
    Hallway(1),
    Hallway(2),                Hallway(3),
    Room(Amber, 1,  Bronze()), Room(Amber, 2,  Amber()),
    Hallway(4),                Hallway(5),
    Room(Bronze, 1, Copper()), Room(Bronze, 2, Desert()),
    Hallway(6),                Hallway(7),
    Room(Copper, 1, Bronze()), Room(Copper, 2, Copper()),
    Hallway(8),                Hallway(9),
    Room(Desert, 1, Desert()), Room(Desert, 2, Amber()),
    Hallway(10),               Hallway(11)
)

#= Real input burrow state
| #############
| #...........#
| ###B#C#B#D###
|   #A#D#C#A#
|   #########
=#
const REALBURROW = Burrow(
    Hallway(1),
    Hallway(2),                Hallway(3),
    Room(Amber, 1,  Desert()), Room(Amber, 2,  Bronze()),
    Hallway(4),                Hallway(5),
    Room(Bronze, 1, Bronze()), Room(Bronze, 2, Desert()),
    Hallway(6),                Hallway(7),
    Room(Copper, 1, Amber()),  Room(Copper, 2, Amber()),
    Hallway(8),                Hallway(9),
    Room(Desert, 1, Copper()), Room(Desert, 2, Copper()),
    Hallway(10),               Hallway(11)
)

# Each index in `NEIGHBORS` contains a vector of the indices that can be reached
# from a given index in `Burrow.locations`, given the standard configuration
# produced by `Burrow()`. This is a bit brittle, since this constant will need
# to change if the `Burrow.locations` order changes.
const NEIGHBORS = [
    [2], 
    [1, 3],   [2, 4, 6],    [3, 5],   [4],
    [3, 7],   [6, 8, 10],   [7, 9],   [8],
    [7, 11],  [10, 12, 14], [11, 13], [12],
    [11, 15], [14, 16, 18], [15, 17], [16],
    [15, 19], [18]
]

# We'll use the doorway indices to keep track of where the `Room`s are 
# in the `Burrow`
const DOORWAYS = Dict(
    Room{Amber}  => 3,  Room{Bronze} => 7,
    Room{Copper} => 11, Room{Desert} => 15
)

# Solve Part One ---------------------------------------------------------------

part1(test = false) = test ? solve(TESTBURROW) : solve(REALBURROW)

I’ve also defined the relationships (graph) between the various spaces an amphipod can be and a listing of doorway indices per room type as constants, since these change between puzzle parts.

Part Two – Dig Deep

Same deal as part one, except there are twice as many amphipods and the rooms are twice as deep! Again, we’ll define our inputs and some Burrow metadata as constants and solve.

#= Hardcoded Inputs ------------------------------------------------------------
| It was convenient for this puzzle to simply create the objects in code.
=#

#= Test input burrow state
| #############
| #...........#
| ###B#C#B#D###
|   #D#C#B#A#
|   #D#B#A#C#
|   #A#D#C#A#
|   #########
=#
const TESTBURROW = Burrow(
    Hallway(1),
    Hallway(2),                Hallway(3),
    Room(Amber, 1,  Bronze()), Room(Amber, 2,  Desert()),
    Room(Amber, 3,  Desert()), Room(Amber, 4,  Amber()),
    Hallway(4),                Hallway(5),
    Room(Bronze, 1, Copper()), Room(Bronze, 2, Copper()),
    Room(Bronze, 3, Bronze()), Room(Bronze, 4, Desert()),
    Hallway(6),                Hallway(7),
    Room(Copper, 1, Bronze()), Room(Copper, 2, Bronze()),
    Room(Copper, 3, Amber()),  Room(Copper, 4, Copper()),
    Hallway(8),                Hallway(9),
    Room(Desert, 1, Desert()), Room(Desert, 2, Amber()),
    Room(Desert, 3, Copper()), Room(Desert, 4, Amber()),
    Hallway(10),               Hallway(11)
)

#= Real input burrow state
| #############
| #...........#
| ###D#B#A#C###
|   #D#C#B#A#
|   #D#B#A#C#
|   #B#D#A#C#
|   #########
=#
const REALBURROW = Burrow(
    Hallway(1),
    Hallway(2),                Hallway(3),
    Room(Amber, 1,  Desert()), Room(Amber, 2,  Desert()),
    Room(Amber, 3,  Desert()), Room(Amber, 4,  Bronze()),
    Hallway(4),                Hallway(5),
    Room(Bronze, 1, Bronze()), Room(Bronze, 2, Copper()),
    Room(Bronze, 3, Bronze()), Room(Bronze, 4, Desert()),
    Hallway(6),                Hallway(7),
    Room(Copper, 1, Amber()),  Room(Copper, 2, Bronze()),
    Room(Copper, 3, Amber()),  Room(Copper, 4, Amber()),
    Hallway(8),                Hallway(9),
    Room(Desert, 1, Copper()), Room(Desert, 2, Amber()),
    Room(Desert, 3, Copper()), Room(Desert, 4, Copper()),
    Hallway(10),               Hallway(11)
)

# Each index in `NEIGHBORS` contains a vector of the indices that can be reached
# from a given index in `Burrow.locations`, given the standard configuration
# produced by `Burrow()`. This is a bit brittle, since this constant will need
# to change if the `Burrow.locations` order changes.
const NEIGHBORS = [
    [ 2],     [ 1,  3], [ 2,  4,  8], 
    [ 3,  5], [ 4,  6], [ 5,  7],
    [ 6],     [ 3,  9], [ 8, 10, 14],
    [ 9, 11], [10, 12], [11, 13],
    [12],     [ 9, 15], [14, 16, 20],
    [15, 17], [16, 18], [17, 19],
    [18],     [15, 21], [20, 22, 26],
    [21, 23], [22, 24], [23, 25],
    [24],     [21, 27], [26]
]

# We'll use the doorway indices to keep track of where the `Room`s are 
# in the `Burrow`
const DOORWAYS = Dict(
    Room{Amber}  => 3,  Room{Bronze} => 9,
    Room{Copper} => 15, Room{Desert} => 21
)

# Solve Part Two ---------------------------------------------------------------

part2(test = false) = test ? solve(TESTBURROW) : solve(REALBURROW)

Wrap Up

So, I’m a bit salty about today’s solution. I’m absolutely certain there is a way to apply an A* heuristic to narrow the search space for this problem, but after several days of off and on poking at this problem, I’ve come up empty-handed. In fact, the best results came from tweaking numbers in the heuristic to try to hone in on the right answer, which felt like guesswork and overfitting, AKA not a systematic or logical approach. This was one of the big reasons I ended up stripping out all the heuristic code and moving on. It’s entirely possible that I somehow overcomplicated the whole endeavor. Ah well. The upshot is that I spent a considerable amount of time reasoning through various permutations of this problem in Julia, so lots of practice! Which, at the end of the day, is the whole point of doing AoC this year. And, I’m almost done!

If you found a different solution (or spotted a mistake in one of mine), please drop me a line!

Advent of Code 2021 – Day 23

By: Julia on Eric Burden

Re-posted from: https://ericburden.work/blog/2022/01/04/advent-of-code-2021-day-23/

It’s that time of year again! Just like last year, I’ll be posting my solutions to the Advent of Code puzzles. This year, I’ll be solving the puzzles in Julia. I’ll post my solutions and code to GitHub as well. I had a blast with AoC last year, first in R, then as a set of exercises for learning Rust, so I’m really excited to see what this year’s puzzles will bring. If you haven’t given AoC a try, I encourage you to do so along with me!

Day 23 – Amphipod

Find the problem description HERE.

The Input – Swipe Left

This time around, I’ve skipped parsing the input from the text file, in large part because I ended up re-factoring my solution (and the input data type) several times in an attempt to reduce run times. So, instead of text parsing, I present the data structure(s) I have settled on.

#= Data Structures -------------------------------------------------------------
| These are the data structures used to represent the problem space
=#

# The different kinds of Amphipod
abstract type Amphipod end
struct Amber  <: Amphipod end
struct Bronze <: Amphipod end
struct Copper <: Amphipod end
struct Desert <: Amphipod end

const MaybeAmphipod = Union{Nothing,Amphipod}

# A space in the burrow where an Amphipod can be
abstract type Location end
struct Hallway <: Location
    idx::Int64
    occupant::MaybeAmphipod
end
Hallway(idx) = Hallway(idx, nothing)

struct Room{T} <: Location
    amphitype::Type{T}
    idx::Int64
    occupant::MaybeAmphipod
end
Room(T, idx) = Room(T, idx, nothing)


# The burrow is represented by a statically sized Tuple of `Location`s
struct Burrow{N}
    locations::NTuple{N, Location}
end
Burrow(L...) = Burrow(L)

Base.getindex(burrow::Burrow, idx::Int64) = burrow.locations[idx]
Base.isempty(location::Location)          = isnothing(location.occupant)
Base.length(burrow::Burrow)               = length(burrow.locations)
matches(room::Room)                       = room.occupant isa room.amphitype

The actual inputs are just constructed in the script as constants like so:

#= Test input burrow state
| #############
| #...........#
| ###B#C#B#D###
|   #A#D#C#A#
|   #########
=#
const TESTBURROW = Burrow(
    Hallway(1),
    Hallway(2),                Hallway(3),
    Room(Amber, 1,  Bronze()), Room(Amber, 2,  Amber()),
    Hallway(4),                Hallway(5),
    Room(Bronze, 1, Copper()), Room(Bronze, 2, Desert()),
    Hallway(6),                Hallway(7),
    Room(Copper, 1, Bronze()), Room(Copper, 2, Copper()),
    Hallway(8),                Hallway(9),
    Room(Desert, 1, Desert()), Room(Desert, 2, Amber()),
    Hallway(10),               Hallway(11)
)

I could have gone back after finally settling on a data type and written code to parse it from the input file, but I don’t wanna!

Solution – One Code to Rule Them All

Since we’re changing things up, and the code I’ll be presenting is the result of several re-factors, I’ll share the final, generalized code that solves both parts. Today’s puzzle has us rearranging amphipods in their burrow, in a very Tower of Hanoi or Rush Hour sort of fashion. Initially, I thought an A* algorithm would be my best bet, but after much fiddling, I couldn’t settle on a heuristic that would actually help on both parts. There were some versions that really helped the test input caused the real input to not converge on a solution (or to take longer to find a solution), and many versions that caused both the test and real inputs to result in answers close to the correct solution, but not quite there. So, no heuristic! And the performance is bad (~10s for both parts on my machine), but I can live with that. Here’s the generalized solving code:

# Shared code between modules Part1 and Part2 ==================================
# Part1 and Part2 are split into modules to help with namespacing 

using DataStructures: BinaryMinHeap


#= Movement Rules --------------------------------------------------------------
| Use the puzzle rules to determine whether or not an amphipod can move from
| one space to another with the following functions...
=#

# Get the index of the hallway space that leads into a `Room`.
doorway(room::Room) = DOORWAYS[typeof(room)]

# Iterate over all the rooms of the same type following the `Room` in `RoomIter`
# Note that every Tuple is already an iterator over its contents, but because
# We're implementing `iterate()` for a _more specific_ type of Tuple, this 
# method will be preferred for iterating over `RoomIter` Tuples.
const RoomIter = Tuple{Room,Burrow}
function Base.iterate(iter::RoomIter, idx::Int = 0)
    (room, burrow) = iter
    doorwayidx     = doorway(room)
    roomidx        = idx > 0 ? idx : doorwayidx + room.idx + 1
    nextroom       = burrow[roomidx]
    nextroom isa typeof(room) || return nothing
    return (nextroom, roomidx + 1)
end

# It is illegal to move from one hallway space to another hallway space
canmove(::Hallway, ::Hallway, ::Burrow) = false

# An amphipod will only leave their room if they do not match the room OR
# they are between the hallway and another amphipod who does not match the room
function canmove(room::Room, ::Hallway, burrow::Burrow) 
    # Can leave the room if the amphipod doesn't match the room type.
    matches(room) || return true

    # Can move from the first space in the room to the hallway if...
    # - the amphipod type does not match the room type (already checked)
    # - an amphipod in a later space does not match the room type
    for nextroom in (room, burrow)
        matches(nextroom) || return true
    end

    # If all the following room spaces are filled with the right kind of
    # amphipod (or `room` is the last space in the Room), then stay put
    return false
end

# There are multiple rules associated with moving from a hallway space into 
# a room...
function canmove(hallway::Hallway, room::Room, burrow::Burrow)
    # Can't move into any of the room spaces if the amphipod type doesn't
    # match the type of room.
    hallway.occupant isa room.amphitype || return false

    # Can move from the hallway into the first `Room` space if...
    # - the amphipod type matches the room type (already checked)
    # - all the later spaces are occupied by an amphipod of the
    # appropriate type
    for nextroom in (room, burrow)
        matches(nextroom) || return false
    end

    # If all the following room spaces are filled with the right kind of
    # amphipod (or `room` is the last space in the Room), then `amphipod` 
    # can move into the room space
    return true
end

# Rules for moving from one room space to another room space
function canmove(room1::Room, room2::Room, burrow::Burrow)
    # Can't move from one space to another in the same room
    typeof(room1) == typeof(room2) && return false

    # In order to move from one room to another, the amphipod must *not* match
    # the room it is in and it *must* match the room it is moving to
    room1.occupant isa room1.amphitype && return false
    room1.occupant isa room2.amphitype || return false

    # Can't move into a room space if any of the following spaces are empty
    # or occupied by the wrong kind of amphipod
    for nextroom in (room2, burrow)
        matches(nextroom) || return false
    end

    return true
end


#= Swapping --------------------------------------------------------------------
| The following functions enable swapping the occupants at two locations in the
| burrow. Since the `Burrow` is immutable, this involves creating a new `Burrow`
| by iteration, while making the change. It would be easier to just use a 
| mutable `Burrow`, but I'm hoping that the immutable struct can be passed on
| the stack instead of the heap.
=#

# Given a room and a possible Amphipod, return a version of that room occupied
# by the possible amphipod
replace(R::Room{Amber},  MA::MaybeAmphipod) = Room(Amber,  R.idx, MA)
replace(R::Room{Bronze}, MA::MaybeAmphipod) = Room(Bronze, R.idx, MA)
replace(R::Room{Copper}, MA::MaybeAmphipod) = Room(Copper, R.idx, MA)
replace(R::Room{Desert}, MA::MaybeAmphipod) = Room(Desert, R.idx, MA)
replace(H::Hallway,      MA::MaybeAmphipod) = Hallway(H.idx, MA)

# Iterator for swapping the possible amphipod in one location with the 
# possible amphipod in another location
struct BurrowSwap{N}
    locations::NTuple{N,Location}
    swap1::Pair{Int64,MaybeAmphipod}
    swap2::Pair{Int64,MaybeAmphipod}
end

# Needed to make the iterator work
Base.eltype(::BurrowSwap)     = Location
Base.length(iter::BurrowSwap) = length(iter.locations)

# Iterator implementation. This is mostly used to produce a new `Burrow` where
# the occupants of two locations are swapped.. Just returns the other locations
# as they are, in order. The locations to be swapped are replaced with a copy
# containing the intended occupant.
function Base.iterate(iter::BurrowSwap, state = 1)
    (swap1, swap2, locations) = (iter.swap1, iter.swap2, iter.locations)
    state > length(locations) && return nothing

    (idx1, idx2) = (swap1.first, swap2.first)
    location = locations[state]
    state == idx1 && return (replace(location, swap1.second), state + 1)
    state == idx2 && return (replace(location, swap2.second), state + 1)
    return (location, state + 1)
end

# Convenience function to conduct the swap, producing a new Burrow with the
# occupants in the locations indicated by `idx1` and `idx2` swapped.
function swap(burrow::Burrow, idx1::Int64, idx2::Int64)
    (occupant1, occupant2) = (burrow[idx1].occupant, burrow[idx2].occupant)
    constructor = BurrowSwap{length(burrow.locations)}
    swapper = constructor(burrow.locations, idx1 => occupant2, idx2 => occupant1)
    return Burrow(Tuple(l for l in swapper))
end


#= Generating States -----------------------------------------------------------
| This section defines functionality for determining the next possible `Burrow`
| states
=#

# A `Move` is a Tuple of movement cost and ending index
const Move = NTuple{2,Int64}
const COST = Dict(Amber() => 1, Bronze() => 10, Copper() => 100, Desert() => 1000)

# For a given burrow and location (indicated by `idx`), return the `Move`s that
# can be reached by the amphipod in the indicated location.
function nextmoves(burrow::Burrow, idx::Int64)
    amphipod = burrow[idx].occupant
    isnothing(amphipod) && return []

    # Starting at the indicated location, perform a breadth-first search for
    # all the spaces that can be reached from the given location. 
    movecost = COST[amphipod]
    queue    = Vector{Move}([(0, idx)])
    visited  = Set{Int64}()
    moves    = Set{Move}()

    while !isempty(queue)
        (cost, current) = pop!(queue)

        for neighbor in NEIGHBORS[current]
            neighbor  visited && continue
            isempty(burrow[neighbor]) || continue

            nextcost = cost + movecost
            pushfirst!(queue, (nextcost, neighbor))

            # Don't keep any moves that would stop in a doorway.
            neighbor  values(DOORWAYS) && continue
            canmove(burrow[idx], burrow[neighbor], burrow) || continue
            push!(moves, (nextcost, neighbor))
        end
        push!(visited, current)
    end
    return moves
end

# Starting with a given `Burrow`, try moving all the `Amphipod`s and return 
# all the new `Burrow`s (new states) that can be produced by moving the
# amphipods. We'll keep up with the `Burrow` states, the cost to get there,
# and the estimated distance from the desired `Burrow` state. We'll sort
# the `MetaBurrow`s by accrued movement cost in our algorithm.
const MetaBurrow = Tuple{Int,Burrow} # (cost, burrow)
Base.isless(lhs::MetaBurrow, rhs::MetaBurrow) = lhs[1] < rhs[1]

function nextburrows(burrow::Burrow)
    burrows = Set{MetaBurrow}()

    for idx in 1:length(burrow)
        moves = nextmoves(burrow, idx)
        for (movecost, nextidx) in moves
            nextburrow = swap(burrow, idx, nextidx)
            push!(burrows, (movecost, nextburrow))
        end
    end
    return burrows
end

# Check all the `Room` spaces. If all of them hold a matching amphipod, then
# we've found the ideal `Burrow` state
function isdone(burrow::Burrow)
    for location in burrow.locations
        location isa Hallway && continue
        matches(location)    || return false
    end
    return true
end


#= Solve the puzzle ------------------------------------------------------------
| Solve using a "best-first" search strategy, where all the states that can
| be reached from current state are added to a BinaryMinHeap, and the state
| with the lowest cost is taken as the next state to check. Repeat until the
| final state is found. The distance heuristic is useful for skipping
| neighbors to check, reducing the total search space.
=#

function solve(initial)
    heap = BinaryMinHeap([(0, initial)])
    seen = Set{Burrow}()

    while !isempty(heap) 
        (cost, current) = pop!(heap)
        current  seen  && continue
        isdone(current) && return cost
        push!(seen, current)

        for (burrowcost, nextburrow) in nextburrows(current)
            nextcost = cost + burrowcost
            push!(heap, (nextcost, nextburrow))
        end

    end
    error("Could not solve this input!")
end

That is, in fact, quite a bit of code that doesn’t include all the failed heuristic code I tried to write. Hoo boy!

Part One – Holes

So, as we know, we’ve got a smaller Burrow size for part one. The actual code that solves part one (mostly constant definitions, at this point) is below:

# Constants --------------------------------------------------------------------

#= Test input burrow state
| #############
| #...........#
| ###B#C#B#D###
|   #A#D#C#A#
|   #########
=#
const TESTBURROW = Burrow(
    Hallway(1),
    Hallway(2),                Hallway(3),
    Room(Amber, 1,  Bronze()), Room(Amber, 2,  Amber()),
    Hallway(4),                Hallway(5),
    Room(Bronze, 1, Copper()), Room(Bronze, 2, Desert()),
    Hallway(6),                Hallway(7),
    Room(Copper, 1, Bronze()), Room(Copper, 2, Copper()),
    Hallway(8),                Hallway(9),
    Room(Desert, 1, Desert()), Room(Desert, 2, Amber()),
    Hallway(10),               Hallway(11)
)

#= Real input burrow state
| #############
| #...........#
| ###B#C#B#D###
|   #A#D#C#A#
|   #########
=#
const REALBURROW = Burrow(
    Hallway(1),
    Hallway(2),                Hallway(3),
    Room(Amber, 1,  Desert()), Room(Amber, 2,  Bronze()),
    Hallway(4),                Hallway(5),
    Room(Bronze, 1, Bronze()), Room(Bronze, 2, Desert()),
    Hallway(6),                Hallway(7),
    Room(Copper, 1, Amber()),  Room(Copper, 2, Amber()),
    Hallway(8),                Hallway(9),
    Room(Desert, 1, Copper()), Room(Desert, 2, Copper()),
    Hallway(10),               Hallway(11)
)

# Each index in `NEIGHBORS` contains a vector of the indices that can be reached
# from a given index in `Burrow.locations`, given the standard configuration
# produced by `Burrow()`. This is a bit brittle, since this constant will need
# to change if the `Burrow.locations` order changes.
const NEIGHBORS = [
    [2], 
    [1, 3],   [2, 4, 6],    [3, 5],   [4],
    [3, 7],   [6, 8, 10],   [7, 9],   [8],
    [7, 11],  [10, 12, 14], [11, 13], [12],
    [11, 15], [14, 16, 18], [15, 17], [16],
    [15, 19], [18]
]

# We'll use the doorway indices to keep track of where the `Room`s are 
# in the `Burrow`
const DOORWAYS = Dict(
    Room{Amber}  => 3,  Room{Bronze} => 7,
    Room{Copper} => 11, Room{Desert} => 15
)

# Solve Part One ---------------------------------------------------------------

part1(test = false) = test ? solve(TESTBURROW) : solve(REALBURROW)

I’ve also defined the relationships (graph) between the various spaces an amphipod can be and a listing of doorway indices per room type as constants, since these change between puzzle parts.

Part Two – Dig Deep

Same deal as part one, except there are twice as many amphipods and the rooms are twice as deep! Again, we’ll define our inputs and some Burrow metadata as constants and solve.

#= Hardcoded Inputs ------------------------------------------------------------
| It was convenient for this puzzle to simply create the objects in code.
=#

#= Test input burrow state
| #############
| #...........#
| ###B#C#B#D###
|   #D#C#B#A#
|   #D#B#A#C#
|   #A#D#C#A#
|   #########
=#
const TESTBURROW = Burrow(
    Hallway(1),
    Hallway(2),                Hallway(3),
    Room(Amber, 1,  Bronze()), Room(Amber, 2,  Desert()),
    Room(Amber, 3,  Desert()), Room(Amber, 4,  Amber()),
    Hallway(4),                Hallway(5),
    Room(Bronze, 1, Copper()), Room(Bronze, 2, Copper()),
    Room(Bronze, 3, Bronze()), Room(Bronze, 4, Desert()),
    Hallway(6),                Hallway(7),
    Room(Copper, 1, Bronze()), Room(Copper, 2, Bronze()),
    Room(Copper, 3, Amber()),  Room(Copper, 4, Copper()),
    Hallway(8),                Hallway(9),
    Room(Desert, 1, Desert()), Room(Desert, 2, Amber()),
    Room(Desert, 3, Copper()), Room(Desert, 4, Amber()),
    Hallway(10),               Hallway(11)
)

#= Real input burrow state
| #############
| #...........#
| ###D#B#A#C###
|   #D#C#B#A#
|   #D#B#A#C#
|   #B#D#A#C#
|   #########
=#
const REALBURROW = Burrow(
    Hallway(1),
    Hallway(2),                Hallway(3),
    Room(Amber, 1,  Desert()), Room(Amber, 2,  Desert()),
    Room(Amber, 3,  Desert()), Room(Amber, 4,  Bronze()),
    Hallway(4),                Hallway(5),
    Room(Bronze, 1, Bronze()), Room(Bronze, 2, Copper()),
    Room(Bronze, 3, Bronze()), Room(Bronze, 4, Desert()),
    Hallway(6),                Hallway(7),
    Room(Copper, 1, Amber()),  Room(Copper, 2, Bronze()),
    Room(Copper, 3, Amber()),  Room(Copper, 4, Amber()),
    Hallway(8),                Hallway(9),
    Room(Desert, 1, Copper()), Room(Desert, 2, Amber()),
    Room(Desert, 3, Copper()), Room(Desert, 4, Copper()),
    Hallway(10),               Hallway(11)
)

# Each index in `NEIGHBORS` contains a vector of the indices that can be reached
# from a given index in `Burrow.locations`, given the standard configuration
# produced by `Burrow()`. This is a bit brittle, since this constant will need
# to change if the `Burrow.locations` order changes.
const NEIGHBORS = [
    [ 2],     [ 1,  3], [ 2,  4,  8], 
    [ 3,  5], [ 4,  6], [ 5,  7],
    [ 6],     [ 3,  9], [ 8, 10, 14],
    [ 9, 11], [10, 12], [11, 13],
    [12],     [ 9, 15], [14, 16, 20],
    [15, 17], [16, 18], [17, 19],
    [18],     [15, 21], [20, 22, 26],
    [21, 23], [22, 24], [23, 25],
    [24],     [21, 27], [26]
]

# We'll use the doorway indices to keep track of where the `Room`s are 
# in the `Burrow`
const DOORWAYS = Dict(
    Room{Amber}  => 3,  Room{Bronze} => 9,
    Room{Copper} => 15, Room{Desert} => 21
)

# Solve Part Two ---------------------------------------------------------------

part2(test = false) = test ? solve(TESTBURROW) : solve(REALBURROW)

Wrap Up

So, I’m a bit salty about today’s solution. I’m absolutely certain there is a way to apply an A* heuristic to narrow the search space for this problem, but after several days of off and on poking at this problem, I’ve come up empty-handed. In fact, the best results came from tweaking numbers in the heuristic to try to hone in on the right answer, which felt like guesswork and overfitting, AKA not a systematic or logical approach. This was one of the big reasons I ended up stripping out all the heuristic code and moving on. It’s entirely possible that I somehow overcomplicated the whole endeavor. Ah well. The upshot is that I spent a considerable amount of time reasoning through various permutations of this problem in Julia, so lots of practice! Which, at the end of the day, is the whole point of doing AoC this year. And, I’m almost done!

If you found a different solution (or spotted a mistake in one of mine), please drop me a line!

Update – I Can’t Quit You!

Ok, fine. There was no way I was going to walk away from this problem a failure! Well, not a failure per se, but not as successful as I’d like. Anyway, after some time away from this problem, I found myself contemplating heuristics in the shower, and thought about what I know about A* heuristics:

  • If the heuristic value is zero, then we’re basically doing Dijkstra’s algorithm (our previous approach).
  • If the heuristic value is always less than (or equal to) the real cost of moving from the current position to the target place in the graph, then we’ll definitely find the shortest path, we just may search more paths than we need to.
  • If the heuristic value is always exactly the cost from the current position to the target, then why are we using A*!? We apparently have some way of calculating the shortest path already!
  • If the heuristic value starts to go above the real cost sometimes, then we may not find the exact shortest path, but we’ll find it faster (because we won’t be searching as thoroughly).
  • If the heuristic value is really high, we’ll just be taking the first path through we can find, no “shortest” about it.

The thing is, I kept finding “shortest paths” that were too long, even with heuristics that should have been lower than the actual costs in all cases (like, tracing the path from the amphipod to the first space in their desired room, ignoring any other amphipods in the way). This could only mean one thing: My A* implementation was borked, whether my heuristic calculation was good or not. Dagnabbit! Fixing my A* algorithm yielded a much better result!

#= Calculate a Distance Heuristic ----------------------------------------------
| Since we'll be using an A* pathfinding algorithm to find the shortest path, 
| we need a heuristic, a best guess, at how far away a Burrow is from the
| desired final state.
=#

# Estimate the 'distance' from the desired final state (all the amphipods are
# in the correct rooms). Essentially, we're trying to calculate a value close
# to, but not over, what it would cost to move the `burrow` to the final state
# if everything went perfectly. To do this, we calculate a simple path cost
# for every out-of-place amphipod.
function distance(burrow::Burrow)::Int
    total = 0
    for idx in 1:length(burrow.locations)
        location = burrow[idx]
        isempty(location) && continue
        location isa Room && matches(location) && continue
        total += simplepathcost(idx, burrow)
    end
    return floor(total)
end

# Make a guess at how much it will cost to move an amphipod to their home, 
# assuming everything goes well. Essentially, we're getting the straight line
# distance of the amphipod into the hallway, down the hallway, and into their
# home room.
disttohall(location::Location) = location isa Hallway ? 0 : location.idx
function simplepathcost(idx::Int, burrow::Burrow)
    # Start by calculating the distance from the amphipod's current location
    # to the hallway. This will be `0` if the amphipod is already in the hall.
    startat  = burrow[idx]
    amphipod = startat.occupant
    exitdist = disttohall(startat)
    exitdoor = startat isa Hallway ? startat : burrow[doorway(startat)]

    # Next, calculate the distance down the hall from the closest hallway
    # space to the 'door' of the amphipod's home room.
    homeroom = Room(typeof(amphipod), 0)
    homedoor = burrow[doorway(homeroom)]
    halldist = abs(exitdoor.idx - homedoor.idx)

    # Now, we estimate how far into the room the amphipod will need to go. This
    # bit could be tricky, since if both Amber amphipods need to move, one 
    # goes to the second space and the other goes to the first. So, we're
    # estimating based on the room depth. The 'target space' for a 2-deep
    # room is 1.5, exactly halfway between the two. The 'target space' for
    # a 4-deep room is 3. These provide pretty reliable estimates.
    movecost = COST[amphipod]
    fulldist = exitdist + halldist + ROOMDEPTH
    return fulldist * movecost
end


#= Solve the puzzle ------------------------------------------------------------
| Solve using an A* search strategy, where all the states that can
| be reached from current state are added to a BinaryMinHeap, and the state
| with the lowest cost + distance is taken as the next state to check. Repeat
| until the final state is found. The distance heuristic is useful for skipping
| neighbors to check, reducing the total search space.
=#

const DistanceBurrow = Tuple{Int,Burrow}
Base.isless(lhs::DistanceBurrow, rhs::DistanceBurrow) = lhs[1] < rhs[1] 
function solve(initial::Burrow)
    heap  = BinaryMinHeap{DistanceBurrow}([(distance(initial), initial)])
    open  = Set{Burrow}([initial])
    costs = Dict{Burrow,Int}(initial => 0)

    while !isempty(heap)
        (currdistance, current) = pop!(heap)
        currdistance - costs[current] == 0 && return costs[current]
        delete!(open, current)

        for (nextcost, next) in nextburrows(current)
            foundcost = costs[current] + nextcost
            foundcost >= get!(costs, next, typemax(Int)) && continue
            costs[next] = foundcost
            next  open && continue
            push!(open, next)
            push!(heap, (distance(next)  + foundcost, next))
        end
    end
    error("Could not find a path!")
end

Now, instead of taking 10-12 seconds to solve both parts (real inputs), we get this result:

❯ julia src/Benchmark.jl -d 23
  Activating project at `~/Projects/2021_advent_of_code/JuliaAdventOfCode`

Julia Advent of Code 2021 Benchmarks:

Day 23:
├─ Part 01:  629.294 ms (5153463 allocations: 234.13 MiB)
└─ Part 02:  5.832 s (46804935 allocations: 2.34 GiB)

Yes, I get it, that may not seem like much to YOU, but I’m feeling very satisfied right now. Clearly, I need to use A* more often, because this was a very frustrating experience resulting from me not spotting my egregious implementation errors.