Algorithmique

Un des plus vieux algorithmes connus est décrit par Euclide dans le livre VII de ses éléments. Il permet de calculer le pgcd de deux entiers à l'aide de comparaisons et soustractions.

Voici la version réseau de Petri de cet algorithme :

image/svg+xml

Pour calculer le pgcd de a et b, placer un jeton tout en bas, a jetons et b jetons dans les places juste au-dessus, puis faire tourner le réseau de Petri. Lorsque le calcul est fini, le jeton unique est revenu en bas, et une des places juste au-dessus est vide. L'autre contient un nombre de jetons égal au pgcd de a et b.

La traduction en C (faisant usage du passage de paramètres par valeur) donne

int pgcd(int a, int b) {
    while (b!=a) {
        if (b<a) {
            a -= b;
        }
        else {
            b -= a;
        }
    }
    return b;
}

I/ Terminaison

Pour garantir la sécurité d'un algorithme, on a besoin de prouver que, quelles que soient les données entrées pour a et b, le calcul du pgcd prend un temps fini, c'est-à-dire que l'algorithme se termine.

1) Définition

Un variant est un élément, dépendant des variables, d'un ensemble bien ordonné, et qui décroît au cours de l'exécution de l'algorithme.

2) Utilité

Au cours de l'exécution de l'algorithme, les valeurs prises successivement par le variant forment une chaîne décroissante. L'ordre étant supposé bien fondé, une telle chaîne ne peut être infinie. Donc l'algorithme se termine au bout d'un temps fini.

Pour prouver la terminaison d'un algorithme, il suffit donc

  1. de trouver un variant
  2. de prouver que c'est bien un variant.

3) Variant pour Euclide

Souvent, on prend un variant dans N dont la relation d'ordre < est bien fondée. Mais l'algorithme d'Euclide porte sur deux entiers x et y. Ceux-ci forment un couple (x,y) et on choisit l'ordre lexicographique sur ces couples d'entiers. Alors

Le couple (x,y) est donc bien un variant. Ce qui prouve la terminaison de l'algorithme d'Euclide.

4) Bug dans la version C

Le raisonnement ci-dessus ne fonctionne que si x et y sont strictement positifs. L'algorithme s'arrête quand x et y sont égaux et ne va donc pas rendre négatif ou nul une des variables x et y, sauf si elle l'était déjà au départ. Le programme comporte donc un bug : si a ou b est négatif ou nul le programme C ne s'arrête jamais (il « plante » ). La programmation défensive consiste à supposer que l'utilisateur va mal utiliser la fonction (par exemple en fournissant des données inadéquates). On cherche donc à se défendre contre les entiers négatifs ou nul, par exemple avec cette version de la fonction :

int pgcd(int a, int b) {
    assert (a>0);
    assert (b>0);
    int x=a,y=b;
    while (y!=x) {
        if (y<x) {
            x -= y;
        } else {
            y -= x;
        }
    }
    return y;
}

II/ Correction

1) Définition

Un invariant d'un algorithme est une proposition logique dépendant des valeurs des variables. la différence avec un variant est que l'implication ⇒ donne des chaînes croissantes stationnaires (c'est un ordre bien fondé à l'envers).

Le fonctionnement des invariants est similaire à celui des variants.

2) Correction partielle

Pour prouver qu'un algorithme fait bien ce qu'il est censé faire (à condition qu'il termine), on doit donc

  1. trouver un invariant
  2. prouver que c'est bien un invariant
  3. et montrer qu'il est vrai au début

Alors cela prouve qu'il est vrai aussi à la fin, et que l'algorithme est correctement partiel.

3) Correction partielle de l'algorithme d'Euclide

On propose comme invariant : le pgcd de x et y est égal au pgcd de a et b. En effet, à la fin, x==y donc cet invariant permet de montrer la correction partielle de l'algorithme d'Euclide. Pour cela on a besoin du

Lemme : si m>n>0 alors le pgcd de m-n et n est égal au pgcd de m et n

  1. Le pgcd p de m et n divise m et n donc m=a×p et n=b×p. Donc m-n=a×p-b×p=(a-b)×p est divisible par p. Or n aussi est divisible par p donc p est un diviseur commun à m-n et n, donc il divise leur pgcd q.
  2. Réciproquement le pgcd q de m-n et n est diviseur de m-n=a'×q et de n=b'×q. Donc m=m-n+n=a'×q+b'×q=(a'+b')×q est divisible par q. Donc q est un diviseur commun à m et n, et donc q divise p.

