Aetherus

Aetherus

Advent of Code 2022 - Day 7

Today’s challenge is quite interesting. I ended up using Zipper to solve this problem. Maybe I overengineered quite a bit.

The data structure

defmodule FSNode do
  @moduledoc """
  A node representing either a file or a dir.
  """

  @type filename :: String.t

  @type t :: %__MODULE__{
    dir?: boolean,
    size: non_neg_integer,
    children: %{optional(filename) => t}
  }

  defstruct dir?: false, size: 0, children: %{}

  @doc """
  Create a node representing an empty dir.
  """
  @spec dir() :: t
  def dir do
    %__MODULE__{dir?: true}
  end

  @doc """
  Create a node representing a file of a specific size.
  """
  @spec file(non_neg_integer) :: t
  def file(size) do
    %__MODULE__{dir?: false, size: size}
  end

  @doc """
  Adds a child to the current node if the current node is a dir.
  """
  @spec add_child!(current_node :: t, filename, child :: t) :: t
  def add_child!(%__MODULE__{dir?: true} = curr, name, %__MODULE__{} = child) do
    %{curr | size: curr.size + child.size , children: Map.put(curr.children, name, child)}
  end

  def add_child!(%__MODULE__{dir?: false}, _, _) do
    raise ArgumentError, "Can't add children to a file."
  end

  @doc """
  Pop the child of specific filename from the current node.
  """
  @spec pop_child!(dir :: t, filename) :: {child :: t, dir_without_child :: t}
  def pop_child!(%__MODULE__{dir?: true} = curr, name) do
    child = Map.fetch!(curr.children, name)
    {child, %{curr | children: Map.delete(curr.children, name), size: curr.size - child.size}}
  end

  def pop_child!(%__MODULE__{dir?: false}, _name) do
    raise ArgumentError, "File has no children."
  end
end

The zipper

defmodule FSZipper do
  @moduledoc """
  Zipper of FSNode tree.
  """

  @opaque t :: {
    focus :: FSNode.t,
    trail :: [{FSNode.t, FSNode.filename}]
  }

  @spec from_tree(FSNode.t) :: t
  def from_tree(tree) do
    {tree, []}
  end

  @spec cd(t, FSNode.filename) :: t
  def cd({%FSNode{dir?: true} = cwd, [{parent, name} | t]}, "..") do
    {FSNode.add_child!(parent, name, cwd), t}
  end

  def cd({%FSNode{dir?: true} = cwd, trail}, name) do
    {%FSNode{dir?: true} = child, cwd} = FSNode.pop_child!(cwd, name)
    {child, [{cwd, name} | trail]}
  end

  @spec add_child(t, FSNode.filename, FSNode.t) :: t
  def add_child({%FSNode{dir?: true} = cwd, trail}, name, %FSNode{} = node) do
    {FSNode.add_child!(cwd, name, node), trail}
  end

  @spec to_tree(t) :: FSNode.t
  def to_tree({node, []}), do: node
  def to_tree(zipper), do: zipper |> cd("..") |> to_tree()
end

The parser

defmodule FSParser do
  @spec parse([String.t]) :: FSNode.t
  def parse(lines) do
    # `lines` should not contain \n and the end of each line.

    FSNode.dir()
    # 'Cuz there's no `dir /` in the input,
    # but there is `$ cd /`,
    # I just put it there by default.
    |> FSNode.add_child!("/", FSNode.dir())
    |> FSZipper.from_tree()
    |> parse(lines)
    |> FSZipper.to_tree()
  end

  @spec parse(FSZipper.t, [String.t]) :: FSZipper.t
  defp parse(zipper, []), do: zipper

  defp parse(zipper, [line | rest]) do
    case parse_line(line) do
      {:dir, name} ->
        zipper
        |> FSZipper.add_child(name, FSNode.dir())
        |> parse(rest)

      {:file, name, size} ->
        zipper
        |> FSZipper.add_child(name, FSNode.file(size))
        |> parse(rest)

      {:cd, dir} ->
        zipper
        |> FSZipper.cd(dir)
        |> parse(rest)

      _ ->
        parse(zipper, rest)
    end
  end

  @spec parse_line(String.t) ::
    :ls |
    {:cd, FSNode.filename} |
    {:dir, FSNode.filename} |
    {:file, FSNode.filename, size :: non_neg_integer} |
    :garbage
  defp parse_line("$ ls"), do: :ls

  defp parse_line("$ cd " <> dir), do: {:cd, dir}

  defp parse_line("dir " <> name), do: {:dir, name}

  defp parse_line(<<char, _::binary>> = line) when char in ?1..?9 do
    [size, name] = String.split(line, " ", parts: 2, trim: true)
    {:file, name, String.to_integer(size)}
  end

  defp parse_line(_), do: :garbage
