Advent of Code 2021 – Day 9

By: Julia on Eric Burden

Re-posted from: https://ericburden.work/blog/2021/12/10/advent-of-code-2021-day-9/

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 9 – Smoke Basin

Find the problem description HERE.

The Input – Enter the Matrix

Not much to say about today’s input that isn’t covered in the code comments
below.

# Today's input is essentially a big block of numbers (0-9).
# Ingest it by reading each line, breaking it into characters,
# storing all the character vectors in a big vector, then 
# converting the 2D vector into a Matrix{Int}
function ingest(path)
    outvectors = open(path) do f
        [collect(line) for line in readlines(f)]
    end
    toint(x) = parse(Int8, x)

    # I transposed the matrix to make debugging easier, since 
    # otherwise it won't print in the same orientation as the input.
    (outmatrix
     =  outvectors
     |> (x -> reduce(hcat, x))
     |> (x -> map(toint, x))
     |> transpose
     |> collect)
    return outmatrix
end

I feel like there’s probably got to be a way to produce a ‘properly’ rotated
Matrix directly, without needing to transpose(), but I haven’t hit upon
it yet. If someone knows how, I’d really appreciate a comment letting me know
what I’ve missed.

Part One – Shifty Business

Our job in part one is to identify each position in a Matrix of height
values that is lower than all four of its neighbors, only considering directly
adjacent neighbors and ignoring diagonals. Now, you could iterate over every
position in the Matrix, check all its neighbors, and save off the indices
that meet the criteria. Or, and hear me out, you could shift the entire
Matrix
up, down, left, and right and do some sort of map-reduce to do the
same thing, just with more array operations. You know what, that second one
sounds more interesting, let’s do that.

# Takes an integer matrix and adds a "ring" of `9`'s to the outside
# of it, essentially "padding" the matrix with the number `9`
function pad(M::Matrix{T}) where T <: Integer
    (rows, cols) = size(M)
    rowpadding = fill(9, (1, cols))
    colpadding = fill(9, (rows + 2, 1))
    
    (vcat(rowpadding, M, rowpadding)
     |> (x -> hcat(colpadding, x, colpadding)))
end

# Find the points in a numeric matrix where the value in that
# position is smaller than the values in the four cardinal 
# directions, if viewed as a map. 
function findlowpoints(heightmap::Matrix{T}) where T <: Integer
    # Create a copy of the heightmap padded with `9`'s
    (rows, cols) = size(heightmap)
    padded = pad(heightmap)

    # Take views of the heightmap that are the same size but
    # shifted either up, down, left, or right. The padding ensures
    # that the `upshift` has a bottom row of all `9`. This ensures
    # that values in  the bottom row of `heightmap` will identify
    # as a 'lowest point' if the values above, to the left, and to
    # the right are also larger. This similarly holds true for values
    # on all four edges of `heightmap`.
    upshift    = view(padded, 1:rows,     2:(cols+1))
    downshift  = view(padded, 3:(rows+2), 2:(cols+1))
    leftshift  = view(padded, 2:(rows+1), 3:(cols+2))
    rightshift = view(padded, 2:(rows+1), 1:cols)

    # Check `heightmap` against all four views. For any point where the
    # value at the corresponding position in all four `shifts` is greater
    # than the value at that point in `heightmap`, that position in the
    # returned BitMatrix will be `1`, otherwise it will be `0`.
    shifts = [rightshift, leftshift, upshift, downshift]
    return mapreduce(S -> S .> heightmap, .&, shifts)
end

# Identify each position in the input that represents a 'low point', 
# and return the sum of the values at those positions.
function part1(input)
    lowpoints = findlowpoints(input)
    return sum(input[lowpoints] .+ 1)
end

Consider this simple example, to help visualize what’s going on:

Original Matrix     Padded Matrix
   _____              _________
  | 3 4 |            | 9 9 9 9 |
  | 4 9 |            | 9 3 4 9 |
   -----             | 9 4 9 9 |
                     | 9 9 9 9 |
                      ---------

Shifted to the...
       _____            _____          _____           _____
Left: | 4 9 |   Right: | 9 3 |    Up: | 4 9 |   Down: | 9 9 |
      | 9 9 |          | 9 4 |        | 9 9 |         | 3 4 |
       -----            -----          -----           -----

         _____
