Advent of Code 2022, Day 7: No Space Left On Device

#ruby #advent of code 2022

Part A

Day 7 is about parsing terminal output, mainly the output of ls command. We can’t run system output on the device received from Elves, because there is no space left. We are running some terminal commands to found out what is the directory structure:

$ cd /
$ ls
dir a
14848514 b.txt
8504156 c.dat
dir d
$ cd a
$ ls
dir e
29116 f
2557 g
62596 h.lst
$ cd e
$ ls
584 i
$ cd ..
$ cd ..
$ cd d
$ ls
4060174 j
8033020 d.log
5626152 d.ext
7214296 k

We need to find good candidates for deletion and to do that we need to determine total size of each directory and output total size of all directories that are less than 100000. In the example above the answer is 95437, directory a is 94853 and directory e is 584.

We can start with a class representing single node (directory or file):

class Node
  attr_reader :name, :parent, :children

  def initialize(parent, name, type, size)
    @name = name
    @type = type
    @size = size

    @parent = parent
    @children = []
  end

  def add(name, type, size)
    @children.push(Node.new(self, name, type, size))
  end

  def get(name)
    @children.find { |child| child.name == name }
  end

  def size
    if @type == "file"
      @size
    else
      @size ||= @children.map(&:size).sum
    end
  end

  def traverse(&block)
    block.call(self)

    children.each do |child|
      child.traverse(&block)
    end
  end

  def dir?
    @type == "dir"
  end
end

It will store node name, type, size and child nodes in case of a directory. Then we have a method to add new child node, to find child node by name and recursive method to calculate the size of a node. I also added a convenient method traverse to iterate over all nodes and pass them to a block.

Having the class for representing single node we now need to parse the input and build directory structure:

data = File.readlines("7.txt", chomp: true)
root = Node.new(nil, "/", "dir", nil)
current = nil

data.each do |line|
  if line.start_with?("$")
    line.gsub!("$ ", "")
    command, argument = line.split(" ")

    if command == "cd"
      if argument == "/"
        current = root
      elsif argument == ".."
        current = current.parent
      else
        current = current.get(argument)
      end
    end
  else
    parts = line.split(" ")
    type = parts[0] == "dir" ? "dir" : "file"
    size = parts[0] == "dir" ? nil : parts[0].to_i

    current.add(parts[1], type, size)
  end
end

We are iterating over each line of input. Variable command will maintain the current directory, kind like output of pwd terminal command. If it starts with $ sign it is a command line and we need to process it accordingly. We mainly should care about cd commands, because this will change the current variable.

If line does not start with $ sign it means it is an output from ls command, so we need to parse it into nodes and add all of them to the current one.

At this point we have a tree of nodes. The last thing is to traverse this tree and sum size of all nodes that are directories and have size at most 100000.

sum = 0

root.traverse do |node|
  if node.dir? && node.size <= 100_000 
    sum += node.size
  end
end

puts sum

Here is the full code:


class Node
  attr_reader :name, :parent, :children

  def initialize(parent, name, type, size)
    @name = name
    @type = type
    @size = size

    @parent = parent
    @children = []
  end

  def add(name, type, size)
    @children.push(Node.new(self, name, type, size))
  end

  def get(name)
    @children.find { |child| child.name == name }
  end

  def size
    if @type == "file"
      @size
    else
      @size ||= @children.map(&:size).sum
    end
  end

  def traverse(&block)
    block.call(self)

    children.each do |child|
      child.traverse(&block)
    end
  end

  def dir?
    @type == "dir"
  end
end

data = File.readlines("7.txt", chomp: true)
root = Node.new(nil, "/", "dir", nil)
current = nil

data.each do |line|
  if line.start_with?("$")
    line.gsub!("$ ", "")
    command, argument = line.split(" ")

    if command == "cd"
      if argument == "/"
        current = root
      elsif argument == ".."
        current = current.parent
      else
        current = current.get(argument)
      end
    end
  else
    parts = line.split(" ")
    type = parts[0] == "dir" ? "dir" : "file"
    size = parts[0] == "dir" ? nil : parts[0].to_i

    current.add(parts[1], type, size)
  end
end

sum = 0

root.traverse do |node|
  if node.dir? && node.size <= 100_000 
    sum += node.size
  end
end

puts sum

Part B

Now we need to run the system update. Total available disk space is 70000000 and we need to have 30000000 of free space.

In the example input the root has size of 48381165, so there is 21618835 left on the device. We are still short of 8381165. The task is to find the smallest directory to delete that will free up enough space to install system update.

We can reuse most of the code from Part A and add this:

directories = {}

root.traverse do |node|
  if node.dir?
    directories[node.name] = node.size
  end
end

total_space = 70_000_000
free_space_required = 30_000_000
total_used_space = directories["/"]
to_remove = free_space_required - (total_space - total_used_space)

min = Float::INFINITY

directories.each do |_, size|
  if size > to_remove && size < min
    min = size
  end
end

puts min

Now we are traversing our directory tree and caching directory sizes in directories hash. We calculate total used space and calculate how much space we need to remove. Finally we can select the smallest directory that will remove enough space.

Here is the full code:

class Node
  attr_reader :name, :parent, :children

  def initialize(parent, name, type, size)
    @name = name
    @type = type
    @size = size

    @parent = parent
    @children = []
  end

  def add(name, type, size)
    @children.push(Node.new(self, name, type, size))
  end

  def get(name)
    @children.find { |child| child.name == name }
  end

  def size
    if @type == "file"
      @size
    else
      @size ||= @children.map(&:size).sum
    end
  end

  def traverse(&block)
    block.call(self)

    children.each do |child|
      child.traverse(&block)
    end
  end

  def dir?
    @type == "dir"
  end
end

data = File.readlines("7.txt").map(&:strip)
root = Node.new(nil, "/", "dir", nil)
current = nil

data.each do |line|
  if line.start_with?("$")
    line.gsub!("$ ", "")
    command, argument = line.split(" ")

    if command == "cd"
      if argument == "/"
        current = root
      elsif argument == ".."
        current = current.parent
      else
        current = current.get(argument)
      end
    end
  else
    parts = line.split(" ")
    type = parts[0] == "dir" ? "dir" : "file"
    size = parts[0] == "dir" ? nil : parts[0].to_i

    current.add(parts[1], type, size)
  end
end

directories = {}

root.traverse do |node|
  if node.dir?
    directories[node.name] = node.size
  end
end

total_space = 70_000_000
free_space_required = 30_000_000
total_used_space = directories["/"]
to_remove = free_space_required - (total_space - total_used_space)

min = Float::INFINITY

directories.each do |_, size|
  if size > to_remove && size < min
    min = size
  end
end

puts min