1Specification, terminaison et variant
Tester un programme sur quelques exemples ne prouve rien : Dijkstra remarquait que « tester peut reveler la presence de bugs, jamais leur absence ». Ce chapitre donne les outils permettant de demontrer qu'un algorithme fait ce qu'on attend de lui.
La specification d'un algorithme est le contrat qu'il doit respecter :
- la precondition : ce que l'on suppose vrai sur les entrees ;
- la postcondition : ce qui doit etre vrai sur la sortie, en supposant la precondition satisfaite.
Sans specification, la question « ce programme est-il correct ? » n'a aucun sens.
def indice_min(t, debut):
"""Indice du plus petit element de t[debut:].
Precondition : t est une liste non vide, 0 <= debut < len(t).
Postcondition : renvoie i tel que debut <= i < len(t)
et t[i] <= t[j] pour tout j dans [debut, len(t)[.
"""
| Propriete | Enonce |
|---|---|
| Terminaison | pour toute entree verifiant la precondition, l'algorithme s'arrete apres un nombre fini d'operations |
| Correction partielle | si l'algorithme s'arrete, alors la postcondition est verifiee |
| Correction totale | terminaison et correction partielle |
Les deux notions sont independantes : un programme qui boucle indefiniment est trivialement partiellement correct (il ne s'arrete jamais, donc l'implication est vraie « a vide »), et un programme qui s'arrete peut renvoyer n'importe quoi.
def f(n):
"""Postcondition : renvoie n!"""
while True: # ne termine JAMAIS
pass
return 0
# partiellement correcte (vacuement), mais pas totalement correcte
def g(n):
"""Postcondition : renvoie n!"""
return 0 # termine toujours
# termine, mais n'est pas partiellement correcte pour n >= 2Prouver la terminaison : le variant
Un variant d'une boucle est une expression \(V\) construite a partir des variables du programme, telle que :
- \(V\) prend ses valeurs dans \(\mathbb{N}\) (ou plus generalement dans un ensemble bien ordonne) a chaque passage en debut de boucle ;
- \(V\) decroit strictement a chaque iteration.
Si une boucle admet un variant, elle effectue un nombre fini d'iterations.
Supposons par l'absurde que la boucle s'execute indefiniment. Notons \(V_k\) la valeur du variant au debut de la \(k\)-ieme iteration. Par hypothese, \((V_k)_{k\ge0}\) est une suite d'entiers naturels, strictement decroissante. Alors, par recurrence immediate, \(V_k \le V_0 - k\) pour tout \(k\). En prenant \(k = V_0 + 1\), on obtient \(V_k \le -1 < 0\), ce qui contredit \(V_k \in \mathbb{N}\). La boucle effectue donc au plus \(V_0\) iterations.
Corollaire pratique : le variant initial fournit une majoration du nombre d'iterations, donc souvent directement la complexite.
def somme(t):
s, i = 0, 0
while i < len(t):
s += t[i]; i += 1
return s
# variant : len(t) - i. Entier >= 0 tant que i < len(t), decroit de 1 par tour.
def division(a, b):
q, r = 0, a
while r >= b:
r -= b; q += 1
return q, r
# variant : r. Entier >= 0 (car r >= b > 0 a l'entree), decroit de b >= 1.
def euclide(a, b):
while b != 0:
a, b = b, a % b
return a
# variant : b. Entier >= 0, et a % b < b : decroissance stricte.def syracuse(n):
while n != 1:
n = n // 2 if n % 2 == 0 else 3*n + 1
return True
Aucun variant connu : \(n\) augmente a chaque etape impaire. La terminaison de cette boucle pour tout \(n \ge 1\) est la conjecture de Collatz, ouverte depuis 1937. Plus generalement, le probleme de l'arret est indecidable (Turing, 1936) : aucun algorithme ne peut decider, pour tout programme, s'il s'arrete.
2Invariants de boucle et correction
Un invariant d'une boucle est une propriete \(P\) portant sur les variables du programme, telle que :
- Initialisation : \(P\) est vraie juste avant la premiere iteration ;
- Conservation : si \(P\) est vraie au debut d'une iteration et que la condition de boucle est vraie, alors \(P\) est encore vraie a la fin de cette iteration ;
- Terminaison (exploitation) : a la sortie, \(P\) est vraie et la condition de boucle est fausse ; on en deduit la postcondition.
C'est exactement un raisonnement par recurrence sur le nombre d'iterations.
- Se demander : « qu'est-ce que je sais de sur les variables, a chaque tour ? »
- Un bon invariant relie l'etat courant a ce qui reste a faire et a ce qui est deja fait.
- Verifier qu'il est assez fort : combine avec la negation de la condition, il doit donner la postcondition. Un invariant trop faible (« \(i \ge 0\) ») est vrai mais inutile.
- Verifier qu'il est assez faible pour etre vrai des l'initialisation.
def maximum(t):
"""Precondition : t est une liste non vide.
Postcondition : renvoie max(t)."""
m = t[0]
i = 1
while i < len(t):
if t[i] > m:
m = t[i]
i += 1
return m
Terminaison. Variant \(V = \mathrm{len}(t) - i\). C'est un entier, positif tant que \(i < \mathrm{len}(t)\), et il decroit de 1 a chaque tour. La boucle effectue donc exactement \(\mathrm{len}(t) - 1\) iterations.
Invariant. \(P_i\) : « \(m = \max(t[0], \dots, t[i-1])\) ».
- Initialisation : \(i = 1\) et \(m = t[0] = \max(t[0])\). \(P_1\) est vraie.
- Conservation : supposons \(P_i\) vraie, c'est-a-dire \(m = \max(t[0..i-1])\). Apres le corps de boucle, la nouvelle valeur de \(m\) est \(\max(m, t[i]) = \max(\max(t[0..i-1]), t[i]) = \max(t[0..i])\), et \(i\) devient \(i+1\) : \(P_{i+1}\) est vraie.
- Exploitation : a la sortie, \(i = \mathrm{len}(t)\) et \(P_i\) donne \(m = \max(t[0..\mathrm{len}(t)-1]) = \max(t)\). C'est la postcondition.
L'algorithme est donc totalement correct, en \(\Theta(n)\).
def puissance(x, n):
"""Precondition : n entier >= 0.
Postcondition : renvoie x**n."""
resultat, base, k = 1, x, n
while k > 0:
if k % 2 == 1:
resultat *= base
base *= base
k //= 2
return resultat
Terminaison. Variant \(V = k\). Entier positif, et \(k \ge 1\) implique \(\lfloor k/2 \rfloor \le k/2 < k\) : decroissance stricte.
Invariant. \(P\) : « \(\texttt{resultat} \times \texttt{base}^{k} = x^n\) ».
- Initialisation : \(1 \times x^n = x^n\). Vrai.
- Conservation : notons \(r, b, k\) les valeurs avant le tour et \(r', b', k'\) apres.
- Si \(k\) est pair : \(r' = r\), \(b' = b^2\), \(k' = k/2\), d'ou \(r'b'^{k'} = r\,(b^2)^{k/2} = r\,b^{k} = x^n\).
- Si \(k\) est impair : \(r' = rb\), \(b' = b^2\), \(k' = (k-1)/2\), d'ou \(r'b'^{k'} = rb\,(b^2)^{(k-1)/2} = r\,b^{1 + k - 1} = r\,b^k = x^n\).
- Exploitation : a la sortie \(k = 0\), donc \(P\) donne \(\texttt{resultat} \times \texttt{base}^0 = \texttt{resultat} = x^n\).
Complexite. Le variant est divise par au moins 2 a chaque tour, donc le nombre d'iterations est \(\lfloor\log_2 n\rfloor + 1 = \Theta(\log n)\).
def tri_insertion(t):
"""Postcondition : t est triee et est une permutation de sa valeur initiale."""
for i in range(1, len(t)):
x, j = t[i], i - 1
while j >= 0 and t[j] > x:
t[j+1] = t[j]
j -= 1
t[j+1] = x
Invariant de la boucle externe : « \(t[0..i-1]\) est triee et contient exactement les elements initialement en positions \(0\) a \(i-1\) ».
Invariant de la boucle interne : « \(t[j+2..i]\) est triee, tous ses elements sont \(> x\), et \(t[0..j] \cup t[j+2..i] \cup \{x\}\) est l'ensemble initial \(t[0..i]\) ».
Variant de la boucle interne : \(j+1\), entier positif decroissant de 1.
La preuve complete est detaillee dans la fiche d'exercices du chapitre 12.
Correction des fonctions recursives
On ne parle plus d'invariant mais de recurrence :
- Terminaison : exhiber une quantite entiere positive qui decroit strictement a chaque appel recursif, et verifier que les cas de base couvrent les valeurs minimales.
- Correction : recurrence forte sur cette quantite. On suppose la fonction correcte pour toutes les valeurs strictement inferieures, et l'on montre qu'elle l'est pour la valeur courante.
def pgcd(a, b):
"""Precondition : a >= 0, b >= 0, (a, b) != (0, 0).
Postcondition : renvoie pgcd(a, b)."""
if b == 0:
return a
return pgcd(b, a % b)
Terminaison. Variant : \(b\). C'est un entier naturel, et lorsque \(b \ne 0\), l'appel porte sur \((b, a \bmod b)\) dont le second argument verifie \(a \bmod b < b\). Le variant decroit strictement, la suite d'entiers naturels est finie : on atteint necessairement \(b = 0\).
Correction. Par recurrence forte sur \(b\).
- Base \(b = 0\) : la fonction renvoie \(a\), et \(\operatorname{pgcd}(a, 0) = a\) pour \(a > 0\). Correct.
- Heredite : soit \(b \ge 1\), supposons le resultat vrai pour tout \(b' < b\).
Comme \(a \bmod b < b\), l'hypothese de recurrence s'applique a l'appel
pgcd(b, a % b), qui renvoie donc \(\operatorname{pgcd}(b, a \bmod b)\). Or, en ecrivant \(a = bq + r\) avec \(r = a \bmod b\) : tout diviseur commun de \(a\) et \(b\) divise \(r = a - bq\), et tout diviseur commun de \(b\) et \(r\) divise \(a = bq + r\). Les deux couples ont donc les memes diviseurs communs, donc le meme pgcd. La fonction renvoie bien \(\operatorname{pgcd}(a,b)\).
3Assertions, verification interactive et complexite
Assertions : la verification a l'execution
def dichotomie(t, v):
assert t == sorted(t), "precondition violee : t doit etre triee"
g, d = 0, len(t) - 1
while g <= d:
assert 0 <= g and d < len(t), "indices hors bornes"
m = (g + d) // 2
if t[m] == v:
return m
if t[m] < v:
g = m + 1
else:
d = m - 1
return -1
Un assert qui echoue leve AssertionError en indiquant la ligne : il
localise l'erreur au plus tot, avant que ses consequences ne se propagent. On y place les
preconditions, les invariants critiques et les postconditions.
Le test t == sorted(t) coute \(\Theta(n\log n)\) : il annule tout l'interet de la
dichotomie. Les assertions couteuses sont a reserver a la phase de mise au point. L'option
python -O les desactive globalement.
Complexite : rappels et definitions
Pour deux fonctions \(f, g : \mathbb{N} \to \mathbb{R}_+^*\) :
- \(f = O(g)\) s'il existe \(C > 0\) et \(n_0\) tels que \(f(n) \le C g(n)\) pour tout \(n \ge n_0\) : \(g\) est une majoration ;
- \(f = \Omega(g)\) si \(g = O(f)\) : \(g\) est une minoration ;
- \(f = \Theta(g)\) si \(f = O(g)\) et \(f = \Omega(g)\) : \(f\) et \(g\) ont le meme ordre de grandeur.
| Classe | Nom | \(n=10^3\) | \(n=10^6\) | Exemple |
|---|---|---|---|---|
| \(\Theta(1)\) | constante | 1 | 1 | acces t[i] |
| \(\Theta(\log n)\) | logarithmique | 10 | 20 | dichotomie |
| \(\Theta(n)\) | lineaire | \(10^3\) | \(10^6\) | parcours |
| \(\Theta(n\log n)\) | quasi-lineaire | \(10^4\) | \(2\cdot10^7\) | tri fusion |
| \(\Theta(n^2)\) | quadratique | \(10^6\) | \(10^{12}\) | tri par selection |
| \(\Theta(2^n)\) | exponentielle | \(10^{301}\) | — | Hanoi, parties |
A raison de \(10^9\) operations par seconde : un algorithme quadratique traite \(10^6\) elements en 17 minutes, un algorithme quasi-lineaire en 0,02 seconde. Au-dela de \(n = 50\), un algorithme exponentiel est inutilisable, quelle que soit la machine.
- Identifier l'operation elementaire pertinente (comparaison, affectation, appel).
- Compter les executions, en fonction de la taille \(n\) de l'entree.
- Distinguer meilleur cas, pire cas, cas moyen si necessaire.
- Simplifier : ne garder que le terme dominant, sans constante multiplicative.
Sommes utiles : \(\sum_{k=1}^{n} k = \frac{n(n+1)}{2} = \Theta(n^2)\), \(\sum_{k=1}^{n} k^2 = \Theta(n^3)\), \(\sum_{k=0}^{n} 2^k = 2^{n+1}-1 = \Theta(2^n)\), \(\sum_{k=1}^{n} \frac1k = \ln n + \gamma + o(1) = \Theta(\log n)\).
L'essentiel du chapitre
- Specifier avant de coder : precondition et postcondition.
- Terminaison : un variant entier positif strictement decroissant. Il majore aussi le nombre d'iterations.
- Correction partielle : un invariant verifiant initialisation, conservation, exploitation.
- Correction totale = terminaison + correction partielle ; les deux sont independantes.
- Un bon invariant, combine a la negation de la condition, doit donner la postcondition.
- Pour le recursif : variant decroissant et recurrence forte.
- Le probleme de l'arret est indecidable : aucune methode automatique universelle n'existe.