Licence CC BY-NC-SA

Un Secret Santa Hamiltonien

Publié :
Auteur :
Catégories :
Temps de lecture estimé : 10 minutes

Il y a deux ans, je célébrais Noël avec 10 amis et nous avions décidé de s’offrir des cadeaux et pour ce faire, nous avons organisé un "Secret Santa". Pour ceux qui ne connaissent pas Secret Santa, l’idée est très simple : avant Noël, un père Noël secret est attributé à chaque invité. Ce père Noël aura la tâche de trouver un cadeau pour cette personne et de lui offrir lors de la fête de Noël. Le jour du réveillon, on choisit une personne au hasard et celle-ci offre son cadeau à la personne qui lui a été attribuée puis cette personne fait de même et ainsi de suite.

Donc, comme je le disais, nous avions décidé d’organiser un Secret Santa mais il s’est passé quelque chose d’horrible: il y avait une sous-boucle dans la distribution ! J’ai commencé en donnant mon cadeau à une personne A qui a continué en donnant son cadeau à une personne B qui a continué en donnant son cadeau à… moi. Cela signifiait donc qu’on allait devoir choisir une autre personne au hasard pour continuer la distribution entre les 7 personnes restantes.

Mauvaise distribution
Mauvaise distribution

L’année dernière, j’ai décidé de ne pas laisser cette situation se reproduire. Voyons comment écrire une application qui attribue des pères Noëls secrets à l’aide de notre meilleure amie: les mathématiques.

Ce que nous disent les maths

La théorie des graphes

Une discipline passionnante des maths est la théorie des graphes. Elle permet de représenter des problèmes complexes sous forme de graphes.

Un graphe, c’est une structure, orientée ou non, composée de sommets et d’arêtes. Un exemple typique est d’utiliser un graphe pour représenter des voyages possibles entre destination. Dans ce cas, les sommets sont des villes et les arêtes représentent s’il est possible de se rendre de façon direct de la ville A à la ville B. Ceci est représenté dans l’illustration ci-dessous.

Graphe de villes
Graphe de villes

Dans cet exemple, il est possible de se rendre directement de Paris à Madrid mais pas de Paris à Berlin. Pour ce faire, il faut d’abord transiter par Bruxelles. Il est également possible de donner une valeur aux arêtes. Dans notre exemple, cela pourrait représenter la distance entre les villes. Ensuite, nous pourrions essayer de trouver le chemin le plus court entre deux villes.

Comme mentionné plus haut, une graphe peut être orienté ou non-orienté. L’exemple des villes est non-orienté : une arête existe entre Paris et Bruxelles, cela signifie qu’il est possible de se rendre de Paris à Bruxelles mais également de faire le chemin inverse, de Bruxelles à Paris. On pourrait imaginer rendre ce graphe orienté, ce qui représenterait des routes à sens unique par exemple.

Très bien, maintenant que nous avons un peu discuter de la théorie, essayons de l’appliquer à notre cas. Pour le Secret Santa, nos sommets seront les participants et les arêtes représenteront l’acte de donner un cadeau à quelqu’un. Il s’agira donc d’un graphe orienté car on veut savoir qui est la personne qui donne le cadeau et qui est celle qui le reçoit. Enfin, on ne donnera aucun poids à nos arêtes étant donné qu’on donne toujours un cadeau ou non. On ne donne jamais de demi-cadeau.

Hamilton à la rescousse

Des nombreux mathématiciens se sont intéressés aux graphes et celui qui va nous être utile ici est William Rowan Hamilton. En effet, un graphe est dit hamiltonien si il possède un cycle hamiltonien. Un cycle hamiltonien peut être décomposé en deux parties:

  • Cycle: Un chemin est considéré comme un cycle si le sommet duquel le chemin débute est également celui auquel il se termine
  • Hamiltonien: un chemin est hamiltonien s’il ne passe qu’une et une seule fois par chaque sommet

La bonne nouvelle, c’est qu’il s’agit exactement de ce que nous voulons! On veut que notre chemin implique tous les participants et ceux-ci ne doivent donner (et recevoir) qu’un seul cadeau. La mauvaise, c’est qu’il n’existe pas d’algorithme qui permette de trouver ce cycle hamiltonien en un temps polynomial.

Implémentation

Représentation du graphe

Maintenant que nous connaissons la théorie, voyons comment nous pouvons implémenter une solution en commençant pas la représentation. Une méthode typique pour représenter un graphe dirigé est d’utiliser un array tab à 2 dimensions où la valeur tab[x][y] représente si une arête existe depuis x vers y. Dans notre cas, nous utiliserons deux valeurs:

  • POSSIBLE si une arête peut être tracée de x vers y

  • IMPOSSIBLE si il n’existe pas d’arêtes de x vers y

    Ceci est représenté dans l’image ci-dessous où une case blanche représente l’état POSSIBLE et une case rouge l’état IMPOSSIBLE.

