Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Advent of Code 2022 - Day 8

advent-of-code/2022/day8.livemd

Advent of Code 2022 - Day 8

Mix.install([
  {:kino, "~> 0.8.0"},
  {:kino_vega_lite, "~> 0.1.4"}
])

Part 1

The task

> you need to count the number of trees that are visible from outside the grid when looking directly along a row or column

> The Elves have already launched a quadcopter to generate a map with the height of each tree (your puzzle input). For example: > > > 30373 > 25512 > 65332 > 33549 > 35390 > > > Each tree is represented as a single digit whose value is its height, where 0 is the shortest and 9 is the tallest. > > A tree is visible if all of the other trees between it and an edge of the grid are shorter than it. Only consider trees in the same row or column; that is, only look up, down, left, or right from any given tree. > > All of the trees around the edge of the grid are visible - since they are already on the edge, there are no trees to block the view.

In the sample arrangement there are 21 trees visible. The 16 trees around the exterior and 5 in the center.

30373
255 2
65 32
3 5 9
35390

Ok, let’s get parsing!

sample_input = """
30373
25512
65332
33549
35390
"""
grid =
  sample_input
  |> String.split("\n", trim: true)
  |> Enum.map(
    &(String.split(&1, "", trim: true)
      |> Enum.map(fn tree ->
        String.to_integer(tree)
      end))
  )

Cool, cool. Let’s bake in some coordinates.

grid =
  sample_input
  |> String.split("\n", trim: true)
  |> Enum.with_index(fn row, y ->
    String.split(row, "", trim: true)
    |> Enum.with_index(fn height, x ->
      {{x, y}, String.to_integer(height)}
    end)
  end)

Cool, cool. Let’s get some structured data for this.

defmodule Tree do
  defstruct [:x, :y, :height, :visible?, :scenic_score]

  def new(coord, height: height) when is_binary(height) do
    new(coord, height: String.to_integer(height))
  end

  def new({x, y}, height: height) do
    %__MODULE__{x: x, y: y, height: height}
  end
end
grid =
  sample_input
  |> String.split("\n", trim: true)
  |> Enum.with_index(fn row, y ->
    String.split(row, "", trim: true)
    |> Enum.with_index(fn height, x ->
      Tree.new({x, y}, height: height)
    end)
  end)

It’s coming together! Let’s get the concept of a “TreeMap” some structured data as well. It will hold the grid of trees as well as the maximum in the x and y directions.

defmodule TreeMap do
  defstruct [:grid, :max_x, :max_y]

  def new(input) do
    grid =
      input
      |> String.split("\n", trim: true)
      |> Enum.with_index(fn row, y ->
        String.split(row, "", trim: true)
        |> Enum.with_index(fn height, x ->
          Tree.new({x, y}, height: height)
        end)
      end)

    %__MODULE__{grid: grid, max_x: max_x(grid), max_y: max_y(grid)}
  end

  def lookup_tree(treemap, {x, y}) do
    treemap.grid
    |> Enum.at(y)
    |> Enum.at(x)
  end

  def lines_of_sight(treemap, tree) do
    {x, y} = {tree.x, tree.y}

    west =
      for west <- x..0, west != x do
        TreeMap.lookup_tree(treemap, {west, y})
      end

    east =
      for east <- (x + 1)..treemap.max_x, east != x do
        TreeMap.lookup_tree(treemap, {east, y})
      end

    north =
      for north <- y..0, north != y do
        TreeMap.lookup_tree(treemap, {x, north})
      end

    south =
      for south <- (y + 1)..treemap.max_y, south != y and south <= treemap.max_y do
        TreeMap.lookup_tree(treemap, {x, south})
      end

    %{west: west, east: east, north: north, south: south}
  end

  def calculate_tree_visibilities(treemap) do
    for x <- 0..treemap.max_x,
        y <- 0..treemap.max_y do
      lookup_tree(treemap, {x, y})
    end
    |> Enum.reduce(treemap, fn tree, acc ->
      visible = visible?(acc, tree)
      update_tree(acc, %{tree | visible?: visible})
    end)
  end

  def visible?(treemap, tree) do
    lines_of_sight(treemap, tree)
    |> Enum.map(fn {_direction, line} ->
      line
      |> Enum.reduce_while(%{visible: true}, fn
        nil, _acc ->
          {:halt, %{visible: true}}

        other_tree, acc ->
          if other_tree.height >= tree.height do
            {:halt, %{visible: false}}
          else
            {:cont, acc}
          end
      end)
    end)
    |> Enum.frequencies()
    |> case do
      %{%{visible: false} => 4} ->
        false

      _ ->
        true
    end
  end

  def count_visible(treemap) do
    for x <- 0..treemap.max_x, y <- 0..treemap.max_y, reduce: 0 do
      acc ->
        tree = lookup_tree(treemap, {x, y})

        if tree.visible? do
          acc + 1
        else
          acc
        end
    end
  end

  def calculate_scenic_scores(treemap) do
    for x <- 0..treemap.max_x,
        y <- 0..treemap.max_y do
      lookup_tree(treemap, {x, y})
    end
    |> Enum.reduce(treemap, fn tree, acc ->
      update_tree(acc, %{tree | scenic_score: scenic_score(treemap, tree)})
    end)
  end

  def scenic_score(treemap, tree) do
    trees_visible(treemap, tree)
    |> Enum.map(fn %{trees: trees} ->
      Enum.count(trees)
    end)
    |> Enum.product()
  end

  def trees_visible(treemap, tree) do
    lines_of_sight(treemap, tree)
    |> Enum.map(fn {direction, line} ->
      line
      |> Enum.reduce_while(%{direction: direction, trees: [], taller_seen: false}, fn
        nil, acc ->
          {:halt, acc}

        _other_tree, acc = %{taller_seen: true} ->
          {:halt, acc}

        other_tree, acc ->
          if other_tree.height >= tree.height do
            {:cont, %{acc | taller_seen: true, trees: acc.trees ++ [other_tree]}}
          else
            {:cont, %{acc | trees: acc.trees ++ [other_tree]}}
          end
      end)
    end)
  end

  def most_scenic(treemap) do
    for x <- 0..treemap.max_x, y <- 0..treemap.max_y, reduce: %{scenic_score: 0} do
      acc ->
        tree = lookup_tree(treemap, {x, y})

        if tree.scenic_score > acc.scenic_score do
          tree
        else
          acc
        end
    end
  end

  def update_tree(treemap, new_tree) do
    updated_grid =
      treemap.grid
      |> List.update_at(new_tree.y, fn row ->
        row
        |> List.update_at(new_tree.x, fn _tree ->
          new_tree
        end)
      end)

    %{treemap | grid: updated_grid}
  end

  def update_tree(treemap, position, update) do
    tree = lookup_tree(treemap, position)
    new_tree = Map.merge(tree, update)
    update_tree(treemap, new_tree)
  end

  defp max_x(grid) do
    Enum.count(Enum.at(grid, 0)) - 1
  end

  defp max_y(grid) do
    Enum.count(grid) - 1
  end
