Prevent single name being left over in Secret Santa algorithm?

Question:

This year, I decided to write up a program to automate our family’s secret santa name drawings by sending each person an email of who they got without anyone else knowing. At first I thought this would be trivially easy, but it has led me to a problem that I don’t know the efficient solution to. The program goes through each name, creates a list containing all unpicked names except for the name being assigned, and then makes a random selection. Here is my code below:

from random import choice

names = ["Foo", "Bar", "Baz"]

unassigned = names[:]

assigned = []

for gifter in names:
    giftee = choice([n for n in unassigned if n != gifter])
    assigned.append((gifter, giftee))
    unassigned.remove(giftee)

for name, giftee in assigned:
    print(f'{name} -> {giftee}')

The problem is that if the last name iterated over is also the last unpicked name, then there is no way to satisfy the needed conditions. From my testing, the code above fails about 25% of the time (at least with 3 names). What can I do, or what algorithm could I implement, in order to ensure that these edge cases are not being caused while iterating over everyone?

I’ve set a few rules for myself in finding a solution:

  1. If at all possible, I don’t want to simply catch the exception and repeat until success. I’d like to find a determinate solution that guarantees success in one run.
  2. I’d like to keep as much entropy/randomness as possible for each individual assignment rather than generating a single, continuous chain (i.e., someone can still give to and receive from the same person). Of course, any added conditional logic will inevitably decrease this to some degree.
Asked By: McSlayR

||

Answers:

Here is one possible solution to your question:
Was a bit longer than I thought it should be, but it works nonetheless.
Heaps of room for improvement:

Code:

from random import choice

# Generate the names in the hat
hat = ["John", "Peter", "Sam", "Susan", "Kate"]
hat_copy = hat[:]
result = []

for t in hat_copy:
    removed = False
    
    # This aims to prevent an error which occurs when the last item remains in the hat
    if len(hat) == 2 and hat_copy[-1] in hat:
        result.append(hat_copy[-1])
        hat.remove(hat_copy[-1])
        result.append(hat[0])
        hat.remove(hat[0])
        
    elif len(hat) > 0:
        # Remove t for this round (only if it exists in hat)
        if t in hat:
            hat.remove(t)
            removed = True
        
        # Select from remaining items in hat
        selection = choice(hat)
        hat.remove(selection)
        result.append(selection)
        
        # Only append t if it was removed in this round.
        if removed:
            hat.append(t)
            
    
print("Names   :", hat_copy)
print("Shuffled:", result)

Output:

Names   : ['John', 'Peter', 'Sam', 'Susan', 'Kate']
Shuffled: ['Sam', 'Kate', 'Susan', 'Peter', 'John']
Answered By: ScottC

How about this?

from random import shuffle, choice

names = ["...", "..."]

# Shuffle the list of names before assignment
shuffled = names[:]
shuffle(shuffled)

assigned = []

for gifter in shuffled:
    # Select a random name from the list of unpicked names, excluding the current name
    giftee = choice([n for n in names if n != gifter and n not in assigned])
    assigned.append((gifter, giftee))

for name, giftee in assigned:
    print(f'{name} -> {giftee}')

It shuffles the names before assignment so that it’s guaranteed to be random.

Answered By: J Muzhen

I ended up adopting a slight variation of ScottC’s solution.

In order to prevent the last name iterated over being the last element left in unassigned, I needed to make a conditional decision on which element to pick on the second-to-last iteration. I implemented it with the code below:

from random import choice

names = ["Foo", "Bar", "Baz"]

unassigned = names[:]

assigned = []

for gifter in names:
    # If we are iterating over the second-to-last person, we need to ensure
    # that the last person in 'names' isn't still in 'unassigned'. If they
    # are, we need to remove them immediately by assigning them to the
    # second-to-last person instead.
    if len(unassigned) == 2 and names[-1] in unassigned:
        giftee = names[-1]
    else:
        giftee = choice([n for n in unassigned if n != gifter])

    assigned.append((gifter, giftee))
    unassigned.remove(giftee)

for gifter, giftee in assigned:
    print(f'{gifter} -> {giftee}')
Answered By: McSlayR
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.