Skip to content

Commit f98790a

Browse files
committed
Day 8 extracted to game_console, and documentation added.
1 parent 4fee94f commit f98790a

File tree

5 files changed

+269
-58
lines changed

5 files changed

+269
-58
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ _Warning:_ I write long explanations. So... yeah.
1717
| 5 | [source](src/advent_2020_clojure/day05.clj) | [blog](docs/day05.md) |
1818
| 6 | [source](src/advent_2020_clojure/day06.clj) | [blog](docs/day06.md) |
1919
| 7 | [source](src/advent_2020_clojure/day07.clj) | [blog](docs/day07.md) |
20+
| 8 | [source](src/advent_2020_clojure/day08.clj) | [blog](docs/day08.md) |

docs/day08.md

+191
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# Day Eight: Handheld Halting
2+
3+
* [Problem statement](https://adventofcode.com/2020/day/8)
4+
* [Solution code](https://github.com/abyala/advent-2020-clojure/blob/master/src/advent_2020_clojure/day08.clj)
5+
6+
---
7+
8+
## Background
9+
10+
We knew this was coming -- the Advent Of Code first puzzle where we start building a computer.
11+
I have no doubt that I'll be reusing this code in future versions, so I tried to start
12+
building in a little code reuse, while at the same time realizing that this document itself
13+
will not represent what the code eventually looks like.
14+
15+
We're given a set of instructions of a program that's currently infinite looping, and we
16+
need to show the value of the accumulator when the first instruction is hit twice. I'm
17+
going to build out a `game-console` namespace to represent the program, and then we'll
18+
work on the looping logic in the `day08` namespace. Hopefully that's the right level of
19+
separation.
20+
21+
---
22+
23+
### Game Console
24+
25+
First, let's represent a game console. I'll define this as a map with three elements -
26+
the instruction offset of `:offset`, the accumulated value of `:acc`, and the current
27+
instruction set of `:instructions`. I fully expect future versions will both provide a
28+
different set of instructions to apply, as well as having programs that update their own
29+
instructions, but we'll deal with them if/when they happen.
30+
31+
```clojure
32+
(defn init-console [instructions]
33+
{:offset 0
34+
:acc 0
35+
:instructions instructions})
36+
```
37+
38+
I'll also add a little helper function to move the offset, since these projects tend to
39+
mess around with how many instructions to jump. I don't think I've used overloaded functions
40+
yet this year, so the `move-offset` will either take in a state and the number of steps to
41+
advance the offset, or just the state with an implied `1` to move. There is a Clojure
42+
syntax for providing default argument values, but I find it to be super ugly to read.
43+
44+
```clojure
45+
(defn move-offset
46+
([state] (move-offset state 1))
47+
([state n] (update state :offset + n)))
48+
```
49+
50+
Now come my operations, which for the time being are tiny instructions. I thought of
51+
inlining these, but again I expect a future game console to support different operations.
52+
For the same reason, I didn't use a multi-method. So `op-nop` just advances the offset,
53+
`op-acc` increments the accumulator by the argument amount and moves the offset, and
54+
`op-jmp` moves the offset by the amount of the argument.
55+
56+
```clojure
57+
(defn op-nop [state & _] (move-offset state))
58+
(defn op-acc [state arg]
59+
(-> state
60+
(update :acc + (Integer/parseInt arg))
61+
move-offset))
62+
(defn op-jmp [state arg]
63+
(move-offset state (Integer/parseInt arg)))
64+
```
65+
66+
Then I want a function that takes in a state and runs a single operations, which we'll
67+
call `run-next-op`. Again, I assume that the map of operation name to its function will
68+
change in the future, so eventually I expect to parameterize the map of functions. One
69+
thing that's interesting here is the expression `(nth instructions offset nil)`. This will
70+
grab the `offset`th value out of the collection of instructions. If no such value exists,
71+
instead of throwing an error, it will just return `nil` from the final argument. Then
72+
the `when-let` says that if there's no instruction at that position, just return a `nil`
73+
for the entire function, since there is no next state. This is a concise way to avoid
74+
a lot of typing for boundary checks.
75+
76+
```clojure
77+
(defn run-next-op [{:keys [instructions offset] :as state}]
78+
(when-let [[ins arg] (nth instructions offset nil)]
79+
(let [op ({"nop" op-nop
80+
"acc" op-acc
81+
"jmp" op-jmp} ins)]
82+
(op state arg))))
83+
```
84+
85+
---
86+
### Part 1
87+
88+
Now on to the first actual challenge with using the game console. We know that the program
89+
is going to loop, so we want to capture the state when it first hits an offset it's already
90+
processed.
91+
92+
The first part is to implement my parser. There's every reason to think this will move
93+
into the `game-console` namespace next time, but I don't want to assume the input data just
94+
yet. So that shouldn't be anything surprising. One important point is that we use `mapv`
95+
instead of `map`, since a sequence of arguments in a command have an important order, and
96+
indexing them seems reasonable.
97+
98+
```clojure
99+
(defn parse-input [input]
100+
(game/init-console (->> (str/split-lines input)
101+
(mapv #(str/split % #" ")))))
102+
```
103+
104+
Now we're going to implement a `run-to-completion` function to give us that final
105+
state. Now looking ahead to part 2, I already know that we need to be able to show the
106+
conclusion of _either_ a looping execution or one that runs to completion, where there is
107+
no next state, so this function will return the completion of the run as well as how it
108+
completed. Becuase there are two possible exit conditions, this function will return
109+
a map of `{:status <:terminated or :loop>, :state <state>}`.
110+
111+
My first solution attempted to use the `iterate` function again instead of `loop-recur`,
112+
but a loop is much more natural to work with and read. So what we do is loop over the
113+
current state `s` and a set of all offsets we've seen so far as `seen`. Then in the loop,
114+
we call `run-next-op` to see what the next state is, and then we make a decision. If the
115+
next state is `nil`, then the program terminated, so we return the _previous_ state.
116+
If we've already seen the next state's offset, then we've looped, so return the state of
117+
the _next_ state. Otherwise, the program is still running, so continue the loop.
118+
119+
```clojure
120+
(defn run-to-completion [state]
121+
(loop [s state seen #{}]
122+
(let [{:keys [offset] :as next-state} (game/run-next-op s)]
123+
(cond
124+
(nil? next-state) {:status :terminated, :state s}
125+
(seen offset) {:status :loop, :state next-state}
126+
:else (recur next-state (conj seen offset))))))
127+
```
128+
129+
Finally, all we need to do is parse the input, run it to completion, and then extract
130+
out the final accumulator.
131+
132+
```clojure
133+
(defn part1 [input]
134+
(-> (parse-input input)
135+
run-to-completion
136+
(get-in [:state :acc])))
137+
```
138+
139+
---
140+
141+
## Part 2
142+
143+
Now we need to figure out the final accumulator value when we mutate the input such that
144+
it terminates. We don't know which line needs to change, but either a `jmp` must become
145+
a `nop`, or the reverse is true.
146+
147+
First, let's look at all possible permutations we can make to the instructions, using the
148+
`alternate-instructions` function. Looks at each instruction, and sees if there's a mapping of
149+
its operation, from `op` to `next-op`. If so, update the operation at that index.
150+
151+
There are several cool parts to this function.
152+
1. First, remember that `keep` applies a function to a collection, filtering out the `nil` mappings.
153+
`keep-indexed` does the same, but the function takes in both the index and the collection value.
154+
2. Second, look at the cool destructuring of the `keep-indexed` function. The function takes in the
155+
index and the instruction, but we only care about the first value of that instruction. So the clause
156+
`[idx [op]]` says to bind `idx` to the first argument, but to treat the second argument as a sequence,
157+
and to bind `op` to the first element of the second argument.
158+
3. I mentioned this in the Day 4 problem, but the `when-let` function binds `next-op` to the expression
159+
`({"jmp" "nop" "nop" "jmp"} op)` if it's not `nil`. This expression takes in a map that binds `jmp` to
160+
`nop` and vice versa, and applying that map to the given operation will return the mapping or `nil` if
161+
it doesn't match. This is a concise way to see if a value has a mapping, check for `nil`, and bind it
162+
all at the same time.
163+
4. This is my first time using `assoc-in` this year. The func takes in a map, here `instructions`, a
164+
path to an element to set, and the new value. This is a great shorthand for
165+
`(assoc (get idx instructions) 0 next-op)`. `update-in` does the same thing, except that the last
166+
argument is the function to apply to the value already at that path, instead of `assoc-in` which
167+
just takes in the new value.
168+
169+
```clojure
170+
(defn alternate-instructions [instructions]
171+
(keep-indexed (fn [idx [op]]
172+
(when-let [next-op ({"jmp" "nop" "nop" "jmp"} op)]
173+
(assoc-in instructions [idx 0] next-op)))
174+
instructions))
175+
```
176+
177+
Finally, we write `part2`. For this we parse the input, get all of the possible alternate instructions,
178+
`assoc` them onto the `state`, and run them to completion. This will give us back a sequence of
179+
`{:status x :state y}` maps. Then we use the familiar `keep -> when` pair of filtering the maps for the
180+
one(s) that are terminated, returning just the accumulator. `first` will pull out the first, and hopefully
181+
only value, and we're all done!
182+
183+
```clojure
184+
(defn part2 [input]
185+
(let [{:keys [instructions] :as state} (parse-input input)]
186+
(->> (alternate-instructions instructions)
187+
(map #(run-to-completion (assoc state :instructions %)))
188+
(keep (fn [{:keys [status state]}]
189+
(when (= :terminated status) (:acc state))))
190+
first)))
191+
```

src/advent_2020_clojure/day08.clj

+23-58
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,34 @@
11
(ns advent-2020-clojure.day08
2-
(:require [clojure.string :as str]))
2+
(:require [clojure.string :as str]
3+
[advent-2020-clojure.game-console :as game]))
34

4-
; State: {:offset n
5-
; :acc x
6-
; :instructions [[cmd args]]}
7-
8-
(defn parse-console [input]
9-
{:offset 0
10-
:acc 0
11-
:instructions (->> (str/split-lines input)
12-
(mapv #(str/split % #" ")))})
13-
14-
(defn parse-num [s]
15-
(->> (subs s 1)
16-
(Integer/parseInt)
17-
(* (if (= \+ (first s)) 1 -1))))
18-
19-
(defn move-offset
20-
([state] (move-offset state 1))
21-
([state n] (update state :offset + n)))
22-
23-
(defn op-nop [state & _] (move-offset state))
24-
(defn op-acc [state arg]
25-
(-> state
26-
(update :acc + (parse-num arg))
27-
move-offset))
28-
(defn op-jmp [state arg]
29-
(move-offset state (parse-num arg)))
30-
31-
(defn run-next-op [state]
32-
(when (< (state :offset) (count (state :instructions)))
33-
(let [[ins arg] (-> state :instructions (nth (state :offset)))]
34-
(case ins
35-
"nop" (op-nop state arg)
36-
"acc" (op-acc state arg)
37-
"jmp" (op-jmp state arg)))))
5+
(defn parse-input [input]
6+
(game/init-console (->> (str/split-lines input)
7+
(mapv #(str/split % #" ")))))
388

399
(defn run-to-completion [state]
40-
(first (->> (iterate (fn [[s seen _]]
41-
[(run-next-op s) (conj seen (:offset s)) s])
42-
[state #{} nil])
43-
(map (fn [[s seen prev]]
44-
(cond
45-
(nil? s) {:status :terminated, :state prev}
46-
(contains? seen (:offset s)) {:status :loop, :state s}
47-
:else {:status :running, :state s})))
48-
(filter #(not= :running (:status %))))))
10+
(loop [s state seen #{}]
11+
(let [{:keys [offset] :as next-state} (game/run-next-op s)]
12+
(cond
13+
(nil? next-state) {:status :terminated, :state s}
14+
(seen offset) {:status :loop, :state next-state}
15+
:else (recur next-state (conj seen offset))))))
4916

5017
(defn part1 [input]
51-
(let [state (parse-console input)]
52-
(->> (iterate (fn [[s seen]]
53-
[(run-next-op s) (conj seen (:offset s))])
54-
[state #{}])
55-
(filter (fn [[s seen]] (contains? seen (:offset s))))
56-
ffirst
57-
:acc)))
18+
(-> (parse-input input)
19+
run-to-completion
20+
(get-in [:state :acc])))
21+
22+
(defn alternate-instructions [instructions]
23+
(keep-indexed (fn [idx [op]]
24+
(when-let [next-op ({"jmp" "nop" "nop" "jmp"} op)]
25+
(assoc-in instructions [idx 0] next-op)))
26+
instructions))
5827

5928
(defn part2 [input]
60-
(let [state (parse-console input)
61-
possible-states (->> (:instructions state)
62-
(keep-indexed (fn [idx [op]]
63-
(when-let [next-op ({"jmp" "nop" "nop" "jmp"} op)]
64-
(assoc-in state [:instructions idx 0] next-op)))))]
65-
(->> possible-states
66-
(map run-to-completion)
29+
(let [{:keys [instructions] :as state} (parse-input input)]
30+
(->> (alternate-instructions instructions)
31+
(map #(run-to-completion (assoc state :instructions %)))
6732
(keep (fn [{:keys [status state]}]
6833
(when (= :terminated status) (:acc state))))
6934
first)))
+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
(ns advent-2020-clojure.game-console)
2+
3+
; State: {:offset n
4+
; :acc x
5+
; :instructions [[cmd args]]}
6+
7+
(defn init-console [instructions]
8+
{:offset 0
9+
:acc 0
10+
:instructions instructions})
11+
12+
(defn move-offset
13+
([state] (move-offset state 1))
14+
([state n] (update state :offset + n)))
15+
16+
(defn op-nop [state & _] (move-offset state))
17+
(defn op-acc [state arg]
18+
(-> state
19+
(update :acc + (Integer/parseInt arg))
20+
move-offset))
21+
(defn op-jmp [state arg]
22+
(move-offset state (Integer/parseInt arg)))
23+
24+
(defn run-next-op [{:keys [instructions offset] :as state}]
25+
(when-let [[ins arg] (nth instructions offset nil)]
26+
(let [op ({"nop" op-nop
27+
"acc" op-acc
28+
"jmp" op-jmp} ins)]
29+
(op state arg))))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
(ns advent-2020-clojure.game-console-test
2+
(:require [clojure.test :refer :all]
3+
[advent-2020-clojure.game-console :refer :all]))
4+
5+
(deftest move-offset-test
6+
(is (= {:offset 2 :acc 0 :instructions nil}
7+
(move-offset {:offset 1 :acc 0 :instructions nil})))
8+
(is (= {:offset 6 :acc 0 :instructions nil}
9+
(move-offset {:offset 1 :acc 0 :instructions nil} 5))))
10+
11+
(deftest op-nop-test
12+
(is (= {:offset 2 :acc 0 :instructions nil}
13+
(op-nop {:offset 1 :acc 0 :instructions nil} "+3"))))
14+
15+
(deftest op-acc-test
16+
(is (= {:offset 2 :acc 3 :instructions nil}
17+
(op-acc {:offset 1 :acc 0 :instructions nil} "+3")))
18+
(is (= {:offset 2 :acc -7 :instructions nil}
19+
(op-acc {:offset 1 :acc 8 :instructions nil} "-15"))))
20+
21+
(deftest op-jmp-test
22+
(is (= {:offset 4 :acc 0 :instructions nil}
23+
(op-jmp {:offset 1 :acc 0 :instructions nil} "+3")))
24+
(is (= {:offset 3 :acc 0 :instructions nil}
25+
(op-jmp {:offset 8 :acc 0 :instructions nil} "-5"))))

0 commit comments

Comments
 (0)