DevOps with Dhawos

Cloud, infrastructure and programming

What I learned doing Advent of code 2024

For the first time I was able to complete Advent of Code completely (and on time). It is a yearly challenge that will present a new puzzle every day from December 1st to December 25th. You can do it in any language you want as you are only required to provide the solution to the puzzle given your input, but you don’t have to actually submit code.

I love using AoC as a learning and practicing opportunity. Today I would like to share what I discovered during this year’s edition.

Little note : I am going to talk about Go in this post because that is the language I used, however you don’t need an advanced knowledge of the language to follow through. The only things you might need to know is that in Go, the slice type is a dynamic array and the map type is an associative array (like dictionnaries in Python for example)

1. Go’s generics

I was already familiar with how generics, can work in other languages. Basically, instead of giving a specific type to a function or a struct definition you can give a generic one. You can also specify constraints on which types should be allowed.

For example to define a Heap type that can work with different types:

type Heap[] []int // Specific implementation
type Heap[T Comparable] []T // Generic one

In any case, while I never really needed generics when working with Go at my job, it was really useful for AoC as it allowed me to define a Grid-like type that I could use throughout all puzzles.

type Grid[T any] struct {
	Nodes         map[Point]T
	Height, Width int
}

It included functions such as finding neighbors which is a VERY common pattern in AoC. All the memes on the r/adventofcode subreddit about 2D grids are a testament to that.

func (grid *Grid[T]) CarthesianNeighbors(point Point) iter.Seq[T] {
	return func(yield func(T) bool) {
		for _, dir := range CarthesianDirections {
			neighbor := point.Add(dir)
			if node, ok := grid.Nodes[neighbor]; ok {
				if !yield(node) {
					break
				}
			}
		}
	}
}

It is very useful because in all the puzzles you might have to represent very different data. With generics, no matter what data you want to work with, you can reuse the same function implementation.

While not really a concern for AoC, it is worth noting that this also does not have any performance penalty at runtime. Behind the scenes, the compiler will figure out on which type this function will be used on and automatically generate a copy of that function for each of those types.

2. Go’s iterators

Go is definitely not the best language to pick up for Advent of Code. While it’s a great tool for some use cases (servers or DevOps tooling for example), for Advent of code you want a lot of functions that works on containers such as slices and maps.

You often get an input in a form of a list, each item on this list must be parsed, some computation must be applied to deduce a value for each element and then you must filter out some outputs or take the sum or product of those outputs. Go does not have map, filter or reduce functions in its standard library.

Of course you can use loops, but that can make the code a little bit longer to write and to navigate. While I know that functional patterns are really not idiomatic in Go (probably due to to the lack of generics until recently), I wanted to try to build them myself to try out generics and iterators as they are a perfect fit for such use cases.

It can make some days really easier. For example here is my part 1 for day 22 :

func part1(input []string) int {
	deriveSecret := func(secret int) int {
		for range 2000 {
			secret = nextSecret(secret)
		}
		return secret
	}
	initialSecrets := functions.Map(slices.Values(input), functions.MustAtoi)
	results := functions.Map(initialSecrets, deriveSecret)
	return functions.Sum(results)
}

This code will simply parse every line in the input and apply a functions to convert strings to int (functions.MustAtoi). Then it will apply the deriveSecret function to each of these entries and sum them.

Iterators were a bit hard to wrap my head around following the official documentation so I’ll try to explain them.

Basically an iterator in Go is a function that takes another function as its argument (named yield, by convention). You then must apply this function to every element in the collection, BUT, you should stop if this function returns false.

So if you want to apply a function to every element you would write something like this :

// Map iterates a sequence by applying a function to each
// element in the input sequence
func Map[T any, U any](input iter.Seq[T], operation func(T) U) iter.Seq[U] {
	return func(yield func(U) bool) {
		for element := range input {
			if !yield(operation(element)) {
				break
			}
		}
	}
}

Another iterator that I wrote, “Pairs”, whose purpose is to take two sequences as input and form pairs by taking each time the next element in both sequences:

// Pair will make pair from two iterators and stop once
// the shortest is exhausted
func Pairs[T any](s1, s2 iter.Seq[T]) iter.Seq[Pair[T]] {
	return func(yield func(Pair[T]) bool) {
		next1, stop1 := iter.Pull(s1)
		defer stop1()
		next2, stop2 := iter.Pull(s2)
		defer stop2()
		for {
			v1, ok1 := next1()
			v2, ok2 := next2()
			if !ok1 || !ok2 {
			    break	
			}
			if !yield(Pair[T]{A: v1, B: v2}) {
				break
			}
		}
	}
}

You can do whatever operation you want inside the iterator function, you just need to call yield on each element unless it returns false. I’m glad that I took the time to learn more about how this concept works in Go.

2. Using profiling tools

At some point I started adding benchmarks to my solutions, just to know if what I did was okay in term of performance.

Now on day 12 I got my working solution pretty quickly. But when browsing the subreddit, I noticed that my runtime was particularly slow compared to what other users with similar setups were reporting. Here is what I had :