end
sample_treemap =
  sample_input
  |> TreeMap.new()
TreeMap.calculate_tree_visibilities(sample_treemap)
|> TreeMap.count_visible()

That’s the right answer for the sample data, let’s try for a solve!

Yes, we are needlesly iterating through the tree map multiple times. (We could determine visibility and count in one pass.) But I like to see the data transform as an easy way to check the results manually.

defmodule Day8.Part1 do
  def solve(input) do
    input
    |> TreeMap.new()
    |> TreeMap.calculate_tree_visibilities()
    |> TreeMap.count_visible()
  end
end
sample_input
|> Day8.Part1.solve()
puzzle_input = Kino.Input.textarea("paste your puzzle input")
puzzle_input
|> Kino.Input.read()
|> Day8.Part1.solve()

Part 2

Now we want to find any (visible or hidden) tree with the best scenic score

A scenic score is calculated by multiplying the number of trees that are visible from the given tree in each direction. That is, the number of trees in each direction before being blocked by a taller tree. e.g. 1 tree west, 2 trees north, 1 tree east, 3 trees south == 1 * 2 * 1 * 3 == 6

A sample scenic score. Tree {2, 1} (the middle 5 in the second row) is 4

  • North: 1 tree
  • West: 1 tree
  • East: 2 trees
  • South: 2 trees

1 * 1 * 2 * 2 = 4

sample_treemap =
  sample_input
  |> TreeMap.new()
sample_tree = TreeMap.lookup_tree(sample_treemap, {2, 1})

I added trees_visible, scenic_score, and other supporting functions to the existing TreeMap module in Part 1.

TreeMap.trees_visible(sample_treemap, sample_tree)
TreeMap.scenic_score(sample_treemap, sample_tree)
TreeMap.calculate_scenic_scores(sample_treemap)
TreeMap.calculate_scenic_scores(sample_treemap)
|> TreeMap.most_scenic()

Ok! Let’s code that up.

defmodule Day8.Part2 do
  def solve(input) do
    input
    |> TreeMap.new()
    |> TreeMap.calculate_scenic_scores()
    |> TreeMap.most_scenic()
    |> then(fn tree ->
      tree.scenic_score
    end)
  end
end
sample_input
|> Day8.Part2.solve()
puzzle_input
|> Kino.Input.read()
|> Day8.Part2.solve()

That’s the right answer. But now I’m curious. Is that a scenic tree?

puzzle_input
|> Kino.Input.read()
|> TreeMap.new()
|> TreeMap.calculate_scenic_scores()
|> TreeMap.calculate_tree_visibilities()
|> TreeMap.most_scenic()

It is not! Well then what’s the most scenic hidden tree?

