Skip to content

Instantly share code, notes, and snippets.

@mcsf
Last active January 24, 2021 19:20
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mcsf/373515d65af58f7ceb9cf866b4d05d62 to your computer and use it in GitHub Desktop.
Save mcsf/373515d65af58f7ceb9cf866b4d05d62 to your computer and use it in GitHub Desktop.
A solution to challenge #20 of Advent of Code's 2020 edition
#lang racket
; SOLUTION TO CHALLENGE #20 OF ADVENT OF CODE'S 2020 EDITION
;
; https://adventofcode.com/2020/day/20
(define (main (filename "sample"))
(define tiles (read-tiles filename))
(define part-1 (apply * (corner-tiles tiles)))
(define part-2 (sea-roughness (tiles->string tiles)))
(list part-1 part-2))
;
; PARSING
;
; Parse the puzzle input into a dict of <tile id> -> <tile rows>.
(define (read-tiles (filename "sample"))
(define (parse-tile str)
(string->number (car (regexp-match #px"\\d+" str))))
(call-with-input-file
filename
(λ (in)
(for/hash ((tile-string (string-split (port->string in) "\n\n")))
(let* ((tile-block (string-split tile-string "\n")))
(values (parse-tile (first tile-block))
(map string->list (rest tile-block))))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; PART ONE ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;
; HANDLING TILES REGARDLESS OF ORIENTATION
;
; Since each tile may be rotated or flipped in any direction, the first idea
; is to represent each tile border by a single number, so borders can be
; compared across tiles. This number is simply a binary reading of the
; sequence of # and . characters. Because each row can be read from any
; direction, the number is defined as the least possible number.
(define (border->number border)
(define (char->digit char) (if (char=? #\. char) 0 1))
(define (chars->number chars)
(for/fold ((acc 0)) ((c chars))
(+ (* 2 acc) (char->digit c))))
(min (chars->number border)
(chars->number (reverse border))))
; Now we can map each tile to a set of such border numbers...
(define tile-sides
`((top . ,(λ (tile) (first tile)))
(right . ,(λ (tile) (map last tile)))
(bottom . ,(λ (tile) (last tile)))
(left . ,(λ (tile) (map first tile)))))
(define (tile->numbers tile)
(map border->number
(map (λ (accessor) (accessor tile))
(dict-values tile-sides))))
; ... into a single dict:
;
; '(
; [1234 . (434 778 ...)]
; ...
; )
(define (map-borders tiles)
(for/hash (((k v) (in-hash tiles)))
(values k (tile->numbers v))))
;
; FINDING TILES THAT FIT TOGETHER
;
; By flipping the map of borders, we can group tiles according to the borders
; that they share.
;
; '(
; (1234 2345 3456)
; (2345 9876 8765)
; ...
; )
(define (connection-sets tiles)
(remove-duplicates
(map remove-duplicates
(dict-values
(dict-condense
(dict-reverse
(map-borders tiles)))))))
; Lastly, we map each tile to tiles that it can connect with.
;
; By itself, this shouldn't mean that we have found all adjacent tiles. But,
; thankfully, it seems that in this challenge no single border is present in
; more than two different tiles. This means that the rest of the solution will
; be much more linear and won't require cycles of testing and backtracking.
;
; '(
; [1234 . (2345 3456)]
; [2345 . (1234 9876 8765)]
; ...
; )
(define (map-connections tiles)
(define sets (connection-sets tiles))
(for*/fold ((result (make-immutable-hash)))
((s sets)
(t s))
(let ((connections (remove t s)))
(hash-update result t (curry append connections) '()))))
;
; FINDING THE CORNERS OF THE JIGSAW
;
; Thanks to the above observation, corner tiles are easily spotted by the fact
; that they only share borders with two other tiles. So we filter the
; connections map to solve part one of the challenge.
;
; '(1234 2345 3456 4567)
(define (corner-tiles tiles)
(for/list (((t connections) (in-hash (map-connections tiles)))
#:when (= 2 (length connections)))
t))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; PART TWO ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;
; PUTTING THE JIGSAW TOGETHER
;
; Looking at the connections map, another useful observation emerges: that the
; outer tiles of the jigsaw are those with no more than three tiles in their
; respective connection sets. Corners are just a special case of outer tile
;
; Drawing on paper, the quickest path to solve the jigsaw is by drawing its
; perimeter first. Starting by one of its corners, t0, we then move to its
; first neighbor t1. Then we take the neighbor's remaining connecting tiles
; and, crucially, we pick the one that is itself an outer tile. And so on
; until we have closed the perimeter.
;
; 0 1 2 3
; 11 4
; 10 5
; 9 8 7 6
;
; This will be implemented in `solve-perimeter`.
;
; Once we have the perimeter, we can start back at t1 and notice that it only
; has one remaining connection: t12. We proceed to t2, whose last connection
; gives us t13. We keep following the spiral of tiles until all have been
; allocated. In the case of a 4x4 jigsaw, the last allocation happens at t8:
;
; 0 1 2 3
; 11 12 13 4
; 10 15 14 5
; 9 8 7 6
;
; This will be implemented in `solve-spiral`.
;
; You'll notice that we didn't bother with Cartesian coordinates: our jigsaw
; solution can be described as a single list: '(t0 t1 ... t15). We can map the
; spiral to (x, y) positions separately.
; Starting from a corner, list the tiles that make up the perimeter.
(define (solve-perimeter tiles)
; The initial tile choice is arbitrary as long as it's a corner tile, so
; choose `second` to match the AoC problem example.
(define initial-tile (second (corner-tiles tiles)))
(define connections (map-connections tiles))
(let loop ((path '()) (id initial-tile))
(define next
(for/first ((t (dict-ref connections id))
#:when (and (not (member t path))
((length (dict-ref connections t)) . < . 4)))
t))
(if next
(loop (cons id path) next)
(reverse (cons id path)))))
(define (solve-spiral tiles)
(define all-connections (map-connections tiles))
(define initial-spiral (solve-perimeter tiles))
(for/fold ((spiral initial-spiral)) ((i (dict-count tiles)))
(define ref-tile (list-ref spiral i))
(define next-tile (for/first ((t (dict-ref all-connections ref-tile))
#:when (not (member t spiral)))
t))
(if next-tile `(,@spiral ,next-tile) spiral)))
; Converting spiral indices to Cartesian coordinates can be done recursively.
; For a 4x4 jigsaw, position 2 corresponds to '(2 0), and 15 to '(1 2).
(define (spiral->cartesian size position)
(if (< size 2)
'(0 0)
(let* ((base (sub1 size))
(face (quotient position base)))
(match face
(0 (list position 0))
(1 (list base (remainder position base)))
(2 (list (- base (remainder position base)) base))
(3 (list 0 (- base (remainder position base))))
(else
(map add1 (spiral->cartesian (- size 2)
(- position (* 4 base)))))))))
; Finally, we can zip everything together to map (x, y) coordinates to tiles.
(define (place-tiles tiles)
(define size (sqrt (dict-count tiles)))
(sort-by-position
(for/hash (((t i) (in-indexed (solve-spiral tiles))))
(values (spiral->cartesian size i) t))))
(define (sort-by-position dict)
(sort (dict->list dict) pos<? #:key car))
(define (pos<? a b)
(if (= (second a) (second b))
(< (first a) (first b))
(< (second a) (second b))))
;
; ORIENTING ALL TILES CORRECTLY
;
; By determining the positions of all the tiles of the jigsaw, we have
; greatly reduced the search space for orienting each tile so as to connect
; with its neighbors.
;
; The next step is to reveal the orientation constraints of each tile by
; combining the positioning information with the borders map (`map-borders`).
; Let's consider t14 and its neighbors:
;
; Positions Borders Constraints for t14
;
; 13 t5 : a, b, c, d top : one of (i j k l)
; 15 14 5 t7 : e, f, g, h right : one of (a b c d)
; 7 t13: i, j, k, l bottom: one of (e f g h)
; t15: m, n, o, p left : one of (m n o p)
(define adjacent-positions
`((left . ,(λ (i j) `(,i ,(sub1 j))))
(right . ,(λ (i j) `(,i ,(add1 j))))
(top . ,(λ (i j) `(,(sub1 i) ,j)))
(bottom . ,(λ (i j) `(,(add1 i) ,j)))))
; Returns a dict of constraints for a given tile as follows:
;
; '((left 18) (right) (top 399) (bottom))
;
; Empty lists indicate the absence of a neighboring tile.
(define (tile-constraints positions borders-map tile-id)
(define tile-pos (for/first (((pos tid) (in-dict positions))
#:when (= tid tile-id)) pos))
(define tile-borders (dict-ref borders-map tile-id))
(for/list (((side accessor) (in-dict adjacent-positions)))
(define neighbor-id (dict-ref positions (apply accessor tile-pos) #f))
(define neighbor-borders (dict-ref borders-map neighbor-id '()))
`(,side . ,(intersect tile-borders neighbor-borders))))
; With each tile's constraints describable as a dict, testing whether a tile
; is correctly oriented amounts to verifying that its four sides satisfy their
; neighborhood constraints.
(define (tile-satisfies? tile constraints)
(for/and (((side numbers) (in-dict constraints))
#:unless (empty? numbers))
(let ((accessor (dict-ref tile-sides side)))
(member (border->number (accessor tile)) numbers))))
; The last step is to try all possible orientations of a tile until all
; constraints are satisfied. By considering each tile on its own, our search
; space is very small.
;
; First, we define our rotating and flipping functions.
(define (rotate90 tile)
(define size (length tile))
(for/list ((i (in-range size)))
(for/list ((j (in-range (sub1 size) -1 -1)))
(list-ref (list-ref tile j) i))))
(define rotate180 (compose rotate90 rotate90))
(define rotate270 (compose rotate180 rotate90))
(define (fliph tile) (map reverse tile))
(define (flipv tile) (reverse tile))
; Now back to our problem space. It consists of all combinations of:
;
; Rotate Flip Flip
; horizontal vertically
; ┌ ┐
; │ 0° │ ┌ ┐ ┌ ┐
; │ 90° │ │ yes │ │ yes │
; │ 180° │ │ no │ │ no │
; │ 270° │ └ ┘ └ ┘
; └ ┘
;
; In other words, the Cartesian product: R × H × V.
;
; However, this set of composed transformations contains duplicates. For
; instance, rotating 90° then flipping horizontally is equivalent to rotating
; 270° then flipping vertically. Avoiding duplicate transformations is
; important, not for performance, but — down the line — to make sure we don't
; spot repeated sea monsters. So, empirically, we simplify our Cartesian
; product by excluding vertical flips:
(define orientation-search-space
(cartesian-product
(list identity rotate90 rotate180 rotate270)
(list identity fliph)))
; Let's turn these transformations into actual functions that take a tile and
; transform it:
(define orientation-attempts
(map (curry apply compose) orientation-search-space))
; Return a tile's rows in the correct orientation:
(define (tile-orient constraints tile-rows)
(for/first ((o orientation-attempts)
#:when (tile-satisfies? (o tile-rows) constraints))
(o tile-rows)))
;
; PRINTING OUT THE SOLVED JIGSAW
;
; Returns a string of lines describing the solved jigsaw, after removal of the
; gaps in between tiles and the borders of the tiles.
(define (tiles->string tiles)
(define positions-matrix (place-tiles tiles))
(define borders-map (map-borders tiles))
(tiles-stitch
positions-matrix
(for/hash (((pos id) (in-dict positions-matrix)))
(define constraints (tile-constraints positions-matrix borders-map id))
(define rows (dict-ref tiles id))
(values id (remove-borders (tile-orient constraints rows))))))
(define (tiles-stitch positions tiles)
(define grid-size (sqrt (dict-count positions)))
(define tile-size (length (first (dict-values tiles))))
(define rows
(for*/list ((i grid-size) (j tile-size))
(for/list ((k grid-size))
(let* ((tile (dict-ref tiles (dict-ref positions (list i k))))
(row (list-ref tile j)))
(list->string row)))))
(string-join
(map (λ (row) (string-join row "")) rows)
"\n"))
(define (remove-borders tile)
(define (trim lst)
(drop (drop-right lst 1) 1))
(map trim (trim tile)))
;
; FINDING SEA MONSTERS
;
; Sea monsters are more elusive than one might think.
(define sea-monster
'(" # "
"# ## ## ###"
" # # # # # # "))
; The two main challenges are:
;
; 1. We are not doing a single string search, we are doing a set of searches
; on contiguous lines. Each of these searches may find sub-occurrences — that
; is, a row — of the sea monster, but they won't count unless they align with
; the other sub-occurrences. Consider the first row of the sea monster and
; it's clear that individual rows can easily match on their own, even in the
; absence of an actual monster. A mirage, as it were.
;
; 2. Each individual row of the sea monster, in this problem, is 20 characters
; long, while the image is 24 characters wide. Thus, there are 4 different
; possible positions (i ∈ [0, 3]) on any given line for a monster to hide in.
; We need to make sure that a string match at i=0 doesn't stop the search from
; also matching at i=1. That is, we must ensure our search supports
; overlapping matches.
; Let's address #2 first by building a regular expressions that use positive
; lookahead so as to not consume the input even if there is a match. Positive
; lookahead is represented as `(?=<expr>)`.
(define (parse-monster rows)
(for/list ((row rows))
(pregexp (format "(?=(~a))" (string-replace row " " ".")))))
; To address #1, here is the approach:
;
; The monster is 3 rows tall, so examine the input in slices of three lines:
; lines 0 to 2, then lines 1 to 3, then 2 to 4, and so on. For each such
; slice, search for row 0 of the monster in line 0, row 1 in line 1, etc.
;
; For every match in a row, collect its offset (which in practice will be 0 <=
; i <= 3). Discard any offset that doesn't match the other lines' offsets. So,
; if in a slice of text we have the following matches:
;
; line 0: matched row 0 at offset 0 and offset 1
; line 1: matched row 1 at offset 1 and offset 2
; line 2: matched row 2 at offset 1
;
; Then the intersection of offsets is 1.
;
; Racket's `regexp-match-positions*` gives us a list of ranges (not just
; offsets) for every match, which serves just as well. The ability to examine
; the input in overlapping slices of three lines comes from our own function
; `slice`.
;
; The implementation becomes concise:
(define (block-match-positions* patterns input)
(append*
(for/list ((lines (slice (length patterns) (string-split input "\n"))))
(apply intersect (map regexp-match-positions* patterns lines)))))
; Counting monsters now amounts to counting these matches in all possible
; orientations of the full image:
(define (count-monsters input)
(define patterns (parse-monster sea-monster))
(for/sum ((o orientation-attempts))
(length
(block-match-positions* patterns (string-map o input)))))
; And our puzzle is entirely solved:
(define (sea-roughness input)
(define pieces-per-monster
(for/sum ((line sea-monster))
(for/sum ((char line) #:when (char=? char #\#)) 1)))
(define pieces-in-sea
(for/sum ((char input) #:when (char=? char #\#)) 1))
(- pieces-in-sea (* pieces-per-monster (count-monsters input))))
;
; UTILITY FUNCTIONS
;
; Like set-intersect, but for lists
(define (intersect . lsts)
(set->list
(apply set-intersect
(map list->set lsts))))
; Turns (slice 3 (range 6))
; into '((0 1 2) (1 2 3) (2 3 4) (3 4 5))
;
; Not the same as `in-slice`.
(define (slice size lst)
(cond (((length lst) . < . size) '())
(((length lst) . = . size) (list lst))
(else (cons (take lst size)
(slice size (cdr lst))))))
; Turns '((a . (1 2)) (b . (2 3)))
; into '((1 . a) (2 . a) (2 . b) (3 . b))
(define (dict-reverse d)
(append-map
(λ (p) (map (λ (v) (cons v (car p))) (cdr p)))
(dict->list d)))
; Turns '((1 . a) (2 . a) (2 . b) (3 . b))
; into '#hash((1 . (a)) (2 . (b a)) (3 . (b)))
(define (dict-condense d)
(for/fold ((acc (make-immutable-hash)))
(((k v) (in-dict d)))
(hash-update acc k (curry cons v) '())))
; Given a function and a string representing n lines of n characters, applies
; the function to the two-dimensional list of characters of the string, and
; returns the result back in string form.
;
; (string-map rotate90 "abc\ndef\nghi") -> "gda\nheb\nifc"
(define (string-map fn str)
(define (unpack str) (map string->list (string-split str "\n")))
(define (repack rows) (string-join (map list->string rows) "\n"))
(repack (fn (unpack str))))
;
; THE END.
;
(provide (all-defined-out))
; vim: set et tw=78:
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment