|
1 | 1 | import Solver from "../../../../../website/src/components/Solver.js"
|
| 2 | +import Literate from "../../../../../website/src/components/Literate.js" |
2 | 3 |
|
3 | 4 | # Day 17: Clumsy Crucible
|
4 | 5 |
|
| 6 | +by [@stewSquared](https://github.com/stewSquared) |
| 7 | + |
5 | 8 | ## Puzzle description
|
6 | 9 |
|
7 | 10 | https://adventofcode.com/2023/day/17
|
8 | 11 |
|
| 12 | +## Solution Summary |
| 13 | + |
| 14 | +This is a classic search problem with an interesting restriction on state transformations. |
| 15 | + |
| 16 | +We will solve this using Dijkstra's Algorithm to find a path through the grid, using the heat loss of each position as our node weights. However, the states in our priority queue will need to include more than just position and accumulated heat loss, since the streak of forward movements in a given direction affects which positions are accessible from a given state. |
| 17 | + |
| 18 | +Since the restrictions on state transformations differ in part 1 and part 2, we'll model them separately from the base state transformations. |
| 19 | + |
| 20 | +### Framework |
| 21 | + |
| 22 | +First, we will need a `Grid` class to represent the possible positions, and store the heat at each position. |
| 23 | +It will be represented by a 2D vector: |
| 24 | +```scala |
| 25 | +case class Grid(grid: Vector[Vector[Int]]): |
| 26 | + val xRange = grid.head.indices |
| 27 | + val yRange = grid.indices |
| 28 | +``` |
| 29 | + |
| 30 | +We can parse the input and store it in the `Grid` class. Each line is treated as a row, and each character in the row is treated as a single column, and required to be a digit: |
| 31 | + |
| 32 | +```scala |
| 33 | +def loadGrid(input: String): Grid = |
| 34 | + Grid: |
| 35 | + Vector.from: |
| 36 | + for line <- input.split("\n") |
| 37 | + yield line.map(_.asDigit).toVector |
| 38 | +``` |
| 39 | + |
| 40 | +We can define some accessors to make it more convenient to work with a `Grid` that is available in the [context](https://docs.scala-lang.org/scala3/book/ca-context-parameters.html). |
| 41 | + |
| 42 | +```scala |
| 43 | +def grid(using Grid) = summon[Grid].grid |
| 44 | +def xRange(using Grid) = summon[Grid].xRange |
| 45 | +def yRange(using Grid) = summon[Grid].yRange |
| 46 | +``` |
| 47 | + |
| 48 | +Second, for convenience, let's introduce a class for presenting direction: |
| 49 | + |
| 50 | +```scala |
| 51 | +enum Dir: |
| 52 | + case N, S, E, W |
| 53 | + |
| 54 | + def turnRight = this match |
| 55 | + case Dir.N => E |
| 56 | + case Dir.E => S |
| 57 | + case Dir.S => W |
| 58 | + case Dir.W => N |
| 59 | + |
| 60 | + def turnLeft = this match |
| 61 | + case Dir.N => W |
| 62 | + case Dir.W => S |
| 63 | + case Dir.S => E |
| 64 | + case Dir.E => N |
| 65 | +``` |
| 66 | + |
| 67 | +Since moving forward, turning left, and turning right are common operations, convenience methods for each are included here. |
| 68 | + |
| 69 | +Third, a class for position: |
| 70 | + |
| 71 | +```scala |
| 72 | +case class Point(x: Int, y: Int): |
| 73 | + def inBounds(using Grid) = |
| 74 | + xRange.contains(x) && yRange.contains(y) |
| 75 | + |
| 76 | + def heatLoss(using Grid) = |
| 77 | + if inBounds then grid(y)(x) else 0 |
| 78 | + |
| 79 | + def move(dir: Dir) = dir match |
| 80 | + case Dir.N => copy(y = y - 1) |
| 81 | + case Dir.S => copy(y = y + 1) |
| 82 | + case Dir.E => copy(x = x + 1) |
| 83 | + case Dir.W => copy(x = x - 1) |
| 84 | +``` |
| 85 | + |
| 86 | +Here we provide some convenience methods for checking if a point is `inBounds` on the grid, |
| 87 | +and the `heatLoss` of a point on the grid. |
| 88 | + |
| 89 | +### Search State |
| 90 | + |
| 91 | +Now we want to be able to model our state as we're searching. The state will track our position (`pos`). To know what transitions are possible, we need to keep track of our `streak` of movements in a given direction (`dir`). Later, we'll also keep track of the heat lost while getting to a state. |
| 92 | + |
| 93 | +<Literate> |
| 94 | + |
| 95 | +```scala |
| 96 | +case class State(pos: Point, dir: Dir, streak: Int): |
| 97 | +``` |
| 98 | + |
| 99 | +Next let's define some methods for transitioning to new states. We know that we can chose to move forward, turn left, or turn right. For now, we won't consider the restrictions from Part 1 or Part 2 on whether or not you can move forward: |
| 100 | + |
| 101 | +```scala |
| 102 | + def straight: State = |
| 103 | + State(pos.move(dir), dir, streak + 1) |
| 104 | + |
| 105 | + def turnLeft: State = |
| 106 | + val newDir = dir.turnLeft |
| 107 | + State(pos.move(newDir), newDir, 1) |
| 108 | + |
| 109 | + def turnRight: State = |
| 110 | + val newDir = dir.turnRight |
| 111 | + State(pos.move(newDir), newDir, 1) |
| 112 | +``` |
| 113 | + |
| 114 | +</Literate> |
| 115 | + |
| 116 | +Note that the streak resets to one when we turn right or turn left, since we also move the position forward in that new direction. |
| 117 | + |
| 118 | +### Dijkstra's Algorithm |
| 119 | + |
| 120 | +Finally, let's lay the groundwork for an implementation of Dijkstra's algorithm. |
| 121 | + |
| 122 | +Since our valid state transformations vary between part 1 and part 2, let's parameterize our search method by a function: |
| 123 | + |
| 124 | +<Literate> |
| 125 | + |
| 126 | +```scala |
| 127 | +import collection.mutable.{PriorityQueue, Map} |
| 128 | + |
| 129 | +type StateTransform = Grid ?=> State => List[State] |
| 130 | + |
| 131 | +def search(next: StateTransform)(using Grid): Int = |
| 132 | +``` |
| 133 | + |
| 134 | +The algorithm uses Map to track the minimum total heat loss for each state, and a Priority Queue prioritizing by this heatloss to choose the next state to visit: |
| 135 | + |
| 136 | +```scala |
| 137 | + val minHeatLoss = Map.empty[State, Int] |
| 138 | + |
| 139 | + given Ordering[State] = Ordering.by(minHeatLoss) |
| 140 | + val pq = PriorityQueue.empty[State].reverse |
| 141 | + |
| 142 | + var visiting = State(Point(0, 0), Dir.E, 0) |
| 143 | + minHeatLoss(visiting) = 0 |
| 144 | +``` |
| 145 | + |
| 146 | +As we generate new states to add to the priority Queue, we need to make sure not to add suboptimal states. The first time we visit any state, it will be with a minimum possible cost, because we're visiting this new state from an adjacent minimum heatloss state in our priority queue. |
| 147 | +So any state we've already visited will be discarded. This is what our loop will look like: |
| 148 | + |
| 149 | +```scala |
| 150 | + val end = Point(xRange.max, yRange.max) |
| 151 | + while visiting.pos != end do |
| 152 | + val states = next(visiting).filterNot(minHeatLoss.contains) |
| 153 | + states.foreach: s => |
| 154 | + minHeatLoss(s) = minHeatLoss(visiting) + s.pos.heatLoss |
| 155 | + pq.enqueue(s) |
| 156 | + visiting = pq.dequeue() |
| 157 | + |
| 158 | + minHeatLoss(visiting) |
| 159 | +``` |
| 160 | + |
| 161 | +</Literate> |
| 162 | + |
| 163 | +Notice how `minHeatLoss` is always updated to the minimum of the state we're visiting from plus the incremental heatloss of the new state we're adding to the queue. |
| 164 | + |
| 165 | +We can then provide a framework for calling the `search` function using the input with `solve`. |
| 166 | +It parses the input to a `Grid`, defining it as a [given instance](https://docs.scala-lang.org/scala3/book/ca-context-parameters.html). |
| 167 | +```scala |
| 168 | +def solve(input: String, next: StateTransform): Int = |
| 169 | + given Grid = loadGrid(input) |
| 170 | + search(next) |
| 171 | +``` |
| 172 | + |
| 173 | +### Part 1 |
| 174 | + |
| 175 | +Now we need to model our state transformation restrictions for Part 1. We can typically move straight, left, and right, but we need to make sure our streak while moving straight never exceeds 3: |
| 176 | + |
| 177 | +```scala |
| 178 | +// Inside case class State: |
| 179 | + def nextStates(using Grid): List[State] = |
| 180 | + List(straight, turnLeft, turnRight).filter: s => |
| 181 | + s.pos.inBounds && s.streak <= 3 |
| 182 | +``` |
| 183 | + |
| 184 | +This will only ever filter out the forward movement, since moving to the left or right resets the streak to 1. |
| 185 | + |
| 186 | +We can then call `solve` with `nextStates` from our entry point for `part1`: |
| 187 | + |
| 188 | +```scala |
| 189 | +def part1(input: String): Int = |
| 190 | + solve(input, _.nextStates) |
| 191 | +``` |
| 192 | + |
| 193 | +### Part 2 |
| 194 | + |
| 195 | +Part 2 is similar, but our streak limit increases to 10. |
| 196 | +Furthermore, while the streak is less than four, only a forward movement is possible: |
| 197 | + |
| 198 | +```scala |
| 199 | +// inside case class State: |
| 200 | + def nextStates2(using Grid): List[State] = |
| 201 | + if streak < 4 then List(straight) |
| 202 | + else List(straight, turnLeft, turnRight).filter: s => |
| 203 | + s.pos.inBounds && s.streak <= 10 |
| 204 | +``` |
| 205 | + |
| 206 | +And we call solve with `nextStates2` to solve `part2`: |
| 207 | + |
| 208 | +```scala |
| 209 | +def part2(input: String): Int = |
| 210 | + solve(input, _.nextStates2) |
| 211 | +``` |
| 212 | + |
| 213 | +## Final Code |
| 214 | + |
| 215 | +```scala |
| 216 | +import collection.mutable.{PriorityQueue, Map} |
| 217 | + |
| 218 | +def part1(input: String): Int = |
| 219 | + solve(input, _.nextStates) |
| 220 | + |
| 221 | +def part2(input: String): Int = |
| 222 | + solve(input, _.nextStates2) |
| 223 | + |
| 224 | +def loadGrid(input: String): Grid = |
| 225 | + Grid: |
| 226 | + Vector.from: |
| 227 | + for line <- input.split("\n") |
| 228 | + yield line.map(_.asDigit).toVector |
| 229 | + |
| 230 | +case class Grid(grid: Vector[Vector[Int]]): |
| 231 | + val xRange = grid.head.indices |
| 232 | + val yRange = grid.indices |
| 233 | + |
| 234 | +enum Dir: |
| 235 | + case N, S, E, W |
| 236 | + |
| 237 | + def turnRight = this match |
| 238 | + case Dir.N => E |
| 239 | + case Dir.E => S |
| 240 | + case Dir.S => W |
| 241 | + case Dir.W => N |
| 242 | + |
| 243 | + def turnLeft = this match |
| 244 | + case Dir.N => W |
| 245 | + case Dir.W => S |
| 246 | + case Dir.S => E |
| 247 | + case Dir.E => N |
| 248 | + |
| 249 | +def grid(using Grid) = summon[Grid].grid |
| 250 | +def xRange(using Grid) = summon[Grid].xRange |
| 251 | +def yRange(using Grid) = summon[Grid].yRange |
| 252 | + |
| 253 | +case class Point(x: Int, y: Int): |
| 254 | + def inBounds(using Grid) = |
| 255 | + xRange.contains(x) && yRange.contains(y) |
| 256 | + |
| 257 | + def heatLoss(using Grid) = |
| 258 | + if inBounds then grid(y)(x) else 0 |
| 259 | + |
| 260 | + def move(dir: Dir) = dir match |
| 261 | + case Dir.N => copy(y = y - 1) |
| 262 | + case Dir.S => copy(y = y + 1) |
| 263 | + case Dir.E => copy(x = x + 1) |
| 264 | + case Dir.W => copy(x = x - 1) |
| 265 | + |
| 266 | +case class State(pos: Point, dir: Dir, streak: Int): |
| 267 | + def straight: State = |
| 268 | + State(pos.move(dir), dir, streak + 1) |
| 269 | + |
| 270 | + def turnLeft: State = |
| 271 | + val newDir = dir.turnLeft |
| 272 | + State(pos.move(newDir), newDir, 1) |
| 273 | + |
| 274 | + def turnRight: State = |
| 275 | + val newDir = dir.turnRight |
| 276 | + State(pos.move(newDir), newDir, 1) |
| 277 | + |
| 278 | + def nextStates(using Grid): List[State] = |
| 279 | + List(straight, turnLeft, turnRight).filter: s => |
| 280 | + s.pos.inBounds && s.streak <= 3 |
| 281 | + |
| 282 | + def nextStates2(using Grid): List[State] = |
| 283 | + if streak < 4 then List(straight) |
| 284 | + else List(straight, turnLeft, turnRight).filter: s => |
| 285 | + s.pos.inBounds && s.streak <= 10 |
| 286 | + |
| 287 | +type StateTransform = Grid ?=> State => List[State] |
| 288 | + |
| 289 | +def solve(input: String, next: StateTransform): Int = |
| 290 | + given Grid = loadGrid(input) |
| 291 | + search(next) |
| 292 | + |
| 293 | +def search(next: StateTransform)(using Grid): Int = |
| 294 | + |
| 295 | + val minHeatLoss = Map.empty[State, Int] |
| 296 | + |
| 297 | + given Ordering[State] = Ordering.by(minHeatLoss) |
| 298 | + val pq = PriorityQueue.empty[State].reverse |
| 299 | + |
| 300 | + var visiting = State(Point(0, 0), Dir.E, 0) |
| 301 | + minHeatLoss(visiting) = 0 |
| 302 | + |
| 303 | + val end = Point(xRange.max, yRange.max) |
| 304 | + while visiting.pos != end do |
| 305 | + val states = next(visiting).filterNot(minHeatLoss.contains) |
| 306 | + states.foreach: s => |
| 307 | + minHeatLoss(s) = minHeatLoss(visiting) + s.pos.heatLoss |
| 308 | + pq.enqueue(s) |
| 309 | + visiting = pq.dequeue() |
| 310 | + |
| 311 | + minHeatLoss(visiting) |
| 312 | +``` |
| 313 | + |
| 314 | +### Run it in the browser |
| 315 | + |
| 316 | +#### Run Part 1 |
| 317 | + |
| 318 | +<Solver puzzle="day17-part1" year="2023"/> |
| 319 | + |
| 320 | +#### Run Part 2 |
| 321 | + |
| 322 | +<Solver puzzle="day17-part2" year="2023"/> |
| 323 | + |
9 | 324 | ## Solutions from the community
|
10 | 325 |
|
| 326 | +- [Solution](https://github.com/stewSquared/advent-of-code/blob/master/src/main/scala/2021/Day17.worksheet.sc) by [stewSquared](https://github.com/stewSquared) |
11 | 327 | - [Solution](https://github.com/merlinorg/aoc2023/blob/main/src/main/scala/Day17.scala) by [merlin](https://github.com/merlinorg/)
|
12 | 328 | - [Solution](https://github.com/xRuiAlves/advent-of-code-2023/blob/main/Day17.scala) by [Rui Alves](https://github.com/xRuiAlves/)
|
13 | 329 |
|
|
0 commit comments