Advent Of Code 2022 - Day 03

Dec 03, 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 03: Rucksack Reorganisation

Summary: Given a list of strings, for each string find which characters are common to both the left half and the right half of the string. Convert those characters to a priority, [a..z] having priority 1..26 and [A..Z] having priority 27..52. Sum the priorites.

Example input:

aAccbB
BbaacC

In the string aAccbB the character c occurs on both sides. It has priority 3 points. In BbaacC the character a occurs on both sides. It has priority 1. Summing those priorities results in 4.

Find the full description here.

Some of the built-ins in F# make this problem relatively easy. Splitting a string exactly in half can be done by treating it as a list of characters and then using List's splitInto function which splits a list into n equal parts.

F# also has set operations built-in, which we can use to determine which character appears on both sides. Convert each side to a set and find the intersection.

We can then convert the intersection to priorities and sum them.

There's a small twist in the conversion to priorities. Typical programming problems that make you convert characters to integers, do so in ASCII order. In ASCII order, however, [a-z] comes after [A-Z]. So rather than going for the obvious (but wrong in this case) Convert.ToInt32(chr) - Convert.ToInt32('A') + 1 we have to check if the character is lowercase and math based on that.

All together, this is what it can look like. A very straight forward list of steps. It does the trick, but I don't like all the occurrences of List.map.

let convertToPrio itemType =
    if Char.IsLower itemType then Convert.ToInt32(itemType) - Convert.ToInt32('a') + 1
    else Convert.ToInt32(itemType) - Convert.ToInt32('A') + 27

let solve1 (input: string list) =
    input
    |> List.map List.ofSeq
    |> List.map (List.splitInto 2)
    |> List.map (List.map Set.ofList)
    |> List.map Set.intersectMany
    |> List.map Set.toList
    |> List.map (List.map convertToPrio)
    |> List.map List.sum
    |> List.sum

Part 2

Summary: In part two we have to take the list of strings and form groups of three. For each we have to find which character occurs in all three strings. Then we have to sum the same priority conversion for each group.

The idea is the same as part 1. We convert the strings into sets and find their intersection. Then we convert the intersection to priorities and sum them.

F#'s Set.intersectMany takes an arbitrary number of sets so once again the language helps us.

Feeling like there must be a better way than writing all those List.map's, I grouped a few of them a single function.

let solve2 (input: string list) =
    input
    |> List.map List.ofSeq
    |> List.map Set.ofList
    |> List.chunkBySize 3
    |> List.map (fun group ->
        group
        |> Set.intersectMany
        |> Set.toList
        |> List.map convertToPrio
        |> List.sum)
    |> List.sum

Improvements

Repeat after me: "Strings are Sequences." At least they are in F#. Which means that if you want to do something to the list of characters, rather than the string, you can use the Seq module without having to convert the string first. This may be obvious to many F# users, but wasn't to me. Instead, I would use Seq.toList first and then operate on the list.

In this particular case that means we can skip List.ofSeq and instead immediately use Set.ofSeq for part 2. In part 1 we did slightly more, but there, too, we can simplify.

input
|> List.map List.ofSeq
|> List.map (List.splitInto 2)
|> List.map (List.map Set.ofList)

// Becomes

input
|> List.map (Seq.splitInto 2)
|> List.map (Seq.map Set.ofSeq)

The >> operator

All those List.maps's I wasn't happy about can also be simplified. the >> operator combines functions. foo >> bar returns a function that first executes foo and then calls bar with the result.

input
|> List.map ((Seq.splitInto 2)
             >> (Seq.map Set.ofSeq)
             >> Set.intersectMany
             >> Set.toList
             >> (List.map convertToPrio)
             >> List.sum)

It still looks a bit icky, but it's a definite improvement.

One final push

The key insight to improving the code came to me a bit later. It wasn't obvious to me at first because I wrote both parts in a slightly different way, but both parts do mostly the same thing.

Specifically, they convert a group of strings into sets of characters, finds their intersection, converts to priorities and sums them. The only difference is how the groups of strings are formed.

In part 1 the groups are formed by taking each line and splitting it in half. In part 2 the groups are formed by taking groups of three lines.

Knowing that we can write two helpers:

let formGroups formGroupFn lines =
    lines
    |> List.map (Seq.map convertToPrio)
    |> formGroupFn
    |> List.map (Seq.map Set.ofSeq)

let getSumOfIntersection groups =
    groups
    |> Seq.map (Set.intersectMany >> Seq.sum)
    |> Seq.sum

formGroups takes the lines from the input and forms groups based on the function passed. For good measure it also converts to priorities. There's not really a reason not to do this.

getSumOfIntersections does what its name implies. Note the use of the >> operator to prevent a second Seq.map.

With these two helpers, the solutions become almost identical two-liners:

[<AocSolver(2022, 3, Level = 1)>]
let solve1 (input: string list) =
    input
    |> formGroups (List.map (Seq.splitInto 2))
    |> getSumOfIntersection

[<AocSolver(2022, 3, Level = 2)>]
let solve2 (input: string list) =
    input
    |> formGroups (List.chunkBySize 3)
    |> getSumOfIntersection

Since they are nearly identical we could even abstract that away, but that hardly seems worth it.

Reflection

Today's problem was made relatively easy by the language. Writing out the steps and then converting them to code was almost a 1:1 translation. Trying to improve the look of the code in between solutions made it harder to refactor later because the obvious similarity dissapeared.

Looking through other solutions later, I found few of them that were more to the point than my own. I expected big improvements to be possible, but in the end even extracting all duplicate logic saved but a few lines.

I hope it stays this way for future problems. Usually my lack of language knowledge starts to get in the way after about day 10. So far this looks promising for this year.