Séance 6 — Ensembles disjoints et tris

Union-find, tri par insertion, tri fusion, tri rapide et borne inférieure

Dr. El Hadji Bassirou TOURÉ

Département de Mathématiques et Informatique, Faculté des Sciences et Techniques, Université Cheikh Anta Diop de Dakar

Objectifs de la séance

Ce que la séance construit

Deux familles d’algorithmes qui terminent le cours : les ensembles disjoints (union-find), qui répondent en un temps presque constant à la question « \(p\) et \(q\) sont-ils reliés ? », et les tris par comparaison, dont la séance établit la borne inférieure. Chaque structure est montrée sur le code Java du cours, pas à pas, et mesurée.

  • Dérouler QuickFind et QuickUnion sur une suite d’unions, avec les tableaux id[] et parent[] et la forêt.
  • Construire l’union pondérée par la taille, démontrer la hauteur au plus \(\log_2 n\) et appliquer la compression de chemin.
  • Refaire l’algorithme de Kruskal de la Séance 5 avec union-find et mesurer le gain.
  • Dérouler le tri par insertion, le tri fusion et la partition du tri rapide, et compter leurs comparaisons.
  • Reconnaître un tri stable et expliquer le pire cas du tri rapide.
  • Démontrer la borne \(\Omega(n\log n)\) des tris par comparaison par un arbre de décision.

Des villages raccordés au réseau

Huit villages de la Petite Côte, raccordés au réseau électrique liaison après liaison.

Lecture. Après les sept raccordements, une seule composante. Les liaisons de droite servent à Kruskal (section 5).

L’interface UF

int find(int p);                // identifiant de la composante de p
void union(int p, int q);       // reunir les composantes de p et de q
int count();                    // nombre de composantes

default boolean connected(int p, int q) {
    return find(p) == find(q);  // meme composante
}

Ensembles disjoints

Une partition de \(\{0,\dots,n-1\}\) en composantes : union(p, q) les réunit, find(p) identifie celle de \(p\), connected(p, q) teste \(\texttt{find(p)}=\texttt{find(q)}\).

Ce que la Séance 5 attendait

Kruskal rejette \(v\)–\(w\) si connected(v, w), et garde l’arête par union(v, w). Le test par parcours atteignait \(38\,476\,757\) sommets pour \(V=4\,000\).

Lecture. acces() : lectures et écritures de id[] ou parent[] (Sedgewick et Wayne, § 1.5).

QuickFind : un identifiant par élément

private final int[] id;  // id[p] : composante de p
private int count;       // nombre de composantes
private long acces;      // acces a id[]

public QuickFindUF(int n) {
    id = new int[n];
    for (int i = 0; i < n; i++) id[i] = i;
    count = n;
}

public int find(int p) {
    acces++;
    return id[p];
}
public void union(int p, int q) {
    int pid = find(p);
    int qid = find(q);
    if (pid == qid) return;  // deja reunis
    for (int i = 0; i < id.length; i++) {
        acces++;
        if (id[i] == pid) {
            id[i] = qid;
            acces++;
        }
    }
    count--;
}

Lecture. id[p] est l’identifiant de la composante de \(p\) : find coûte un accès, mais union lit les \(n\) cases et réécrit toute la composante de \(p\).

QuickFind, pas à pas

Sortie \(\rightarrow\) 

true
[7, 7, 7, 7, 7, 7, 7, 7]

Lecture. Ligne 1 : huit villages, chacun seul dans sa composante : id[p] vaut \(p\).

Lecture. Lignes 3–7, 11 : union(0, 1) : pid \(=0\), qid \(=1\) ; la boucle lit les 8 cases et la case 0 passe à 1.

Lecture. Lignes 3–7, 11 : union(1, 2) : pid \(=1\), qid \(=2\) ; la boucle lit les 8 cases et les cases 0, 1 passent à 2.

Lecture. Lignes 3–7, 11 : union(2, 3) : pid \(=2\), qid \(=3\) ; la boucle lit les 8 cases et les cases 0, 1, 2 passent à 3.

Lecture. Lignes 3–7, 11 : union(4, 5) : pid \(=4\), qid \(=5\) ; la boucle lit les 8 cases et la case 4 passe à 5.

Lecture. Lignes 3–7, 11 : union(6, 7) : pid \(=6\), qid \(=7\) ; la boucle lit les 8 cases et la case 6 passe à 7.

Lecture. Lignes 3–7, 11 : union(5, 7) : pid \(=5\), qid \(=7\) ; la boucle lit les 8 cases et les cases 4, 5 passent à 7.

Lecture. Lignes 3–7, 11 : union(3, 7) : pid \(=3\), qid \(=7\) ; les quatre cases de valeur 3 passent à 7.

Lecture. Ligne 12 : connected(0, 6) se lit en deux accès : id[0] et id[6] valent 7.

Lecture. Ligne 13 : 7 unions, chacune a lu les 8 cases : une union coûte \(\Theta(n)\), un find un accès.

QuickUnion : une forêt

protected final int[] parent;
protected int count;
protected long acces;   // acces a parent[]

public QuickUnionUF(int n) {
    parent = new int[n];
    for (int i = 0; i < n; i++) parent[i] = i;
    count = n;
}

public int find(int p) {
    acces++;            // premier test
    while (p != parent[p]) {
        p = parent[p];  // monter
        acces += 2;     // lecture + test
    }
    return p;           // la racine
}
public void union(int p, int q) {
    int i = find(p);
    int j = find(q);
    if (i == j) return;
    parent[i] = j;  // i sous j
    acces++;
    count--;
}

Lecture. Une racine est son propre parent et identifie son arbre. find remonte à la racine : \(2d+1\) accès à la profondeur \(d\) ; union écrit une case.

QuickUnion, pas à pas

Sortie \(\rightarrow\) 

true

Lecture. Ligne 1 : huit arbres d’un seul nœud : chaque élément est sa propre racine.

