Author Archives: Julia on Eric Burden

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!

Advent of Code 2021 – Day 8

By: Julia on Eric Burden

Re-posted from: https://www.ericburden.work/blog/2021/12/08/advent-of-code-2021-day-8/

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.

Advent of Code 2021 – Day 8

By: Julia on Eric Burden

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

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 8 – Seven Segment Search

Find the problem description HERE.

The Input – Guess Who’s Back

While today’s input format isn’t super complicated, we’ll be doing a good amount
of parsing and manipulating that input, so it makes sense to ingest it as an
efficient data type. I’ve found that working with Tuples generally results in
faster code than using structs, where it makes sense. Since we know how many
values we’ll have in each input record, we can use NTuples for each part.

const SignalPatterns = NTuple{10,String}
const RawOutputs = NTuple{4,String}
const RawInput = @NamedTuple {patterns::SignalPatterns, outputs::RawOutputs}

function parseline(s::AbstractString)::RawInput
    (patternstr, outputstr) = split(s, " | ")
    sortstr = join  sort  collect
    patterns = Tuple([sortstr(s) for s in split(patternstr)])
    outputs = Tuple([sortstr(s) for s in split(outputstr)])
    return (patterns = patterns, outputs = outputs)
end

function ingest(path)
    return open(path) do f
        [parseline(s) for s in readlines(f)]
    end
end

Part One – Easy Street

Today’s puzzle has us decoding scrambled seven-segment displays. Quite frankly,
I’m appalled at the missed opportunity to have this be Day 7’s puzzle, but you
don’t always get what you want, as a wise man once said. It looks like our
submarine has, like, a bajillion of these seven segment displays, each displaying
four digits of pure chaotic garbage at the moment, and our job is to unscramble
them. Well, that’s probably our job in part two. Our job in part one is just to
figure out how many times the numbers 1, 4, 7, and 8 appear in our
display outputs. This is somewhat easy, because each of these numbers contains
a unique count of the seven segments, like so:

Segments in 1, 4, 7, and 8

All other numbers, it turns out, are represented by either five or six segments.
So, for this part, we just look over all the output values (the ones to the
right of the | in the puzzle input), and count the number of strings of
length 2, 4, 3, or 8.

# Given a `RawOutput` (a Tuple of letter combinations), identify which
# ones represent a number containing a unique number of segments, and
# just count how many you found.
function counteasydigits(outputs::RawOutputs)::Int
    easyfilter(output) = filter(x -> length(x) in [2, 3, 4, 7], output)
    (outputs
     |> easyfilter
     |> length)
end

# Search for the obvious number displays in our outputs and count the
# number found.
function part1(input)
    tooutputs(RI) = map(x -> x.outputs, RI)
    easycount(input) = map(counteasydigits, input)
    (input
     |> tooutputs
     |> easycount
     |> sum)
end

So, that wasn’t too difficult. In fact, it was almost suspiciously easy…

Part Two – Set Me Up

Now we’re paying for the relatively simple part one, and to be honest, we
definitely should have seen this one coming. Now our main job is to decode the
rest of the signals to identify which combination of letters corresponds to each
display of a particular number. To do this, we’ll compare the letters corresponding
to the segments in the display of known numbers (1, 4, 7, 8) to the letters
corresponding to the segments in the display of unknown numbers (0, 2, 3, 5, 6, 9)
to deduce the unknown numbers. We know how many segments are in each of the
unknown number displays (5 segments -> 2, 3, 5; 6 segments -> 0, 6, 9), so
armed with these known lengths and our four known collections of segments, we
can deduce the remaining numbers. In our solution below, we start with the 5-length
signals, then move on to the 6-length signals. For your reference, here are
the segment arrangements that represent the rest of the numbers:

Segments in 2, 3, and 5
Segments in 0, 6, and 9

One example of our approach is identifying the segments that represent the
number 3. Of the 5-segment combinations, only 3 contains all the segments
from the display of 1. Using rules like this for all 6 of the unknown numbers
allows us to identify them and get the problem solution, which is to use our
known signal -> number mapping to get the total value of each of the outputs
and sum them together to solve the puzzle.

function decode(rawinput::RawInput)::Int
    (patterns, outputs) = rawinput

    # Sort the patterns such that the unique patterns are placed in the front
    # by length, and the non-unique patterns are sorted to the back.
    sortpatternsby(x) = length(x) in [5, 6] ? length(x) + 5 : length(x)
    sortedpatterns = sort(collect(patterns), by = sortpatternsby)

    # These are the easy matches, sorted to the front of 
    # sortedpatterns by our sorting function
    patternmatches = [
        (sortedpatterns[1], 1),
        (sortedpatterns[2], 7),
        (sortedpatterns[3], 4),
        (sortedpatterns[4], 8)
    ]

    # Match the 5- and 6-length patterns
    (one, seven, four) = sortedpatterns[1:3]
    fivecharmatches = getfivecharmatches(sortedpatterns[5:7], four, seven)
    sixcharmatches  = getsixcharmatches(sortedpatterns[8:10], four, seven)
    append!(patternmatches, fivecharmatches, sixcharmatches)

    # Now with all the patterns deciphered, let's get the 
    # values in our outputs
    patterndict = Dict(patternmatches)
    numbers = reverse([patterndict[output] for output in outputs])
    return sum(numbers[i] * 10^(i - 1) for i = 1:length(numbers))
end

function getfivecharmatches(patterns, four, seven)::Vector{Tuple{String,Int}}
    matches = []

    # Use a set of letters representing the segments left if you "subtract"
    # the segments in seven from the segments in four to help find the 
    # length 5 displays
    fournotseven = setdiff(four, seven)

    # Rules to identify two, three, and five
    istwo(x)   = seven  x && fournotseven  x
    isthree(x) = seven  x
    isfive(x)  = fournotseven  x

    # Check each length 5 set of segments and identify two, three, and five
    for pattern in patterns
        match = istwo(pattern)   ? (pattern, 2) :
                isthree(pattern) ? (pattern, 3) :
                isfive(pattern)  ? (pattern, 5) : missing
        ismissing(match) && error("Could not match pattern $pattern")
        push!(matches, match)
    end
    return matches
end

function getsixcharmatches(patterns, four, seven)::Vector{Tuple{String,Int}}
    matches = []

    # Rules to identify zero, six, and nine based on known displays
    iszero(x) = four  x && seven  x
    issix(x)  = four  x && seven  x
    isnine(x) = four  x && seven  x

    # Check each length 6 set of segments and identify zero, six, and nine
    for pattern in patterns
        match = iszero(pattern) ? (pattern, 0) :
                issix(pattern)  ? (pattern, 6) :
                isnine(pattern) ? (pattern, 9) : missing
        ismissing(match) && error("Could not match pattern $pattern")
        push!(matches, match)
    end
    return matches
end

function part2(input)
    decodeeach(x) = map(decode, x)
    (input
     |> decodeeach
     |> sum)
end

That’s kind of a lot of code, but each 5- and 6-segment display number has to
have it’s own rule for identification. This code, at least, manages to make all
the identifications in a single pass over the sorted signals. My first crack at
this took three separate passes.

Wrap Up

Hoo boy, today was something. Once I realized I could deduce the unknown numeric
displays from the known displays, it really became a matter of trying to figure
out which order to decode each unknown signal in, and a mental exercise in whether
I could use just the “easy” signals to decode the rest. All in all, this was a
more challenging puzzle that gave me a chance to use (and learn) some of the
set notation operators in Julia.

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