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
2 Likes

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

1 Like

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

Day 7 Livebook

2 Likes

Also went the stack route.
I was wondering if there was a Stream function that took an after_fun. I’ll probably refactor to use Stream.transform/5 (rather than reading in the entire list and recursing over function)!

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()
1 Like

I first extracted the files, keeping the size and the parent directories, and then updated the directories by walking through those files.

With simple recursion

1 Like

Pattern matching + Map of directory size.

This one was a little more challenging until I remembered to use :digraph to make life easier. Not totally happy with my parsing that required the full path as a node key, but I couldn’t think of another way to keep /a/e from being overwritten by a possible /b/e path. Also not super happy about having to walk the graph to update the sizes with the sub-directory sizes but it ended up being efficient enough for this exercise. Also a couple lines of duplication between part1 and part2 that I could have turned into one helper function, but LOC would actually increase so I didn’t think it was worth it.

Very crude benchmarking, but with :timer.tc parsing and solving part 1 was 3192 usec, part 2 was 3321 usec.

1 Like

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
2 Likes

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
3 Likes

Again a bit late to the party and probably more complicated than needed to be, but it feels pretty readable to me. I also had great fun exploring the Path module. That is, until I had a bug changing into parent folders that had me puzzled for easily an hour :see_no_evil:

defmodule ExAOC2022.Day7 do

  @input "./lib/day7_input.txt"

  @disk_size 70_000_000
  @required_space 30_000_000

  def puzzle1() do
    {_path, sizes} = analyze_file_system()

    Enum.reduce(sizes, 0, fn {_, v}, acc -> if v <= 100_000, do: acc + v, else: acc end)
  end

  def puzzle2() do
    {_path, sizes} = analyze_file_system()

    space_left = @disk_size - sizes["/"]
    space_needed = @required_space - space_left

    Map.values(sizes) |> Enum.sort |> Enum.find(&(&1 >= space_needed))
  end

  defp analyze_file_system() do
    @input
    |> file_by_line
    |> Enum.map(&String.split/1)
    |> Enum.reduce({nil, %{}}, &run/2)
  end

  defp run(["$", "cd", "/"], {_, sizes}), do: {Path.rootname("/"), Map.put(sizes, "/", 0)}
  defp run(["$", "cd", ".."], {path, sizes}), do: {parent_dir(path), sizes}
  defp run(["$", "cd", dir], {path, sizes}) do
    path = path |> Path.join(dir)
    {path, Map.put(sizes, path, 0)}
  end
  defp run(["$", _], acc), do: acc
  defp run(["dir", _], acc), do: acc
  defp run([size, _file], {path, sizes}) do
    sizes = for {k, _v} <- sizes, reduce: sizes do
      acc ->
        if String.contains?(path, k) do
          Map.update!(acc, k, &(&1 + String.to_integer(size)))
        else
          acc
        end
    end
    {path, sizes}
  end

  defp run(_, acc), do: acc

  defp parent_dir(path) do
    path
    |> Path.split()
    |> Enum.reverse()
    |> tl()
    |> Enum.reverse
    |> Path.join()
  end


  defp file_by_line(file) do
    file
    |> File.read!()
    |> String.split(~r/\R/)
  end
end

Wow, I like your solution a lot! Very cool

Did not like today’s puzzle. Connection to elves more tenuous than usual and I still don’t follow the logic of how these sizes are being calculated. Had to grit my teeth and push through the second half when I realized the outer directory total wasn’t complete because the commands never back out of the final path :angry:

use of then/1 was basically pure spite at that point

1 Like

Parse

drive =
  input
  |> String.split("\n", trim: true)
  |> Enum.reduce([], fn
    "$ cd " <> dir, acc ->
      [{:cd, dir} | acc]

    "$ ls", acc ->
      acc

    "dir " <> dir, acc ->
      [{:dir, dir} | acc]

    cmd, acc ->
      [size, name] = String.split(cmd, " ")
      [{:file, String.to_integer(size), name} | acc]
  end)
  |> Enum.reverse()
  |> Enum.reduce({%{}, []}, fn
    {:cd, ".."}, {tree, [_x | new_path]} ->
      {tree, new_path}

    {:cd, dir}, {tree, path} ->
      canonical_path = [dir | path] |> Enum.reverse()
      {Map.put_new(tree, canonical_path, []), [dir | path]}

    {:dir, dir}, {tree, path} ->
      canonical_path = path |> Enum.reverse()
      {Map.update!(tree, canonical_path, &[{:dir, dir} | &1]), path}

    {:file, _size, _name} = file, {tree, path} ->
      canonical_path = path |> Enum.reverse()
      {Map.update!(tree, canonical_path, &[file | &1]), path}

    _, acc ->
      acc
  end)
  |> then(fn {tree, _} -> tree end)

Drive module

defmodule Drive do
  def dirs_with_size(drive) do
    drive
    |> Enum.reduce(%{}, fn {path, _dir}, acc ->
      Map.put(acc, path, dir_size(path, drive))
    end)
  end

  defp dir_size(path, drive) do
    Map.get(drive, path)
    |> Enum.reduce(0, fn
      {:file, size, _}, acc -> acc + size
      {:dir, dir}, acc -> acc + dir_size(path ++ [dir], drive)
    end)
  end
end

Part 1

Drive.dirs_with_size(drive)
|> Enum.reduce(0, fn
  {_, size}, acc when size <= 100_000 -> acc + size
  _, acc -> acc
end)

Part 2

dirs_with_size = Drive.dirs_with_size(drive)
remaining_space = 70_000_000 - dirs_with_size[["/"]]
needed_space = 30_000_000 - remaining_space

dirs_with_size
|> Enum.map(& elem(&1, 1))
|> Enum.sort(:desc)
|> Enum.reduce(fn size, acc ->
  cond do
    size <= acc && size >= needed_space -> size
    true -> acc
  end
end)

This one broke me. I’m still playing with it. For the test input it worked fine but for the challenge itself, Nah :frowning:

defmodule DaySeven do
  defstruct parent_dirs: [], current_dir: "/", dirs: %{"/" => 0}

  def get_total_size_of_directories_at_most_100k(input) do
    input
    |> String.split("\n", trim: true)
    |> Enum.reduce(%DaySeven{}, &process_line/2)
    |> then(fn %DaySeven{dirs: dirs} -> dirs |> Map.values() end)
    |> Enum.filter(&is_size_less_than_100k?/1)
    |> Enum.sum()
  end

  defp process_line("$ cd /", acc), do: acc
  defp process_line("$ ls", acc), do: acc
  defp process_line("dir " <> _rest, acc), do: acc

  defp process_line("$ cd ..", %DaySeven{parent_dirs: [latest_parent | remaining_parents]} = acc) do
    %{acc | current_dir: latest_parent, parent_dirs: remaining_parents}
  end

  defp process_line("$ cd " <> dir_name, %DaySeven{current_dir: new_parent_dir} = acc) do
    updated_dirs = Map.put_new(acc.dirs, dir_name, 0)
    %{acc | current_dir: dir_name, parent_dirs: [new_parent_dir | acc.parent_dirs], dirs: updated_dirs}
  end

  defp process_line(file_line, acc) do
    [size | _] =
      file_line
      |> String.split(" ", trim: true)

    size
    |> Integer.parse()
    |> process_file_size(acc)
  end

  defp process_file_size(:error, acc), do: acc

  defp process_file_size({file_size, _}, %DaySeven{current_dir: current_dir} = acc) do
    updated_dirs_with_current_file =
      acc.dirs
      |> Map.update!(current_dir, fn existing_value -> existing_value + file_size end)

    updated_dirs =
      acc.parent_dirs
      |> Enum.reduce(updated_dirs_with_current_file, fn parent, d_acc ->
        d_acc
        |> Map.update!(parent, fn existing_value -> existing_value + file_size end)
      end)

    %{acc | dirs: updated_dirs}
  end

  defp is_size_less_than_100k?(file_size), do: file_size <= 100_000
end

You should consider the possibility that a directory has subdirectories with the same name.

1 Like