Lecture. Lignes 3–4, 7–10, 13 : union(0, 1) : 0 et 1 sont des racines ; parent[0] \(=1\).

Lecture. Lignes 3–4, 7–10, 13 : union(1, 2) : 1 et 2 sont des racines ; parent[1] \(=2\).

Lecture. Lignes 3–4, 7–10, 13 : union(2, 3) : 2 et 3 sont des racines ; parent[2] \(=3\).

Lecture. Lignes 3–4, 7–10, 13 : union(4, 5) : 4 et 5 sont des racines ; parent[4] \(=5\).

Lecture. Lignes 3–4, 7–10, 13 : union(6, 7) : 6 et 7 sont des racines ; parent[6] \(=7\).

Lecture. Lignes 3–4, 7–10, 13 : union(5, 7) : 5 et 7 sont des racines ; parent[5] \(=7\).

Lecture. Lignes 3–4, 7–10, 13 : union(3, 7) : 3 et 7 sont des racines ; parent[3] \(=7\).

Lecture. Lignes 3–4, 14 : find(0) monte 0 → 1 → 2 → 3 → 7 : 4 liens, 9 accès ; find(6) : 6 → 7. La hauteur est 4.

Le pire cas de QuickUnion

for (int n : new int[] {1000, 2000, 4000, 8000}) {
    QuickUnionUF qu = new QuickUnionUF(n);
    WeightedQuickUnionUF wqu = new WeightedQuickUnionUF(n);
    for (int i = 1; i < n; i++) {
        qu.union(0, i);   // la racine de 0 sous i
        wqu.union(0, i);
    }
    System.out.printf(Locale.ROOT, "%4d : QuickUnion %8d acces, hauteur %4d ; "
        + "ponderee %5d acces, hauteur %d%n", n, qu.acces(), qu.hauteur(),
        wqu.acces(), wqu.hauteur());
}

Résultat \(\rightarrow\)

1000 : QuickUnion   999999 acces, hauteur  999 ; ponderee  2997 acces, hauteur 1
2000 : QuickUnion  3999999 acces, hauteur 1999 ; ponderee  5997 acces, hauteur 1
4000 : QuickUnion 15999999 acces, hauteur 3999 ; ponderee 11997 acces, hauteur 1
8000 : QuickUnion 63999999 acces, hauteur 7999 ; ponderee 23997 acces, hauteur 1

Lecture. union(0, i) place la racine de 0 sous \(i\) : la chaîne s’allonge d’un lien à chaque appel, et find(0) la remonte en entier. \(n^2-1\) accès et hauteur \(n-1\) pour QuickUnion, \(3(n-1)\) accès et hauteur 1 pour l’union pondérée.

L’union pondérée par la taille

protected final int[] taille;  // taille[r], r racine

@Override
public void union(int p, int q) {
    int i = find(p);
    int j = find(q);
    if (i == j) return;
    if (taille[i] < taille[j]) {  // i sous j
        parent[i] = j;
        taille[j] += taille[i];
    } else {                      // j sous i
        parent[j] = i;
        taille[i] += taille[j];
    }
    acces++;
    count--;
}

Hauteur au plus \(\log_2 n\)

La profondeur d’un élément \(x\) n’augmente que lorsque la racine de son arbre passe sous une autre racine, d’un arbre au moins aussi grand : la taille de l’arbre de \(x\) au moins double. Elle ne dépasse pas \(n\) : la profondeur de \(x\) augmente au plus \(\log_2 n\) fois.

Coût. find et union en \(\mathcal{O}(\log n)\) (les accès à taille[] ne sont pas comptés). L’union par le rang (CLRS, § 19.3) range une borne de la hauteur au lieu de la taille ; elle donne la même borne.

L’union pondérée par la taille, pas à pas

Sortie \(\rightarrow\) 

0 8

Lecture. Lignes 2–5, 9–10, 14 : mêmes raccordements, find de QuickUnion ; au départ, taille[i] \(=1\) : 1 passe sous 0, taille[0] \(=2\).

Lecture. Lignes 2–5, 9–10, 14 : union(1, 2) : racines 0 et 2 ; taille[0] \(=2\geq1\) : 2 passe sous 0, taille[0] \(=3\).

Lecture. Lignes 2–5, 9–10, 14 : union(2, 3) : racines 0 et 3 ; taille[0] \(=3\geq1\) : 3 passe sous 0, taille[0] \(=4\).

Lecture. Lignes 2–5, 9–10, 14 : union(4, 5) : racines 4 et 5 ; tailles égales (1) : 5 passe sous 4, taille[4] \(=2\).

Lecture. Lignes 2–5, 9–10, 14 : union(6, 7) : racines 6 et 7 ; tailles égales (1) : 7 passe sous 6, taille[6] \(=2\).

Lecture. Lignes 2–5, 9–10, 14 : union(5, 7) : racines 4 et 6 ; tailles égales (2) : 6 passe sous 4, taille[4] \(=4\).

Lecture. Lignes 2–5, 9–10, 14 : union(3, 7) : racines 0 et 4 ; tailles égales (4) : 4 passe sous 0, taille[0] \(=8\).

Lecture. Ligne 15 : find(7) : 7 → 6 → 4 → 0 ; hauteur 3 \(=\log_2 8\), contre 4 pour QuickUnion.

Exercice : QuickUnion et union pondérée

Six éléments, cinq unions

union(0, 1), union(1, 2), union(3, 4), union(2, 4), union(5, 4).

  1. parent[] après chaque union, en QuickUnion.
  2. parent[] et la taille de la racine, en union pondérée.
  3. La hauteur finale de chaque forêt.
  4. Les accès de find(4) dans chacune.
