Day 15: Warehouse Woes
Puzzle DescriptionAdvent of Code 2024, Day 15
Scala Center PageScala Center Advent of Code 2024, Day 15
Solution Summary
- Parse input into grid, robot position, and moves list
- Execute moves list
- The bulk of this is calculating how boxes move when pushed.
- In
part1
, this can fairly simply be represented as a tail recursive function. - In
part2
, it’s hard (if not impossible) to make this function tailrec.cats.Eval
can still let us do this recursively within the limits of the JVM stack.
- Calculate GPS coordinates and sum them
Part 1
First, let’s define our basic types. Here’s Vec2i
:
case class Vec2i(x: Int, y: Int):
def offset(dir: Direction): Vec2i = {
dir match
case Direction.Up => Vec2i(x, y - 1)
case Direction.Down => Vec2i(x, y + 1)
case Direction.Left => Vec2i(x - 1, y)
case Direction.Right => Vec2i(x + 1, y)
}
def isContainedIn(w: Int, h: Int): Boolean =
x >= 0 && x < w && y >= 0 && y < h
And here’s Grid
:
case class Grid[A](values: Vector[Vector[A]]):
val height: Int = values.size
val width: Int = values.head.size
def apply(x: Int, y: Int): A = values(y)(x)
def apply(p: Vec2i): A = apply(p.x, p.y)
def isDefinedAt(p: Vec2i): Boolean = p.isContainedIn(width, height)
def isDefinedAt(x: Int, y: Int): Boolean = isDefinedAt(Vec2i(x, y))
def updated(x: Int, y: Int)(value: A): Grid[A] = Grid(values.updated(y, values(y).updated(x, value)))
def updated(p: Vec2i)(value: A): Grid[A] = updated(p.x, p.y)(value)
def zipWithIndices: Seq[(A, Vec2i)] = {
for {
y <- 0 until height
x <- 0 until width
} yield (this (x, y), Vec2i(x, y))
}
object Grid:
def apply[A](values: IterableOnce[IterableOnce[A]]): Grid[A] = Grid(values.iterator.map(_.iterator.toVector).iterator.toVector)
Then let’s make an enumeration of the possible grid items:
enum GridItem:
case Empty, Wall, Box
And now a holder for the current state:
case class ProblemState(grid: Grid[GridItem], robot: Vec2i, remainingMoves: List[Direction])
Now we can write our parser:
def parse(str: String): ProblemState =
val Array(gridStr, moveStr) = str.split("\n\n")
var robotPos = Vec2i(-1, -1)
val grid = Grid(gridStr.linesIterator.zipWithIndex.map: (str, y) =>
str.toVector.zipWithIndex.map: (it, x) =>
it match
case '#' => GridItem.Wall
case 'O' => GridItem.Box
case '.' => GridItem.Empty
case '@' =>
assert(robotPos == Vec2i(-1, -1))
robotPos = Vec2i(x, y)
GridItem.Empty
case _ => ???
)
val dirs = moveStr.linesIterator.flatten.map:
case '<' => Direction.Left
case '^' => Direction.Up
case '>' => Direction.Right
case 'v' => Direction.Down
case _ => ???
ProblemState(grid, robotPos, dirs.toList)
Now for the actual move part:
extension (grid: Grid[GridItem]) {
@targetName("updatedMoveP1")
def updatedMove(start: Vec2i, dir: Direction): Option[Grid[GridItem]] =
@tailrec
def go(curGrid: Grid[GridItem], start: Vec2i): Option[Grid[GridItem]] =
val newPos = start.offset(dir)
val oldItem = curGrid(start)
curGrid(newPos) match
case GridItem.Wall => None
case GridItem.Empty => Some(curGrid.updated(newPos)(oldItem))
case GridItem.Box => go(curGrid, newPos)
go(grid, start).map: it =>
val newPos = start.offset(dir)
if it.isDefinedAt(newPos) then
it.updated(newPos)(GridItem.Empty)
else it
}
This took a while to figure out, but here’s what it’s doing: It checks the point that is being moved to. If it’s empty, it ends there and
copies the starting item to that position. If it’s a Wall
then it returns early, saying that this move is impossible. If it’s a Box
,
it recurses, starting at the boxes position. Copying the starting item to the position at the end is important to make sure the boxes slide.
This implementation wouldn’t work if each box had a unique ID, but boxes are identical apart from their coordinates so we can safely ignore their identity.
Now to go back to the problem state, now let’s hook up our grid step function with the inputs we have:
case class ProblemState(grid: Grid[GridItem], robot: Vec2i, remainingMoves: List[Direction]):
def step: Option[ProblemState] =
remainingMoves match
case head :: rest =>
grid.updatedMove(robot, head) match
case Some(newGrid) => Some(ProblemState(newGrid, robot.offset(head), rest))
case _ => Some(ProblemState(grid, robot, rest))
case _ => None
And let’s also add a function to calculate the sum of the gps calculations:
// ...
def gpsCalc: Long =
grid.zipWithIndices.filter(_._1 == GridItem.Box).map((_, p) => p.y.toLong * 100L + p.x.toLong).sum
Now we can write our part 1 solution:
def part1(str: String): Long =
val input = parse(str)
Iterator.iterate(input): i =>
i.step.getOrElse(i)
.find(_.remainingMoves.isEmpty).get.gpsCalc
Part 2
For my code I ended up having to duplicate of lot of code to accomadate the new BoxL
and BoxR
.
Most of the duplication comes from making a new enumeration:
enum GridItemP2:
case Empty, Wall, BoxL, BoxR
Let’s make a new state holder as well:
case class ProblemStateP2(grid: Grid[GridItemP2], robot: Vec2i, remainingMoves: List[Direction]):
// ...
And we may as well add the gpsCalc
function here too:
// ...
def gpsCalc: Long =
grid.zipWithIndices.filter(_._1 == GridItemP2.BoxL).map((_, p) => p.y.toLong * 100L + p.x.toLong).sum
We also need a way to convert the initial problem state to our new problem state, like here:
case class ProblemState(grid: Grid[GridItem], robot: Vec2i, remainingMoves: List[Direction]):
// ...
def doubled: ProblemStateP2 =
val newGrid = Grid(grid.values.map(_.flatMap:
case GridItem.Box => List(GridItemP2.BoxL, GridItemP2.BoxR)
case GridItem.Wall => List(GridItemP2.Wall, GridItemP2.Wall)
case GridItem.Empty => List(GridItemP2.Empty, GridItemP2.Empty)
))
ProblemStateP2(newGrid, robot.copy(x = robot.x * 2), remainingMoves)
Now for the hard part: the movement for part 2. This was my implementation:
extension (grid: Grid[GridItemP2])
@targetName("updatedMoveP2")
def updatedMove(istart: Vec2i, dir: Direction): Option[Grid[GridItemP2]] =
// ???
def go(start: Vec2i, movedItems: List[(Vec2i, GridItemP2)]): Eval[Option[List[(Vec2i, GridItemP2)]]] =
val newPos = start.offset(dir)
grid(newPos) match
case GridItemP2.Wall => Eval.always(None)
case GridItemP2.Empty => Eval.now(Some(movedItems))
case GridItemP2.BoxL =>
val rPos = newPos.offset(Direction.Right)
assert(grid(rPos) == GridItemP2.BoxR)
val newItems = movedItems.prepended(newPos, GridItemP2.BoxL)
dir match
case Direction.Left | Direction.Right =>
go(newPos, newItems)
case Direction.Up | Direction.Down =>
go(newPos, newItems).flatMap:
case Some(it) => go(rPos, it.prepended(rPos, GridItemP2.BoxR))
case None => Eval.now(None)
case GridItemP2.BoxR =>
val lPos = newPos.offset(Direction.Left)
assert(grid(lPos) == GridItemP2.BoxL)
val newItems = movedItems.prepended(newPos, GridItemP2.BoxR)
dir match
case Direction.Right | Direction.Left =>
go(newPos, newItems)
case Direction.Up | Direction.Down =>
go(newPos, newItems).flatMap:
case Some(it) => go(lPos, it.prepended(lPos, GridItemP2.BoxL))
case None => Eval.now(None)
go(istart, List()).value.map:
val alreadyInspected = mut.Set[Vec2i]()
_.foldLeft(grid.updated(istart)(GridItemP2.Empty)):
case (g, (p, item)) =>
if (!alreadyInspected.contains(p))
alreadyInspected.add(p)
g.updated(p)(GridItemP2.Empty).updated(p.offset(dir))(item)
else g
Here we use Eval which trampolines computations meaning that the recursion can be of arbitrary depth. Unlike last time, this internal tailrec function
returns a Option[List[(Vec2i, GridItemP2)]]
. This lists all the points that were found valid to move from, and at the end these are all
folded together to get back a grid.
Let’s walk through the logic here. go
is first called with the robots position, and it offsets that position by the direction.
If it finds an empty spot, it ends and returns an empty list, as no box was moved. If it finds a wall it returns None
, meaning that this movement
isn’t possible. Otherwise, it must have hit either a box’s left piece or a box’s right piece. The logic of these two are mirrored but otherwise the same.
If the direction is Left
or Right
then we don’t have to do anything special - this is basically the same as the part 1 case.
If the direction is Up
or Down
though, then we have to test both edges.
This function has a throughline of movedItems which is just a list of all the box pieces that were moved. This is threaded through in the BoxL
and BoxR
cases,
prepending the position and type of each edge.
I start with the edge the robot runs into, flatMap
the Eval
(which lets me use the value of the last function call while staying stack safe), and test the value.
If it’s None
, the first edge is immovable and I can
stop there. Otherwise, I have to test the other edge, so I return the test of the other edge.
At the end, I get the .value
of the Eval
, which just executes it, then map
the option.
To prevent some oddities, I use a mutable Set
to make sure I don’t move a piece multiple times. Other than that, the foldLeft
is fairly straight forward,
updating the old point to Empty
and updating the new point to the piece type.
It’s important here that the return of go
is in an order where boxes that fill empty spaces go first. This makes
the foldLeft
work properly, as otherwise it would end up erasing boxes that it had already drawn.
Ok, now with the huge hard part out of the way we can hook up step
in ProblemStateP2
:
case class ProblemStateP2(grid: Grid[GridItemP2], robot: Vec2i, remainingMoves: List[Direction]):
// ...
def step: Option[ProblemStateP2] =
remainingMoves match
case head :: rest =>
grid.updatedMove(robot, head) match
case Some(newGrid) => Some(ProblemStateP2(newGrid, robot.offset(head), rest))
case _ => Some(ProblemStateP2(grid, robot, rest))
case _ => None
And now we can finish part 2:
def part2(str: String): Long =
val input = parse(str)
Iterator.iterate(input.doubled): i =>
i.step.getOrElse(i)
.find(_.remainingMoves.isEmpty).get.gpsCalc
Final code:
enum Direction:
case Up, Down, Left, Right
case class Vec2i(x: Int, y: Int):
def offset(dir: Direction): Vec2i = {
dir match
case Direction.Up => Vec2i(x, y - 1)
case Direction.Down => Vec2i(x, y + 1)
case Direction.Left => Vec2i(x - 1, y)
case Direction.Right => Vec2i(x + 1, y)
}
def isContainedIn(w: Int, h: Int): Boolean =
x >= 0 && x < w && y >= 0 && y < h
case class Grid[A](values: Vector[Vector[A]]):
val height: Int = values.size
val width: Int = values.head.size
def apply(x: Int, y: Int): A = values(y)(x)
def apply(p: Vec2i): A = apply(p.x, p.y)
def isDefinedAt(p: Vec2i): Boolean = p.isContainedIn(width, height)
def isDefinedAt(x: Int, y: Int): Boolean = isDefinedAt(Vec2i(x, y))
def updated(x: Int, y: Int)(value: A): Grid[A] = Grid(values.updated(y, values(y).updated(x, value)))
def updated(p: Vec2i)(value: A): Grid[A] = updated(p.x, p.y)(value)
def zipWithIndices: Seq[(A, Vec2i)] = {
for {
y <- 0 until height
x <- 0 until width
} yield (this (x, y), Vec2i(x, y))
}
object Grid:
def apply[A](values: IterableOnce[IterableOnce[A]]): Grid[A] = Grid(values.iterator.map(_.iterator.toVector).iterator.toVector)
enum GridItem:
case Empty, Wall, Box
enum GridItemP2:
case Empty, Wall, BoxL, BoxR
extension (grid: Grid[GridItem]) {
@targetName("updatedMoveP1")
def updatedMove(start: Vec2i, dir: Direction): Option[Grid[GridItem]] =
@tailrec
def go(curGrid: Grid[GridItem], start: Vec2i): Option[Grid[GridItem]] =
val newPos = start.offset(dir)
val oldItem = curGrid(start)
curGrid(newPos) match {
case GridItem.Wall => None
case GridItem.Empty => Some(curGrid.updated(newPos)(oldItem))
case GridItem.Box => go(curGrid, newPos)
}
// prevent weird
go(grid, start).map: it =>
val newPos = start.offset(dir)
if it.isDefinedAt(newPos) then
it.updated(newPos)(GridItem.Empty)
else it
def pretty: String =
grid.values.map: it =>
it.map:
case GridItem.Empty => '.'
case GridItem.Wall => '#'
case GridItem.Box => 'O'
.mkString("", "", "")
.mkString("", "\n", "")
}
extension (grid: Grid[GridItemP2])
@targetName("updatedMoveP2")
def updatedMove(istart: Vec2i, dir: Direction): Option[Grid[GridItemP2]] =
// ???
def go(start: Vec2i, movedItems: List[(Vec2i, GridItemP2)]): Eval[Option[List[(Vec2i, GridItemP2)]]] =
val newPos = start.offset(dir)
grid(newPos) match
case GridItemP2.Wall => Eval.always(None)
case GridItemP2.Empty => Eval.now(Some(movedItems))
case GridItemP2.BoxL =>
val rPos = newPos.offset(Direction.Right)
assert(grid(rPos) == GridItemP2.BoxR)
val newItems = movedItems.prepended(newPos, GridItemP2.BoxL)
dir match
case Direction.Left | Direction.Right =>
go(newPos, newItems)
case Direction.Up | Direction.Down =>
go(newPos, newItems).flatMap:
case Some(it) => go(rPos, it.prepended(rPos, GridItemP2.BoxR))
case None => Eval.now(None)
case GridItemP2.BoxR =>
val lPos = newPos.offset(Direction.Left)
assert(grid(lPos) == GridItemP2.BoxL)
val newItems = movedItems.prepended(newPos, GridItemP2.BoxR)
dir match
case Direction.Right | Direction.Left =>
go(newPos, newItems)
case Direction.Up | Direction.Down =>
go(newPos, newItems).flatMap:
case Some(it) => go(lPos, it.prepended(lPos, GridItemP2.BoxL))
case None => Eval.now(None)
go(istart, List()).value.map:
val alreadyInspected = mut.Set[Vec2i]()
_.foldLeft(grid.updated(istart)(GridItemP2.Empty)):
case (g, (p, item)) =>
if (!alreadyInspected.contains(p))
alreadyInspected.add(p)
g.updated(p)(GridItemP2.Empty).updated(p.offset(dir))(item)
else g
case class ProblemState(grid: Grid[GridItem], robot: Vec2i, remainingMoves: List[Direction]):
def step: Option[ProblemState] =
remainingMoves match
case head :: rest =>
grid.updatedMove(robot, head) match
case Some(newGrid) => Some(ProblemState(newGrid, robot.offset(head), rest))
case _ => Some(ProblemState(grid, robot, rest))
case _ => None
def gpsCalc: Long =
grid.zipWithIndices.filter(_._1 == GridItem.Box).map((_, p) => p.y.toLong * 100L + p.x.toLong).sum
def doubled: ProblemStateP2 =
val newGrid = Grid(grid.values.map(_.flatMap:
case GridItem.Box => List(GridItemP2.BoxL, GridItemP2.BoxR)
case GridItem.Wall => List(GridItemP2.Wall, GridItemP2.Wall)
case GridItem.Empty => List(GridItemP2.Empty, GridItemP2.Empty)
))
ProblemStateP2(newGrid, robot.copy(x = robot.x * 2), remainingMoves)
case class ProblemStateP2(grid: Grid[GridItemP2], robot: Vec2i, remainingMoves: List[Direction]):
def step: Option[ProblemStateP2] =
remainingMoves match
case head :: rest =>
grid.updatedMove(robot, head) match
case Some(newGrid) => Some(ProblemStateP2(newGrid, robot.offset(head), rest))
case _ => Some(ProblemStateP2(grid, robot, rest))
case _ => None
def gpsCalc: Long =
grid.zipWithIndices.filter(_._1 == GridItemP2.BoxL).map((_, p) => p.y.toLong * 100L + p.x.toLong).sum
def parse(str: String): ProblemState =
val Array(gridStr, moveStr) = str.split("\n\n")
var robotPos = Vec2i(-1, -1)
val grid = Grid(gridStr.linesIterator.zipWithIndex.map: (str, y) =>
str.toVector.zipWithIndex.map: (it, x) =>
it match
case '#' => GridItem.Wall
case 'O' => GridItem.Box
case '.' => GridItem.Empty
case '@' =>
assert(robotPos == Vec2i(-1, -1))
robotPos = Vec2i(x, y)
GridItem.Empty
case _ => ???
)
val dirs = moveStr.linesIterator.flatten.map:
case '<' => Direction.Left
case '^' => Direction.Up
case '>' => Direction.Right
case 'v' => Direction.Down
case _ => ???
ProblemState(grid, robotPos, dirs.toList)
def part1(str: String): Long =
val input = parse(str)
Iterator.iterate(input): i =>
i.step.getOrElse(i)
.find: it =>
it.remainingMoves.isEmpty
.get.gpsCalc
def part2(str: String): Long =
val input = parse(str)
Iterator.iterate(input.doubled): i =>
i.step.getOrElse(i)
.find(_.remainingMoves.isEmpty).get.gpsCalc
Here’s my full solution. My solution uses my common package but for this page I rewrote them into the script.