Aller au contenu

Algorithme de Kruskal, union-find

Ce TP accompagne le chapitre Algorithmique avancée des graphes et utilise la structure du chapitre Union-Find.

Union-Find

On va définir un type

struct union_find {
    int *parent;
    int *rank;
    int nelements;
};
typedef struct union_find union_find;

Question 1. Écrire des fonctions de prototypes :

union_find uf_makesets(int n);
int uf_find(union_find uf, int x);
void uf_union(union_find uf, int x, int y);
void uf_free(union_find uf);

Ces fonctions vont réaliser les opérations vues en classe. Pour makesets on va réaliser des makeset des éléments de 0 à n-1 d'un coup en créant la valeur de type union_find (et notamment en allouant la mémoire). uf_free va alors libérer toute la mémoire allouée dans une telle structure.

Corrigé
union_find uf_makesets(int n)
{
    union_find uf;
    uf.parent = malloc(sizeof(int) * n);
    uf.rank = malloc(sizeof(int) * n);
    for(int i = 0; i < n; i++)
    {
        uf.parent[i] = i;
        uf.rank[i] = 0;
    }
    uf.nelements = n;
    return uf;
}

int uf_find(union_find uf, int x)
{
    if (uf.parent[x] != x)
        uf.parent[x] = uf_find(uf, uf.parent[x]);
    return uf.parent[x];
}

void uf_union(union_find uf, int x, int y)
{
    int rx = uf_find(uf, x);
    int ry = uf_find(uf, y);
    assert(rx != ry);

    if (uf.rank[rx] > uf.rank[ry])
        uf.parent[ry] = rx;
    else {
        uf.parent[rx] = ry;
        if (uf.rank[rx] == uf.rank[ry])
            uf.rank[ry] = uf.rank[ry] + 1;
    }
}

void uf_free(union_find uf)
{
    free(uf.parent);
    free(uf.rank);
}

Ici, on a choisi d'interdire l'union de deux éléments déjà dans le même ensemble avec un assert : c'est à l'appelant de tester uf_find(uf, x) != uf_find(uf, y) au préalable. Le chapitre Union-Find fait le choix inverse de rendre cet appel inoffensif. Il faut inclure stdlib.h et assert.h.

Question 2. Écrire des jeux de tests pour ces fonctions. Avec des assert par exemple.

Question 3. On veut comparer les différentes optimisations de cette structure de données. Pour cela, on rajoute deux nouveaux paramètres booléens compress et weighted qui vont indiquer respectivement si on effectue de la compression de chemin ou si on utilise les rangs pour équilibrer lors des unions. Reprendre les fonctions précédentes pour qu'elles tiennent compte de ces paramètres. On pourra remplacer le prototype de makesets pour initialiser les valeurs : union_find uf_makesets(int n, bool compress, bool weighted);

Corrigé

On ajoute les deux booléens à la structure (et on inclut stdbool.h) :

struct union_find {
    int *parent;
    int *rank;
    int nelements;
    bool compress;
    bool weighted;
};
typedef struct union_find union_find;

union_find uf_makesets(int n, bool compress, bool weighted)
{
    union_find uf;
    uf.compress = compress;
    uf.weighted = weighted;
    uf.parent = malloc(sizeof(int) * n);
    uf.rank = malloc(sizeof(int) * n);
    for(int i = 0; i < n; i++)
    {
        uf.parent[i] = i;
        uf.rank[i] = 0;
    }
    uf.nelements = n;
    return uf;
}

int uf_find(union_find uf, int x)
{
    if (uf.compress) {
        if (uf.parent[x] != x)
            uf.parent[x] = uf_find(uf, uf.parent[x]);
        return uf.parent[x];
    } else {
        while(uf.parent[x] != x)
            x = uf.parent[x];
        return x;
    }
}

void uf_union(union_find uf, int x, int y)
{
    int rx = uf_find(uf, x);
    int ry = uf_find(uf, y);
    assert(rx != ry);

    if (!uf.weighted || uf.rank[rx] > uf.rank[ry])
        uf.parent[ry] = rx;
    else {
        uf.parent[rx] = ry;
        if (uf.rank[rx] == uf.rank[ry])
            uf.rank[ry] = uf.rank[ry] + 1;
    }
}

uf_free est inchangée.

Question 4. Écrire une fonction

int *random_tests(int n, int m);

qui génère un jeu de test aléatoire de m unions pour n valeurs. Ce jeu de test sera représenté par un tableau t de 2m entiers indiquant qu'on souhaite effectuer des unions entre t[2*i] et t[2*i+1]. On ne demande pas que les unions soient valides ici.