union QuickUnion pondérée taille
(0, 1) 1 1 2 3 4 5 0 0 2 3 4 5 [t[0] = 2]{.fragment .etape-only fragment-index=1}
(1, 2) 1 2 2 3 4 5 0 0 0 3 4 5 [t[0] = 3]{.fragment .etape-only fragment-index=1}
(3, 4) 1 2 2 4 4 5 0 0 0 3 3 5 [t[3] = 2]{.fragment .etape-only fragment-index=1}
(2, 4) 1 2 4 4 4 5 0 0 0 0 3 5 [t[0] = 5]{.fragment .etape-only fragment-index=1}
(5, 4) 1 2 4 4 4 4 0 0 0 0 3 0 [t[0] = 6]{.fragment .etape-only fragment-index=1}

Correction

  1. La racine de \(p\) passe sous celle de \(q\) : 0, 1, 2 forment une chaîne sous 4.
  2. En (2, 4), l’arbre de 0 (3 éléments) reçoit celui de 3 (2) ; en (5, 4), 5 passe sous 0.
  3. 3 (0 → 1 → 2 → 4) contre 2 (4 → 3 → 0).
  4. 4 est racine en QuickUnion : 1 accès ; profondeur 2 en pondérée : 5 accès.

La compression de chemin

@Override
public int find(int p) {
    int racine = p;
    acces++;
    while (racine != parent[racine]) {
        racine = parent[racine];
        acces += 2;
    }
    while (p != racine) {    // second passage
        int suivant = parent[p];
        parent[p] = racine;  // rattache a la racine
        p = suivant;
        acces += 2;
    }
    return racine;
}

Compression de chemin

Après avoir trouvé la racine, find parcourt une seconde fois le chemin et rattache chaque élément directement à la racine. Le chemin suivant est plus court pour tous ces éléments et pour leurs descendants.

PathCompressionUF hérite de l’union pondérée et ne remplace que find : \(4d+1\) accès pour un élément de profondeur \(d\).

La compression de chemin, pas à pas

Sortie \(\rightarrow\) 

7
[7, 7, 7, 7, 5, 7, 7, 7]

Lecture. Lignes 2, 14 : find(0) sur la forêt finale de QuickUnion ; racine part de 0.

Lecture. Lignes 3–4 : racine \(=1\) : la première boucle monte d’un niveau.

Lecture. Lignes 3–4 : racine \(=2\) : la première boucle monte d’un niveau.

Lecture. Lignes 3–4 : racine \(=3\) : la première boucle monte d’un niveau.

Lecture. Lignes 3–4 : racine \(=7\) : c’est la racine, la boucle s’arrête.

Lecture. Lignes 6–9 : \(p=0\) : parent[0] passe de 1 à 7, puis \(p=1\).

Lecture. Lignes 6–9 : \(p=1\) : parent[1] passe de 2 à 7, puis \(p=2\).

Lecture. Lignes 6–9 : \(p=2\) : parent[2] passe de 3 à 7, puis \(p=3\).

Lecture. Lignes 6–9 : \(p=3\) : parent[3] vaut déjà 7, la case est réécrite ; \(p=7\).

Lecture. Lignes 6, 11, 14 : \(p=7\), fin de la boucle ; find(0) rend 7, le chemin est aplati.

Lecture. Ligne 15 : hauteur 2 au lieu de 4 : un nouveau find(0) ne suit qu’un lien.

La mesure des quatre versions

Random r = new Random(2026);
System.out.println("    n  paires  QuickFind QuickUnion ponderee compression");
for (int n = 1000; n <= 64000; n *= 2) {
    int[][] paires = Paires.jusquaConnexe(n, r);
    UF[] ufs = {new QuickFindUF(n), new QuickUnionUF(n),
        new WeightedQuickUnionUF(n), new PathCompressionUF(n)};
    System.out.printf("%5d %7d", n, paires.length);
    for (UF uf : ufs) {
        for (int[] x : paires) {
            if (!uf.connected(x[0], x[1])) uf.union(x[0], x[1]);
        }
        double parPaire = (double) uf.acces() / paires.length;
        System.out.printf(Locale.ROOT, " %10.1f", parPaire);
    }
    System.out.println();
}

Lecture. Paires.jusquaConnexe tire des paires \((p, q)\) au hasard jusqu’à ce qu’une seule composante reste ; chaque version reçoit les mêmes paires et fait un connected, puis un union si la paire n’est pas reliée.

Les quatre versions mesurées

{.t-scriptsize}Paires au hasard (Random(2026)), \(n\) de \(1\,000\) à \(64\,000\) ; à \(64\,000\) : \(327\,882\) paires.

Lecture. QuickFind et QuickUnion coûtent des milliers d’accès par paire, qui croissent avec \(n\) (\(\times38\) et \(\times68\) de \(1\,000\) à \(64\,000\)). L’union pondérée coûte 10,2 accès par paire et garde une hauteur de 7 pour \(\log_2 n\approx16\) ; la compression ramène la hauteur à 2, pour 10,8 accès.

Quoi, pourquoi, quand ça échoue : union-find

Quoi

Une forêt rangée dans parent[] ; find remonte à la racine, union relie deux racines, la plus petite sous la plus grande, et find aplatit le chemin parcouru.

Pourquoi on l’utilise

Avec union pondérée (par la taille ou par le rang) et compression, \(m\) opérations sur \(n\) éléments coûtent \(\mathcal{O}(m\,\alpha(n))\), où \(\alpha\) est l’inverse de la fonction d’Ackermann : \(\alpha(n)\leq4\) pour tout \(n\) utile (Tarjan, 1975 ; CLRS, § 19.4). Connexité dynamique, Kruskal, composantes d’une image.

Quand ça échoue

La structure ne sait pas séparer : une liaison coupée ne se retire pas d’une composante. Les éléments sont les entiers \(0..n-1\) : des noms de villages passent d’abord par une table de hachage (Séance 3).

Exercice : une compression de chemin

Une forêt de huit éléments

