Advent of code 2023 - day 1 (F#)

Categories: .NET F#, programming challenge
It's that time of the year again. It's almost Christmas. You might look forward to opening presents, spending time with family, or enjoying good food. But for developers who enjoy a challenge this is the time to time wake up early for new puzzles to unlock. Yes! It's Advent of code. Let's take a look at what Eric Wastl has to offer this year.

Introduction

Advent of code is website with sets of programming puzzles. Every year on December 1 a new Advent starts and for the following 25 days a new puzzle unlocks every day. Overall, puzzles get harder as the advent progresses. These puzzles are a great reason to try out a new or unfamiliar language!
There are also leaderboards for the fastest 100 puzzle solvers of every puzzle.
I enjoy these puzzles very much, but I'm not a competitive person (usually). In this article we will take a look at a few of the puzzles and how to tackle them. All solutions can be found in my repository.

The input for each puzzle can be downloaded from https://adventofcode.com/<year>/day/<day>/input.
Entering input manually in code or a text file each time you want to implement a new puzzle is cumbersome. Luckily the authentication mechanism is very simple and you can implement a utility method to download and parse the input to you liking.
If you inspect this web page with developer tools in your browser you will only see one cookie with the name "session". You can provide session as a cookie, or even just as an HTTP header! In my system I am reading this cookie value from user secrets so that it doesn't have to exist in the repository.

C#
using Microsoft.Extensions.Configuration;
using System;
using System.Net.Http;

public static class InputHelper
{
    private static readonly HttpClient _httpClient = new();
    private static IConfiguration _config;
    static InputHelper()
    {
        InitializeUserSecrets();
        InitializeHttpClient();
    }
    private static void InitializeUserSecrets()
    {
        _config = new ConfigurationBuilder()
        .SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
        .AddUserSecrets(typeof(InputHelper).Assembly)
        .Build();
    }
    private static void InitializeHttpClient()
    {
        string sessionId = _config["sessionId"];
        _httpClient.DefaultRequestHeaders.Add("cookie", $"session={sessionId}");
    }
}

Part 1

[...]

The newly-improved calibration document consists of lines of text; each line originally contained a specific calibration value that the Elves now need to recover. On each line, the calibration value can be found by combining the first digit and the last digit (in that order) to form a single two-digit number.

For example:
1abc2
pqr3stu8vwx
a1b2c3d4e5f
treb7uchet

In this example, the calibration values of these four lines are 12, 38, 15, and 77. Adding these together produces 142.

Consider your entire calibration document. What is the sum of all of the calibration values?


What they're asking here is

As we will operate on character level, it will be easiest to parse the input to a list of char arrays.

F#
let input = AOC.Util.InputHelper.GetInputLines<char[]>("https://adventofcode.com/2023/day/1/input", lineSeparatorRegex = "\n", argumentSeparatorRegex = "")
            |> Async.AwaitTask |> Async.RunSynchronously

We can make use of the integrated collection methods in F# to detect the first and last digit of an array.

F#
(Array.find Char.IsDigit line)
(Array.findBack Char.IsDigit line)

All digits 0-9 are within the range 0x30 to 0x39 in ASCII. So to parse a character to an integer we can subtract 0x30 (or the equivalent '0') from it.

F#
(int (Array.find Char.IsDigit line) - 0x30)
,(int (Array.findBack Char.IsDigit line) - 0x30))

We can map each line to a tuple of these two integers.
Finally we take the sum of these digits that make up a number.
The end results looks like this:

F#
[<EntryPoint>]
let main argv =
    let input = AOC.Util.InputHelper.GetInputLines<char[]>("https://adventofcode.com/2023/day/1/input", lineSeparatorRegex = "\n", argumentSeparatorRegex = "")
                |> Async.AwaitTask |> Async.RunSynchronously
    let digitPairSeq = input 
                        |> Seq.map(fun line -> 
                                        (int (Array.find Char.IsDigit line) - 0x30)
                                        ,(int (Array.findBack Char.IsDigit line) - 0x30))
    let sum = digitPairSeq |> Seq.sumBy(fun (c1, c2) -> c1 * 10 + c2)
    printfn "%i" sum
    0

Part 2

[...]

Your calculation isn't quite right. It looks like some of the digits are actually spelled out with letters: one, two, three, four, five, six, seven, eight, and nine also count as valid "digits".

Equipped with this new information, you now need to find the real first and last digit on each line. For example:

In this example, the calibration values are 29, 83, 13, 24, 42, 14, and 76. Adding these together produces 281.

What is the sum of all of the calibration values?


This is actually not such a big change. Of course, Char.IsDigit won't work anymore.
Let's create a new function lastDigit. This function will be used for each line and should return the last digit (either by full word or by character).
We can create a list of digits in full words. We can then find the element where its string or index last occurs in a line.

F#
let digits = ["one"; "two"; "three"; "four"; "five"; "six"; "seven"; "eight"; "nine";]

We need the indexed version of this list, as we need to find the index in this list whereby the value OR index was last found in the line.
If, for the entire list, we take the max of either line.LastIndexOf(digitName) or line.LastIndexOf(string (i+1)) then we have have found the correct index.
However we need the digit, so this still has to be increased by one.

F#
let lastDigit (line:string) = 
                            digits 
                            |> List.indexed 
                            |> List.maxBy (fun (i, digitName) -> 
                                Math.Max(line.LastIndexOf(digitName), line.LastIndexOf(string (i+1)))) 
                            |> fst  
                            |> (+) 1

You would think that, to get the first digit, we can just do the opposite (min and IndexOf). However IndexOf return -1 if the string was not found so this will result in incorrect results. The workaround here is to increment it by an arbitrary number so that it will never be the minimum character index (ideally this number is equal to the maximum line length of your input).

F#
let addIfMinus1 inc v = if v = -1 then v + inc else v;
let firstDigit (line:string) = 
                            digits 
                            |> List.indexed 
                            |> List.minBy (fun (i, digitName) -> 
                                let i1 = line.IndexOf(digitName)
                                let i2 = line.IndexOf(string (i+1))
                                if i1 = -1 && i2 = -1 then 1000 
                                else if i1 = -1 then i2 
                                else if i2 = -1 then i1 
                                else Math.Min(i1, i2)) 
                            |> fst 
                            |> (+) 1

The logic in the entry point is very similar.

F#
[<EntryPoint>]
let main argv =
    let input = AOC.Util.InputHelper.GetInputLines<char[]>("https://adventofcode.com/2023/day/1/input", lineSeparatorRegex = "\n", argumentSeparatorRegex = "")
                |> Async.AwaitTask |> Async.RunSynchronously
    let digitPairSeq2 = input 
                        |> Seq.map(fun line ->
                                        let strLine = System.String line
                                        let i1 = (firstDigit strLine)
                                        let i2 = (lastDigit strLine)
                                        (i1, i2))
    let sum2 = digitPairSeq2 |> Seq.sumBy(fun (c1, c2) -> c1 * 10 + c2)
    printfn "%i" sum2
    0