christhekeele

christhekeele

Advent of Code 2023 - Day 5

Setting this down for the night, as after a quick naive solve for quick part 1 I realize that part 2 is by design computationally expensive to solve by repurposing the code used in part 1. Will update if I get part 2 working fast, I think I have the right idea in mind!

Input

Processing

I generated a list of seeds, and a map-of-maps with each key being a transform type, and each value being a map with input ranges as keys and output ranges as values.

Types
@type seeds :: [integer()]

@type maps :: %{type :: atom => mapping}

@type mapping :: %{Range.t() => Range.t()}

@type input :: {seeds, maps}
Example Input
{[79, 14, 55, 13],
 %{
   seed_to_soil: %{50..97 => 52..99, 98..99 => 50..51},
   soil_to_fertilizer: %{0..14 => 39..53, 15..51 => 0..36, 52..53 => 37..38},
   fertilizer_to_water: %{
     0..6 => 42..48,
     7..10 => 57..60,
     11..52 => 0..41,
     53..60 => 49..56
   },
   water_to_light: %{18..24 => 88..94, 25..94 => 18..87},
   light_to_temperature: %{45..63 => 81..99, 64..76 => 68..80, 77..99 => 45..67},
   temperature_to_humidity: %{0..68 => 1..69, 69..69 => 0..0},
   humidity_to_location: %{56..92 => 60..96, 93..96 => 56..59}
 }}

Source code available here.

defmodule AoC.Day.Five.Input do
  def parse(input_file \\ System.fetch_env!("INPUT_FILE")) do
    [[seeds] | maps] =
      input_file
      |> File.read!()
      |> String.split("\n\n")
      |> Enum.reject(&(&1 == ""))
      |> Enum.map(&String.split(&1, "\n"))
      |> Enum.map(fn line -> Enum.reject(line, &(&1 == "")) end)

    <<"seeds: ">> <> seeds = seeds
    seeds = seeds |> String.split(" ") |> Enum.map(&String.to_integer/1)

    maps = Map.new(maps, &parse_map/1)

    {seeds, maps}
  end

  def parse_map([name | mappings]) do
    name =
      name
      |> String.trim_trailing(" map:")
      |> String.replace("-", "_")
      |> String.to_atom()

    mappings = Map.new(mappings, &parse_mapping/1)
    {name, mappings}
  end

  def parse_mapping(mapping) do
    [output, input, length] =
      mapping
      |> String.split(" ", parts: 3)
      |> Enum.map(&String.to_integer/1)

    {Range.new(input, input + length - 1, 1), Range.new(output, output + length - 1, 1)}
  end
end

Part One

Solution

The benefit of using ranges here is immediately apparent, as I never have to hydrate a full one to lookup a seed’s location. Instead I just scan through input ranges and use an offset to get the correct output of a mapping. Reduce this through all the mappings, and you’re home free.

Source code available here.

defmodule AoC.Day.Five.Part.One do
  def solve({seeds, maps}) do
    seeds
    |> Enum.map(&lookup_seed_location(&1, maps))
    |> Enum.min()
  end

  def lookup_seed_location(seed, maps) do
    [
      :seed_to_soil,
      :soil_to_fertilizer,
      :fertilizer_to_water,
      :water_to_light,
      :light_to_temperature,
      :temperature_to_humidity,
      :humidity_to_location
    ]
    |> Enum.reduce(seed, &perform_mapping(&2, &1, maps))
  end

  def perform_mapping(input, type, maps) do
    {input, type, maps}

    Enum.find_value(maps[type], input, fn {
                                            input_range = input_start.._,
                                            output_start.._
                                          } ->
      if input in input_range do
        offset = input - input_start
        output_start + offset
      end
    end)
  end
end

Part Two

(Too Slow) Solution

Here we learn that our seed inputs are in fact seed ranges. Tempting to just expand those ranges into lists and push each seed through our single seed lookup from before. However, the full input uses ranges billions of seeds wide and this does not perform!

Clearly, a correct solution will need to be smarter about lookups. Here, using ranges obfuscates what we need to do—take an input start value and offset, and preserve that offset throughout our lookups to avoid ever actually having to materialize our input ranges. I’ll probably rewrite the input handler, and part 1 in terms of it, before tackling part 2 again. Perhaps even tomorrow!

Source code available here.