end

The solution part

defmodule Day07 do
  @spec part1([String.t]) :: non_neg_integer
  def part1(input) do
    input
    |> FSParser.parse()
    |> filter1([])
    |> Enum.map(& &1.size)
    |> Enum.sum()
  end

  @total_capacity 70000000
  @target_free_capacity 30000000

  @spec part2([String.t]) :: non_neg_integer
  def part2(input) do
    fs = FSParser.parse(input)
    size_to_free = fs.size - (@total_capacity - @target_free_capacity)

    fs
    |> filter2(size_to_free, [])
    |> Enum.map(& &1.size)
    |> Enum.min()
  end

  @cap 100000

  defp filter1(%FSNode{dir?: true, size: size, children: children}, acc) when size > @cap do
    children
    |> Map.values()
    |> Enum.filter(& &1.dir?)
    |> Enum.reduce(acc, &filter1/2)
  end

  defp filter1(%FSNode{dir?: true} = node, acc) do
    node.children
    |> Map.values()
    |> Enum.filter(& &1.dir?)
    |> Enum.reduce([node | acc], &filter1/2)
  end

  defp filter1(%FSNode{dir?: false}, acc), do: acc

  defp filter2(%FSNode{dir?: false}, _size_to_free, acc), do: acc

  defp filter2(%FSNode{dir?: true, size: size}, size_to_free, acc)
       when size < size_to_free,
       do: acc

  defp filter2(%FSNode{dir?: true} = dir, size_to_free, acc) do
    case Enum.filter(Map.values(dir.children), & &1.size >= size_to_free) do
      [] -> [dir | acc]
      children -> Enum.reduce(children, acc, &filter2(&1, size_to_free, &2))
    end
  end
end

Most Liked

LostKobrakai

LostKobrakai

Reading part 1 this morning I didn’t want to deal with trees or whatever, so I just didn’t bother. Now in the evening I noticed I don’t need to deal with half of the stuff in the input. Just go the S3 route of a KV storage of {path, size} and the rest can be computed from there. I did a second pass to calculate all the directory sizes, which made answering the puzzle questions rather simple operations. In the end I was really surprised by how expressive the resulting code turned out to be.

Solution
defmodule Day7 do
  defstruct prefix: nil, files: %{}, dirs: %{}

  def parse_files(text) do
    state =
      text
      |> String.split("\n")
      |> Enum.reject(&(&1 == ""))
      |> Enum.reduce(%__MODULE__{}, fn
        "$ cd /", state ->
          %__MODULE__{state | prefix: "/"}

        "$ cd ..", state ->
          %__MODULE__{state | prefix: Path.dirname(state.prefix)}

        "$ cd " <> dir, state ->
          %__MODULE__{state | prefix: Path.join(state.prefix, dir)}

        "$ ls", state ->
          state

        "dir " <> _, state ->
          state

        file_info, state ->
          [size, filename] = String.split(file_info, " ", parts: 2)
          files = Map.put(state.files, Path.join(state.prefix, filename), String.to_integer(size))
          %__MODULE__{state | files: files}
      end)

    Enum.reduce(state.files, state, fn {path, size}, state ->
      path
      |> stream_of_parent_directories()
      |> Enum.reduce(state, fn dir, state ->
        update_in(state, [Access.key!(:dirs), Access.key(dir, 0)], &(&1 + size))
      end)
    end)
  end

  defp stream_of_parent_directories(path) do
    Stream.unfold(path, fn
      nil ->
        nil

      path ->
        case Path.dirname(path) do
          "/" -> {"/", nil}
          dir -> {dir, dir}
        end
    end)
  end

  def find_sum_of_small_folders(text) do
    text
    |> parse_files()
    |> Map.fetch!(:dirs)
    |> Map.values()
    |> Enum.filter(fn size -> size < 100_000 end)
    |> Enum.sum()
  end

  def find_size_of_smallest_directory_to_delete(text) do
    dirs =
      text
      |> parse_files()
      |> Map.fetch!(:dirs)

    total_space = 70_000_000
    total_used = dirs["/"]
    total_free = total_space - total_used
    size_update = 30_000_000
    size_to_be_freed = size_update - total_free

    dirs
    |> Map.values()
    |> Enum.filter(fn size -> size >= size_to_be_freed end)
    |> Enum.min()
  end
