Advent of Code 2023 Started! Day 1 in Elixir and Livebook

2023-12-01
aoc
aoc2023
elixir
livebook

Every year I look forward to December and not because of Christmas, but because of Advent of Code. I don’t try to compete in the leaderboard (pretty tough to do from Europe because of the timezones), but I do enjoy challenging myself in other ways.

For example, in 2020 I decided to solve the whole thing in Rust. It was quite challenging at times (Rust is an amazing language, but the lack of REPL makes it not the best choice for AoC), but definitely made me much more confident in using the language and was a lot of fun. And I did some “mini-REPL” for myself to simplify testing my solutions. That year was also the first time I tried streaming it on YouTube.

Year 2022 was also special: this time, I decided to solve the challenges rotating between 4 languages: Elixir, Rust, Python, and Julia. The roster was built on two principles: only the languages I like (because it’s supposed to be fun!) and each language should be focused on a different paradigm. In this case, those were functional, low-level/Rust (I really don’t know how to classify Rust, it’s a thing in its own category), dynamic, and array/scientific computing. I solved almost all of it, and as an additional bonus my ability to quickly switch between different paradigms improved to some extent. And of course, you can see my solutions and some streams. Sadly, I got corona mid-event, so even though I continued to solve it, I couldn’t continue streaming.

Plus, each year I try to prioritize a couple things:

  • I like my code to be clean. I think it’s impossible to write clean code if you don’t make it a default setting of sorts. So I try to be quick, but I always go through my solutions several time in the end to make them nice to read. Note, that when I talk about clean code I’m talking more “Zen of Python” than “Design Patterns”/“Uncle Bob’s book” (dislike both of these).
  • I try to make my solution reasonably fast. Most AoC tasks should be solvable under 1 second, and though I don’t always stick to this guideline, I do try :)

So, this year I’m back at it again and, again, trying something new as well! I don’t expect to stream much, but I will be doing blog posts with highlights and maybe will do a couple of streams. I’m using multiple languages again, but instead of a fixed sequence as I did in 2022, I will try to go freeform and select the best tool for the job. I’m also trying to stick with Livebook when using Elixir this year to become a bit more efficient at using it. Otherwise, it’s my blessed quartet with Rust, Julia, and Python.

Day 1 Highlights

I used Elixir with Livebook for this one. Solution.

A big learning today was that Regex.scan is not reliable O_O Or at least docs could be improved. Consider:

Same as run/3, but scans the target several times collecting all matches of the regular expression.

all matches of the regular expression” is not what it does. What it actually does, it collects all non-overlapping matches.

This mattered a lot for this task, because in some cases we had strings like this:

eightwothree

You can see that it contains eight, two, and three, but when I used Regex.scan with a regex that could match all of these, it only matched eight and three.

So I needed to use a bit of a recursive magic to make it work:

defp scan_with_overlaps(regex, line) when is_binary(line) do
  scan_with_overlaps(
    regex,
    line,
    Regex.run(regex, line, return: :index, capture: :first),
    []
  )
end

defp scan_with_overlaps(regex, line, [{idx, len}], matches) do
  scan_with_overlaps(
    regex,
    line,
    Regex.run(regex, line, return: :index, capture: :first, offset: idx + 1),
    [String.slice(line, idx, len) | matches]
  )
end

defp scan_with_overlaps(_regex, _line, nil, matches), do: Enum.reverse(matches)

The key here is the offset parameter I pass to Regex.run in the second function body. This prevents the “just matched” pattern from matching again and matches the next one it can find, so overlapping patterns are correctly matched.

As usual, when you read recursive code, it’s important to consider the initial, looping, and termination conditions. We always start with empty list of matches. In the “loop” we accept the “just matched” result as the 3rd argument. It can either be a list with a tuple containing the index of the first matching character and the length of the match or a nil value. If we hit nil, we know that we cannot match anymore, so this is our termination condition.

We also do a micro-optimization that is typical for Elixir: accumulate values in a list by prepending them at the list’s head, and reverse the list once it’s ready. Prepends are O(1) and appends are O(n) so it can matter.

In any case, it was a fun task to do, and I’m looking forward to the second day!

If you enjoyed this content, you can sponsor me on Github to produce more videos / educational blog posts.

And if you're looking for consulting services, feel free to contact me .