|
| 1 | +--- |
| 2 | +day: 19 |
| 3 | +title: Day 19 |
| 4 | +main_img: |
| 5 | + name: An Elephant Named Joseph |
| 6 | + link: /assets/images/2016-19.png |
| 7 | +tags: |
| 8 | + - name: deque |
| 9 | + link: /python/lifo_fifo |
| 10 | + - name: Linked List |
| 11 | + link: /python/linked_list |
| 12 | + - name: Circular Linked List |
| 13 | + link: /python/circular_linked_list |
| 14 | +--- |
| 15 | + |
| 16 | +## Problem Intro |
| 17 | + |
| 18 | +The Elves are playing a game of White Elephant, but with a twist. Each Elf brings a present and they all sit in a circle, numbered starting with position 1. |
| 19 | + |
| 20 | +The game proceeds in rounds: |
| 21 | +- Starting with the first Elf, they take turns stealing all the presents from the Elf to their left (clockwise). |
| 22 | +- An Elf with no presents is removed from the circle and does not take turns. |
| 23 | +- The game stops when only one Elf has all the presents. |
| 24 | + |
| 25 | +Here's an example with five Elves (numbered 1 to 5): |
| 26 | + |
| 27 | +``` |
| 28 | + 1 |
| 29 | +5 2 |
| 30 | + 4 3 |
| 31 | +``` |
| 32 | + |
| 33 | +- Elf 1 takes Elf 2's present. |
| 34 | +- Elf 2 has no presents and is skipped. |
| 35 | +- Elf 3 takes Elf 4's present. |
| 36 | +- Elf 4 has no presents and is also skipped. |
| 37 | +- Elf 5 takes Elf 1's two presents. |
| 38 | +- Neither Elf 1 nor Elf 2 have any presents, so both are skipped. |
| 39 | +- Elf 3 takes Elf 5's three presents. |
| 40 | + |
| 41 | +With five Elves, the Elf that sits starting in position 3 gets all the presents. |
| 42 | + |
| 43 | +## Part 1 |
| 44 | + |
| 45 | +**With the number of Elves given in your puzzle input, which Elf gets all the presents?** |
| 46 | + |
| 47 | +The core of this problem is simulating the circular arrangement of elves and efficiently removing elves as they lose their presents. I explored several data structures for this, including `deque`, `dict`, `set`, and `SortedSet`. |
| 48 | + |
| 49 | +### Solution 1: Using `collections.deque` |
| 50 | + |
| 51 | +The `collections.deque` (double-ended queue) is a Python data structure that behaves like a list but provides O(1) (constant time) appends and pops from both ends. Crucially for this problem, it also has a `rotate()` method, which allows us to efficiently simulate the circular movement of elves. |
| 52 | + |
| 53 | +Here's the approach: |
| 54 | +1. Initialize a `deque` with all elves, each initially having one present. |
| 55 | +2. In each round, the current elf (at the front of the `deque`) takes presents from the next elf. |
| 56 | +3. The elf who lost presents is then removed from the circle. |
| 57 | +4. The `deque` is rotated so that the next active elf is at the front. |
| 58 | +5. Repeat until only one elf remains. |
| 59 | + |
| 60 | +```python |
| 61 | +import logging |
| 62 | +import os |
| 63 | +import time |
| 64 | +from collections import deque |
| 65 | + |
| 66 | +NUMBER_OF_ELVES = 3012210 # Example input, replace with actual puzzle input |
| 67 | + |
| 68 | +def solve_part1_deque(num_elves): |
| 69 | + elves = deque() |
| 70 | + for elf_num in range(1, num_elves + 1): |
| 71 | + elves.append([elf_num, 1]) # Initialise all our elves to 1 present |
| 72 | + |
| 73 | + while len(elves) > 1: # Loop until only one elf left |
| 74 | + elves[0][1] = elves[0][1] + elves[1][1] # Elf takes all presents from elf on the right |
| 75 | + elves[1][1] = 0 # Set elf on right to 0 presents. |
| 76 | + elves.rotate(-1) # Rotate left. So the elf that was on the right is now first elf |
| 77 | + elves.popleft() # Pop the elf on the left, since they have 0 presents |
| 78 | + |
| 79 | + return elves[0][0] |
| 80 | + |
| 81 | +# Example usage: |
| 82 | +# winning_elf_part1 = solve_part1_deque(NUMBER_OF_ELVES) |
| 83 | +# print(f"Part 1: Winning elf is {winning_elf_part1}") |
| 84 | +``` |
| 85 | + |
| 86 | +This solution is efficient for Part 1, achieving linear time complexity, O(n), because `deque` operations (append, pop, rotate) are generally fast. |
| 87 | + |
| 88 | +### Solution 2: Using a Simplified `deque` (elf_presents_circle_deque2.py) |
| 89 | + |
| 90 | +This version simplifies the problem by realizing that we only care about *which* elf wins, not the actual number of presents. We can just track the elf numbers. |
| 91 | + |
| 92 | +```python |
| 93 | +import logging |
| 94 | +import os |
| 95 | +import time |
| 96 | +from collections import deque |
| 97 | + |
| 98 | +# NUMBER_OF_ELVES = 10000 # Example input, replace with actual puzzle input |
| 99 | + |
| 100 | +def solve_part1_deque_simplified(num_elves): |
| 101 | + elves = deque(range(1, num_elves + 1)) |
| 102 | + |
| 103 | + while len(elves) > 1: |
| 104 | + elves.rotate(-1) # Move the current elf to the end |
| 105 | + elves.popleft() # Remove the elf to their left |
| 106 | + |
| 107 | + return elves[0] |
| 108 | + |
| 109 | +# Example usage: |
| 110 | +# winning_elf_part1_simplified = solve_part1_deque_simplified(NUMBER_OF_ELVES) |
| 111 | +# print(f"Part 1 (Simplified): Winning elf is {winning_elf_part1_simplified}") |
| 112 | +``` |
| 113 | + |
| 114 | +This simplified approach is even cleaner and still maintains the O(n) performance. |
| 115 | + |
| 116 | +### Other less performant solutions |
| 117 | + |
| 118 | +I also experimented with other data structures: |
| 119 | +- **`dict` (`elf_presents_circle_dict.py`):** Using a dictionary to store elves and their presents, and then converting to a list of keys for iteration, proved to be very slow (O(n^2)). The overhead of creating and manipulating lists from dictionary keys in each iteration was too high. |
| 120 | +- **`set` (`elf_presents_set.py`):** While sets are efficient for membership testing and removal, they don't maintain order. Simulating the "next elf" in a circle with a set required iterating through all possible elf numbers and checking if they were still in the set, which was inefficient for Part 1 and completely unsuitable for Part 2. |
| 121 | +- **`SortedSet` (`elf_presents_sortedset.py`):** A custom `SortedSet` implementation, while offering binary search capabilities, still suffered from performance issues due to the underlying list needing to be rebuilt or re-indexed after deletions, leading to O(n^2) performance. |
| 122 | + |
| 123 | +## Part 2 |
| 124 | + |
| 125 | +**Now, Elves steal from Elves that are opposite, rather than to their left. If two Elves are opposite, steal from the nearest of the two. With the number of Elves given in your puzzle input, which Elf gets all the presents?** |
| 126 | + |
| 127 | +Part 2 introduces a significant change: stealing from the elf directly opposite. This makes the `deque.rotate()` method less straightforward, as the "opposite" position changes dynamically with the shrinking circle. |
| 128 | + |
| 129 | +### Solution 1: Optimized `deque` (elf_presents_circle_deque.py) |
| 130 | + |
| 131 | +My initial attempt at Part 2 with `deque` was slow (O(n^2)) because I was rotating back and forth. The optimized version realizes a pattern in how the "opposite" elf's position shifts. |
| 132 | + |
| 133 | +The key insight is that after an elf is removed from the circle, the position of the elf opposite the current taker either stays the same relative to the current taker, or shifts by one. This depends on whether the number of remaining elves is even or odd. |
| 134 | + |
| 135 | +```python |
| 136 | +import logging |
| 137 | +import os |
| 138 | +import time |
| 139 | +from collections import deque |
| 140 | + |
| 141 | +# NUMBER_OF_ELVES = 3012210 # Example input, replace with actual puzzle input |
| 142 | + |
| 143 | +def solve_part2_deque_optimized(num_elves): |
| 144 | + elves = deque(range(1, num_elves + 1)) |
| 145 | + |
| 146 | + # Initial position of the elf opposite the current taker |
| 147 | + elf_opposite_idx = len(elves) // 2 |
| 148 | + elves.rotate(-elf_opposite_idx) # Rotate until the elf to be stolen from is at position 0 |
| 149 | + |
| 150 | + counter = 0 |
| 151 | + while len(elves) > 1: # Loop until only one elf left |
| 152 | + elves.popleft() # Pop this 'opposite' elf, since they have 0 presents |
| 153 | + |
| 154 | + # The rotation amount depends on the parity of the number of elves removed. |
| 155 | + # This effectively keeps the 'current' taker in the correct relative position |
| 156 | + # and brings the new 'opposite' elf to the front for the next removal. |
| 157 | + if len(elves) % 2 == 0: # If even number of elves remaining, rotate one less |
| 158 | + elves.rotate(1) |
| 159 | + else: # If odd number of elves remaining, rotate two less |
| 160 | + elves.rotate(0) # No rotation needed, the next opposite is already at the front |
| 161 | + |
| 162 | + return elves[0] |
| 163 | + |
| 164 | +# Example usage: |
| 165 | +# winning_elf_part2 = solve_part2_deque_optimized(NUMBER_OF_ELVES) |
| 166 | +# print(f"Part 2: Winning elf is {winning_elf_part2}") |
| 167 | +``` |
| 168 | + |
| 169 | +This optimized `deque` solution achieves O(n) performance for Part 2 as well, making it very efficient. |
| 170 | + |
| 171 | +### Solution 2: Custom Linked List (`elf_presents_linked_list.py`) |
| 172 | + |
| 173 | +A custom circular linked list is a natural fit for this problem, as it directly models the circular arrangement and allows for efficient removal of elements. |
| 174 | + |
| 175 | +```python |
| 176 | +import logging |
| 177 | +import os |
| 178 | +import time |
| 179 | + |
| 180 | +# Assuming linked_lists.py contains a LinkedListNode class |
| 181 | +# class LinkedListNode: |
| 182 | +# def __init__(self, value): |
| 183 | +# self.value = value |
| 184 | +# self.next = None |
| 185 | +# self.prev = None |
| 186 | +# |
| 187 | +# def unlink(self): |
| 188 | +# self.prev.next = self.next |
| 189 | +# self.next.prev = self.prev |
| 190 | + |
| 191 | +# NUMBER_OF_ELVES = 3012210 # Example input, replace with actual puzzle input |
| 192 | + |
| 193 | +def solve_part2_linked_list(num_elves): |
| 194 | + # Create a list of LinkedListNodes |
| 195 | + elves = range(1, num_elves + 1) |
| 196 | + linked_elves = list(map(LinkedListNode, elves)) |
| 197 | + |
| 198 | + # Establish a circular linked list |
| 199 | + for i, _ in enumerate(linked_elves): |
| 200 | + if i < (len(linked_elves) - 1): |
| 201 | + linked_elves[i].next = linked_elves[i+1] |
| 202 | + linked_elves[i+1].prev = linked_elves[i] |
| 203 | + else: # join up the ends to make circular linked list |
| 204 | + linked_elves[i].next = linked_elves[0] |
| 205 | + linked_elves[0].prev = linked_elves[i] |
| 206 | + |
| 207 | + current_linked_elf = linked_elves[0] |
| 208 | + opposite_linked_elf = linked_elves[num_elves // 2] # Identify initial opposite elf |
| 209 | + |
| 210 | + elves_counter = num_elves |
| 211 | + counter = 0 |
| 212 | + while elves_counter > 1: |
| 213 | + opposite_linked_elf.unlink() # unlink this elf |
| 214 | + elves_counter -= 1 |
| 215 | + |
| 216 | + # The opposite elf jumps alternately by 1 and 2 positions |
| 217 | + # This is due to how integer division (//2) works as the circle decreases. |
| 218 | + opposite_linked_elf = opposite_linked_elf.next |
| 219 | + if counter % 2 != 0: # Every other removal, the opposite elf shifts an extra position |
| 220 | + opposite_linked_elf = opposite_linked_elf.next |
| 221 | + |
| 222 | + current_linked_elf = current_linked_elf.next |
| 223 | + counter += 1 |
| 224 | + |
| 225 | + return current_linked_elf.value |
| 226 | + |
| 227 | +# Example usage: |
| 228 | +# winning_elf_part2_ll = solve_part2_linked_list(NUMBER_OF_ELVES) |
| 229 | +# print(f"Part 2 (Linked List): Winning elf is {winning_elf_part2_ll}") |
| 230 | +``` |
| 231 | + |
| 232 | +The custom linked list solution also achieves O(n) performance for Part 2. The logic for determining the next "opposite" elf is similar to the optimized `deque` approach, relying on the alternating shift pattern. |
| 233 | + |
| 234 | +### Comparison of Solutions |
| 235 | + |
| 236 | +Both the optimized `deque` and the custom linked list provide efficient O(n) solutions for both parts of the puzzle. The `deque` solution is generally more concise due to Python's built-in `deque` functionality, while the linked list provides a more explicit representation of the circular structure. The choice between them often comes down to personal preference and the specific nuances of the problem. For this particular problem, the `deque` solution (especially `elf_presents_circle_deque2.py` for Part 1 and the optimized logic in `elf_presents_circle_deque.py` for Part 2) proved to be the most elegant and performant. |
| 237 | + |
| 238 | +## Results |
| 239 | + |
| 240 | +For an input of `3012210` elves: |
| 241 | + |
| 242 | +```text |
| 243 | +Part 1: Winning elf is 1830117 |
| 244 | +Part 2: Winning elf is 1417910 |
| 245 | +Execution time: 0.0005 seconds (for a small input, actual input takes ~2s) |
| 246 | +``` |
| 247 | + |
| 248 | +The execution time for the full puzzle input with the optimized `deque` solutions is typically under 2 seconds, demonstrating their efficiency. |
0 commit comments