end
mruoss

mruoss

I went with Stream.transform/5 keeping track of a stack of directory_sizes.

Day 7 Livebook

adamu

adamu

This is the first day that broke me :exploding_head:

Not happy with my answer as I’ve clearly over-complicated it and will definitely be reading through everyone else’s. It does complete in less that 1ms though.

I did:

  1. Build up the file tree as a %{path => node} map
  2. Depth-first search to find the directories in order of fewest descendants
  3. Walk the list from 2, updating the dir sizes
stefanluptak

stefanluptak

I feel I went a very similar route. Tree with sizes already calculated and then a custom filter function on top of it.

LiveBook with solution

mudasobwa

mudasobwa

Creator of Cure

Oldie but goldie, my first Elixir library ever Iteraptor came to the rescue after parsing the tree.

|> Iteraptor.reduce(%{}, fn {keys, v}, acc ->
  keys
  |> Enum.slice(0..-2)
  |> Enum.reduce({[], acc}, fn key, {path, acc} ->
    path = [key | path]
    {path, Map.update(acc, path, v, &(v + &1))}
  end)
  |> elem(1)
end) 
# |> Map.filter(&match?({_, v} when v <= 100_000, &1)) |> Map.values() |> Enum.sum()

Where Next?

Popular in Challenges Top

adamu
I said I was on a break, but I took a sneak peak and it looked fun so… Part 1 completes in half a millisecond with a single pass of the ...
New
sasajuric
Note by the Moderators: This topic is to talk about Day 5 of the Advent of Code. For general discussion about the Advent of Code 2018 an...
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
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
gangstead
This is my second year doing AoC in Elixir and my first year doing it with Livebook. When I was doing just plain Elixir I usually set up...
New
Aetherus
This topic is about Day 5 of the Advent of Code 2020 . Thanks to @egze, we have a private leaderboard: https://adventofcode.com/2020/le...
New
bjorng
Note: This topic is to talk about Day 12 of the Advent of Code 2019. There is a private leaderboard for elixirforum members. You can joi...
New
igorb
I found today a bit tedious: advent-of-code-2024/lib/advent_of_code2024/day15.ex at main · ibarakaiev/advent-of-code-2024 · GitHub.
New
bjorng
This topic is about Day 1 of the Advent of Code 2021. We have a private leaderboard (shared with users of Erlang Forums): https://adven...
New
mattbaker
I’m having so much fun working on the “Protohackers” challenges, I never got into Advent of Code much but this has been amazing. The chal...
New

Other popular topics Top

lanycrost
Hi everyone! I need implement if…else if…else condition from my elixir code, and anymore of this control flow structures not work proper...
New
lastday4you
I wanted to check elixir version in phoenix because i found that my elixir is 1.5 but when i use Enum.chunk_by it said the function is un...
New
senggen
Erlang/OTP 25 [erts-13.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] 15:22:35.803 [error] gen_event {lager_file_backend...
New
sorentwo
Hello! tl;dr Announcing Oban, an Ecto based job processing library with a focus on reliability and historical observability. After spen...
977 41022 311
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod -- where is this set? Thanks.
New
quazar
How to set Jason to encode all fields in ecto schema, I don’t care about security and implementing only is taking long list of attributes...
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
myronmarston
The Elixir Typespec docs show the following syntax for keyword lists in typespecs: # ... | [key: type] # keyword lis...
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
magnetic
Hey :wave:t3: Elixir community, I’ve been learning Elixir, and working on some side projects. My editor of choice is VSCode, and althoug...
New

We're in Beta

About us Mission Statement