Prouver un algorithme
Ce que ce chapitre apporte
- Distinguer ce qu'un jeu d'essai établit de ce qu'il n'établira jamais.
- Énoncer l'invariant d'une boucle et vérifier ses trois obligations.
- Repérer sur une trace le tour exact où un invariant supposé se rompt.
- Poser un variant pour établir qu'une boucle finit par s'arrêter.
- Séparer la correction d'un algorithme de sa terminaison.
Un algorithme qui rend le bon résultat sur trois exemples n'est pas un algorithme juste : c'est un algorithme qui n'a pas encore été pris en défaut. Ce chapitre donne l'outil qui permet d'affirmer davantage, et de l'affirmer pour toutes les données d'un coup : l'invariant de boucle, doublé du variant qui garantit l'arrêt.
Jusqu'ici, la façon de contrôler un algorithme a toujours été la même : le dérouler sur un jeu d'essai et comparer la sortie à ce qui était attendu. Cette méthode est indispensable, et elle a un défaut que rien ne corrigera jamais : elle ne parle que des données essayées. Un jeu d'essai qui échoue prouve qu'il y a une faute. Un jeu d'essai qui passe ne prouve rien.
La question posée ici est donc concrète, et elle se pose devant n'importe quelle boucle un peu longue : comment savoir qu'elle fait bien ce qu'on croit, alors qu'il est impossible d'essayer toutes les entrées possibles. La réponse tient en une phrase écrite au bon endroit.
Trois essais qui passent ne prouvent rien
Voici un algorithme qui cherche le plus petit élément d'un tableau. Il est presque juste, et c'est ce « presque » qui fait tout le chapitre.
programme principal
Le résultat affiché est 2, et c'est bien le plus petit élément du tableau. Un deuxième essai sur [3, 1, 4] rendrait 1, un troisième sur [6, 6, 6] rendrait 6. Trois essais, trois succès : l'algorithme paraît correct, et il serait mis en production sans hésiter.
Il est faux. La même ligne, sur un autre tableau :
programme principal
Le résultat affiché est 6, alors que le plus petit élément vaut 5. Une seule case a changé par rapport au tableau précédent, et la réponse est devenue fausse.
Un jeu d'essai qui échoue prouve la présence d'une faute, et il en désigne même souvent l'endroit. Un jeu d'essai qui passe ne prouve rien : il établit seulement que la faute, s'il y en a une, ne se voit pas sur ces données-là. Aucun nombre d'essais réussis ne transforme le second cas en premier.
La faute, ici, est visible à l'œil une fois qu'elle est nommée : la comparaison porte sur t[i] < t[i - 1], c'est-à-dire sur l'élément précédent du tableau, alors qu'elle devrait porter sur le minimum retenu jusque-là. L'algorithme compare chaque valeur à sa voisine au lieu de la comparer au record en cours. Reste à savoir comment on trouve ce genre de faute sans avoir la chance de tomber sur le bon jeu d'essai.
L'invariant de boucle
L'idée est de renoncer à parler des données et de parler de la boucle elle-même. Plutôt que de demander « que vaut le résultat sur ce tableau », on énonce une propriété qui décrit ce que les variables contiennent à chaque tour, et on montre que la boucle la préserve.
Un invariant de boucle est une propriété des variables qui est vraie avant le premier tour et qui reste vraie après chaque tour, quel que soit le nombre de tours effectués. Il ne décrit pas une valeur particulière, il décrit une relation qui tient toujours.
Le cas le plus simple est celui d'une somme. La boucle ci-dessous additionne les cases d'un tableau, et la trace affiche à chaque tour la valeur de somme et celle de i.
programme principal
L'invariant s'énonce ainsi : au moment de tester la condition, somme contient le total des cases t[1] à t[i - 1]. Il se lit directement dans la figure, parce que les deux variables qu'il met en relation sont justement celles que le moteur affiche. À dérouler la boucle pas à pas, la vérification se fait tour après tour :
| Au test | i | somme | Total de t[1] à t[i - 1] |
|---|---|---|---|
| avant le 1er tour | 1 | 0 | total d'aucune case, soit 0 |
| avant le 2e tour | 2 | 4 | 4 |
| avant le 3e tour | 3 | 11 | 4 + 7 |
| avant le 4e tour | 4 | 13 | 4 + 7 + 2 |
| à la sortie | 5 | 22 | 4 + 7 + 2 + 9 |
La dernière ligne est celle qui conclut : à la sortie, i vaut 5, donc l'invariant affirme que somme contient le total des cases t[1] à t[4], c'est-à-dire de tout le tableau. Le résultat cherché tombe alors sans qu'il ait été nécessaire de connaître les valeurs du tableau.
Les trois obligations d'un invariant
Vérifier un invariant sur cinq lignes de trace est un bon exercice de lecture, mais ce n'est encore qu'un jeu d'essai déguisé. La preuve proprement dite tient en trois points, et il faut les trois.
- L'initialisation. L'invariant est vrai avant d'entrer dans la boucle. Ici,
sommevaut 0 etivaut 1 : l'invariant réclame le total des casest[1]àt[0], c'est-à-dire d'aucune case, et le total d'aucune case vaut bien 0. - La conservation. Si l'invariant est vrai au début d'un tour, il est encore vrai au début du tour suivant. En supposant
sommeégale au total det[1]àt[i - 1], le corps ajoutet[i]puis augmenteide 1 :sommecontient alors le total det[1]àt[i], qui est bien le total det[1]àt[i - 1]pour la nouvelle valeur dei. - La conclusion. Combiné à la raison qui fait sortir de la boucle, l'invariant donne le résultat voulu. On sort quand
i > longueur(t), et commeiaugmente de 1 par tour, on sort aveci = longueur(t) + 1exactement :sommecontient le total de tout le tableau.
L'invariant s'écrit en commentaire juste au-dessus de la ligne du TantQue ou du Pour, et il porte sur l'état des variables au moment du test. Le fixer à cet instant précis évite la moitié des discussions : un invariant vrai en milieu de corps mais faux au test ne sert à rien, puisque c'est au test que la boucle décide de continuer ou de sortir.
Un invariant qui se casse, et le tour exact où il se casse
Retour à l'algorithme faux du début. L'invariant qu'il faudrait pour conclure est évident : min contient le plus petit des éléments t[1] à t[i]. Si cette propriété tient à chaque tour, alors au dernier tour min contient le plus petit de tout le tableau, et l'algorithme est juste.
La figure suivante vérifie cet invariant elle-même. Une variable reference suit le plus petit élément réellement rencontré, calculé correctement ; à chaque tour, la comparaison entre min et reference dit si l'invariant tient encore.
programme principal
La sortie produite est sans ambiguïté :
tour 2 : min = 5 = plus petit de t[1..2] : invariant tenu
tour 3 : min = 5 = plus petit de t[1..3] : invariant tenu
tour 4 : min = 7 alors que le plus petit de t[1..4] vaut 5 : invariant rompu
tour 5 : min = 6 alors que le plus petit de t[1..5] vaut 5 : invariant rompu
L'invariant tient deux tours, et se rompt au tour 4. À dérouler la figure jusque-là, le moment se voit dans la colonne min : elle passe de 5 à 7. Une variable censée contenir un minimum vient d'augmenter, ce qui est absurde en soi, et la ligne responsable est celle qui vient d'être exécutée.
[8, 5, 9, 7, 2] du début, l'invariant se rompt aussi au tour 4, de la même façon et pour la même raison. Simplement, le dernier élément valait 2, il était plus petit que tout le reste, et le tour 5 a remis min sur la bonne valeur par accident. Le résultat final était juste alors que l'algorithme ne l'était pas.C'est le scénario le plus coûteux qui soit : une faute qui se corrige toute seule sur les données d'essai et qui ressort plus tard, sur des données réelles, sans que personne pense à remettre l'algorithme en cause.
L'invariant fait alors office de cahier des charges pour la correction. Il faut que min soit comparé à ce qu'il contient, et non à la case précédente :
programme principal
La conservation se démontre maintenant en une phrase. Si min est le plus petit de t[1] à t[i - 1] en entrant dans le tour, alors le plus petit de t[1] à t[i] est soit ce même min, soit t[i] : le test choisit exactement le bon des deux. L'initialisation est vraie parce que min ← t[1] fait de min le plus petit d'une portion d'un seul élément. La conclusion suit, et l'algorithme est correct pour tous les tableaux, pas seulement pour trois.
La terminaison se prouve à part : le variant
Tout ce qui précède établit une chose, et une seule : si la boucle s'arrête, le résultat est le bon. C'est ce que l'on appelle la correction partielle, et le « si » n'est pas une formalité.
Un variant de boucle est une quantité entière, toujours positive ou nulle, qui décroît strictement à chaque tour. Son existence prouve que la boucle s'arrête : une suite d'entiers positifs qui décroît strictement ne peut pas être infinie.
La recherche dichotomique fournit le variant le plus lisible du parcours. La portion encore à explorer va de bas à haut ; sa taille, haut - bas + 1, est le variant. La figure la calcule à chaque tour, pour qu'elle se lise dans la trace au même titre que les autres variables.
programme principal
La colonne variant prend successivement les valeurs 8, 3, 1 puis 0. Elle décroît strictement, elle ne descend jamais sous zéro, et la boucle s'arrête donc après quatre tours au plus sur ce tableau. Le raisonnement ne dépend pas des valeurs : chaque tour remplace bas par milieu + 1 ou haut par milieu - 1, et dans les deux cas la portion perd au moins la case milieu.
Il suffit de retirer un seul caractère pour que la garantie disparaisse. Dans la version suivante, l'auteur a écrit haut ← milieu au lieu de haut ← milieu - 1.
programme principal
Le déroulement s'interrompt de lui-même : le moteur refuse d'aller au-delà de cinq cents pas et le signale. Les trois premiers tours sont irréprochables, haut descend de 8 à 4 puis à 3, et bas monte à 3. Puis bas et haut valent tous les deux 3, milieu vaut 3, la valeur cherchée est plus petite que t[3], donc haut ← milieu réaffecte 3 à une variable qui valait déjà 3. Le variant reste bloqué à 1, et rien ne le fera plus descendre.
Une quantité qui décroît au sens large peut stagner indéfiniment, et c'est exactement ce qui arrive ici. La condition de sortie bas <= haut est pourtant bien écrite, la boucle est pourtant bien bornée par un test : rien de tout cela ne sert si un tour peut laisser l'état inchangé. La preuve de terminaison ne porte pas sur la condition, elle porte sur ce que le corps fait au variant.
Les deux moitiés se perdent séparément : une boucle qui ne termine pas peut avoir un invariant parfait, et une boucle qui termine toujours peut rendre n'importe quoi.
Preuve complète d'une recherche dichotomique
Reste à mettre les deux moitiés bout à bout sur un algorithme utile. La recherche dichotomique, étudiée dans le chapitre diviser pour régner, cherche une valeur dans un tableau trié en coupant en deux à chaque tour.
programme principal
L'invariant. Si x figure dans le tableau, alors son indice est compris entre bas et haut. La formulation compte : l'invariant ne promet pas que x est présent, il promet que la portion bas à haut est le seul endroit où il peut encore se trouver.
L'initialisation. Avant le premier tour, bas vaut 1 et haut vaut longueur(t) : la portion est le tableau entier, et l'invariant est vrai sans rien supposer.
La conservation. Le tableau étant trié, si t[milieu] < x, alors toutes les cases d'indice inférieur ou égal à milieu contiennent des valeurs inférieures à x, donc x ne peut pas s'y trouver, et bas ← milieu + 1 conserve l'invariant. Le cas symétrique se traite de la même façon. C'est ici, et nulle part ailleurs, que l'hypothèse « le tableau est trié » est utilisée : la preuve désigne elle-même la condition d'emploi de l'algorithme.
La conclusion. La boucle s'arrête pour deux raisons possibles. Soit trouve est devenu non nul, et t[trouve] = x a été testé directement. Soit bas > haut, la portion est vide, et l'invariant affirme que x ne pouvait être que là : x est donc absent du tableau.
La terminaison. Le variant est haut - bas + 1, déjà examiné plus haut. Sur ce déroulement, la trace montre bas passer de 1 à 5, puis milieu tomber sur 6 et trouve prendre la valeur 6 : la valeur 23 est bien en sixième position, et deux tours ont suffi là où un parcours case par case en aurait demandé six.
Les quatre points ci-dessus disent, sans rien ajouter d'autre, que l'algorithme réclame un tableau trié, qu'il rend 0 quand la valeur est absente, et qu'il s'arrête toujours. Une preuve bien écrite remplace le paragraphe d'explications qu'on ne prend jamais le temps d'écrire, et elle a l'avantage d'être vérifiable.
Vérification
1.Un jeu d'essai qui passe sur dix tableaux différents prouve quoi ?
2.L'invariant d'une boucle décrit quoi ?
3.Quelles sont les trois obligations à vérifier sur un invariant ?
4.Un variant de boucle est une quantité qui doit faire quoi ?
5.Un invariant correctement démontré établit quoi, exactement ?
Exercices type
Exercice 1 : énoncer l'invariant de la boucle extérieure du tri par insertion, et le vérifier sur la trace.
Afficher la solution
programme principal
L'invariant de la boucle extérieure : les cases t[1] à t[i - 1] contiennent les i - 1 premiers éléments d'origine, triés entre eux. La trace le confirme à chaque ligne :
après le tour 2 : 3 7 9 1 5 2
après le tour 3 : 3 7 9 1 5 2
après le tour 4 : 1 3 7 9 5 2
après le tour 5 : 1 3 5 7 9 2
après le tour 6 : 1 2 3 5 7 9
Après le tour 2, les deux premières cases sont triées ; après le tour 3, les trois premières, et ainsi jusqu'au tour 6. À la sortie, i vaut longueur(t) + 1, et l'invariant affirme que le tableau entier est trié.
Deux précisions que l'invariant impose et qu'il serait tentant d'oublier : les cases triées sont bien les éléments d'origine et non d'autres valeurs, ce qui interdit une version qui perdrait un élément en route ; et la partie droite du tableau, elle, n'est pas concernée, ce que la trace montre bien avec le 2 qui reste en dernière position jusqu'au dernier tour. Le tour 3 ne change rien au tableau, ce qui est normal : le 9 est déjà à sa place parmi les valeurs déjà triées, et l'invariant est tout de même conservé.
Le variant de la boucle intérieure est j, qui décroît de 1 à chaque passage et reste positif par la condition j >= 1. Le variant de la boucle extérieure est longueur(t) - i.
Le détail des trois tris et de leurs coûts est traité dans le chapitre sur les tris.
Exercice 2 : l'algorithme suivant est censé compter les éléments strictement positifs d'un tableau. Énoncer son invariant et dire s'il tient.
compte ← 0
Pour i de 1 à longueur(t)
Si t[i] > 0 Alors
compte ← 1
FinSi
FinPour
Afficher la solution
L'invariant voulu serait : « compte contient le nombre d'éléments strictement positifs parmi t[1] à t[i - 1] ».
L'initialisation tient : avant le premier tour, compte vaut 0 et la portion est vide. La conservation ne tient pas : compte ← 1 ne dépend pas de la valeur précédente de compte, si bien qu'au deuxième élément positif la propriété devient fausse. L'invariant se rompt au deuxième élément positif rencontré, et pas avant.
Le jeu d'essai qui masque la faute est facile à fabriquer : tout tableau contenant exactement zéro ou un élément positif donne la bonne réponse. La correction est compte ← compte + 1, et l'invariant devient alors conservé.
Exercice 3 : proposer un variant pour une boucle qui divise n par 2 tant que n est pair.
Afficher la solution
n lui-même convient : il est entier, il reste positif tant qu'il est divisé par 2 sans reste, et chaque tour le remplace par n DIV 2, donc le fait décroître strictement dès que n dépasse 1. La boucle s'arrête donc toujours.
Le piège de l'énoncé est d'aller chercher un compteur de tours : un variant n'a pas à être une variable nouvelle, et il n'a pas à compter quoi que ce soit. Toute quantité entière positive qui décroît strictement fait l'affaire, et la plus simple est presque toujours une variable déjà présente.
La méthode
- Écrire l'invariant avant la boucle, pas après l'algorithme. Une boucle dont l'invariant ne s'énonce pas est une boucle dont l'intention n'est pas claire, et c'est le moment de la réécrire plutôt que de la prouver.
- Le formuler sur les variables que la trace affiche. Un invariant qui parle d'une quantité invisible ne se vérifie pas pas à pas ; un invariant qui relie
iet l'accumulateur se contrôle d'un coup d'œil sur chaque tour. - Vérifier les trois obligations dans l'ordre : vrai avant le premier tour, préservé par un tour quelconque, concluant une fois combiné à la condition de sortie.
- Traiter la conclusion avec la valeur exacte de sortie. Sortir avec
i = longueur(t) + 1et sortir aveci = longueur(t)ne donnent pas le même résultat, et c'est là que se logent les erreurs d'un tour. - Poser le variant séparément : une quantité entière, positive ou nulle, que chaque tour fait décroître strictement. Vérifier que la décroissance est stricte dans tous les cas du corps, y compris les branches rares.
- Faire dire à la figure si l'invariant tient. Ajouter le calcul de référence et un message par tour transforme une figure qui illustre en figure qui vérifie, et le tour de la rupture apparaît sans discussion.
Synthèse
- Un jeu d'essai qui échoue prouve la présence d'une faute ; un jeu d'essai qui passe ne prouve pas son absence.
- Un invariant de boucle est une propriété des variables, vraie avant le premier tour et préservée par chaque tour. Ses trois obligations sont l'initialisation, la conservation et la conclusion.
- Un invariant supposé qui se rompt désigne le tour exact de la faute, et la figure le montre quand elle calcule elle-même la valeur de référence.
- Un variant est une quantité entière positive qui décroît strictement à chaque tour ; son existence prouve la terminaison, et la décroissance doit être stricte.
- La correction d'un algorithme est la réunion de la correction partielle, donnée par l'invariant, et de la terminaison, donnée par le variant.
Le chapitre suivant, gloutons et programmation dynamique, met ces outils au travail sur une famille d'algorithmes où l'intuition se trompe souvent : ceux qui choisissent à chaque étape ce qui paraît le mieux sur le moment.