Day 19: An Elephant Named Joseph

author: Harshvardhan Pandit

license: MIT

link to problem statement

The Elves contact you over a highly secure emergency channel. Back at the North Pole, the Elves are busy misunderstanding White Elephant parties.

Each Elf brings a present. They all sit in a circle, numbered starting with position 1. Then, starting with the first Elf, they take turns stealing all the presents from the Elf to their left. An Elf with no presents is removed from the circle and does not take turns.

For example, with five Elves (numbered 1 to 5):

  1
5   2
 4 3

  • Elf 1 takes Elf 2's present.
  • Elf 2 has no presents and is skipped.
  • Elf 3 takes Elf 4's present.
  • Elf 4 has no presents and is also skipped.
  • Elf 5 takes Elf 1's two presents.
  • Neither Elf 1 nor Elf 2 have any presents, so both are skipped.
  • Elf 3 takes Elf 5's three presents.

So, with five Elves, the Elf that sits starting in position 3 gets all the presents.

With the number of Elves given in your puzzle input, which Elf gets all the presents?

Solution logic

At every turn, the next elf is skipped or removed from the circle. This goes on until only one elf is remaining. We will simulate the entire scenario as a naive solution before moving on to a better, optimized version.

Test Data

The test data has 5 elves, with elf 3 getting all the presents. We generate an array of 5 elves as the test input.


In [1]:
no_elves = 5
elves = [elf for elf in range(1, no_elves + 1)]
print(elves)


[1, 2, 3, 4, 5]

Simulate one round of stealing presents

To simulate one round, we define the function play_round that takes an elf, then skips one over while iterating over the list. If the number of elves are odd, then the first elf's presents are stolen by the last elf, and therefore, we drop the first elf from the list.


In [2]:
def play_round(elves):
    _elves = []
    elf = 0
    while elf < len(elves):
        _elves.append(elves[elf])
        elf += 2
    if len(elves) % 2 == 1:
        _elves.pop(0)
    return _elves

Continue simulating rounds until only one elf is remaining


In [3]:
while len(elves) > 1:
    elves = play_round(elves)
print(elves[0])


3

Run on the given input


In [4]:
with open('../inputs/day19.txt', 'r') as f:
    input_data = int(f.readline())

In [5]:
elves = [elf for elf in range(1, input_data + 1)]

In [6]:
while len(elves) > 1:
    elves = play_round(elves)
print('answer', elves[0])


answer 1834903

Optimized version - Josephus problem

The problem statement is actually quite well known as the Josephus problem which can be generalized to a counting out problem. The solution to solve the Josephus problem is given as:

f(n) = f(n - 2) + k - 1) % n + 1

Where n is the number of players, k is the skip count where k-1 people are skipped and the k-th is executed. (in this case, k = 2).


In [7]:
n = 2
k = 2
j = [1]
while n <= input_data:
    j.append((j[n - 2] + k - 1) % n + 1)
    n += 1
print('answer', j[-1])


answer 1834903

Part Two

Realizing the folly of their present-exchange rules, the Elves agree to instead steal presents from the Elf directly across the circle. If two Elves are across the circle, the one on the left (from the perspective of the stealer) is stolen from. The other rules remain unchanged: Elves with no presents are removed from the circle entirely, and the other elves move in slightly to keep the circle evenly spaced.

For example, with five Elves (again numbered 1 to 5):

The Elves sit in a circle; Elf 1 goes first: 1 5 2 4 3

Elves 3 and 4 are across the circle; Elf 3's present is stolen, being the one to the left. Elf 3 leaves the circle, and the rest of the Elves move in:

  1           1
5   2  -->  5   2
 4 -          4

Elf 2 steals from the Elf directly across the circle, Elf 5:

  1         1 
-   2  -->     2
  4         4 

Next is Elf 4 who, choosing between Elves 1 and 2, steals from Elf 1:

 -          2  
    2  -->
 4          4

Finally, Elf 2 steals from Elf 4:

 2
    -->  2  
 -

So, with five Elves, the Elf that sits starting in position 2 gets all the presents.

With the number of Elves given in your puzzle input, which Elf now gets all the presents?

