Aetherus

Aetherus

Advent of Code 2023 - Day 18

I confess that I asked ChatGPT about the math. It gave me a name of an algorithm called the Shoelace formula. I still have to pay attention to the off-by-1 problem though.

Here’s my code (omit the input parsing part):

actions = [{"R", 6}, {"D", 5}, ...]

directions = %{
  "L" => {0, -1},
  "R" => {0, 1},
  "U" => {-1, 0},
  "D" => {1, 0}
}

vertices =
  for {dir, meters} <- actions,
      reduce: [{0, 0}] do
    [{i, j} | _] = acc ->
      {di, dj} = directions[dir]      
      next_pos = {i + di * meters, j + dj * meters}
      [next_pos | acc]
  end

area =
  vertices
  |> Stream.chunk_every(2, 1, :discard)
  |> Stream.map(fn [{i1, j1}, {i2, j2}] ->
    (i1 - i2) * (j1 + j2)
  end)
  |> Enum.sum()
  |> div(2)
  |> abs()

perimeter = actions |> Enum.map(&elem(&1, 1)) |> Enum.sum()

IO.inspect(area + div(perimeter, 2) + 1)

Part 2 only differs in parsing the input.

Most Liked

Aetherus

Aetherus

It’s a shame that I almost found the shoelace formula myself, but in the end I turned to ChatGPT :joy:

When I came back to my thinking, I found some beauty in math.

Suppose you have a convex polygon. You can pick any point inside that polygon and split that polygon into triangles.

For example, in the image above, you can calculate the area of the polygon ABCDEFG by calculating the area of the triangles PAB, PBC, PCD, …, PGA and adding them up. The area of the triangle PAB is

image

Same for the other triangles.

Be ware that we are doing vector cross product here, so the order matters.

It turns out that this process also works for concave polygons.

In the image above, the area of triangle PCD is

image

which is negative, but that’s OK because the area of the triangle marked 1 is also in PBC and PDE, so it’s counted twice positive so we need to cancel it once. And the area marked 2 is in PDE but not in the polygon, so we also need to cancel it. The negative area of PCD does both jobs.

It turned out that we can not only pick the P inside the polygon, but anywhere, even outside the polygon or on the edge or corner.

For example, if we choose a P outside the polygon ABCDE, we can always create a polygon that encloses P and shares some of the edges of ABCDE (in the image above, that is the polygon ABCQR). The area of the polygon ABCDE is equal to the area of ABCQR minus the area of AEDCQR. Note that P is both inside ABCQR and AEDCQR, so we can just use the formula above to calculate the area of both polygons.

That’s the proof that even when P is outside the polygon, the formula still works.

If we pick P at (0, 0), then we get the triangular form of the shoelace formula.

As for the trapezoid form of the shoelace formula, though it comes from a very different mindset, if we expand each (y1 + y2) * (x1 - x2) to x1 * y1 + x1 * y2 - x2 * y1 - x2 * y2, then we notice that, after sum up all the terms, those terms with the same suffixes are canceled out, and the remaining terms are just a permutation of the triangular form.

rugyoga

rugyoga

I adapted my code from day 10.
Actually ended up completely rewriting it.
But the concept of storing “L”, “F”, “J”, “7”, “|”, “-” helped me.
Took an hour to run on part 2. :blush:

import AOC

