Advent Of Code 2022 - Day 17: Pyroclastic Flow
Dec 17, 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 17: Pyroclastic Flow
Summary: Given a list of moves, repeating if it runs out, simulate a game of Tetris, without removing full lines.
How high is your tower of rocks (pieces) after 2022
rocks have fallen?
Example input:
>>><<><>><<<>><>>><<<>>><<<><<<>><>><<>>
Read the full problem statement here.
After yesterday's foray into bit masks, today I decided to go for bit masks again.
Tetris rocks have odd shapes. To see if they can move to the left, right or
down, we have to verify that they have no collision with what's already there,
be it wall, floor or another piece. If we represent each row as an integer, with
each individual bit indicating if an index in that row is occupied, then it's
easy to check if the merger of two rows causes a collision. Specifically, two
rows can merge if their bitwise AND is 0
.
If we represent a falling piece in a vacuum, then we can use this to test both
vertical movement as well as horizontal movement. A piece can move to the left
(or right) if the bitwise AND of the row that it's in and the moved piece is
0
.
Representation of the rocks is a bit tedious. We represent them as a list of bit masks, with each entry in the list representing a row. Representing the floor and the walls is a lot more straight forward.
Note that the least significant bit matches up with the left side of the chamber. This way we can count indices logically, but when representing the binary it's reversed.
let rocks = [ [0b1111] [0b010 0b111 0b010] [0b100 0b100 0b111] [0b1 0b1 0b1 0b1] [0b11 0b11] ] let floor = 0b111111111 let wall = 0b100000001
We need a circular collection. It seems like this isn't very difficult to do. We can turn a sequence circular by first yielding all of it's items and then recursively keep doing that.
let circular sequence = let rec items () = seq { for e in sequence do yield e yield! items () } items ()
It turns out that doing this and then asking a large number of items from it is
very, very slow. So instead, we build a more efficient one using an array and a
modulo on the index. The modE
function works correctly for negative numbers.
module CircularCollection = let modE a b = ((a % b) + b) % b type t<'a> = ('a array * int * int) let init (source: 'a seq) = let asArray = Seq.toArray source (asArray, 0, Array.length asArray) let moveNext ((source, index, length): t<'a>) = let nextIndex = modE (index + 1) length (source, nextIndex, length) let item ((source, index, _length): t<'a>) = source[index] let itemAt at ((source, index, length): t<'a>) = let idx = modE (index + at) length source[idx] let index ((_source, index, _length): t<'a>) = index let length ((_source, _index, length): t<'a>) = length
The problem states that a new rock always spawns with three rows between its bottom and the top of the current tower, and two tiles from the left wall. We shift the rock by three bits to put it in the right spot and add three wall tiles to the existing chamber.
let spawn rock chamber = let shiftedRock = rock |> List.map (fun line -> line <<< 3) let extendedChamber = rock |> List.fold (fun chamber rock -> wall :: chamber) chamber (shiftedRock, wall :: wall :: wall :: extendedChamber)
We also need a zipper. The chamber is represented as a list. As the rock falls
through the chamber we need to access and change various different points in
that list. A basic implementation for a list zipper is not very difficult. Note
that update
updates the tail of the zipper.
module ListZipper = let init (list: 'a list): ('a list * 'a list) = ([], list) let next (head, tail) = match tail with | x::xs -> x :: head, xs | [] -> failwith "Cannot zip next on empty list" let prev (head, tail) = match head with | x::xs -> xs, x :: tail | [] -> failwith "Cannot zip prev on empty list" let update (updater: 'a list -> 'a list) (head, tail): ('a list * 'a list) = (head, updater tail) let hasNext (_head, tail) = match tail with | _x::_xs -> true | _ -> false let hasPrev (head, _tail) = match head with | _x::_xs -> true | _ -> false let view (_head, tail) = tail let rec rebuild (head, tail) = match head with | _x::_xs -> rebuild <| prev (head, tail) | [] -> tail
Next, we need a bunch of helpers. applyJet
moves a rock in the direction of
the jet. isValidPosition
checks that the rock can be in the given position in
the chamber. It does so by checking the bitwise AND of all rows of both itself
and the part of the chamber it's in. We use Seq.forall2
rather than
List.forall2
because Seq
stops when the shorter list runs out of elements.
tryFall
tries to move the rock down one position. tryJet
attempts to apply a
jet. tryStep
combines the two. Note that if trying a jet fails, the rock
simply does not move to the side. If falling fails, the rock settles in place
and we move on to the next rock.
let applyJet jet rock = let fn = match jet with | '<' -> (>>>) | '>' -> (<<<) | _ -> failwith "Invalid jet" rock |> List.map (fun x -> fn x 1) let isValidPosition rock chamber = Seq.forall2 (fun r c -> r &&& c = 0) <| rock <| ListZipper.view chamber let tryFall chamber rock = let nextChamberState = ListZipper.next chamber // should always work because we have a floor if isValidPosition rock nextChamberState then Some nextChamberState else None let tryJet chamber jet rock = let shiftedRock = applyJet jet rock if isValidPosition shiftedRock chamber then Some shiftedRock else None let tryStep chamber rock jets = let shiftedRock = Option.defaultValue <| rock <| tryJet chamber (CircularCollection.item jets) rock let fallen = tryFall chamber <| shiftedRock (Option.defaultValue chamber fallen, shiftedRock, CircularCollection.moveNext jets, Option.isSome fallen)
Merging uses the update
method of our zipper, merging the chamber and the
rock, putting it into place. We can merge each row by using the bitwise OR
operator.
If there are empty rows then we remove them. This ensures both that when we spawn the next rock we can blindly add three empty rows as well as that the height of our tower is simply the amount of rows of the chamber.
let merge chamber rock = chamber |> ListZipper.update (fun tail -> let top = (Seq.map2 (fun c r -> c ||| r) <| tail <| rock) |> Seq.toList let rest = List.skip (List.length rock) tail List.append top rest) |> ListZipper.rebuild |> List.reject ((=) wall)
That in place, we can simulate dropping a single rock. We try a single step. If succesful, we recursively try more steps. If not then we merge the rock into the current position with the chamber.
let simulateOneRock chamber rocks jets = let rec doSimulate chamber rock jets = let (newChamber, newRock, newJets, success) = tryStep chamber rock jets if success then doSimulate newChamber newRock newJets else (merge chamber newRock, CircularCollection.moveNext rocks, newJets) let (rock, chamber) = spawn (CircularCollection.item rocks) chamber doSimulate <| ListZipper.init chamber <| rock <| jets
To simulate multiple falling rocks we simply call Seq.scan
on a range. The
last
element in that sequence is the final configuration of our chamber.
let simulateMultipleRocks chamber rocks jets n = {1..n} |> Seq.scan (fun (c, r, j) _i -> simulateOneRock c r j) (chamber, rocks, jets)
To solve, we simulate count = 2022
rocks and take the height of the resulting
chamber, subtracting the floor.
let rockCycle = CircularCollection.init rocks let jetCycle = CircularCollection.init jets simulateMultipleRocks [floor] rockCycle jetCycle count |> Seq.last |> (fun (c, _, _) -> List.length c - 1)
Part 2
Summary: What's the height after 1000000000000 rocks?
Read the full problem statement here (only if you solved part 1).
I can't say I didn't see this one coming. There's no way that we can simulate the falling of this many rocks, so we have to come up with a better idea.
We can detect a cycle. If we see the same floor configuration at the same rock and the same jet, we've discovered a cycle. We can then divide our large number by the amount of rocks in the cycle and multiply that by the height of the cycle.
What remains then are the parts of the tower before the cycle starts, and what remains after the last cycle ends but there is no full cycle to the 1000000000000th rock.
We can store the floor configuration as a height map. For each column the height map represents the distance of the closest occupied tile from the top. The index of the rock and the jet are simply integers.
let heightMap (chamber: int list) = let heightForIndex n = chamber |> List.findIndex (fun line -> line &&& (1 <<< n) <> 0) [7..-1..1] |> List.map heightForIndex
Then we create a few helpers to cache what we've seen. For each combination of height map, rock index and jet index, we store after how many rocks we've seen this configuration for the first time and how high the tower was at that point.
let emptyCache (): Map<(int list * int * int), (int * int)> = Map.empty let cacheKey chamber rocks jets = (heightMap chamber, CircularCollection.index rocks, CircularCollection.index jets) let inCache chamber rocks jets cache = Map.containsKey (cacheKey chamber rocks jets) cache let addToCache index chamber rocks jets cache = Map.add (cacheKey chamber rocks jets) (index, List.length chamber) cache let cacheValue chamber rocks jets (cache: Map<(int list * int * int), (int * int)>) = cache[cacheKey chamber rocks jets]
Now we can start finding a cycle. To find a cycle we simulate dropping a rock and then checking the cache to see if we've seen the configuration before. If we did not see the configuration before then we recursively continue simulating. If we did see the configuration before then we found a cycle. We subtract the stored height and index from the current height and index and return a tuple containing the first time we saw the cycle, the amount of rocks in the cycle and the height difference caused by the cycle.
let findCycle () = let rec loop cache index (chamber, rocks, jets) = let (c, r, j) = simulateOneRock chamber rocks jets if not (inCache c r j cache) then loop <| addToCache index c r j cache <| index + 1 <| (c, r, j) else let curHeight = List.length c let (cachedIndex, cachedHeight) = cacheValue c r j cache (cachedIndex, index - cachedIndex, curHeight - cachedHeight) loop <| emptyCache () <| 1 <| ([floor], rockCycle, jetCycle) let (cStart, cLength, cHeight) = findCycle ()
Next we determine how much we still have to simulate and how much we can
calculate based on the cycle. pre
is the number of rocks to simulate before
the cycle starts. cycles
is the amount of cycles. We mutiply it by the height
of an individual cycle to determine the total height from cycles. post
is the
amount of steps we still need to simulate after the last cycle.
The order in which we do this, doesn't matter. If a cycle occurs after n
rocks, a cycle of the same length and height occurs after n + 1
blocks. This
means we can simulate pre
and post
together and add the cycles after that.
let pre = cStart let cycles = (steps - int64 cStart) / int64 cLength let post = int32 <| (steps - int64 cStart) % int64 cLength let heightFromCycles = cycles * int64 cHeight let leftToSimulate = pre + post simulateMultipleRocks [floor] rockCycle jetCycle leftToSimulate |> Seq.last |> (fun (c, _, _) -> int64 <| List.length c - 1) |> ((+) heightFromCycles)
Improvements
I've noticed that in many posts the improvements are pretty much the same. I
hack together a solution using for
-loops and mutation and then refactor the
mutation away, changing the for
-loop into a fold
or a scan
.
In most cases I can also extract the solution to part 1, make one or two things slightly configurable and pass those in both parts.
I'll leave these kinds of improvements out of this section for now and just immediately describe them as they've ended up after refactoring. If I learn something new then it will still end up in this section.
Reflection
Today was a fun problem, full of tiny little details. I have to admit that I was way to hungover to solve a problem with this many details. I had a hard time wrapping my head around basically all of it and spent way too long.
It would have been a better problem if I did not have a Christmas party the night before. But such is life.
On to the next one!
The full code for the day is on GitHub.