Information

Author(s) Alexis Englebert
Deadline Geen deadline
Submission limit No limitation

Sign in

Graphes 4 - Arbres, forêts et algorithme de Kruskal

Arbres et forêts

Un arbre est un graphe connexe qui ne possède pas de cycle. Une forêt est un graphe sans cycle : ses composantes connexes sont donc des arbres. Un nœud isolé est un arbre (à un nœud et sans arête), et un graphe sans arête est une forêt.

Rappel de quelques propriétés équivalentes vues au cours, pour un graphe \(G = (N, R)\) :

  1. \(G\) est connexe et sans cycle (c'est un arbre) ;
  2. \(G\) est connexe et \(|R| = |N| - 1\) ;
  3. \(G\) est sans cycle et \(|R| = |N| - 1\) ;
  4. \(G\) est sans cycle et lui ajouter une arête crée un et un seul cycle.
/course/LINFO1114/arbres_kruskal/foret.svg

À gauche, une forêt de trois arbres (une couleur par arbre). À droite, un graphe qui n'est pas une forêt : l'arête \(3 - 6\) ferme le cycle \((1, 3, 6, 1)\).

Arbres sous-tendants de poids minimum

Soit \(G = (N, R)\) un graphe connexe et \(c : R \to \mathbb{R}^{>0}\) une fonction qui associe un poids à chaque arête. Un arbre \(A = (N, R')\) est un arbre sous-tendant \(G\) si \(R' \subseteq R\) : il relie tous les nœuds de \(G\) en utilisant uniquement des arêtes de \(G\). Son poids est \(c(A) = \sum_{r \in R'} c(r)\). Un arbre sous-tendant de poids minimum est un arbre sous-tendant dont le poids est le plus petit possible. Un graphe possède un arbre sous-tendant si et seulement s'il est connexe.

/course/LINFO1114/arbres_kruskal/kruskal.svg

L'exemple des slides : un arbre sous-tendant de poids minimum (en vert), de poids \(2 + 3 + 4 + 5 + 7 = 21\).

Représentation des graphes et union-find

Dans les deux exercices, le graphe a n nœuds numérotés de 0 à n - 1, et edges est la liste de ses arêtes (non orientées). Le graphe n'est pas forcément simple : une arête peut apparaître plusieurs fois et une arête (u, u) est une boucle.

Les deux exercices reposent sur la même question : si j'ajoute l'arête \(\{u, v\}\) aux arêtes déjà retenues, est-ce que je crée un cycle ? C'est le cas si et seulement si \(u\) et \(v\) sont déjà reliés par un chemin, c'est-à-dire s'ils sont dans la même composante connexe. Pour répondre efficacement à cette question, on vous donne une structure union-find, qui maintient les composantes connexes au fur et à mesure qu'on ajoute des arêtes. Elle n'est pas la matière du cours : vous pouvez l'utiliser telle quelle, sans comprendre son fonctionnement interne. Sur INGInious, la classe UnionFind est déjà importée avant votre code : ne la recopiez pas (pour tester sur votre ordinateur, copiez-la au début de votre fichier).

class UnionFind:
    """Partition des nœuds 0, 1, ..., n - 1 en ensembles disjoints.

    Au départ, chaque nœud est seul dans son ensemble. Si on appelle union(u, v) pour chaque arête (u, v)
    d'un graphe, les ensembles sont exactement les composantes connexes de ce graphe.
    Chaque opération coûte un temps quasi constant.
    """

    def __init__(self, n):
        self.parent = list(range(n))
        self.taille = [1] * n

    def find(self, x):
        """Renvoie le représentant de l'ensemble qui contient x.
        Deux nœuds sont dans le même ensemble si et seulement s'ils ont le même représentant."""
        racine = x
        while self.parent[racine] != racine:
            racine = self.parent[racine]
        while self.parent[x] != racine:
            self.parent[x], x = racine, self.parent[x]
        return racine

    def union(self, x, y):
        """Fusionne l'ensemble qui contient x et celui qui contient y."""
        rx, ry = self.find(x), self.find(y)
        if rx == ry:
            return
        if self.taille[rx] < self.taille[ry]:
            rx, ry = ry, rx
        self.parent[ry] = rx
        self.taille[rx] += self.taille[ry]

Exemple d'utilisation :

uf = UnionFind(4)             # quatre ensembles : {0}, {1}, {2}, {3}
uf.union(0, 1)                # {0, 1}, {2}, {3}
uf.union(2, 3)                # {0, 1}, {2, 3}
uf.find(0) == uf.find(1)      # True  : 0 et 1 sont dans le même ensemble
uf.find(1) == uf.find(2)      # False

Question 1: Le graphe est-il une forêt ?

Écrivez une fonction est_foret(n, edges) qui renvoie True si le graphe est une forêt (c'est-à-dire s'il ne contient aucun cycle) et False sinon.

edges est une liste de couples (u, v). Par exemple, la forêt dessinée à gauche plus haut s'écrit :

n = 9
edges = [(0, 1), (0, 2), (1, 3), (1, 4), (5, 6), (6, 7)]
est_foret(n, edges)   # True : trois arbres, {0, 1, 2, 3, 4}, {5, 6, 7} et {8}

et le graphe de droite :

n = 7
edges = [(0, 1), (1, 2), (1, 3), (4, 5), (1, 6), (3, 6)]
est_foret(n, edges)   # False : cycle (1, 3, 6, 1)

Évaluation. Votre fonction est testée sur des forêts et des graphes avec cycle, jusqu'à 5000 nœuds (2 secondes par test). La note est le plus petit des deux taux de réussite (forêts reconnues, graphes avec cycle rejetés) : une fonction qui répond toujours la même chose obtient 0. Le feedback dessine chaque petit graphe, avec ses arbres ou un de ses cycles.

Question 2: L'algorithme de Kruskal

Écrivez une fonction kruskal(n, edges) qui renvoie un arbre sous-tendant de poids minimum du graphe, ou None si le graphe n'est pas connexe.

Ici, edges est une liste de triplets (u, v, c) : une arête entre u et v, de poids c > 0 (un entier ou un nombre à virgule). L'arbre est renvoyé sous la forme d'une liste de triplets, qui doivent être des éléments de edges ; l'ordre de la liste et le sens de chaque arête ((u, v, c) ou (v, u, c)) n'ont pas d'importance. Par exemple, pour l'exemple des slides (avec \(A, B, C, X, Y, Z = 0, 1, 2, 3, 4, 5\)) :

n = 6
edges = [(5, 2, 2), (2, 4, 8), (4, 3, 5), (3, 0, 11), (0, 5, 3), (5, 1, 7),
         (1, 3, 6), (0, 1, 12), (1, 2, 10), (5, 4, 9), (4, 1, 4)]
kruskal(n, edges)   # [(5, 2, 2), (0, 5, 3), (4, 1, 4), (4, 3, 5), (5, 1, 7)], poids 21

Algorithme de Kruskal (slides du cours), pour un graphe \(G = (N, R)\) connexe :

  • Init : \(R_{ord} :=\) les arêtes de \(R\) triées par poids croissant, \(R' := \varnothing\).
  • Loop : tant que \(|R'| < |N| - 1\) : on retire de \(R_{ord}\) la première arête \(\alpha\) (la plus légère qui reste) ; si \((N, R' \cup \{\alpha\})\) est sans cycle, alors \(R' := R' \cup \{\alpha\}\).
  • Result : \((N, R')\) est un arbre sous-tendant \(G\) de poids minimum.

Le test « \((N, R' \cup \{\alpha\})\) est sans cycle » est celui de l'exercice précédent : \((N, R')\) est une forêt, et lui ajouter \(\alpha = \{u, v\}\) crée un cycle si et seulement si \(u\) et \(v\) sont déjà dans le même arbre. Utilisez UnionFind pour le faire en temps quasi constant. Pour trier les arêtes par poids, utilisez sorted(edges, key=lambda e: e[2]).

Graphe non connexe
L'algorithme du cours suppose \(G\) connexe. Si \(G\) n'est pas connexe, \(R_{ord}\) se vide avant que \(R'\) n'atteigne \(|N| - 1\) arêtes : arrêtez-vous quand il n'y a plus d'arête à examiner et renvoyez None.
Égalités et efficacité
  • Si plusieurs arêtes ont le même poids, il peut exister plusieurs arbres de poids minimum : n'importe lequel est accepté.
  • Les plus grands tests ont jusqu'à 5000 nœuds et 50 000 arêtes (2 secondes par test). Avec UnionFind, l'algorithme coûte \(\mathcal{O}(|R| \log |R|)\) (le tri). Chercher un chemin entre \(u\) et \(v\) par un parcours du graphe à chaque arête coûte \(\mathcal{O}(|R| \cdot |N|)\) : c'est trop lent.

Évaluation. La note est le plus petit des deux taux de réussite (arbres de poids minimum corrects pour les graphes connexes, None pour les graphes non connexes). Pour un graphe connexe, la réponse doit être une liste de \(|N| - 1\) arêtes du graphe, sans cycle, dont le poids total est minimum. Le feedback dessine votre arbre sur chaque petit graphe.