Advent of Code - Day 3

Mon, Jan 03 2022

There has been a gap as I went on a Xmas break. In Australia, we have our Summer holidays at Xmas time so tend to take longer holidays.

Day Three - Part A

Part A is pretty straightforward. The biggest issue was working out how to handle data once I transformed it. I decided to use strings, but more on this later.

The data is a list of strings representing a list of bits.

[ "011000001000", "111000110101", "101000011100", "100110010100","001111111111" ...]

To find the solution, I needed to transform the list to determine the most common bit at each position, i.e. are there more 1 or 0 at each index for the complete list. For the sample above at index 0, there are more 1 than 0, so the result will start with 1 at index 0 and so on until the last index where there are more 0 than 1.

The first thing I wanted to do was convert the data into a list of lists. My two options are to create a charlist or a list. I decided to use a list because I had more options for manipulating a list in the core library (something I realised later is untrue).

    lists = Enum.map(@data, &String.split(&1, "", trim: true))

The next thing I wanted to do was create a list of values for each index.

  defp lists_by_position(lists, length_of_list) do
    for i <- 0..length_of_list - 1 do
      Enum.map(lists, &Enum.at(&1, i))
    end
  end

 # result - list of all bits at each index
 [
  ["0", "1", "0", "1", "1", "0", "0", "0", "1", "0", "0", "0", "0", "0", "1",
  "1", "1", "0", "0", "0", "1", "1", "0", "0", "1", "1", "1", "1", "1", "0",
  "1", "0", "0", "1", "1", "1", "1", "1", "0", "0", "0", "0", "0", "0", "1",
  "1", "1", "0", "1", ...],
  ["0", "0", "0", "0", "1", "0", "1", "1", "1", "0", "1", "0", "0", "0", "0",
  "0", "1", "1", "0", "1", "1", "0", "1", "0", "1", "1", "1", "0", "0", "0",
  "1", "1", "1", "1", "0", "0", "0", "1", "1", "1", "0", "0", "1", "1", "1",
  "0", "1", "1", ...]
  ...
 ]
 

I was then able to count the most common bits for each position. I used an Enum.reduce/3 function and started by counting the 1 for each index. If the number was greater than half of the total, I added 1 to the accumulator; if not, I added 0. Finally, because I am adding to the head of the list, I reversed it before returning the value.

  defp calc_most_common_bit(lists_to_count, length_of_pos_list) do
    Enum.reduce(lists_to_count, [], fn list, acc ->
      ones = Enum.count(list, &(&1 == "1"))
      bit = if ones > length_of_pos_list/2, do: "1", else: "0"
      [bit | acc]
    end)
    |> Enum.reverse()
  end

  # result - the most common bits at each index for the list of data
  ["0", "0", "1", "1", "1", "0", "1", "0", "0", "1", "1", "1"]

calc_most_common_bit gave me the value of gamma represented by the most common bits at each index. To calculate epsilon (The least common bits), I reversed the gamma values.

  defp bitwise_of_gamma(gamma_list) do
    Enum.reduce(gamma_list, [], fn i, acc ->
      [(if i == "1", do: "0", else: "1") | acc]
    end)
    |> Enum.reverse
  end

Now I had the gamma and epsilon values and needed to convert them into integer. To do this, I needed to convert the list of strings to a charlist; I also realised that I could have been working with a charlist all along (Lesson learnt :)). I created a function to convert to charlist and an integer.

  defp to_integer(list) do
    list
    |> List.to_charlist()
    |> List.to_integer(2)
  end

Bringing this all together, I have a function that calculates the power consumption of the submarine.

  def day_three_a() do
    lists =
    Enum.map(@data, &String.split(&1, "", trim: true))

    length_of_raw_list = Enum.count(List.first(lists))

    lists_to_count = lists_by_position(lists, length_of_raw_list)

    length_of_pos_list = Enum.count(List.first(lists_to_count))

    gamma_list = calc_most_common_bit(lists_to_count, length_of_pos_list)
    epsilon_list = bitwise_of_gamma(gamma_list)

    gamma = to_integer(gamma_list)
    epsilon = to_integer(epsilon_list)

    gamma * epsilon
  end

Everything worked, and I have my star!

Day Two - Part B

The second part of this problem took me way longer than it should have, I blame being on holidays, but I made a fundamentally wrong assumption upfront. Below is the solution I ended up with; I will point out my flawed assumption below.

