Skip to main content

Day 22: Sand Slabs

by Paweł Cembaluk

Puzzle description

https://adventofcode.com/2023/day/22

Model

Before delving into the solution, let's familiarize ourselves with the representation of bricks.. We have two case classes: Coordinate to denote a point in the three-dimensional space, and Brick to define a brick with starting and ending coordinates.

case class Coordinate(x: Int, y: Int, z: Int)

case class Brick(start: Coordinate, end: Coordinate)

Part 1

Parsing the input

The parse method employs pattern matching and string interpolation to extract starting and ending coordinates from a multi-line string representation of bricks. It iterates over each line, deconstructs it into coordinate values using pattern matching, and constructs a sequence of Brick objects with the parsed coordinates.

def parse(input: String): Seq[Brick] =
for
s"$x1,$y1,$z1~$x2,$y2,$z2" <- input.split('\n')
yield
val start = Coordinate(x1.toInt, y1.toInt, z1.toInt)
val end = Coordinate(x2.toInt, y2.toInt, z2.toInt)
Brick(start, end)

Brick methods

There are two fundamental operations on the Bricks that form the backbone of our solution: moving the brick down, and determining whether the brick collides with another one.

To facilitate the movement of bricks downward, the moveDown method is employed. This operation involves adjusting the z value of the brick's coordinates.

lazy val moveDown: Brick =
copy(
start = start.copy(z = start.z - 1),
end = end.copy(z = end.z - 1)
)

Another vital operation involves determining whether two bricks collide with each other. 3D collision is analogous to 1D collision, so let's start with that.

1D line segments on X axis collide with each other when:

maxX >= otherMinX && otherMaxX >= minX

We are not guaranteed the order of coordinates, so we have to determine min values and max values ourselves. Let's express it as a method on the Brick:

def xOverlaps(other: Brick) = {
val minX = start.x min end.x
val maxX = start.x max end.x
val otherMinX = other.start.x min other.end.x
val otherMaxX = other.start.x max other.end.x
maxX >= otherMinX && otherMaxX >= minX
}

To extend this to a 3D collision, we apply the same logic for the y and z axes. To avoid code repetition, we introduce an axis: Coordinate => Int extractor that extracts a coordinate value from a Brick. The updated method is as follows:

def axisOverlaps(other: Brick)(axis: Coordinate => Int) = {
val min = axis(start) min axis(end)
val max = axis(start) max axis(end)
val otherMin = axis(other.start) min axis(other.end)
val otherMax = axis(other.start) max axis(other.end)
max >= otherMin && otherMax >= min
}

Now, to determine a 3D collision, we create a method that uses the axisOverlaps defined above for each axis:

def collidesWith(other: Brick): Boolean =
axisOverlaps(other)(_.x) &&
axisOverlaps(other)(_.y) &&
axisOverlaps(other)(_.z)

Dropping groups of bricks

As the input is a snapshot of falling bricks, we must first drop them all to a stationary position.

First, define a way to check if a brick has collided with the ground, determined with a simple check on the z axis:

def collidesWithGround(brick: Brick): Boolean =
brick.start.z == 0 || brick.end.z == 0

Next, since we will determine the collisions at this point, we'll create a Map that will associate each brick with the bricks supporting it.

Let's define the dropBricks function that can do this:

import scala.collection.mutable

