Showing posts with label series. Show all posts
Showing posts with label series. Show all posts

Saturday, September 6, 2025

Paginated collections

If you like the series abstraction, then you will want collections to have functions that return series of their elements. The code that uses series will be able to treat the collection functionality while the generated code will be iterative. If you are dealing with web resources, it is likely that your collection will be paginated. You don't want to fetch all the pages at the beginning and then process the elements one at a time, you want to interleave the page fetches with the element processing, so that you process several elements between each page fetch.

This would be trivial to implement as a flatmap — you'd iterate over the pages, then for each page you'd iterate over the collection elements on that page — but the series abstraction isn't built to do a nested iteration like that. Instead, you need to build a series out of a state machine.

The state will be a queue of pending elements along with the token for the next page. At each step, the state machine will pop the queue. If the queue is empty, we fetch the next page and refill the queue. The inner series generator is the series of states of this state machine. It makes transitions like this:

  (<item1> <item2> … <next-page-token>)
  (<item2> … <next-page-token>)
  …
  (<next-page-token>)
  (<itemN+1> <itemN+2> … <next-page-token+1>)
  (<itemN+2> … <next-page-token+1>)
  …

This is implemented via the general series scan-fn that takes an initial state, a state transition function, and returns a series of states:

(scan-fn
         (lambda () (get-elements (fetch-first-page)))
         (lambda (queue)
           (if (next-page-token? (car queue))
               (get-elements (fetch-next-page (car queue)))
               (cdr queue)))
         (lambda (queue) (null queue)))

Over this series of states, we map a function that returns the head of the queue:

(map-fn 't #'car <series-of-states>)
  item1
  item2
  …
  next-page-token
  itemN+1
  itemN+2
  …
  next-page-token+1
  …

Finally, we filter out the page tokens:

(choose-if (lambda (x) (not (next-page-token? x)))
          <series-of-heads>)
  item1
  item2
  …
  itemN+1
  itemN+2
  …

It is a bit convoluted, but it gives you the elements of the series one at a time, fetching the pages as needed when the current page is emptied. We're not fetching the entire collection at the start, although we are buffering a page of elements at any time.

It won't bother Lisp programmers, but it may bother people who are anal about static types that the intermediate series is heterogeneous — it contains the elements of the collection as well as the next page tokens. If it really bothers you, you can rework the state machine to look ahead for the next page marker so that there is always at least one element in the queue. There is ultimately little difference: in one case, we filter out the page markers, in the other case, we look ahead and avoid putting them in. It is a matter of taste whether the complication of looking ahead is worth avoiding the type filter.


Monday, March 17, 2025

Series vs. streams

A generator is an abstraction of a sequence of values. It is a procedure that returns the next value in the sequence each time it is invoked. The generator can run out of items to return at some point if the sequence is finite, or it can keep generating values if the sequence is ininite.

A generator is decidely non-functional. Each time it is called it has the potential to return a different value. But let's make it functional. Instead of returning a single value, let's return two values: the next value in the sequence and the next state of the generator. The generator can now be pure functional and return the exact same two values each time. The caller will keep track of the current generator will replace the current generator with the next one returned by the call.

We implement generators as a promise that returns a pair of the next value and the next generator. The returned pair is what S&ICP call a stream. In other words, a stream is output of a functional generator that is 180 degrees out of phase of the generator.

Streams are similar to series in that you can write computations that operate on the aggregate stream, but it will be piplined to operate one element at time. But rather than having the compiler perform a code walk to set up an explicit pipeline, the runtime system sets up an implicit pipeline through the constraints of the promises. This makes streams a bit more flexible than series.

Series are more efficient than streams because the compiler can turn the implicit pipeline into an explicit one that is easy to optimize. Streams turn into a series of nested lexical closures with the attendant overhead.

One of the difficulties in using streams is that you often have to pay very careful attention to avoid fencepost errors and generating elements one beyond what is necessary. This isn't just a matter of using up a tad more storage, but it can lead to unexpected infinite loops because you attempt to reach one beyond the base case. Very often you find that you need two versions of each function: one that takes a stream argument, and one that takes a generator argument that you are careful to avoid calling unless necessary.

Streams are lazy by nature. Laziness introduces a need for static types. If you have a computed value, you can examine it to find out its type, but if you have a promise, you cannot tell what type of object it will return without forcing the promise. You cannot do a type dispatch on a promise because you don't know what it will return. A static type would indicate the type of the returned value without forcing the promise.

Series requires that the entire pipeline from source to sink be visible to the compiler. Streams do not have this requirement.

Despite their drawbacks, I rather like streams. I use them in my linear-fractional-transformations package to represent exact real numbers as streams of linear fractional transformations. I also use streams of integers to represent the continued fraction expansion of exact real numbers.


Saturday, March 8, 2025

Advent of Code 2024: Day 25

On day 25, we are given a set of locks and keys as ascii art. A typical lock looks like this:

.....
.#...
.##.#
.##.#
###.#
#####
#####

and a typical key looks like this:

#####
#####
##.#.
##.#.
##.#.
#..#.
.....

We read the input file with a little state machine that accumulates lines until a blank line or end of file is reached. It decides whether what it read was a lock or a key by looking to see if the first row is all #'s or not. If it is, it's a key, otherwise it's lock.

