Note
Roadmap :
- finir l'écriture.
La récursivité¶
Principe et exemples¶
On dit qu'une fonction est récursive lorsqu'elle va s'appeler elle-même lors de son exécution. Le plus souvent, cet appel sera directement visible depuis le corps de la fonction.
Par exemple, la fonction fact suivante permettant de calculer \(n!\) est récursive :
let rec fact n =
if n = 0
then 1
else n * fact (n-1)
int fact(int n)
{
if (n == 0)
{
return 1;
}
else
{
return n * fact(n-1);
}
}
def fact(n):
if n == 0:
return 1
else:
return n * fact(n-1)
On utilise ici un exemple mathématique pour la simplicité des fonctions
écrites. En effet, on ne voit rien d'autre dans fact que ces appels
récursifs et ils correspondent directement à la définition mathématique :
Remarque
En fait, la définition mathématique se fait plutôt en posant :
Donc, pour retraduire une telle définition en tant que fonction, il est souvent nécessaire de décaler les rangs.
On remarque tout de suite deux choses :
- il y a une valeur, \(n = 0\), pour laquelle on ne fait aucun appel
- dans les autres cas, quand on fait un appel, l'argument \(n-1\) diminue strictement.
Cela permet d'être sûr que, pour un argument \(n \ge 0\), la suite des appels
s'arrête : les arguments successifs \(n, n-1, \dots\) forment une suite
strictement décroissante d'entiers naturels, qui ne peut donc pas être infinie,
et elle s'arrête quand on atteint \(0\). Attention, pour \(n < 0\), on n'atteint
jamais le cas de base et fact n ne termine pas (en pratique, la pile
d'exécution déborde). On verra dans la
suite comment on peut s'en assurer pour des fonctions plus complexes.
Quand on évalue fact 5 on va effectuer une série d'appels :
Pour calculer la valeur il faut descendre le long des appels récursifs jusqu'à tomber sur un cas de base puis remonter.
Implémentation pratique¶
Que se passe-t-il quand on appelle une fonction ?¶
Le principe de définition et d'appels de fonctions est à la base de la programmation structurée : au lieu d'avoir une longue succession d'instructions ou d'expressions, on les regroupe au sein de fonctions pour s'organiser. Cela permet également de factoriser du code : au lieu de dupliquer, on regroupe le même code au sein d'une fonction.
La question qu'on se pose ici est celle du mécanisme permettant une telle exécution du code. On pourrait naïvement penser qu'appeler une fonction serait absolument équivalent à copier son corps, c'est-à-dire que le programme
def f(x):
y = x * x
return x + y
a = 3
b = f(a)
print(b)
serait équivalent à
a = 3
# Début de f
x = a
y = x * x
# Fin de f et valeur de retour
b = x + y
print(b)
Au début de l'informatique, c'était effectivement la manière dont on appelait les fonctions. Mais on peut se rendre compte facilement du problème en changeant les noms de variables : le programme
def f(x):
a = x * x
return x + a
a = 3
b = f(a)
print(b)
deviendrait
a = 3
# Début de f
x = a
a = x * x
# Fin de f et valeur de retour
b = x + a
print(b)
Ici, l'appel de la fonction a changé la valeur de a (qui vaut 9 après l'appel) car a était aussi une
variable définie dans f. Il est nécessaire d'avoir un mécanisme sophistiqué
permettant de sauvegarder le contexte d'exécution d'une fonction et de le
restaurer quand l'appel à une fonction est terminé.
Comme les fonctions vont elles-mêmes appeler d'autres fonctions, on ne peut pas se contenter de ne garder qu'un seul contexte. La bonne solution est d'utiliser ce qu'on appelle une pile d'exécution. Avant d'évaluer une fonction, on empile son contexte sur la pile, quand elle a terminé, on le dépile. On reverra plus tard la structure de données pile mais, pour le moment, on peut se l'imaginer comme une pile d'assiettes à laver : au départ, elle est vide. Chaque fois qu'on empile une assiette, elle grandit. Quand on veut laver une assiette, on prend nécessairement la dernière assiette empilée.
Un exemple¶
Si on considère le programme :
def f():
return 4
def g(x):
v = x+f()
return v
def h(x):
v = g(x+1) ** 2
return v
print(h(3))
Il va s'exécuter ainsi :
-
Au départ la pile d'exécution est vide :
-
Ligne 12 : on appelle
h, on crée donc sur la pile une entrée pourh.
-
Ligne 9 : on appelle
gdepuish, on crée donc sur la pile une entrée pourg. On remarque notamment quexn'aura pas le même sens et la même valeur dansgque dansh.
-
Ligne 5 : on appelle
f, on crée donc sur la pile une entrée pourf.
-
Ligne 2 : on sort de
fen renvoyant la valeur 4. On dépile ainsi l'environnement def.
-
Ligne 5 : on définit une variable
vlocale àgavec la valeurx + 4, où 4 est la valeur de retour def, soit 8.
-
Ligne 6 : on sort de
gen renvoyant la valeur 8. On dépile ainsi l'environnement deg.
-
Ligne 9 : on définit une variable
vlocale àhavec la valeur de retour degélevée au carré, soit 64 :
-
Ligne 10 : l'appel de
hest fini, on le dépile. - Ligne 12 : on a récupéré la valeur de retour 64, on l'affiche avec un appel à
print(qui sera aussi empilé).
Pour une fonction récursive¶
Le mécanisme mis en place est suffisamment robuste pour permettre à une fonction de s'appeler elle-même. En effet, le fait d'avoir bien séparé les environnements d'exécution assure qu'il n'y aura pas de problèmes.
Si on reprend l'exemple vu au-dessus de l'évaluation de \(5!\), on remarque que lorsqu'on descend le long des appels, on empile, et lorsqu'on remonte, on dépile.
Programmer en récursif¶
Pour écrire une fonction récursive, on raisonne comme pour une preuve par récurrence : on suppose qu'on sait résoudre le problème sur des instances plus petites et on cherche à en déduire la solution de l'instance courante. Il faut alors :
- traiter directement un ou plusieurs cas de base, pour lesquels on ne fait aucun appel ;
- dans les autres cas, se ramener à des instances strictement plus petites, en un sens qu'il faudra préciser.
Exemple
On veut calculer \(x^n\) pour un entier \(n \ge 0\). La définition \(x^0 = 1\)
et \(x^n = x \times x^{n-1}\) donne directement une fonction récursive
similaire à fact. Mais on peut aussi remarquer que
ce qui permet de se ramener à l'instance \(\lfloor n/2 \rfloor\), bien plus petite que \(n - 1\). C'est l'exponentiation rapide.
let rec puissance x n =
if n = 0
then 1
else
let y = puissance x (n/2) in
if n mod 2 = 0
then y * y
else x * y * y
int puissance(int x, int n)
{
if (n == 0)
{
return 1;
}
int y = puissance(x, n/2);
if (n % 2 == 0)
{
return y * y;
}
else
{
return x * y * y;
}
}
def puissance(x, n):
if n == 0:
return 1
y = puissance(x, n // 2)
if n % 2 == 0:
return y * y
else:
return x * y * y
Remarque
On a pris soin de stocker le résultat de l'appel récursif dans y. Écrire
puissance x (n/2) * puissance x (n/2) donnerait le même résultat mais
ferait deux appels récursifs au lieu d'un : on verra avec l'arbre d'appels
que cela fait perdre tout l'intérêt de la méthode.
Prouver la correction par récurrence¶
La structure d'une fonction récursive se prête naturellement à une preuve par récurrence : l'hypothèse de récurrence porte sur les appels récursifs.
Propriété
Pour tout entier \(n \ge 0\), fact n renvoie \(n!\).
Preuve
Par récurrence sur \(n\). Pour \(n = 0\), fact 0 renvoie \(1 = 0!\).
Soit \(n \ge 1\) tel que fact (n-1) renvoie \((n-1)!\). Comme \(n \neq 0\),
fact n renvoie \(n\) multiplié par la valeur de fact (n-1), soit
\(n \times (n-1)! = n!\).
Propriété
Pour tout entier \(x\) et tout entier \(n \ge 0\), puissance x n renvoie \(x^n\).
Preuve
Par récurrence forte sur \(n\). Pour \(n = 0\), la fonction renvoie \(1 = x^0\).
Soit \(n \ge 1\) et supposons le résultat vrai pour tout \(k < n\). Comme \(\lfloor n/2 \rfloor < n\), l'hypothèse de récurrence assure que \(y = x^{\lfloor n/2 \rfloor}\).
- Si \(n\) est pair, \(n = 2 \lfloor n/2 \rfloor\) et la fonction renvoie \(y^2 = x^n\).
- Si \(n\) est impair, \(n = 2 \lfloor n/2 \rfloor + 1\) et la fonction renvoie \(x y^2 = x^n\).
Exercice
On considère la fonction suivante, où t est un tableau et n un entier
tel que \(0 \le n \le\) la longueur de t :
let rec croissant t n =
if n = 0 || n = 1
then true
else t.(n-2) <= t.(n-1) && croissant t (n-1)
bool croissant(int t[], int n)
{
if (n == 0 || n == 1)
{
return true;
}
else
{
return t[n-2] <= t[n-1] && croissant(t, n-1);
}
}
def croissant(t, n):
if n == 0 or n == 1:
return True
else:
return t[n-2] <= t[n-1] and croissant(t, n-1)
- Montrer par récurrence que l'appel
croissant t ntermine. - Montrer par récurrence que
croissant t nrenvoie vrai si et seulement si \(t_0 \le t_1 \le \dots \le t_{n-1}\).
Solution
- Pour \(n \in \{0, 1\}\), il n'y a pas d'appel récursif. Si l'appel pour \(n \ge 1\) termine, l'appel pour \(n + 1\) effectue une comparaison puis éventuellement l'appel pour \(n\), qui termine. On aurait pu se contenter de remarquer que \(n\) diminue strictement à chaque appel en restant dans \(\N\) : c'est la version récursive de la notion de variant de boucle.
- On montre par récurrence sur \(n\) que
croissant t nest vrai si et seulement si \(\forall i \in \range{0}{n-2}, t_i \le t_{i+1}\). Pour \(n \in \{0, 1\}\), il n'y a rien à comparer et la fonction renvoie vrai. Supposons la propriété vraie au rang \(n \ge 1\). Au rang \(n + 1\), la fonction renvoie vrai si et seulement si \(t_{n-1} \le t_n\) etcroissant t nest vrai, c'est-à-dire, par hypothèse de récurrence, si et seulement si \(t_{n-1} \le t_n\) et \(\forall i \in \range{0}{n-2}, t_i \le t_{i+1}\), ce qui équivaut à \(\forall i \in \range{0}{n-1}, t_i \le t_{i+1}\).
Remarque
On a raisonné ici sur des entiers mathématiques. Avec les entiers machine
de C ou d'OCaml, le résultat n'est correct que modulo les dépassements de
capacité, par exemple fact 21 n'est pas \(21!\) en OCaml sur 63 bits.
En Python, les entiers étant en précision arbitraire, il n'y a pas ce problème.
Récursivité croisée¶
Il arrive que deux fonctions (ou plus) s'appellent l'une l'autre : on parle de récursivité croisée ou mutuelle. Un exemple volontairement simple est celui de la parité d'un entier naturel : \(n\) est pair si \(n = 0\) ou si \(n-1\) est impair, et \(n\) est impair si \(n \neq 0\) et \(n-1\) est pair.
En OCaml, une fonction ne peut faire référence qu'à des noms déjà
définis. On définit donc les deux fonctions simultanément avec let rec ... and ... :
let rec est_pair n =
if n = 0
then true
else est_impair (n-1)
and est_impair n =
if n = 0
then false
else est_pair (n-1)
En C, une fonction doit être déclarée avant d'être utilisée. On écrit donc
d'abord le prototype de est_impair pour pouvoir l'appeler dans
est_pair :
bool est_impair(int n);
bool est_pair(int n)
{
if (n == 0)
{
return true;
}
return est_impair(n-1);
}
bool est_impair(int n)
{
if (n == 0)
{
return false;
}
return est_pair(n-1);
}
En Python, le nom est_impair n'est cherché qu'au moment de l'appel, il n'y
a donc rien de particulier à faire :
def est_pair(n):
if n == 0:
return True
return est_impair(n-1)
def est_impair(n):
if n == 0:
return False
return est_pair(n-1)
On retrouvera ce schéma de manière beaucoup plus naturelle avec des types de données mutuellement récursifs ou en analyse syntaxique, où chaque catégorie de la grammaire (expression, terme, facteur, ...) correspond à une fonction.
L'arbre d'appels¶
Définition¶
Définition
L'arbre d'appels associé à l'évaluation d'une fonction récursive sur un argument est l'arbre dont :
- la racine est l'appel initial ;
- les fils d'un nœud sont les appels récursifs effectués directement par cet appel, dans l'ordre où ils sont faits.
Les feuilles correspondent aux appels qui n'en font aucun autre, le plus souvent les cas de base.
Pour fact 5, l'arbre est un simple chemin, comme sur la figure du début du
chapitre. Il en va autrement quand une fonction fait plusieurs appels
récursifs, comme le calcul naïf du \(n\)-ième terme de la suite de Fibonacci
définie par \(F_0 = 0\), \(F_1 = 1\) et \(F_{n} = F_{n-1} + F_{n-2}\) pour \(n \ge 2\) :
let rec fib n =
if n <= 1
then n
else fib (n-1) + fib (n-2)
int fib(int n)
{
if (n <= 1)
{
return n;
}
return fib(n-1) + fib(n-2);
}
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
L'arbre d'appels de fib 4 est alors :
On y voit que fib 2 est calculé deux fois et fib 1 trois fois : la fonction
refait sans cesse les mêmes calculs.
La pile d'exécution permet de relier cet arbre à l'exécution réelle : à chaque instant, la pile contient exactement les appels situés sur le chemin de la racine au nœud en cours d'évaluation. La hauteur de la pile est donc majorée par la hauteur de l'arbre plus un.
Complexité en nombre d'appels¶
Si chaque appel effectue, en dehors de ses appels récursifs, un nombre d'opérations borné par une constante, la complexité temporelle est proportionnelle au nombre de nœuds de l'arbre d'appels. On compte alors les appels en traduisant la structure de la fonction en une équation de récurrence.
Exemple
Notons \(C(n)\) le nombre d'appels effectués lors de l'évaluation de fact n.
On a \(C(0) = 1\) et \(C(n) = 1 + C(n-1)\) pour \(n \ge 1\), d'où \(C(n) = n + 1\).
Propriété
Notons \(A(n)\) le nombre d'appels effectués lors de l'évaluation de fib n.
Pour tout \(n \ge 0\), \(A(n) = 2F_{n+1} - 1\). En particulier, en notant
\(\varphi = \frac{1+\sqrt{5}}{2}\), on a \(A(n) \ge 2\varphi^{n-1} - 1\) : le
nombre d'appels est exponentiel en \(n\).
Preuve
On a \(A(0) = A(1) = 1\) et, pour \(n \ge 2\), \(A(n) = 1 + A(n-1) + A(n-2)\) en comptant l'appel lui-même puis les deux sous-arbres.
On montre \(A(n) = 2F_{n+1} - 1\) par récurrence double. Pour \(n = 0\) et \(n = 1\) : \(2F_1 - 1 = 2F_2 - 1 = 1\). Si c'est vrai aux rangs \(n-1\) et \(n-2\), alors \(A(n) = 1 + 2F_n - 1 + 2F_{n-1} - 1 = 2F_{n+1} - 1\).
Montrons ensuite \(F_n \ge \varphi^{n-2}\) pour \(n \ge 1\), encore par récurrence double : \(F_1 = 1 \ge \varphi^{-1}\), \(F_2 = 1 = \varphi^0\) et, pour \(n \ge 3\), \(F_n = F_{n-1} + F_{n-2} \ge \varphi^{n-3} + \varphi^{n-4} = \varphi^{n-4}(\varphi + 1) = \varphi^{n-2}\) car \(\varphi^2 = \varphi + 1\). D'où \(A(n) = 2F_{n+1} - 1 \ge 2\varphi^{n-1} - 1\).
Concrètement, fib 30 effectue \(2 F_{31} - 1 = 2\,692\,537\) appels alors
qu'une simple boucle calculant les termes successifs en ferait une trentaine.
On verra avec la programmation dynamique comment éviter ces recalculs.
Propriété
Notons \(P(n)\) le nombre d'appels effectués lors de l'évaluation de
puissance x n. On a \(P(0) = 1\) et, pour \(n \ge 1\),
\(P(n) = \lfloor \log_2 n \rfloor + 2\).
Preuve
On a \(P(0) = 1\) et \(P(n) = 1 + P(\lfloor n/2 \rfloor)\) pour \(n \ge 1\). On procède par récurrence forte sur \(n \ge 1\). Pour \(n = 1\) : \(P(1) = 1 + P(0) = 2 = \lfloor \log_2 1 \rfloor + 2\).
Soit \(n \ge 2\) et \(k = \lfloor \log_2 n \rfloor \ge 1\), de sorte que \(2^k \le n < 2^{k+1}\). On a alors \(2^{k-1} \le \lfloor n/2 \rfloor < 2^k\), donc \(\lfloor n/2 \rfloor \ge 1\) et \(\lfloor \log_2 \lfloor n/2 \rfloor \rfloor = k - 1\). Par hypothèse de récurrence, \(P(n) = 1 + (k - 1) + 2 = k + 2\).
Avec la version maladroite évoquée plus haut qui fait deux appels
puissance x (n/2), l'équation devient \(P'(n) = 1 + 2P'(\lfloor n/2 \rfloor)\)
et on vérifie de même que \(P'(2^k) = 2^{k+2} - 1\) : on fait alors plus d'appels
que la méthode naïve en \(n+1\) appels.
Terminaison¶
On a justifié la terminaison de fact n pour \(n \ge 0\) en remarquant que
l'argument décroît strictement dans \(\N\). On généralise cette idée.
Définition
Soit \(f\) une fonction récursive dont les arguments appartiennent à un ensemble \(D\). Un variant (ou une mesure) pour \(f\) est une application \(\mu : D \to \N\) telle que, pour tout \(a \in D\), chaque appel récursif effectué lors de l'évaluation de \(f(a)\) se fait sur un argument \(a' \in D\) vérifiant \(\mu(a') < \mu(a)\).
Théorème
Si \(f\) admet un variant \(\mu\) sur \(D\) et si, en dehors des appels récursifs, l'évaluation de \(f(a)\) termine pour tout \(a \in D\), alors l'évaluation de \(f(a)\) termine pour tout \(a \in D\).
Preuve
On montre par récurrence forte sur \(m \in \N\) la propriété : « pour tout \(a \in D\) tel que \(\mu(a) = m\), l'évaluation de \(f(a)\) termine ».
Soit \(m \in \N\) tel que la propriété soit vraie pour tout \(m' < m\) et soit \(a \in D\) avec \(\mu(a) = m\). Chaque appel récursif fait par \(f(a)\) porte sur un \(a' \in D\) avec \(\mu(a') < m\), il termine donc par hypothèse de récurrence. Comme le reste du calcul termine aussi, l'évaluation de \(f(a)\) termine. Le cas \(m = 0\) est contenu dans ce raisonnement : il ne peut alors y avoir aucun appel récursif.
Exemple
- Pour
puissance x n, avec \(D = \N\), \(\mu(n) = n\) convient car \(\lfloor n/2 \rfloor < n\) pour \(n \ge 1\). - Pour
est_pairetest_impair, on applique le même raisonnement aux deux fonctions à la fois avec la mesure \(\mu(n) = n\). - Pour
fact, l'ensemble \(D\) est important : sur \(\Z\), \(\mu(n) = n\) n'est pas à valeurs dans \(\N\) et, effectivement,fact (-1)ne termine pas.
Parfois, aucune mesure entière simple ne s'impose. On utilise alors plus généralement un ensemble ordonné \((E, \preceq)\) bien fondé, c'est-à-dire dans lequel il n'existe pas de suite infinie strictement décroissante. La preuve s'adapte : si \(f(a_0)\) ne terminait pas, l'un de ses appels récursifs \(f(a_1)\) ne terminerait pas, puis l'un des appels de \(f(a_1)\), et ainsi de suite, ce qui construirait une suite infinie strictement décroissante \(\mu(a_0) \succ \mu(a_1) \succ \dots\), contradiction.
Exemple
La fonction d'Ackermann est définie sur \(\N^2\) par
Le premier argument ne décroît pas toujours (appel \(A(m, n-1)\)) et le second peut augmenter énormément (appel \(A(m-1, A(m, n-1))\)). En revanche, chaque appel fait décroître strictement le couple \((m, n)\) pour l'ordre lexicographique sur \(\N^2\) : \((m-1, \cdot) < (m, n)\) et \((m, n-1) < (m, n)\).
Cet ordre est bien fondé : dans une suite strictement décroissante \((m_k, n_k)_k\), la suite \((m_k)\) est décroissante dans \(\N\) donc constante à partir d'un certain rang, et à partir de ce rang \((n_k)\) est strictement décroissante dans \(\N\), ce qui empêche la suite d'être infinie. Ainsi, l'évaluation de \(A(m,n)\) termine toujours, même si elle peut prendre un temps colossal : \(A(3, 3) = 61\) mais \(A(4, 2) = 2^{65536} - 3\).
Récursivité terminale¶
Présentation¶
Quand on évalue fact n, il faut attendre le retour de l'appel fact (n-1)
pour effectuer la multiplication par n : le contexte de l'appel courant doit
être conservé sur la pile. C'est ce qui explique la phase de remontée vue au
début du chapitre.
Définition
Un appel de fonction est terminal lorsque sa valeur est directement la valeur renvoyée par la fonction appelante, sans aucun calcul supplémentaire. Une fonction récursive est récursive terminale lorsque tous ses appels récursifs sont terminaux.
fact n'est pas récursive terminale à cause de la multiplication n * ....
Pour la rendre terminale, on effectue la multiplication avant l'appel en
la stockant dans un argument supplémentaire, appelé accumulateur :
let fact_term n =
let rec aux n acc =
if n = 0
then acc
else aux (n-1) (n * acc)
in aux n 1
int fact_aux(int n, int acc)
{
if (n == 0)
{
return acc;
}
return fact_aux(n-1, n * acc);
}
int fact_term(int n)
{
return fact_aux(n, 1);
}
def fact_term(n, acc=1):
if n == 0:
return acc
return fact_term(n-1, n * acc)
L'évaluation de fact_term 3 donne la suite d'appels aux 3 1, aux 2 3,
aux 1 6, aux 0 6 qui renvoie directement 6 : il n'y a plus rien à faire à la
remontée.
Remarque
Pour prouver la correction, on ne peut pas montrer que aux n acc renvoie
\(n!\), ce qui est faux. On montre par récurrence sur \(n\) la propriété plus
forte : pour tout entier acc, aux n acc renvoie \(n! \times
\texttt{acc}\). On en déduit que fact_term n renvoie \(n! \times 1\).
Généraliser l'énoncé pour faire apparaître l'accumulateur est le point clé
de ce type de preuve.
Optimisation¶
Lors d'un appel terminal, le contexte de la fonction appelante n'a plus aucune utilité puisqu'il n'y a plus rien à calculer après. Le compilateur peut donc réutiliser son emplacement sur la pile au lieu d'en empiler un nouveau : l'appel devient un simple saut au début de la fonction avec de nouveaux arguments. Une fonction récursive terminale s'exécute alors avec une pile de taille constante, exactement comme une boucle.
OCaml garantit cette optimisation. On peut le constater sur le calcul de
\(\sum_{k=1}^n k\) :
# let rec somme n = if n = 0 then 0 else n + somme (n-1);;
val somme : int -> int = <fun>
# somme 100_000_000;;
Stack overflow during evaluation (looping recursion?).
# let somme_term n =
let rec aux n acc = if n = 0 then acc else aux (n-1) (acc + n) in
aux n 0;;
val somme_term : int -> int = <fun>
# somme_term 100_000_000;;
- : int = 5000000050000000
La limite exacte dépend de la taille de pile autorisée, mais seule la version terminale fonctionne pour tout \(n\).
La norme du langage C ne garantit rien. En pratique, gcc et clang
effectuent cette optimisation lorsqu'on compile avec optimisations
(-O2), mais il ne faut pas s'en remettre à cela pour la correction d'un
programme : en C, on écrira plutôt une boucle.
Python n'effectue jamais cette optimisation. De plus, la profondeur de
la pile d'appels est limitée par défaut à 1000 :
>>> fact_term(10000)
Traceback (most recent call last):
...
RecursionError: maximum recursion depth exceeded
On peut augmenter cette limite avec sys.setrecursionlimit, mais pour des
calculs de grande profondeur, on préfère une boucle.
Techniques¶
Accumulateur¶
La technique de l'accumulateur vue sur fact est très générale : on ajoute
un argument qui contient le résultat partiel déjà calculé, et le cas de base
renvoie cet accumulateur.
Sur les listes en OCaml, le parcours voit les éléments de gauche à droite
et, quand on ajoute chaque élément en tête d'un accumulateur, le premier
élément vu se retrouve au fond. C'est exactement ce qu'il faut pour renverser
une liste :
let renverse l =
let rec aux l acc =
match l with
| [] -> acc
| t :: q -> aux q (t :: acc)
in aux l []
Mais c'est un piège quand on veut conserver l'ordre : on renverse alors le résultat à la fin, ce qui reste linéaire :
let map_term f l =
let rec aux l acc =
match l with
| [] -> List.rev acc
| x :: q -> aux q (f x :: acc)
in aux l []
Du récursif terminal à l'itératif¶
Une fonction récursive terminale se traduit mécaniquement en boucle : les arguments deviennent des variables, le cas de base devient la condition de sortie et l'appel récursif devient une mise à jour des variables.
let fact_iter n =
let n = ref n in
let acc = ref 1 in
while !n <> 0 do
acc := !n * !acc;
n := !n - 1
done;
!acc
int fact_iter(int n)
{
int acc = 1;
while (n != 0)
{
acc = n * acc;
n = n - 1;
}
return acc;
}
def fact_iter(n):
acc = 1
while n != 0:
acc = n * acc
n = n - 1
return acc
Réciproquement, toute boucle while peut s'écrire comme une fonction
récursive terminale dont les arguments sont les variables modifiées par la
boucle. Récursif terminal et itératif sont donc deux écritures d'un même
calcul.
Remarque
Pour une fonction qui n'est pas récursive terminale, comme fib ou un
parcours d'arbre, le passage à l'itératif est plus délicat : il faut gérer
soi-même une pile qui remplace la pile d'exécution. On le fera pour le
parcours en profondeur d'un graphe.
Le choix entre les deux écritures relève avant tout de la clarté : une fonction récursive qui suit la structure du problème est souvent plus simple à écrire et à prouver. On ne cherche à la rendre terminale que lorsque la profondeur des appels peut devenir grande, typiquement linéaire en la taille des données.
Types inductifs et induction structurelle¶
Définition naïve des types inductifs¶
Un type inductif est un type dont les valeurs sont construites à l'aide d'un nombre fini de constructeurs, chacun pouvant prendre des arguments, dont certains sont eux-mêmes du type que l'on définit. Naïvement, une valeur d'un tel type est obtenue en appliquant un nombre fini de fois ces constructeurs.
En OCaml, les listes en sont l'exemple de base : une liste est soit [],
soit x :: q où q est une liste. On peut définir de même les arbres
binaires dont les nœuds sont étiquetés par des entiers :
type arbre = Vide | Noeud of arbre * int * arbre
Une fonction récursive sur un type inductif suit naturellement sa définition, avec un cas par constructeur et un appel récursif sur chaque argument du type lui-même :
let rec taille a =
match a with
| Vide -> 0
| Noeud (g, _, d) -> 1 + taille g + taille d
let rec nb_vides a =
match a with
| Vide -> 1
| Noeud (g, _, d) -> nb_vides g + nb_vides d
Une telle fonction termine toujours : le nombre de constructeurs utilisés
pour écrire l'argument est un variant, puisque g et d en utilisent
strictement moins que Noeud (g, x, d).
Pour démontrer des propriétés de ces fonctions, on raisonne de la même manière.
Théorème
Soit \(P\) une propriété portant sur les valeurs du type arbre. Si
- \(P(\texttt{Vide})\) est vraie,
- pour tous arbres \(g, d\) et tout entier \(x\), \(P(g)\) et \(P(d)\) impliquent \(P(\texttt{Noeud}(g, x, d))\),
alors \(P(a)\) est vraie pour tout arbre \(a\). C'est le principe d'induction structurelle.
Preuve
On montre par récurrence forte sur \(k \in \N\) que \(P(a)\) est vraie pour tout arbre \(a\) écrit avec \(k\) constructeurs. Soit \(a\) un tel arbre. Si \(a = \texttt{Vide}\), c'est la première hypothèse. Sinon, \(a = \texttt{Noeud}(g, x, d)\) où \(g\) et \(d\) sont écrits avec strictement moins de \(k\) constructeurs : \(P(g)\) et \(P(d)\) sont vraies par hypothèse de récurrence, donc \(P(a)\) par la seconde hypothèse.
Exemple
Pour tout arbre a, nb_vides a = taille a + 1. En effet, c'est vrai pour
Vide (\(1 = 0 + 1\)) et si c'est vrai pour g et d, alors pour
a = Noeud (g, x, d) on a nb_vides a \(= (\texttt{taille g} + 1) +
(\texttt{taille d} + 1) = \texttt{taille a} + 1\).
Le principe se généralise à tout type inductif : il y a une hypothèse par
constructeur, avec une hypothèse d'induction pour chaque argument du type
lui-même. Pour les listes, on retrouve : si \(P(\texttt{[]})\) et si \(P(q)\)
implique \(P(\texttt{x :: q})\), alors \(P\) est vraie pour toute liste. Pour le
type type nat = Z | S of nat, on retrouve exactement la récurrence sur \(\N\).