Advent Of Code 2022 - Day 05

Dec 05, 2022

It's December. Time for snow, slippery roads, hot chocolate and cozy fire places. Also time for Advent of Code. An advent calendar with small, daily programming puzzles, growing progressively more difficult.

Every year I participate in a programming language I did not use for Advent of Code before, in order to learn new ways of doing things and to challenge myself. This year, that language is F#.

Day 05: Supply Stacks

Summary: Given a number of stacks of crates and a list of moves in the form move N from X to Y, determine which crates are on top of each stack after performing all the moves.

Example input:

    [D]    
[N] [C]    
[Z] [M] [P]
 1   2   3 

move 1 from 2 to 1
move 3 from 1 to 3
move 2 from 2 to 1
move 1 from 1 to 2

Crates can only be moved one at a time, so moving a set of N crates will put them on top of another stack in reverse order.

Find the full description here.

Yesterday I talked about parsing being cumbersome in some Advent of Code problems and how I wanted to look into a parser combinator to make parsing easier, but didn't.

At least half of today's problem is a parsing problem. The input is nice and visual, but annoying to parse.

Parsing the input

The input can be separated into two parts. Moves and crates. Starting with the easier one, moves, we see how scanf-like parsing would be very nice here. Alas, it doesn't exist in F#. A hackier solution is to split each line into words, convert them to integers (which will fail for the words), select only the ones for which conversion succeeds and put them into a nice data structure.

type Move = {
    Count: int;
    Src: int;
    Dst: int;
}

let parseMoveLine (line: string) =
    match line.Split(" ") |> Array.choose parseIntOpt with
    | [|count;src;dst|] -> { Count = count; Src = src - 1; Dst = dst - 1 }
    | _ -> failwith $"Failed to parse move line: {line}"

The crates are more annoying, mostly because not all stacks have the same height. You cannot visually see it, but thankfully the lines with missing crates are not truncated. They have the same length as the other lines.

Each spot is represented by three characters. A missing crate is represented by three spaces. A crate has the form [identifier] and finally a stack identifier is just a number surrounded by spaces. We don't have to parse the stack identifiers, but we also won't run into any problems if we just identify them as crates.

Each of the individual pieces is separated from the others with a space. There are various ways to hack together a parser for this. My way was to replace the three spaces representing an empty crate with [-], then remove all occurrences of [ and ] and finally split on space, removing empty entries.

let parseContainerLine (line: string) =
    line.Replace("    ", " [-]")
        .Replace("[", " ")
        .Replace("]", " ")
        .Split(" ", StringSplitOptions.RemoveEmptyEntries)

We aren't there yet. This gives us horizontal lists of crates, but we need vertical ones. Taking a hint from myself from earlier this Advent of Code and wanting to move on to solve the actual problem I hacked something together with mutation. I determined the amount of stacks, made an array of that width seeded with empty lists and built the stacks one line at a time. We have to start at the last line, because we want the tops to be the heads of the list.

let parseContainerLines (lines: string list) =
    let asSegments = lines |> List.map parseContainerLine
    let len = Array.length asSegments[0]
    let stacks = Array.init len (fun _ -> [])
    let filledStacks =
        List.foldBack (fun (elt: string array) stacks ->
            let mutable x = stacks
            for i = 0 to len - 1 do
                x <- (Array.updateAt i (elt[i] :: stacks[i]) x)
            x) asSegments stacks
    Array.map (List.reject ((=) "-")) filledStacks

Not pretty, but it works. We have to end by removing the crates with "-" as their identifier, because those were used as placeholders for missing crates.

Solving the problem

With the parsing solved we can now solve the problem: perform moves on the stacks of crates in order to determine which crates end up on top of each stack after the set of moves has been completed.

Lists in F# behave like stacks. It's cheap to add/remove items at the front. Since the problem statement says that we can only move one crate at a time, that's exactly what we do. We take the first item of the source stack and prepend it to the destination stack. Once again with mutation because I couldn't really wrap my head around mapping / folding.

let step (move: Move) (stacks: string list array) =
    let mutable newStacks = stacks
    for i = 0 to move.Count - 1 do
        let container = List.head newStacks[move.Src]
        newStacks <- Array.updateAt move.Dst (container :: newStacks[move.Dst]) newStacks
        newStacks <- Array.updateAt move.Src (List.tail newStacks[move.Src]) newStacks
    newStacks

Getting the tops and glueing it all together is an exercise for the reader.

Part 2

Summary: Instead of only being able to move one crate at a time, for part two we have to move the amount of crates in every instruction simultaneously, in order.

That's a small change that actually simplifies the step function. Since we have to preserve the order we can do it in one step, taking N items from the head of the source stack and prepending them to the destination stack.

let step2 (move: Move) (stacks: string list array) =
    let mutable newStacks = stacks
    let containers = List.take move.Count newStacks[move.Src]
    newStacks <- Array.updateAt move.Src (List.skip move.Count newStacks[move.Src]) newStacks
    newStacks <- Array.updateAt move.Dst (List.append containers newStacks[move.Dst]) newStacks
    newStacks

Improvements

Everything done, almost all of it sucks in my opinion. It's hacky, ugly and full of mutation. The goal is achieved. Scoring stars is more important than nice code, but I'm also here to learn. So let's improve, shall we?

Merge the two step functions

