Skip to content

Commit 42c362e

Browse files
committed
Add an article for 2023 Day 23
1 parent 28b4637 commit 42c362e

File tree

1 file changed

+326
-0
lines changed

1 file changed

+326
-0
lines changed

docs/2023/puzzles/day23.md

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,339 @@
11
import Solver from "../../../../../website/src/components/Solver.js"
2+
import Literate from "../../../../../website/src/components/Literate.js"
23

34
# Day 23: A Long Walk
45

6+
by [@stewSquared](https://github.com/stewSquared)
7+
58
## Puzzle description
69

710
https://adventofcode.com/2023/day/23
811

12+
## Solution
13+
14+
### Overview and Observations
15+
16+
The general problem of finding the longest path through a grid or a graph is NP-hard, so we won't be using any fancy algorithms or heuristics today; we'll use a depth-first backtracking search. For part 2, we'll need some optimizations (graph compression and bitmasking) that reduce the size of the search space and visited set so the algorithm can run in around 2 seconds.
17+
18+
The general approach to finding the longest path via DFS is to maintain a set of visited positions alongside the current position. The next position can be any adjacent position that isn't in the visited set. We then recursively search from one of those adjacent positions until we find the end, note the path length, then try the other adjacent positions, keeping the longest path length we find.
19+
20+
For both problems, it is worth noticing that the vast majority of path positions in the maze are only connected to two other paths, so when entering from one path, there is only one path from which we can exit. Some paths are connected to three or four other paths. These paths, we'll call junctions.
21+
22+
Each junction might have two or three adjacent paths we can enter. When we exit a junction, we will inevitably reach another junction (or the end of the maze). Because of this, every path through the maze is fully determined by the sequence of junctions it enters. This allows us two optimizations:
23+
24+
- We can compress the grid into an adjacency graph of vertices (the junctions) with weighted edges (the distance of the path between junctions) to other vertices. This allows us to have a distinctly smaller visited set, as there are only ~35 junctions in the puzzle input. This also avoids re-computing the distance between two junctions as we might in a cell-by-cell search of the grid. On my machine, this drops the run time for part 2 from 1 minute to ~10 seconds (90%).
25+
26+
- 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%).
27+
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+
30+
### Framework
31+
32+
First we define a `Point` case class for representing coordinates, and a `Dir` enum for representing direction. Direction will be used when "walking" on a path through the maze to calculate whether a slope blocks us and to prevent us from searching adjacent points that are backwards. Similar definitions show up in solutions to other Advent of Code problems:
33+
34+
```scala
35+
case class Point(x: Int, y: Int):
36+
def dist(p: Point) = math.abs(x - p.x) + math.abs(y - p.y)
37+
def adjacent = List(copy(x = x + 1), copy(x = x - 1), copy(y = y + 1), copy(y = y - 1))
38+
39+
def move(dir: Dir) = dir match
40+
case Dir.N => copy(y = y - 1)
41+
case Dir.S => copy(y = y + 1)
42+
case Dir.E => copy(x = x + 1)
43+
case Dir.W => copy(x = x - 1)
44+
```
45+
46+
```scala
47+
enum Dir:
48+
case N, S, E, W
49+
50+
def turnRight = this match
51+
case Dir.N => E
52+
case Dir.E => S
53+
case Dir.S => W
54+
case Dir.W => N
55+
56+
def turnLeft = this match
57+
case Dir.N => W
58+
case Dir.W => S
59+
case Dir.S => E
60+
case Dir.E => N
61+
```
62+
63+
Next we create a `Maze` class that will give us basic information about the maze from the raw data:
64+
65+
<Literate>
66+
67+
```scala
68+
case class Maze(grid: Vector[Vector[Char]]):
69+
70+
def apply(p: Point): Char = grid(p.y)(p.x)
71+
72+
val xRange: Range = grid.head.indices
73+
val yRange: Range = grid.indices
74+
75+
def points: Iterator[Point] = for
76+
y <- yRange.iterator
77+
x <- xRange.iterator
78+
yield Point(x, y)
79+
```
80+
81+
So far we just have helper methods. The next few definitions are the things we'll really want to *know* about the maze in order to construct our solutions:
82+
83+
```scala
84+
val walkable: Set[Point] = points.filter(p => grid(p.y)(p.x) != '#').toSet
85+
val start: Point = walkable.minBy(_.y)
86+
val end: Point = walkable.maxBy(_.y)
87+
88+
val junctions: Set[Point] = walkable.filter: p =>
89+
Dir.values.map(p.move).count(walkable) > 2
90+
.toSet + start + end
91+
92+
val slopes = Map.from[Point, Dir]:
93+
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
98+
```
99+
100+
</Literate>
101+
102+
`walkable` gives us the set of points that are not walls, ie., they are paths or slopes.
103+
104+
`junctions` gives us the junction points mentioned in the overview, ie., paths that have multiple entrances or exits. Notice that `start` and `end` are part of the set here. While these are not true junctions, we do want them to appear in our adjacency graph.
105+
106+
`slopes` gives us the direction of each slope. For Part 1, these are the directions one must be travelling in order to progress past a slope position.
107+
108+
### Finding Connected Junctions
109+
110+
Next, we need an algorithm for finding junctions that are connected to a given junction, while tracking the distance travelled to reach that junction. This is the heart of our solution, and is necessary for both parts 1 and 2:
111+
112+
<Literate>
113+
114+
```scala
115+
def connectedJunctions(pos: Point)(using maze: Maze) = List.from[(Point, Int)]:
116+
assert(maze.junctions.contains(pos))
117+
118+
def walk(pos: Point, dir: Dir): Option[Point] =
119+
val p = pos.move(dir)
120+
Option.when(maze.walkable(p) && maze.slopes.get(p).forall(_ == dir))(p)
121+
```
122+
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.
124+
125+
```scala
126+
def search(pos: Point, facing: Dir, dist: Int): Option[(Point, Int)] =
127+
if maze.junctions.contains(pos) then Some(pos, dist) else
128+
val adjacentSearch = for
129+
nextFacing <- LazyList(facing, facing.turnRight, facing.turnLeft)
130+
nextPos <- walk(pos, nextFacing)
131+
yield search(nextPos, nextFacing, dist + 1)
132+
133+
if adjacentSearch.size == 1 then adjacentSearch.head else None
134+
```
135+
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.
137+
138+
```scala
139+
for
140+
d <- Dir.values
141+
p <- walk(pos, d)
142+
junction <- search(p, d, 1)
143+
yield junction
144+
145+
```
146+
147+
Finally, we begin the search in each direction from our current junction, returning all the connected junctions found.
148+
149+
</Literate>
150+
151+
### Part 1
152+
153+
`connectedJunctions` is sufficient to solve Part 1 quickly:
154+
155+
```scala
156+
def longestDownhillHike(using maze: Maze): Int =
157+
def search(pos: Point, dist: Int)(using maze: Maze): Int =
158+
if pos == maze.end then dist else
159+
connectedJunctions(pos).foldLeft(0):
160+
case (max, (n, d)) => max.max(search(n, dist + d))
161+
162+
search(maze.start, 0)
163+
```
164+
165+
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.
166+
167+
### Part 2
168+
169+
For part 2, we'll implement the optimization mentioned in the overview, namely, bitmasking and graph compression. Graph compression is partially implemented in `connectedJunctions`, but we'll want to avoid recomputation by storing the full graph as a map from a junction, to a list of connected junctions and the distances to each of those junctions.
170+
171+
<Literate>
172+
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+
175+
```scala
176+
def longestHike(using maze: Maze): Int =
177+
type Index = Int
178+
179+
val indexOf: Map[Point, Index] =
180+
maze.junctions.toList.sortBy(_.dist(maze.start)).zipWithIndex.toMap
181+
```
182+
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:
184+
185+
```scala
186+
val adjacent: Map[Index, List[(Index, Int)]] =
187+
maze.junctions.toList.flatMap: p1 =>
188+
connectedJunctions(p1).flatMap: (p2, d) =>
189+
val forward = indexOf(p1) -> (indexOf(p2), d)
190+
val reverse = indexOf(p2) -> (indexOf(p1), d)
191+
List(forward, reverse)
192+
.groupMap(_._1)(_._2)
193+
```
194+
195+
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.
197+
198+
```scala
199+
def search(junction: Index, visited: BitSet, totalDist: Int): Int =
200+
if junction == indexOf(maze.end) then totalDist else
201+
adjacent(junction).foldLeft(0):
202+
case (longest, (nextJunct, dist)) =>
203+
if visited(nextJunct) then longest else
204+
longest.max(search(nextJunct, visited + nextJunct, totalDist + dist))
205+
206+
search(indexOf(maze.start), BitSet.empty, 0)
207+
```
208+
209+
</Literate>
210+
211+
## Final Code
212+
213+
```scala
214+
import collection.immutable.BitSet
215+
216+
import locations.Directory.currentDir
217+
import inputs.Input.loadFileSync
218+
219+
def part1(input: String): Int =
220+
given maze: Maze = Maze(loadInput())
221+
println(s"The solution is $longestDownhillHike")
222+
223+
@main def part2: Unit =
224+
given maze: Maze = Maze(parseInput())
225+
println(s"The solution is $longestHike")
226+
227+
def parseInput(fileContents: String): Vector[Vector[Char]] = Vector.from:
228+
fileContents.split("\n").map(_.toVector)
229+
230+
enum Dir:
231+
case N, S, E, W
232+
233+
def turnRight = this match
234+
case Dir.N => E
235+
case Dir.E => S
236+
case Dir.S => W
237+
case Dir.W => N
238+
239+
def turnLeft = this match
240+
case Dir.N => W
241+
case Dir.W => S
242+
case Dir.S => E
243+
case Dir.E => N
244+
245+
case class Point(x: Int, y: Int):
246+
def dist(p: Point) = math.abs(x - p.x) + math.abs(y - p.y)
247+
def adjacent = List(copy(x = x + 1), copy(x = x - 1), copy(y = y + 1), copy(y = y - 1))
248+
249+
def move(dir: Dir) = dir match
250+
case Dir.N => copy(y = y - 1)
251+
case Dir.S => copy(y = y + 1)
252+
case Dir.E => copy(x = x + 1)
253+
case Dir.W => copy(x = x - 1)
254+
255+
case class Maze(grid: Vector[Vector[Char]]):
256+
257+
def apply(p: Point): Char = grid(p.y)(p.x)
258+
259+
val xRange: Range = grid.head.indices
260+
val yRange: Range = grid.indices
261+
262+
def points: Iterator[Point] = for
263+
y <- yRange.iterator
264+
x <- xRange.iterator
265+
yield Point(x, y)
266+
267+
val walkable: Set[Point] = points.filter(p => grid(p.y)(p.x) != '#').toSet
268+
val start: Point = walkable.minBy(_.y)
269+
val end: Point = walkable.maxBy(_.y)
270+
271+
val junctions: Set[Point] = walkable.filter: p =>
272+
Dir.values.map(p.move).count(walkable) > 2
273+
.toSet + start + end
274+
275+
val slopes = Map.from[Point, Dir]:
276+
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
281+
282+
def connectedJunctions(pos: Point)(using maze: Maze) = List.from[(Point, Int)]:
283+
def walk(pos: Point, dir: Dir): Option[Point] =
284+
val p = pos.move(dir)
285+
Option.when(maze.walkable(p) && maze.slopes.get(p).forall(_ == dir))(p)
286+
287+
def search(pos: Point, facing: Dir, dist: Int): Option[(Point, Int)] =
288+
if maze.junctions.contains(pos) then Some(pos, dist) else
289+
val adjacentSearch = for
290+
nextFacing <- LazyList(facing, facing.turnRight, facing.turnLeft)
291+
nextPos <- walk(pos, nextFacing)
292+
yield search(nextPos, nextFacing, dist + 1)
293+
294+
if adjacentSearch.size == 1 then adjacentSearch.head else None
295+
296+
for
297+
d <- Dir.values
298+
p <- walk(pos, d)
299+
junction <- search(p, d, 1)
300+
yield junction
301+
302+
def longestDownhillHike(using maze: Maze): Int =
303+
def search(pos: Point, dist: Int)(using maze: Maze): Int =
304+
if pos == maze.end then dist else
305+
connectedJunctions(pos).foldLeft(0):
306+
case (max, (n, d)) => max.max(search(n, dist + d))
307+
308+
search(maze.start, 0)
309+
310+
def longestHike(using maze: Maze): Int =
311+
type Index = Int
312+
313+
val indexOf: Map[Point, Index] =
314+
maze.junctions.toList.sortBy(_.dist(maze.start)).zipWithIndex.toMap
315+
316+
val adjacent: Map[Index, List[(Index, Int)]] =
317+
maze.junctions.toList.flatMap: p1 =>
318+
connectedJunctions(p1).flatMap: (p2, d) =>
319+
val forward = indexOf(p1) -> (indexOf(p2), d)
320+
val reverse = indexOf(p2) -> (indexOf(p1), d)
321+
List(forward, reverse)
322+
.groupMap(_._1)(_._2)
323+
324+
def search(junction: Index, visited: BitSet, totalDist: Int): Int =
325+
if junction == indexOf(maze.end) then totalDist else
326+
adjacent(junction).foldLeft(0):
327+
case (longest, (nextJunct, dist)) =>
328+
if visited(nextJunct) then longest else
329+
longest.max(search(nextJunct, visited + nextJunct, totalDist + dist))
330+
331+
search(indexOf(maze.start), BitSet.empty, 0)
332+
```
333+
9334
## Solutions from the community
10335

336+
- [Solution](https://github.com/stewSquared/advent-of-code/blob/master/src/main/scala/2023/Day23.worksheet.sc) by [Stewart Stewart](https://github.com/stewSquared)
11337
- [Solution](https://github.com/xRuiAlves/advent-of-code-2023/blob/main/Day23.scala) by [Rui Alves](https://github.com/xRuiAlves/)
12338

13339
Share your solution to the Scala community by editing this page.

0 commit comments

Comments
 (0)