parent = [1, 2, 3, 3, 3, 4, 5, 6].

  1. La racine, la hauteur et le chemin de 7.
  2. Les accès de find(7) sans compression.
  3. parent[] et les accès de find(7) avec compression.
  4. Les accès d’un second find(7), la nouvelle hauteur.
élément 0 1 2 3 4 5 6 7
avant 1 2 3 3 3 4 5 6
après 1 2 3 3 3 3 3 3

Correction

  1. Racine 3 ; chemin 7 → 6 → 5 → 4 → 3 : hauteur 4.
  2. 4 liens : \(2\times4+1=9\) accès.
  3. 7, 6 et 5 passent sous 3 (4 y est déjà) : \(4\times4+1=17\) accès.
  4. 1 lien : \(3+2=5\) accès ; hauteur 3, celle de 0 → 1 → 2 → 3.

Kruskal avec union-find

private void construire(GraphePondere g) {
    BinaryMinHeap<Arete> tas = new BinaryMinHeap<>();
    for (Arete e : g.aretes()) tas.insert(e);
    while (!tas.isEmpty() && arbre.size() < g.V() - 1) {
        Arete e = tas.extractMin();
        examinees++;
        if (uf.connected(e.v, e.w)) {    // meme composante : cycle
            rejets++;
            continue;
        }
        uf.union(e.v, e.w);
        arbre.add(e);
        poids += e.poids;
    }
    comparaisonsTas = tas.comparaisons;
}

Lecture. Le code de la Séance 5, où relies(foret, v, w) devient uf.connected(v, w) et foret.addEdge(v, w) devient uf.union(v, w) : la forêt n’est plus parcourue.

Kruskal avec union-find, pas à pas

0 Mbour, 1 Saly, 2 Malicounda, 3 Nguékhokh, 4 Joal, 5 Fadial, 6 Nianing, 7 Pointe Sarène ; coûts en millions de FCFA.

Sortie \(\rightarrow\) 

99 119 11 129 11 12 149 11 12 14 159 11 12 14 15 169 11 12 14 15 16 22
total 99

Lecture. Lignes 1–3 : les 11 liaisons entrent dans le tas ; huit composantes.

Lecture. Lignes 4–10 : 4–5 (9) : racines 4 et 5, 5 passe sous 4.

Lecture. Lignes 4–10 : 6–7 (11) : racines 6 et 7, 7 passe sous 6.

Lecture. Lignes 4–10 : 0–1 (12) : racines 0 et 1, 1 passe sous 0.

Lecture. Lignes 4–10 : 0–2 (14) : racines 0 et 2, 2 passe sous 0.

Lecture. Lignes 4–10 : 2–3 (15) : racines 0 et 3, 3 passe sous 0.

Lecture. Lignes 4–10 : 0–6 (16) : racines 0 et 6, 6 passe sous 0.

Lecture. Lignes 4–6 : 1–2 (18) : find rend 0 pour les deux, rejetée.

Lecture. Lignes 4–6 : 2–6 (20) : find rend 0 pour les deux, rejetée.

Lecture. Lignes 4–10 : 4–7 (22) : find(7) rattache 7 à 0 (compression), puis 4 passe sous 0.

Lecture. Ligne 12 : 99 millions de FCFA ; 9 liaisons examinées, 2 rejetées par find, sans parcours.

Le gain mesuré

Random r = new Random(221);                // les graphes de la Seance 5
for (int V : new int[] {500, 1000, 2000, 4000}) {
    GraphePondere g = new GraphePondere(V, false);
    for (int[] a : Generateur.aretes(V, 4 * V, r)) g.addEdge(a[0], a[1], a[2]);
    Kruskal k = new Kruskal(g);            // test de cycle par parcours
    KruskalUF u = new KruskalUF(g);        // test de cycle par union-find
    System.out.printf(Locale.ROOT, "%4d : %5d examinees ; parcours %8d visites ; "
        + "union-find %6d acces%n", V, u.examinees, k.visites, u.acces());
}

Résultat \(\rightarrow\)

 500 :  1718 examinees ; parcours   611838 visites ; union-find  19301 acces
1000 :  2971 examinees ; parcours  1986671 visites ; union-find  33723 acces
2000 :  5681 examinees ; parcours  7389527 visites ; union-find  65343 acces
4000 : 13589 examinees ; parcours 38476757 visites ; union-find 152747 acces

Lecture. Les graphes de la Séance 5 et les mêmes arêtes examinées. À \(V=4\,000\) : \(38\,476\,757\) sommets atteints contre \(152\,747\) accès, 252 fois moins ; environ 11 accès par arête examinée, quel que soit \(V\). Kruskal coûte alors \(\mathcal{O}(E\log E)\), le tas des arêtes.

Le tri par comparaison

public long comparaisons;  // appels de plusPetit
public long acces;         // acces au tableau

public abstract void trier(K[] a);

protected boolean plusPetit(K[] a, int i, int j) {
    comparaisons++;
    acces += 2;
    return a[i].compareTo(a[j]) < 0;
}

protected void echanger(K[] a, int i, int j) {
    K t = a[i];
    a[i] = a[j];
    a[j] = t;
    acces += 4;
}

Tri par comparaison

Un tri qui n’apprend rien des clés autrement qu’en les comparant deux à deux (compareTo). Il est en place s’il n’utilise qu’une mémoire auxiliaire constante (hors pile des appels).

Tri stable

Deux éléments de clés égales restent dans leur ordre de départ : un classement par note garde l’ordre alphabétique entre deux notes égales.

Lecture. Les quatre tris de la séance héritent de Tri : les mêmes compteurs les comparent.

Le tri par insertion

@Override
public void trier(K[] a) {
    for (int i = 1; i < a.length; i++) {
        for (int j = i; j > 0 && plusPetit(a, j, j - 1); j--) {
            echanger(a, j, j - 1);  // a[j] recule d'une case
        }
    }
}

Invariant