Solution logic

We need to redefine our play_round function, where instead of only skipping over the next elf, we need to remove the middle elf from the list. Which one is the middle elf depends on the length of the list at that time. So at each turn (and not round), the length of the list changes. Since we are implmenting the naive solution, we will iterate over the list once in the loop instead of optimizing the index access.

Since we need to eliminate the middle elf at each turn, we must find the middle elf. Let us start by seeing what the pattern is by plotting the number of elfs with the middle elf (the one to be eliminated) for each arrangement.

n    0-indexed array    
1         -    
2         1    
3         1    
4         2    
5         2    
6         3    
7         3   
8         4    
9         4

We can see that the element to remove is given by n/2 where n is the total number of elves in the list at that time. Taking this into account, we must now account for the fact that the list mutates after every turn.

turn    list            play  eliminated  effective-list
0       1, 2, 3, 4, 5   -     -           -
1       1, 2, X, 4, 5   1     3           1, 2, 3, 4, 5
2       1, 2, X, 4, X   2     5           2, 4, 5, 1
3       X, 2, X, 4, X   4     1           4, 1, 2
4       X, 2, X, X, X   2     4           2, 4

If we compare the eliminated index and re-list such that the elf currently playing is always at the top/front/start of the array, we get the effective-list. It it, the resultant array at each turn is left-shifted by one. We exploit this to create the resultant array. Thus our strategy becomes -

remove the elf at index n/2
left the list by 1 - pop the first element and add it to the end

Continue this approach until only 1 elf is left.

Note that the simulation takes a LOT of time. Being n^2, it is very inefficient. Which is why I don't actually run it here. It'll take time. Seriously.


In [8]:
# DO NOT RUN
def do_not_run():
    elves = [elf for elf in range(1, input_data + 1)]
    while len(elves) > 1:
        elves.pop(len(elves)//2)
        elves = elves[1:] + elves[:1]
    print(elves[0])

Alternative logic

We use the concept of balanced trees, where we split the elves into two different lists. In one list, we add the first half elements, and in the second list, we add the other half in the reverse order, i.e. append to the front. Since the elves are always removed from the middle of the list, we can do this easily in a tree, as the middle is always the root. After removal, we need to balance the tree by moving elements from the top of the left branch (the starting elves) to the top of the right branch (the last elves), and then move the elements from the bottom of the right branch (the middle elves) to the bottom of the left branch (the middle elves).

left branch  --> first half of elements, normal order
right branch --> second half of elements, reverse order

while length of tree (left + right) > 1:
    if left tree has more nodes:
        remove node from end of left branch
    else:
        remove node from end of right branch
    remove node from top of left branch and append to top of right branch
    remove node from bottom of right branch and append to bottom of left branch

In python, lists are great data structures, but they are not performant when it comes to removing things from the front. Since we need to remove things from both ends, we will use dequeue which is a double ended queue and is performant for insertions and removals at both ends, but will perform badly for other operations such as splicing and random removals (which we do not require).


In [9]:
from collections import deque
left = deque(i for i in range(1, input_data//2 + 1))
right = deque(i for i in range(input_data, input_data//2, -1))
print('tree left->', len(left), '1:', left[0], 'last:', left[-1])
print('tree right->', len(right), '1:', right[0], 'last:', right[-1])


tree left-> 1507301 1: 1 last: 1507301
tree right-> 1507302 1: 3014603 last: 1507302

In [10]:
while left and right:
    if len(left) > len(right):
        left.pop()
    else:
        right.pop()
    right.appendleft(left.popleft())
    left.append(right.pop())

Since we do not know which of the left or right branches might contain the final element, we need to check which of the queues are not empty and then retrieve the answer from that.


In [11]:
if left:
    print('answer', left[0])
else:
    print('answer', right[0])


answer 1420280

Other answers

I received a link to a pastebin containing the following code that gives the answer, though I don't know why or even how it works. I suspect that if I drill enough, I'll find that it is a summarization of the operations done on a balanced binary tree.

p = 3 ** int(log(elves - 1, 3))
return elves - p + max(elves - 2 * p, 0)

== END ==