Advent Of Code 2022 - Day 07
Dec 07, 2022
It's December. Time for snow, slippery roads, hot chocolate and cozy fire places. Also time for Advent of Code. An advent calendar with small, daily programming puzzles, growing progressively more difficult.
Every year I participate in a programming language I did not use for Advent of Code before, in order to learn new ways of doing things and to challenge myself. This year, that language is F#.
Day 07: No Space Left On Device
Summary: We're given the output of a terminal, a bit similar to a real one. The
commands in this terminal are cd
and ls
.
cd /
changes dir to root. cd foo
moves a directory deeper. cd ..
moves
out a directory. ls
lists the content of the directory. Subdirectories are
listed as dir name
, files are listed as size-in-bytes name
.
The size of a directory is the size of all of its files plus the size of all of its subdirectories.
We're asked to give the sum of the size of all the directories with a size of at most 100000.
Example input:
$ 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
Find the full description here.
The full problem statement nudges us in the direction of taking the input and using it to build a tree representing the directory and file structure and then run calculations on that tree.
That's a solid idea, but we can go for an easier solution. The only thing we're being asked about is the size of each directory. The contents are not relevant, other than to calculate the size.
So what we can do is go over each line of the input and follow the cd
directions to update the current path. Then if we see a line containing a
size, we update the size of the current directory as well as the sizes of all
of the parent directories.
When we're done we will have a dictionary with paths as keys and sizes are values.
Like several times before in this year's Advent of Code, I started with a version with mutation.
let calculateSizes (lines: string list) = let mutable pwd = [] let mutable sizes = Map.empty for line in lines do match line.Split(" ") with | [|"$"; "cd"; ".."|] -> pwd <- List.tail pwd | [|"$"; "cd"; dirName|] -> pwd <- dirName :: pwd | [|"$"; "ls"|] -> () | [|"dir"; _|] -> () | [|sizeStr; _|] -> let size = Int32.Parse(sizeStr) sizes <- updateSizes pwd size sizes | _ -> failwith "Failed to parse" sizes
In F# you can use lists as keys of a map. If that's not possible, you can just as easily concatenate the parts separated by slashes.
updateSizes
is a little bit involved because not only does it update the size
of the current directory, it also updates the sizes of all parents.
Since we're storing the path as a list of segments, we can just pop a segment and recurse on the remaining segments.
let rec updateSizes pwd size sizes = match pwd with | [] -> sizes | _::xs -> let updated = sizes |> Map.change pwd (fun i -> Some (Option.defaultValue 0 i + size)) updateSizes xs size updated
This particular solution makes the assumption that ls
is not called on any
directory twice. It also assumed that cd /
is only called at the
beginning. Both of these assumptions were true for my input (and I verified this
before basing my solutions on the assumptions). Implementing cd /
specifically
adds just one extra match
case.
To ensure we only process one ls
for each directory we can put each path for
which we've seen an ls
into a set
and verify that a path we see ls
for is
not in that set yet.
With the sizes calculated it's easy to find all values smaller than 100000.
let solve1 (input: string list) = input |> calculateSizes |> Map.values |> Seq.filter (fun sz -> sz <= 100000) |> Seq.sum
Part 2
Summary: Part two is slightly more elaborate. We have to free up space on the disk. The disk is 70.000.000 bytes. We need 30.000.000 bytes of free space. What is the size of the smallest directory we can delete to have at least 30.000.000 bytes of free space?
Since the size of the disk is given, the free space we already have is 70.000.000 minus the size of the root folder. To get the minimum size of any directory we need to delete, we subtract that number from 30.000.000.
Then we take our list of sizes and find the smallest number equal to or larger than that number:
let solve2 (input: string list) = let sizes = calculateSizes input let availableSpace = 70000000 - sizes[["/"]] let spaceNeeded = 30000000 - availableSpace sizes |> Map.values |> Seq.filter (fun sz -> spaceNeeded <= sz) |> Seq.min
Improvements
There wasn't a lot I wanted to improve about today's solution, except of course
to remove the mutation. Like we've seen before, we can replace the for
-loop
with List.fold
and use the variable we're mutating as the state. Since we're
mutating two variables this time (pwd
and sizes
), we combine the two in a
tuple.
let calculateSizes (lines: string list) = let handleLine (pwd, sizes) (line: string) = match line.Split(" ") with | [|"$"; "cd"; "/"|] -> (["/"], sizes) | [|"$"; "cd"; ".."|] -> (List.tail pwd, sizes) | [|"$"; "cd"; dirName|] -> (dirName :: pwd, sizes) | [|"$"; "ls"|] -> (pwd, sizes) | [|"dir"; _|] -> (pwd, sizes) | [|sizeStr; _|] -> let size = Int32.Parse(sizeStr) (pwd, updateSizes pwd size sizes) | _ -> failwith "Failed to parse" let (_, sizes) = lines |> List.fold handleLine ([], Map.empty) sizes
Reflection
I liked today. The problem statement is fun. The solution direction that the problem statement nudges you into is perfectly fine, although the alternative is simpler.
The entire thing is still not too difficult. It is, however, a bit more involved than the previous day. We're getting a bit further in, so it's nice to see a problem that's just a tad more challenging.
The full code for the day is on GitHub.