Avant le tour \(i\), a[0..i-1] est trié ; a[i] recule par échanges jusqu’à rencontrer un élément qui ne lui est pas supérieur. Chaque échange supprime une inversion (une paire mal ordonnée) : il y a autant d’échanges que d’inversions.

Lecture. La comparaison est stricte : à égalité, a[j] ne passe pas devant ; le tri est stable et en place (Sedgewick et Wayne, § 2.1 ; CLRS, § 2.1).

Le tri par insertion, pas à pas

Sortie \(\rightarrow\) 

[150, 300, 500, 650, 700, 900]

Lecture. Ligne 1 : six prix relevés au marché, en FCFA ; la partie triée, en jaune, commence par la case 0.

Lecture. Lignes 2–6 : \(i=1\), \(j=1\) : 300 \(<\) 650 : échange, 300 recule en case 0. \(j=0\) : la boucle s’arrête sans comparer.

Lecture. Lignes 2–3 : \(i=2\), \(j=2\) : 900 \(\geq\) 650 : une comparaison place 900.

Lecture. Lignes 2–6 : \(i=3\), \(j=3\) : 150 \(<\) 900 : échange, 150 recule en case 2.

Lecture. Lignes 3–6 : \(i=3\), \(j=2\) : 150 \(<\) 650 : échange, 150 recule en case 1.

Lecture. Lignes 3–6 : \(i=3\), \(j=1\) : 150 \(<\) 300 : échange, 150 recule en case 0. \(j=0\) : la boucle s’arrête sans comparer.

Lecture. Lignes 2–6 : \(i=4\), \(j=4\) : 500 \(<\) 900 : échange, 500 recule en case 3.

Lecture. Lignes 3–6 : \(i=4\), \(j=3\) : 500 \(<\) 650 : échange, 500 recule en case 2.

Lecture. Ligne 3 : \(i=4\), \(j=2\) : 500 \(\geq\) 300 : la boucle s’arrête, 500 est à sa place.

Lecture. Lignes 2–6 : \(i=5\), \(j=5\) : 700 \(<\) 900 : échange, 700 recule en case 4.

Lecture. Ligne 3 : \(i=5\), \(j=4\) : 700 \(\geq\) 650 : la boucle s’arrête, 700 est à sa place.

Lecture. Ligne 9 : 10 comparaisons, 7 échanges : un échange par inversion du tableau de départ.

Le coût selon l’ordre de départ

Random r = new Random(2026);
for (int n : new int[] {1000, 2000, 4000}) {
    Integer[][] cas = {Donnees.croissant(n), Donnees.auHasard(n, r),
        Donnees.decroissant(n)};
    System.out.printf("%4d", n);
    for (Integer[] a : cas) {
        TriInsertion<Integer> t = new TriInsertion<>();
        t.trier(a);
        System.out.printf(" %11d", t.comparaisons);
    }
    System.out.printf(" %9d%n", n * n / 4);
}

Résultat \(\rightarrow\)

1000         999      251591      499500    250000
2000        1999      997770     1999000   1000000
4000        3999     3999750     7998000   4000000

Lecture. Colonnes : \(n\), comparaisons sur un tableau croissant, au hasard, décroissant, puis \(n^2/4\). Croissant : \(n-1\) ; décroissant : \(n(n-1)/2\), toutes les paires inversées ; au hasard : près de \(n^2/4\).

Exercice : un tri par insertion

Cinq notes

a = [13, 9, 16, 9, 11].

  1. Le tableau après chaque tour \(i\).
  2. Les comparaisons et les échanges.
  3. Les deux 9 gardent-ils leur ordre ?
  4. Le meilleur et le pire cas pour \(n=5\).
tour tableau après comp. éch.
\(i=1\) 9 13 16 9 11 1 1
\(i=2\) 9 13 16 9 11 1 0
\(i=3\) 9 9 13 16 11 3 2
\(i=4\) 9 9 11 13 16 3 2
total 8 5

Correction

  1. \(i=1\) : 9 recule jusqu’en case 0 ; \(i=2\) : 16 reste.
  2. 8 comparaisons, 5 échanges : les 5 inversions.
  3. Oui : le second 9 s’arrête sur \(9<9\), faux.
  4. 4 comparaisons (trié), 10 (ordre décroissant).

Le tri fusion

@Override
public void trier(K[] a) {
    K[] aux = Arrays.copyOf(a, a.length);
    trier(a, aux, 0, a.length - 1);
}

private void trier(K[] a, K[] aux, int lo, int hi) {
    if (hi <= lo) return;              // 0 ou 1 element
    int mid = lo + (hi - lo) / 2;
    trier(a, aux, lo, mid);            // moitie gauche
    trier(a, aux, mid + 1, hi);        // moitie droite
    fusionner(a, aux, lo, mid, hi);
}

Diviser, trier, fusionner

Trier chaque moitié par un appel récursif, puis fusionner les deux moitiés triées dans a[lo..hi]. Le tableau aux, de taille \(n\), est alloué une fois : le tri fusion n’est pas en place.

Lecture. Sedgewick et Wayne, § 2.2 ; CLRS, § 2.3. La récursion s’arrête à un élément (hi <= lo).

La fusion en Java

private void fusionner(K[] a, K[] aux, int lo, int mid, int hi) {
    for (int k = lo; k <= hi; k++) aux[k] = a[k];
    acces += 2L * (hi - lo + 1);
    int i = lo;                        // tete de la moitie gauche
    int j = mid + 1;                   // tete de la moitie droite
    for (int k = lo; k <= hi; k++) {
        if (i > mid) a[k] = aux[j++];
        else if (j > hi) a[k] = aux[i++];
        else if (plusPetit(aux, j, i)) a[k] = aux[j++];
        else a[k] = aux[i++];          // egalite : la gauche d'abord
        acces += 2;
    }
}

