Welcome to Advent of Code 2024!
Like every year, I start the challenge with the best attitude and love of being an Elixir programmer. Although I know that at some point, I will go to the “what is this? I hate it” phase, unlike other years, this time, I am committed to finishing Advent of Code and, more importantly, sharing it with you.
I hope you enjoy this series of December posts, where we will discuss the approach for each exercise. But remember that it is not the only one, and the idea of this initiative is to have a great time and share knowledge, so don’t forget to post your solutions and comments and tag us to continue the conversation.
Let’s go for it!
Day 1: Historian Hysteria
Before starting any exercise, I suggest spending some time defining the structure that best fits the problem’s needs. If the structure is adequate, it will be easy to reuse it for the second part without further complications.
In this case, the exercise itself describes lists as the input, so we can skip that step and instead consider which functions of the Enum or List modules can be helpful.
We have this example input:
3 4
4 3
2 5
1 3
3 9
3 3
The goal is to transform it into two separate lists and apply sorting, comparison, etc.
List 1: [3, 4, 2, 1, 3, 3 ]
List 2: [4, 3, 5, 3, 9, 3]
Let’s define a function that reads a file with the input. Each line will initially be represented by a string, so use String.split to separate it at each line break.
def get_input(path) do
path
|> File.read!()
|> String.split("\n", trim: true)
end
["3 4", "4 3", "2 5", "1 3", "3 9", "3 3"]
We will still have each row represented by a string, but we can now modify this using the functions in the Enum module. Notice that the whitespace between characters is constant, and the pattern is that the first element should go into list one and the second element into list two. Use Enum.reduce to map the elements to the corresponding list and get the following output:
%{
first_list: [3, 3, 1, 2, 4, 3],
second_list: [3, 9, 3, 5, 3, 4]
}
I’m using a map so that we can identify the lists and everything is clear. The function that creates them is as follows:
@doc """
This function takes a list where the elements are strings with two
components separated by whitespace.
Example: "3 4"
It assigns the first element to list one and the second to list two,
assuming both are numbers.
"""
def define_separated_lists(input) do
Enum.reduce(input, %{first_list: [], second_list: []}, fn row, map_with_lists ->
[elem_first_list, elem_second_list] = String.split(row, " ")
%{
first_list: [String.to_integer(elem_first_list) | map_with_lists.first_list],
second_list: [String.to_integer(elem_second_list) | map_with_lists.second_list]
}
end)
end
Once we have this format, we can move on to the first part of the exercise.
Part 1
Use Enum.sort to sort the lists ascendingly and pass them to the Enum.zip_with function that will calculate the distance between the elements of both. Note that we are using abs to avoid negative values, and finally, Enum.reduce to sum all the distances.
first_sorted_list = Enum.sort(first_list)
second_sorted_list = Enum.sort(second_list)
first_sorted_list
|> Enum.zip_with(second_sorted_list, fn x, y -> abs(x-y) end)
|> Enum.reduce(0, fn distance, acc -> distance + acc end)
Part 2
For the second part, you don’t need to sort the lists; use Enum.frequencies
and Enum.reduce to get the multiplication of the elements.
frequencies_second_list = Enum.frequencies(second_list)
Enum.reduce(first_list, 0, fn elem, acc ->
elem * Map.get(frequencies_second_list, elem, 0) + acc
end)
That’s it. As you can see, once we have a good structure, the corresponding module, in this case, Enum, makes the operations more straightforward, so it’s worth spending some time defining which input will make our life easier.
You can see the full version of the exercise here.
Day 2: Red-Nosed Reports
The initial function receives a path corresponding to the text file with the input and reads the strings, separating them by newlines. Inside this function, we will also convert each string to a list of integers, using the Enum functions.
def get_input(path) do
path
|> File.read!()
|> String.split("\n", trim: true)
|> Enum.map(&convert_string_to_int_list(&1))
end
With this example:
7 6 4 2 1
1 2 7 8 9
9 7 6 2 1
1 3 2 4 5
8 6 4 4 1
1 3 6 7 9
Our output will look like this:
[
[7, 6, 4, 2, 1],
[1, 2, 7, 8, 9],
[9, 7, 6, 2, 1],
[1, 3, 2, 4, 5],
[8, 6, 4, 4, 1],
[1, 3, 6, 7, 9]
]
We already have a format that allows us to compare integers and validate each report individually. So, let’s do that.
Part 1
For a report to be valid, the following conditions must be met:
- The levels are either all increasing or all decreasing.
- Any two adjacent levels differ by at least one and at most three.
We will use Enum.sort and Enum.filter to get those lists that are sorted either ascending or descending.
Enum.filter(levels, &(is_ascending?(&1) || is_descending?(&1)))
A list is sorted ascending if it matches with Enum.sort(list)A list is sorted descending it if matches with Enum.sort(list, :desc)
defp is_ascending?(list), do: Enum.sort(list) == list
defp is_descending?(list), do: Enum.sort(list, :desc) == list
Once we have the ordered lists, we will now filter those that meet the condition that the distance between their elements is >= 1 and <= 3.
Enum.filter(levels, &valid_levels_distance?(&1, is_valid))
The valid_levels_distance function is a recursive function that iterates over the elements of the list and if it meets the condition it returns true, otherwise, it returns false. In the end, we will have the lists that meet both conditions and only need to count their elements.
path
|> get_input()
|> get_sorted_level_lists()
|> get_valid_adjacent_levels()
|> Enum.count()
Part 2
For this second part, I used a function that wraps the validations. In the previous exercise, each step is separated but here I will define the function is_a_valid_list?
defp is_a_valid_list?(list),
do: (is_ascending?(list) || is_descending?(list)) && valid_levels_distance?(list, false)
If the list is invalid, the following function will remove each level and check if the conditions are met with this operation.
@spec remove_level_and_validate_list(list(), integer) :: list()
def remove_level_and_validate_list(level, index) when index == length(level), do: []
def remove_level_and_validate_list(level, index) do
new_list = List.delete_at(level, index)
if is_a_valid_list?(new_list) do
new_list
else
remove_level_and_validate_list(level, index + 1)
end
end
With this, we will have all the valid lists, whether original or modified, and the last step will be to count their elements.
path
|> get_input()
|> get_valid_lists_with_bad_levels()
|> Enum.count()
I like to use recursive functions for these kinds of exercises because it’s an explicit way to check what happens at each step. But remember that we can also take advantage of Enum and have more compact code. Let me know which approach you prefer.
You can check the full version here.
Day 3: Mull It Over
Let’s start by defining a function to read a text file with the input, a simple File.read!(path). Now, according to the description, the problem screams Regular Expressions, my favorite thing in the world…
Fortunately, the Regex module provides us with everything we need, so we only have to focus on defining the correct patterns.
Spoiler: The second part of the exercise could also be solved with regular expressions, but I’ve taken a different approach, I’ll get to that.
Part 1
Our input is a string, so we can use Regex.scan to get all occurrences of mul(x,y) where x and y are integers, that is, they are made up of one or more digits. Therefore, the \d option will help us obtain them, specifying that they can be one or more digits.
This expression is enough:
~r/mul\((\d+),(\d+)\)/
The function looks like this:
def get_valid_mul_instructions(section) do
regex_valid_multi = ~r/mul\((\d+),(\d+)\)/
Regex.scan(regex_valid_multi, section, capture: :all_but_first)
end
I’m taking advantage of the capture options for a slightly cleaner format, with capture: :all_but_first we directly have a list with the elements we need, for example for mul(2,4) the result would be [“2″, ” 4″].
[["2", "4"], ["5", "5"], ["11", "8"], ["8", "5"]]
In the end, we will have a list like the following, which we can process to convert the elements into integers, multiply them, and add everything together. I used Enum.reduce.
Enum.reduce(correct_instructions, 0, fn [x, y] = _mul, acc ->
String.to_integer(x) * String.to_integer(y) + acc
end)
Part 2
Ah! I almost gave up on this part.
My initial idea was to define a regular expression that would replace the don’t()…do() pattern with any string, like “INVALID,” for example. That way, we would have input without the invalid blocks, and we could reuse all the code from the first section.
After a thousand failed attempts and remembering why I hate regular expressions, I completely changed the approach to use String.split. When it also failed, I realized that at some point I changed the original input, and I was never going to get the correct result… anyway. That’s why the final version ended up being much longer than I would have liked, but I invite you to try regular expressions first and take advantage of Regex to solve this second part smoothly.
In this case, the approach was to use String.split to separate the blocks every time I encountered a don’t() or do() and have a list to iterate through.
def remove_invalid_blocks(section) do
regex = ~r/(don't[(][)]|do[(][)])/
String.split(section, regex, include_captures: true)
end
Something like this:
[
"xmul(2,4)&mul[3,7]!^",
"don't()",
"_mul(5,5)+mul(32,64](mul(11,8)un",
"do()",
"?mul(8,5))",
"don't()",
"mul(2,3)"
]
We can add conditions so that everything between a don’t() and do() block is discarded. Once we have an input without these parts, we can apply the same procedure we used for part one.
The code ends up looking like this:
path
|> get_input()
|> remove_invalid_blocks()
|> get_valid_mul_instructions()
|> sum_multiplication_results()
You can check the full version here.
Day 4: Ceres Search
Ah, this is one of those problems where the structure we define to work with can make things easier or more complicated. We need a matrix representation.
We could use lists to simulate the arrays of other programming languages; however, let’s consider how we will iterate to obtain the elements around a specific item.
It’s easier to have a shortcut indicating the coordinates, something like: item(3,4). With lists, we have to do a bit more manipulation, so I’ll use a map.
The idea is to transform the entry into a map that allows us constant access:
%{
{0, 0} => "M", {0, 1} => "M", {0, 2} => "M",
{1, 0} => "M", {1, 1} => "S", {1, 2} => "A",
{2, 0} => "A", {2, 1} => "M", {2, 2} => "X",
}
Let’s define a function to read a text file with the input and transform each string into coordinates. For this, I will use Enum.with_index.
path
|> File.read!()
|> String.split("\n", trim: true)
|> Enum.with_index(fn element, index -> get_coordinate_format({element, index}) end)
Part 1
Once we have the expected format, then we convert the word we will search for into a list as well.
word = String.graphemes("XMAS")
Now, we filter with our input the positions where the “X” appears. This way, we will save ourselves from checking element by element and only validate those that may be the beginning of the word.
Enum.filter(input, fn {_coordinate, value} -> value == character end)
character = “M”
The output will be something like this:
[
{{0, 4}, "X"},
{{9, 5}, "X"},
{{8, 5}, "X"}
]
Where the format corresponds to {row, column}. And now, we will work on this list. Considering that for a coordinate {x, y} the adjacent positions are the following:
[
{row, colum + 1},
{row, colum - 1},
{row + 1, colum},
{row - 1, colum},
{row - 1, colum + 1},
{row - 1, colum - 1},
{row + 1, colum + 1},
{row + 1, colum - 1}
]
We will iterate in that direction by comparing with the elements. That is, if the position {x, y} = “X” and the coordinate {x, y + 1} = “M” then we will move {x, y + 1} until one of the characters is different from our word.
If we complete the word then we add 1 to our counter (Enum.reduce).
Enum.reduce(coordinates, 0, fn {coord, _elem}, occurrences ->
check_coordinate(coord, word, input) + occurrences
end)
Part 2
For part two use the same approach of looking for the coordinates corresponding to the character “A” to work only with those that have a chance of being what we need.
Enum.filter(input, fn {_coordinate, value} -> value == character end)
character = “A”
And for the comparison of the elements around it I used brute force haha, since there are only 4 possible combinations, I decided to add them directly.
Enum.reduce(coordinates, 0, fn {coordinate, _elem}, acc ->
valid_x_mas(coordinate, input) + acc
end)
def valid_x_mas({row, column} = _coordinate, input) do
top_left = input[{row - 1, column - 1}]
top_right = input[{row - 1, column + 1}]
bottom_left = input[{row + 1, column - 1}]
bottom_right = input[{row + 1, column + 1}]
cond do
top_left == "M" && bottom_left == "M" && top_right == "S" && bottom_right == "S" -> 1
top_left == "M" && bottom_left == "S" && top_right == "M" && bottom_right == "S" -> 1
top_left == "S" && bottom_left == "S" && top_right == "M" && bottom_right == "M" -> 1
top_left == "S" && bottom_left == "M" && top_right == "S" && bottom_right == "M" -> 1
true -> 0
end
end
You can check the full version here.
The post Advent of Code 2024 appeared first on Erlang Solutions.