Représentation sous forme de tableau
Représentation sous forme de tableau

La diagonale est uniquement composée d’états impossible car personne ne peut être son propre Secret Santa. J’ai aussi ajouté une contrainte que les couples ne peuvent pas être le Secret Santa l’un de l’autre afin d’ajouter quelque contraintes (et je trouvais cela plus amusant). On pourrait très bien imaginer plus ou moins de contraintes (par exemple, si Donald était le Secret Santa de Pluto l’année dernière, on pourrait l’interdire cette année).

Trouver un cycle hamiltonien

Place maintenant à l’algorithme en tant que tel. Comme mentionné plus tôt, il est impossible de trouver un cycle hamiltonien en un temps polynomial. Il existe des algorithmes optimisés qui permettent de limiter le temps nécessaire à sa résolution mais si l’on se limite à un nombre limité d’amis avec qui passer Noël, une simple approche exhaustive fera l’affaire, d’autant plus que notre graphe comporte énormément d’arêtes.

Voici donc notre approche:

  1. Choisir un sommet au hasard, l’ajouter au cycle. Il s’agit de notre sommet de départ
  2. Pour ce sommet, choisir aléatoirement un autre sommet accessible.
  3. Ajouter ce nouveau sommet au cycle:
    • Si ce sommet a déjà été visité, le retirer du cycle et retourner à l’étape 2.
    • Si il n’a pas encore été visité et qu’il s’agit du dernier sommet, c’est terminé on a trouvé notre cycle.
    • Sinon, répéter l’étape deux à partir du nouveau sommet
Graphe avec cycle hamiltonien
Graphe avec cycle hamiltonien

Exemple en Java

Voyons un exemple d’une implémentation en Java.

public class Participant {

    private String name;
    private List<String> exclusionList;
//...
}
public class SecretSantaService {

    public enum Status {
        Possible,
        Impossible,
        ;
    }

    public List<Participant> computeHamiltonCycle(List<Participant> participants) {
        Map<Participant, Map<Participant, Status>> giftsTable = buildGiftsTable(participants);
        ArrayList<Participant> hamiltonCycle = new ArrayList<>();
        hamiltonCycle.add(participants.get(0));
        if (!computeHamiltonCycle(giftsTable, hamiltonCycle, participants.get(0))) {
            throw new IllegalArgumentException("Could not compute a proper secret santa based on provided participants");
        }

        return hamiltonCycle;
    }

    private Map<Participant, Map<Participant, Status>> buildGiftsTable(List<Participant> participants) {
        Map<Participant, Map<Participant, Status>> giftsTable = new HashMap<>();
        for(Participant givingParticipant : participants) {
            Map<Participant, Status> statusMap = new HashMap<>();
            for(Participant receivingParticipant : participants) {
                if (givingParticipant.getExclusionList() != null && givingParticipant.getExclusionList().contains(receivingParticipant.getName())) {
                    statusMap.put(receivingParticipant, Status.Impossible);
                } else {
                    statusMap.put(receivingParticipant, Status.Possible);
                }
            }
            giftsTable.put(givingParticipant, statusMap);
        }
        return giftsTable;
    }

    private boolean computeHamiltonCycle(Map<Participant, Map<Participant, Status>> giftsTable, List<Participant> hamiltonCycle, Participant givingParticipant) {
        if(hamiltonCycle.size() == giftsTable.keySet().size() && giftsTable.get(givingParticipant).get(hamiltonCycle.get(0)) == Status.Possible) {
            //We are done
            return true;
        }

        List<Participant> possibleReceivingParticipants = getPossibleReceivingParticipants(giftsTable.get(givingParticipant), hamiltonCycle);
        while(possibleReceivingParticipants.size() > 0) {
            int receivingParticipantIdx = getRandomNumberInRange(0, possibleReceivingParticipants.size() - 1);
            Participant receivingParticipant = possibleReceivingParticipants.get(receivingParticipantIdx);
            hamiltonCycle.add(receivingParticipant);
            if (computeHamiltonCycle(giftsTable, hamiltonCycle, receivingParticipant)) {
                return true;
            }

            hamiltonCycle.remove(receivingParticipant);
            possibleReceivingParticipants.remove(receivingParticipantIdx);
        }

        return false;
    }

    private List<Participant> getPossibleReceivingParticipants(Map<Participant,SecretSantaService.Status> receivingParticipants, List<Participant> hamiltonCycle) {
        return receivingParticipants.entrySet()
                .stream()
                .filter(e -> e.getValue() !=SecretSantaService.Status.Impossible && !hamiltonCycle.contains(e.getKey()))
                .map(Map.Entry::getKey)
                .collect(Collectors.toList());
    }