Lecture. Chaque tour de boucle écrit une case de a : la tête la plus petite des deux moitiés, ou le reste de la moitié non épuisée. À égalité, plusPetit(aux, j, i) est faux : la gauche passe d’abord, et le tri est stable.

La fusion de deux moitiés triées, pas à pas

Sortie \(\rightarrow\) 

[150, 200, 450, 450, 500, 600, 850, 900]

Lecture. Lignes 2, 13 : fusionner(0, 3, 7) : les deux moitiés triées sont copiées dans aux, la gauche en bleu, la droite en orange.

Lecture. Lignes 3–4 : \(i\) pointe la tête de la moitié gauche, \(j\) celle de la droite.

Lecture. Lignes 5, 8 : \(k=0\) : 150 \(<\) 200 : a[0] reçoit 150, de la droite, et \(j\) avance.

Lecture. Lignes 5, 9 : \(k=1\) : 450 \(\geq\) 200 : a[1] reçoit 200, de la gauche, et \(i\) avance.

Lecture. Lignes 5, 9 : \(k=2\) : égalité, 450 et 450 : la gauche passe d’abord. C’est ce qui rend le tri stable.

Lecture. Lignes 5, 8 : \(k=3\) : 450 \(<\) 600 : a[3] reçoit 450, de la droite, et \(j\) avance.

Lecture. Lignes 5, 8 : \(k=4\) : 500 \(<\) 600 : a[4] reçoit 500, de la droite, et \(j\) avance.

Lecture. Lignes 5, 9 : \(k=5\) : 900 \(\geq\) 600 : a[5] reçoit 600, de la gauche, et \(i\) avance.

Lecture. Lignes 5, 9 : \(k=6\) : 900 \(\geq\) 850 : a[6] reçoit 850, de la gauche, et \(i\) avance.

Lecture. Lignes 5–6 : \(k=7\) : \(i>\) mid, la gauche est épuisée : a[7] reçoit 900.

Lecture. Ligne 14 : 8 cases écrites, 7 comparaisons : une fusion de \(n\) éléments compare au plus \(n-1\) fois.

Le coût du tri fusion

Récurrence (Séance 2)

\(C(n)\leq C(\lceil n/2\rceil)+C(\lfloor n/2\rfloor)+n-1\), d’où \(C(n)\leq n\lceil\log_2 n\rceil\) : \(\log_2 n\) niveaux, au plus \(n-1\) comparaisons par niveau. Le théorème maître (\(a=b=2\), \(f(n)=\Theta(n)\)) donne \(\Theta(n\log n)\), quel que soit l’ordre de départ.

Lecture. Le tableau de départ de la fusion pas à pas sort des deux premiers niveaux : \(4+6+7=17\) comparaisons pour \(n=8\).

Le tri rapide

@Override
public void trier(K[] a) {
    trier(a, 0, a.length - 1);
}

private void trier(K[] a, int lo, int hi) {
    if (lo >= hi) return;  // 0 ou 1 element
    int p = partition(a, lo, hi);
    trier(a, lo, p - 1);
    trier(a, p + 1, hi);
}
public int partition(K[] a, int lo, int hi) {
    int r = lo + hasard.nextInt(hi - lo + 1);
    echanger(a, r, hi);  // le pivot en a[hi]
    int i = lo;          // a[lo..i-1] < pivot
    for (int j = lo; j < hi; j++) {
        if (plusPetit(a, j, hi)) {
            echanger(a, i, j);
            i++;
        }
    }
    echanger(a, i, hi);  // le pivot a sa place
    return i;
}

Lecture. Partition de Lomuto (CLRS, § 7.1) : le pivot, placé en a[hi], est comparé à chaque élément ; les plus petits sont rangés à gauche de \(i\). Pivot aléatoire (CLRS, § 7.3) : tiré par hasard.nextInt. Le pivot finit à sa place définitive, et les deux côtés sont triés séparément.

La partition de Lomuto, pas à pas

Sortie \(\rightarrow\) 

4

Lecture. Lignes 2, 15 : partition(0, 7) : hasard (graine 2026) tire \(r=4\) ; le pivot est 600.

Lecture. Ligne 3 : le pivot 600 passe en case 7, la dernière ; 300 prend sa place.

Lecture. Ligne 4 : \(i=0\) : la zone des éléments plus petits que le pivot est vide.

Lecture. Lignes 5–6 : \(j=0\) : 750 \(\geq\) 600 : il reste dans la zone de droite, \(i\) ne bouge pas.

Lecture. Lignes 5–8 : \(j=1\) : 250 \(<\) 600 : échange des cases 0 et 1, puis \(i=1\).

Lecture. Lignes 5–6 : \(j=2\) : 950 \(\geq\) 600 : il reste dans la zone de droite, \(i\) ne bouge pas.

Lecture. Lignes 5–8 : \(j=3\) : 400 \(<\) 600 : échange des cases 1 et 3, puis \(i=2\).

Lecture. Lignes 5–8 : \(j=4\) : 300 \(<\) 600 : échange des cases 2 et 4, puis \(i=3\).

Lecture. Lignes 5–8 : \(j=5\) : 100 \(<\) 600 : échange des cases 3 et 5, puis \(i=4\).

Lecture. Lignes 5–6 : \(j=6\) : 850 \(\geq\) 600 : il reste dans la zone de droite, \(i\) ne bouge pas.

Lecture. Ligne 11 : le pivot passe en case 4 : à sa gauche les 4 prix plus petits, à sa droite les 3 plus grands.

Lecture. Lignes 12, 15 : partition rend 4 : 7 comparaisons. Le tri rapide trie ensuite a[0..3] et a[5..7].

Des clés toutes égales

for (int n : new int[] {1000, 2000, 4000}) {
    Integer[] a = new Integer[n];
    Arrays.fill(a, 500);                    // n prix identiques
    TriRapide<Integer> t = new TriRapide<>(2026);
    t.trier(a);
    System.out.println(n + " : " + t.comparaisons + " comparaisons ; n(n-1)/2 = "
        + n * (n - 1) / 2);
}

