Advent of Code, in Erlang: Day 4

Published Wednesday, December 15, 2021 by Bryan

Here I've been going on and on about how recent Advent Of Code days have had more complex parsing than earlier days, when I completely forgot about Bingo on Day 4. The timestamp in the git repo says I finished this puzzle at 6pm. I think the fact that I was hungry for dinner shows in the code.

parse_file(Filename) ->
    {ok, Data} = file:read_file(Filename),
    [CallString | BoardStrings] = string:split(Data, <<"\n">>, all),
    Call = parse_call(CallString),
    Boards = parse_boards(BoardStrings),
    {Call, Boards}.

parse_call(CallString) ->
    [ binary_to_integer(N) ||
        N <- string:split(CallString, <<",">>, all) ].

parse_boards([<<>>,Row1,Row2,Row3,Row4,Row5|Boards]) ->
    [[parse_row(Row1),
      parse_row(Row2),
      parse_row(Row3),
      parse_row(Row4),
      parse_row(Row5)]
    |parse_boards(Boards)];
parse_boards(_) ->
    [].

parse_row(Row) ->
    [ binary_to_integer(N) ||
        N <- string:split(Row, <<" ">>, all),
        N =/= <<>> ].

Nothing fancy in the parsing this time. All the number-strings get converted to numbers, mostly because we'll need them that way later to calculate the winning board's score anyway. Each board is represented by a list of rows, which are themselves lists of numbers. Pretty cool that Erlang can pull an empty line, plus the five following rows out of a list of lines all at once?

solveA([Next|Rest], Boards) ->
    NewBoards = call_number(Next, Boards),
    case lists:filter(fun winning_board/1, NewBoards) of
        [B|_] ->
            {Next, B};
        [] ->
            solveA(Rest, NewBoards)
    end.

call_number(N, Boards) ->
    [ [ [case E of N -> x; _ -> E end
         || E <- R ]
        || R <- B]
      || B <- Boards ].

winning_board(Board) ->
    lists:any(
      fun(Row) -> lists:all(fun(C) -> C == x end, Row) end,
      Board ++ columns(Board)).

columns(Board) ->
    [ [lists:nth(C, R) || R <- Board] ||
        C <- [1,2,3,4,5] ].

Solving Part 1 involves iterating the list of called numbers until the first board wins. The solveA function returns the last number that was called, and the board that won when that happens.

The call_number/2 function is what convinced me I was tired when I wrote this code. That's a list comprehension over a row of numbers, inside a list comprehension over a board's rows, inside a list comprehension over the boards in the game. I've been called on this habit too many times in code reviews, but I don't know, maybe it's readable here?

The winning_board shows a desire to just find the answer without hassle as well. Instead of trying to recurse directly across row values to check for column completion, I wrote a columns/1 function that reconstructs the board as a list of column lists instead of a list of row lists. Then I just check all ten lists (five row lists plus five column lists) for any completed. I'm sure it's simpler code than the other would have been, but it does feel just a little wasteful to repeatedly reconstruct those lists.

score_win(Call, Board) ->
    lists:sum(
      [lists:sum([ N || N <- Row, N =/= x]) || Row <- Board])
        * Call.

{Call, Boards} = puzzle04:parse_input(Example).
{LastCall, WinningBoard} = puzzle04:solveA(Call, Boards).
4512 = puzzle04:score_win(LastCall, WinningBoard).

Even with some ugly nesting and inefficient list handling, the right answer popped out the other end. Cool.

Part 2 wants not the first winner, but instead the last winner.

solveB([Next|Rest], Boards) ->
    NewBoards = call_number(Next, Boards),
    case lists:partition(fun winning_board/1, NewBoards) of
        {[W], []} ->
            {Next, W};
        {_, NonWinners} ->
            solveB(Rest, NonWinners)
    end.

Okay, the final proof that I was hangry. The solveB/2 implementation above, I consider not bad. It's not the version I found in the file when I opened it to write this post. That version will remain in the git history as incriminating history. This version, though, directly separates the list of boards after the call into two piles: winners and non-winners. If there's just one winner, and no non-winners, we know we've found the last board to win. Otherwise, we ignore any boards that did win, and continue with the NonWinners.

{LastCallB, WinningBoardB} = puzzle04:solveB().
26878 = puzzle04:score_win(LastCallB, WinningBoardB).

My journal suggests that dinner was soup and butter-slathered slices of fresh-baked herbed sourdough. I am certain my brain was thinking of nothing but the scent of rosemary by the time I committed that code. Did you find a cleaner or more efficient way to store/mark/check the bingo boards? Let me know on Twitter (@hobbyist) or on Github (beerriot).

Day 15 has been a bit of a doozy for me. Good luck if you're working on it right now! Check back later for my explanation.