❯ go test -bench=.
goos: linux
goarch: amd64
pkg: advent-of-go/solutions/2024/12
cpu: AMD Ryzen 5 5600 6-Core Processor              
BenchmarkPart1-12             10         122588965 ns/op
BenchmarkPart2-12              9         136754725 ns/op
PASS
ok      advent-of-go/solutions/2024/12  2.724s

This means that my runtime was 136ms for part 2. Not necessarily terrible but I wondered if I could do better without changing my approach too much. So while I had no experience with it I started the go profiler just to see if I could get a hint as to what was slowing me down. Here is what I got :

profiler1

I had to search a little bit to find out what this meant but basically I was spending a lot of time in two things :

I didn’t really know how to solve that, maybe I was doing too much allocations, so I looked at my make calls. In Go, the make functions is used to allocate memory for slices and maps among other things. It was the perfect culprit. Here is what I wrote at some point :

    crops := make(map[ds.Point]bool, len(grid.Nodes))

The second argument of the make function is the amount of memory to allocate. Here the length of grid.Nodes was 19600 with my input, but in reality the crops map contained only about 100 elements.

In go, maps are backed by buckets. To store or access a key,value pair in the map, Go will hash the key of the element to find out which bucket holds that data. When I reserve enough space to store 196000 key,value pairs, Go will create a lot more buckets that will need to be garbage collected, which will take some time.

Changing this to :

    crops := make(map[ds.Point]bool, 0)

Made the program run almost 10x faster.

❯ go test -bench=.
goos: linux
goarch: amd64
pkg: advent-of-go/solutions/2024/12
cpu: AMD Ryzen 5 5600 6-Core Processor              
BenchmarkPart1-12             96          11678739 ns/op
BenchmarkPart2-12             78          14791988 ns/op
PASS
ok      advent-of-go/solutions/2024/12  2.306s

Lesson learned : Trying to size correctly for maps is good, but overshooting by such a large margin is a mistake. I will consider this more carefully in the future.

3. Recursion and Memoization

On some days, like day 19 and day 21 the part 2 requires to explore a much bigger solution space than in part 1. So using the same approach usually won’t work if you want an answer before the end of the universe.

There is a technique that is relatively simple to use and can help in those case and that is : Memoization

Memoization is a technique that makes perfect sense once you know it, but back when I started doing AoC, I did not really think about it. Basically the idea is that you want to store the result of expensive computations. Here is what it can look like in practice. I’ll take the day 19 as an example so you might want to skip this part if you plan to do it at a later date.

On that day you were given a list of string such as gbbr and a list of pattern you could use to make up that string : r, wr, b, g, bwu, rb, gb, br. The task was to find all of the possible way to make each string using only the pattern you were given.

When you think about it, you may realize that you can divide this problem into smaller sub-problems which makes it a perfect candidate for a recursion. Which is the idea of calling a function inside itself, the process is as follows :

  1. Try to find all the pattern that can be used to make the string, here we have gb and g
  2. For each pattern that matches try to solve the sub-problem where the pattern is removed and sum those values, here this is bbr and br

If we apply this process we can find that there are two ways to do bbr (b,br and b,b,r) and two ways to do br (b,r and br). That makes 4 in total. Here is the code that does that


func possibleOptions(patterns []string, design string) int {
	sum := 0
	for _, pattern := range patterns {
		if pattern == design {
			sum += 1
			continue
		}
		if strings.Index(design, pattern) == 0 {
			var result, count int
			var cached bool
			subDesign := design[len(pattern):]
			result = possibleOptions(patterns, subDesign)
			sum += result
		}
	}
	return sum
}

You may have noticed that we found how many combination exists for br twice. But under no circumstances would the result for that substring change. So instead of computing this every time, we can just store this in a map. And use this in our function

var cache map[string]int

func possibleOptions(patterns []string, design string) int {
	sum := 0
	for _, pattern := range patterns {
		if pattern == design {
			sum += 1
			continue
		}
		if strings.Index(design, pattern) == 0 {
			var result, count int
			var cached bool
			subDesign := design[len(pattern):]
      // Avoid computation if we already know the result
			if count, cached = cache[subDesign]; cached { 
				result = count
			} else {
				result = possibleOptions(patterns, subDesign)
			}
			sum += result
		}
	}
  // Save the result
	cache[design] = sum
	return sum
}

In general recursion is a very good way to tackle problems that can be easily divided in sub-problems like this one. You can always write a recursive function in an iterative way, but I find that depending on the problem structure, recursion makes it easier to reason about.

As for memoization, the structure is often the same so it’s a good tool to keep in mind when an AoC problem is taking too long to solve.

4. Thought process

I’ve now done almost two events worth of puzzle (20 days in 2023 and the 25 in 2024). So I developed habits, some of which really helped me in the long run :

Closing words

Doing the Advent of Code was a good excuse to write some Go and learn some stuff while also having fun. I don’t have that many opportunities to use Go at my current job so it was really refreshing. That’s the thing about AoC, it’s there, a lot of people are solving it as well, and you can use it for whatever reason such as discovering a new language, challenging yourself or just have some fun on the first few days.

If you did AoC as well I hope you enjoyed it as much as I did, and if you didn’t I encourage you to take a look.

Thanks for reading. Don’t hesitate to give me your feedback on this article on my Bluesky: @dhawos.bsky.social.

See you next time.