defmodule AoC.Day.Five.Part.Two do
  import AoC.Day.Five.Part.One

  def solve({seeds, maps}) do
    seeds
    |> seeds_to_seed_ranges()
    |> Enum.map(&Range.to_list/1) # This is Bad™ actually
    |> List.flatten()
    |> Enum.map(&lookup_seed_location(&1, maps))
    |> Enum.min()
  end

  def seeds_to_seed_ranges(seeds) do
    seeds
    |> Enum.chunk_every(2)
    |> Enum.map(&Range.new(List.first(&1), List.first(&1) + List.last(&1) - 1, 1))
  end
end

Most Liked

lud

lud

This problem scared me like here we go with big numbers … c’mon, day five!?

My solution for part 2 was to pass the initial ranges to each map. The top iterator is over the input maps because I felt it would be simpler to keep a list of ranges to translate at each step. But I guess it could be possible to pass each seed range to each map before translating the next seed range.

Anyway:

  • initialize the current ranges with the seed ranges
  • for the first “mapper” (like seed-to-soil), translate all current ranges from source to destination. This may require to cut the current ranges at boundaries. For instance if the current range is 10..20 and the mapper is source=0..15 dest=100..115 then this returns a translated range 100..105 (for the 10..15 part) and an other range of 16..20 that was not matched by the mapper.
  • After each range split and translation we have a bunch of translated ranges and a residue of unmatched ranges. According to the rules those are valid ranges, so we concat the two lists and that is our new current ranges.
  • Continue the same process for each remaining mapper.
  • Finally, take the range that has the lowest .first and return that .first.
defmodule AdventOfCode.Y23.Day5 do
  alias AoC.Input, warn: false

  def read_file(file, _part) do
    Input.read!(file)
  end

  def parse_input(input, _part) do
    blocks = input |> String.trim_trailing() |> String.split("\n\n")

    [
      "seeds: " <> seeds_raw,
      "seed-to-soil map:\n" <> seed_to_soil_raw,
      "soil-to-fertilizer map:\n" <> soil_to_fertilizer_raw,
      "fertilizer-to-water map:\n" <> fertilizer_to_water_raw,
      "water-to-light map:\n" <> water_to_light_raw,
      "light-to-temperature map:\n" <> light_to_temperature_raw,
      "temperature-to-humidity map:\n" <> temperature_to_humidity_raw,
      "humidity-to-location map:\n" <> humidity_to_location_raw
    ] = blocks

    %{
      seeds: int_list(seeds_raw),
      seed_to_soil: parse_ranges(seed_to_soil_raw),
      soil_to_fertilizer: parse_ranges(soil_to_fertilizer_raw),
      fertilizer_to_water: parse_ranges(fertilizer_to_water_raw),
      water_to_light: parse_ranges(water_to_light_raw),
      light_to_temperature: parse_ranges(light_to_temperature_raw),
      temperature_to_humidity: parse_ranges(temperature_to_humidity_raw),
      humidity_to_location: parse_ranges(humidity_to_location_raw)
    }
  end

  defp int_list(line) do
    line |> String.split(" ") |> Enum.map(&String.to_integer/1)
  end

  defp parse_ranges(lines) do
    lines
    |> String.split("\n")
    |> Enum.map(&parse_range/1)
  end

  defp parse_range(line) do
    [dest_0, source_0, len] = int_list(line)

    source_range = source_0..(source_0 + len - 1)//1
    dest_range = dest_0..(dest_0 + len - 1)//1
    {source_range, dest_range}
  end

  def part_one(problem) do
    locations = Enum.map(problem.seeds, &find_location(&1, problem))
    Enum.min(locations)
  end

  @path [
    :seed_to_soil,
    :soil_to_fertilizer,
    :fertilizer_to_water,
    :water_to_light,
    :light_to_temperature,
    :temperature_to_humidity,
    :humidity_to_location
  ]

  defp find_location(seed, data) do
    Enum.reduce(@path, seed, fn tl_key, id -> translate(Map.fetch!(data, tl_key), id) end)
  end

  defp translate(ranges, id) do
    case Enum.find(ranges, fn {source_range, _dest_range} -> id in source_range end) do
      {source_range, dest_range} ->
        diff = id - source_range.first
        dest_range.first + diff

      nil ->
        id
    end
  end

  def part_two(problem) do
    ranges =
      problem.seeds
      |> Enum.chunk_every(2)
      |> Enum.map(fn [first, last] -> first..(first + last - 1) end)

    final_ranges = Enum.reduce(@path, ranges, &translate_ranges(Map.fetch!(problem, &1), &2))

    Enum.min_by(final_ranges, & &1.first).first
  end

  defp translate_ranges(mappers, ranges) do
    # For each mapper, split all the ranges into those that are covered by the
    # mapper source and those that are not. The latter can be consumed by the
    # next mapper and so on.
    #
    # Finally return the covered ranges translated by the mapper and the
    # leftover as-is, as they are valid ranges but map 1:1.

    Enum.flat_map_reduce(mappers, ranges, fn {source, _} = mapper, rest_ranges ->
      {covered_ranges, rest_ranges} = split_ranges(rest_ranges, source)
      {Enum.map(covered_ranges, &translate_range(&1, mapper)), rest_ranges}
    end)
    |> case do
      {translated, as_is} -> translated ++ as_is
    end
  end

  defp translate_range(range, {source, dest}) do
    diff = dest.first - source.first
    Range.shift(range, diff)
  end

  defp split_ranges(ranges, source) do
    split_ranges(ranges, source, {[], []})
  end

  defp split_ranges([r | ranges], source, {covered_ranges, rest_ranges}) do
    case split_range(r, source) do
      {nil, rest} ->
        split_ranges(ranges, source, {covered_ranges, rest ++ rest_ranges})

      {covered, nil} ->
        split_ranges(ranges, source, {covered ++ covered_ranges, rest_ranges})

      {covered, rest} ->
        split_ranges(ranges, source, {covered ++ covered_ranges, rest ++ rest_ranges})
    end
  end

  defp split_ranges([], _source, acc) do
    acc
  end

  def split_range(range, source)

  def split_range(ra.._rz = range, _sa..sz) when sz < ra do
    {nil, [range]}
  end

  def split_range(_ra..rz = range, sa.._sz) when sa > rz do
    {nil, [range]}
  end

  def split_range(ra..rz = range, sa..sz) when sa <= ra and sz >= rz do
    {[range], nil}
  end

  def split_range(ra..rz, sa..sz) when sa >= ra and sz >= rz do
    {[sa..rz], [ra..(sa - 1)]}
  end

  def split_range(ra..rz, sa..sz) when sa <= ra and sz <= rz do
    {[ra..sz], [(sz + 1)..rz]}
  end

  def split_range(ra..rz, sa..sz) when sa >= ra and sz <= rz do
    {[sa..sz], [ra..(sa - 1), (sz + 1)..rz]}
  end
end

With my input I have 153 ranges after the last step. It takes between 1 and 2 milliseconds for part 2 (I never have consistent times on my machine…)

rugyoga

rugyoga

Produces the answer in 1ms using native Ranges.

import AOC

aoc 2023, 5 do

  def p1(input) do
    {seeds, maps} = process(input)
    Enum.map(seeds, &maps_number(&1, maps)) |> Enum.min
  end

  def map_number([], n), do: n
  def map_number([{range, _} = fun | maps], n) do
    if n in range do
      apply_fun(fun, n)
    else
      map_number(maps, n)
    end
  end

  def apply_fun({range, dest}, n), do: dest+n-range.first

  def process(input) do
    ["seeds: " <> seeds | maps] = input |> String.split("\n\n", trim: true)
    {parse_nums(seeds),
     maps
      |> Enum.map(
        fn map ->
          [_name, elements] = String.split(map, " map:\n")
            elements
            |> String.split("\n", trim: true)
            |> Enum.map(&parse_nums/1)
            |> Enum.map(&create_mapping/1)
        end
      )}
  end

  def create_mapping([dest, source, length]) do
    {source..(source+length-1), dest}
  end

  def map_range(range, []), do: [range]
  def map_range(arg_range, [{fun_range, _} = fun_def | maps]) do
    if Range.disjoint?(fun_range, arg_range) do
      map_range(arg_range, maps)
    else
      fun_lo..fun_hi = fun_range
      arg_lo..arg_hi = arg_range
      lo = [fun_lo, arg_lo] |> Enum.max
      hi = [fun_hi, arg_hi] |> Enum.min
      [apply_fun(fun_def, lo)..apply_fun(fun_def, hi) |
       (if arg_lo < lo, do: map_range(arg_lo..lo-1, maps), else: [])
       ++ (if hi < arg_hi, do: map_range(hi+1..arg_hi, maps), else: [])]
    end
  end

  def map_ranges(ranges, []), do: normalize_ranges(ranges)
  def map_ranges(ranges, [map | maps]) do
    ranges
    |> normalize_ranges()
    |> Enum.map(fn arg -> map_range(arg, map) end)
    |> List.flatten
    |> map_ranges(maps)
  end

  def normalize_ranges(ranges) do
    ranges
    |> Enum.sort()
    |> merge_ranges()
  end

  def merge_ranges([]), do: []
  def merge_ranges([a]), do: [a]
  def merge_ranges([a | [b | ranges]]) do
    if Range.disjoint?(a, b) do
      [a | merge_ranges([b | ranges])]
    else
      merge_ranges([Enum.min([a.first, b.first])..Enum.max(a.last, b.last) | ranges])
    end
  end

  def maps_number(n, maps) do
    Enum.reduce(maps, n, &map_number/2)
  end

  def parse_nums(nums), do: nums |> String.split(" ", trim: true) |> Enum.map(&String.to_integer/1)

  def p2(input) do
    {seeds, maps} = process(input)
    seeds
    |> Enum.chunk_every(2)
    |> Enum.map(fn [start, length] -> start..(start+length-1) end)
    |> map_ranges(maps)
    |> hd
    |> then(&(&1.first))
  end
