Skip to content

Commit 567398e

Browse files
committed
Add an article for Day 17 of 2023
1 parent 11cafa0 commit 567398e

File tree

1 file changed

+266
-0
lines changed

1 file changed

+266
-0
lines changed

docs/2023/puzzles/day17.md

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,278 @@ import Solver from "../../../../../website/src/components/Solver.js"
22

33
# Day 17: Clumsy Crucible
44

5+
by [@stewSquared](https://github.com/stewSquared)
6+
57
## Puzzle description
68

79
https://adventofcode.com/2023/day/17
810

11+
## Solution Summary
12+
13+
This is a classic search problem with an interesting restriction on state transformations.
14+
15+
We will solve this using Djikstra'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.
16+
17+
Since the restrictions on state transformations differ in part 1 and part 2, we'll model them separately from the base state transformations.
18+
19+
### Framework
20+
21+
First, for convenience, let's introduce classes for presenting position and direction:
22+
23+
```scala
24+
enum Dir:
25+
case N, S, E, W
26+
27+
def turnRight = this match
28+
case Dir.N => E
29+
case Dir.E => S
30+
case Dir.S => W
31+
case Dir.W => N
32+
33+
def turnLeft = this match
34+
case Dir.N => W
35+
case Dir.W => S
36+
case Dir.S => E
37+
case Dir.E => N
38+
39+
case class Point(x: Int, y: Int):
40+
def inBounds = xRange.contains(x) && yRange.contains(y)
41+
42+
def move(dir: Dir) = dir match
43+
case Dir.N => copy(y = y - 1)
44+
case Dir.S => copy(y = y + 1)
45+
case Dir.E => copy(x = x + 1)
46+
case Dir.W => copy(x = x - 1)
47+
```
48+
49+
Since moving forward, turning left, and turning right are common operations, convenience methods for each are included here.
50+
51+
Next, let's work with our input. We'll parse it as a 2D vector of integers:
52+
53+
```scala
54+
val grid: Vector[Vector[Int]] = Vector.from:
55+
val file = loadFileSync(s"$currentDir/../input/day17")
56+
for line <- file.split("\n")
57+
yield line.map(_.asDigit).toVector
58+
```
59+
60+
And now a few convenience methods that need the input:
61+
62+
```scala
63+
val xRange = grid.head.indices
64+
val yRange = grid.indices
65+
66+
def inBounds(p: Point) =
67+
xRange.contains(p.x) && yRange.contains(p.y)
68+
69+
def heatLoss(p: Point) =
70+
if inBounds(p) then grid(p.y)(p.x) else 0
71+
```
72+
73+
### Search State
74+
75+
Now we want to be able to model our state as we're searching. The state will track our position. To know what transitions are possible, we need to keep track of our streak of movements in a given direction. We'll also keep track of the heat lost while getting to a state.
76+
77+
```scala
78+
case class State(pos: Point, dir: Dir, streak: Int):
79+
```
80+
81+
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:
82+
83+
```scala
84+
// inside case class State:
85+
def straight: State =
86+
State(pos.move(dir), dir, streak + 1)
87+
88+
def turnLeft: State =
89+
val newDir = dir.turnLeft
90+
State(pos.move(newDir), newDir, 1)
91+
92+
def turnRight: State =
93+
val newDir = dir.turnRight
94+
State(pos.move(newDir), newDir, 1)
95+
```
96+
97+
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.
98+
99+
### Djikstra's Algorithm
100+
101+
Finally, let's lay the groundwork for an implementation of Djikstra's algorithm.
102+
103+
Since our valid state transformations vary between part 1 and part 2, let's parameterize our search method by a function:
104+
105+
```scala
106+
def search(next: State => List[State]): Int
107+
```
108+
109+
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:
110+
111+
```scala
112+
// inside def search:
113+
import collection.mutable.{PriorityQueue, Map}
114+
115+
val minHeatLoss = Map.empty[State, Int]
116+
117+
given Ordering[State] = Ordering.by(minHeatLoss)
118+
val pq = PriorityQueue.empty[State].reverse
119+
120+
var visiting = State(Point(0, 0), Dir.E, 0)
121+
val minHeatLoss(visiting) = 0
122+
```
123+
124+
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.
125+
So any state we've already visited will be discarded. This is what our loop will look like:
126+
127+
```scala
128+
// inside def search:
129+
val end = Point(xRange.max, yRange.max)
130+
while visiting.pos != end do
131+
val states = next(visiting).filterNot(minHeatLoss.contains)
132+
states.foreach: s =>
133+
minHeatLoss(s) = minHeatLoss(visiting) + heatLoss(s)
134+
pq.enqueue(s)
135+
visiting = pq.dequeue()
136+
137+
minHeatLoss(visting)
138+
```
139+
140+
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.
141+
142+
### Part 1
143+
144+
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 straight streak never exceeds 3:
145+
146+
```scala
147+
// Inside case class State:
148+
def nextStates: List[State] =
149+
List(straight, turnLeft, turnRight).filter: s =>
150+
inBounds(s.pos) && s.streak <= 3
151+
```
152+
153+
This will only ever filter out the forward movement, since moving to the left or right resets the streak to 1.
154+
155+
### Part 2
156+
157+
Part 2 is similar, but our streak limit increases to 10.
158+
Furthermore, while the streak is less than four, only a forward movement is possible:
159+
160+
```scala
161+
// inside case class State:
162+
def nextStates2: List[State] =
163+
if streak < 4 then List(straight)
164+
else List(straight, turnLeft, turnRight).filter: s =>
165+
inBounds(s.pos) && s.streak <= 10
166+
```
167+
168+
## Final Code
169+
170+
```scala
171+
import locations.Directory.currentDir
172+
import inputs.Input.loadFileSync
173+
174+
@main def part1: Unit =
175+
println(s"The solution is ${search(_.nextStates)}")
176+
177+
@main def part2: Unit =
178+
println(s"The solution is ${search(_.nextStates2)}")
179+
180+
def loadInput(): Vector[Vector[Int]] = Vector.from:
181+
val file = loadFileSync(s"$currentDir/../input/day17")
182+
for line <- file.split("\n")
183+
yield line.map(_.asDigit).toVector
184+
185+
enum Dir:
186+
case N, S, E, W
187+
188+
def turnRight = this match
189+
case Dir.N => E
190+
case Dir.E => S
191+
case Dir.S => W
192+
case Dir.W => N
193+
194+
def turnLeft = this match
195+
case Dir.N => W
196+
case Dir.W => S
197+
case Dir.S => E
198+
case Dir.E => N
199+
200+
val grid = loadInput()
201+
202+
val xRange = grid.head.indices
203+
val yRange = grid.indices
204+
205+
case class Point(x: Int, y: Int):
206+
def move(dir: Dir) = dir match
207+
case Dir.N => copy(y = y - 1)
208+
case Dir.S => copy(y = y + 1)
209+
case Dir.E => copy(x = x + 1)
210+
case Dir.W => copy(x = x - 1)
211+
212+
def inBounds(p: Point) =
213+
xRange.contains(p.x) && yRange.contains(p.y)
214+
215+
def heatLoss(p: Point) =
216+
if inBounds(p) then grid(p.y)(p.x) else 0
217+
218+
case class State(pos: Point, dir: Dir, streak: Int):
219+
def straight: State =
220+
val newPos = pos.move(dir)
221+
State(pos.move(dir), dir, streak + 1)
222+
223+
def turnLeft: State =
224+
val newDir = dir.turnLeft
225+
val newPos = pos.move(newDir)
226+
State(newPos, newDir, 1)
227+
228+
def turnRight: State =
229+
val newDir = dir.turnRight
230+
val newPos = pos.move(newDir)
231+
State(newPoss, newDir, 1)
232+
233+
def nextStates: List[State] =
234+
List(straight, turnLeft, turnRight).filter: s =>
235+
inBounds(s.pos) && s.streak <= 3
236+
237+
def nextStates2: List[State] =
238+
if streak < 4 then List(straight)
239+
else List(straight, turnLeft, turnRight).filter: s =>
240+
inBounds(s.pos) && s.streak <= 10
241+
242+
def search(next: State => List[State]): Int =
243+
import collection.mutable.{PriorityQueue, Map}
244+
245+
val minHeatLoss = Map.empty[State, Int]
246+
247+
given Ordering[State] = Ordering.by(minHeatLoss)
248+
val pq = PriorityQueue.empty[State].reverse
249+
250+
var visiting = State(Point(0, 0), Dir.E, 0)
251+
val minHeatLoss(visiting) = 0
252+
253+
val end = Point(xRange.max, yRange.max)
254+
while visiting.pos != end do
255+
val states = next(visiting).filterNot(minHeatLoss.contains)
256+
states.foreach: s =>
257+
minHeatLoss(s) = minHeatLoss(visiting) + heatLoss(s.pos)
258+
pq.enqueue(s)
259+
visiting = pq.dequeue()
260+
261+
minHeatLoss(end)
262+
```
263+
264+
### Run it in the browser
265+
266+
#### Part 1
267+
268+
<Solver puzzle="day17-part1" year="2023"/>
269+
270+
#### Part 2
271+
272+
<Solver puzzle="day17-part2" year="2023"/>
273+
9274
## Solutions from the community
10275

276+
- [Solution](https://github.com/stewSquared/advent-of-code/blob/master/src/main/scala/2021/Day17.worksheet.sc) by [stewSquared](https://github.com/stewSquared)
11277
- [Solution](https://github.com/merlinorg/aoc2023/blob/main/src/main/scala/Day17.scala) by [merlin](https://github.com/merlinorg/)
12278
- [Solution](https://github.com/xRuiAlves/advent-of-code-2023/blob/main/Day17.scala) by [Rui Alves](https://github.com/xRuiAlves/)
13279

0 commit comments

Comments
 (0)