Skip to content

Commit c24ac14

Browse files
committed
Day 18 documentation
1 parent 0a9fa1b commit c24ac14

File tree

5 files changed

+649
-0
lines changed

5 files changed

+649
-0
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ _Warning:_ I write long explanations. So... yeah.
2727
| 15 | [source](src/advent_2020_clojure/day15.clj) | [blog](docs/day15.md) |
2828
| 16 | [source](src/advent_2020_clojure/day16.clj) | [blog](docs/day16.md) |
2929
| 17 | [source](src/advent_2020_clojure/day17.clj) | [blog](docs/day17.md) |
30+
| 18 | [source](src/advent_2020_clojure/day18.clj) | [blog](docs/day18.md) |

docs/day18.md

+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# Day Eighteen: Operation Order
2+
3+
* [Problem statement](https://adventofcode.com/2020/day/18)
4+
* [Solution code](https://github.com/abyala/advent-2020-clojure/blob/master/src/advent_2020_clojure/day18.clj)
5+
6+
---
7+
8+
I'm going to declare right now that I had a ton of fun with this problem. As is becoming the case, it's
9+
a little hard to talk about Part 1 without also looking at Part 2, since I tend to refactor the code so
10+
much to be reusable between both parts. And since this is my blog post, I make the rules!
11+
12+
The problem states that we are given several lines of text, each representing an arithmetic equation we need
13+
to solve. The operations are `+`, `*`, and parentheses, but the order of operations is all wacky. While
14+
parentheses always take priority, in part one we apply addition or multiplication based strictly on the
15+
left-to-right ordering, and in part two we prioritize addition over multiplication. My Dear Aunt Sally feels
16+
an excuse is quite in order!
17+
18+
## Planning ahead
19+
20+
Let's start with the end in mind. We know that parts 1 and 2 differ only in how we prioritize which operation
21+
to perform at a time, so let's figure that out. I know I'm going to handle the parentheses especially, so I
22+
think at some point I'm going to have an expression without parentheses, and I'll make a pass through the
23+
data to decide whether or not to apply any given operation. So that means I need to look at each mathematical
24+
line as a sequence of tokens, and I need to decide whether to take action on any single token.
25+
26+
Let's parse the data. I want to take a String like `1 + (2 * (3 * 4) + 5)` and see it as a simple vector of
27+
values. The parentheses are always next to a number, but everything else is space delimited. For the numeric
28+
strings, let's turn them into `BigInteger`s right away, since we'll get overflow if we use `Long`s. The regex
29+
is a little ugly, but it's not bad. If I put in artifical spaces, you can see it's essentially one big `or`,
30+
and `re-seq` returns a sequence of tokens that match the regex. The `mapv` only attempts to parse values
31+
that aren't symbols; in theory, I could have done a try-catch parse for each token instead.
32+
33+
I won't spend any time going over [Clojure-Java interop](https://clojure.org/reference/java_interop), but
34+
suffice it to say that `(BigInteger. "123")`, with that extra period, calls the constructor of `BigInteger`
35+
with the parameter `"123"`.
36+
37+
```clojure
38+
(defn parse-mathematical-string [line]
39+
(->> (re-seq #"\d+|\(|\)|\+|\*" line)
40+
(mapv #(if (#{"+" "*" "(" ")"} %) % (BigInteger. %)))))
41+
```
42+
43+
Now let's zip to the very end. I'm going to have a `solve` function that takes in my data file and the
44+
ordered operations for non-parenthetical vectors. It's going to parse each line, run some calculation
45+
with those operations, and then add the results together. Since we're dealing with `BigIntegers`, we
46+
can't use `(apply + values)` and instead have to `reduce` the result using the Java method `.add`.
47+
48+
```clojure
49+
(defn solve [input operations]
50+
(->> (str/split-lines input)
51+
(map parse-mathematical-string)
52+
(map #(calculate % operations))
53+
(reduce #(.add %1 %2))))
54+
55+
; Helper functions for order-of-operations
56+
(def only-add (partial = "+"))
57+
(def only-mul (partial = "*"))
58+
(def add-or-mul (partial #{"+" "*"}))
59+
60+
(defn part1 [input] (solve input [add-or-mul]))
61+
(defn part2 [input] (solve input [only-add only-mul]))
62+
```
63+
64+
Now that we have an idea of the overall structure, we can fill in the details.
65+
66+
## Calculation functions
67+
68+
Now that I'm getting more comfortable with Clojure, I'm trying to focus more on business-looking
69+
functions. The `calculate` function should take in the vector of tokens and the sequence of
70+
operations, and return the `BigInteger` result. When looking at a list of tokens, we should apply
71+
a simple priority. If there's only one value, return it. If there are parentheses, handle them.
72+
Otherwise, do "ordered-arithmetic" with the operation filters that were passed in.
73+
74+
```clojure
75+
(defn calculate [tokens operations]
76+
(or (unpack-simple-expression tokens)
77+
(apply-parentheses tokens operations)
78+
(ordered-arithmatic tokens operations)))
79+
```
80+
81+
The `unpack-simple-expression` function is, well, simple. If there's only one token, return it.
82+
Remember that `when` will return `nil` if its predicate is false, and the `or` in `calculate`
83+
will return the first non-`nil` value.
84+
85+
```clojure
86+
(defn unpack-simple-expression [tokens]
87+
(when (= 1 (count tokens)) (first tokens)))
88+
```
89+
90+
Next I want to get to `apply-parentheses`, but first we need to helper functions. We are going
91+
to need Java's `indexOf` and `lastIndexOf` functions, but they return `-1` if the value is not found,
92+
and magic constants are evil. So I'll start with an `index-of-or-nil` to make this easier to read
93+
and use in Clojure. Second, I need a function that takes in a vector of tokens, removes several
94+
of them, and replaces them with a new value, so `replace-subvec` will handle that for us.
95+
96+
```clojure
97+
(defn index-of-or-nil [coll v]
98+
(let [idx (.indexOf coll v)]
99+
(when (>= idx 0) idx)))
100+
101+
(defn replace-subvec [v low high new-value]
102+
(apply conj (subvec v 0 low) new-value (subvec v high)))
103+
```
104+
105+
Ok, let's handle `apply-parentheses`. The easiest way to handle the nesting is to find the
106+
_first_ closed paren, and the _last_ open paren before it, because that will represent the
107+
range of an expression without additional parentheses. `when-let` will only process if we
108+
find an index for `close-paren`, and if it's there, we can assume we'll find one for
109+
`open-paren`. Then we extract the subvector between these two indices and call `calculate`
110+
on it. Armed with that simplification, we call `replace-subvec` to create our new vector
111+
of tokens, and we call back to `calculate` with the new simplified vector.
112+
113+
One thing to note is that I've set up a mutual dependency between the functions `calculate`
114+
and `apply-parentheses`. My initial one-function-to-rule-them-all solution didn't have this,
115+
but I'm making little functions. Clojure requires function definitions to appear in order,
116+
so we need to use `declare` to create a definition for `calculate` that `apply-parentheses`
117+
can hook into, before we define it later in the file. It's a little ugly, but at least it
118+
brings to light something that generally we don't want, so maybe that's a hidden benefit
119+
of this limitation!
120+
121+
```clojure
122+
; Forward declaration, needed for mutual dependent functions.
123+
(declare calculate)
124+
125+
(defn apply-parentheses [tokens operations]
126+
(when-let [close-paren (index-of-or-nil tokens ")")]
127+
(let [open-paren (.lastIndexOf (subvec tokens 0 close-paren) "(")
128+
new-value (-> (subvec tokens (inc open-paren) close-paren)
129+
(calculate operations))]
130+
(-> (replace-subvec tokens open-paren (inc close-paren) new-value)
131+
(calculate operations)))))
132+
```
133+
134+
At this point, we've handled parentheses and identity, so it's time to handle addition and
135+
subtraction. Let's make a little function called `simple-math` that takes in a vector and
136+
the index of the operation, and it returns a reduced vector having applied the operation
137+
to the values on either side. Nothing fancy here.
138+
139+
```clojure
140+
(defn simple-math [tokens idx]
141+
(let [[op tok-a tok-b] (map #(tokens (+ idx %)) [0 -1 1])
142+
new-val (case op
143+
"*" (.multiply tok-a tok-b)
144+
"+" (.add tok-a tok-b))]
145+
(replace-subvec tokens (dec idx) (+ idx 2) new-val)))
146+
```
147+
148+
Then the last piece to handle is the so-called "ordered arithmetic." Remember that the
149+
operations from part 1 will be `[add-or-mul]` while part 2 will be `[only-add only-mul]`,
150+
so we've got a vector of unary predicates. I'll implement this as a recursive function,
151+
since we need to look at all of the operations, and all of the tokens. Each time through,
152+
we'll short-circuit if this is a simple expression with only one value. If not, see if we
153+
can find the index of a token that passes the current predicate. If so, call `simple-math`
154+
and recursively call this function with the new value. Otherwise, drop the current operation
155+
and recursively call back in with the next one. Note that we assume the expressions are all
156+
valid, so we don't have to worry about running out of operations.
157+
158+
```clojure
159+
(defn ordered-arithmetic [tokens [op & other-ops :as operations]]
160+
(or (unpack-simple-expression tokens)
161+
(when-let [idx (->> tokens
162+
(keep-indexed (fn [idx tok] (when (op tok) idx)))
163+
first)]
164+
(-> (simple-math tokens idx)
165+
(ordered-arithmetic operations)))
166+
(ordered-arithmetic tokens other-ops)))
167+
```
168+
169+
So yeah, I had a ton of fun with this problem!

0 commit comments

Comments
 (0)