def dropBricks(bricks: Seq[Brick]): Map[Brick, Set[Brick]] = {

First, sort the bricks by the z axis to handle the falling order. This is necessary because the input doesn't guarantee the order of the bricks:

  val bricksByZAsc = bricks.sortBy(brick => (brick.start.z) min (brick.end.z))

Next, initialize the Stack of bricks to drop and the Map of already dropped ones:

  val remainingBricks = mutable.Stack.from(bricksByZAsc)
val droppedBricks = mutable.Map[Brick, Set[Brick]]()

Then, loop over the remaining bricks with while (remainingBricks.nonEmpty).

  while (remainingBricks.nonEmpty) {

On each iteration, simulate the fall of a single brick:

    val brick = remainingBricks.pop()
val brickMovedDown = brick.moveDown

First, determine if there are any colliding bricks from the currently known droppedBricks. We can produce this as a Set, with contents determined using the previously defined Brick#collidesWith method:

    val collidingBricks =
droppedBricks.keys.filter(brickMovedDown.collidesWith).toSet

Now, determine whether the brick is stationary. "Stationary" means that it either collides with the ground or another brick. If it is stationary, put it into the droppedBricks along with the dropped bricks that collide with it. If not, put it back into the remainingBricks to move it further down in the next step:

    if (collidesWithGround(brickMovedDown) || collidingBricks.nonEmpty)
droppedBricks.put(brick, collidingBricks)
else
remainingBricks.push(brickMovedDown)
}

After all the bricks finish falling, return the droppedBricks by converting to an immutable Map.

  droppedBricks.toMap
}

Determining the disintegrable bricks

Now, let's get back to the core challenge. We want to figure out how many bricks we can safely disintegrate. A brick is considered disintegrable if it's not the sole support for another brick. Using our map that outlines which bricks support others, we can easily identify the opposite – bricks that we cannot disintegrate. All remaining bricks are safe for disintegration:

def getDisintegrableBricks(brickToSupportingBricks: Map[Brick, Set[Brick]]): Set[Brick] = {
val nonDisintegrableBricks = brickToSupportingBricks.values.collect {
case supporting if supporting.sizeIs == 1
supporting.head // the only brick that holds the brick above
}.toSet
brickToSupportingBricks.keySet diff nonDisintegrableBricks
}

Bringing it all together, we get the solution for Part 1:

def part1(input: String): Int = {
val bricks = parse(input)
val brickToSupportingBricks = dropBricks(bricks)
val disintegrableBricks = getDisintegrableBricks(brickToSupportingBricks)
disintegrableBricks.size
}

Part 2

Part 2 builds upon the code from Part 1 with the introduction of a new functionality: calculating the total number of bricks that will fall after the removal of a specific brick. To accomplish this, we'll define the countFallingChain function. We'll utilize brickToSupportingBricks and brick as function arguments.

def countFallingChain(brickToSupportingBricks: Map[Brick, Set[Brick]])(brick: Brick): Int = {

Initially, we set up the collection of disintegratedBricks, remainingBricks to check, and a flag to determine the completion of the chain reaction:

  val disintegratedBricks = mutable.Set[Brick](brick)
var remainingBricks = brickToSupportingBricks.removed(brick)
var isChainReactionFinished = false

Next, loop while the chain reaction is not finished

  while (!isChainReactionFinished) {

In each iteration of the loop, we identify the bricks that have fallen (considered disintegrated) and those that remain untouched:

    val (newDisintegratedBricks, newRemainingBricks) = remainingBricks
.partition { (_, supportingBricks) =>
supportingBricks.nonEmpty && supportingBricks.subsetOf(disintegratedBricks)
}

If no bricks have fallen, indicating the completion of the chain reaction, we conclude the process. Otherwise, we add all the fallen bricks to disintegratedBricks and update remainingBricks for further checking:

    if (newDisintegratedBricks.isEmpty)
isChainReactionFinished = true
else
disintegratedBricks.addAll(newDisintegratedBricks.keySet)
remainingBricks = newRemainingBricks
}

Finally, after the chain reaction is complete, we return the count of disintegrated bricks, excluding the initial one:

  disintegratedBricks.size - 1 // don't include the initial brick
}

With countFallingChain defined, we can utilize it to calculate the falling chain for each brick:

val fallingChainCounts = brickToSupportingBricks.keys.toList.map(
countFallingChain(brickToSupportingBricks)
)

It's important to highlight the use of .toList in this context. The keys method returns an Iterable, which is a Set underneath. By converting it to a list, we ensure that each count is preserved independently. Without this conversion, if multiple bricks have the same falling chain count, some counts may be lost.

By combining these individual chain counts and summing them up, we arrive at the answer for Part 2:

def part2(input: String): Int = {
val bricks = parse(input)
val brickToSupportingBricks = dropBricks(bricks)
val fallingChainCounts = brickToSupportingBricks.keys.toList.map(
countFallingChain(brickToSupportingBricks)
)
fallingChainCounts.sum
}

Final code

case class Coordinate(x: Int, y: Int, z: Int)

case class Brick(start: Coordinate, end: Coordinate) {

lazy val moveDown: Brick =
copy(
start = start.copy(z = start.z - 1),
end = end.copy(z = end.z - 1)
)

def collidesWith(other: Brick): Boolean =
axisOverlaps(other)(_.x) &&
axisOverlaps(other)(_.y) &&
axisOverlaps(other)(_.z)

private def axisOverlaps(other: Brick)(axis: Coordinate => Int) = {
val min = axis(start) min axis(end)
val max = axis(start) max axis(end)
val otherMin = axis(other.start) min axis(other.end)
val otherMax = axis(other.start) max axis(other.end)
max >= otherMin && otherMax >= min
}
}

def parse(input: String): Seq[Brick] =
for s"$x1,$y1,$z1~$x2,$y2,$z2" <- input.split('\n')
yield
val start = Coordinate(x1.toInt, y1.toInt, z1.toInt)
val end = Coordinate(x2.toInt, y2.toInt, z2.toInt)
Brick(start, end)

import scala.collection.mutable

def dropBricks(bricks: Seq[Brick]): Map[Brick, Set[Brick]] = {
val bricksByZAsc = bricks.sortBy(brick => (brick.start.z) min (brick.end.z))
val remainingBricks = mutable.Stack.from(bricksByZAsc)
val droppedBricks = mutable.Map[Brick, Set[Brick]]()

while (remainingBricks.nonEmpty) {
val brick = remainingBricks.pop()
val brickMovedDown = brick.moveDown
val collidingBricks =
droppedBricks.keys.filter(brickMovedDown.collidesWith).toSet
if (collidesWithGround(brickMovedDown) || collidingBricks.nonEmpty)
droppedBricks.put(brick, collidingBricks)
else
remainingBricks.push(brickMovedDown)
}

droppedBricks.toMap
}

def collidesWithGround(brick: Brick): Boolean =
brick.start.z == 0 || brick.end.z == 0

def getDisintegrableBricks(brickToSupportingBricks: Map[Brick, Set[Brick]]): Set[Brick] = {
val nonDisintegrableBricks = brickToSupportingBricks.values.collect {
case supporting if supporting.sizeIs == 1 =>
supporting.head // the only brick that holds the brick above
}.toSet
brickToSupportingBricks.keySet diff nonDisintegrableBricks
}

def part1(input: String): Int = {
val bricks = parse(input)
val brickToSupportingBricks = dropBricks(bricks)
val disintegrableBricks = getDisintegrableBricks(brickToSupportingBricks)
disintegrableBricks.size
}

def countFallingChain(brickToSupportingBricks: Map[Brick, Set[Brick]])(brick: Brick): Int = {
val disintegratedBricks = mutable.Set[Brick](brick)
var remainingBricks = brickToSupportingBricks.removed(brick)
var isChainReactionFinished = false

while (!isChainReactionFinished) {
val (newDisintegratedBricks, newRemainingBricks) = remainingBricks
.partition { (_, supportingBricks) =>
supportingBricks.nonEmpty && supportingBricks.subsetOf(
disintegratedBricks
)
}
if (newDisintegratedBricks.isEmpty)
isChainReactionFinished = true
else
disintegratedBricks.addAll(newDisintegratedBricks.keySet)
remainingBricks = newRemainingBricks
}

disintegratedBricks.size - 1 // don't include the initial brick
}

def part2(input: String): Int = {
val bricks = parse(input)
val brickToSupportingBricks = dropBricks(bricks)
val fallingChainCounts = brickToSupportingBricks.keys.toList.map(
countFallingChain(brickToSupportingBricks)
)
fallingChainCounts.sum
}

Solutions from the community

Share your solution to the Scala community by editing this page. You can even write the whole article! See here for the expected format