Output: | 1 0 |
        | 0 0 |
         -----

If you look at the index at [1, 1], in the original matrix it has a value of
3. In the ‘shifted’ matrices, index [1, 1] has the values 4, 9, 4, and
9, in the order given above. Since these values are all greater than 3, the
bit in the output BitMatrix will be 1.

Yes, this was an excuse to use a whole lot of Array/Matrix goodness built in
to Julia. I’m not even sorry.

Part Two – Bottoms Up!

Next, we need to find the sizes of the three largest ‘basins’, or contiguous
areas of our map that are less than the maximum height of 9. Theoretically,
this tells us which areas to avoid, but I couldn’t help but notice that the
BitMatrix generated in part one had a bunch of these ‘ridges’ consisting of
lines throughout the map where the value was 9. Yes, these represent the
boundaries of the ‘basins’, but I’d wager they also represent safe paths through
the cave. Ah well, maybe we burn fuel at some weird rate just like those crabs
from earlier. Let’s size up some basins!

# Given a matrix and an index, return the indices of the positions
# above, below, to the left, and to the right of the given index,
# accouting for edges.
function neighborindices(matrix::AbstractMatrix, idx::CartesianIndex)::Vector{CartesianIndex}
    out = []; sizehint!(out, 4)
    (rows, cols) = size(matrix)
    if idx[1] > 1;    push!(out, idx - CartesianIndex(1, 0)); end
    if idx[1] < rows; push!(out, idx + CartesianIndex(1, 0)); end
    if idx[2] > 1;    push!(out, idx - CartesianIndex(0, 1)); end
    if idx[2] < cols; push!(out, idx + CartesianIndex(0, 1)); end
    return out
end

# Given a BitMatrix were a `1` indicates a part of a basin and a `0` 
# indicates a part of a ridge and an index to start search from,
# perform a breadth-first search of `heightmap`, to find all adjacent
# 'basin' points that can be reached from the starting point.
function basinsizeat(heightmap::BitMatrix, idx::CartesianIndex)::Int
    queue = [idx]           # Points to check
    visited = Set(queue)    # Points we've already checked
    cells = 1               # Number of points that have been checked

    # As long as there are still more points to check...
    while !isempty(queue)
        # Take an index from the top of the queue 
        location = pop!(queue)

        # For all of that index's neighbors...
        for neighbor in neighborindices(heightmap, location)
            # If it's already been checked, skip it
            neighbor in visited && continue

            # Mark it as checked
            push!(visited, neighbor)

            # If that neighboring index is part of the basin, add that
            # index to the front of the queue and increment our count
            if heightmap[neighbor]
                pushfirst!(queue, neighbor)
                cells += 1
            end
        end
    end

    return cells
end

# Transform our input into a BitMatrix, where `1`'s indicate basins,
# positions where the value is less than 9. For each lowpoint, in
# the input, perform a breadth-first search for neighboring basin 
# cells and return the count. Once all the basins have been measured,
# get the sizes (by number of included indices) of the three largest
# and return the product of all three.
function part2(input)
    basins = input .< 9
    lowpoints = findlowpoints(input)
    basinsizes = []; sizehint!(basinsizes, sum(lowpoints))
    for index in findall(lowpoints)
        push!(basinsizes, basinsizeat(basins, index))
    end
    sort!(basinsizes, rev = true)
    return prod(basinsizes[1:3])
end

I used some Julia matrix magic in part one, but for part two it’s a pretty standard
breadth-first search starting at each low point. Now, you could easily imagine
a situation in which we’d need to remove low points from our search list for
cases where a single large basin contained multiple low points, but my input
didn’t include any areas like that. Your mileage may vary.

Wrap Up

This was a fun day. It was also the only day I stayed up to actually work on the
puzzle when it unlocked at 11:00 PM CST, due in large part to the fact that I
fell asleep putting my kid to bed and woke up with a truly egregious amount of
energy for that time of night. I managed to finish, both parts in under an hour,
which is pretty good for me since I tend to re-factor as I go (I’m a bit nit-picky).
It seems like the challenge for these is starting to hit the upward slope, but
it’s been a pretty friendly progression so far. It’s still fun, so I’m looking
forward to the next one!

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