end
Aetherus

Aetherus

Pretty fast solution using :gb_trees.

The keys in the trees are {source_low, source_high} and the corresponding values are {destination_low, destination_high} (I don’t like the idea of length, so I just want to convert them to ranges, but comparison of ranges gives warning, so I just convert them to {low, high}).

Maybe using :gb_trees is an overkill. I realized that I can just sort them.

Here’s the Livebook file:

https://github.com/Aetherus/advent-of-code/blob/master/2023/day-05.livemd

Where Next?

Popular in Challenges Top

LostKobrakai
This topic is about Day 9 of the Advent of Code 2020 . Thanks to @egze, we have a private leaderboard: https://adventofcode.com/2020/le...
New
cblavier
Hey there :wave: No magic or algorithmic finesse today, I just finished the challenge and I my code is quite slow (1sec for part1, 3se...
New
LostKobrakai
This one has been quite the ride. Struggled at first to find a good data format to suite the problem. I really like how that turned out b...
New
bjorng
This topic is about Day 18 of the Advent of Code 2020 . Thanks to @egze, we have a private leaderboard: https://adventofcode.com/2020/l...
New
kwando
Phew, this one took a while to get right. My naive attempts was way to slow so I reached for Dijkstras shortest path algorithm.. and that...
New
bjorng
Note: This topic is to talk about Day 18 of the Advent of Code 2019. There is a private leaderboard for elixirforum members. You can joi...
New
antoine-duchenet
Everything went smoothly today. Nothing to change to solve part 2 because I already used memoization for part 1 (it looked like an AoC e...
New
bjorng
This topic is about Day 2 of the Advent of Code 2021. We have a private leaderboard (shared with users of Erlang Forums): https://adven...
New
rugyoga
part 1: https://github.com/rugyoga/aoc2021/blob/main/day8.exs part 2: https://github.com/rugyoga/aoc2021/blob/main/day8b.exs
New
seeplusplus
This one was much easier for me than yesterday’s. Part 1 runs in 22ms and part 2 runs in ~3s. defmodule BridgeRepair do def parse_inpu...
New

Other popular topics Top

marius95
Hello everyone, I try to use an Javascript Event Handler in my root.html.leex file. Therefore I created a function in the app.js file: ...
New
JakeBecker
TL;DR: I’ve just released an implementation of Microsoft’s IDE-independent Language Server Protocol for Elixir. It adds language support ...
1144 53690 245
New
chrismccord
This release brings a number of exciting features, including integration with the new Phoenix LiveDashboard and Phoenix LiveView. There h...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
alice
Hey, Just curious what are the main benefits of Elixir compared to Clojure? When is Elixir more useful than Clojure and vice versa? Th...
New
boundedvariable
I am going through the kafka architecture. All the features what the kafka is providing are already in Erlang. I would like hear your opi...
New
nsuchy
Hi. I’ve noticed that Windows Powershell has it’s own IEX command and you cannot access Elixir’s IEX due to the conflict. This isn’t a cr...
New
axelson
This post is a wiki (feel free to hit the edit button near the bottom right of this post to add your own changes!) This post collects co...
239 47930 226
New
joaquinalcerro
Hi there, I am working with Ecto-Postgresql and I need to call all of the records from a specific table but the table has 40,000 records...
New
hariharasudhan94
Lets say I have map like this fetching from my database %{"_id" =&gt; #BSON.ObjectId&lt;58eb1a7a9ad169198c3dXXXX&gt;, "email" =&gt; ...
New

We're in Beta

About us Mission Statement