    private int getRandomNumberInRange(int min, int max) {

        if(min == max) {
            return min;
        }

        if (min > max) {
            throw new IllegalArgumentException("max must be greater than min");
        }

        Random r = new Random();
        return r.nextInt((max - min) + 1) + min;
    }
}

Tout commence par un appel à la méthode computeHamiltonCycle(List<Participant> participants) à laquelle on passe une liste de Participants. Chaque Participant est constitué d’un nom et d’une liste d’exclusions (afin d’exclure les couples par exemple).

La méthode computeHamiltonCycle(List<Participant> participants) va tout d’abord créer notre graphe (giftsTable) sur base des Participants reçus puis va appeler la méthode computeHamiltonCycle(Map<Participant, Map<Participant, Status>> giftsTable, List<Participant> hamiltonCycle, Participant givingParticipant) en lui fournissant le graphe fraichement créé, une le cycle hamiltonien (initialement uniquement composé du sommet de départ) ainsi que le premier participant duquel le cycle débutera.

Dans cette nouvelle méthode, on vérifie tout d’abord si on n’a pas déjà atteint la fin de notre calcul. Évidemment, pour la première itération, c’est un peu inutile mais comme nous allons avoir des appels récursifs, cela servira de condition d’arrêt.

Ensuite, on choisit une arête au hasard dans les arêtes disponibles ayant un sommet pas encore visité et on la suit afin d’atteindre le prochain sommet. On appelle alors notre méthode à nouveau en utilisant ce nouveau sommet. Si cet appel échoue (retourne false), on retire le sommet du cycle et on en choisit un autre aléatoirement.

Enfin, une fois que notre chemin est de longueur égale au nombre de participants, on vérifie que nous pouvons bien "fermer" le cycle en vérifiant que notre dernier sommet a le droit de donner un cadeau au sommet initial. Si c’est le cas, notre algorithme est terminé et nous avons trouvé notre cycle hamiltonien.

Créer un webservice

Afin de partager ce service avec le monde afin d’éviter à tous d’avoir des Secret Santa non-optimaux, on peut facilement utiliser Spring Boot et créer un Controller appelant notre service.

@Controller
@RequestMapping("/")
public class SecretSantaController {

    private final SecretSantaService secretSantaService;

    public SecretSantaController(SecretSantaService secretSantaService) {
        this.secretSantaService = secretSantaService;
    }

    @RequestMapping(method= RequestMethod.GET)
    public SecretSantaResponse getSecretSanta(SecretSantaRequest request) {
        var hamiltonCycle = secretSantaService.computeHamiltonCycle(request.getParticipants());
        List<SecretSanta> secretSantas = buildSecretSantas(hamiltonCycle);
        return new SecretSantaResponse(secretSantas);
    }

    private List<SecretSanta> buildSecretSantas(List<Participant> hamiltonCycle) {
        List<SecretSanta> secretSantas = new ArrayList<>();
        Participant previousParticipant = hamiltonCycle.get(hamiltonCycle.size() - 1);
        for (Participant participant : hamiltonCycle) {
            secretSantas.add(new SecretSanta(previousParticipant, participant));
            previousParticipant = participant;
        }
        return secretSantas;
    }
}

Un exemple de ceci est disponible à l’adresse https://secretsanta.migwel.dev/.

Test

Afin de vérifier que tout ceci fonctionne correctement, on peut écrire un test en utilisant JUnit.

    @Test
    void testGetValidSecretSanta() throws IOException {
        InputStream inStream = this.getClass().getClassLoader().getResourceAsStream("validsecretsantarequest.json");
        SecretSantaRequest request = new ObjectMapper().readValue(inStream, SecretSantaRequest.class);
        SecretSantaResponse response = controller.getSecretSanta(request);
        List<SecretSanta> secretSantas = response.getSecretSantas();
        assertEquals(request.getParticipants().size(), secretSantas.size());

        Set<Participant> givingParticipant = new HashSet<>();
        Set<Participant> receivingParticipant = new HashSet<>();
        Participant previousReceiver = secretSantas.get(secretSantas.size() - 1).getReceiver();
        for(SecretSanta secretSanta : secretSantas) {
            assertEquals(previousReceiver, secretSanta.getSecretSanta());
            assertFalse(givingParticipant.contains(secretSanta.getSecretSanta()));
            assertFalse(receivingParticipant.contains(secretSanta.getReceiver()));
            givingParticipant.add(secretSanta.getSecretSanta());
            receivingParticipant.add(secretSanta.getReceiver());
            previousReceiver = secretSanta.getReceiver();
        }
    }

