|
1 |
| -# Day Sixteen: Ticket Translation |
| 1 | +# Day Seventeen: Conway Cubes |
2 | 2 |
|
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) |
5 | 5 |
|
6 | 6 | ---
|
7 | 7 |
|
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. |
11 | 12 |
|
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! |
15 | 20 |
|
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 |
27 | 22 |
|
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. |
32 | 29 |
|
33 | 30 | ```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 #{}))) |
38 | 38 | ```
|
39 | 39 |
|
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. |
41 | 52 |
|
42 | 53 | ```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)) |
45 | 63 | ```
|
46 | 64 |
|
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. |
64 | 70 |
|
65 | 71 | ```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. |
75 | 85 |
|
76 | 86 | ```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]))) |
80 | 94 | ```
|
81 | 95 |
|
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. |
91 | 103 |
|
92 | 104 | ```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)))) |
106 | 115 | ```
|
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. |
112 | 121 |
|
113 | 122 | ```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)) |
135 | 129 | ```
|
136 | 130 |
|
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. |
141 | 134 |
|
142 | 135 | ```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 | +``` |
0 commit comments