aoc 2023, 18 do
  def p1(input) do
    input
    |> parse()
    |> chunker()
    |> Enum.reduce({{0,0}, %{}}, &dig/2)
    |> elem(1)
    |> count_enclosed()
  end

  def dig([{dir1, n1, hex1}, {dir2, n2, hex2}], {{row, col}, map}) do
    {filler, final} = case {dir1, dir2} do
      {"R", "D"} -> {"-", "7"}
      {"R", "U"} -> {"-", "J"}
      {"L", "D"} -> {"-", "F"}
      {"L", "U"} -> {"-", "L"}
      {"D", "R"} -> {"|", "L"}
      {"U", "R"} -> {"|", "F"}
      {"D", "L"} -> {"|", "J"}
      {"U", "L"} -> {"|", "7"}
    end

    {delta_row, delta_col} = %{"R" => {0, 1}, "L" => {0, -1}, "U" => {-1, 0}, "D" => {1, 0}}[dir1]
    coord = {row+delta_row, col+delta_col}
    map = Map.put(map, coord, if(n1 == 1, do: final, else: filler))
    if n1==1 do
      {coord, map}
    else
      dig([{dir1, n1-1, hex1}, {dir2, n2, hex2}], {coord, map})
    end
  end

  def count_enclosed(loop_map) do
    loop_map
    |> Enum.group_by(fn ({{row, _}, _}) -> row end)
    |> Enum.sort()
    |> Enum.map(&count_row/1)
    |> Enum.sum()
  end

  def count_row({_, items}) do
    items
    |> Enum.sort()
    |> Enum.reduce(
      {{false, 0}, {nil, nil}},
      fn {{_, col}, pipe}, {{interior, count}, {last_col, last_turn}} ->
        case pipe do
          "L" -> {{interior, count+1+ add_count(interior, last_col, col)}, {col, "L"}}
          "F" -> {{interior, count+1+ add_count(interior, last_col, col)}, {col, "F"}}
          "J" -> {{if(last_turn == "F", do: not interior, else: interior), count+1}, {col, "J"}}
          "7" -> {{if(last_turn == "L", do: not interior, else: interior), count+1}, {col, "7"}}
          "|" -> {{not interior, count+1 + add_count(interior, last_col, col)}, {col, "|"}}
          "-" -> {{interior, count+1}, {last_col, last_turn}}
        end
      end)
    |> elem(0)
    |> elem(1)
  end

  def add_count(false, _, _), do: 0
  def add_count(true, last_col, col), do: col - last_col - 1

  def chunker(l), do: Enum.chunk_every(l, 2, 1, l)

  def parse(input) do
    input
    |> String.split("\n")
    |> Enum.map(fn line -> line |> String.split(" ", trim: true) |> then(fn [x, y, z] -> {x, String.to_integer(y), z} end) end)
  end

  def extract_hex({_, _, <<"(#", distance::binary-5, direction::binary-1, ")">>}) do
    {Enum.at(["R", "D", "L", "U"], String.to_integer(direction, 16)), String.to_integer(distance, 16), nil}
  end

  def p2(input) do
    input
    |> parse()
    |> Enum.map(&extract_hex/1)
    |> chunker()
    |> Enum.reduce({{0,0}, %{}}, &dig/2)
    |> elem(1)
    |> count_enclosed()
  end
end
midouest

midouest

I did a flood fill for part 1, figured I’d have to implement that ray-casting algorithm for part 2, but got spooked by the large segments. I remembered that folks had mentioned the Shoelace Algorithm for similar problems, so I tried implementing that in Nx. That alone didn’t produce the right answer and I didn’t know enough about the Shoelace Algorithm to tell why, so I converted it to plain Elixir. I had a hunch that the answer was off because the perimeter wasn’t being considered. I don’t fully understand why my math works. :sweat_smile: I just divided the perimeter in half and added one plus the area and it was correct. :woman_shrugging:

Part 1
defmodule Part1 do
  @deltas %{
    "U" => {-1, 0},
    "D" => {1, 0},
    "L" => {0, -1},
    "R" => {0, 1}
  }

  def dig(input) do
    {_, map} =
      for line <- String.split(input, "\n", trim: true),
          reduce: {{0, 0}, MapSet.new()} do
        {curr, acc} ->
          [dir, amt, _] = String.split(line)
          amt = String.to_integer(amt)
          {dy, dx} = @deltas[dir]

          {next, acc} =
            for _ <- 0..(amt - 1), reduce: {curr, acc} do
              {{y, x}, acc} ->
                next = {y + dy, x + dx}
                acc = MapSet.put(acc, next)
                {next, acc}
            end

          {next, acc}
      end

    map
  end

  def bounds(map) do
    {{y0, _}, {y1, _}} = Enum.min_max(map)
    {{_, x0}, {_, x1}} = Enum.min_max_by(map, fn {_, x} -> x end)
    {{y0, x0}, {y1, x1}}
  end

  def print(map) do
    {{y0, x0}, {y1, x1}} = bounds(map)

    for y <- y0..y1 do
      for x <- x0..x1 do
        char = if MapSet.member?(map, {y, x}), do: "#", else: "."
        IO.write(char)
      end

      IO.puts("")
    end
  end

  def interior(map) do
    {{y0, x0}, {y1, x1}} = bounds(map)
    {{y0, x0}, {y1, x1}} = bbox = {{y0 - 1, x0 - 1}, {y1 + 1, x1 + 1}}
    exterior = flood(map, bbox)
    (y1 - y0 + 1) * (x1 - x0 + 1) - MapSet.size(exterior)
  end

  def flood(map, {start, _} = bbox), do: flood(map, bbox, [start], MapSet.new())
  def flood(_, _, [], explored), do: explored

  def flood(map, {{y0, x0}, {y1, x1}} = bbox, [{y, x} = next | frontier], explored) do
    explored = MapSet.put(explored, next)

    neighbors =
      @deltas
      |> Map.values()
      |> Enum.map(fn {dy, dx} -> {y + dy, x + dx} end)
      |> Enum.reject(fn {y, x} = neighbor ->
        y < y0 or y > y1 or x < x0 or x > x1 or
          MapSet.member?(explored, neighbor) or
          MapSet.member?(map, neighbor)
      end)

    frontier = neighbors ++ frontier
    flood(map, bbox, frontier, explored)
  end
end