Aussi, il est important de tester que si une requête est invalide (par exemple, si un des participants ne veut donner de cadeau à personne), le service doit échouer.

    @Test
    void testGetInvalidSecretSanta() throws IOException {
        InputStream inStream = this.getClass().getClassLoader().getResourceAsStream("invalidsecretsantarequest.json");
        SecretSantaRequest request = new ObjectMapper().readValue(inStream, SecretSantaRequest.class);
        assertThrows(IllegalArgumentException.class, () -> controller.getSecretSanta(request));
    }

Nous voici donc prêts à aborder les fêtes de fin d’année comme il se doit, sans avoir à craindre les horribles sous-boucles de Noël. Si vous souhaitez faire tourner votre propre instance, les sources sont disponibles sur Github.

9 commentaires

Je pense qu’il y a bien plus rapide pour trouver un cycle dans un graphe complet.

Le nombre de contrainte est assez faible je pense qu’on peut tirer un cycle aléatoire et vérifier que les contraintes sont respectées.

Ensuite, une modification du mélange de Fisher-Yates permettrait d’avoir un algorithme garanti en temps linéaire. À priori à minuit là comme ça je dirais que c’est un algo qui donnerait des résultats correctement distribué. Mais j’aimerais bien le vérifier. (Vérification faite, non. On risque d’arriver à une fin prématurée dès qu’il y a la moindre contrainte, ça implique donc du backtracking et donc un temps exponentiel, même si en pratique non pas du tout vu que le nombre de contrainte est très faible)

Ou alors il y a tout un pan du problème que je n’ai pas compris, mais je ne pense pas.

Édité par ache

ache.one                 🦹         👾                                🦊

+0 -0

J’aurais pu être plus clair dans ma description, je pense. Il n’y a théoriquement pas de solution linéaire / polynomiale dans le cas général mais en pratique, comme le nombre de contraintes est très faible comme tu le précises, l’approche "stupide" que je présente s’exécute en général de façon linéaire. Généralement, le nombre de "destinataires" potentiels est tellement élevé lorsqu’on traite un sommet qu’on doit rarement revenir en arrière et tout reprendre depuis le début.

+0 -0

Il n’y a effectivement pas d’interface graphique, c’est un simple web service. Tu peux facilement l’appeler en utilisant curl:

curl -H "Content-Type: application/json" -X POST -d '{"participants": [{"name" : "Mickey"},{"name" : "Minnie"},{"name" : "Donald"},{"name" : "Daisy"}]}' https://secretsanta.migwel.dev/ | json_pp

Réponse:

{
   "secretSantas" : [
      {
         "receiver" : {
            "name" : "Mickey",
            "exclusionList" : null,
            "email" : null
         },
         "secretSanta" : {
            "exclusionList" : null,
            "name" : "Donald",
            "email" : null
         }
      },
      {
         "receiver" : {
            "email" : null,
            "exclusionList" : null,
            "name" : "Minnie"
         },
         "secretSanta" : {
            "email" : null,
            "exclusionList" : null,
            "name" : "Mickey"
         }
      },
      {
         "receiver" : {
            "email" : null,
            "name" : "Daisy",
            "exclusionList" : null
         },
         "secretSanta" : {
            "email" : null,
            "name" : "Minnie",
            "exclusionList" : null
         }
      },
      {
         "secretSanta" : {
            "name" : "Daisy",
            "exclusionList" : null,
            "email" : null
         },
         "receiver" : {
            "exclusionList" : null,
            "name" : "Donald",
            "email" : null
         }
      }
   ]
}
+0 -0

Très très sympa ce billet ! (Tu devrais peut-être le proposer à la validation pour un article)

Holosmos

Merci ! C’est mon premier post donc je ne savais pas trop quoi choisir entre article et billet. La description disait qu’un article doit être plus objectif tandis qu’ici, je trouve que c’est un peu léger donc je pensais qu’un billet était plus approprié.

+0 -0

Je confirme que ça pourrait faire un article. Il passera par le processus de validation, ce qui fait un peu de travail pour l’auteur, mais permet généralement de peaufiner la publication et donc d’en augmenter la qualité !

+0 -0

Sympa! Bon travail.

Une méthode typique pour représenter un graphe dirigé est d’utiliser un array tab à 2 dimensions où la valeur tab[x][y] représente si une arête existe depuis x vers y

Les "trucs typiques" c’est souvent tellement typique qu’on leur a donné un nom :D Ici, je pense que ça vaut le coup de donner le nom (au moins en spoiler ou en balise info). C’est une matrice d’adjacence, non ?

Au cas où quelqu’un cherche dans un moteur le mot "matrice d’adjacence" et veut tomber sur un exemple précis, ça pourra sûrement l’aider de voir ça sur un exemple de Secret Santa.

Happiness is a warm puppy

+0 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte