Skip to content

Commit 3c2ff85

Browse files
committed
Day 22
1 parent 54528df commit 3c2ff85

File tree

8 files changed

+831
-0
lines changed

8 files changed

+831
-0
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@
2222
| 18 | [source](src/advent_2021_clojure/day18.clj) | [blog](docs/day18.md) |
2323
| 20 | [source](src/advent_2021_clojure/day20.clj) | [blog](docs/day20.md) |
2424
| 21 | [source](src/advent_2021_clojure/day21.clj) | [blog](docs/day21.md) |
25+
| 22 | [source](src/advent_2021_clojure/day22.clj) | [blog](docs/day22.md) |

docs/day22.md

+231
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
# Day Twenty-Two: Reactor Reboot
2+
3+
* [Problem statement](https://adventofcode.com/2021/day/22)
4+
* [Solution code](https://github.com/abyala/advent-2021-clojure/blob/master/src/advent_2021_clojure/day22.clj)
5+
6+
---
7+
8+
## Preamble
9+
10+
I loved today's puzzle again! Part 1 was really simple until I realized that the algorithm wouldn't scale into the
11+
large dataset that was coming up in Part 2, so I had to do large rewrite. That said, I found this to be a very
12+
enjoyable challenge.
13+
14+
We are given a list of reboot instructions, providing a range of cuboids with instructions on whether to set every
15+
enclosed cube on or off. After running through all the instructions in order, we need to count the number of cubes
16+
that are switched on. My original solution was to identify every cube within each line of instructions, converting
17+
them into maps of the cube to `:on` or `:off`, merge the maps together, and count the number of `:on` values. But
18+
that wasn't the final approach.
19+
20+
My solution looked at the puzzle a little differently from the instructions. In the instructions, we start with two
21+
"on" lines; each line independently turned on 27 cubes, but since line 2 overlapped with line 1, it only added 19 cubes
22+
that weren't already on from line 1. I approached this from the other perspective - the cuboid from line 1 contains
23+
points that are all one, but the cuboid from line 1 breaks cuboid 1 into smaller cuboids that don't overlap with
24+
cuboid 2. After breaking apart all overlapping previous cuboids, the new one either joins the set of "on" cuboids, or
25+
it just quietly disappears because the new cuboid is off.
26+
27+
Take a 2-dimensional example. We start with a square of `x=1..5,y=1..5` and we overlay it with a new square of
28+
`x=4..6,y=4..6`. Instead of breaking apart the second square, I break the first square into two smaller rectangles
29+
which don't overlap with each other or the new rectangle. Then if the new rectangle is "on," I end up with three
30+
rectangles in total; otherwise I end up with just two.
31+
32+
```
33+
Adding an "on" square:
34+
..... 11122 111 22 ###
35+
..... 11122 111 22 ###
36+
..... plus overlay yields 11122 which is 111 22 ###
37+
..... ### 111### 111
38+
..... ### 111### 111
39+
### ###
40+
41+
Adding an "off" square:
42+
..... 11122 111 22
43+
..... 11122 111 22
44+
..... plus overlay yields 11122 which is 111 22
45+
..... ### 111 111
46+
..... ### 111 111
47+
###
48+
```
49+
50+
This approach lets me deal only with squares, or rather cuboids when we add the third dimension back in, without
51+
having to deal with any irregular shapes. Plus the overlay logic doesn't care if the new square/cuboid is on or off
52+
until after we've dealt with the overlays.
53+
54+
---
55+
56+
## Part 1
57+
58+
Alright, let's get down to business. Because we'l be looking at overlapping dimensions, the easiest way to represent
59+
a cuboid would be a map of `{:x [low high], :y [low high], :z [low high]}`, and each input instruction will be of the
60+
form `[op cuboid]`. So to parse each line of the input, we'll split the first word from the rest of the line as a
61+
keyword, identifying each dimension on the right using a neat regular expression I saw online a few days ago:
62+
`#"\-?\d+"`. This captures both the optional negative sign and also any number of numeric digits, and `read-string` can
63+
properly map those values to Longs.
64+
65+
Note that as a matter of convention in my solution, I'll usually use `x0` and `x1` to represent `x-low` and `x-high`.
66+
If I have two cuboids where I'm examining both of their min and max values, I'll use `x0a` and `x1a` for the first
67+
cuboid, and `x0b` and `x1b` for the second.
68+
69+
```clojure
70+
(defn parse-instruction [line]
71+
(let [[instruction dim-str] (str/split line #" ")
72+
[x0 x1 y0 y1 z0 z1] (map read-string (re-seq #"\-?\d+" dim-str))]
73+
[(keyword instruction) {:x [x0 x1], :y [y0 y1], :z [z0 z1]}]))
74+
```
75+
76+
We know that we'll need to discard any input instruction whose cuboid lies outside the initialization area, which is
77+
defined to the cuboids between -50 and 50. `within-initialization-area?` looks at the value range for each dimension
78+
of a cuboid, and calls `every?` to check if the values are within the required area.
79+
80+
```clojure
81+
(def dimensions [:x :y :z])
82+
83+
(defn within-initialization-area? [cuboid]
84+
(letfn [(dim-ok? [dim] (let [[v0 v1] (dim cuboid)]
85+
(and (>= v0 -50) (<= v1 50))))]
86+
(every? dim-ok? dimensions)))
87+
```
88+
89+
Next we implement another helper function called `overlap?`, which compares two cuboids either for a single dimension
90+
or for all of them. Cuboids overlap in a dimension if each min value is not greater than the other's max value. Cuboids
91+
overlap if all of their dimensions overlap.
92+
93+
```clojure
94+
(defn overlap?
95+
([cuboid1 cuboid2] (every? (partial overlap? cuboid1 cuboid2) dimensions))
96+
([cuboid1 cuboid2 dim] (let [[v0a v1a] (dim cuboid1)
97+
[v0b v1b] (dim cuboid2)]
98+
(and (<= v0a v1b) (<= v0b v1a)))))
99+
```
100+
101+
Now we get to the first of two meaty functions - `split-on-dimension`. This takes in two cuboids and a dimension, and
102+
breaks the first cuboid apart into overlapping and non-overlapping cuboids. In my mind, non-overlapping cuboids are
103+
"safe," because they can't be affected by the invading cuboid, while overlapping ones are "unsafe," so the function
104+
returns a map of `{:safe cuboids, :unsafe cuboids}`. First off, if the two cuboids don't overlap in the dimension,
105+
the first cuboid is safe, so return a map of `:safe` to the vector of the cuboid itself. If they do overlap, we
106+
calculate `overlap0` and `overlap1` as the min and max values that overlap, recognizing that we should get the same
107+
values if we were to flip the order of the cuboids; the `:unsafe` region will be the original cuboid with this region
108+
set at the correct dimension. Then we look on both sides of this unsafe region to see if they're still part of the
109+
original cuboid, and only add them in if they are.
110+
111+
For example, imagine we are looking in two dimensions again, and we call
112+
`(split-on-dimension {:x [1 5], :y [10 20]} {:x [3 5], :y [1 100]} :x)`. Looking at just the `x` values, we can see
113+
that cuboid1 has values 1 through 5 while cuboid2 has values 3 through 5. The overlap is therefore `[3 5]`, so we'll
114+
want to return `{:unsafe [{:x [3 5], :y [10 20]}]}`. But what about the area to the left and right of it? The left
115+
value would be `{:x [1 2], :y [10 20]}` which is still within the original cuboid, so that's fine. The right value
116+
would be `{:x [6 5], :y [10 20]}` since the overlap region includes `x=6`, and this range doesn't make any sense since
117+
the min `x` value is greater than the max, so we discard it.
118+
119+
```clojure
120+
(defn split-on-dimension [cuboid1 cuboid2 dim]
121+
(if-not (overlap? cuboid1 cuboid2 dim)
122+
{:safe [cuboid1]}
123+
(let [[v0a v1a] (dim cuboid1)
124+
[v0b v1b] (dim cuboid2)
125+
overlap0 (max v0a v0b)
126+
overlap1 (min v1a v1b)
127+
safe-regions (filter (partial apply <=) [[v0a (dec overlap0)] [(inc overlap1) v1a]])
128+
overlap-region [overlap0 overlap1]]
129+
{:safe (map #(assoc cuboid1 dim %) safe-regions)
130+
:unsafe [(assoc cuboid1 dim overlap-region)]})))
131+
```
132+
133+
Hopefully that previous function made sense, because we're about to leverage it for `remove-overlaps`. Here, we again
134+
check if the two cuboids overlap at all; if not, we just return the vector of the first cuboid, since there's no reason
135+
to break it apart. (Note that with this check, we don't actually have to check `overlap?` in `split-on-dimension`, but
136+
it's a cheap check to make sure the data is always valid.) If the cuboids do overlap, then we're going to use a `reduce`
137+
over the three dimensions, looking at the set of safe and unsafe cuboids. Initially, the first cuboid is considered
138+
unsafe since we haven't examined it over overlaps. For each dimension, we compare each of the unsafe regions to the
139+
new cuboid, merging together all of their safe and unsafe cuboids for that dimension. If we identified any new safe
140+
cuboids, we'll add them to collection of previously reviewed ones. However, we _replace_ the previous unsafe regions
141+
with the new ones, since a previously unsafe region may have been split apart by `split-on-dimension`. When all is
142+
said and done, `remove-overlaps` just returns the sequence of safe regions, as region still unsafe must fully overlap
143+
with cuboid2.
144+
145+
```clojure
146+
(defn remove-overlaps [cuboid1 cuboid2]
147+
(if-not (overlap? cuboid1 cuboid2)
148+
[cuboid1]
149+
(first (reduce (fn [[acc-safe acc-unsafe] dim]
150+
(let [{:keys [safe unsafe]}
151+
(->> acc-unsafe
152+
(map #(split-on-dimension % cuboid2 dim))
153+
(apply merge-with conj))]
154+
[(apply conj acc-safe safe) unsafe]))
155+
[() [cuboid1]]
156+
dimensions))))
157+
```
158+
159+
The worst is behind us now. We'll make three small functions that should build up into running all instructions from
160+
the input data set. First, `remove-all-overlaps` takes in a sequence of cuboids and a new cuboid, and uses `reduce` to
161+
join together all safe regions after removing their overlaps from the new cuboid. Then `apply-instruction` takes in the
162+
cuboids and an instruction, which again is an operation and a cuboid. After stripping away all overlaps, this function
163+
either returns the remaining safe regions for an `:off` instruction, or adds the new cuboid to the safe regions for an
164+
`:on` instruction. Finally, `apply-instructions` just calls `apply-instruction` on all instructions, starting with an
165+
empty collection of safe cuboids, since initially every cube is off.
166+
167+
```clojure
168+
(defn remove-all-overlaps [cuboids new-cuboid]
169+
(reduce (fn [acc c] (apply conj acc (remove-overlaps c new-cuboid)))
170+
()
171+
cuboids))
172+
173+
(defn apply-instruction [cuboids [op new-cuboid]]
174+
(let [remaining (remove-all-overlaps cuboids new-cuboid)]
175+
(if (= op :on)
176+
(conj remaining new-cuboid)
177+
remaining)))
178+
179+
(defn apply-instructions [instructions]
180+
(reduce apply-instruction () instructions))
181+
```
182+
183+
Almost done! Once we've run through all instructions, we need to know how many cubes are on. For this, the
184+
`cuboid-size` function looks at each dimension within a cuboid, multiplying their lengths together. Remember that all
185+
dimensions in this problem are inclusive on both ends, so the length of `[4 6]` is 3, not 2.
186+
187+
```clojure
188+
(defn cuboid-size [cuboid]
189+
(->> (map (fn [dim] (let [[v0 v1] (cuboid dim)]
190+
(inc (- v1 v0)))) dimensions)
191+
(apply *)))
192+
```
193+
194+
Alright, let's finish up with the `part1` function already! We'll read each line of the input, and map it to its
195+
parsed instruction. We then need to filter out the ones whose cuboids are within the initialization area. Then with
196+
only valid instructions, we'll apply them all together, map the size of each resulting cuboid, and add them together.
197+
Done!
198+
199+
```clojure
200+
(defn part1 [input]
201+
(->> (str/split-lines input)
202+
(map parse-instruction)
203+
(filter #(within-initialization-area? (second %)))
204+
apply-instructions
205+
(map cuboid-size)
206+
(apply +)))
207+
```
208+
209+
---
210+
211+
## Part 2
212+
213+
Ok, so part 2 is the same as part 1, except that we can use all cuboids, not just the ones in the initialization area.
214+
So we'll just pull out most of the logic from `part1` into a `solve` function, which gets invoked with the input data
215+
and a filter function to apply to the initial cuboids. For part 1, we'll pass in the `within-initialization-area?`
216+
function again, while for part 2 we can ust `identity` to keep all of the input.
217+
218+
```clojure
219+
(defn solve [instruction-filter input]
220+
(->> (str/split-lines input)
221+
(map parse-instruction)
222+
(filter #(instruction-filter (second %)))
223+
apply-instructions
224+
(map cuboid-size)
225+
(apply +)))
226+
227+
(defn part1 [input] (solve within-initialization-area? input))
228+
(defn part2 [input] (solve identity input))
229+
```
230+
231+
That's it! Pretty fun little puzzle today.

0 commit comments

Comments
 (0)