Skip to content

Commit 54528df

Browse files
committed
Day 21
1 parent 95e17dc commit 54528df

File tree

5 files changed

+394
-0
lines changed

5 files changed

+394
-0
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@
2121
| 17 | [source](src/advent_2021_clojure/day17.clj) | [blog](docs/day17.md) |
2222
| 18 | [source](src/advent_2021_clojure/day18.clj) | [blog](docs/day18.md) |
2323
| 20 | [source](src/advent_2021_clojure/day20.clj) | [blog](docs/day20.md) |
24+
| 21 | [source](src/advent_2021_clojure/day21.clj) | [blog](docs/day21.md) |

docs/day21.md

+263
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
# Day Twenty-One: Dirac Dice
2+
3+
* [Problem statement](https://adventofcode.com/2021/day/21)
4+
* [Solution code](https://github.com/abyala/advent-2021-clojure/blob/master/src/advent_2021_clojure/day21.clj)
5+
6+
---
7+
8+
## Part 1
9+
10+
Today we're going to be playing some 2-player dice games. Each player rolls a die three times, moves their position
11+
around a board track with values from 1 to 10, adding up the point value on the third space landed on. The first
12+
player to 1000 wins.
13+
14+
As always, we start with considering the data structures to use. I changed this several times, before settling on a
15+
mix of practicality and using neat features because I can. For the game board, I'll use a straightforward structure of
16+
type `{:players [{:pos 1, :score 0}], :next-player 0}`. The `:players` value is a two-element vector of players, each
17+
of which being a map of `:pos` to the value from 1 to 10 around the board, and `:score` being the accumulated score
18+
thus far. The second element in the map is `:next-player`, which is either 0 or 1 and points to the next player in the
19+
vector to move.
20+
21+
We're also going to use a deterministic die, one that loops values from 1 to 100 with each roll. A normal person would
22+
just merge in `{:die {:next-value 1, :num-rolls 3}}` to the game, but I decided to have some fun and instead store the
23+
die's `:values` as an infinite sequence of cycling values from 1 to 100. The good news is that I rarely get to use
24+
`cycle` and it's a fun function. The bad news is that I can never just `println` the game anymore because the die
25+
will forever generate the next value to print. That's what I get for trying to be cute.
26+
27+
There's no parsing to be done, so let's make generator functions `new-player` and `new-game`. The former takes in a
28+
player's starting position and creates its map, and the latter takes in the two players and forms a game around them,
29+
not counting the die.
30+
31+
```clojure
32+
(defn new-player [start] {:pos start, :score 0})
33+
(defn new-game [players] {:players (vec players), :next-player 0})
34+
```
35+
36+
Next we'll make a few simple helper functions. `move-pos` takes the sum of three die rolls and the current position of
37+
a player on the board, and returns the new position. 1-indexing is always fun, so I just made a simple recursive
38+
function called `board-pos` to simplify the code. And finally, `swap-players` just changes the player number from 0 to
39+
1 and back again.
40+
41+
```clojure
42+
(defn board-pos [n] (if (> n 10) (board-pos (- n 10)) n))
43+
(defn move-pos [roll pos] (board-pos (+ roll pos)))
44+
(defn swap-players [n] (- 1 n))
45+
```
46+
47+
Even though we haven't dealt with the deterministic die yet, we can still create `move-player`, which takes in the
48+
current state of the game we just built and the sum of rolling the die three times, and returns the new state of the
49+
game. We'll first use `:next-player` to figure out which player in the vector is moving. First we'll use `update` to
50+
create a player who has moved `roll` number of times, by way of `move-pos`. Then with that new player on the correct
51+
space, we'll `update` it again to add its new space to its current score. Finally, we'll replace the player back into
52+
the game, and use `swap-players` to return the state with the next `:next-player`.
53+
54+
```clojure
55+
(defn move-player [game roll]
56+
(let [{:keys [players next-player]} game
57+
player (players next-player)
58+
moved (update player :pos (partial move-pos roll))
59+
scored (update moved :score + (:pos moved))]
60+
(-> game
61+
(assoc-in [:players next-player] scored)
62+
(update :next-player swap-players))))
63+
```
64+
65+
Alright, now let's take care of that deterministic die. As I said, I represented the die as
66+
`{:values infinite-seq, :num-rolls n}`, so that's easy enough. Now to roll the die, I could have decided to make the
67+
die itself stateful, such that I can roll it, drop the head of the sequence, and increment the number of rolls all at
68+
once, but we can do this functionally and immutably fairly easily. The `roll-die` function takes in a die and the
69+
number of rolls we want (always 3), and returns a vector of the die after its rolls, plus the sum of the rolls. This
70+
means just calling `take` on the next 3 values in the sequence and adding them up, and then using `update` on the die
71+
to call `drop` the same number of times. The calling function will be responsible for replacing its die with the new
72+
one.
73+
74+
```clojure
75+
(def deterministic-die {:values (cycle (range 1 101)), :num-rolls 0})
76+
(defn roll-die [die n]
77+
(let [{:keys [values]} die
78+
sum (apply + (take n values))]
79+
[(-> die
80+
(update :values #(drop n %))
81+
(update :num-rolls + n))
82+
sum]))
83+
```
84+
85+
Speaking of the calling function, we can create `take-turn` now, and it's really simple. We'll call `roll-die` on the
86+
game's die, extracting out the replacement die and the sum of what it rolled. Then we return the game after calling
87+
`move-player` based on the die rolls, and then to associate in the new die.
88+
89+
```clojure
90+
(defn take-turn [game]
91+
(let [[rolled-die sum] (-> game :die (roll-die 3))]
92+
(-> game
93+
(move-player sum)
94+
(assoc :die rolled-die))))
95+
```
96+
97+
At every state of the game, we need to know if there's a winner, so the `winner` function will do that. The easiest
98+
option is just to return whether any of their scores are at least as high as the target. (We'll come back in part 2 to
99+
enhance this slightly.) Then the `play-until` function will run through all moves within the game until there is a
100+
winner; as usual, we leverage `iterate` to generate the sequence of game states before returning the first one with a
101+
winner.
102+
103+
```clojure
104+
(defn winner [game target-score]
105+
(some #(>= % target-score) (map :score (:players game))))
106+
107+
(defn play-until [game target-score]
108+
(->> (iterate take-turn game)
109+
(filter #(winner % target-score))
110+
first))
111+
```
112+
113+
All that's left now is to tally the final score and put it all together. `final-score` multiplies `:num-rolls` from the
114+
latest version of the deterministic die, by the score of the next player, since the one that _didn't_ just play is the
115+
loser of the game. Then `part1` just assembles the game, places the die into it, plays until someone hits 1000, and
116+
returns the final score.
117+
118+
```clojure
119+
(defn final-score [game]
120+
(let [{:keys [die players next-player]} game]
121+
(* (:num-rolls die)
122+
(:score (get players next-player)))))
123+
124+
(defn part1 [player1 player2]
125+
(-> (new-game (map new-player [player1 player2]))
126+
(assoc :die deterministic-die)
127+
(play-until 1000)
128+
final-score))
129+
```
130+
131+
---
132+
133+
## Part 2
134+
135+
The first and most obvious thing to note is that when the problem statement has numeric values in the trillions, _don't
136+
go with brute force!_ We're splitting universes every time we roll the magical 3-sided die, and returning in how many
137+
universes the best player wins.
138+
139+
The trick here is to realize that there are only 27 possible ways to roll three dice -- 111, 112, 113, 121, 122, 123,
140+
etc. The smallest sum of rolls is 3 (111), and the largest is 9 (333); the rest can be represented as a simple map
141+
of the sum of rolls to the number of times it happens. Note that there are only seven possible sums, from 3 to 9.
142+
143+
```clojure
144+
(def dirac-rolls {3 1, 4 3, 5 6, 6 7, 7 6, 8 3, 9 1})
145+
```
146+
147+
So our strategy will be to begin with the starting position (where the total score is 0), and queue up the next seven
148+
game states we can imagine. To avoid calculating the same game multiple times, we'll use a sorted set as our priority
149+
queue for the next game state to inspect. As long as we always evaluate lower-scoring states before higher-scoring
150+
ones, we'll never repeat a calculation. I learned from Day 15's Sorted Value Maps that a sorted set is just fine.
151+
152+
Now I won't go into the full details, but while comparators and sorting aren't bad in Clojure, you have to be really
153+
careful about _incomplete_ comparators in sorted sets; if Clojure compares two values and sees a zero, then the sorted
154+
set thinks it's the same value and won't store them both. This actually makes sense for Clojure, since it deals with
155+
immutable constructs all over the place, so if two values are compared to be the same, then you don't need two of them.
156+
157+
```clojure
158+
; A sorted set of two different vectors
159+
(sorted-set [1 2] [1 3])
160+
=> #{[1 2] [1 3]}
161+
162+
; A sorted set where we instruct Clojure to only compare the first value of each vector, results in the second vector
163+
; being swallowed up!
164+
(sorted-set-by #(< (first %1) (first %2))
165+
[1 2] [1 3])
166+
=> #{[1 2]}
167+
```
168+
169+
Who cares? Well if we're going to sort multiple game states in a sorted set, we'll need to make sure we not only sort
170+
them logically (from lowest total score to highest), but that we also include all of the key data values in the
171+
comparator. We'll make `game-option-sorter` to handle this work. It creates an internal helper function called
172+
`game-compare`, which takes in a game and returns a vector of its summed up score, then each of the scores, the next
173+
player ID, and the player positions. All we really care about is the score, but everything else must be included to
174+
remove accidental data deletion.
175+
176+
```clojure
177+
(def game-option-sorter
178+
(letfn [(game-compare [g] (let [{:keys [players next-player]} g
179+
[{pos1 :pos, score1 :score} {pos2 :pos, score2 :score}] players]
180+
[(+ score1 score2) score1 score2 next-player pos1 pos2]))]
181+
(fn [g1 g2] (compare (game-compare g1) (game-compare g2)))))
182+
```
183+
184+
Ok, let's figure out what to do with the Dirac die. We'll make a function called `roll-dirac-dice`, which takes in a
185+
game state, and returns a map of each possible game state the rolls can lead to, mapped to their frequency. This shows
186+
us how the universe explodes after rolling the die three times.
187+
188+
```clojure
189+
(defn roll-dirac-dice [game]
190+
(reduce (fn [acc [roll n]]
191+
(let [next-game (move-player game roll)]
192+
(u/update-add acc next-game n)))
193+
{}
194+
dirac-rolls))
195+
```
196+
197+
At this point, I'm going to throw everything into one big `part2` function, because honestly I'm a little tired from
198+
lots of refactorings today. That said, it's in a pretty decent state. We'll start by initializing the game with the
199+
two players, and set up a `loop-recur`, since we'll want to go through all of our possible non-winning game options.
200+
`game-options` is our sorted set of unseen game states, while `universes` is the map of all upcoming game states to
201+
the number of paths to get there. Each time we loop, if there's an unseen game state, we'll pick the lowest-scoring
202+
one, and roll the Dirac die to get find the possible universes we might explore. Before we recurse, we'll want to
203+
remove the selected game state, since we will have seen it; and add in all rolled universes that aren't winners,
204+
since a game stops once there's a winner. For the universes, we'll again remove the current game from the map, since
205+
we'll never look at it again; and we add in the each of the newly-seen universes, multiplied by the number of universes
206+
that made the selected game state possible.
207+
208+
Note that I got tired of dealing with updating maps to add in values when the keys may not have already been present,
209+
so I created `update-add` to the `utils` namespace to handle this for us.
210+
211+
Finally, once we've run out of game states without winners, it's time to get our final score. From all of the universes,
212+
which should now only contain game states with winners, we'll map each universe to its winner and frequency, and then
213+
group them by the winner. Finally, we add together the number of universes, and select the larger value.
214+
215+
```clojure
216+
; advent-2021-clojure.utils namespace
217+
(defn update-add [m k v] (update m k #(+ (or % 0) v)))
218+
219+
; advent-2021-clojure.day21 namespace
220+
(defn part2 [player1 player2]
221+
(let [target 21
222+
initial-game (new-game (map new-player [player1 player2]))]
223+
(loop [game-options (sorted-set-by game-option-sorter initial-game), universes {initial-game 1}]
224+
(if-let [game (first game-options)]
225+
(let [paths-to-game (universes game)
226+
rolled-universes (roll-dirac-dice game)
227+
next-game-options (->> (keys rolled-universes)
228+
(remove #(winner % target))
229+
(apply conj (disj game-options game)))
230+
next-universes (reduce-kv (fn [m k v] (u/update-add m k (* v paths-to-game)))
231+
(dissoc universes game)
232+
rolled-universes)]
233+
(recur next-game-options next-universes))
234+
(->> universes
235+
(map (fn [[game n]] [(winner game target) n]))
236+
(group-by first)
237+
(map (comp #(apply + (map second %)) second))
238+
(apply max))))))
239+
```
240+
241+
So we're done, right? Well not quite. I said before that the `winner` function had to change slightly. Whereas in part
242+
1 we only needed to know if there was a winner, now we need to know who it is! So we'll just look at all players in a
243+
game, keeping the index of whichever has a score above the threshold, and select the first one. We shouldn't have to
244+
change anything in part 1, even though the old solution returned `true` or `nil` in the past and this one returns the
245+
index of the winning player, since integers are as truthy as `true` is. But for part 2, the `winner` function now
246+
returns the player index, which we use in our `group-by` function.
247+
248+
```clojure
249+
(defn winner [game target-score]
250+
(->> (:players game)
251+
(keep-indexed (fn [idx {:keys [score]}] (when (>= score target-score) idx)))
252+
first))
253+
```
254+
255+
---
256+
257+
## Epilogue
258+
259+
I know there's a way to combine parts 1 and 2 together into a combined algorithm, but I don't think I'm going to do it
260+
this time. It just requires knowing that the deterministic die from part 1 returns only a single universe with a
261+
single possible outcome, whereas the Dirac die creates multiple universes. In that vein, it should be fairly simple
262+
to keep a copy of the changing die (deterministic die changes while Dirac doesn't), and either choosing the single
263+
winning universe for part 1 or look at all of them for part 2. Maybe I'll do this in a few days, maybe not!

src/advent_2021_clojure/day21.clj

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
(ns advent-2021-clojure.day21
2+
(:require [advent-2021-clojure.utils :as u]))
3+
4+
(defn new-player [start] {:pos start, :score 0})
5+
(defn new-game [players] {:players (vec players), :next-player 0})
6+
7+
(defn board-pos [n] (if (> n 10) (board-pos (- n 10)) n))
8+
(defn move-pos [roll pos] (board-pos (+ roll pos)))
9+
(defn swap-players [n] (- 1 n))
10+
11+
(def deterministic-die {:values (cycle (range 1 101)), :num-rolls 0})
12+
(defn roll-die [die n]
13+
(let [{:keys [values]} die
14+
sum (apply + (take n values))]
15+
[(-> die
16+
(update :values #(drop n %))
17+
(update :num-rolls + n))
18+
sum]))
19+
20+
(defn move-player [game roll]
21+
(let [{:keys [players next-player]} game
22+
player (players next-player)
23+
moved (update player :pos (partial move-pos roll))
24+
scored (update moved :score + (:pos moved))]
25+
(-> game
26+
(assoc-in [:players next-player] scored)
27+
(update :next-player swap-players))))
28+
29+
(defn take-turn [game]
30+
(let [[rolled-die sum] (-> game :die (roll-die 3))]
31+
(-> game
32+
(move-player sum)
33+
(assoc :die rolled-die))))
34+
35+
(defn winner [game target-score]
36+
(->> (:players game)
37+
(keep-indexed (fn [idx {:keys [score]}] (when (>= score target-score) idx)))
38+
first))
39+
40+
(defn play-until [game target-score]
41+
(->> (iterate take-turn game)
42+
(filter #(winner % target-score))
43+
first))
44+
45+
(defn final-score [game]
46+
(let [{:keys [die players next-player]} game]
47+
(* (:num-rolls die)
48+
(:score (get players next-player)))))
49+
50+
(defn part1 [player1 player2]
51+
(-> (new-game (map new-player [player1 player2]))
52+
(assoc :die deterministic-die)
53+
(play-until 1000)
54+
final-score))
55+
56+
(def dirac-rolls {3 1, 4 3, 5 6, 6 7, 7 6, 8 3, 9 1})
57+
58+
(def game-option-sorter
59+
(letfn [(game-compare [g] (let [{:keys [players next-player]} g
60+
[{pos1 :pos, score1 :score} {pos2 :pos, score2 :score}] players]
61+
[(+ score1 score2) score1 score2 next-player pos1 pos2]))]
62+
(fn [g1 g2] (compare (game-compare g1) (game-compare g2)))))
63+
64+
(defn roll-dirac-dice [game]
65+
(reduce (fn [acc [roll n]]
66+
(let [next-game (move-player game roll)]
67+
(u/update-add acc next-game n)))
68+
{}
69+
dirac-rolls))
70+
71+
(defn part2 [player1 player2]
72+
(let [target 21
73+
initial-game (new-game (map new-player [player1 player2]))]
74+
(loop [game-options (sorted-set-by game-option-sorter initial-game), universes {initial-game 1}]
75+
(if-let [game (first game-options)]
76+
(let [paths-to-game (universes game)
77+
rolled-universes (roll-dirac-dice game)
78+
next-game-options (->> (keys rolled-universes)
79+
(remove #(winner % target))
80+
(apply conj (disj game-options game)))
81+
next-universes (reduce-kv (fn [m k v] (u/update-add m k (* v paths-to-game)))
82+
(dissoc universes game)
83+
rolled-universes)]
84+
(recur next-game-options next-universes))
85+
(->> universes
86+
(map (fn [[game n]] [(winner game target) n]))
87+
(group-by first)
88+
(map (comp #(apply + (map second %)) second))
89+
(apply max))))))

src/advent_2021_clojure/utils.clj

+2
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,5 @@
3333

3434
(defn lower-case? [s] (every? #(Character/isLowerCase ^char %) s))
3535
(defn upper-case? [s] (every? #(Character/isUpperCase ^char %) s))
36+
37+
(defn update-add [m k v] (update m k #(+ (or % 0) v)))

0 commit comments

Comments
 (0)