Skip to content

Commit 0a9fa1b

Browse files
committed
Day 17 documentation
1 parent 2939e4c commit 0a9fa1b

File tree

2 files changed

+120
-132
lines changed

2 files changed

+120
-132
lines changed

docs/day17.md

+114-124
Original file line numberDiff line numberDiff line change
@@ -1,154 +1,144 @@
1-
# Day Sixteen: Ticket Translation
1+
# Day Seventeen: Conway Cubes
22

3-
* [Problem statement](https://adventofcode.com/2020/day/16)
4-
* [Solution code](https://github.com/abyala/advent-2020-clojure/blob/master/src/advent_2020_clojure/day16.clj)
3+
* [Problem statement](https://adventofcode.com/2020/day/17)
4+
* [Solution code](https://github.com/abyala/advent-2020-clojure/blob/master/src/advent_2020_clojure/day17.clj)
55

66
---
77

8-
Today's problem was a matter of parsing a complex incoming string, and then doing a bunch of set manipulation, maps,
9-
and filters. To be honest, I didn't find this one very entertaining, so I'm just going to quickly run through what
10-
each function does, show how the shape of the data changes from function to function, and call it a day.
8+
This problem reminds me of day 11, where we're given a set of points within a space, and we perform a
9+
generational calculation on it some number of times. As I've done a few times, I'm going to cheat a
10+
little bit by having seen what part 2 looks like, but honestly the level of effort to go from part 1
11+
to part 2 was very small this time, so I feel comfortable showing them both at once.
1112

12-
---
13-
14-
## Part 1
13+
We are given as input a 2-dimensional grid of points, where the ones marked with `#` signs are considered
14+
"active." For every point in infinite directions, we define that point to be active if it was active in
15+
the last generation and it has 2-3 active neighbors, or if it was inactive and has exactly 3 active
16+
neighbors. In part 1, an "active neighbor" looks at the 26 points up to one distance away in the
17+
3-dimensional `[x y z]` space. In part 2, an "active neighbor" looks at the 80 points up to one distance
18+
away in the 4-dimensional `[x y z w]` space. Everything else is the same, so we know how parts 1 and 2
19+
will differ!
1520

16-
The `parse-input` function parses the incoming String into my state object. I leverage the `utils/split-blank-line-seq`
17-
function from a few problems ago, which converts the giant String into three components -- one for the rules, the
18-
"your ticket" section, and the "nearby tickets" section. Then it's a little regex and a whole lot of Integer parsing.
19-
The output shape is a map, that definitely could be simplified if I were so inclined.
20-
21-
```clojure
22-
; Output shape
23-
{:fields [["name" [low1 high1] [low2 high2]]]
24-
:my-ticket [1 2 3 4]
25-
:nearby-tickets [[1 2 3] [4 5 6] [7 8 9]]}
26-
```
21+
## Parts 1 and 2
2722

28-
The `invalid-fields` function looks at all of the fields and combines them into a single list of `[low high]` pairs.
29-
Then the `flatten` function fully flattens the nearby tickets into a single list of integer values, which we filter
30-
down using `not-any?` to return only the values that aren't within any of the ranges. The output shape is a simple
31-
list of integers, representing the invalid fields.
23+
As always, let's think about the shape of the data we want, so we can decide how to parse the input. One
24+
option is to think of nested vectors, so a vector of vectors of vectors for part 1, and the very exciting
25+
vector of vectors of vectors of vectors for part 2. My brain doesn't like that sort of thinking, so I went
26+
another way. The neighbor calculation is going to look only at the number of active neighbors, so I'll
27+
parse the input into a set of `[x y z w]` points, and work with a simple set. Since the input data is
28+
defined to be two-dimensional, my `z` and `w` coordinates all start at zero.
3229

3330
```clojure
34-
(defn invalid-fields [{:keys [fields nearby-tickets]}]
35-
(let [every-range (->> (map rest fields) (apply concat))]
36-
(->> (flatten nearby-tickets)
37-
(filter (fn [fld] (not-any? (fn [[x y]] (<= x fld y)) every-range))))))
31+
(defn parse-input [input]
32+
(->> (str/split-lines input)
33+
(map-indexed (fn [y row] (map-indexed (fn [x c] [[x y 0 0] c])
34+
row)))
35+
(apply concat)
36+
(keep (fn [[coords v]] (when (= v \#) coords)))
37+
(into #{})))
3838
```
3939

40-
Then `part1` just adds together all invalid fields.
40+
_Quick note:_ I recognized something that I did not leverage in my solution. Because we started from
41+
active nodes all in a 2-dimensional space where `z=0`, it stands to reason that there is going to be
42+
symmetry. Whatever exists at `z=-1` should also exist at `z=1`. I suppose I could leverage this by
43+
only calculating positive `z` values and then doubling the results for the negative `z`s, but I didn't
44+
do that. The same is probably true for the `w` axis. Meh; the performance was fine without it.
45+
46+
The most important part of this problem is calculating all of the neighbors of a given point. Rather
47+
than use 3D points for part one and 4D points for part 2, after reading the part2 instructions, I
48+
treated all points as 4D. My `neighbors-of` function looks at all points whose `x`, `y`, and `z` values
49+
are offset by a value in `[-1 0 1]`; the `w` axis will also be off by one of those values if we look in
50+
4D, but will always have an offset of `0` if we're only in 3D. As a result, I made two helper `def`s
51+
to show how we think about the problem set.
4152

4253
```clojure
43-
(defn part1 [input]
44-
(->> input parse-input invalid-fields (apply +)))
54+
(defn neighbors-of [four-d? [x y z w]]
55+
(for [new-w (if four-d? [-1 0 1] [0])
56+
new-x [-1 0 1]
57+
new-y [-1 0 1]
58+
new-z [-1 0 1]
59+
:when (some #(not= 0 %) [new-x new-y new-z new-w])]
60+
[(+ x new-x) (+ y new-y) (+ z new-z) (+ w new-w)]))
61+
(def neighbors-3d (partial neighbors-of false))
62+
(def neighbors-4d (partial neighbors-of true))
4563
```
4664

47-
---
48-
49-
## Part 2
50-
51-
Part 2 was a little trickier to solve, and my answer is a bit wordy. But I wanted to focus a bit more on transforming
52-
the data from one state to another, rather than doing everything inline as a giant list. So the solution is a bunch
53-
of small transformations.
54-
55-
The overall strategy was to rip apart the complexity of the ticket structure. So I wanted to make a map of sets. To
56-
start, every ticket field was mapped to the set of all possible field indices. Then we go through every nearby ticket,
57-
and for every field type in which the ticket field doesn't fit, remove that index from the field type's set of
58-
indices. By removing every rule violation, we should be left with only the field types and the indexes for which all
59-
tickets validate. So with that...
60-
61-
`valid-tickets-only` takes in the full state, and returns only the nearby tickets that are valid, meaning that they
62-
don't have any of the fields described previously in the `invalid-fields` function. This returns all of the remaining
63-
tickets in their normal form, so `[[1 2 3] [4 5 6] [7 8 9]]`.
65+
Next, I defined `bounding-cube` to identify the range of `x`, `y`, `z`, and `w` coordinates that we
66+
need to look at, given the previous generation. Because a point only considers the number of active
67+
neighbors, we can find the min and max values in each dimension, and look at the points one below
68+
the min and one above the max. This function returns a four-element vector of tuple vectors, representing
69+
the min and max values we need to scan in the `x`, `y`, `z`, and `w` dimensions.
6470

6571
```clojure
66-
(defn valid-tickets-only [{:keys [nearby-tickets] :as parsed}]
67-
(let [bad-fields (-> parsed invalid-fields set)]
68-
(filterv (fn [t] (not-any? #(bad-fields %) t))
69-
nearby-tickets)))
70-
```
71-
72-
The next function, `ticket-pairs`, rips out every `[idx v]` tuple across all of the nearby tickets, since I found that
73-
easier to reason with than keeping the original vector of vectors. So this function takes in the ticket list, of shape
74-
`[[1 2 3] [4 5 6] [7 8 9]]` and returns a simple `[[0 1] [1 2] [2 3] [0 4] [1 5] [2 6] [0 7] [1 8] [2 9]]` vector.
72+
(defn bounding-cube [active-points]
73+
(mapv (fn [offset]
74+
(let [values (map #(get % offset) active-points)]
75+
[(dec (apply min values)) (inc (apply max values))]))
76+
[0 1 2 3]))
77+
```
78+
79+
With these ranges ready, let's identify all of the points in the bounding cube that we'll need to
80+
inspect on each generation. This is simply the Cartesian product of all points within the above
81+
ranges. Now the ranges out of `bounding-cube` are inclusive, but the `range` function is open on
82+
the start and closed on the end. Rather than make a top-level function, I use the seldom-used
83+
`letfn` function, to define a locally scoped function `inclusive-range`, which returns a range
84+
that's inclusive of both the start and end values.
7585

7686
```clojure
77-
(defn ticket-pairs [tickets]
78-
(mapcat (fn [ticket] (map-indexed (fn [idx v] (vector idx v)) ticket))
79-
tickets))
87+
(defn points-in-cube [[cube-x cube-y cube-z cube-w]]
88+
(letfn [(inclusive-range [[low high]] (range low (inc high)))]
89+
(for [x (inclusive-range cube-x)
90+
y (inclusive-range cube-y)
91+
z (inclusive-range cube-z)
92+
w (inclusive-range cube-w)]
93+
[x y z w])))
8094
```
8195

82-
The first beefy function is `possible-indexes`, with its coordinating function `all-possible-indexes`.
83-
`possible-indexes` takes in the list of all `ticket-pairs` and the total number of fields in the tickets, and then
84-
a single `field` rule definition of form `["name" [low1 high1] [low2 high2]]` from problem state. We run a reducing
85-
function, starting with a set of all possible indexes, and then we look at every ticket pair. If that pair's value
86-
doesn't comply with the rule, remove that pair's index from the set of possible indexes. There's a whole bunch of
87-
extra calculations going on, but this algorithm is still very fast. As a reminder to my future self -- we use `dissoc`
88-
to remove a mapping from a map, but `disj` to remove a value from a set. Why one function can't do both...
89-
90-
Then `all-possible-fields` just maps each field to the set of possible indexes, and throws it all into a map.
96+
Let's keep stepping back. We can return all neighbors of a point in 3 or 4 dimensions, and we
97+
can take a generation (set of active points) and return the points in the cube that we need to
98+
inspect in the next generation. We want to build a function called `next-point`, which calculates
99+
whether a point will be active or inactive in the next generation. For this, we need to calculate
100+
the number of active neighbors, where we find all neighbors of that point and count the number
101+
that are currently active. Then we use the logic of looking for 2 or 3 neighbors based on the
102+
current state.
91103

92104
```clojure
93-
(defn possible-indexes [ticket-pairs num-fields [name [low1 high1] [low2 high2]]]
94-
(->> (reduce (fn [acc [idx v]]
95-
(if (or (<= low1 v high1) (<= low2 v high2))
96-
acc
97-
(disj acc idx)))
98-
(set (range num-fields))
99-
ticket-pairs)
100-
(vector name)))
101-
102-
(defn all-possible-indexes [fields nearby-ticket-pairs]
103-
(->> fields
104-
(map #(possible-indexes nearby-ticket-pairs (count fields) %))
105-
(into {})))
105+
(defn num-active-neighbors [active-points neighbor-fn point]
106+
(->> (neighbor-fn point)
107+
(filter #(active-points %))
108+
count))
109+
110+
(defn next-point [active-points neighbor-fn point]
111+
(let [actives (num-active-neighbors active-points neighbor-fn point)]
112+
(if (active-points point)
113+
(contains? #{2 3} actives)
114+
(= 3 actives))))
106115
```
107-
Armed with the possible fields, our goal is to use `field-mappings` to actually decide which field belongs to which
108-
index. We use a simple `loop-recur`, starting with all fields being `unsolved`, and moving them one-by-one into the
109-
`solved` set. In each loop, we find one field that only has one index that is valid and hasn't been claimed yet by
110-
another field. Knowing the field name and its singular index, we use `remove-all-traces` to eliminate that index
111-
from all remaining fields' set of indexes, and loop again.
116+
117+
Almost done. `next-board` takes in a board and returns the next set of active points, and this
118+
is now just a coordinator of other functions. Starting from the current board, we identify the
119+
bounding cube, translate that into all of the points within the cube, keep only the points that
120+
are active in the next generation, and wrap those points up into a new set.
112121

113122
```clojure
114-
(defn remove-all-traces [possible-indexes index]
115-
(loop [fields possible-indexes, keys (keys possible-indexes)]
116-
(if-let [k (first keys)]
117-
(recur (update fields k #(disj % index))
118-
(rest keys))
119-
fields)))
120-
121-
(defn field-mappings [fields nearby-ticket-pairs]
122-
(loop [unsolved (all-possible-indexes fields nearby-ticket-pairs)
123-
solved {}]
124-
(if (empty? unsolved)
125-
solved
126-
(let [[name actual-index] (->> unsolved
127-
(keep (fn [[name indexes]]
128-
(when (= 1 (count indexes))
129-
[name (first indexes)])))
130-
first)]
131-
(recur (-> unsolved
132-
(dissoc name)
133-
(remove-all-traces actual-index))
134-
(assoc solved name actual-index))))))
123+
(defn next-board [active-points neighbor-fn]
124+
(->> active-points
125+
bounding-cube
126+
points-in-cube
127+
(keep #(when (next-point active-points neighbor-fn %) %))
128+
set))
135129
```
136130

137-
Finally, `part2` puts the pieces together. Starting with the input, we parse it, identify the valid tickets, flatten
138-
them into ticket pairs, and identify the mappings. With the correct mappings in-place, we use a `keep-when` to pick
139-
the fields whose name starts with `departure`, map each to the value that that index in `my-ticket`, and multiple the
140-
values together.
131+
Finally, we have our `solve` function and the tiny `part1` and `part2` functions. In `solve`,
132+
we set up a sequence of all generations of boards, using `(iterate next-board)`. Then we grab
133+
the 6th generation and count up the number of active points.
141134

142135
```clojure
143-
(defn part2 [input]
144-
(let [{:keys [fields my-ticket] :as parsed} (parse-input input)
145-
valid-tickets (valid-tickets-only parsed)
146-
nearby-ticket-pairs (ticket-pairs valid-tickets)
147-
mappings (field-mappings fields nearby-ticket-pairs)]
148-
(->> mappings
149-
(keep (fn [[name idx]]
150-
(when (str/starts-with? name "departure") (get my-ticket idx))))
151-
(apply *))))
152-
```
153-
154-
All in all, there's a bunch of code here, but it's all reasonably straightforward... now that it's done!
136+
(defn solve [input neighbor-fn]
137+
(let [active-seq (->> input
138+
parse-input
139+
(iterate #(next-board % neighbor-fn)))]
140+
(count (nth active-seq 6))))
141+
142+
(defn part1 [input] (solve input neighbors-3d))
143+
(defn part2 [input] (solve input neighbors-4d))
144+
```

src/advent_2020_clojure/day17.clj

+6-8
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,13 @@
2525
[(dec (apply min values)) (inc (apply max values))]))
2626
[0 1 2 3]))
2727

28-
(defn inclusive-range [[low high]]
29-
(range low (inc high)))
30-
3128
(defn points-in-cube [[cube-x cube-y cube-z cube-w]]
32-
(for [x (inclusive-range cube-x)
33-
y (inclusive-range cube-y)
34-
z (inclusive-range cube-z)
35-
w (inclusive-range cube-w)]
36-
[x y z w]))
29+
(letfn [(inclusive-range [[low high]] (range low (inc high)))]
30+
(for [x (inclusive-range cube-x)
31+
y (inclusive-range cube-y)
32+
z (inclusive-range cube-z)
33+
w (inclusive-range cube-w)]
34+
[x y z w])))
3735

3836
(defn num-active-neighbors [active-points neighbor-fn point]
3937
(->> (neighbor-fn point)

0 commit comments

Comments
 (0)