Structure Union-Find

I/ Partition

1) Partition d'un ensemble

Une partition d'un ensemble E est une collection de sous-ensembles de E telle que

Par exemple voici une partition d'un ensemble de 30 éléments en 5 sous-ensembles :

{1/2,0.5,5/10,3/6,4/8,6/12,8/16,2/4}
{5/25,0.2,3/15,1/5,4/20,2/10}
{0.75,3/4,6/8,9/12,12/16}
{6/4,3/2,1.5,18/12,9/6,15/10,12/8}
{6/9,2/3,4/6,8/12}

On vérifie que par exemple la fraction 3/2 est dans le quatrième sous-ensemble et dans aucun autre.

2) Relation d'équivalence

La relation « est dans le même sous-ensemble que » est une relation d'équivalence :

Les sous-ensembles de la partition sont appelés classes d'équivalence de la partition. En abrégé on dira seulement classes. Mais ce n'est pas exactement la même notion que les classes Css ou celles de la programmation objet.

3) Structure Union-Find

Au début des années 1960, Bernard Galler et Michael Fischer ont proposé la structure Union-Find. Pour distinguer les sous-ensembles, on affecte à chacun une étiquette (ci-dessus, leur numéro). En machine cela se fait par un pointeur ou un tableau associatif. Dans la suite on choisit pour étiquette un des éléments du sous-ensemble, appelé représentant. Il ne représente pas le sous-ensemble, mais chacun de ses éléments. Une structure Union-Find est une partition dotée de deux méthodes :

Par exemple en appliquant Union entre la cinquième et la quatrième classe ci-dessus, on obtient cette nouvelle partition :

{1/2,0.5,5/10,3/6,4/8,6/12,8/16,2/4}
{5/25,0.2,3/15,1/5,4/20,2/10}
{0.75,3/4,6/8,9/12,12/16}
{6/4,3/2,1.5,18/12,9/6,15/10,12/8,6/9,2/3,4/6,8/12}

4) Applications

II/ Implémentations

1) Liste de listes

On représente E par une liste, dont les éléments sont eux-mêmes des listes (les classes) :

class UnionFind():
    def __init__(self):
        self.liste = []
    def makeSet(self,elt):
        if elt not in [e for k in self.liste for e in k ]:
            self.liste.append([elt])
    def is_representative(self,elt):
        return elt in [k[0] for k in self.liste]
    def find(self,elt):
        for k in range(len(self.liste)):
            if elt in self.liste[k]:
                return k,self.liste[k]
        return None
    def union(self,e1,e2):
        i,r1 = self.find(e1)
        j,r2 = self.find(e2)
        self.liste.remove(r1)
        self.liste[j] += r1

On ajoute une méthode makeSet permettant de créer une nouvelle classe singleton (en général pour la réunir ultérieurement à une autre classe, plus grosse). On rajoute également une méthode permettant de savoir si un élément est un représentant :

%3 2/4 2/4 1/2 1/2 2/4->1/2 3/6 3/6 3/6->2/4 4/8 4/8 4/8->3/6 5/10 5/10 5/10->4/8 6/12 6/12 6/12->5/10 8/16 8/16 8/16->6/12 0,5 0,5 0,5->8/16 2/10 2/10 1/5 1/5 2/10->1/5 3/15 3/15 3/15->2/10 4/20 4/20 4/20->3/15 5/25 5/25 5/25->4/20 0,2 0,2 0,2->5/25 6/8 6/8 3/4 3/4 6/8->3/4 9/12 9/12 9/12->6/8 12/16 12/16 12/16->9/12 0,75 0,75 0,75->12/16 6/4 6/4 3/2 3/2 6/4->3/2 9/6 9/6 9/6->6/4 12/8 12/8 12/8->9/6 15/10 15/10 15/10->12/8 18/12 18/12 18/12->15/10 1,5 1,5 1,5->18/12 4/6 4/6 2/3 2/3 4/6->2/3 6/9 6/9 6/9->4/6 8/12 8/12 8/12->6/9

