Skip to content

Commit 81101dc

Browse files
Add an article for 2023 Day 23 (#546)
* Add an article for 2023 Day 23 * add ExpandImage component * proof-read --------- Co-authored-by: Jamie Thompson <[email protected]>
1 parent 28b4637 commit 81101dc

File tree

4 files changed

+1070
-0
lines changed

4 files changed

+1070
-0
lines changed

docs/2023/puzzles/day23.md

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,370 @@
11
import Solver from "../../../../../website/src/components/Solver.js"
2+
import Literate from "../../../../../website/src/components/Literate.js"
3+
import ExpandImage from "../../../../../website/src/components/ExpandImage.js"
24

35
# Day 23: A Long Walk
46

7+
by [@stewSquared](https://github.com/stewSquared)
8+
59
## Puzzle description
610

711
https://adventofcode.com/2023/day/23
812

13+
## Solution
14+
15+
### Overview and Observations
16+
17+
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.
18+
19+
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.
20+
21+
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.
22+
23+
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:
24+
25+
- 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%).
26+
27+
- 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%).
28+
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>
36+
37+
### Framework
38+
39+
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:
40+
41+
```scala
42+
case class Point(x: Int, y: Int):
43+
def dist(p: Point) = math.abs(x - p.x) + math.abs(y - p.y)
44+
def adjacent = List(copy(x = x + 1), copy(x = x - 1), copy(y = y + 1), copy(y = y - 1))
45+
46+
def move(dir: Dir) = dir match
47+
case Dir.N => copy(y = y - 1)
48+
case Dir.S => copy(y = y + 1)
49+
case Dir.E => copy(x = x + 1)
50+
case Dir.W => copy(x = x - 1)
51+
```
52+
53+
```scala
54+
enum Dir:
55+
case N, S, E, W
56+
57+
def turnRight = this match
58+
case Dir.N => E
59+
case Dir.E => S
60+
case Dir.S => W
61+
case Dir.W => N
62+
63+
def turnLeft = this match
64+
case Dir.N => W
65+
case Dir.W => S
66+
case Dir.S => E
67+
case Dir.E => N
68+
```
69+
70+
Next we create a `Maze` class that will give us basic information about the maze from the raw data:
71+
72+
<Literate>
73+
74+
```scala
75+
case class Maze(grid: Vector[Vector[Char]]):
76+
77+
def apply(p: Point): Char = grid(p.y)(p.x)
78+
79+
val xRange: Range = grid.head.indices
80+
val yRange: Range = grid.indices
81+
82+
def points: Iterator[Point] = for
83+
y <- yRange.iterator
84+
x <- xRange.iterator
85+
yield Point(x, y)
86+
```
87+
88+
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:
89+
90+
```scala
91+
val walkable: Set[Point] = points.filter(p => grid(p.y)(p.x) != '#').toSet
92+
val start: Point = walkable.minBy(_.y)
93+
val end: Point = walkable.maxBy(_.y)
94+
95+
val junctions: Set[Point] = walkable.filter: p =>
96+
Dir.values.map(p.move).count(walkable) > 2
97+
.toSet + start + end
98+
```
99+
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:
103+
points.collect:
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
109+
```
110+
111+
</Literate>
112+
113+
`walkable` gives us the set of points that are not walls, ie., they are paths or slopes.
114+
115+
`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.
116+
117+
`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.
118+
119+
### Finding Connected Junctions
120+
121+
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:
122+
123+
<Literate>
124+
125+
```scala
126+
def connectedJunctions(pos: Point)(using maze: Maze) = List.from:
127+
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.
131+
132+
```scala
133+
def walk(pos: Point, dir: Dir): Option[Point] =
134+
val p = pos.move(dir)
135+
Option.when(maze.walkable(p) && maze.slopes.get(p).forall(_ == dir))(p)
136+
```
137+
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.
139+
140+
```scala
141+
def search(pos: Point, facing: Dir, dist: Int): Option[(Point, Int)] =
142+
if maze.junctions.contains(pos) then Some(pos, dist) else
143+
val adjacentSearch = for
144+
nextFacing <- LazyList(facing, facing.turnRight, facing.turnLeft)
145+
nextPos <- walk(pos, nextFacing)
146+
yield search(nextPos, nextFacing, dist + 1)
147+
148+
if adjacentSearch.size == 1 then adjacentSearch.head else None
149+
```
150+
151+
Finally, we begin the search in each direction from our current junction, returning all the connected junctions found.
152+
153+
```scala
154+
for
155+
d <- Dir.values
156+
p <- walk(pos, d)
157+
junction <- search(p, d, 1)
158+
yield junction
159+
end connectedJunctions
160+
```
161+
162+
</Literate>
163+
164+
### Part 1
165+
166+
`connectedJunctions` is sufficient to solve Part 1 quickly:
167+
168+
```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+
177+
def longestDownhillHike(using maze: Maze): Int =
178+
def search(pos: Point, dist: Int): Int =
179+
if pos == maze.end then dist else
180+
connectedJunctions(pos).foldLeft(0):
181+
case (max, (n, d)) => max.max(search(n, dist + d))
182+
183+
search(maze.start, 0)
184+
end longestDownhillHike
185+
```
186+
187+
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.
188+
189+
### Part 2
190+
191+
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.
192+
193+
<Literate>
194+
195+
```scala
196+
def part2(input: String): Int =
197+
given Maze = Maze(parseInput(input))
198+
longestHike
199+
200+
def longestHike(using maze: Maze): Int =
201+
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:
205+
206+
```scala
207+
val indexOf: Map[Point, Index] =
208+
maze.junctions.toList.sortBy(_.dist(maze.start)).zipWithIndex.toMap
209+
```
210+
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:
212+
213+
```scala
214+
val adjacent: Map[Index, List[(Index, Int)]] =
215+
maze.junctions.toList.flatMap: p1 =>
216+
connectedJunctions(p1).flatMap: (p2, d) =>
217+
val forward = indexOf(p1) -> (indexOf(p2), d)
218+
val reverse = indexOf(p2) -> (indexOf(p1), d)
219+
List(forward, reverse)
220+
.groupMap(_._1)(_._2)
221+
```
222+
223+
Finally, we perform a depth-first search that is very similar to what we used in Part 1.
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.
225+
226+
```scala
227+
def search(junction: Index, visited: BitSet, totalDist: Int): Int =
228+
if junction == indexOf(maze.end) then totalDist else
229+
adjacent(junction).foldLeft(0):
230+
case (longest, (nextJunct, dist)) =>
231+
if visited(nextJunct) then longest else
232+
longest.max(search(nextJunct, visited + nextJunct, totalDist + dist))
233+
234+
search(indexOf(maze.start), BitSet.empty, 0)
235+
end longestHike
236+
```
237+
238+
</Literate>
239+
240+
## Final Code
241+
242+
```scala
243+
import collection.immutable.BitSet
244+
245+
def part1(input: String): Int =
246+
given Maze = Maze(parseInput(input))
247+
longestDownhillHike
248+
249+
def part2(input: String): Int =
250+
given Maze = Maze(parseInput(input))
251+
longestHike
252+
253+
def parseInput(fileContents: String): Vector[Vector[Char]] =
254+
Vector.from:
255+
fileContents.split("\n").map(_.toVector)
256+
257+
enum Dir:
258+
case N, S, E, W
259+
260+
def turnRight = this match
261+
case Dir.N => E
262+
case Dir.E => S
263+
case Dir.S => W
264+
case Dir.W => N
265+
266+
def turnLeft = this match
267+
case Dir.N => W
268+
case Dir.W => S
269+
case Dir.S => E
270+
case Dir.E => N
271+
272+
case class Point(x: Int, y: Int):
273+
def dist(p: Point) = math.abs(x - p.x) + math.abs(y - p.y)
274+
def adjacent = List(copy(x = x + 1), copy(x = x - 1), copy(y = y + 1), copy(y = y - 1))
275+
276+
def move(dir: Dir) = dir match
277+
case Dir.N => copy(y = y - 1)
278+
case Dir.S => copy(y = y + 1)
279+
case Dir.E => copy(x = x + 1)
280+
case Dir.W => copy(x = x - 1)
281+
282+
case class Maze(grid: Vector[Vector[Char]]):
283+
284+
def apply(p: Point): Char = grid(p.y)(p.x)
285+
286+
val xRange: Range = grid.head.indices
287+
val yRange: Range = grid.indices
288+
289+
def points: Iterator[Point] = for
290+
y <- yRange.iterator
291+
x <- xRange.iterator
292+
yield Point(x, y)
293+
294+
val walkable: Set[Point] = points.filter(p => grid(p.y)(p.x) != '#').toSet
295+
val start: Point = walkable.minBy(_.y)
296+
val end: Point = walkable.maxBy(_.y)
297+
298+
val junctions: Set[Point] = walkable.filter: p =>
299+
Dir.values.map(p.move).count(walkable) > 2
300+
.toSet + start + end
301+
302+
val slopes: Map[Point, Dir] = Map.from:
303+
points.collect:
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
309+
310+
def connectedJunctions(pos: Point)(using maze: Maze) = List.from:
311+
def walk(pos: Point, dir: Dir): Option[Point] =
312+
val p = pos.move(dir)
313+
Option.when(maze.walkable(p) && maze.slopes.get(p).forall(_ == dir))(p)
314+
315+
def search(pos: Point, facing: Dir, dist: Int): Option[(Point, Int)] =
316+
if maze.junctions.contains(pos) then Some(pos, dist) else
317+
val adjacentSearch = for
318+
nextFacing <- LazyList(facing, facing.turnRight, facing.turnLeft)
319+
nextPos <- walk(pos, nextFacing)
320+
yield search(nextPos, nextFacing, dist + 1)
321+
322+
if adjacentSearch.size == 1 then adjacentSearch.head else None
323+
324+
for
325+
d <- Dir.values
326+
p <- walk(pos, d)
327+
junction <- search(p, d, 1)
328+
yield junction
329+
end connectedJunctions
330+
331+
def longestDownhillHike(using maze: Maze): Int =
332+
def search(pos: Point, dist: Int): Int =
333+
if pos == maze.end then dist else
334+
connectedJunctions(pos).foldLeft(0):
335+
case (max, (n, d)) => max.max(search(n, dist + d))
336+
337+
search(maze.start, 0)
338+
end longestDownhillHike
339+
340+
def longestHike(using maze: Maze): Int =
341+
type Index = Int
342+
343+
val indexOf: Map[Point, Index] =
344+
maze.junctions.toList.sortBy(_.dist(maze.start)).zipWithIndex.toMap
345+
346+
val adjacent: Map[Index, List[(Index, Int)]] =
347+
maze.junctions.toList.flatMap: p1 =>
348+
connectedJunctions(p1).flatMap: (p2, d) =>
349+
val forward = indexOf(p1) -> (indexOf(p2), d)
350+
val reverse = indexOf(p2) -> (indexOf(p1), d)
351+
List(forward, reverse)
352+
.groupMap(_._1)(_._2)
353+
354+
def search(junction: Index, visited: BitSet, totalDist: Int): Int =
355+
if junction == indexOf(maze.end) then totalDist else
356+
adjacent(junction).foldLeft(0):
357+
case (longest, (nextJunct, dist)) =>
358+
if visited(nextJunct) then longest else
359+
longest.max(search(nextJunct, visited + nextJunct, totalDist + dist))
360+
361+
search(indexOf(maze.start), BitSet.empty, 0)
362+
end longestHike
363+
```
364+
9365
## Solutions from the community
10366

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

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

website/src/components/ExpandImage.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import React from 'react'
2+
3+
const ExpandImage = ({ children }) => {
4+
5+
const StyledChildren = () =>
6+
React.Children.map(children, child => {
7+
return (
8+
<div className='image-container-175mw'>
9+
{child}
10+
</div>
11+
)
12+
}
13+
);
14+
15+
return <StyledChildren />;
16+
}
17+
18+
export default ExpandImage

0 commit comments

Comments
 (0)