Advent of Code 2021 – Day 4

By: Julia on Eric Burden

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

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 4 – Giant Squid

Find the problem description HERE.

The Input – Board Stiff

I refuse to discuss this “Input” section any further. It’s here. It’s probably
going to be here for a while. Deal with it. (See my posts for previous days if
you’re curious why I’d be slightly salty about needing this section). For
today’s puzzle, the input parsing does like 90% of the heavy lifting. A quick
read of the instructions tells me that I’m going to need Bingo Boards, and
that I’ll need to be able to “mark off” numbers in a sequence and know when
a particular Board has accumulated five marked off numbers in a vertical
or horizontal line. Importantly, diagonal lines are specifically excluded, which
was a bummer because I specifically included them in my first pass at this.

I know how Bingo works! Who needs directions? – Me

I’m using a Dict in each Board to map values to their positions on the board,
to make number lookups O(1). I’m using a BitArray the same shape as the board
values for “marking off” numbers as they’re called, and I’m using a fancy Dict
to tell when a full line has been marked off, creating a winning board.

# Used to indicate whether a given line index is for a row
# or a column
abstract type AbstractIndexType end
struct RowIndex <: AbstractIndexType idx::Int end
struct ColIndex <: AbstractIndexType idx::Int end

# A struct to represent the state of an individual bingo board
# Contains fields for:
# - indexmap:   A Dict whose keys are values in the board and whose
#               values are the index of that board value
# - numbers:    A 2D matrix of the board values
# - found:      A mask for `numbers` to indicate which board numbers
#               have been called
# - linecounts: A Dict used to determine when a full column or row
#               has been drawn. The keys are an AbstractIndexType
#               indicating the line(s) of the number on the board.
#               The values are the count of numbers in that
#               row/column that have been drawn.
mutable struct Board
    indexmap::Dict{Int, Int}
    numbers::Matrix{Int}
    found::BitMatrix
    linecounts::Dict{AbstractIndexType, Int}
end

# Constructor for a `Board`, generates the other fields from `numbers`
function Board(numbers::Matrix{Int}) 
    # Build the `indexmap`
    mapnumbers(n) = map(i -> (numbers[i], i), n)
    (indexmap
        =  numbers
        |> eachindex
        |> mapnumbers
        |> Dict)
    
    found = falses(size(numbers))
    linecounts = Dict()
    Board(indexmap, numbers, found, linecounts)
end

# Call a number for a particular board. As in bingo, when a number
# is called, it should be marked on the board. This function 
# marks which number was called in the board's `found` BitMatrix
# and updates the board's `linecounts` . If playing that number
# fills out a row or column, return `true`, otherwise return `false`.
function play!(number::Int, board::Board)::Bool
    haskey(board.indexmap, number) || return false
    idx = get(board.indexmap, number, 0)
    board.found[idx] = true

    (row, col) = Tuple(CartesianIndices(board.numbers)[idx])
    boardwins = false
    linecountkeys = [RowIndex(row), ColIndex(col)]
    for key in linecountkeys
        linecount = get(board.linecounts, key, 0)
        board.linecounts[key] = linecount + 1
        if linecount + 1 >= 5; boardwins = true; end
    end

    return boardwins
end

# Read in and parse the contents of the input file
function ingest(path)
    open(inputpath) do f
        # Read the first line of numbers and parse into 
        # an array of Ints
        numstr = readuntil(f, "\n\n")
        numbers = [parse(Int, s) for s in split(numstr, ",")]

        # Utility functions to help with parsing 
        mergevectors(x) = reduce(hcat, x)
        alltoint(x) = parse.(Int, x)
        intoboard = Board  collect  transpose
        boards = []

        # Until the end of the file, read up to the next empty line,
        # split the lines, merge into a Matrix, convert all the Matrix
        # strings to integers, transpose the Matrix, then pass it to
        # the Board constructor.
        while !eof(f)
            boardstr = readuntil(f, "\n\n")
            (boardnums 
                = [split(s) for s in split(boardstr, "\n")]
                |> mergevectors
                |> alltoint
                |> intoboard)
            push!(boards, boardnums)
        end
        (numbers, boards) # Return numbers and boards in a Tuple
    end
end

You can use the same method with the linecounts Dict for determining when
diagonal lines have been made too, but since the puzzle forbids it, I won’t
go into it (if you’re curious, let me know in the comments).

Part One – Squid Game

Ok…. We’re under attack by a giant squid, and our solution is to play Bingo
with it. Cool. Makes sense. At least we’re willing to cheat our way through it!

# Play each number on each board until a winner is found, then
# return the sum of the winning board's unmarked numbers times
# the last number drawn.
function part1(numbers, boards)
    for number in numbers
        for board in boards
            if play!(number, board)
                unmarked = board.numbers[.!board.found]
                return sum(unmarked) * number
            end
        end
    end
    error("Could not find a winning board!")
end

There’s not much to say about this code, other that you need to pass part1()
a copy of a list of Boards if you want to use that list for anything else
later, since the Boards are mutated directly. Otherwise, this does exactly
what the comment says it does. Note that play!() return true if marking the
given number for the given board creates a 5-in-a-row for that board, and false
otherwise.

Part Two – There Can Be Only One

In part two, we come to our senses. No, we didn’t rig up any actual defenses
against the squid, but we did decide to cheat in its favor at Bingo. Now
we need to identify the last board to win and make sure we get that one.

using DataStructures

# Play each number on each board until all winners are found, then
# return the sum of the last winning board's unmarked numbers times
# the last number drawn.
function part2(numbers, boards)
    # Convert the Vector of boards into an OrderedSet to allow for
    # convenient iteration in order and easy removal of boards from
    # the set once they "win"
    boardset = OrderedSet{Board}(boards)
    for number in numbers
        for board in boardset
            if play!(number, board)
                # Return for the last winning board
                if length(boardset) == 1
                    unmarked = board.numbers[.!board.found]
                    return sum(unmarked) * number
                end

                # Remove any winning board from the OrderedSet
                delete!(boardset, board)
            end
        end
    end
    error("Could not find a winning board!")
end

This code is very much like the code for part one, with the twist of using an
OrderedSet for our Boards instead of a Vector. This way, we can iterate
through the Boards in order, while also quickly and easily nixing the winning
boards one at a time until only one remains.

Wrap Up

Yesterday, I remarked on some R-like features of Julia that I really enjoyed.
Today, I had fun tinkering with some more Rust-like features in using Types to
specify row/column indices and in the more OO-style syntax of structs. Granted,
structs aren’t particularly object-oriented, but they’re a lot closer than
S3 or S4 objects, in my opinion (if you don’t know what that is, don’t worry
about it). I do find myself missing Traits (from Rust), but I’m slowly getting
used to Julie’s “multiple dispatch” model. The file parsing functions available
in the Julia standard library were super nice to use, too. I know I’ll miss
those when I’m working in R or Rust. All in all, a really solid day.

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