Dans le graphe ci-dessous, les arcs vont vers le représentant qui est donc la queue de liste.

2) Tableau associatif

On peut implémenter sous forme de tableau associatif (en Python dictionnaire) la fonction qui, à chaque élément de E, associe son représentant :

class UnionFind():
    def __init__(self):
        self.dico = {}
    def makeSet(self,elt):
        if elt not in self.dico:
            self.dico[elt] = elt
    def is_representative(self,elt):
        return elt in self.dico and self.dico[elt]==elt
    def find(self,elt):
        if elt in self.dico:
            return self.dico[elt]
        else:
            return None
    def union(self,e1,e2):
        r1,r2 = self.find(e1),self.find(e2)
        for k in self.dico:
            if self.dico[k]==r1:
                self.dico[k] = r2

La modélisation est plus compacte :

%3 3/15 3/15 1/5 1/5 3/15->1/5 6/9 6/9 2/3 2/3 6/9->2/3 4/8 4/8 1/2 1/2 4/8->1/2 1,5 1,5 3/2 3/2 1,5->3/2 12/16 12/16 3/4 3/4 12/16->3/4 18/12 18/12 18/12->3/2 5/25 5/25 5/25->1/5 3/6 3/6 3/6->1/2 8/16 8/16 8/16->1/2 6/4 6/4 6/4->3/2 15/10 15/10 15/10->3/2 2/10 2/10 2/10->1/5 0,75 0,75 0,75->3/4 0,2 0,2 0,2->1/5 6/12 6/12 6/12->1/2 4/20 4/20 4/20->1/5 9/6 9/6 9/6->3/2 4/6 4/6 4/6->2/3 0,5 0,5 0,5->1/2 5/10 5/10 5/10->1/2 6/8 6/8 6/8->3/4 8/12 8/12 8/12->2/3 2/4 2/4 2/4->1/2 9/12 9/12 9/12->3/4 12/8 12/8 12/8->3/2

3) Forêt (graphe sans cycle)

Il est possible de rendre les deux méthodes find et union rapides toutes les deux, en remplaçant la fonction représentant par une fonction père telle que le représentant est le père du père du père ... jusqu'à ce qu'il n'y ait plus de père. Le graphe obtenu est une forêt, dont les composantes connexes sont des arbres. Le représentant est alors la racine de l'arbre. L'arbre est défini récursivement par la fonction père :

class UnionFind():
    def __init__(self):
        self.dico = {}
    def makeSet(self,elt):
        if elt not in self.dico:
            self.dico[elt] = None
    def is_representative(self,elt):
        return elt in self.dico and self.dico[elt] is None
    def find(self,elt):
        if elt in self.dico:
            pere = elt
            while self.dico[pere] is not None:
                pere = self.dico[pere]
            return pere
        else:
            return None
    def union(self,e1,e2):
        r1 = self.find(e1)
        self.dico[r1] = e2

III/ Compression des chemins

1) Hauteur des arbres

On a vu que l'efficacité de l'implémentation par une forêt dépend de la hauteur des arbres. Or celle-ci dépend de l'ordre dans lequel ont été réalisées les opérations union.

a) Union de chacun au précédent

Si, chaque fois qu'un élément est ajouté à E, on l'unit au plus récent élément de la même classe, on retrouve une structure similaire à celle vue au I/1 : liste de liste. Les arbres sont unaires.

b) Union de chacun au représentant

Si par contre dès qu'on a ajouté un élément on l'unit tout de suite à son représentant, on retrouve la représentation du I/2 : tous les arbres sont de hauteur 1.

c) Exemple

Si on unit 8/16 à 4/8 avant d'unir 4/8 à 2/4 l'arbre de représentant 1/2 est de hauteur 3 :

%3 3/15 3/15 1/5 1/5 3/15->1/5 6/9 6/9 2/3 2/3 6/9->2/3 4/8 4/8 2/4 2/4 4/8->2/4 1/2 1/2 2/4->1/2 1,5 1,5 15/10 15/10 1,5->15/10 3/2 3/2 15/10->3/2 12/16 12/16 6/8 6/8 12/16->6/8 3/4 3/4 6/8->3/4 18/12 18/12 6/4 6/4 18/12->6/4 6/4->3/2 5/25 5/25 5/25->1/5 3/6 3/6 3/6->1/2 8/16 8/16 8/16->4/8 2/10 2/10 2/10->1/5 0,75 0,75 0,75->3/4 0,2 0,2 0,2->2/10 6/12 6/12 6/12->3/6 4/20 4/20 4/20->2/10 9/6 9/6 9/6->3/2 4/6 4/6 4/6->2/3 0,5 0,5 5/10 5/10 0,5->5/10 5/10->1/2 8/12 8/12 8/12->4/6 9/12 9/12 9/12->3/4 12/8 12/8 12/8->6/4

Alors que si on unit 4/8 à 2/4 avant d'unir 8/16 à 4/8, l'arbre de représentant (racine) 1/2 a une hauteur 2 :

%3 3/15 3/15 1/5 1/5 3/15->1/5 6/9 6/9 2/3 2/3 6/9->2/3 4/8 4/8 2/4 2/4 4/8->2/4 1/2 1/2 2/4->1/2 1,5 1,5 15/10 15/10 1,5->15/10 3/2 3/2 15/10->3/2 12/16 12/16 6/8 6/8 12/16->6/8 3/4 3/4 6/8->3/4 18/12 18/12 6/4 6/4 18/12->6/4 6/4->3/2 5/25 5/25 5/25->1/5 3/6 3/6 3/6->1/2 8/16 8/16 8/16->2/4 2/10 2/10 2/10->1/5 0,75 0,75 0,75->3/4 0,2 0,2 0,2->2/10 6/12 6/12 6/12->3/6 4/20 4/20 4/20->2/10 9/6 9/6 9/6->3/2 4/6 4/6 4/6->2/3 0,5 0,5 5/10 5/10 0,5->5/10 5/10->1/2 8/12 8/12 8/12->4/6 9/12 9/12 9/12->3/4 12/8 12/8 12/8->6/4

2) Compression des chemins dans les arbres

Voici la même partition, mais avec un ordre différent des opérations d'union :

%3 3/15 3/15 2/10 2/10 3/15->2/10 1/5 1/5 2/10->1/5 6/9 6/9 2/3 2/3 6/9->2/3 4/8 4/8 3/6 3/6 4/8->3/6 2/4 2/4 3/6->2/4 1,5 1,5 15/10 15/10 1,5->15/10 3/2 3/2 15/10->3/2 12/16 12/16 6/8 6/8 12/16->6/8 3/4 3/4 6/8->3/4 18/12 18/12 6/4 6/4 18/12->6/4 6/4->3/2 5/25 5/25 4/20 4/20 5/25->4/20 4/20->3/15 1/2 1/2 2/4->1/2 8/16 8/16 6/12 6/12 8/16->6/12 5/10 5/10 6/12->5/10 0,75 0,75 0,75->6/8 0,2 0,2 0,2->5/25 5/10->4/8 9/6 9/6 9/6->15/10 4/6 4/6 4/6->2/3 0,5 0,5 0,5->8/16 8/12 8/12 8/12->2/3 9/12 9/12 9/12->3/4 12/8 12/8 12/8->6/4

Une première amélioration consiste à greffer, non pas sur le nœud sélectionné, mais sur son représentant :

    def union(self,e1,e2):
        r1 = self.find(e1)
        r2 = self.find(e2)
        self.dico[r1] = r2

on obtient alors cette nouvelle forêt, avec les mêmes unions successives :