The loop in the first step function may hide it, but step and step2 are nearly identical. We can remove the loop from step and instead reverse the set of N crates taken from the front of the source list.

If the only difference is reverse or not reverse we can turn step into a higher order function, passing a transformation function as we go.

let step reorder (move: Move) (stacks: string list array) =
    let mutable newStacks = stacks
    let containers = List.take move.Count newStacks[move.Src] |> reorder
    newStacks <- Array.updateAt move.Src (List.skip move.Count newStacks[move.Src]) newStacks
    newStacks <- Array.updateAt move.Dst (List.append containers newStacks[move.Dst]) newStacks
    newStacks

Remove mutation

The worst offenders are step and parseContainerLines, but I also had mutation in my original solve:

let solve1 (input: string list) =
    let (stacks, moves) = parseInput input
    let mutable mutableStacks = stacks
    for move in moves do
        mutableStacks <- step move mutableStacks
    getTopOfStacks mutableStacks

We begin by removing the mutation in step. While we're at it, we will also reorder the arguments so that we can pass step as an argument to List.fold. That way we can also fix the mutation in solve.

let step reorder (stacks: string list array) move =
    let containers = List.take move.Count stacks[move.Src] |> reorder
    stacks
    |> Array.updateAt move.Src (List.skip move.Count stacks[move.Src])
    |> Array.updateAt move.Dst (List.append containers stacks[move.Dst])

[<AocSolver(2022, 5, Level = 1)>]
let solve1 (input: string list) =
    let (stacks, moves) = parseInput input
    moves
    |> List.fold step stacks
    |> getTopOfStacks

Like many for-loops and a mutating variable, we can get rid of that in parseContainerLines by transforming it into a List.fold:

let parseContainerLines (lines: string list) =
    let asSegments = lines |> List.map parseContainerLine
    let len = Array.length asSegments[0]
    let stacks = Array.init len (fun _ -> [])
    let filledStacks =
        List.foldBack (fun (elt: string array) stacks ->
            [0..len-1]
            |> List.fold (fun stacks i -> Array.updateAt i (elt[i] :: stacks[i]) stacks) stacks)
            asSegments stacks
    Array.map (List.reject ((=) "-")) filledStacks

Discover List.transpose

It turns out that F# has a built-in function for transposing a list of lists. It's conveniently called transpose and makes our life a lot easier.

let parseContainerLines (lines: string list) =
    lines
    |> List.map parseContainerLine
    |> List.transpose
    |> List.toArray
    |> Array.map (List.reject ((=) "-"))

Gone is the nested unreadable fold.

Parser combinator

The above changes make the code a lot better, but all things considered the parsing code is still hacky. We can do better. And we should.

FParsec is a parser combinator library for F# and we'll use it to make the parsing for this problem significantly nicer.

Once again, the input consists of two parts. This time we'll work top to bottom.

Parsing crates

As observed above, each crate is either represented by three spaces or by [identifer] or for the last line that we don't care about, by a number surrounded by spaces.

We'll write a parser for a single crate that returns None for a missing crate and Some identifier for crates.

let parseEmptyCrate = pstring "   " >>% None
let parseSingleCrate = (skipAnyChar >>. anyString 1 .>> skipAnyChar) |>> Some
let parseCrate = parseEmptyCrate <|> parseSingleCrate

The >>% operator runs the parser before it and returns the result of the function after. The <|> operator parses one or the other.

The .>>, >>. and .>>. operators combine the parsers around them. The period (.) indicates that the result on that side will be returned. The other result will be ignored.

With the parser for a single crate done, let's extend it to a full line. Since we no longer convert missing crates to [-] we need a slightly different function to form our stacks. List.choose discards any elements that are None so it leaves us with only existing crates.

let cratesToStacks crates =
    crates
    |> List.transpose
    |> List.map (List.choose id)
    |> List.toArray

let parseCrates = sepBy parseCrate (pchar ' ')
let parseCrateLine = parseCrates .>> skipNewline
let parseStacks = (manyTill parseCrateLine newline) |>> cratesToStacks

manyTill runs the first parser many times, until the second parser passes. When it does, it's consumed. This means that the newline separating the crates and the commands is consumed by parseStacks.

Parsing commands

Parsing a single move is a bit more verbose than I'd like, still not beating the scanf-like syntax that I prefer, but it is very straight forward. pipe3 takes three parsers as arguments, performs them in succession and calls the provided function with the results. We use it to build the same Move type as before.

let parseMove =
    pipe3 (skipString "move " >>. pint32)
          (skipString " from " >>. pint32)
          (skipString " to " >>. pint32)
          (fun a b c -> {Count = a; Src = b - 1; Dst = c - 1})

Parsing a line with a command is slightly more involved than for crates because the input file does not end with a newline. Therefore a line containing a command can end either with a newline or with eof.

let parseMoveLine = parseMove .>> (skipNewline <|> eof)

Parsing the full input

Bringing it all together we parse both the stacks and many commands.

let parseInput =  parseStacks .>>. many parseMoveLine

The result of running this parser is the same as the original parse function, so no other code has to change.

Reflection

Today was a very hacky day with a lot of ugly code and annoying parsing. It took quite some effort to clean it all up. More than I'd like. But I'm happy with the final result.

I learned about a parser combinator framework. Discovered List.transpose and got some more practice in refactoring away for-loops and mutation.

The full code for the day is on GitHub.