puzzle_input
|> Kino.Input.read()
|> TreeMap.new()
|> TreeMap.calculate_scenic_scores()
|> TreeMap.calculate_tree_visibilities()
|> then(fn treemap ->
  treemap.grid
end)
|> List.flatten()
|> Enum.reject(&amp; &amp;1.visible?)
|> Enum.max_by(&amp; &amp;1.scenic_score)

That one.

Graphing the tree map

puzzle_map =
  puzzle_input
  |> Kino.Input.read()
  |> TreeMap.new()
  |> TreeMap.calculate_scenic_scores()
  |> TreeMap.calculate_tree_visibilities()
  |> then(&amp; &amp;1.grid)
  |> List.flatten()
  |> Enum.map(fn tree ->
    %{
      "x" => tree.x,
      "y" => tree.y,
      "height" => tree.height,
      "scenic_score" => tree.scenic_score,
      "visible" => tree.visible?,
      "hidden" => !tree.visible?
    }
  end)
alias VegaLite, as: Vl
Vl.new(width: 700, height: 700, title: "TreeMap by height")
|> Vl.data_from_values(puzzle_map)
|> Vl.mark(:rect, tooltip: [content: "data"])
|> Vl.encode_field(:x, "x", type: :ordinal)
|> Vl.encode_field(:y, "y", type: :ordinal)
|> Vl.encode(:color,
  field: "height",
  type: :quantitative,
  legend: true
)
Vl.new(width: 700, height: 700, title: "TreeMap of visible trees and their height")
|> Vl.data_from_values(puzzle_map |> Enum.filter(&amp; &amp;1["visible"]))
|> Vl.mark(:rect, tooltip: [content: "data"])
|> Vl.encode_field(:x, "x", type: :ordinal)
|> Vl.encode_field(:y, "y", type: :ordinal)
|> Vl.encode(:color,
  field: "height",
  type: :quantitative,
  legend: true
)
Vl.new(width: 700, height: 700, title: "TreeMap of hidden trees and their height")
|> Vl.data_from_values(puzzle_map |> Enum.filter(&amp; &amp;1["hidden"]))
|> Vl.mark(:rect, tooltip: [content: "data"])
|> Vl.encode_field(:x, "x", type: :ordinal)
|> Vl.encode_field(:y, "y", type: :ordinal)
|> Vl.encode(:color,
  field: "height",
  type: :quantitative,
  legend: true
)
Vl.new(width: 600, height: 600, title: "TreeMap by scenic score")
|> Vl.data_from_values(puzzle_map)
|> Vl.mark(:rect, tooltip: [content: "data"])
|> Vl.encode_field(:x, "x", type: :ordinal)
|> Vl.encode_field(:y, "y", type: :ordinal)
|> Vl.encode(:color,
  field: "scenic_score",
  type: :quantitative,
  legend: true
)

Scratch - lines of sight

Let’s determine the lines of sight for a given tree by its coordinates, originating each list at the tree.

max_x = 4
max_y = 4

#     0 1 2 3 4 = x
#     ---------
# 0 | 3 0 3 7 3
# 1 | 2 5 5 1 2
# 2 | 6 5 3 3 2
# 3 | 3 3 5 4 9
# 4 | 3 5 3 9 0
# =
# y

{x, y} = {2, 2}

# finding its column/row neighbors

west =
  for west <- x..0, west != x do
    {west, y}
  end

east =
  for east <- (x + 1)..max_x, east != x do
    {east, y}
  end

north =
  for north <- y..0, north != y do
    {x, north}
  end

south =
  for south <- (y + 1)..max_y, south != y do
    {x, south}
  end

%{west: west, east: east, north: north, south: south}

Scratch - calculating visibility

sample_treemap =
  sample_input
  |> TreeMap.new()
visible_tree = TreeMap.lookup_tree(sample_treemap, {3, 4})
hidden_tree = TreeMap.lookup_tree(sample_treemap, {2, 2})
TreeMap.lines_of_sight(sample_treemap, visible_tree)
|> Enum.map(fn {_direction, line} ->
  line
  |> Enum.reduce_while(%{visible: true}, fn other_tree, acc ->
    if other_tree.height >= visible_tree.height do
      {:halt, %{visible: false}}
    else
      {:cont, acc}
    end
  end)
end)
|> Enum.frequencies()
|> case do
  %{%{visible: false} => 4} ->
    false

  _ ->
    true
end
TreeMap.lines_of_sight(sample_treemap, hidden_tree)
|> Enum.map(fn {_direction, line} ->
  line
  |> Enum.reduce_while(%{visible: true}, fn other_tree, acc ->
    if other_tree.height >= hidden_tree.height do
      {:halt, %{visible: false}}
    else
      {:cont, acc}
    end
  end)
end)
|> Enum.frequencies()
|> case do
  %{%{visible: false} => 4} ->
    false

  _ ->
    true
end