Part1.dig(input)
|> Part1.interior()
Part 2
defmodule Part2 do
  @deltas %{
    "0" => {0, 1},
    "1" => {1, 0},
    "2" => {0, -1},
    "3" => {-1, 0}
  }

  def dig(input) do
    origin = [0, 0]

    {points, _} =
      input
      |> String.split("\n", trim: true)
      |> Enum.map_reduce(origin, fn line, [y, x] ->
        [_, <<distance::binary-size(5), direction::binary>>] =
          String.split(line, ~r/[()#]/, trim: true)

        distance = String.to_integer(distance, 16)
        {dy, dx} = @deltas[direction]
        next = [y + distance * dy, x + distance * dx]
        {next, next}
      end)

    segments =
      [origin | points]
      |> Enum.chunk_every(2, 1, :discard)

    area =
      segments
      |> Enum.map(fn [[y1, x1], [y2, x2]] -> y1 * x2 - x1 * y2 end)
      |> Enum.sum()
      |> abs()
      |> div(2)

    perimeter =
      segments
      |> Enum.map(fn [[y1, x1], [y2, x2]] -> abs(y2 - y1) + abs(x2 - x1) end)
      |> Enum.sum()
      |> div(2)

    area + perimeter + 1
  end
end

Part2.dig(input)

EDIT: Went back and did part 2 with Nx now that I get the math working in plain Elixir.

Part 2 Nx
    tensor =
      [origin | points]
      |> Enum.chunk_every(2, 1, :discard)
      |> Nx.tensor(type: :f64)

    area_nx =
      tensor
      |> Nx.LinAlg.determinant()
      |> Nx.sum()
      |> Nx.abs()
      |> Nx.divide(2)

    perimeter_nx =
      Nx.abs(Nx.subtract(tensor[[.., 0, 0]], tensor[[.., 1, 0]]))
      |> Nx.add(Nx.abs(Nx.subtract(tensor[[.., 0, 1]], tensor[[.., 1, 1]])))
      |> Nx.sum()
      |> Nx.divide(2)
      |> Nx.add(1)

    Nx.add(area_nx, perimeter_nx) |> Nx.as_type(:s64)

Where Next?

Popular in Challenges Top

woolfred
It is that time of the year again: Advent of Code 2022 :christmas_tree: Day 1 Leaderboard:
New
ehayun
I have 2 arrays: a1 can be any combination of value or nil like that a1 = [1,nil,3] and array 2 the same a2 = [4,2, nil] How do I com...
New
coen.bakker
Since I started using Elixir, I have benefited greatly from being able to study various open-source projects. The codebase of LiveBook, i...
New
liamcmitchell
A frustrating one for me. I spent a long time trying to understand why some combinations resulted in fewer presses and struggled to keep ...
New
bjorng
Note: This topic is to talk about Day 25 of the Advent of Code 2019. There is a private leaderboard for elixirforum members. You can joi...
New
adamu
Nobody’s doing Advent of Code this year? :grinning_face_with_smiling_eyes: I might do the first week or so. For Day 1, first I solved i...
New
Aetherus
This topic is about Day 16 of the Advent of Code 2020 . Thanks to @egze, we have a private leaderboard: https://adventofcode.com/2020/l...
New
code-shoily
Just did part 1. Part 2 seems to be demanding too much of my reading time so will get to that after I am done with some chores. Oh here ...
New
Aetherus
This topic is about Day 7 of the Advent of Code 2020 . Thanks to @egze, we have a private leaderboard: https://adventofcode.com/2020/le...
New
christhekeele
Thought I’d kick today’s thread off! Parsing Enum rocks, so most of my code was actually in parsing input. ▶ Preprocessing input Part 1...
New

Other popular topics Top

Emily
I have VueJS GUIs with the project generated using Webpack. I have Elixir modules that will need to be used by the VueJS GUIs. I forese...
New
lessless
I believe there are people here who are dealing with CSV files import on the daily basis, and since Excel is a really popular tool there ...
New
johnnyicon
Hi all, I’ve just started learning Elixir and Phoenix Framework, so please pardon my n00bness at this stage. I’m trying to use Postgres...
New
fireproofsocks
Forgive me if this is obvious, but how does one delete a database record WITHOUT selecting it first? Ecto.Repo — Ecto v3.14.0 has exampl...
New
RisingFromAshes
I’ve read in another post that it may be possible with a router helper - but I couldn’t find an appropriate one, and tbh, I’m still just ...
New
dblack
I’ve got an issue with an app and I’ve no idea of how to troubleshoot it. I’m hoping someone here might have seen something similar. I p...
New
hariharasudhan94
I would like to know what is the best IDE for elixir development?
New
dogweather
I wrote this comment on r/haskell, and it’s not popular there. :wink: But I think I’m on to something… Haskell reminds me of Java, and e...
New
jononomo
For some reason my phoenix channels are working for me in my local dev environment, but as soon as I deploy via Docker, I get a 403 error...
New
JorisKok
I have a server on AWS, and was running a load test using artillery. When looking at the Phoenix dashboard I see the Ports going to 100% ...
New

We're in Beta

About us Mission Statement