Rappel : on peut utiliser rand() % A pour générer une valeur aléatoire entière entre 0 et A-1.

Corrigé
int *random_tests(int n, int m)
{
    int *fusions = malloc(sizeof(int) * 2 * m);

    for(int i = 0; i < 2*m; i++)
    {
        fusions[i] = rand() % n;
    }

    return fusions;
}

Question 5. Écrire une fonction

void apply_tests(union_find uf, int *tests, int m);

qui va appliquer les tests qu'on vient de générer sur un union_find qui aura été initialisé par le bon appel à uf_makesets.

Attention ici il faudra faire attention à ne faire l'union que si elle est valide, c'est-à-dire uniquement lorsqu'elle fusionne deux arbres de la forêt.

Corrigé
void apply_tests(union_find uf, int *tests, int m)
{
    for(int i = 0; i < m; i++)
    {
        int x = tests[2*i];
        int y = tests[2*i+1];

        if (uf_find(uf, x) != uf_find(uf, y))
            uf_union(uf, x, y);
    }
}

Il est possible de mesurer des temps simplement en C à l'aide de la fonction clock_t clock(); définie dans time.h.

Cette fonction renvoie le temps processeur consommé par le programme, exprimé en tops d'horloge ; une constante CLOCKS_PER_SEC permet alors d'en déduire un nombre de secondes ainsi :

clock_t start = clock();
// tâche à mesurer en temps
clock_t end = clock();
// le nombre flottant de secondes écoulées
double elapsed = ((double)(end-start)/CLOCKS_PER_SEC);

Question 6. En déduire des tracés de comparaison du coût moyen en fonction de n pour 2n unions. On comparera avec/sans l'union pondérée et la compression de chemins (quatre choix donc). Attention, il sera sûrement impossible de mesurer le temps pris par la version sans optimisation pour de grandes valeurs de n.

Remarque

Pour tracer des courbes, on peut utiliser gnuplot ou faire un export vers Python avec matplotlib. On peut même faire un export en CSV dans la console et copier-coller dans un tableur.

La partie suivante présente rapidement gnuplot.

Remarque

À titre indicatif, pour \(n = 20\,000\) et \(2n\) unions aléatoires, on a mesuré environ 0,6 s sans optimisation contre moins de 0,01 s dès qu'une des deux optimisations est activée. Sans optimisation, la hauteur des arbres peut devenir linéaire et chaque find coûte alors \(O(n)\).

Gnuplot en bref

Si on a un fichier data.dat avec des valeurs séparées en colonnes par des tabulations ('\t'). Par exemple :

0   0   0
1   1   1
2   4   8
3   9   27
4   16  64

on peut procéder ainsi pour afficher un tracé avec gnuplot :

$ gnuplot
gnuplot> plot "data.dat"

Par défaut, il va tracer la deuxième colonne en fonction de la première.

Pour tracer la colonne j en fonction de la colonne i :

gnuplot> plot "data.dat" using i:j

On peut demander de tracer des lignes avec :

gnuplot> plot "data.dat" using i:j with linespoint

Tout ou presque peut être abrégé donc la commande précédente peut s'écrire :

gnuplot> p "data.dat" u i:j w lp

Pour tracer plusieurs courbes, on peut mettre les descriptions à la suite :

gnuplot> p "data.dat" u 1:2 w lp, "data.dat" u 1:3 w lp

On peut aussi utiliser des boucles pour faire ça :

gnuplot> p for [col=2:3] "data.dat" using 1:col w lp

Pour écrire un fichier, on peut préciser un terminal de sortie, par exemple en png, avec :

gnuplot> set terminal pngcairo size 500,500
gnuplot> set output "fichier.png"

Arbre couvrant de poids minimal

On considère un graphe de points du plan dont les arêtes sont données dans un tableau :

struct edge {
    int u;
    int v;
};
typedef struct edge edge;

struct vertex {
    double x;
    double y;
};
typedef struct vertex vertex;

struct graph {
    vertex *vertices;
    int nvertices;
    edge *edges;
    int nedges;
};
typedef struct graph graph;

Ainsi, l'arête e relie les sommets d'indice e.u et e.v. Un sommet v correspond au point du plan (v.x, v.y).

Question 7. Écrire une fonction

double w(graph *g, edge *e);

qui renvoie la distance euclidienne entre les deux sommets donnés par une arête.

Attention pour avoir sqrt il faudra math.h et lier avec -lm.