(defun read-input (pathname)
  (let ((package (find-package "ADVENT2024/DAY25")))
    (with-open-file (stream pathname)
      (let iter ((line (read-line stream nil))
                 (accum '())
                 (locks '())
                 (keys '()))
        (if line
            (let ((char-list (map 'list (lambda (c) (intern (string c) package)) line)))
              (if (null char-list)
                  (let ((item (make-grid (length accum) (length (first accum))
                                         :initial-contents (reverse accum))))
                    (if (every (lambda (s) (eq s '\#)) (first accum))
                        (iter (read-line stream nil)
                              '()
                              locks
                              (cons item keys))
                        (iter (read-line stream nil)
                              '()
                              (cons item locks)
                              keys)))
                  (iter (read-line stream nil)
                        (cons char-list accum)
                        locks
                        keys)))
            (let ((item (make-grid (length accum) (length (first accum))
                                   :initial-contents (reverse accum))))
              (if (every (lambda (s) (eq s '\#)) (first accum))
                  (values (reverse locks) (reverse (cons item keys)))
                  (values (reverse (cons item locks)) (reverse keys)))))))))

A key fits into a lock (but doesn't necessarily open it) if none of the '#'s in the key overlap with the '#'s in the lock. This is easily checked by iterating over the key and lock in parallel and ensuring that at least one of the characters is '.'.

(defun fits? (key lock)
  (collect-and (#M(lambda (k l)
                    (or (eql k '|.|) (eql l '|.|)))
                  (scan 'array key)
                  (scan 'array lock))))

For part 1, we are asked to find the number of key/lock combinations that result in a fit. We use map-product from the alexandria library to map the fits? predicate over the cartesian product of keys and locks. We then count the number of fits.

(defun part-1 ()
  (multiple-value-bind (locks keys) (read-input (input-pathname))
    (count t (map-product #'fits? keys locks))))

There is no part 2 for this problem.


We've arrived at the end of the 2024 Advent of Code. I started this series with two intents: to demonstrate an approach to solving the problems that is more idiomatic to Common Lisp, and to learn more about the series library. I don't claim my solutions are the best. They could all use some improvement, and I'm sure you code golfers can find numerous ways to shave strokes. But I think each solution is fairly reasonable and tries to show off how to effectively use Common Lisp in a number of simple prolems.

For these problems I purposefully avoided the loop macro and tried to use the series library as much as possible. I used named-let for the more complex iterations.

I was ultimately disappointed in series. I like the idea of automatically generating pipelines from a more functional style, but it simply hits the complexity wall far too quickly. For simple iterations, it's great, but for anything even slightly more complex, it becomes difficult to use.

The full source code I wrote is available on GitHub at https://github.com/jrm-code-project/Advent2024 Be aware that I have not included the puzzle input files. The code will not run without them. You can download the puzzle inputs from the Advent of Code website and put them in the appropriate directories, each in a file called input.txt

I'm curious to hear what you think of my solutions. If you have any comments or suggestions, please feel free to contact me via email or by leaving a comment.


Wednesday, March 5, 2025

Advent of Code 2024: Day 22

On Day 22 we are introduced to a simple pseudo-random number generator (PRNG) that uses this recurrance to generate pseudo-random numbers:

S1 = ((Xn << 6) ⊕ Xn) mod 224
S2 = ((S1 >> 5) ⊕ S1) mod 224
Xn+1 = ((S2 << 11) ⊕ S2) mod 224

We just define this as a simple function, but we are carful to put a check-type on the input to make sure it is a number in the correct range. This gives the compiler enough information to optimize the body of the generator to a sequence of inline fixed-point operations, avoid the overhead of a function call out to the generic arithmetic.

(defun next-pseudorandom (pseudorandom)
  (check-type pseudorandom (integer 0 (16777216)))
  (macrolet ((mix (a b) ‘(logxor ,a ,b))
             (prune (x) ‘(mod ,x 16777216)))
    (let* ((s1 (prune (mix (* pseudorandom 64) pseudorandom)))
           (s2 (prune (mix (floor s1 32) s1)))
           (s3 (prune (mix (* s2 2048) s2))))
      s3)))

We can generate a series of random numbers from a given seed:

(defun scan-pseudorandom (seed)
  (declare (optimizable-series-function))
  (scan-fn '(integer 0 (16777216))
           (lambda () seed)
           #'next-pseudorandom))

The nth pseudorandom number is the nth element in the series, i.e. the result of applying the next-pseudorandom function n times to the seed:

(defun nth-pseudorandom (seed n)
  (collect-nth n (scan-pseudorandom seed)))

Part 1 of the problem is to sum the 2000th pseudorandom numbers generated from seeds given in a file.

(defun part-1 ()
  (collect-sum (#Mnth-pseudorandom (scan-file (input-pathname)) (series 2000))))

For part 2, we're going to be simulating a market. The prices are single digit pseudorandom numbers:

(defun scan-prices (seed)
  (declare (optimizable-series-function))
  (#Mmod (scan-pseudorandom seed) (series 10)))

The bidders in our market are monkeys, and we read them from our input file:

(defun scan-monkeys (input-pathname)
  (declare (optimizable-series-function 2))
  (cotruncate (scan-range :from 0)
              (scan-file input-pathname)))

The seed that we read from the input pathname will be used to create a price series for each monkey.

Each monkey looks for trends in the market by looking at the last four price changes. If the last four prices changes match the trend the monkey looks for, the monkey will make a trade and get a profit of the current price.

For part 2, we assume all the monkeys look for the same trend. Some trend will maximize the total profit of all the monkeys. We want to know what that maximum profit is.

We'll proceed in two steps. First, we make a table that maps trends to profits for each monkey. We'll start with an empty table, then we'll iterate over the monkeys, adding the trend info for that monkey. Once we have the table, we'll iterate over all the possible trends and find the one that maximizes the total profit.

price-deltas is a series of the differences between the prices in the price series. We'll use this to determine the trend.

(defun price-deltas (price-series)
  (declare (optimizable-series-function)
           (off-line-port price-series))
  (mapping (((before after) (chunk 2 1 price-series)))
     (- after before)))

price-trends is a series of trends. The trend is simply a list of the last four price deltas.

(defun price-trends (price-series)
  (declare (optimizable-series-function)
           (off-line-port price-series))
  (mapping (((d1 d2 d3 d4) (chunk 4 1 (price-deltas price-series))))
           (list d1 d2 d3 d4)))

add-trend-info! adds the trend info for a monkey to the table. We'll look at a count of 2000 prices (minus the first four because there aren't enough to establish a trend). The key to an entry in the table will be taken from the price-trends. The value for an entry is the price after that trend. The table maps a trend to an alist that maps monkeys to profits, so once we know the trend, we look to see if an entry for the monkey already exists in the value. If it does, we're done. But if it doesn't, we add an entry for the monkey with the profit.

(defun add-trend-info! (table monkeyid seed)
  (iterate ((count (scan-range :from 4 :below 2001))
            (trend (price-trends (scan-prices seed)))
            (value (subseries (scan-prices seed) 4)))
    (declare (ignore count))
    (unless (assoc monkeyid (gethash trend table '()))
      (push (cons monkeyid value) (gethash trend table '())))))

Once we have added the trend info for all the monkeys, we find the entry in the table that maximizes the total profit.

(defun trend-table-maximum (table)
  (let ((best-score 0)
        (best-key nil))
    (maphash (lambda (key value)
               (let ((score (reduce #'+ (map 'list #'cdr value))))
                 (when (> score best-score)
                   (setq best-key key)
                   (setq best-score score))))
             table)
    (values best-key best-score)))

Finally, we can put it all together in the part-2 function:

(defun part-2 ()
  (multiple-value-bind (best-key best-value)
      (let ((table (make-hash-table :test #'equal)))
        (iterate (((monkeyid seed) (scan-monkeys (input-pathname))))
          (add-trend-info! table monkeyid seed))
        (trend-table-maximum table))
    (declare (ignore best-key))
    best-value))

Monday, March 3, 2025

Advent of Code 2024: Day 20

For day 20, we return to a maze problem. The maze involved, however, is trivial — there are no decision points, it is just a convoluted path.

;;; -*- Lisp -*-

(in-package "ADVENT2024/DAY20")

(defun read-input (input-pathname)
  (read-file-into-grid
    (char-interner #’identity (find-package "ADVENT2024/DAY20"))
     input-pathname))

(defun find-start-and-goal (maze)
  (let ((inverse (invert-grid maze ’|.|)))
    (values (car (gethash ’S inverse))
            (car (gethash ’E inverse)))))

We compute the distance to the goal at all points along the path by walking the path backwards.

(defun compute-distances (maze)
  (let ((distances (make-grid (grid-height maze) (grid-width maze)
                              :initial-element nil)))
    (multiple-value-bind (start goal) (find-start-and-goal maze)
      (declare (ignore start))
      (let iter ((current goal)
                 (distance 0))
        (when current
          (setf (grid-ref distances current) distance)
          (iter (let* ((neighbors (#M2v+ (scan ’list (list +north+ +south+ +east+ +west+))
                                     (series current)))
                       (fill? (#M(lambda (maze neighbor)
                                   (and (on-grid? maze neighbor)
                                        (not (eql (grid-ref maze neighbor) ’\#))
                                        (null (grid-ref distances neighbor))))
                                 (series maze)
                                 neighbors)))
                  (collect-first (choose fill? neighbors)))
                (1+ distance))))
      distances)))

When we run through the maze we are allowed to cheat just once by walking through a wall. For part 1, we can walk just one step through a wall, but for part 2, we can walk up to 20 steps ignoring the walls. We might as well combine the two solutions into a single parameterized function. We will be asked to count the number of cheats that shorten the path by at least 100 steps.

I tried for quite some time to come up with a series oriented way to solve this, but it turned out to be much easier to just write a named-let iterative loop. So much for series.

First, we have a function that finds the cheats for a specific location. We are given a grid of distances to the goal, a coord that we start from, the current distance to the goal, the number of steps we can take through the walls, and the number of steps we have to shave off to count this cheat.

We iterate in a square grid centered at the current location and twice as wide plus one as the cheat steps. Check the locations in the distance grid that fall within the square and this tells us how much closer to the goal we can get by cheating to that location. We have to add in the manhattan distance from the current location to the cheat location to get the total distance. Subtract that from the original distance to the goal and we have the number of steps we save by using this cheat. If it exceeds our threshold, we count it.

(defun scan-square-coords (size)
  (declare (optimizable-series-function))
  (let ((displacement (coord size size)))
    (#M2v- (scan-coords (1+ (* size 2)) (1+ (* size 2)))
           (series displacement))))

(defun count-location-cheats (distances coord distance cheat-steps threshold)
  (collect-sum
   (choose
    (mapping (((cheat-vec) (scan-square-coords cheat-steps)))
      (let ((manhattan-distance (+ (abs (column cheat-vec)) (abs (row cheat-vec))))
            (cheat-coord (2v+ coord cheat-vec)))
        (and (<= manhattan-distance cheat-steps)
             (on-grid? distances cheat-coord)
             (let ((cheat-distance (grid-ref distances cheat-coord)))
               (and cheat-distance
                    (let* ((distance-if-cheating (+ manhattan-distance cheat-distance))
                           (savings (- distance distance-if-cheating)))
                      (and (>= savings threshold)
                           1))))))))))

So then we just iterate over the locations in the distance grid and call this function for each location, summing the results.

(defun count-cheats (distances-grid cheat-steps threshold)
  (collect-sum
   (choose
    (mapping (((coord distance) (scan-grid distances-grid)))
      (and distance
           (count-location-cheats distances-grid coord distance cheat-steps threshold))))))

For part 1, we can only take two steps through a wall.

(defun part-1 ()
  (count-cheats (compute-distances (read-input (input-pathname))) 2 100))

For part 2, we can take up to 20 steps through a wall.

(defun part-2 ()
  (count-cheats (compute-distances (read-input (input-pathname))) 20 100))

Sunday, March 2, 2025

Advent of Code 2024: Day 19

For day 19, we are constructing sequences from fragments. We are first given a list of fragments, separated by commas. For example:

r, wr, b, g, bwu, rb, gb, br

The we are given a series of sequences that we need to construct by concatenating the fragments. For example:

brwrr  = br + wr + r
bggr   = b + g + g + r
;;; -*- Lisp -*-

(in-package "ADVENT2024/DAY19")

(defun read-input (input-pathname)
  (let ((parsed
          (collect 'list
            (#M(lambda (line)
                 (map 'list #'str:trim (str:split #\, line)))
               (scan-file input-pathname #'read-line)))))
    (values (first parsed) (map 'list #'first (rest (rest parsed))))))

Our job is to determine if the sequences can be constructed from the fragments. This is an easy recursive predicate:

(defun can-make-sequence? (fragments sequence)
  (or (zerop (length sequence))
      (some
       (lambda (fragment)
         (multiple-value-bind (prefix? suffix)
             (starts-with-subseq fragment sequence :return-suffix t)
           (and prefix?
                (can-make-sequence? fragments suffix))))
      fragments)))

Part 1 is to determine how many of the sequences can be constructed from the fragments.

(defun part-1 ()
  (multiple-value-bind (fragments sequences) (read-input (input-pathname))
    (count-if (lambda (sequence)
                  (can-make-sequence? fragments sequence))
              sequences)))

Part 2 is to count the number of ways we can construct the sequences from the fragments. Naively, we would just count the number of ways we can construct each sequence using each of the fragments as the first fragment and then sum them.

(defun count-solutions (fragments sequence)
  (if (zerop (length sequence))
      1
      (collect-sum
        (#M(lambda (fragment)
             (multiple-value-bind (prefix? suffix)
                 (starts-with-subseq fragment sequence :return-suffix t)
               (if prefix?
                   (count-solutions fragments suffix)
                   0)))
          (scan 'lists fragments)))))

But the naive approach won’t work for the larger input. The combinatorics grow far too quickly, so we need to be more clever. One possible way to do this is with “dynamic programming”, but most of the times I've seen this used, it involved a table of values and you had to invert your solution to fill in the table from the bottom up. But this is unnecessarily complicated. It turns out that “dynamic programming” is isomorphic to simple memoized recursive calls. So we won't bother with the table and inverting our solution. We'll just add some ad hoc memoization to our recursive count-solutions:

(defparameter *count-solutions-cache* (make-hash-table :test 'equal))

(defun count-solutions (fragments sequence)
  (let ((key (cons fragments sequence)))
    (or (gethash key *count-solutions-cache*)
        (setf (gethash key *count-solutions-cache*)
              (if (zerop (length sequence))
                  1
                  (collect-sum
                    (#M(lambda (fragment)
                         (multiple-value-bind (prefix? suffix)
                             (starts-with-subseq fragment sequence :return-suffix t)
                           (if prefix?
                               (count-solutions fragments suffix)
                               0)))
                      (scan 'list fragments))))))))

(defun part-2 ()
  (multiple-value-bind (fragments sequences) (read-input (input-pathname))
    (collect-sum
     (#M(lambda (sequence)
          (count-solutions fragments sequence))
        (scan ’list sequences)))))

This runs at quite a reasonable speed.


Thursday, February 27, 2025

Advent of Code 2024: Day 16

For day 16, we are solving a maze. We want to find the lowest cost path from the start to the end. Taking a step straight ahead costs 1, but turning left or right costs 1000.

This puzzle was the most vexing of all the puzzles. The solution is straightforward but the devil is the details. I found myself constantly mired in the CARs and CDRs of the path data structure, descending too far or not far enough. I tried several different representations for a path, each one with its own set of problems. Trying to keep track of the direction of the steps in the path turned out to be an exercise in frustration.

The algorithm is a variant of Dijkstra’s algorithm, which finds the shortest weighted path in a graph. In our case, the graph is derived from the maze. The vertices of the graph are the locations in the maze with three or more paths leading out of them. The edges in the graph are the steps between the vertices. But you cannot compute the cost of a path by summing the weights of the edges, as the final edge in the path may be reached either by proceeding straight through the prior vertex, or by turing left or right at the prior vertex. Thus I modified Dijkstra's algorithm to be edge-oriented rather than vertex-oriented. This turned out to be a key to solving the problem. With the vertex-oriented solutions, I had to keep track of the orientation of the path as it entered and left the vertex, and annotating the steps along the path with their orientation turned into a bookkeeping nightmare. With the edge-oriented solution, I could discard the orientation information as I advanced the algorithm and reconstruct the orientation information only after I had generated a candidate path. This greatly simplified the bookkeeping.

The algorithm uses a pure functional weight-balanced binary tree as a priority queue for the paths. The tree is kept in order of increasing path score, so the lowest scoring path is always the leftmost path in the tree. In my original implementation, I punted and used a linear priority queue. This is simple, and it works, but is far too slow. The weight-balanced binary tree was cribbed from MIT-Scheme.

;;; -*- Lisp -*-

(in-package "ADVENT2024/DAY16")

(defun read-input (input-pathname)
  (read-file-into-grid
    (char-interner #’identity (find-package "ADVENT2024/DAY16"))
     input-pathname))

(defun start-and-goal (maze)
  (let ((inverse (invert-grid maze ’|.|)))
    (values (first (gethash ’S inverse))
            (first (gethash ’E inverse)))))

Since Dijkstra’s algorithm is a graph algorithm, we need to represent the maze as a graph. We simplify the graph by flooding the dead ends.

(defun dead-end? (maze coord)
  (and (on-grid? maze coord)
       (eql (grid-ref maze coord) ’|.|)
       (let ((n (coord-north coord))
             (s (coord-south coord))
             (e (coord-east coord))
             (w (coord-west coord)))
         (let ((n* (or (not (on-grid? maze n))
                       (eql (grid-ref maze n) ’\#)))
               (s* (or (not (on-grid? maze s))
                       (eql (grid-ref maze s) ’\#)))
               (e* (or (not (on-grid? maze e))
                       (eql (grid-ref maze e) ’\#)))
               (w* (or (not (on-grid? maze w))
                       (eql (grid-ref maze w) ’\#))))
           (or (and n* e* w*)
               (and e* n* s*)
               (and s* e* w*)
               (and w* n* s*))))))

(defun flood-dead-end! (maze coord)
  (when (dead-end? maze coord)
    (setf (grid-ref maze coord) ’\#)
    (flood-dead-end! maze (coord-north coord))
    (flood-dead-end! maze (coord-south coord))
    (flood-dead-end! maze (coord-east coord))
    (flood-dead-end! maze (coord-west coord))))

(defun flood-dead-ends! (maze)
  (iterate ((coord (scan-grid-coords maze)))
    (flood-dead-end! maze coord)))

We then mark the vertices of the graph by looking for locations with three or more paths leading out of them.

(defun vertex? (maze coord)
  (and (on-grid? maze coord)
       (eql (grid-ref maze coord) ’|.|)
       (let ((n (coord-north coord))
             (s (coord-south coord))
             (e (coord-east coord))
             (w (coord-west coord)))
         (let ((n* (and (on-grid? maze n) (member (grid-ref maze n) ’(\. + S E))))
               (s* (and (on-grid? maze s) (member (grid-ref maze s) ’(\. + S E))))
               (e* (and (on-grid? maze e) (member (grid-ref maze e) ’(\. + S E))))
               (w* (and (on-grid? maze w) (member (grid-ref maze w) ’(\. + S E)))))
           (or (and n* e* w*)
               (and e* n* s*)
               (and s* e* w*)
               (and w* n* s*))))))

(defun mark-vertices! (maze)
  (let ((vertices ’()))
    (iterate ((coord (scan-grid-coords maze)))
      (when (vertex? maze coord)
        (setf (grid-ref maze coord) ’+)
        (push coord vertices)))
    vertices))

After flooding the dead ends and marking the vertices, all the edges begin and end at a vertex.

It isn’t necessary for the solution, but it helps to be able to visualize the maze. The show-maze procedure will print the maze to the standard output. The show-maze procedure takes an optional list of coords to highlight in the maze.

(defun show-maze (maze &optional highlight)
  (format t "~&")
  (dotimes (row (grid-height maze))
    (format t "~%")
    (dotimes (col (grid-width maze))
      (cond ((eql (grid-ref maze (coord col row)) ’\#)
             (format t "#"))
            ((member (coord col row) highlight :test #’equal)
             (format t "O"))
            ((eql (grid-ref maze (coord col row)) ’|S|)
             (format t "S"))
            ((eql (grid-ref maze (coord col row)) ’|E|)
             (format t "E"))
            ((eql (grid-ref maze (coord col row)) ’+)
             (format t "+"))
            (t
             (format t "."))))))

Between the vertices, we have the edges of the graph. An edge is simply the a list of coordinates between two vertices. The first and last coordinates of the edge are vertices. To find all the coordinates between two vertices, we walk the edge from the start until we reach another vertex. We don’t maintain direction. Instead, we just make sure that the new coordinate isn’t the last one in the edge we are walking so that we move forward.

(defun walk-edge (maze coord edge)
  (let ((n (coord-north coord))
        (s (coord-south coord))
        (e (coord-east coord))
        (w (coord-west coord)))
    (cond ((and (on-grid? maze n)
                (not (equal n (first edge)))
                (eql (grid-ref maze n) ’|.|))
           (walk-edge maze n (cons coord edge)))
          ((and (on-grid? maze n)
                (not (equal n (first edge)))
                (member (grid-ref maze n) ’(+ S E)))
           (list* n coord edge))
          ((and (on-grid? maze e)
                (not (equal e (first edge)))
                (eql (grid-ref maze e) ’|.|))
           (walk-edge maze e (cons coord edge)))
          ((and (on-grid? maze e)
                (not (equal e (first edge)))
                (member (grid-ref maze e) ’(+ S E)))
           (list* e coord edge))
          ((and (on-grid? maze s)
                (not (equal s (first edge)))
                (eql (grid-ref maze s) ’|.|))
           (walk-edge maze s (cons coord edge)))
          ((and (on-grid? maze s)
                (not (equal s (first edge)))
                (member (grid-ref maze s) ’(+ S E)))
           (list* s coord edge))
          ((and (on-grid? maze w)
                (not (equal w (first edge)))
                (eql (grid-ref maze w) ’|.|))
           (walk-edge maze w (cons coord edge)))
          ((and (on-grid? maze w)
                (not (equal w (first edge)))
                (member (grid-ref maze w) ’(+ S E)))
           (list* w coord edge)))))

Given a vertex, we can find all the edges that lead out of that vertex.

(defun vertex-edges (maze vertex)
  (let ((n (coord-north vertex))
        (s (coord-south vertex))
        (e (coord-east vertex))
        (w (coord-west vertex))
        (edges ’()))
    (when (and (on-grid? maze n) (member (grid-ref maze n) ’(|.| + S E)))
      (push (walk-edge maze n (list vertex)) edges))
    (when (and (on-grid? maze s) (member (grid-ref maze s) ’(|.| + S E)))
      (push (walk-edge maze s (list vertex)) edges))
    (when (and (on-grid? maze e) (member (grid-ref maze e) ’(|.| + S E)))
      (push (walk-edge maze e (list vertex)) edges))
    (when (and (on-grid? maze w) (member (grid-ref maze w) ’(|.| + S E)))
      (push (walk-edge maze w (list vertex)) edges))
    edges))

Given the ordered list of coords in a path through the maze, we need to be able to score it. There is a cost of 1 for every step, and a cost of 1000 for every turn. We calculate these separately.

To find the directions of the steps in the path, we examine adjacent coords. If the columns are the same, the direction is north/south. If the rows are the same, the direction is east/west. The very first direction is east/west because the start is always facing east. Once we have a sequence of the directions, we examine adjacent directions to see if they are the same. If they are, we went straight, otherwise we turned.

(defun count-turns (coord-list)
  (multiple-value-bind (bs as)
      (chunk 2 1 (multiple-value-bind (ls rs) (chunk 2 1 (scan ’list coord-list))
                   (catenate (#M(lambda (l r)
                                  (cond ((= (column l) (column r)) ’ns)
                                        ((= (row l) (row r)) ’ew)
                                        (t (error "Funky coord-list."))))
                                ls
                                rs)
                             (scan ’list (list ’ew)))))
    (collect-length (choose (#Mnot (#Meq bs as))))))

(defun score-coord-list (coord-list)
  (1- (+ (length coord-list)
         (* 1000 (count-turns coord-list)))))

We represent a path as a list of edges. Given a list of edges, we need to stitch them together to create a list of coords in order to score the path. We cannot simply append the edges together, as the vertices between the edges will be duplicated. Instead, we drop the last coord (the ending vertex) from each edge except the first.

(defun at-goal? (path goal)
  (equal (first (first path)) goal))

(defun path->coord-list (path)
  (if (null (rest path))
      (first path)
      (append (butlast (first path)) (path->coord-list (rest path)))))

Given a path, we can extend it by finding the edges that lead out of the last vertex in the path. We discard the edge that came into the vertex, as we don’t want to backtrack.

(defun path-extensions (maze path)
  (let* ((latest-edge (first path))
         (latest-vertex (first latest-edge))
         (back-edge (reverse latest-edge))
         (outgoing-edges (remove back-edge (vertex-edges maze latest-vertex) :test #’equal)))
    (map ’list (lambda (edge) (cons edge path)) outgoing-edges)))

As I mentioned earlier, we use a weight-balanced binary tree as a priority queue. I didn’t bother trying to abstract this. I’m just manipulate the raw nodes of the tree. Each node has a key, which is the score, and a value, which is a list of paths that have that score. We compare keys with the < function. Weight-balanced binary trees are pure functional — adding or popping the queue returns a new queue rather than side effecting the existing one.

(defun make-priority-queue ()
  wtree::empty)

(defun pq-insert (pq entry score)
  (let* ((probe (wtree::node/find #’< pq score)))
    (wtree::node/add #’< pq score (cons entry (and probe (wtree::node/v probe))))))

(defun pq-pop (pq)
  (let* ((node (wtree::node/min pq))
         (score (wtree::node/k node))
         (value-list (wtree::node/v node))
         (value (car value-list))
         (tail (cdr value-list)))
    (if (null tail)
        (values value score (wtree::node/delmin pq))
        (values value score (wtree::node/add #’< (wtree::node/delmin pq) score tail)))))

We finally arrive at the solve-maze procedure. This proceeds in three parts. First, we prepare the maze by flooding the dead ends and marking the vertices. We initialize visited-edges which is a hash table mapping an edge to the lowest score that has been found for a path ending in that edge. We initialize predecessor-edges which is a hash table mapping an edge to the edge that came before it in the lowest scoring path. The initial edges are the ones leading out of the start vertex, and the initial paths are the paths each containing one of the initial edges.

The second part is the main iteration. The outer iteration pops the lowest scoring path so far from the priority queue. If the path ends at the goal, we have found one solution and we proceed to part three where we collect other solutions that with the same score that end at the goal. Otherwise, we enter an inner loop over all ways we can extend the path by one edge. For each extension, we score the extension and look up the most recent edge in the visited-edges.

If we have not visited the edge before, we store the edge in visited-edges and store its predecessor in predecessor-edges. If we have visited the edge before, we have three cases. If the score of the extension is greater that the score we have seen before, we discard the extension. If the score of the extension is equal to the score we have see before, we add the edge preceeding the final edge to the predecessor-edges, but do not pursue this path further. If the score of the extension is less than the score we have previously found, we update the visited-edges with the new lower score and update the predecessor-edges so that this path is the only path leading to the final edge.

When we find a path that ends at the goal, we enter the third part of the procedure. We pop paths from the priority queue collecting any other paths that have also reached the goal with the same score. Finally, we return the list of shortest paths.

(defun solve-maze (maze)
  (flood-dead-ends! maze)
  (mark-vertices! maze)
  (multiple-value-bind (start goal)
      (start-and-goal maze)
    (let* ((visited-edges     (make-hash-table :test ’equal))
           (predecessor-edges (make-hash-table :test ’equal))
           ;; The initial edges are the ones that start at the start vertex.
           (initial-edges (vertex-edges maze start))
           ;; A path is a list of edges.  An initial path is a list of one edge starting at the start vertex.
           (initial-paths (map ’list #’list initial-edges)))

      (dolist (edge initial-edges)
        (setf (gethash edge visited-edges) (score-path (list edge))))

      ;; Main loop, iteratively extend the lowest scoring path.
      (let iter ((scored-paths (do ((pq (make-priority-queue) (pq-insert pq (car initial-paths) (score-path (car initial-paths))))
                                    (initial-paths initial-paths (cdr initial-paths)))
                                   ((null initial-paths) pq))))
        (unless (wtree::empty? scored-paths)
          (multiple-value-bind (path path-score next-scored-paths) (pq-pop scored-paths)
            (if (at-goal? path goal)
                ;; Reached the goal.  Keep popping until we have all solutions.
                (let solution-iter ((solutions (list path))
                                    (next-scored-paths next-scored-paths))
                  (if (wtree::empty? next-scored-paths)
                      solutions
                      (multiple-value-bind (other-path other-path-score next-scored-paths) (pq-pop next-scored-paths)
                        (if (= other-path-score path-score)
                            (solution-iter (if (at-goal? other-path goal)
                                               (cons other-path solutions)
                                               solutions)
                                           next-scored-paths)
                            (values solutions predecessor-edges)))))
                (let iter1 ((extensions (path-extensions maze path))
                            (next-scored-paths next-scored-paths))
                  (if (null extensions)
                      (iter next-scored-paths)
                      (let* ((extension (first extensions))
                             (extension-score (score-path extension))
                             (latest-edge (first extension))
                             (predecessor (second extension))
                             (prior-score (gethash latest-edge visited-edges)))
                        (cond ((null prior-score)
                               (setf (gethash latest-edge visited-edges) extension-score
                                     (gethash latest-edge predecessor-edges) (list predecessor))
                               (iter1 (rest extensions)
                                      (pq-insert next-scored-paths extension extension-score)))
                              ;; If we have found an extension with a worse score, we ignore it.
                              ((> extension-score prior-score)
                               (iter1 (rest extensions) next-scored-paths))
                              ;; If we have found an extension with an equal score, we add the predecessor,
                              ;; but do not pursue it further.
                              ((= extension-score prior-score)
                               (push predecessor (gethash latest-edge predecessor-edges))
                               (iter1 (rest extensions) next-scored-paths))
                              ;; If we have found an extension with a better score, we replace the prior extension.
                              ((< extension-score prior-score)
                               (setf (gethash latest-edge visited-edges) extension-score
                                     (gethash latest-edge predecessor-edges) (list predecessor))
                               (iter1 (rest extensions)
                                      (pq-insert next-scored-paths extension extension-score))))))))))))))

Of note is how the inner and outer iterations interact. The inner iteration is initialized with one of the loop variables of the outer loop. When the inner loop is done, it tail calls the outer loop with the loop variable it originally got from the outer loop. This gives the effect of the inner loop sharing a loop variable with the outer loop.

collect-minimum-coords collects all the coords along all minimal paths that lead through edges on the edge list.

(defun collect-minimum-coords (edge-list predecessor-table)
  (fold-left (lambda (coords edge)
               (union coords
                      (union edge (collect-minimum-coords (gethash edge predecessor-table) predecessor-table)
                             :test #’equal)
                      :test #’equal))
             ’()
             edge-list))

For part 1 of the puzzle, we solve the maze and return the score of a shortest path.

(defun part-1 ()
  (let ((maze (read-input (input-pathname))))
    (multiple-value-bind (paths predecessor-table) (solve-maze maze)
      (declare (ignore predecessor-table))
      (score-path (first paths)))))

For part 2 of the puzzle, we solve the maze and collect the coords of all the minimal paths that lead through the edges of the shortest paths.

(defun part-2 ()
  (let ((maze (read-input (input-pathname))))
    (multiple-value-bind (paths predecessor-table) (solve-maze maze)
      (let ((minimum-coords (collect-minimum-coords (map ’list #’first paths) predecessor-table)))
        (length minimum-coords)))))

Wednesday, February 26, 2025

Advent of Code 2024: Day 15

For day 15, we are simulating moving crates around a warehouse. We are give a map of the warehouse which we will read into a grid, and a list of moves of our little robot. When the robot encounters a crate, it will push it in the direction it is moving, if it can. If the crate rests against another crate, it will push both crates. If the crate rests against a wall, it will not move. If the crate cannot move, the robot doesn’t move either. The robot can only push.

The second part of the puzzle uses double-wide crates, so our input code has a flag to indicate whether to create single-wide or double-wide crates in the initial grid.

;;; -*- Lisp -*-

(in-package "ADVENT2024/DAY15")

(defun decode-cell (string package wide?)
  (if wide?
      (cond ((equal string "#") (list ’\# ’\#))
            ((equal string "O") (list ’[ ’]))
            ((equal string ".") (list ’\. ’\.))
            ((equal string "@") (list ’@ ’\.))
            (t (error "Unknown cell ~a" string)))
      (list (intern (string-upcase string) package))))

In the input, the directions are represented with the characters ^, v, <, and >. We will convert these to the corresponding vectors.

(defun decode-move (move)
  (cond ((equal move "<") +west+)
        ((equal move "^") +north+)
        ((equal move ">") +east+)
        ((equal move "v") +south+)
        (t (error "Unknown move ~a" move))))

We’ll use a regular expression to parse the input. If it is a line consisting of #, O, ., or @, we’ll decode it as a row of the grid. If it is a line consisting of one of the directions, we’ll decode it as a move.

(defun read-input (input-pathname &optional (wide? nil))
  (multiple-value-bind (blanks grids moves)
      (#3M(lambda (line)
            (cl-ppcre:register-groups-bind (blank grid move)
                ("(^$)|([#.O@]+)|([><v^]+)" line)
              (values blank grid move)))
          (scan-file input-pathname #’read-line))
    (let ((blank-lines (collect ’list (choose blanks)))
          (grid-lines  (collect ’list
                         (#M(lambda (line)
                              (collect-append (#Mdecode-cell
                                               (#Mstring (scan ’string line))
                                               (series (find-package "ADVENT2024/DAY15"))
                                               (series wide?))))
                            (choose grids))))
          (move-list (collect-append (#M(lambda (line)
                                          (collect ’list (#Mdecode-move
                                                           (#Mstring (scan ’string line)))))
                                        (choose moves)))))
      (declare (ignore blank-lines))
      (values (make-grid (length grid-lines) (length (first grid-lines)) :initial-contents grid-lines)
              move-list))))

can-move-to? will determine if we can move to a particular cell in the grid from a particular direction. If the cell is empty, we can move there. If the cell is a crate, we can move there if we can move the crate.

(defun can-move-to? (grid coord delta)
  "True if location on grid at coord is empty, or item at location can move in direction."
  (and (on-grid? grid coord)
       (or (eql (grid-ref grid coord) ’\.)
           (can-move? grid coord delta))))

can-move? will determine if we can move an item on the grid one step in a particular direction. The tricky part is double wide crates. We need to check both cells to see if we can move the entire crate.

(defun can-move? (grid coord delta)
  "True if item on grid at coord can move in direction."
  (and (on-grid? grid coord)
       (ecase (grid-ref grid coord)
         (\. (error "No item at coord."))
         (\# nil)
         (@ (let ((target (2v+ coord delta)))
               (can-move-to? grid target delta)))
         (O (let ((target (2v+ coord delta)))
               (can-move-to? grid target delta)))
         (\[ (if (or (equal delta +north+)
                      (equal delta +south+))
                  (let ((target1 (2v+ coord delta))
                        (target2 (2v+ (2v+ coord delta) +east+)))
                    (and (can-move-to? grid target1 delta)
                         (can-move-to? grid target2 delta)))
                  (let ((target (2v+ coord delta)))
                    (can-move-to? grid target delta))))
         (\] (if (or (equal delta +north+)
                      (equal delta +south+))
                  (let ((target1 (2v+ coord delta))
                        (target2 (2v+ (2v+ coord delta) +west+)))
                    (and (can-move-to? grid target1 delta)
                         (can-move-to? grid target2 delta)))
                  (let ((target (2v+ coord delta)))
                    (can-move-to? grid target delta)))))))

move! will move an item on the grid one step in a particular direction if possible. It returns the new grid location if it moved, or nil if it didn’t. When moving an item we put a blank spot where the item was. The tricky part is double-wide crates, where we need to move both cells.

(defun move! (grid coord delta)
  "Move item on grid at coord in direction delta."
  (if (can-move? grid coord delta)
      (ecase (grid-ref grid coord)
        (\. (error "Cannot move empty locations."))
        (\# (error "Cannot move walls."))
        (@ (let ((target (2v+ coord delta)))
               (unless (eql (grid-ref grid target) ’\.)
                 (move! grid target delta))
               (setf (grid-ref grid target) ’@
                     (grid-ref grid coord) ’\.)
               target))

        (O (let ((target (2v+ coord delta)))
               (unless (eql (grid-ref grid target) ’\.)
                 (move! grid target delta))
               (setf (grid-ref grid target) ’O
                     (grid-ref grid coord) ’\.)
               target))

        (\[ (let* ((targetl (2v+ coord delta))
                    (targetr (2v+ targetl +east+)))
               (unless (or (eql delta +east+)
                           (eql (grid-ref grid targetl) ’|.|))
                 (move! grid targetl delta))
               (unless (or (eql delta +west+)
                           (eql (grid-ref grid targetr) ’\.))
                 (move! grid targetr delta))
               (setf (grid-ref grid targetl) ’[
                     (grid-ref grid targetr) ’])
               (unless (eql delta +east+)
                 (setf (grid-ref grid (2v+ coord +east+)) ’\.))
               (unless (eql delta +west+)
                 (setf (grid-ref grid coord) ’\.))
               targetl))

        (\] (let* ((targetr (2v+ coord delta))
                    (targetl (2v+ targetr +west+)))
               (unless (or (eql delta +east+)
                           (eql (grid-ref grid targetl) ’\.))
                 (move! grid targetl delta))
               (unless (or (eql delta +west+)
                           (eql (grid-ref grid targetr) ’\.))
                 (move! grid targetr delta))
               (setf (grid-ref grid targetl) ’[
                     (grid-ref grid targetr) ’])
               (unless (eql delta +east+)
                 (setf (grid-ref grid coord) ’\.))
               (unless (eql delta +west+)
                 (setf (grid-ref grid (2v+ coord +west+)) ’\.))
           targetr))))
    coord))

We need a function to find the initial location of the robot:

(defun find-robot (grid)
  (collect-first
   (choose
    (mapping (((coord item) (scan-grid grid)))
      (when (eql item ’@)
        coord)))))

And we need a function to score the grid.

(defun score-map (grid)
  (collect-sum
   (mapping (((coord item) (scan-grid grid)))
     (if (or (eql item ’O)
             (eql item ’[))
         (+ (* (row coord) 100) (column coord))
         0))))

It isn’t necessary for the solution, but it is helpful when debugging to have a function to print the grid.

(defun show-grid (grid)
  (dotimes (row (grid-height grid))
    (dotimes (column (grid-width grid))
      (format t "~a" (grid-ref grid (coord column row))))
    (format t "~%")))

To solve the puzzle, we use fold-left to drive the robot using the list of moves. This will side-effect the grid. When we are done moving, we score the grid.

(defun puzzle (input-file wide?)
  (multiple-value-bind (grid moves) (read-input input-file wide?)
    (fold-left (lambda (robot move)
                 (move! grid robot move))
               (find-robot grid)
               moves)
    (score-map grid)))

(defun part-1 ()
  (puzzle (input-pathname) nil))

(defun part-2 ()
  (puzzle (input-pathname) t))

Tuesday, February 25, 2025

Advent of Code 2024: Day 14

For day 14, we are given the initial positions and velocities of a number of robots that walk a 101x103 grid. These are parsed with a regular expression:

(in-package "ADVENT2024/DAY14")

(defun read-input (input-file)
  (collect ’list
    (#M(lambda (line)
         (cl-ppcre:register-groups-bind ((#’parse-integer column) (#’parse-integer row)
                                         (#’parse-integer dx) (#’parse-integer dy))
             ("p=(\\d+),(\\d+)\\s+v=(-?\\d+),(-?\\d+)" line)
           (list (coord column row)
                 (coord dx dy))))
       (scan-file input-file #’read-line))))

The robots walk a grid linearly. They do not interact with each other, and they wrap when they cross the edge of the grid.

(defun step-n (width height coord velocity n)
  (2v-mod (2v+ coord (2v* n velocity)) (coord width height)))

For part 1, we are asked to multiply the number of robots in each quadrant after 100 steps in a 101 x 103 grid.

(defun quadrant (width height coord)
  (let ((half-width (floor width 2))
        (half-height (floor height 2)))
    (cond ((< (row coord) half-height)
           (cond ((< (column coord) half-width) 1)
                 ((> (column coord) half-width) 2)
                 (t nil)))
          ((> (row coord) half-height)
           (cond ((< (column coord) half-width) 3)
                 ((> (column coord) half-width) 4)
                 (t nil)))
          (t nil))))

(defparameter +grid-width+ 101)
(defparameter +grid-height+ 103)

(defun part-1 ()
  (let ((quadrants
          (#M(lambda (coord)
               (quadrant +grid-width+ +grid-height+ coord))
             (#M(lambda (robot)
                  (step-n +grid-width+ +grid-height+ (first robot) (second robot) 100))
                (scan 'list (read-input (input-pathname)))))))
    (* (collect-sum (#M(lambda (q) (if (eql q 1) 1 0)) quadrants))
       (collect-sum (#M(lambda (q) (if (eql q 2) 1 0)) quadrants))
       (collect-sum (#M(lambda (q) (if (eql q 3) 1 0)) quadrants))
       (collect-sum (#M(lambda (q) (if (eql q 4) 1 0)) quadrants)))))

We’re told that the robots form a picture of a Christmas tree after a certain number of steps. This was a bit tricky to figure out, but I figured that if the robots were clumped together in a picture, the number of empty rows and columns elsewhere would be maximized. Robots return to their starting point every 101 x 103 steps, so we only need to check the first 10403 steps.

(defun occupied-row? (locs row)
  (find row locs :test #’= :key #’row))

(defun occupied-column? (locs column)
  (find column locs :test #’= :key #’column))

(defun score (locs)
  (+ (collect-length
       (choose-if #’not
         (#Moccupied-row? (series locs) (scan-range :from 0 :below +grid-height+))))
     (collect-length
       (choose-if #’not
         (#Moccupied-column? (series locs) (scan-range :from 0 :below +grid-width+))))))

(defun part-2 ()
  (let ((robots (read-input (input-pathname))))
    (caar
     (sort
      (collect ’list
        (#M(lambda (n)
             (cons n (score (map ’list (lambda (robot)
                                         (step-n +grid-width+ +grid-height+ 
                                                 (first robot) (second robot) n))
                                 robots))))
           (scan-range :from 0 :below (* +grid-width+ +grid-height+))))
      #’> :key #’cdr))))

Monday, February 24, 2025

Advent of Code 2024: Day 13

For day 13, we're asked to find the solutions to some linear equations. We use the cl-ppcre library to parse the equations and then use Cramer’s rule to solve them.

;;; -*- Lisp -*-

(in-package "ADVENT2024/DAY13")

;; Cramers Rule to solve Ax + By = M, Cx + Dy = N
;; x = (MD - BN) / (AD - BC)
;; y = (AN - MC) / (AD - BC)

(defun cramers-rule (A B C D M N)
  (let ((det (- (* A D) (* B C))))
    (if (= det 0)
        nil
        (values (/ (- (* M D) (* B N)) det)
                (/ (- (* A N) (* M C)) det)))))

The input is in blocks of three lines with a fourth blank line between them. We parse the input with a regular expressions and then apply cramers-rule to solve the equations. The conversion factor is a delta that is added to the coordinates of the target.

(defun puzzle (pathname &optional (conversion 0))
  (collect-sum
   (multiple-value-bind (line1 line2 line3 line4)
       (chunk 4 4 (catenate (scan-file pathname #’read-line)
                            (scan ’list ’("")))) ;; extra blank line at end
     (#M(lambda (line1 line2 line3)
          (cl-ppcre:register-groups-bind ((#’parse-integer ax)
                                          (#’parse-integer ay))
              ("Button A: X\\+(\\d+), Y\\+(\\d+)" line1)
            (cl-ppcre:register-groups-bind ((#’parse-integer bx)
                                            (#’parse-integer by))
                ("Button B: X\\+(\\d+), Y\\+(\\d+)" line2)
              (cl-ppcre:register-groups-bind ((#’parse-integer px)
                                              (#’parse-integer py))
                  ("Prize: X\\=(\\d+), Y\\=(\\d+)" line3)
                (multiple-value-bind (x y) (cramers-rule ax bx
                                                         ay by
                                                         (+ px conversion)
                                                         (+ py conversion))
                  (if (and x y (>= x 0) (>= y 0) (integerp x) (integerp y))
                      (+ (* x 3) y)
                      0))))))
        line1 line2 line3))))

(defun part-1 ()
  (puzzle (input-pathname)))

(defun part-2 ()
  (puzzle (input-pathname) (expt 10 13)))

Sunday, February 23, 2025

Advent of Code 2024: Day 12

For day 12, we’re back to a grid of characters. Each character labels a region on the grid.

;;; -*- Lisp -*-

(in-package "ADVENT2024/DAY12")

(defun read-input (pathname)
  (read-file-into-grid
   (char-interner #’identity (find-package "ADVENT2024/DAY12"))
   pathname))

We’re asked to find the area and perimeter of each region and the number of line segments in the perimeter. We use a flood-fill algorithm to find the area. If the flood-fill walks out of the region, we add 1 to the perimeter. The number of line segments in the perimeter is equal to the number of corners of the region, so we check for interior and exterior corners at each step.

(defun neighbors (coord)
   (list (coord-north coord)
         (coord-south coord)
         (coord-east coord)
         (coord-west coord)))

(defun flood-fill (grid visited seed-point)
  (let ((region-tag (grid-ref grid seed-point)))
    (flet ((in-region? (coord)
             (and (on-grid? grid coord)
                  (eql region-tag (grid-ref grid coord)))))
      (let itr ((points (list seed-point))
                (area 0)
                (perimeter 0)
                (corners 0))
        (if (null points)
            (values area perimeter corners)
            (let ((this-point (car points))
                  (rest-points (cdr points)))
              (cond ((not (in-region? this-point))
                     (itr rest-points area (1+ perimeter) corners))
                    ((grid-ref visited this-point)
                     (itr rest-points area perimeter corners))
                    (t
                     (setf (grid-ref visited this-point) t)
                     (itr (append rest-points (neighbors this-point))
                          (1+ area)
                          perimeter
                          (+ corners
                             (let ((n*  (in-region? (coord-north     this-point)))
                                   (ne* (in-region? (coord-northeast this-point)))
                                   (e*  (in-region? (coord-east      this-point)))
                                   (se* (in-region? (coord-southeast this-point)))
                                   (s*  (in-region? (coord-south     this-point)))
                                   (sw* (in-region? (coord-southwest this-point)))
                                   (w*  (in-region? (coord-west      this-point)))
                                   (nw* (in-region? (coord-northwest this-point))))
                               (+ ;; Concave corners
                                  (if (and n* e* (not ne*)) 1 0)
                                  (if (and e* s* (not se*)) 1 0)
                                  (if (and s* w* (not sw*)) 1 0)
                                  (if (and w* n* (not nw*)) 1 0)
                                  ;; Convex corners
                                  (if (and (not n*) (not e*)) 1 0)
                                  (if (and (not e*) (not s*)) 1 0)
                                  (if (and (not s*) (not w*)) 1 0)
                                  (if (and (not w*) (not n*)) 1 0))))))))))))))

To find seed points for regions, we scan the grid for points that are not visited. We rely on the series being pipelined so that the visited array is mutated as we process each region.

(defun scan-unvisited (visited)
  (declare (optimizable-series-function))
  (choose
   (mapping (((coord val) (scan-grid visited)))
     (and (null val) coord))))

To scan the regions, we flood fill the unvisited points.

(defun scan-regions (grid)
  (declare (optimizable-series-function 3))
  (let ((visited (make-array (array-dimensions grid) :initial-element nil)))
    (#3M(lambda (seed) (flood-fill grid visited seed))
     (scan-unvisited visited))))

To solve the puzzle, we scan the regions and apply the score function, summing the results.

(defun puzzle (grid score-function)  
  (collect-sum
   (mapping (((area perimeter segments) (scan-regions grid)))
     (funcall score-function area perimeter segments))))

Part 1 scores by multiplying the area by the perimeter.

(defun part-1 ()
  (puzzle (read-input (input-pathname))
          (lambda (area perimeter segments)
            (declare (ignore segments))
            (* area perimeter))))

Part 2 scores by multiplying the area by the number of segments in the perimeter.

(defun part-2 ()
  (puzzle (read-input (input-pathname))
          (lambda (area perimeter segments)
            (declare (ignore perimeter))
            (* area segments))))

Saturday, February 22, 2025

Advent of Code 2024: Day 11

For day 11, we are given a list of stones inscribed with numbers.

;;; -*- Lisp -*-

(in-package "ADVENT2024/DAY11")

(defun read-input (input-pathname)
  (collect ’list (scan-file input-pathname)))

On each blink (clock tick), we apply some rules to all the stones:

(defun digit-count (stone)
  (1+ (integer-log stone 10)))

(defun split-stone (stone)
  (let ((divisor (expt 10 (/ (digit-count stone) 2))))
    (multiple-value-list (floor stone divisor))))  ;; thanks Charlie McMackin

(defun apply-rules (stone)
  (cond ((zerop stone) (list 1))
        ((evenp (digit-count stone)) (split-stone stone))
        (t (list (* stone 2024)))))

The puzzle goes through the effort to ensure that you know that the stones are kept in order, but the fact is the order doesn’t matter at all. We can just keep track of the total number of stones of each value in a table. After each step, we collect all the stones with the same value and sum them up. Each stone will be represented by a stone-entry cons of the stone value and the number of stones with that value.

(defun coalesce (stone-alist)
  (let ((table (make-hash-table :test ’eql)))
    (dolist (stone-entry stone-alist table)
      (incf (gethash (car stone-entry) table 0) (cdr stone-entry)))))

So on each blink, we iterate over the table and apply the rules. Then we coalesce the results.

(defun blink (stone-table)
  (coalesce
   (multiple-value-bind (stone-values stone-counts) (scan-hash stone-table)
     (collect-append
      (#M(lambda (stone-value stone-count)
           (map ’list
               (lambda (new-stone) (cons new-stone stone-count))
               (apply-rules stone-value)))
         stone-values
         stone-counts)))))

The main loop is to simply call blink n times.

(defun initial-stone-table (initial-stones)
  (coalesce (map ’list (lambda (stone) (cons stone 1)) initial-stones)))

(defun blink-n (initial-stones n)
  (do ((stone-table (initial-stone-table initial-stones) (blink stone-table))
       (generation 0 (1+ generation)))
  ((>= generation n)
   (collect-sum (multiple-value-bind (stones counts) (scan-hash stone-table)
                   counts)))))

(defun part-1 ()
  (blink-n (read-input (input-pathname)) 25))

(defun part-2 ()
  (blink-n (read-input (input-pathname)) 75))

This problem took me a moment until I realized that the order of the stones didn’t matter. If you try to keep the stones in order, you end up with an exponential explosion of stones.


Friday, February 21, 2025

Advent of Code 2024: Day 10

For Day 10, we are given a topographic map as a grid of elevations.

;;; -*- Lisp -*-

(in-package "ADVENT2024/DAY10")

(defun read-grid (input-pathname)
  (read-file-into-grid #'char->decimal input-pathname))

The trailheads are at elevation 0.

(defun find-trailheads (grid)
  (gethash 0 (invert-grid grid)))

At any elevation, we can take a step to a neighboring cell if it is at 1 unit higher elevation.

(defun take-step (grid location)
  (let ((target-elevation (1+ (grid-ref grid location))))
    (collect 'list
      (choose-if (λ (loc)
                   (and (on-grid? grid loc)
                        (= (grid-ref grid loc) target-elevation)))
                 (scan 'list (list
                              (coord-north location)
                              (coord-east  location)
                              (coord-south location)
                              (coord-west  location))))))

The trail-walker is a trail collector that takes a trailhead and does a breadth-first search to find the highest elevations reachable from that trailhead.

(defun trail-walker (grid)
  (λ (trailhead)
    (collect-last
     (scan-fn 'list
              (λ () (list trailhead))
              (λ (frontiers)
                (remove-duplicates
                 (collect-append
                  (#Mtake-step (series grid) (scan frontiers)))
                 :test #'equal))
              #'null)))

A scorer is a curried function that takes a collector, then a grid, then a trailhead, and returns the score for that trailhead, which is the number of collected trails.

(defun scorer (collector)
  (λ (grid)
    (let ((collect-trails (funcall collector grid)))
      (λ (trailhead)
        (length (funcall collect-trails trailhead))))))

The puzzle takes a grid and a scorer, and sums the scores of the trailheads in the grid.

(defun puzzle (grid trailhead-scorer)
  (collect-sum
    (map-fn 'integer
            (funcall trailhead-scorer grid)
            (scan 'list (find-trailheads grid)))))

For the first part of the puzzle, we are to sum the scores of the trailheads using the trail-walker as the scorer. That is, for each trailhead, the number of highest points we can reach.

(defun part-1 ()
  (puzzle (read-grid (input-pathname)) (scorer #'trail-walker)))

For part two, we sum the number of paths to the highest points reachable from each trailhead. When we do the breadth-first search, we keep the history of the path we have taken.

(defun extend-path (grid path)
  (map 'list (λ (step) (cons step path)) (take-step grid (car path))))

(defun trail-collector (grid)
  (λ (trailhead)
    (collect-last
     (scan-fn 'list
              (λ () (list (list trailhead)))
              (λ (paths)
                (collect-append
                 (#Mextend-path
                    (series grid)
                    (scan 'list paths))))
              (λ (paths)
                (every (λ (path)
                         (= (length path) 11))
                       paths)))))

(defun part-2 ()
  (puzzle (read-grid (input-pathname)) (scorer #'trail-collector)))

Thursday, February 20, 2025

Advent of Code 2024: Day 9

On Day 9 we are simulating defragging a disk. The input is a list of digits that alternate between a file size and a gap size. No file or gap is larger than 9 blocks.

The first part of the problem is to simply move the rightmost block that contains a file to the leftmost empty space. We repeat until there are no more empty spaces. Again, we could attempt to shoehorn this into a series approach, but it is straightforward to use an array to represent our disk and use side effects to simulate moving the blocks around.

Whenever you use an array, you’ll virtually always want to side effect the array contents, and you’ll virtually always iterate over the array. As a result, the do macro and its varaints such as dotimes will be useful interation constructs. The code will be definitely not be functional, but imperative in nature. There is just going to be state hanging out everywhere in this code.

The first thing we have to do is read the input:

;;; -*- Lisp -*-

(in-package "ADVENT2024/DAY9")

(defun read-input (input-file)
  (with-open-file (stream input-file :direction :input)
    (read-line stream nil nil)))

(defun read-layout (input-file)
  (let* ((line             (read-input input-file))
         (file-count       (/ (1+ (length line)) 2))
         (files            (make-simple-vector file-count))
         (free-block-count (1- file-count))
         (free-blocks      (make-simple-vector free-block-count)))
    (do ((input-index      0 (1+ input-index))
         (disk-loc         0 (+ disk-loc (char->decimal (schar line input-index))))
         (file-flag        t (not file-flag))
         (file-id          0 (if file-flag
                                 (1+ file-id)
                                 file-id))
         (free-block-index 0 (if file-flag
                                 free-block-index
                                 (1+ free-block-index))))
        ((>= input-index (length line)) (values files free-blocks))
      (if file-flag
          (setf (svref files file-id)
                (cons disk-loc (char->decimal (schar line input-index))))
          (setf (svref free-blocks free-block-index)
                (cons disk-loc (make-simple-vector (char->decimal (schar line input-index))
                                                   :initial-element nil)))))

The main iteration alternates between reading a file block and a free block. The blocks are placed in a file array or a free-block array depending on the file-flag, which toggles on each pass through the loop. The disk-loc is updated with the size of the file or free block. File blocks are just the disk-loc consed to the file size, but free blocks are the disk-loc consed to an empty array that will be filled in later.

We’ll be required to compute a “checksum” of the disk, which will, for each block, the number of the block multplied by the file id of the file in the block. Since the files and the freelist are represented differently, we need a separate routine for each one and we’ll add them at the end.

(defun files-checksum (files)
  (collect-sum
    (#M(λ (file-id)
         (let ((file-record (svref files file-id)))
           (collect-sum
             (#M(λ (i)
                  (* file-id (+ (car file-record) i)))
              (scan-range :below (cdr file-record)))))
        (scan-range :below (length files))))

The outer collect-sum loop iterates over each file summing the checksums. The inner collect-sum iterates over the blocks (stored in the cdr of the file record) in the file summing the product of the file-id and the offset of the block itself.

We handle freelist segments differently. They have an array into which we have moved file blocks, so we iterate over the array and multiply the file-id in the array (if there is one) by the freelist block offset and sum them:

(defun freelist-checksum (freelist)
  (collect-sum
   (#M(λ (freelist-record)
        (let* ((segment-base (car freelist-record))
               (segment (cdr freelist-record)))
         (collect-sum
           (#M(λ (offset-in-segment)
                (let ((file-id (or (svref segment offset-in-segment) 0)))
                  (* file-id (+ segment-base offset-in-segment))))
            (scan-range :below (length segment))))))
    (scan 'vector freelist))))

(defun filesystem-checksum (files freelist)
  (+ (files-checksum files)
     (freelist-checksum freelist)))

The two parts of the day’s puzzle involve two different strategies for “defragging” the disk. For part 1, we repeatedly move the rightmost file block to the leftmost free block. To find the rightmost occupied block, we iterate over the file list from right to left:

(defun source-block (files)
  (do ((file-id (1- (length files)) (1- file-id)))
      ((not (zerop (cdr (svref files file-id))))
       (values (+ (car (svref files file-id)) (cdr (svref files file-id)))
               (svref files file-id)
               file-id)))

To find the leftmost free block, we search the free blocks until we find one that is not fully occupied.

(defun target-block (freelist)
  (let* ((free-segment (find-if (λ (segment)
                                  (position nil (cdr segment)))
                                freelist))
         (offset (position nil (cdr free-segment))))
    (values (+ (car free-segment) offset)
            (cdr free-segment)
            offset))

These aren’t the most efficient because they forget the information from the previous search, but presumably a defragging process would be dominated by the disk reads and writes.

Once we have the source and target blocks, we move the source block to the target block:

(defun move-block! (files freelist)
  (multiple-value-bind (source-block source-record file-id) (source-block files)
    (multiple-value-bind (target-block target-segment target-offset) (target-block freelist)
      (when (< target-block source-block)
        (decf (cdr source-record))
        (setf (svref target-segment target-offset) file-id)
        t))))

We only move blocks to lower addresses and return NIL if we cannot move a block.

To defrag with this strategy, we just repeatedly call move-block! until it returns NIL:

(defun defrag1! (files freelist)
  (when (move-block! files freelist)
    (defrag1! files freelist)))

(defun puzzle (input-pathname defrag)
  (multiple-value-bind (files freelist) (read-layout input-pathname)
    (funcall defrag files freelist)
    (filesystem-checksum files freelist)))

(defun part-1 ()
  (puzzle (input-pathname) #'defrag1!))

Part 2 uses a different strategy to defrag the disk. Instead of blocks, we move entire files, and we move them to the leftmost block that they’ll fit. To find that leftmost block, we scan the freelist:

(defun defrag2-target (file freelist)
  (collect-first
   (choose-if
    (λ (free-record)
      (and (< (car free-record) (car file))
           (<= (cdr file) (count nil (cdr free-record)))))
    (scan 'vector freelist))))

We iterate over the files from right to left:

(defun defrag2! (files freelist)
  (do ((file-id (1- (length files)) (- file-id 1)))
      ((zerop file-id))
    (let* ((file (svef files file-id))
           (target (defrag2-target file freelist)))
      (when target
        (let* ((start (position nil (cdr target)))
               (end (+ start (cdr file))))
          (fill (cdr target) file-id :start start :end end)
          (setf (cdr file) 0)))))

(defun part-2 ()
  (puzzle (input-pathname) #'defrag2!))