Skip to content

Commit 5af6fdb

Browse files
committed
proof-read
1 parent b3f04a6 commit 5af6fdb

File tree

2 files changed

+708
-34
lines changed

2 files changed

+708
-34
lines changed

docs/2023/puzzles/day23.md

Lines changed: 65 additions & 34 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

@@ -88,13 +95,17 @@ So far we just have helper methods. The next few definitions are the things we'l
8895
val junctions: Set[Point] = walkable.filter: p =>
8996
Dir.values.map(p.move).count(walkable) > 2
9097
.toSet + start + end
98+
```
9199

92-
val slopes = Map.from[Point, Dir]:
100+
Here we can populate which points are slopes by looking up a point with `this.apply(p)`, shortened to `this(p)`.
101+
```scala
102+
val slopes: Map[Point, Dir] = Map.from:
93103
points.collect:
94-
case p if apply(p) == '^' => p -> Dir.N
95-
case p if apply(p) == 'v' => p -> Dir.S
96-
case p if apply(p) == '>' => p -> Dir.E
97-
case p if apply(p) == '<' => p -> Dir.W
104+
case p if this(p) == '^' => p -> Dir.N
105+
case p if this(p) == 'v' => p -> Dir.S
106+
case p if this(p) == '>' => p -> Dir.E
107+
case p if this(p) == '<' => p -> Dir.W
108+
end Maze
98109
```
99110

100111
</Literate>
@@ -112,15 +123,19 @@ Next, we need an algorithm for finding junctions that are connected to a given j
112123
<Literate>
113124

114125
```scala
115-
def connectedJunctions(pos: Point)(using maze: Maze) = List.from[(Point, Int)]:
126+
def connectedJunctions(pos: Point)(using maze: Maze) = List.from:
116127
assert(maze.junctions.contains(pos))
128+
```
129+
130+
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.
117131

132+
```scala
118133
def walk(pos: Point, dir: Dir): Option[Point] =
119134
val p = pos.move(dir)
120135
Option.when(maze.walkable(p) && maze.slopes.get(p).forall(_ == dir))(p)
121136
```
122137

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.
138+
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.
124139

125140
```scala
126141
def search(pos: Point, facing: Dir, dist: Int): Option[(Point, Int)] =
@@ -133,33 +148,40 @@ This `walk` helper method attempts to move in a given direction from a given pos
133148
if adjacentSearch.size == 1 then adjacentSearch.head else None
134149
```
135150

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.
151+
Finally, we begin the search in each direction from our current junction, returning all the connected junctions found.
137152

138153
```scala
139154
for
140155
d <- Dir.values
141156
p <- walk(pos, d)
142157
junction <- search(p, d, 1)
143158
yield junction
144-
159+
end connectedJunctions
145160
```
146161

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

151164
### Part 1
152165

153166
`connectedJunctions` is sufficient to solve Part 1 quickly:
154167

155168
```scala
169+
def part1(input: String): Int =
170+
given Maze = Maze(parseInput(input))
171+
longestDownhillHike
172+
173+
def parseInput(fileContents: String): Vector[Vector[Char]] =
174+
Vector.from:
175+
fileContents.split("\n").map(_.toVector)
176+
156177
def longestDownhillHike(using maze: Maze): Int =
157-
def search(pos: Point, dist: Int)(using maze: Maze): Int =
178+
def search(pos: Point, dist: Int): Int =
158179
if pos == maze.end then dist else
159180
connectedJunctions(pos).foldLeft(0):
160181
case (max, (n, d)) => max.max(search(n, dist + d))
161182

