Skip to content

Commit

Permalink
Add generic Grid2d and Point2d data classes and use them in 2024/12
Browse files Browse the repository at this point in the history
  • Loading branch information
pfolta committed Dec 13, 2024
1 parent 0f3ebcd commit 705a503
Show file tree
Hide file tree
Showing 5 changed files with 251 additions and 46 deletions.
5 changes: 0 additions & 5 deletions src/main/kotlin/adventofcode/common/Collections.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,6 @@ inline fun <reified T : Any?> List<List<T>>.neighbors(
.filter { it.first < this[y].size && it.second < size }
.toSet()

inline fun <reified T : Any?> List<List<T>>.contains(
x: Int,
y: Int,
): Boolean = y in indices && x in this[y].indices

/**
* Transposes a 2D collection by rotating it 90deg clockwise:
*
Expand Down
93 changes: 93 additions & 0 deletions src/main/kotlin/adventofcode/common/Grid2d.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package adventofcode.common

import kotlin.String

data class Point2d(
val x: Int,
val y: Int,
) {
/**
* Pretty formatted String representation.
*
* (0, 1)
*/
override fun toString(): String = "($x, $y)"

/**
* Add two points together by adding their x and y coordinates
*/
operator fun plus(other: Point2d): Point2d = Point2d(x + other.x, y + other.y)
}

val NORTH = Point2d(0, -1)
val NORTHEAST = Point2d(1, -1)
val EAST = Point2d(1, 0)
val SOUTHEAST = Point2d(1, 1)
val SOUTH = Point2d(0, 1)
val SOUTHWEST = Point2d(-1, 1)
val WEST = Point2d(-1, 0)
val NORTHWEST = Point2d(-1, -1)

data class Grid2d<T>(val values: List<List<T>>) {
val points = values.flatMapIndexed { y, row -> List(row.size) { x -> Point2d(x, y) } }
val isSquare: Boolean = values.all { row -> row.size == values.size }

/**
* Pretty formatted String representation.
*
* [A, B, C]
* [D, E, F]
* [G, H, I]
*/
override fun toString(): String = values.joinToString("\n") { row -> row.toString() }

/**
* Returns `true` if the grid contains `value`
*/
operator fun contains(value: T): Boolean = values.flatten().contains(value)

/**
* Returns `true` if the point is within the grid.
*/
operator fun contains(point: Point2d): Boolean = point in points

/**
* Returns the points of all instances of `value` if the grid contains it.
*/
fun find(value: T): List<Point2d> =
points
.filter { (x, y) -> values[y][x] == value }
.map { (x, y) -> Point2d(x, y) }

/**
* Returns the value at the given point if the point is within the grid, throws otherwise.
*/
operator fun get(point: Point2d): T =
if (point in this) values[point.y][point.x] else throw IndexOutOfBoundsException("Point $point is not part of this grid")

/**
* Returns the value at the given point if the point is within the grid, or `null` otherwise.
*/
fun getOrNull(point: Point2d): T? = if (point in this) values[point.y][point.x] else null

/**
* Returns a set of valid neighbors for a given point P in the grid.
*
* Excluding diagonals: Including diagonals:
* _ N _ N N N
* N P N N P N
* _ N _ N N N
* where N is a neighbor of P.
*/
fun neighborsOf(
point: Point2d,
includeDiagonals: Boolean = false,
): Set<Point2d> =
when {
includeDiagonals -> setOf(NORTH, NORTHEAST, EAST, SOUTHEAST, SOUTH, SOUTHWEST, WEST, NORTHWEST)
else -> setOf(NORTH, EAST, SOUTH, WEST)
}
.map { direction -> point + direction }
.filter { it in this }
.toSet()
}
7 changes: 0 additions & 7 deletions src/main/kotlin/adventofcode/common/Tuple.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,3 @@ object Tuple {

operator fun Pair<Int, Int>.minus(other: Pair<Int, Int>) = first - other.first to second - other.second
}