This time I had to filter the list based on the most common bit at each position, discarding values as they failed the test provided in the problem. For the @data I needed to look at each bit value at each index and for the oxygen generator rating keep the numbers that have the most common bits (Defaulting to 1 if it is even) and for the CO2 scrubber rating keep numbers that have the least common values at each index.

As I have done so far, I wanted to use recursion as I find it easier to read and debug when there are issues. I started with filtering for the most common bits for each index. I started with my exit, and I knew that my answer was as soon as I had one value.

  defp filter_by_most_common(matches) when length(matches) == 1, do: matches

Now I needed to add some logic; I started by getting the most common bit at the current position I was testing. I realised that I needed an index value to track where to test the value. So I started with all the data and filtered it down to a single value.

  defp filter_by_most_common(matches, _idx) when length(matches) == 1, do: matches
  defp filter_by_most_common(matches, idx) do
    bit =
      matches
      |> lists_by_position(Enum.count(List.first(matches)))
      |> calc_most_common_bit(Enum.count(matches))
      |> Enum.at(idx)
  end

I could reuse the functions for Part A and grab the bit value based on the index. This is where I had initially made a mistake. Instead of calculating the most common bit for the filtered list, I started by passing in the result of calc_most_common_bit/2. This gave me an incorrect answer and proved challenging to debug. After rereading the question, I realised that the test was based on the most common bit in the remaining list. I wasted over an hour debugging my logic!

Now that I had the most common bit at index = n, I was able to get the matches and pass them back to the function.

  defp filter_by_most_common(matches, _idx) when length(matches) == 1, do: matches
  defp filter_by_most_common(matches, idx) do
    bit =
      matches
      |> lists_by_position(Enum.count(List.first(matches)))
      |> calc_most_common_bit(Enum.count(matches))
      |> Enum.at(idx)

    matches = Enum.filter(matches, &(Enum.at(&1, idx) == bit))

    filter_by_most_common(matches, idx + 1)
  end

Unfortunately, this did not work. After rereading the question, if the most and least common bits were equal, I needed to default to a 1 or a 0, depending on what I calculated. I added some more logic to test the equality of the counts and used a default value for the filter.

  defp filter_by_most_common(matches, _idx, _default) when length(matches) == 1, do: matches
  defp filter_by_most_common(matches, idx, default) do
    bit =
      matches
      |> lists_by_position(Enum.count(List.first(matches)))
      |> calc_most_common_bit(Enum.count(matches))
      |> Enum.at(idx)

    match = Enum.filter(matches, &(Enum.at(&1, idx) == bit))
    dont_match = Enum.filter(matches,
        &(Enum.at(&1, idx) == (if bit == "1", do: "0", else: "1"))
      )

    matches =
      cond do
        Enum.count(match) == Enum.count(dont_match) ->
          Enum.filter(matches, &(Enum.at(&1, idx) == default))
        true -> match
      end

    filter_by_most_common(matches, idx + 1, default)
  end

I spent a bit more time refactoring but, in the end, ended up using a bit of copy/paste coding. I will refactor the recursion and pass in a function to provide a default filter if I get time. In the meantime, I added the following code.

  defp filter_by_least_common(matches, _idx, _default) when length(matches) == 1, do: matches
  defp filter_by_least_common(matches, idx, default) do
    bit =
      matches
      |> lists_by_position(Enum.count(List.first(matches)))
      |> calc_most_common_bit(Enum.count(matches))
      |> bitwise_of_gamma()
      |> Enum.at(idx)

    match = Enum.filter(matches, &(Enum.at(&1, idx) == bit))
    dont_match = Enum.filter(matches, &(Enum.at(&1, idx) == (if bit == "1", do: "0", else: "1")))

    matches =
      cond do
        Enum.count(match) == Enum.count(dont_match) ->
        Enum.filter(matches, &(Enum.at(&1, idx) == default))
        true -> match
      end

    filter_by_least_common(matches, idx + 1, default)
  end

Now that my filters were working, I created a header function and ran the code.

  def day_three_b() do
    lists =
      Enum.map(@data, &String.split(&1, "", trim: true))

    [o2_list] = filter_by_most_common(lists, 0, "1")
    [co2_list] = filter_by_least_common(lists, 0, "0")

    o2 = to_integer(o2_list)
    co2 = to_integer(co2_list)

    o2 * co2
  end

Running using my data, and I get the correct answer … woohoo!