Comme p divise q et q divise p, p=q ce qui démontre le lemme.

En supposant donc ( « hypothèse de récurrence » ) qu'en entrant dans la boucle, x et y sont tels que leur pgcd est celui de a et b,

On a donc bien un invariant. Or il est vrai au début de l'algorithme puisqu'à ce moment, l'invariant s'écrit « le pgcd de a et b est le pgcd de a et b ».

Ceci montre la correction partielle de l'algorithme d'Euclide dans les cas où il termine, c'est sur le pgcd de a et b.

4) Correction

Comme on a montré que l'algorithme d'Euclide (version débuguée) termine, il est correct :

III/ Complexité

On a vu au I que l'algorithme d'Euclide met un temps fini pour calculer un pgcd, indépendamment des valeurs de a et b. Mais on a souvent besoin d'estimer plus finement ce temps. Il y a plusieurs manières de définir la complexité d'un algorithme.

1) Complexité dans le meilleur cas

Il y a deux sortes d'instructions :

L'étude de la complexité de l'algorithme d'Euclide revient donc à compter les passages dans la boucle, et plus précisément de quelle manière le nombre de passages dans la boucle dépend de a et b. On peut déléguer ce travail à C, en modifiant la fonction pgcd pour qu'au lieu de renvoyer le pgcd, elle renvoie la valeur d'un compteur p (comme « pas » ) :

int pgcd(int a, int b) {
    int p = 0;
    while (b!=a) {
        p++;
        if (b<a) {
            a -= b;
        }
        else {
            b -= a;
        }
    }
    return p;
}

On apprend que le plus petit nombre de passage dans la boucle est 0 : c'est ce qui se passe quand on calcule le pgcd d'un nombre avec lui-même.

La complexité dans le meilleur cas est donc constante O(1).

2) Complexité dans le pire cas

Le programme C ci-dessus révèle que le pire cas est celui où on calcule le pgcd de a et 1 : dans ce cas on soustrait 1, a-1 fois de suite, à x, jusqu'à ce que x==1.

La complexité dans le pire cas est linéaire O(n).

3) Dans le cas général

On peut faire un relevé statistique avec la fonction pgcd ci-dessus (celle qui renvoit le compteur p) avec un tableau d'effectif t :

int t [20] = {0};
int i,j;
for (i = 1; i<=20; i++) {
    for (j = 1; j<=20; j++) {
        t[pgcd(i,j)]++;
    }
}

for (i = 0; i<20; i++) {
    printf("%d : %d fois\n",i,t[i]);
}

L'affichage produit montre des statisiques intéressantes sur les 400 valeurs examinées de (1,1) à (20,20) dans l'ordre lexicographique :

0 : 20 fois
1 : 20 fois
2 : 24 fois
3 : 36 fois
4 : 40 fois
5 : 52 fois
6 : 64 fois
7 : 48 fois
8 : 32 fois
9 : 16 fois
10 : 12 fois
11 : 4 fois
12 : 4 fois
13 : 4 fois
14 : 4 fois
15 : 4 fois
16 : 4 fois
17 : 4 fois
18 : 4 fois
19 : 4 fois

4) Complexité dans le cas moyen

Pour calculer la complexité de l'algorithme d'Euclide en moyenne, on calcule une moyenne sur nmax valeurs :

float temps_moyen(int nmax) {
    float n,s;
    float t[20];
    int i,j;
    for (i = 1; i<20; i++) {
        t[i] = 0;
    }
    for (i = 1; i<=nmax; i++) {
        for (j = 1; j<=nmax; j++) {
            n++;
            t[pgcd(i,j)]++;
        }
    }
    s = 0.0;
    for (i = 0; i<nmax; i++) {
        s += t[i]*i;
    }
    return s/n;
}

On apprend par exemple que pour calculer le pgcd de deux nombres entre 1 et 20, on boucle en moyenne 5,94 fois. Pour n allant de 1 à 20, le nombre moyen de passages dans la boucle pour 1≤a≤20 et 1≤b≤20 est représenté graphiquement ci-dessous :

0123456789101112131415161718190123456

Et pour a et b allant jusqu'à 100 :

0102030405060708090100012345678910111213141516

La complexité en moyenne semble logarithmique.