Advent of Code 2023 - Day 3: Gear Ratios
1 2 |
#lang iracket/lang #:require racket (require "../advent.rkt" threading) |
Day 3 involves some two dimensional analysis, so we’ll use complex numbers as a convenient two dimensional index:
1 |
(define neighbors '(0-i 1-i 1 1+i 0+i -1+i -1 -1-i)) |
Most of the work today is in the input parsing. Our input looks like this:
467..114..
...*......
..35..633.
......#...
617*......
.....+.58.
..592.....
......755.
...$.*....
.664.598..
Our goal in parsing is to create two lists - a list of numbers and a list of symbols. The elements of each list will be a pair. The first element of the pair is a two dimensional index, represented by a complex number, and the second element is a token. For numbers, the token will be a string representation of the number (to easily allow iterating over each digit), and for symbols, the token will be a character.
While I could’ve created both lists in a single pass, I chose instead to first create a single list containing both numbers and symbols, and then use filter
to create each of the final lists from the single intermediate list.
I’ve broken the parsing up into a number of functions, and I’ll cover them in a “bottom up” fashion. First, we’ll define a couple example lines to illustrate some parsing functions:
1 2 |
(define num-line '(#\4 #\6 #\7 #\. #\. #\1 #\1 #\4 #\. #\.)) (define sym-line '(#\. #\. #\. #\* #\. #\. #\. #\. #\. #\.)) |
The lower level parsing functions accept a list of characters, line
, that begins with the token to be parsed, and an index into the current line of input, col
. The function returns two values, the token parsed, and the index into the current input line just passed the token. For example:
1 2 3 4 |
(define (parse-sym line col) (values (car line) (add1 col))) (parse-sym (drop sym-line 3) 3) ; e.g. input line after we've consumed the first three . characters |
#*
4
For the other types of tokens (dots and numbers), we’ll need a generalized function to parse characters:
1 2 3 4 5 6 7 8 9 |
(define (parse-chars line col pred? [ convert identity ]) (let ([ pos (or (index-where line pred?) (length line)) ]) (values (convert (take line pos)) (+ col pos)))) (define (parse-dots line col) (parse-chars line col (λ (ch) (not (char=? #\. ch))) (const #f))) (define (parse-num line col) (parse-chars line col (λ (ch) (not (char-numeric? ch))) list->string)) |
The only reason we’re parsing the dots is to consume enough of the input to get to the next token, so parse-dots
returns #f
for the token (no use allocating a string to hold the dots, since we’ll ignore them):
1 |
(parse-dots sym-line 0) |
#f
3
We do care about numbers, so parse-num
will return the parsed number:
1 |
(parse-num num-line 0) |
“467”
3
1 |
(parse-num (drop num-line 5) 5) |
“114”
8
Now we can write the general parse-token
:
1 2 3 4 5 6 7 |
(define (parse-token line col) (let ([ ch (car line) ]) (cond [ (char=? #\. ch) (parse-dots line col) ] [ (char-numeric? ch) (parse-num line col) ] [ else (parse-sym line col) ]))) (parse-token num-line 0) |
“467”
3
The parse-tokens
function will parse all of the input and return a list of tokens along with the two dimensional index of the token. Tokens will either be strings, for numbers, or characters, for symbols, or #f
for dots:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
(define (parse-tokens lines) (let next-line ([ lines lines ][ row 0 ][ tokens '() ]) (if (null? lines) tokens (let next-token ([ line (car lines) ][ col 0 ][ tokens tokens ]) (if (null? line) (next-line (cdr lines) (add1 row) tokens) (let-values ([ (token new-col) (parse-token line col) ]) (next-token (drop line (- new-col col)) new-col (cons (cons (make-rectangular col row) token) tokens)))))))) (define lines (parse-aoc 3 string->list)) (take (parse-tokens lines) 7) |
----------------------------------------------------------------------------------------------------
day03.txt -> 110 chars, 10 lines; first 3 lines; last 2 lines:
----------------------------------------------------------------------------------------------------
467..114..
...*......
..35..633.
...
...$.*....
.664.598..
----------------------------------------------------------------------------------------------------
(parse 3) -> 10 entries:
----------------------------------------------------------------------------------------------------
((#\4 #\6 #\7 #\. #\. #\1 #\1 #\4 #\. #\.)
(#\. #\. #\. #\* #\. #\. #\. #\. #\. #\.)
...
(#\. #\6 #\6 #\4 #\. #\5 #\9 #\8 #\. #\.))
----------------------------------------------------------------------------------------------------
’((8+9i . #f) (5+9i . “598”) (4+9i . #f) (1+9i . “664”) (0+9i . #f) (6+8i . #f) (5+8i . #*))
Here we see that the number "598 is at row 9, col 5 (zero based), and the symbol *
is at row 8, col 5.
Now we’ll simply filter the list into two separate lists - one for numbers and one for symbols:
1 2 3 4 5 6 7 |
(define (parse-input tokens) (values (filter (compose1 string? cdr) tokens) (filter (compose1 char? cdr) tokens))) (define-values (nums syms) (parse-input (parse-tokens lines))) (take nums 3) |
’((5+9i . “598”) (1+9i . “664”) (6+7i . “755”))
1 |
(take syms 3) |
’((5+8i . #*) (3+8i . #$) (5+5i . #+))
The hard part of parsing is over! Now we’ll define four helper functions. adjacent?
will indicate whether two indices are adjacent i.e. within a distance of 1 in the vertical, horizontal or diagonal directions:
1 2 3 4 |
(define (adjacent? pos1 pos2) (ormap (λ (n) (= (+ pos1 n) pos2)) neighbors)) (adjacent? (make-rectangular 3 4) (make-rectangular 4 4)) |
#t
num-adjacent-so-sym?
indicates whether the number num-str
at index num-pos
is adjacent to the symbol at index sym-pos
. To do this, we iterate over the index of the digits in num-str
to see if any of them are adjacent to the symbol index:
1 2 3 4 5 6 |
(define (num-adjacent-to-sym? num-str num-pos sym-pos) (ormap (λ (n) (adjacent? (+ num-pos n) sym-pos)) (range (string-length num-str)))) (num-adjacent-to-sym? "592" 2+6i 5+5i) ; number beginning at (6,2), symbol at (5,5) |
#t
is-part-number?
indicates whether the number, num-str
, at index, num-pos
is adjacent to any symbol. To do that, we’ll iterate over all the symbols to see if any are adjacent to the number:
1 2 3 4 5 6 |
(define (is-part-number? num-str pos syms) (ormap (λ (sym-pair) (num-adjacent-to-sym? num-str pos (car sym-pair))) syms)) (is-part-number? "592" 2+6i syms) |
#t
Lastly, gear-with-nums
accepts a *
symbol index, pos
, and returns a list of numbers adjacent to that symbol:
1 2 3 4 5 6 7 |
(define (gear-with-nums pos) (~> (filter (λ (pair) (num-adjacent-to-sym? (cdr pair) (car pair) pos)) nums) (map (compose1 string->number cdr) _))) (gear-with-nums 3+1i) |
’(35 467)
Now, all that’s left is the individual part functions. For Part 1:
- Filter the list of number pairs to only those that are part numbers
- For each pair, grab the actual number string and convert to an integer
- Sum all of the numbers in the list
1 2 3 4 5 6 7 8 |
(define (part1) (~> (filter (λ (pair) (is-part-number? (cdr pair) (car pair) syms)) nums) (map (compose1 string->number cdr) _) (list-sum _))) (part1) |
4361
For Part 2:
- Filter the list of symbols to only those that are
*
- For each of those, associate a list of numbers that are adjacent
- Filter that list to only those that have exactly 2 numbers
- Compute the product for each
- Sum all the products
1 2 3 4 5 6 7 8 |
(define (part2) (~> (filter (compose1 (curry char=? #\*) cdr) syms) (map (compose1 gear-with-nums car) _) (filter (compose1 (curry = 2) length) _) (map list-prod _) list-sum)) (part2) |
467835