Skip to content

Commit 96f75ca

Browse files
committed
proof-read
1 parent b3f04a6 commit 96f75ca

File tree

2 files changed

+692
-24
lines changed

2 files changed

+692
-24
lines changed

docs/2023/puzzles/day23.md

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Solver from "../../../../../website/src/components/Solver.js"
22
import Literate from "../../../../../website/src/components/Literate.js"
3+
import ExpandImage from "../../../../../website/src/components/ExpandImage.js"
34

45
# Day 23: A Long Walk
56

@@ -25,7 +26,13 @@ Each junction might have two or three adjacent paths we can enter. When we exit
2526

2627
- For each iteration of the search through this graph, we check all adjacent junctions against the visited set. When using a hash set, this will result in computing hashes of position coordinates tens of millions of times. We can avoid this by giving each junction an index and using a BitSet of these indices as our visited set. Checking for membership in a BitSet only requires a bitshift and a bitwise AND mask. On my machine, this drops the run time from ~7 seconds to ~2 seconds (70%).
2728

28-
For part 1, neither of these optimizations are necessary. To understand why, notice that every junction is surrounded by slopes. When a junction is surrounded by four slopes, as most of them are, two are incoming and two are outgoing. For part 1, these are arranged in such a way that the adjacency graph becomes a directed acyclic graph, with a greatly reduced search space. One way to notice this early on is to generate a [visualization via GraphViz](https://dreampuf.github.io/GraphvizOnline/#digraph%20G%20%7B%0A%220%22%20-%3E%20%221%22%20%5Blabel%3D159%5D%3B%0A%221%22%20-%3E%20%223%22%20%5Blabel%3D138%5D%3B%0A%221%22%20-%3E%20%222%22%20%5Blabel%3D80%5D%3B%0A%222%22%20-%3E%20%224%22%20%5Blabel%3D170%5D%3B%0A%222%22%20-%3E%20%225%22%20%5Blabel%3D310%5D%3B%0A%223%22%20-%3E%20%224%22%20%5Blabel%3D72%5D%3B%0A%223%22%20-%3E%20%226%22%20%5Blabel%3D334%5D%3B%0A%224%22%20-%3E%20%2211%22%20%5Blabel%3D232%5D%3B%0A%224%22%20-%3E%20%229%22%20%5Blabel%3D116%5D%3B%0A%225%22%20-%3E%20%2211%22%20%5Blabel%3D184%5D%3B%0A%225%22%20-%3E%20%227%22%20%5Blabel%3D112%5D%3B%0A%226%22%20-%3E%20%229%22%20%5Blabel%3D38%5D%3B%0A%226%22%20-%3E%20%228%22%20%5Blabel%3D44%5D%3B%0A%227%22%20-%3E%20%2212%22%20%5Blabel%3D222%5D%3B%0A%227%22%20-%3E%20%2213%22%20%5Blabel%3D66%5D%3B%0A%228%22%20-%3E%20%2210%22%20%5Blabel%3D38%5D%3B%0A%228%22%20-%3E%20%2215%22%20%5Blabel%3D440%5D%3B%0A%229%22%20-%3E%20%2214%22%20%5Blabel%3D188%5D%3B%0A%229%22%20-%3E%20%2210%22%20%5Blabel%3D20%5D%3B%0A%2210%22%20-%3E%20%2216%22%20%5Blabel%3D120%5D%3B%0A%2210%22%20-%3E%20%2219%22%20%5Blabel%3D222%5D%3B%0A%2211%22%20-%3E%20%2214%22%20%5Blabel%3D72%5D%3B%0A%2211%22%20-%3E%20%2213%22%20%5Blabel%3D74%5D%3B%0A%2212%22%20-%3E%20%2218%22%20%5Blabel%3D144%5D%3B%0A%2212%22%20-%3E%20%2220%22%20%5Blabel%3D520%5D%3B%0A%2213%22%20-%3E%20%2217%22%20%5Blabel%3D202%5D%3B%0A%2213%22%20-%3E%20%2218%22%20%5Blabel%3D140%5D%3B%0A%2214%22%20-%3E%20%2216%22%20%5Blabel%3D196%5D%3B%0A%2214%22%20-%3E%20%2217%22%20%5Blabel%3D152%5D%3B%0A%2215%22%20-%3E%20%2219%22%20%5Blabel%3D184%5D%3B%0A%2215%22%20-%3E%20%2222%22%20%5Blabel%3D472%5D%3B%0A%2216%22%20-%3E%20%2223%22%20%5Blabel%3D94%5D%3B%0A%2216%22%20-%3E%20%2221%22%20%5Blabel%3D198%5D%3B%0A%2217%22%20-%3E%20%2224%22%20%5Blabel%3D56%5D%3B%0A%2217%22%20-%3E%20%2221%22%20%5Blabel%3D102%5D%3B%0A%2218%22%20-%3E%20%2224%22%20%5Blabel%3D146%5D%3B%0A%2218%22%20-%3E%20%2220%22%20%5Blabel%3D60%5D%3B%0A%2219%22%20-%3E%20%2222%22%20%5Blabel%3D60%5D%3B%0A%2219%22%20-%3E%20%2223%22%20%5Blabel%3D40%5D%3B%0A%2220%22%20-%3E%20%2228%22%20%5Blabel%3D280%5D%3B%0A%2221%22%20-%3E%20%2227%22%20%5Blabel%3D162%5D%3B%0A%2221%22%20-%3E%20%2226%22%20%5Blabel%3D82%5D%3B%0A%2222%22%20-%3E%20%2225%22%20%5Blabel%3D124%5D%3B%0A%2223%22%20-%3E%20%2226%22%20%5Blabel%3D190%5D%3B%0A%2223%22%20-%3E%20%2225%22%20%5Blabel%3D112%5D%3B%0A%2224%22%20-%3E%20%2227%22%20%5Blabel%3D104%5D%3B%0A%2224%22%20-%3E%20%2228%22%20%5Blabel%3D250%5D%3B%0A%2225%22%20-%3E%20%2230%22%20%5Blabel%3D336%5D%3B%0A%2226%22%20-%3E%20%2231%22%20%5Blabel%3D128%5D%3B%0A%2226%22%20-%3E%20%2230%22%20%5Blabel%3D98%5D%3B%0A%2227%22%20-%3E%20%2229%22%20%5Blabel%3D156%5D%3B%0A%2227%22%20-%3E%20%2231%22%20%5Blabel%3D132%5D%3B%0A%2228%22%20-%3E%20%2229%22%20%5Blabel%3D58%5D%3B%0A%2229%22%20-%3E%20%2232%22%20%5Blabel%3D392%5D%3B%0A%2230%22%20-%3E%20%2233%22%20%5Blabel%3D188%5D%3B%0A%2231%22%20-%3E%20%2233%22%20%5Blabel%3D62%5D%3B%0A%2231%22%20-%3E%20%2232%22%20%5Blabel%3D84%5D%3B%0A%2232%22%20-%3E%20%2234%22%20%5Blabel%3D92%5D%3B%0A%2233%22%20-%3E%20%2234%22%20%5Blabel%3D158%5D%3B%0A%2234%22%20-%3E%20%2235%22%20%5Blabel%3D49%5D%3B%0A%7D).
29+
For part 1, neither of these optimizations are necessary. To understand why, notice that every junction is surrounded by slopes. When a junction is surrounded by four slopes, as most of them are, two are incoming and two are outgoing. For part 1, these are arranged in such a way that the adjacency graph becomes a directed acyclic graph, with a greatly reduced search space. One way to notice this early on is to generate a visualization via GraphViz, such as the following:
30+
31+
import GraphVizSvg from '/img/2023-day23/graphviz.svg';
32+
33+
<ExpandImage>
34+
<GraphVizSvg />
35+
</ExpandImage>
2936

3037
### Framework
3138

@@ -112,15 +119,19 @@ Next, we need an algorithm for finding junctions that are connected to a given j
112119
<Literate>
113120

114121
```scala
115-
def connectedJunctions(pos: Point)(using maze: Maze) = List.from[(Point, Int)]:
122+
def connectedJunctions(pos: Point)(using maze: Maze) = List.from:
116123
assert(maze.junctions.contains(pos))
124+
```
117125

126+
This `walk` helper method attempts to move in a given direction from a given position, accounting for walls and slopes in the maze. This alternatively could have been defined as a method on `Point` itself.
127+
128+
```scala
118129
def walk(pos: Point, dir: Dir): Option[Point] =
119130
val p = pos.move(dir)
120131
Option.when(maze.walkable(p) && maze.slopes.get(p).forall(_ == dir))(p)
121132
```
122133

123-
This `walk` helper method attempts to move in a given direction from a given position, accounting for walls and slopes in the maze. This alternatively could have been defined as a method on `Point` itself.
134+
This `search` helper method walks down a path from a junction while tracking the current direction and distance. `adjacentSearch` attempts to walk recursively in directions that don't go backwards. A LazyList is used here to prevent stack overflows. If there is only one adjacent path to walk too, we continue searching that path recursively until we reach a junction, otherwise, we have reached a dead end; `None` represents the fact that no new junctions are reachable down this path.
124135

125136
```scala
126137
def search(pos: Point, facing: Dir, dist: Int): Option[(Point, Int)] =
@@ -133,33 +144,40 @@ This `walk` helper method attempts to move in a given direction from a given pos
133144
if adjacentSearch.size == 1 then adjacentSearch.head else None
134145
```
135146

136-
This `search` helper method walks down a path from a junction while tracking the current direction and distance. `adjacentSearch` attempts to walk recursively in directions that don't go backwards. A LazyList is used here to prevent stack overflows. If there is only one adjacent path to walk too, we continue searching that path recursively until we reach a junction, otherwise, we have reached a dead end; `None` represents the fact that no new junctions are reachable down this path.
147+
Finally, we begin the search in each direction from our current junction, returning all the connected junctions found.
137148

138149
```scala
139150
for
140151
d <- Dir.values
141152
p <- walk(pos, d)
142153
junction <- search(p, d, 1)
143154
yield junction
144-
155+
end connectedJunctions
145156
```
146157

147-
Finally, we begin the search in each direction from our current junction, returning all the connected junctions found.
148-
149158
</Literate>
150159

151160
### Part 1
152161

153162
`connectedJunctions` is sufficient to solve Part 1 quickly:
154163

155164
```scala
165+
def part1(input: String): Int =
166+
given Maze = Maze(parseInput(input))
167+
longestDownhillHike
168+
169+
def parseInput(fileContents: String): Vector[Vector[Char]] =
170+
Vector.from:
171+
fileContents.split("\n").map(_.toVector)
172+
156173
def longestDownhillHike(using maze: Maze): Int =
157-
def search(pos: Point, dist: Int)(using maze: Maze): Int =
174+
def search(pos: Point, dist: Int): Int =
158175
if pos == maze.end then dist else
159176
connectedJunctions(pos).foldLeft(0):
160177
case (max, (n, d)) => max.max(search(n, dist + d))
161178

162179
search(maze.start, 0)
180+
end longestDownhillHike
163181
```
164182

165183
This uses a recursive helper method named `search`. Beginning with `start`, we recursively search for the longest path starting at each of the connected junctions.
@@ -170,17 +188,23 @@ For part 2, we'll implement the optimization mentioned in the overview, namely,
170188

171189
<Literate>
172190

173-
We begin by assigning indices to each of the junctions, by sorting them (in any way, as long as the ordering is well-defined) and zipping with an index:
174-
175191
```scala
192+
def part2(input: String): Int =
193+
given Maze = Maze(parseInput(input))
194+
longestHike
195+
176196
def longestHike(using maze: Maze): Int =
177197
type Index = Int
198+
```
199+
200+
We begin by assigning indices to each of the junctions, by sorting them (in any way, as long as the ordering is well-defined) and zipping with an index:
178201

202+
```scala
179203
val indexOf: Map[Point, Index] =
180204
maze.junctions.toList.sortBy(_.dist(maze.start)).zipWithIndex.toMap
181205
```
182206

183-
Next, we define an adjacency graph. Since `connectedJunctinos` takes slopes into account, and we no longer care about slopes for part 2, we add both the forward and reverse directinos into our Map. Note how we translate the Point locations used by `connectedJunctions` into indices using `indexOf`, defined above:
207+
Next, we define an adjacency graph. Since `connectedJunctions` takes slopes into account, and we no longer care about slopes for part 2, we add both the forward and reverse directions into our Map. Note how we translate the Point locations used by `connectedJunctions` into indices using `indexOf`, defined above:
184208

185209
```scala
186210
val adjacent: Map[Index, List[(Index, Int)]] =
@@ -193,7 +217,7 @@ Next, we define an adjacency graph. Since `connectedJunctinos` takes slopes into
193217
```
194218

195219
Finally, we perform a depth-first search that is very similar to what we used in Part 1.
196-
The main differences are that we now use indices of junctions rather than `Point`s reperesenting current position, and we now check adjacent junctions against a BitSet of visited points, which we now track as we search recursively.
220+
The main differences are that we now use indices of junctions rather than `Point`s representing current position, and we now check adjacent junctions against a BitSet of visited points, which we now track as we search recursively.
197221

198222
```scala
199223
def search(junction: Index, visited: BitSet, totalDist: Int): Int =
@@ -204,6 +228,7 @@ The main differences are that we now use indices of junctions rather than `Point
204228
longest.max(search(nextJunct, visited + nextJunct, totalDist + dist))
205229

206230
search(indexOf(maze.start), BitSet.empty, 0)
231+
end longestHike
207232
```
208233

209234
</Literate>
@@ -213,19 +238,17 @@ The main differences are that we now use indices of junctions rather than `Point
213238
```scala
214239
import collection.immutable.BitSet
215240

216-
import locations.Directory.currentDir
217-
import inputs.Input.loadFileSync
218-
219241
def part1(input: String): Int =
220-
given maze: Maze = Maze(loadInput(input))
221-
println(s"The solution is $longestDownhillHike")
242+
given Maze = Maze(parseInput(input))
243+
longestDownhillHike
222244

223-
@main def part2(input: String): Unit =
224-
given maze: Maze = Maze(parseInput(input))
225-
println(s"The solution is $longestHike")
245+
def part2(input: String): Int =
246+
given Maze = Maze(parseInput(input))
247+
longestHike
226248

227-
def parseInput(fileContents: String): Vector[Vector[Char]] = Vector.from:
228-
fileContents.split("\n").map(_.toVector)
249+
def parseInput(fileContents: String): Vector[Vector[Char]] =
250+
Vector.from:
251+
fileContents.split("\n").map(_.toVector)
229252

230253
enum Dir:
231254
case N, S, E, W
@@ -279,7 +302,7 @@ case class Maze(grid: Vector[Vector[Char]]):
279302
case p if apply(p) == '>' => p -> Dir.E
280303
case p if apply(p) == '<' => p -> Dir.W
281304

282-
def connectedJunctions(pos: Point)(using maze: Maze) = List.from[(Point, Int)]:
305+
def connectedJunctions(pos: Point)(using maze: Maze) = List.from:
283306
def walk(pos: Point, dir: Dir): Option[Point] =
284307
val p = pos.move(dir)
285308
Option.when(maze.walkable(p) && maze.slopes.get(p).forall(_ == dir))(p)
@@ -300,12 +323,13 @@ def connectedJunctions(pos: Point)(using maze: Maze) = List.from[(Point, Int)]:
300323
yield junction
301324

302325
def longestDownhillHike(using maze: Maze): Int =
303-
def search(pos: Point, dist: Int)(using maze: Maze): Int =
326+
def search(pos: Point, dist: Int): Int =
304327
if pos == maze.end then dist else
305328
connectedJunctions(pos).foldLeft(0):
306329
case (max, (n, d)) => max.max(search(n, dist + d))
307330

308331
search(maze.start, 0)
332+
end longestDownhillHike
309333

310334
def longestHike(using maze: Maze): Int =
311335
type Index = Int
@@ -329,6 +353,7 @@ def longestHike(using maze: Maze): Int =
329353
longest.max(search(nextJunct, visited + nextJunct, totalDist + dist))
330354

331355
search(indexOf(maze.start), BitSet.empty, 0)
356+
end longestHike
332357
```
333358

334359
## Solutions from the community

0 commit comments

Comments
 (0)