162183
search(maze.start, 0)
184+
end longestDownhillHike
163185
```
164186

165187
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 +192,23 @@ For part 2, we'll implement the optimization mentioned in the overview, namely,
170192

171193
<Literate>
172194

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-
175195
```scala
196+
def part2(input: String): Int =
197+
given Maze = Maze(parseInput(input))
198+
longestHike
199+
176200
def longestHike(using maze: Maze): Int =
177201
type Index = Int
202+
```
203+
204+
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:
178205

206+
```scala
179207
val indexOf: Map[Point, Index] =
180208
maze.junctions.toList.sortBy(_.dist(maze.start)).zipWithIndex.toMap
181209
```
182210

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:
211+
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:
184212

185213
```scala
186214
val adjacent: Map[Index, List[(Index, Int)]] =
@@ -193,7 +221,7 @@ Next, we define an adjacency graph. Since `connectedJunctinos` takes slopes into
193221
```
194222

195223
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.
224+
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.
197225

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

206234
search(indexOf(maze.start), BitSet.empty, 0)
235+
end longestHike
207236
```
208237

209238
</Literate>
@@ -213,19 +242,17 @@ The main differences are that we now use indices of junctions rather than `Point
213242
```scala
214243
import collection.immutable.BitSet
215244

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

223-
@main def part2(input: String): Unit =
224-
given maze: Maze = Maze(parseInput(input))
225-
println(s"The solution is $longestHike")
249+
def part2(input: String): Int =
250+
given Maze = Maze(parseInput(input))
251+
longestHike
226252

227-
def parseInput(fileContents: String): Vector[Vector[Char]] = Vector.from:
228-
fileContents.split("\n").map(_.toVector)
253+
def parseInput(fileContents: String): Vector[Vector[Char]] =
254+
Vector.from:
255+
fileContents.split("\n").map(_.toVector)
229256

230257
enum Dir:
231258
case N, S, E, W
@@ -272,14 +299,15 @@ case class Maze(grid: Vector[Vector[Char]]):
272299
Dir.values.map(p.move).count(walkable) > 2
273300
.toSet + start + end
274301

275-
val slopes = Map.from[Point, Dir]:
302+
val slopes: Map[Point, Dir] = Map.from:
276303
points.collect:
277-
case p if apply(p) == '^' => p -> Dir.N
278-
case p if apply(p) == 'v' => p -> Dir.S
279-
case p if apply(p) == '>' => p -> Dir.E
280-
case p if apply(p) == '<' => p -> Dir.W
304+
case p if this(p) == '^' => p -> Dir.N
305+
case p if this(p) == 'v' => p -> Dir.S
306+
case p if this(p) == '>' => p -> Dir.E
307+
case p if this(p) == '<' => p -> Dir.W
308+
end Maze
281309

282-
def connectedJunctions(pos: Point)(using maze: Maze) = List.from[(Point, Int)]:
310+
def connectedJunctions(pos: Point)(using maze: Maze) = List.from:
283311
def walk(pos: Point, dir: Dir): Option[Point] =
284312
val p = pos.move(dir)
285313
Option.when(maze.walkable(p) && maze.slopes.get(p).forall(_ == dir))(p)
@@ -298,14 +326,16 @@ def connectedJunctions(pos: Point)(using maze: Maze) = List.from[(Point, Int)]:
298326
p <- walk(pos, d)
299327
junction <- search(p, d, 1)
300328
yield junction
329+
end connectedJunctions
301330

302331
def longestDownhillHike(using maze: Maze): Int =
303-
def search(pos: Point, dist: Int)(using maze: Maze): Int =
332+
def search(pos: Point, dist: Int): Int =
304333
if pos == maze.end then dist else
305334
connectedJunctions(pos).foldLeft(0):
306335
case (max, (n, d)) => max.max(search(n, dist + d))
307336

308337
search(maze.start, 0)
338+
end longestDownhillHike
309339

310340
def longestHike(using maze: Maze): Int =
311341
type Index = Int
@@ -329,6 +359,7 @@ def longestHike(using maze: Maze): Int =
329359
longest.max(search(nextJunct, visited + nextJunct, totalDist + dist))
330360

331361
search(indexOf(maze.start), BitSet.empty, 0)
362+
end longestHike
332363
```
333364

334365
## Solutions from the community

0 commit comments

Comments
 (0)