%3 3/15 3/15 2/10 2/10 3/15->2/10 1/5 1/5 2/10->1/5 6/9 6/9 2/3 2/3 6/9->2/3 4/8 4/8 3/6 3/6 4/8->3/6 2/4 2/4 3/6->2/4 1,5 1,5 15/10 15/10 1,5->15/10 3/2 3/2 15/10->3/2 12/16 12/16 6/8 6/8 12/16->6/8 3/4 3/4 6/8->3/4 18/12 18/12 6/4 6/4 18/12->6/4 6/4->3/2 5/25 5/25 4/20 4/20 5/25->4/20 4/20->3/15 1/2 1/2 2/4->1/2 8/16 8/16 6/12 6/12 8/16->6/12 5/10 5/10 6/12->5/10 0,75 0,75 0,75->6/8 0,2 0,2 0,2->5/25 5/10->4/8 9/6 9/6 9/6->15/10 4/6 4/6 4/6->2/3 0,5 0,5 0,5->5/10 8/12 8/12 8/12->2/3 9/12 9/12 9/12->3/4 12/8 12/8 12/8->6/4

Mais on peut aussi modifier la méthode find pour qu'en parcourant les nœuds à la recherche du représentant, elle en profite pour les faire adopter par le représentant :

    def find(self,elt):
        if elt in self.dico:
            pere = elt
            vus = [elt]
            while self.dico[pere] is not None:
                pere = self.dico[pere]
                vus.append(pere)
            for s in vus:
                if self.dico[s] is not None:
                    self.dico[s] = pere
            return pere
        else:
            return None

C'est un peu comme une mémoïsation (on stocke les nœuds parcourus) mais avec effet de bord (la racine devient père adoptif de tous les nœuds parcourus).

En fait la fonction find est récursive : si le nœud n'a pas de père il est son propre représentant, sinon on applique la fonction find à son père car ils ont le même représentant.

On obtient la forêt suivante, avec les conditions précédentes :

%3 3/15 3/15 2/10 2/10 3/15->2/10 1/5 1/5 2/10->1/5 6/9 6/9 2/3 2/3 6/9->2/3 4/8 4/8 3/6 3/6 4/8->3/6 2/4 2/4 3/6->2/4 1,5 1,5 15/10 15/10 1,5->15/10 3/2 3/2 15/10->3/2 12/16 12/16 6/8 6/8 12/16->6/8 3/4 3/4 6/8->3/4 18/12 18/12 6/4 6/4 18/12->6/4 6/4->3/2 5/25 5/25 4/20 4/20 5/25->4/20 4/20->3/15 1/2 1/2 2/4->1/2 8/16 8/16 5/10 5/10 8/16->5/10 5/10->4/8 0,75 0,75 0,75->6/8 0,2 0,2 0,2->5/25 6/12 6/12 6/12->5/10 9/6 9/6 9/6->15/10 4/6 4/6 4/6->2/3 0,5 0,5 0,5->5/10 8/12 8/12 8/12->2/3 9/12 9/12 9/12->3/4 12/8 12/8 12/8->6/4

Pour faire des manipulations sur une forêt voir cette application en ligne.

3) Coût amorti

a) Principe

Le coût en temps n'est pas meilleur avec la représentation par forêt dans le pire cas : dans ce cas la hauteur de chaque arbre est égale à 1 de moins que sa taille et le coût dans le pire cas est linéaire. Or le pire cas est rare, et on dispose d'heuristiques permettant d'éviter le pire cas.

La modification de find ralentit cette méthode (puisqu'on y ajoute des instructions) mais au bénéfice de maintenir relativement bas le coût de union. C'est comme si on effectuait des dépenses prématurées et en apparence inutiles pour engranger des bénéfices par la suite. D'où le nom de coût amorti donné par Bob Tarjan en 1985.

Union Find est le troisième exemple donné par Bob Tarjan dans l'article fondateur sur le coût amorti.

b) Union par taille

Comme l'union n'est pas commutative, on a le choix entre

On peut choisir d'unir l'arbre de hauteur la plus faible à celui de hauteur la plus grande. Le coût est modélisé par 1 de plus que la plus grande hauteur.