Skip to Content

Advent of Code 2022 - Day 7, in Kotlin - No Space Left On Device

Kotlin solutions to parts 1 and 2 of Advent of Code 2022, Day 7: 'No Space Left On Device'

Posted on

Part of the design philosophy of UNIX is that Everything Is A File . In today’s solution, we’ll flip that on its head and declare that Nothing Is A File. I had fun with today’s puzzle because the solution I have below is very different from the one I originally stated with. I had tried to somewhat faithfully model “Everything Is A File” at first, and ended up with the opposite after a lot of refactoring.

If you’d rather just view code, the GitHub Repository is here .

Puzzle Input

Originally, I had a somewhat complicated structure to model files and directories, thinking that Part Two would ask different questions. But once I solved the problem and started to refactor it something hit me - We don’t care about the files, just their total size within a directory. We’re never asked questions about the files themselves (sorry, spoiler, I know), just about how much space they take up in a directory. So that’s all we’ll model. Nothing Is A File.

To do this, let’s define a Directory class to use.

// In Day07

class Directory(val name: String) {
    private val subDirs: MutableMap<String, Directory> = mutableMapOf()
    private var sizeOfFiles: Int = 0

    val size: Int
        get() = sizeOfFiles + subDirs.values.sumOf { it.size }

    fun addFile(size: Int) {
        sizeOfFiles += size
    }

    fun traverse(dir: String): Directory =
        subDirs.getOrPut(dir) { Directory(dir) }
}

Let’s go over what is in our Directory class. Each directory has a public name and a private Map<String,Director> to keep track of subDirs. Additionally, each Directory has a sizeOfFile var to keep track of the total size of all the files within that directory. We don’t store their names or anything about them.

Next we have a size property with a getter in order to properly calculate the size of the directory. We do it this way because in theory, the file system is a dynamic place and subdirectories can be added at any time, altering the size of the parent directory. To calculate the size of a directory we add the sizeOfFiles (files in this directory) to the sumOf all the subdirectoories. This is going to lead to a recursive call to all the subDirs.

We’ll add a function to addFile, only caring about its size. Because we never actually do anything with the files, we can leave off their names and any sort of individualized tracking.

Finally, we’ll add a traverse function. This lets us either add or retrieve a subDir. We’ll use this when building the file tree in a moment. When given the name of a dir, we either already have it (in which case we return it) or we need to create it, store it, and return it. Thankfully Kotlin’s getOrPut does the hard work for us.

Now we can parse our input, which is a List<String> today using a when expression and a stack.

// In Day07

private fun parseInput(input: List<String>): Directory {
    val callStack = ArrayDeque<Directory>().apply { add(Directory("/")) }
    input.forEach { item ->
        when {
            item == "$ ls" -> {}// Noop
            item.startsWith("dir") -> {} // Noop
            item == "$ cd /" -> callStack.removeIf { it.name != "/" }
            item == "$ cd .." -> callStack.removeFirst()
            item.startsWith("$ cd") -> {
                val name = item.substringAfterLast(" ")
                callStack.addFirst(callStack.first().traverse(name))
            }
            else -> {
                val size = item.substringBefore(" ").toInt()
                callStack.first().addFile(size)
            }
        }
    }
    return callStack.last()
}

First we stet up a callStack which we’ll seed with the root Directory ("/"). We use a stack here because as we traverse to new directories, we need to know our path for when we cd .. back up and out. We could have added a parent: Directory? to each Directory, but I feel the call stack more accurately captures what we want in this case. Besides, we don’t need to do any kind of parent-traversals outside of the parsing phase.

Next we go through each item of input and decode what to do about it. The ls case is the easiest - ignore it as there is no work to do. The same goes for when a file listing shows us a dir. Since we don’t really care about empty directories, we can leave defining them until we actually traverse into one later on in this process.

When we see $ cd /, we can throw away the callStack except for the root directory, and we do that via removeIf. When we see $ cd .. we know we need to go up one directory so we remove the topmost Directory from the callStack, leaving its parent as the new topmost Directory on the stack.

When we need to change directories by detecting $ cd <name>, we parse out the name using substringAfterLast and calling our traverse function on the Directory on the top of the stack. The traverse function returns the newly created (or found) Directory so to switch into it, we push it on to the top of the stack.

Finally, we handle adding files (the only case left) by figuring out its size via subStringBefore and converting the String to an Int via toInt. We take that and addFile to the current working directory (whichever one is on top of the callStack).

When that’s done, we return the last (root) item in the callStack. Why last? If the parsing process leaves us in a subdirectory, we want the root. Finding the bottom of the stack (via last) will always be the root directory.

We’ll define a rootDirectory in our Day07 class to hold the root of the filesystem for us.

class Day07(input: List<String>) {

    private val rootDirectory: Directory = parseInput(input)

}

⭐ Day 7, Part 1

The puzzle text can be found here.

Due to all the work in the parsing section, we’re almost done. The only thing we really need is a function to find directories that we care about. I called this function find because it reminded me of the kinds of operations we perform with the UNIX find command . We’ll go in and add this to Directory:

// In Day07.Directory

fun find(predicate: (Directory) -> Boolean): List<Directory> =
    subDirs.values.filter(predicate) +
        subDirs.values.flatMap { it.find(predicate) }

When we call find we’re given a predicate which lets us determine which Directory objects we care about (in our case, we only care about their size). I thought about going back and only allowing find by size but I figured this was a good opportunity to talk about High Order Functions. A High Order Function is a function that takes another function as an argument (in our case) or returns a function. In this case, the predicate is a function that determines if we care about a specific Directory.

We use the predicate initially to find all of the immediate subDirs that match the predicate. Then we ask all of those subDirs if they have any matches, and flatMap the results into a single List<Directory>. Why flatMap instead of map? If we were to map, we would end up with a List<List<Directory>>. Since we want a “flattened” view of this (flattening removes the intermediate collections), we call flatMap to get a List<Directory>. The flatMap function does all the work of condensing the inner List<Directory> into a single list.

All that’s left is to call our function:

// Day07

fun solvePart1(): Int =
    rootDirectory.find { it.size <= 100_000 }.sumOf { it.size }

Here you can see we define the predicate as it.size <= 100_000 to indicate we want to find directories whose size is no greater than 100,000. I like to put _ in where I can as it helps me read the numbers better. When we have all the Directory objects that past the predicate we sumOf their size to get the answer for Part One.

Star earned! Onward!

⭐ Day 7, Part 2

The puzzle text can be found here.

We can use the existing find function but with a different predicate to solve Part Two:

// Day07

fun solvePart2(): Int {
    val unusedSpace = 70_000_000 - rootDirectory.size
    val deficit = 30_000_000 - unusedSpace
    return rootDirectory.find { it.size >= deficit }.minBy { it.size }.size
}

Fist we calculate both the unusedSpace and the deficit we need to make up. Then we use our existing find function to find directories that are at least as large as the deficit and then take the size of the smallest one.

Star earned! See you tomorrow!

Further Reading

  1. Index of All Solutions - All posts and solutions for 2022, in Kotlin.
  2. My Github repo - Solutions and tests for each day.
  3. Solution - Full code for day 7
  4. Advent of Code - Come join in and do these challenges yourself!