Skip to content

caseneuve/aoc2024

Repository files navigation

Advent of Code 2024: Notes

Day 1

Part 1

I’m particularly fond of using threading macros in Clojure, as they make it easy to follow and express input transformations step by step. It’s interesting how this preference can sometimes lead to tunnel vision, preventing me from noticing simpler solutions.

For example, consider this implementation:

(->> lists
     (map sort)
     (apply map list)
     (reduce (fn [s p] (+ s (abs (apply - p)))) 0))

Here, there’s a transposing step to create a list of pairs from two lists ((apply map list)), but it’s somewhat redundant since the operations in reduce can be performed directly without transposing:

(->> lists
     (map sort)
     (apply #(mapv (comp abs -) %1 %2))
     (apply +))

Finally, stepping away from the threading mindset and flipping the logic upside down reduces this to a one-liner:

(reduce + (mapv (comp abs -) (map sort lists)))

Now – I like it because it’s consise and I already know the logic, but is it more readable?

Part 2

I completely forgot about frequencies – honestly, I was surprised such an obvious utility hadn’t already been implemented… Naturally, this:

(->> right (reduce (fn [m e] (update m e (fnil inc 0))) {}))

can be replaced with the much simpler:

(frequencies right)

and then redundant threading becomes:

(reduce (fn [s n] (+ (* n (fqs n 0)) s)) 0 left)

Initial 0 in reduce can be eliminated by using transducer that maps over the first list:

(transduce (map #(abs (* % (fqs % 0)))) + left)

Once again, I like the transducer version when I see it – no need for a redundant initial step, and no need for an explicit fn declaration. That said, “transductions” still bend my mind a bit!

P.S. I’m still glad I wrote my version of frequencies. It gave me the opportunity to discover fnil which combined with update, provides functionality similar to Python’s defaultdict: (update MAP key (fnil inc 0)).

Day 3

In my first approach, I used a straightforward loop:

(loop [[op & xs] ops, enabled true, sum 0]
  (if (nil? op) sum
      (condp = op
        "do()"    (recur xs true sum)
        "don't()" (recur xs false sum)
                  (recur xs enabled (cond-> sum (or enabled (= part 1)) (+ (mul op)))))))

Here, ops is a list of all occurrences of “muls”, “dos”, and “don’ts”. It’s still quite verbose with three recur calls. The mul function computes the product of integers extracted from the matching strings.

Wanting a more concise solution, I initially tried to write smarter regexes. However, this quickly led to the classic “now you have two problems” scenario. As it turns out, the simpler solution wasn’t about designing a better regex to catch what I needed but to remove irrelevant parts of the input using one.

So the trick fo part 2 is (str/replace s #"(?s)don't\(\).*?(do\(\)|\Z)" "").

Here’s how it works:

  • (?s) enables dot-matches-all mode, allowing . to match any character, including newlines (\n).
  • .*? matches zero or more of any character, non-greedily.
  • (do\(\)|\Z) matches do() or the end of input (\Z).

Using this cleanup step, the revised solution looks like this:

(defn clear-donts [s] (str/replace s #"(?s)don't\(\).*?(do\(\)|\Z)" ""))

(defn solve [input part]
  (let [ops (re-seq #"mul\(\d+,\d+\)" (cond-> input (= part 2) clear-donts))
        mul #(apply * (map read-string (re-seq #"\d+" %)))]
    (->> ops (map mul) (apply +))))

Day 4

For now I keep ugly notes with a working solution… Would like to refactor the diag(onal) transposition function and use it in the part 2, along with juxt.

Day 5

That was fun! I got to a working solution pretty quickly with loops and checking pages ordering by using set/intersection of rules for given page and pages preceding it. But then, after staring at both parts for a while, I’ve just realized that the only thing I need is actually the simple comparator used to sort the pages #(not (contains? (RUL %2) %1)) – by comparing sorted pages to original pages I know which should go to which part of the solution, the rest is simple reduction.

Day 18

Dijkstra is faster here (0.1 sec vs 0.15 using only bfs for both parts), but after all I decided to refactor using only bfs for simplicity. Also it looks like second part can be brute-forced, but due to some typos in my initial implementation I thought it would be running forever, hence implemented binary search…

(defn shortest-path [bytes [X Y :as exit] cut]
  (let [BYT (set (take cut bytes))]
    (loop [Q (priority-map [[0 0] #{[0 0]}] 0), best Integer/MAX_VALUE, score-map {}]
      (if (empty? Q) (dec best)
          (let [[[pos seen] _] (peek Q)]
            (if (= pos exit) (recur (pop Q), (min (count seen) best), score-map)
                (let [[mvs scs]
                      (reduce
                       (fn [[mv bs] [x y :as pos]]
                         (let [s (count seen)]
                           (if (and (<= 0 x X) (<= 0 y Y)
                                    (not (contains? BYT pos)) (not (contains? seen pos))
                                    (< s (score-map pos Integer/MAX_VALUE)))
                             [(conj mv [[pos (conj seen pos)] s]) (assoc bs pos s)]
                             [mv bs])))
                       [[] score-map] (t/moves pos))])))))))

Day 19

Another memo day. I got an over-complicated solution for part one (priority maps fixation after previous days, I guess…) – it was giving the right answer, however the run was not finishing due all possible combinations:

(fn [init]
  (loop [Q (into (priority-map) (map (fn [n] [[(subs init 0 n) 0] (- (count init) n)]) pos)),
         steps 0]
    (cond
      (= steps 120) false ; 120 was enough for me, but this should've been
                          ; an indicator than I should be looking for something better...
      (empty? Q) false
      :else
      (let [[[s idx] left] (peek Q)
            c (count s)]
        (if (contains? (px c) s)
          (if (zero? left) true
              (recur
               (into (pop Q)
                     (map (fn [n]
                            [[(subs init (+ idx c) (+ idx c n)) (+ idx c)]
                             (- left n)])
                          (remove #(> % left) pos))),
               (inc steps)))
          (recur (pop Q) (inc steps)))))))

Obviously it wasn’t working for part 2. Wanted to refactor the above to use “keep state” map, but it was, again, over complicated. Finally started from scratch with memoize and more elegant string processing using starts-with? instead of a set lookup for a slice. Code for part 2 was basically the same (instead of getting “some”, i. e. any, we need to get “all”), I used the final version to solve both parts.

(def p1
  (memoize
   (fn [cur pats]
     (if (empty? cur) 1
         (some
          (fn [s]
            (when (starts-with? cur s)
              (p1 (subs cur (count s)) pats))) pats)))))

About

Advent of Code 2024 with babashka

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published