Résultat \(\rightarrow\)

1000 : 499500 comparaisons ; n(n-1)/2 = 499500
2000 : 1999000 comparaisons ; n(n-1)/2 = 1999000
4000 : 7998000 comparaisons ; n(n-1)/2 = 7998000

Lecture. Aucun élément n’est strictement plus petit que le pivot : chaque partition rend lo, et le sous-tableau ne perd qu’un élément. \(n(n-1)/2\) comparaisons et \(n\) appels imbriqués, quels que soient les tirages.

Quoi, pourquoi, quand ça échoue : le tri rapide

Quoi

Partitionner autour d’un pivot tiré au hasard, puis trier récursivement les deux côtés ; en place, sans tableau auxiliaire.

Pourquoi on l’utilise

En moyenne, sur des clés distinctes, \(2(n+1)H_n-4n\approx2n\ln n\approx1{,}39\,n\log_2 n\) comparaisons (CLRS, § 7.4 ; Sedgewick et Wayne, § 2.3, proposition K) : \(25\,428\,905\) attendues pour \(n=1\,024\,000\), \(25\,033\,925\) mesurées. Le hasard porte sur les tirages, pas sur l’ordre des données : un tableau déjà trié n’est pas un pire cas.

Quand ça échoue

Le pire cas, \(n(n-1)/2\), reste possible ; il est certain avec des clés égales (Lomuto). La partition en trois zones (Sedgewick et Wayne, § 2.3) le traite. Le tri rapide n’est pas stable.

Exercice : une partition

Sept prix (centaines de FCFA)

a = [35, 80, 15, 60, 45, 20, 70] ; le tirage donne \(r=4\).

  1. Le pivot et le tableau après echanger(r, hi).
  2. Pour chaque \(j\) : la comparaison, \(i\) et le tableau.
  3. La valeur rendue et les deux sous-tableaux.
  4. Le nombre de comparaisons.
étape test \(i\) tableau
pivot --- 0 35 80 15 60 70 20 45
\(j=0\) \(35<45\) 1 35 80 15 60 70 20 45
\(j=1\) \(80\geq45\) 1 35 80 15 60 70 20 45
\(j=2\) \(15<45\) 2 35 15 80 60 70 20 45
\(j=3\) \(60\geq45\) 2 35 15 80 60 70 20 45
\(j=4\) \(70\geq45\) 2 35 15 80 60 70 20 45
\(j=5\) \(20<45\) 3 35 15 20 60 70 80 45
fin --- 3 35 15 20 45 70 80 60

Correction

  1. Pivot 45, échangé avec 70 en case 6.
  2. Trois prix plus petits : 35, 15, 20.
  3. 3 ; a[0..2] \(=\) 35, 15, 20 et a[4..6] \(=\) 70, 80, 60.
  4. 6, une par \(j\) : \(n-1\).

Un tri stable et un tri qui ne l’est pas

@Override
public int compareTo(Etudiant o) {
    return Integer.compare(o.note, note);  // meilleure note d'abord
}
Etudiant[] liste = {new Etudiant("Awa", 14), new Etudiant("Cheikh", 12),
    new Etudiant("Coumba", 15), new Etudiant("Fatou", 12),
    new Etudiant("Moussa", 14), new Etudiant("Ndeye", 12)};  // ordre alphabetique
Etudiant[] f = liste.clone();
new TriFusion<Etudiant>().trier(f);
System.out.println("fusion : " + Arrays.toString(f));
Etudiant[] q = liste.clone();
new TriRapide<Etudiant>(2026).trier(q);
System.out.println("rapide : " + Arrays.toString(q));

Résultat \(\rightarrow\)

fusion : [Coumba 15, Awa 14, Moussa 14, Cheikh 12, Fatou 12, Ndeye 12]
rapide : [Coumba 15, Awa 14, Moussa 14, Ndeye 12, Cheikh 12, Fatou 12]

Lecture. Le tri fusion garde Cheikh, Fatou, Ndeye dans l’ordre alphabétique ; le tri rapide donne Ndeye, Cheikh, Fatou : un échange à longue distance fait passer un élément devant un autre de même clé.

L’arbre de décision

Arbre de décision

Pour une taille \(n\) fixée, un tri par comparaison se représente par un arbre binaire : chaque nœud est une comparaison, chaque feuille un ordre de sortie. Une exécution suit un chemin de la racine à une feuille ; sa longueur est le nombre de comparaisons.

Lecture. Le tri par insertion sur \(a\), \(b\), \(c\) : 6 feuilles, une par ordre possible ; 2 comparaisons au mieux, 3 au pire.

La borne \(\Omega(n\log n)\)

Théorème (CLRS, § 8.1 ; Sedgewick et Wayne, § 2.2)

Tout tri par comparaison fait au moins \(\lceil\log_2 n!\rceil\) comparaisons au pire : l’arbre doit avoir au moins \(n!\) feuilles, une par permutation, et un arbre binaire de hauteur \(h\) a au plus \(2^h\) feuilles. Donc \(2^h\geq n!\), soit \(h\geq\log_2 n!\).

Ordre de grandeur

\[ \log_2 n!\;\geq\;\log_2\Big(\big(\tfrac n2\big)^{n/2}\Big)=\tfrac n2\log_2\tfrac n2,\qquad \log_2 n!=n\log_2 n-n\log_2 e+\mathcal{O}(\log n) \]

La première inégalité vient des \(n/2\) facteurs de \(n!\) au moins égaux à \(n/2\) ; la seconde est la formule de Stirling.

Lecture. Le tri fusion, en \(n\log_2 n\), est donc optimal à un facteur près. Le tri par dénombrement (CLRS, § 8.2) échappe à la borne : il ne compare pas, il compte des clés entières.

