AoC 2024

My solutions for Advent of Code 2024

Author

Rens Oliemans

Welcome to my solution page for Advent of Code 2024. You can find the git repo here.

Day 1: Historian Hysteria

Part 1

We have a list of pairs of numbers, and we need to pair the smallest left number with the smallest right number, second-smallest left with second-smallest right, etc. Idea: transpose, sort both lists, transpose again, and compute.

(defn part1 [input]
  (->> (s/parse-lines input)
       (map s/parse-ints)
       v/transpose
       (map sort)
       v/transpose
       (map #(abs (- (first %) (second %))))
       (reduce +)))

Part 2

This is easy with the fn frequencies:

(defn part2 [input]
  (let [input (v/transpose (map s/parse-ints (s/parse-lines input)))
        one (first input)
        freqs (frequencies (second input))]
    (->> one
         (map #(* % (freqs % 0)))
         (reduce +))))

Day 2: Red-Nosed Reports

Part 1

(partition 2 1) finds all pairs, and then we can just compute the difference and return whether each difference is between 1 and 3 (inclusive), and each difference has the same parity.

(defn diffs [record]
  (->> record
       (partition 2 1)
       (mapv (fn [[a b]] (- b a)))))

(defn is-safe? [record]
  (let [differences (diffs record)]
    (and (every? #(<= 1 (abs %) 3) differences)
         (apply = (map pos? differences)))))

(defn part1 [input]
  (->> (s/parse-lines input s/parse-ints)
       (filter is-safe?)
       (count)))

Part 2

Remove each level one by one and check if the record is safe then. Not the most efficient, but simple and fast enough (~50msec):

(defn drop-nth [coll n]
  (keep-indexed #(if (not= %1 n) %2) coll))

(defn dampened-is-safe? [record]
  (some is-safe? (map #(drop-nth record %)
                      (range (count record)))))

(defn part2 [input]
  (->> (s/parse-lines input s/parse-ints)
       (filter dampened-is-safe?)
       (count)))

Day 3: Mull It Over

Part 1

regex on "mul\((\d+),(\d+)\)", multiply each result and sum.

(defn part1 [input]
  (->> (re-seq #"mul\((\d+),(\d+)\)" input)
       (map (fn [[_ a b]] (* (Long/parseLong a)
                             (Long/parseLong b))))
       (reduce +)))

Part 2

do() enables instructions, don't() disables them. I started out doing a more complicated loop where I keep track of the current state (enabled/disabled). Instead, we just remove all text in between don't() and do() and re-run part one on this smaller input. In order to replace everything, don’t forget to remove the newlines as well, as some don't()/do() pairs have newlines between them.

(defn part2 [input]
  (let [inp (-> input
                (str/replace #"\n" "")
                (str/replace #"don't\(\).+?do\(\)" ""))]
    (part1 inp)))

Day 5: Print Queue

Part 1

We can think of the input as a directed graph, where 29|13 leads to a vertex from 29 to 13. We parse the input to obtain the orderings, updates and a dependency graph. Our solution requires us to find the middle number of all updates that are correctly-ordered. Here’s the parsing and the shell of part 1:

(defn parse-input
  "Parses an input string and returns three useful objects.
    The first obj is a list of orderings, strings of type \"A|B\".
    The second obj is a list of updates, each one a list of strings.
    The third obj is a dependency graph, a map."
  [input]
  (let [[orderings updates] (->> (s/parse-blocks input) (map s/parse-lines))]
    [orderings
     (map #(str/split % #",") updates)
     (build-dependency-graph orderings)]))

(defn part1 [input]
  (let [[orderings updates dep-graph] (parse-input input)
        dep-sorted? (partial dep-sorted? dep-graph)]
    (->> updates
         (pmap #(list % (dep-sorted? %)))
         (filter last)
         (pmap first)
         (pmap middle-num)
         (reduce +))))

The above code relies on three functions not yet defined: middle-num, dep-sorted? and build-dependency-graph. build-dependency-graph and middle-num are relatively straightforward:

(defn build-dependency-graph
  [orderings]
  (let [order-pairs (->> orderings
                         (map #(str/split % #"\|"))
                         (map #(hash-map (second %), [(first %)])))]
    (apply (partial merge-with into) order-pairs)))

(defn middle-num
  "Finds the middle string in a list of string, and parse it to a
  number. Assumes the length of the list list is odd."
  [update]
  (read-string (nth update (/ (count update) 2))))

And the meat is in dep-sorted?, which tells us wheter an update is sorted, using a dependency graph as obtained from build-dependency-graph. It’s 2025 at the time of writing and I forgot how it works, figuring this out is left as an exercise for the reader.

(defn dep-sort
  "Sort a list of strings based on a dependency map.
  The map defines which elements should come after others."
  [dep-graph update]
  (let [graph (reduce (fn [acc item]
                        (assoc acc item 
                               (set (get dep-graph item []))))
                      {} update)
        local-deps (fn [deps] (filter #(contains? (set update) %) deps))]
    (vec (sort-by (fn [item]
                    (let [deps (get dep-graph item [])]
                      (count (local-deps deps))))
                  update))))

(defn dep-sorted? [dependency-graph update]
  (= update (dep-sort dependency-graph update)))

Part 2

This is now trivial, since we can now easily sort everything, filter the unsorted updates, and do the same computation:

(defn part2 [input]
  (let [[orderings updates deps] (parse-input input)
        is-sorted? (partial dep-sorted? deps)
        dep-sort (partial dep-sort deps)]
    (->> updates
         (pmap #(list % (dep-sort %)))
         (filter #(not= (first %) (last %)))
         (pmap last)
         (pmap middle-num)
         (reduce +))))

Day 6: Guard Gallivant

Part 1

The guard walks across the grid until they encounter an obstacle (#), marking a turn in clockwise direction. Simply walk out that path until (get-in grid next-location) is nil:

(defn guard-route
  "Takes a `grid` as input returns a vector of 2d coordinates: the route
  of the guard, starting at `start` and turning clockwise at \"#\"
  characters. "
  [grid start]
  (let [size (count grid)
        grid (assoc-in grid start \.)]
    (loop [location start
           directions (cycle v/cardinal-dirs)
           route []]
      (let [[y x] location
            [x' y'] (first directions)
            next-location [(+ y y') (+ x x')]
            next-object (get-in grid next-location)
            route (conj route location)]
        (condp = next-object
          nil route
          \. (recur next-location
                    directions
                    route)
          \# (recur location
                    (next directions)
                    route))))))

(defn part1 [input]
  (let [grid (g/to-matrix input)
        start (first (g/locs-where grid #(= % \^)))
        route (guard-route grid start)]
    (count (distinct route))))

Part 2

For each point in the route (that isn’t the starting position), add an obstacle and check whether the route has a loop:

(defn route-has-loop? [grid start]
  (let [size (count grid)
        grid (assoc-in grid start \.)]
    (loop [location start
           directions (cycle v/cardinal-dirs)
           visited #{}]
      (let [[y x] location
            [x' y'] (first directions)
            next-location [(+ y y') (+ x x')]
            next-object (get-in grid next-location)
            pair [next-location [x' y']]]
        (if (contains? visited pair)
          true ;; we have a loop!
          (condp = next-object
            nil false ;; we exited the puzzle
            \. (recur next-location
                      directions
                      visited)
            \# (recur location
                      (next directions)
                      (conj visited pair))))))))

(defn part2 [input]
  (let [grid (g/to-matrix input)
        start (first (g/locs-where grid #(= % \^)))
        route (disj (set (guard-route grid start)) start)]
    (->> route
         (pmap (fn [new-obstacle]
                 (route-has-loop? (assoc-in grid new-obstacle \#) start)))
         (filter true?)
         (count))))

Timing of different methods

The following table shows a short overview of the results of (time (p2 input)) (it’s too slow for (crit/quick-bench)) with some different variants:

Method time
Set - always add & check 9s
Set - only add on obstacle 6s
Set - only check on north 5.5
Counter (7000) 4s
  1. set methods.

    These methods refer to keeping track of a set of visited (node, direction) pairs. If we’ve seen one before, we must be in a loop. My original implementation was Set - always add & check: add every location we visit to the visited set and check if we’ve seen it before. That turned out to be the slowest one—my code spent about 10% of its time hashing entries. One step faster is Set - only add on obstacle, which only adds an element to the set when we visit an obstacle.

    The fastest set-related method (though only slightly) was Set - only check on north, and this only checks if the current (node, direction)-pair is in visited if direction = North=. This was counterintuitive for me since that meant it was actually doing some extra work: it might be traversing the current path for up to 3 extra obstacles compared to the previous one. However, the hashing was apparently so expensive compared to traversing the grid that this was still a hair faster.

    Since this was only slightly faster but made the code a bit convoluted and difficult to understand (“why are we only checking if we’ve been here if we’re heading North right now?”), I opted for the second method.

  2. Counter.

    This is kind of a hack, but it’s faster than the set-methods. Instead of a visited set, we keep track of a counter of nodes we’ve visited. Once we reach 7000, we assume we’re in a loop and exit. 6500 also worked for me, but that might be too input-dependent.

    Still, any arbitrary number fails for some input, so I’ve opted to not do this.

Day 7: Bridge Repair

Part 1

This is an example line: 3267: 81 40 27. Let \(t = 3267\) be the target, and \(n_1 = 81, n_2 = 40, n_3 = 27\) be the “numbers” that should make up \(t\). Each equation has \(k\) numbers, \(k=3\) for this example.

Let’s define a function is-correct?, which will output true if \(t\) can be “made” with \(n_1, \dots, n_k\) and false otherwise.

Since the compution is LTR, \(n_k\) will always be applied last. If the operator is multiplication, for example, \(t\) must be divisible by \(n_k\). If it is not, the multiplication operator is not valid for that spot. If it is, we recursively check is-correct? with a new \(t' = t / n_k\), and the numbers \(n_1, n_2, \dots, n_{k-1}\).

At a certain point we might end up with \(k=1\), the base case for is-correct?. is-correct? will output true if and only if \(t=k\).

We must first define for each operator a reverse and valid?:

(defn- operators-1 []
  [{:name :multiplication :reverse #(/ %1 %2) :valid? #(= 0 (mod %1 %2))}
   {:name :addition :reverse #(- %1 %2) :valid? #(> %1 %2)}])

And here is our recursive is-correct?:

(defn- is-correct?
  "Can `target` be made with `numbers`, using `operators`?"
  [target numbers operators]
  (if (= 1 (count numbers))
    (= target (first numbers))
    (->> operators
         (filter (fn [op] ((:valid? op) target (last numbers))))
         (some (fn [op]
                 (is-correct?
                  ((:reverse op) target (last numbers))
                  (butlast numbers)
                  operators))))))

(defn- sum-correct-equations [input operators]
  (->> (s/parse-lines input)
       (map s/parse-ints)
       (filter (fn [[target & numbers]] (is-correct? target numbers operators)))
       (map first)
       (reduce +)))

(defn part1 [input]
  (let [ops (operators-1)]
    (sum-correct-equations input ops)))

Part 2

Part two is effectively identical, but now with the concatenation operator:

(defn- operators-2 []
  (into (operators-1)
        [{:name :concatenation
          :reverse (fn [n x]
                     (let [s (str n)]
                       (Long/parseLong (subs s 0 (- (count s) (count (str x)))))))
          :valid? (fn [n x]
                    (let [sn (str n)
                          sx (str x)]
                      (and (> (count sn) (count sx))
                           (str/ends-with? sn sx))))}]))

(defn part2 [input]
  (let [ops (operators-2)]
    (sum-correct-equations input ops)))

My previous version ran over three seconds, but this runs in 50msec.