Skip to content

Latest commit

 

History

History
153 lines (127 loc) · 8 KB

day20.md

File metadata and controls

153 lines (127 loc) · 8 KB

Day Twenty: Trench Map


Part 1

Today we're simulating a Game Of Life as represented by an image whose pixels are either light or dark with each generation. Our goal is to run the game a certain number of times against an image atop an infinite background, and then count up the number of lit pixels in the end.

To start off, let's parse the input, which comes in as a single line of the 512-character "algorithm," followed by the initial image. We'll just return that as a two-element vector, leveraging utils/split-blank-line to split the input into its two segments. For the algorithm, we'll make a pixel-map that converts . to zero and # to 1; calling mapv will ensure the result is a an indexed vector instead of a linked list. For the image, we'll want that to be a map of {[x y] 0-or-1}. We'll leverage our existing point/parse-to-char-coords to return a sequence of each coordinate pair to its character, and then associate the coords onto the resulting map by again mapping the character using the pixel-map. Naturally we'll use constants of dark-pixel and light-pixel to avoid repeating 0 and 1 throughout the solution.

(def dark-pixel 0)
(def light-pixel 1)

(defn parse-input [input]
  (let [pixel-map {\. dark-pixel, \# light-pixel}
        [alg image] (utils/split-blank-line input)]
    [(mapv pixel-map alg)
     (reduce (fn [m [coord c]] (assoc m coord (pixel-map c)))
             {}
             (point/parse-to-char-coords image))]))

Our goal will include going to every point in the current image, looking at its surrounding 3x3 grid, and construct a new image from the converted characters. But what do we do along the border? Well initially, every surrounding point will be dark (aka 0), but the algorithm tells us whether that will be true for every generation. To determine this, we'll make an infinite border-seq, which takes in the algorithm and returns what every infinite border value will be in each generation. Imagining an initial point far away from our image; all nine points in its grid will be dark, meaning that its binary value will be 000000000 or just plain 0. Some algorithms will map a 0 to 0 again, keeping the border dark, but some might map it to 1 to make it light. Then similarly, a distant point of all light values will have a binary value of 111111111 or 511, which could again map each character to dark or light. So block-of will map 0 to 0, and 1 to 511, and we'll iterate on mapping each previous value to its block value, and then use alg to convert it to its new value at that index.

For what it's worth, in the sample problem, (get alg 0) is zero, so the border is always dark. In my input, (map alg [0 511]) was [1 0], so the background flickered every generation from dark to light and back again.

(defn border-seq [alg]
  (let [block-of {dark-pixel 0, light-pixel 511}]
    (iterate (comp alg block-of) dark-pixel)))

Now we can create a migrate-image function, which takes in the algorithm, the value of all border coordinates, and the current image, and returns the next image by migrating every point. To do this, we'll use a simple reduce-kv to migrate each coordinate in the image, calling next-value-at for each coordinate. The next-value-at looks up all points surrounding the current one, maps it to either its value on the image or else the default value of the infinite border character. Then it concatenates all 9 characters into a binary string, which it converts into a number and finds within the algorithm vector.

(defn next-value-at [alg border image coords]
  (->> (point/surrounding true coords)
       (map #(or (image %) border))
       (apply str)
       utils/parse-binary
       alg))

(defn migrate-image [alg border image]
  (reduce-kv (fn [m coord _] (assoc m coord (next-value-at alg border image coord)))
             {} image))

We want to get to a function that returns every generation of the image, but there's only one issue to address first. While migrate-image and next-value-at takes into account the infinite border, with each generation we almost certainly will have at least one point along the perimeter that interacted with the infinite border, meaning that the points just outside the previous border of the image will need to be calculated in the next generation. To account for this, with each generation we'll need to expand the image by one row at the top and bottom, and by one column on the left and right. (In theory, we could just expand the entire image once to the maximum intended size, but that pollutes what each function does.)

The expand-image function looks at the current min and max values for both x and y, and associates the border character to all coordinates of the surrounding perimeter. We implement perimeter-points in the point namespace by taking all [x y] coordinates from a top-left point to the bottom-right point. Then expand-image uses reduce to assoc the border onto each perimeter point, starting from the image map itself.

; advent-2021-clojure.point namespace
(defn perimeter-points [[x0 y0] [x1 y1]]
  (concat (for [x [x0 x1], y (range y0 (inc y1))] [x y])
          (for [y [y0 y1], x (range (inc x0) x1)] [x y])))

; advent-2021-clojure.day20 namespace
(defn expand-image [image border]
  (let [min-max (juxt (partial apply min) (partial apply max))
        [min-x max-x] (min-max (map ffirst image))
        [min-y max-y] (min-max (map (comp second first) image))]
    (reduce #(assoc %1 %2 border)
            image
            (point/perimeter-points [(dec min-x) (dec min-y)]
                                    [(inc max-x) (inc max-y)]))))

So where are we now? We know the sequence of values of the infinite border, and we can use that sequence to both expand the current image one step into its perimeter, and then to migrate every point of the image based on its neighbors. The only major step remaining is to create image-seq, to generate an infinite sequence of every generation the image goes through as it migrates. This function takes in the algorithm and initial image, and then creates the border-seq in the background. For each generation, is expands and migrates the image on the current head of the border sequence, and then uses lazy-seq (similar to iterate) to create the next element of the sequence. We don't use iterate here because we can't use the same value for the border with each generation, but rather need to call (rest borders) each time.

(defn image-seq
  ([alg image] (image-seq alg image (border-seq alg)))
  ([alg image borders] (let [border (first borders)
                             image' (->> (expand-image image border)
                                         (migrate-image alg border))]
                         (lazy-seq (cons image' (image-seq alg image' (rest borders)))))))

Alright, let's create our solve function. We'll take in the input string and the number of enhancements we want to apply. The function parses the input, converts it into the sequence of images, and finds the nth value by dropping (dec enhance-count) values from the sequence and pulling the next value. From that image, it counts the number of values in the [[x y] pixel] pair by counting the number of pixels that are light-pixel, or 1.

(defn solve [input enhance-count]
  (->> (parse-input input)
       (apply image-seq)
       (drop (dec enhance-count))
       first
       (filter #(= light-pixel (second %)))
       count))

Finally, part1 just calls solve, looking for the number of pixels after the second enhancement.

(defn part1 [input] (solve input 2))

Part 2

Part 2 runs the same algorithm for fifty enhancements. The code we already have is fine; we'll get the answer in about 10-15 seconds, which is fast enough for me.

(defn part2 [input] (solve input 50))