Exercice : la borne et le tri fusion

Petites tailles

  1. \(\lceil\log_2 n!\rceil\) pour \(n=4\), 5 et 8.
  2. Le pire cas du tri fusion pour \(n=4\) et 8.
  3. Peut-on trier 5 éléments en 6 comparaisons ?
  4. Pour \(n=8\), comparer le tri fusion à la borne.
\(n\) \(n!\) \(\lceil\log_2 n!\rceil\) fusion, pire cas
4 24 5 5
5 120 7 8
8 40 320 16 17

Correction

  1. \(2^4=16<24\leq32\) ; \(2^6=64<120\) ; \(2^{15}<40\,320\leq2^{16}\).
  2. \(n\lceil\log_2 n\rceil-2^{\lceil\log_2 n\rceil}+1\) : 5 et 17 (la figure du tri fusion).
  3. Non : 6 comparaisons distinguent au plus \(2^6=64<120\) ordres.
  4. Une comparaison de plus (17 contre 16) ; l’écart, de l’ordre de \(0{,}44\,n\), devient négligeable devant \(n\log_2 n\).

Les tris côte à côte

tri pire cas moyenne mesuré (\(n=1\,024\,000\)) mémoire stable
insertion \(n(n-1)/2\) \(\approx n^2/4\) \(0{,}25\,n^2\) (à \(n=16\,000\)) en place oui
fusion \(\leq n\lceil\log_2 n\rceil\) \(\Theta(n\log n)\) \(0{,}94\,n\log_2 n\) aux de \(n\) cases oui
rapide \(n(n-1)/2\) \(\approx1{,}39\,n\log_2 n\) (1,24 à cette taille) \(1{,}22\,n\log_2 n\) en place (pile des appels) non
tas (Séance 4) \(\mathcal{O}(n\log n)\) \(\Theta(n\log n)\) \(1{,}87\,n\log_2 n\) tas de \(n\) éléments non
borne \(\lceil\log_2 n!\rceil\) \(0{,}93\,n\log_2 n\)

Lecture. Comparaisons des classes du cours sur les mêmes entiers au hasard (Random(2026)). Le tri fusion est à 1 % de la borne ; le tri par insertion reste le plus économe sur un tableau presque trié (\(n-1\) comparaisons s’il est trié).

Les comparaisons mesurées

{.t-scriptsize}Classes TriInsertion, TriFusion, TriRapide (graine 2026) et TriTas ; \(n\) de \(1\,000\) à \(1\,024\,000\) (\(16\,000\) pour l’insertion).

À retenir

À retenir

  • Union-find : find, union, connected sur une partition de \(0..n-1\).
  • QuickFind : find en un accès, union en \(\Theta(n)\) ; QuickUnion : une forêt, find en \(\Theta(d)\), \(d\leq\) hauteur, jusqu’à \(n-1\).
  • Union pondérée par la taille : hauteur \(\leq\log_2 n\) ; avec la compression de chemin, \(\mathcal{O}(\alpha(n))\) amorti par opération.
  • Kruskal avec union-find : \(\mathcal{O}(E\log E)\), le tas des arêtes.
  • Insertion : un échange par inversion, \(n-1\) à \(n(n-1)/2\) comparaisons, stable.
  • Fusion : \(\Theta(n\log n)\) dans tous les cas, \(n\) cases auxiliaires, stable ; rapide : partition de Lomuto, pivot aléatoire, \(\approx1{,}39\,n\log_2 n\) en moyenne, en place, non stable.
  • Borne : tout tri par comparaison fait au moins \(\lceil\log_2 n!\rceil=\Omega(n\log n)\) comparaisons au pire.

Synthèse du cours

séance structures et algorithmes coûts à retenir
1. Types abstraits listes, piles, files ; tableau ou liste chaînée ajout en fin \(\mathcal{O}(1)\) amorti
2. Analyse \(\mathcal{O}\), \(\Omega\), \(\Theta\), récurrences, théorème maître \(2T(n/2)+n=\Theta(n\log n)\)
3. Hachage chaînage, sondage linéaire, rehash \(\mathcal{O}(1)\) en moyenne, charge bornée
4. Arbres et tas ABR, AVL, tas binaire, tri par tas \(\mathcal{O}(\log n)\) (AVL, tas)
5. Graphes parcours, tri topologique, Dijkstra, Kruskal \(\Theta(V+E)\), \(\mathcal{O}((V+E)\log V)\)
6. Disjoints et tris union-find, insertion, fusion, rapide \(\mathcal{O}(\alpha(n))\), \(\Theta(n\log n)\)

Lecture. Chaque structure se choisit par les opérations qu’elle rend rapides : l’accès par clé (hachage), l’ordre (arbres, tri), le minimum (tas), la connexité (graphes, union-find). Le tas de la Séance 4 sert à Dijkstra, à Kruskal et au tri par tas ; l’union-find de cette séance achève Kruskal.

Références

  • Sedgewick, Wayne — Algorithms, 4e éd., Addison-Wesley, 2011 (§ 1.5 : union-find ; § 2.1 : tris élémentaires ; § 2.2 : tri fusion et complexité du tri ; § 2.3 : tri rapide ; § 2.4 : tri par tas).
  • Cormen, Leiserson, Rivest, Stein — Introduction to Algorithms, 4e éd., MIT Press, 2022 (ch. 2 : tri par insertion, tri fusion ; ch. 7 : tri rapide ; ch. 8 : borne inférieure, tri par dénombrement ; ch. 19 : ensembles disjoints).
  • Skiena — The Algorithm Design Manual, 3e éd., Springer, 2020 (ch. 4 : tri ; ch. 8 : union-find, avec les arbres couvrants minimaux).
  • Tarjan — « Efficiency of a good but not linear set union algorithm », Journal of the ACM, 22(2), 1975.
  • Hoare — « Quicksort », The Computer Journal, 5(1), 1962.