enum class Direction(val delta: Pair<Int, Int>) {
NORTH(0 to -1),
EAST(1 to 0),
SOUTH(0 to 1),
WEST(-1 to 0),
}
56 changes: 22 additions & 34 deletions src/main/kotlin/adventofcode/year2024/Day12GardenGroups.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,15 @@ package adventofcode.year2024

import adventofcode.Puzzle
import adventofcode.PuzzleInput
import adventofcode.common.Direction
import adventofcode.common.Direction.EAST
import adventofcode.common.Direction.NORTH
import adventofcode.common.Direction.SOUTH
import adventofcode.common.Direction.WEST
import adventofcode.common.Tuple.plus
import adventofcode.common.contains
import adventofcode.common.neighbors
import adventofcode.common.EAST
import adventofcode.common.Grid2d
import adventofcode.common.NORTH
import adventofcode.common.Point2d
import adventofcode.common.SOUTH
import adventofcode.common.WEST

class Day12GardenGroups(customInput: PuzzleInput? = null) : Puzzle(customInput) {
private val garden by lazy {
input.lines().mapIndexed { y, line -> line.mapIndexed { x, type -> GardenPlot(x, y, type) } }
}
private val garden by lazy { Grid2d(input.lines().map { line -> line.toCharArray().toList() }) }

private val regions by lazy { garden.mapRegions() }

Expand All @@ -23,66 +19,58 @@ class Day12GardenGroups(customInput: PuzzleInput? = null) : Puzzle(customInput)
override fun partTwo() = regions.sumOf { region -> region.area * region.sides(garden) }

companion object {
private data class GardenPlot(
val x: Int,
val y: Int,
val type: Char,
)

private data class Region(
val type: Char,
val plots: Set<GardenPlot>,
val plots: Set<Point2d>,
) {
val area = plots.size

val perimeter =
plots.sumOf { plot ->
Direction
.entries
.map { direction -> (plot.x to plot.y) + direction.delta }
.filterNot { side -> side in plots.map { (x, y) -> x to y } }
setOf(NORTH, EAST, SOUTH, WEST)
.map { direction -> plot + direction }
.filterNot { side -> side in plots }
.size
}

fun sides(garden: List<List<GardenPlot>>): Int =
plots.sumOf { (x, y) ->
fun sides(garden: Grid2d<Char>): Int =
plots.sumOf { plot ->
setOf(NORTH to EAST, EAST to SOUTH, SOUTH to WEST, WEST to NORTH)
.map { (first, second) ->
listOf(x to y, (x to y) + first.delta, (x to y) + second.delta, (x to y) + first.delta + second.delta)
.map { (a, b) -> if (garden.contains(a, b)) garden[b][a].type else null }
listOf(plot, plot + first, plot + second, plot + first + second)
.map { a -> garden.getOrNull(a) }
}
.count { (plot, sideA, sideB, corner) ->
(plot != sideA && plot != sideB) || (plot == sideA && plot == sideB && plot != corner)
}
}
}

private fun List<List<GardenPlot>>.mapRegions(): Set<Region> {
private fun Grid2d<Char>.mapRegions(): Set<Region> {
val regions = mutableSetOf<Region>()
val queue = ArrayDeque(flatten())
val queue = ArrayDeque(points)

while (queue.isNotEmpty()) {
val current = queue.removeFirst()
val plots = getConnectedPlots(current)

queue.removeAll(plots)
regions.add(Region(current.type, plots))
regions.add(Region(this[current], plots))
}

return regions
}

private fun List<List<GardenPlot>>.getConnectedPlots(plot: GardenPlot): Set<GardenPlot> {
val region = mutableSetOf<GardenPlot>()
private fun Grid2d<Char>.getConnectedPlots(plot: Point2d): Set<Point2d> {
val region = mutableSetOf<Point2d>()
val queue = ArrayDeque(setOf(plot))

while (queue.isNotEmpty()) {
val current = queue.removeFirst()

val neighbors =
neighbors(current.x, current.y, false)
.map { (x, y) -> this[y][x] }
.filter { (_, _, type) -> type == plot.type }
neighborsOf(current)
.filter { neighbor -> this[plot] == this[neighbor] }
.filterNot { neighbor -> neighbor in region }

region.addAll(setOf(current) + neighbors)
Expand Down
136 changes: 136 additions & 0 deletions src/test/kotlin/adventofcode/common/Grid2dSpec.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package adventofcode.common

import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.collections.beEmpty
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.should
import io.kotest.matchers.shouldBe

class Grid2dSpec : FreeSpec({
val grid =
Grid2d(
listOf(
listOf('A', 'B', 'C'),
listOf('D', 'E', 'F'),
listOf('G', 'H', 'I'),
),
)

"isSquare" - {
"is `true` for a square grid" {
grid.isSquare shouldBe true
}

"is `false` for a non-square grid" {
Grid2d(
listOf(
listOf('A', 'B', 'C', 'D'),
listOf('E', 'F', 'G', 'H'),
listOf('I', 'J', 'K', 'L'),
),
).isSquare shouldBe false
}
}

"contains value operator" - {
"returns `true` if the grid contains that value" {
('E' in grid) shouldBe true
}

"returns `false` if the grid does not contain that value" {
('Z' in grid) shouldBe false
}
}

"contains Point2d operator" - {
"returns `true` if the grid contains that point" {
(Point2d(0, 0) in grid) shouldBe true
(Point2d(1, 1) in grid) shouldBe true
}

"returns `false` if the grid does not contain that value" {
(Point2d(0, -1) in grid) shouldBe false
(Point2d(4, 0) in grid) shouldBe false
}
}

"find" - {
"returns the point of that value if the grid contains that value exactly once" {
grid.find('E') shouldContainExactly listOf(Point2d(1, 1))
}

"returns the points of all instances if the grid contains that value" {
Grid2d(
listOf(
listOf('A', 'B', 'C'),
listOf('D', 'A', 'F'),
listOf('G', 'H', 'A'),
),
).find('A') shouldContainExactly listOf(Point2d(0, 0), Point2d(1, 1), Point2d(2, 2))
}

"returns an empty list if the grid does not contain that value" {
grid.find('Z') should beEmpty()
}
}

"get" - {
"returns the value at the given point if it exists" {
grid[Point2d(2, 1)] shouldBe 'F'
}

"throws if the point is not part of the grid" {
shouldThrow<IndexOutOfBoundsException> {
grid[Point2d(10, 10)]
}.apply {
message shouldBe "Point (10, 10) is not part of this grid"
}
}
}

"getOrNull" - {
"returns the value at the given point if it exists" {
grid.getOrNull(Point2d(2, 1)) shouldBe 'F'
}

"returns null if the point is not part of the grid" {
grid.getOrNull(Point2d(10, 10)) shouldBe null
}
}

"neighborsOf" - {
"returns the cardinal neighbors of a point in the grid" {
grid.neighborsOf(Point2d(1, 1)) shouldContainExactlyInAnyOrder setOf(Point2d(1, 0), Point2d(2, 1), Point2d(1, 2), Point2d(0, 1))
}

"returns only valid cardinal neighbors of a point on the edge of grid" {
grid.neighborsOf(Point2d(0, 0)) shouldContainExactlyInAnyOrder setOf(Point2d(1, 0), Point2d(0, 1))
grid.neighborsOf(Point2d(2, 1)) shouldContainExactlyInAnyOrder setOf(Point2d(2, 0), Point2d(2, 2), Point2d(1, 1))
}

"returns cardinal and diagonal neighbors of a point in the grid when includeDiagonals is `true`" {
grid.neighborsOf(Point2d(1, 1), includeDiagonals = true) shouldContainExactlyInAnyOrder
setOf(
Point2d(1, 0),
Point2d(2, 0),
Point2d(2, 1),
Point2d(2, 2),
Point2d(1, 2),
Point2d(0, 2),
Point2d(0, 1),
Point2d(0, 0),
)
}
}

"toString" {
grid.toString() shouldBe
"""
[A, B, C]
[D, E, F]
[G, H, I]
""".trimIndent()
}
})

0 comments on commit 705a503

Please sign in to comment.