Corrigé
double w(graph *g, edge *e)
{
    vertex a = g->vertices[e->u];
    vertex b = g->vertices[e->v];
    return sqrt( (a.x-b.x)*(a.x-b.x) + (a.y-b.y)*(a.y-b.y) );
}

Question 8. Écrire une fonction

graph randomize(int n, int m, double w, double h);

qui crée un graphe aléatoirement avec n sommets (n ≥ 2) et m arêtes. Les sommets auront des coordonnées dans [0,w] x [0,h].

Note : pour N assez grand, on peut générer aléatoirement un double dans [0,w] ainsi : w * ((double)(rand() % (N+1)) / N)

Corrigé
graph randomize(int n, int m, double w, double h)
{
    int N = 10000;
    graph g;

    g.nvertices = n;
    g.vertices = malloc(sizeof(vertex) * n);
    for(int i = 0; i < n; i++)
    {
        g.vertices[i].x = w * ((double)(rand() % (N+1)) / N);
        g.vertices[i].y = h * ((double)(rand() % (N+1)) / N);
    }
    g.nedges = m;
    g.edges = malloc(sizeof(edge) * m);
    for(int i = 0; i < m; i++)
    {
        int u = rand() % n;
        int v = rand() % (n-1);
        if (v == u) v = v + 1;
        g.edges[i].u = u;
        g.edges[i].v = v;
    }

    return g;
}

On évite ainsi les boucles \(u \smile u\) : si \(v = u\), alors \(u \le n-2\) et \(v + 1 \le n - 1\). Le paramètre w masque ici la fonction w de la question précédente, ce qui ne pose pas de problème puisqu'on ne l'utilise pas dans cette fonction. Le graphe obtenu peut contenir des arêtes multiples et n'est pas forcément connexe.

Question 9. Pour trier les arêtes on va utiliser qsort de stdlib.h. Cette fonction pose plusieurs problèmes :

  • il faut faire des conversions depuis void * explicitement ;
  • comme la fonction de comparaison ne prend pas de paramètre supplémentaire, on doit utiliser des variables globales.

On vous demande ainsi d'ajouter dans votre fichier :

graph *global_graph = NULL;
int compare_edges(const void *e1, const void *e2)
{
    double w1 = w(global_graph, (edge *)e1);
    double w2 = w(global_graph, (edge *)e2);
    if (w1 < w2)
        return -1;
    if (w1 > w2)
        return 1;
    return 0;
}

et pour trier les arêtes du graphe g, on pourra alors écrire :

global_graph = g;
qsort(g->edges, g->nedges, sizeof(edge), compare_edges);

Question 10. On sait qu'un arbre couvrant contient nvertices-1 arêtes, on va donc stocker un arbre couvrant comme un tableau d'entiers mst de cette longueur. mst[i] sera l'indice de la i-ème arête dans l'arbre.

Écrire ainsi une fonction

int *kruskal(graph *g);

qui renvoie le tableau mst en l'ayant alloué préalablement.

On s'assurera que le graphe est connexe en vérifiant que la structure union_find utilisée ne contient qu'un seul arbre.

Corrigé

On compte les arbres de la forêt en comptant les racines :

int uf_count_trees(union_find uf)
{
    int count = 0;
    for(int i = 0; i < uf.nelements; i++)
        if (uf.parent[i] == i)
            count++;
    return count;
}

int *kruskal(graph *g)
{
    global_graph = g;
    qsort(g->edges, g->nedges, sizeof(edge), compare_edges);

    int *mst = malloc(sizeof(int) * (g->nvertices - 1));
    union_find uf = uf_makesets(g->nvertices, true, true);
    int mst_size = 0;

    for (int i = 0; i < g->nedges; i++)
    {
        int ru = uf_find(uf, g->edges[i].u);
        int rv = uf_find(uf, g->edges[i].v);
        if (ru != rv)
        {
            uf_union(uf, ru, rv);
            mst[mst_size] = i;
            mst_size++;
        }
    }
    assert(uf_count_trees(uf) == 1);
    uf_free(uf);

    return mst;
}

Attention, les indices stockés dans mst sont ceux des arêtes après le tri : kruskal modifie l'ordre du tableau g->edges.

Remarque

Pour tester kruskal, on peut comparer, sur de nombreux petits graphes aléatoires connexes (4 à 6 sommets), le poids de l'arbre obtenu avec le minimum des poids des parties à \(n - 1\) arêtes qui forment un arbre, obtenu par énumération de toutes ces parties. C'est ainsi qu'ont été testés les corrigés de ce TP.

Question 11. Faire des essais de graphes aléatoires en affichant le graphe et par-dessus dans une autre couleur l'arbre couvrant de poids minimal obtenu.