Skip to content

Commit

Permalink
Add puzzle solution for 2023, day 23
Browse files Browse the repository at this point in the history
  • Loading branch information
curtislb committed Jun 10, 2024
1 parent 06ddf04 commit 21296f8
Show file tree
Hide file tree
Showing 19 changed files with 752 additions and 32 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ includes:
* [Advent of Code 2019][aoc-2019-link] (Days 1-25)
* [Advent of Code 2020][aoc-2020-link] (Days 1-25)
* [Advent of Code 2021][aoc-2021-link] (Days 1-25)
* [Advent of Code 2023][aoc-2023-link] (Days 1-22)
* [Advent of Code 2023][aoc-2023-link] (Days 1-23)

## Getting Started

Expand Down
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -321,4 +321,7 @@ dependencies {
kover(project(":year2023:day22:bricks"))
kover(project(":year2023:day22:part1"))
kover(project(":year2023:day22:part2"))
kover(project(":year2023:day23:hike"))
kover(project(":year2023:day23:part1"))
kover(project(":year2023:day23:part2"))
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class ArrayQueue<E> : Collection<E> {
}

/**
* Returns a new instance of [ArrayQueue] with the specified [elements] in FIFO order.
* Returns a new instance of [ArrayQueue] with the given [elements] in FIFO order.
*/
fun <T> arrayQueueOf(vararg elements: T): ArrayQueue<T> = ArrayQueue<T>().apply {
for (element in elements) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,10 @@ enum class Direction(private val clockwiseIndex: Int) {
* @throws IllegalArgumentException If [char] has no corresponding direction.
*/
fun fromChar(char: Char): Direction = when (char) {
'U', 'u' -> UP
'R', 'r' -> RIGHT
'D', 'd' -> DOWN
'L', 'l' -> LEFT
'U', 'u', '^' -> UP
'R', 'r', '>' -> RIGHT
'D', 'd', 'v' -> DOWN
'L', 'l', '<' -> LEFT
else -> throw IllegalArgumentException("No direction for char: $char")
}
}
Expand Down
1 change: 1 addition & 0 deletions common/graph/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies {
val junitJupiterVersion: String by properties
val junitPlatformVersion: String by properties

implementation(project(":common:collection"))
implementation(project(":common:heap"))
testImplementation(project(":common:testing"))
testImplementation("org.assertj:assertj-core:$assertjVersion")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.curtislb.adventofcode.common.graph

import com.curtislb.adventofcode.common.collection.arrayQueueOf

/**
* A graph consisting of unique nodes of type [V], connected by directed edges.
*/
Expand All @@ -17,26 +19,21 @@ abstract class UnweightedGraph<V> {
* or until [process] returns `true`, indicating that the search should be terminated early.
*/
fun bfsApply(source: V, process: (node: V, distance: Long) -> Boolean) {
val searchQueue = ArrayDeque<Pair<V, Long>>().apply { addLast(Pair(source, 0L)) }
val visited = mutableSetOf<V>()
while (searchQueue.isNotEmpty()) {
val (node, distance) = searchQueue.removeFirst()

// Ignore this node if it was previously visited
if (node in visited) {
continue
}

// Process this node, terminating the search if necessary
val nodeQueue = arrayQueueOf(source)
val distanceMap = mutableMapOf(source to 0L)
while (nodeQueue.isNotEmpty()) {
// Dequeue and process the next node, terminating if necessary
val node = nodeQueue.poll()
val distance = distanceMap[node]!!
if (process(node, distance)) {
return
}

// Enqueue all unvisited neighboring nodes with distances
visited.add(node)
// Enqueue all new neighboring nodes, and set their distances
for (neighbor in getNeighbors(node)) {
if (neighbor !in visited) {
searchQueue.addLast(Pair(neighbor, distance + 1L))
if (neighbor !in distanceMap) {
nodeQueue.offer(neighbor)
distanceMap[neighbor] = distance + 1L
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ abstract class WeightedGraph<V> {
heuristic: (node: V) -> Long,
isGoal: (node: V) -> Boolean
): Long? {
val distanceMap = mutableMapOf(source to 0L)
val nodeHeap = MinimumHeap<V>().apply { addOrDecreaseKey(source, heuristic(source)) }
val distanceMap = mutableMapOf(source to 0L)
while (!nodeHeap.isEmpty()) {
// Check the next node with the lowest f-score
val (node, _) = nodeHeap.popMinimum()
Expand All @@ -49,8 +49,8 @@ abstract class WeightedGraph<V> {
val oldDistance = distanceMap[edge.node]
val newDistance = distance + edge.weight
if (oldDistance == null || oldDistance > newDistance) {
distanceMap[edge.node] = newDistance
nodeHeap.addOrDecreaseKey(edge.node, newDistance + heuristic(edge.node))
distanceMap[edge.node] = newDistance
}
}
}
Expand All @@ -66,8 +66,8 @@ abstract class WeightedGraph<V> {
* If there is no path from [source] to any goal node, this function instead returns `null`.
*/
fun dijkstraDistance(source: V, isGoal: (node: V) -> Boolean): Long? {
val visited = mutableSetOf<V>()
val nodeHeap = MinimumHeap<V>().apply { addOrDecreaseKey(source, 0L) }
val visited = mutableSetOf<V>()
while (!nodeHeap.isEmpty()) {
// Check the next node with the shortest distance
val (node, distance) = nodeHeap.popMinimum()
Expand All @@ -77,14 +77,18 @@ abstract class WeightedGraph<V> {

visited.add(node)

// Update the shortest known distance to each unvisited neighbor
for (edge in getEdges(node)) {
if (edge.node !in visited) {
val oldDistance = nodeHeap[edge.node]
val newDistance = distance + edge.weight
if (oldDistance == null || oldDistance > newDistance) {
nodeHeap.addOrDecreaseKey(edge.node, newDistance)
}
// Process edges from this node and update distances to neighbors
for ((neighbor, edgeWeight) in getEdges(node)) {
// Don't re-add a previously visited node
if (neighbor in visited) {
continue
}

// Update the shortest known distance if needed
val oldDistance = nodeHeap[neighbor]
val newDistance = distance + edgeWeight
if (oldDistance == null || oldDistance > newDistance) {
nodeHeap.addOrDecreaseKey(neighbor, newDistance)
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -502,3 +502,8 @@ include("year2023:day21:part2")
include("year2023:day22:bricks")
include("year2023:day22:part1")
include("year2023:day22:part2")

// Day 23: A Long Walk
include("year2023:day23:hike")
include("year2023:day23:part1")
include("year2023:day23:part2")
21 changes: 21 additions & 0 deletions year2023/day23/hike/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
plugins {
val kotlinVersion: String by System.getProperties()
val koverVersion: String by System.getProperties()

kotlin("jvm") version kotlinVersion
id("org.jetbrains.kotlinx.kover") version koverVersion
}

dependencies {
val assertjVersion: String by properties
val junitJupiterVersion: String by properties
val junitPlatformVersion: String by properties

api(project(":common:grid"))
implementation(project(":common:collection"))
implementation(project(":common:geometry"))
testImplementation("org.assertj:assertj-core:$assertjVersion")
testImplementation("org.junit.jupiter:junit-jupiter-api:$junitJupiterVersion")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitJupiterVersion")
testRuntimeOnly("org.junit.platform:junit-platform-launcher:$junitPlatformVersion")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package com.curtislb.adventofcode.year2023.day23.hike

import com.curtislb.adventofcode.common.collection.arrayQueueOf
import com.curtislb.adventofcode.common.geometry.Direction
import com.curtislb.adventofcode.common.geometry.Point
import com.curtislb.adventofcode.common.grid.Grid
import com.curtislb.adventofcode.common.grid.forEachPointValue
import com.curtislb.adventofcode.common.grid.mutableGridOf
import java.io.File

/**
* A 2D grid of tiles representing a collection of interconnected hiking trails that span the height
* of the grid.
*
* @property grid A grid of [Tile]s that makes up the map of hiking trails. The grid must have at
* least one [Tile.PATH] tile in each of its first and last rows.
*
* @throws IllegalArgumentException If the [grid] does not have a [Tile.PATH] tile in each of its
* first and last rows.
*/
class HikingTrailMap(private val grid: Grid<Tile>) {
/**
* The position of the starting path tile in the first row of the grid.
*/
private val startPosition: Point

/**
* The position of the goal path tile in the last row of the grid.
*/
private val goalPosition: Point

init {
// Find the grid position of the starting path tile
val startColumnIndex = grid.firstRow().indexOf(Tile.PATH)
require(startColumnIndex != -1) { "Grid must have a path tile in the first row" }
startPosition = Point.fromMatrixCoordinates(0, startColumnIndex)

// Find the grid position of the goal path tile
val goalColumnIndex = grid.lastRow().indexOf(Tile.PATH)
require(goalColumnIndex != -1) { "Grid must have a path tile in the last row" }
goalPosition = Point.fromMatrixCoordinates(grid.lastRowIndex, goalColumnIndex)
}

/**
* Returns the length of the longest walking path from the starting tile (in the first grid row)
* to the goal tile (in the last grid row), without stepping on the same tile more than once.
*
* If there is no walkable path from the starting tile to the goal tile, this function instead
* returns -1.
*
* If [isIcy] is `true`, slope tiles (e.g. [Tile.SLOPE_UP]) are considered to be icy, meaning it
* is only possible to step onto an adjacent tile in the "downhill" direction for that slope.
*/
fun findLongestPathDistance(isIcy: Boolean = true): Int {
// Find all relevant "waypoints" in the grid
val waypoints = mutableSetOf(startPosition, goalPosition)
grid.forEachPointValue { point, char ->
if (char.isWalkable && isWaypoint(point)) {
waypoints.add(point)
}
}

// Use BFS to construct a "weighted graph" of edges between waypoints
val waypointEdgesMap = mutableMapOf<Point, MutableMap<Point, Int>>()
for (source in waypoints) {
val pointQueue = arrayQueueOf(source)
val distanceMap = mutableMapOf(source to 0)
while (pointQueue.isNotEmpty()) {
val point = pointQueue.poll()
val distance = distanceMap[point]!!

// Stop searching along path when a waypoint is found
if (point in waypoints && point != source) {
waypointEdgesMap.getOrPut(source) { mutableMapOf() }[point] = distance
continue
}

// Enqueue adjacent tiles that are walkable and unvisited
for (neighbor in getWalkableNeighbors(point, isIcy)) {
if (neighbor !in distanceMap) {
pointQueue.offer(neighbor)
distanceMap[neighbor] = distance + 1
}
}
}
}

return maxDistanceToGoal(startPosition, waypointEdgesMap, visitedSet = mutableSetOf())
}

/**
* Returns the positions of all tiles onto which it's possible to step from the tile at the
* specified grid [position].
*
* If [isIcy] is `true`, slope tiles (e.g. [Tile.SLOPE_UP]) are considered to be icy, meaning it
* is only possible to step onto an adjacent tile in the "downhill" direction for that slope.
*/
private fun getWalkableNeighbors(position: Point, isIcy: Boolean): List<Point> {
return when (val tile = grid[position]) {
// Can step from a normal path tile onto any adjacent tile
Tile.PATH -> position.cardinalNeighbors().filter(::isWalkable)

// Shouldn't be possible to step onto a forest tile
Tile.FOREST -> emptyList()

// Can only step in the "downhill" direction from an icy slope
Tile.SLOPE_UP, Tile.SLOPE_RIGHT, Tile.SLOPE_DOWN, Tile.SLOPE_LEFT -> {
if (isIcy) {
val direction = Direction.fromChar(tile.symbol)
val neighbor = position.move(direction)
if (isWalkable(neighbor)) listOf(neighbor) else emptyList()
} else {
position.cardinalNeighbors().filter(::isWalkable)
}
}
}
}

/**
* Returns `true` if it is possible to step onto the tile at the specified grid [position].
*/
private fun isWalkable(position: Point) = grid.getOrNull(position)?.isWalkable == true

/**
* Returns `true` if the tile at the specified grid [position] satisfies *any* of the following
* criteria:
*
* - It is the starting path tile.
* - It is the goal path tile.
* - It is adjacent to 3+ walkable tiles.
*/
private fun isWaypoint(position: Point): Boolean {
return position == startPosition ||
position == goalPosition ||
position.cardinalNeighbors().count(::isWalkable) > 2
}

/**
* Returns the maximum distance from [source] to the goal position by following weighted edges
* in the specified [edgesMap] and without visiting any position more than once, including
* any previously visited positions in the given [visitedSet].
*
* If it is no path from [source] to the goal that satisfies the above conditions, this function
* instead returns -1.
*/
private fun maxDistanceToGoal(
source: Point,
edgesMap: Map<Point, Map<Point, Int>>,
visitedSet: MutableSet<Point>
): Int {
// Distance from the goal to itself is 0
if (source == goalPosition) {
return 0
}

// Set default distance for if no path is found
var maxDistance = -1

// Mark source as visited
visitedSet.add(source)

// Check recursively for the maximum distance from all neighbors
for ((neighbor, distance) in edgesMap[source]!!) {
// Don't check a previously visited position
if (neighbor in visitedSet) {
continue
}

// Check distance from neighbor and save it if needed
val neighborDistance = maxDistanceToGoal(neighbor, edgesMap, visitedSet)
if (neighborDistance >= 0) {
maxDistance = maxOf(maxDistance, distance + neighborDistance)
}
}

// Unmark source as visited (backtrack)
visitedSet.remove(source)

return maxDistance
}

companion object {
/**
* Returns an instance of [HikingTrailMap] with the tile grid read from the given [file].
*
* The [file] must contain lines of equal length, where each character represents the [Tile]
* at the corresponding grid position.
*
* @throws IllegalArgumentException If [file] is formatted incorrectly.
*/
fun fromFile(file: File): HikingTrailMap {
val grid = mutableGridOf<Tile>()
file.forEachLine { line ->
val row = line.map { Tile.fromChar(it) }
grid.addShallowRow(row)
}
return HikingTrailMap(grid)
}
}
}
Loading

0 comments on commit 21296f8

Please sign in to comment.