02 Recursivite
02 Recursivite
02 Recursivite
Chapitre 2
Récursivité
To understand what recursion is,
you must first understand recursion.
1. Algorithmes récursifs
1.1 La multiplication du paysan russe
La méthode du paysan russe est un très vieil algorithme de multiplication de deux nombres entiers déjà décrit
(sous une forme légèrement différente) sur un papyrus égyptien rédigé vers av. J.-C. Il s’agissait de la
principale méthode de calcul en Europe avant l’introduction des chiffres arabes, et les premiers ordinateurs
l’ont utilisé avant que la multiplication ne soit directement intégrée dans le processeur sous forme de circuit
électronique.
Sous une forme moderne, il peut être décrit ainsi :
x y p
function multiply(x, y) 105 253 0
p←0 52 506 253
while x > 0 do 26 1012 253
if x est impair then 13 2024 253
p ← p+y 6 4048 2277
x ← bx/2c 3 8096 2277
y ← y +y 1 16192 10373
return p 0 32384 26565
Cependant, il ne saute pas aux yeux que cet algorithme calcule effectivement le produit de x par y ; pour
le montrer il faut commencer par observer qu’il réalise l’itération de trois suites (pn )n∈N , (xn )n∈N et (yn )n∈N
définies par les conditions initiales :
p0 = 0, x0 = x, y0 = y
et les relations de récurrence :
pn + yn si xn est impair
pn+1 = , xn+1 = bxn /2c, yn+1 = 2yn .
pn
sinon
Prouver la terminaison de l’algorithme, c’est montrer l’existence d’un rang N pour lequel xN 6 0. Prouver sa
validité c’est montrer que pour ce rang N on a pN = xy.
Pour justifier soigneusement ceci on utilise l’écriture en base 2 de x : posons x = (bk bk−1 · · · b1 b0 )2 avec bk = 1.
Il devient dès lors très facile de constater que xn = (bk bk−1 · · · bn )2 et que yn = 2n y. Par ailleurs, la relation de
récurrence vérifiée par la suite (pn )n∈N peut aussi s’écrire pn+1 = pn + yn × (xn mod 2) = pn + bn yn .
Nous avons donc xk = bk = 1 , 0 et xk+1 = 0, ce qui prouve la terminaison de l’algorithme, et :
k
X k
X
pk+1 = p0 + bk (2k y) = bk 2k y = xy
n=0 n=0
Jean-Pierre Becirspahic
2.2 informatique commune
Autrement dit, on ramène le problème du calcul du produit de x par y au produit de bx/2c et de 2y.
La plus-part des langages de programmation actuels permettent de mettre en œuvre directement cette réduction
du problème, en autorisant une fonction à s’appeler elle-même : on parle alors de fonction récursive. On trouvera
figure 2 les deux versions, itérative et récursive, de la méthode du paysan russe.
Nous verrons plus loin comment l’interprète de commande gère les différents appels récursifs ; pour l’instant
nous allons mettre en évidence deux éléments indispensables à la terminaison d’une fonction récursive :
– il est nécessaire qu’il y ait une condition d’arrêt ;
– il ne doit pas y avoir de suite infinie d’appels récursifs.
n o
Dans le cas de la multiplication paysanne, la condition d’arrêt correspond aux couples (x, y) x 6 0 et le
nombre d’appels récursifs est fini puisque lorsque x > 0 la suite définie par x0 = x et la relation xn+1 = bxn /2c est
une suite qui stationne en 0. On peut même calculer le nombre exact d’appels récursifs : il y en a blog xc + 1.
Dans la quasi totalité des algorithmes récursifs que nous rencontrerons il ne sera guère difficile de vérifier ces
deux conditions, et si jamais nous faisons une erreur l’interprète de commande nous l’indiquera comme par
exemple :
>>> def f(n):
return 1+f(n+1)
>>> f(0)
RuntimeError: maximum recursion depth exceeded
Comme nous pouvons le constater, l’interprète Python limite arbitrairement le nombre d’appels récursifs (la
valeur par défaut est égale à 1000).
Il faut cependant noter qu’il est aussi très facile de définir des fonctions récursives dont la preuve de terminaison
est très délicate à établir. Pour autant que je le sache, la preuve de la terminaison de la fonction Q de Hofstadter 1
résiste encore à l’analyse malgré son apparente simplicité :
def q(n):
if n <= 2:
return 1
return q(n−q(n−1)) + q(n−q(n−2))
1. Cette fonction, ainsi que celle présentée dans l’exercice 2, proviennent de son remarquable ouvrage : Gödel, Escher, Bach : Les Brins
d’une Guirlande Éternelle.
Récursivité 2.3
l’algorithme qu’il souhaite écrire. À l’inverse, Python, même s’il l’autorise, ne favorise pas l’écriture récursive 2
(limitation basse par défaut du nombre d’appels récursifs, pas d’optimisation pour la récursivité terminale).
Enfin, le choix d’écrire une fonction récursive ou itérative peut dépendre du problème à résoudre : certains
problèmes se résolvent particulièrement simplement sous forme récursive, et le plus emblématique de tous est
sans conteste le problème des tours de Hanoï inventé par le mathématicien français Édouard Lucas. Ce jeu
mathématique est constitué de trois tiges sur lesquelles sont enfilés n disques de diamètres différents. Au début
du jeu, ces disques sont tous positionnés sur la première tige (du plus grand au plus petit) et l’objectif est de
déplacer tous ces disques sur la troisième tige, en respectant les règles suivantes :
– un seul disque peut être déplacé à la fois ;
– on ne peut jamais poser un disque sur un disque de diamètre inférieur.
Maintenant, raisonnons par récurrence : pour pouvoir déplacer le dernier disque, il est nécessaire de déplacer
les n − 1 disques qui le couvrent sur la tige centrale. Une fois ces déplacements effectués, nous pouvons déplacer
le dernier disque sur la troisième tige. Il reste alors à déplacer les n − 1 autres disques vers la troisième tige.
Tout est dit : pour pouvoir déplacer n disques de la tige 1 vers la tige 3 il suffit de savoir déplacer n − 1 disques
de la tige 1 vers la tige 2 puis de la tige 2 vers la tige 3. Autrement dit, il suffit de généraliser le problème de
manière à décrire le déplacement de n disques de la tige i à la tige k en utilisant la tige j comme pivot. Ceci
conduit à la définition suivante :
def hanoi(n, i=1, j=2, k=3):
if n == 0:
return None
hanoi(n−1, i, k, j)
print("Déplacer le disque {} de la tige {} vers la tige {}.".format(n, i, k))
hanoi(n−1, j, i, k)
2. On peut citer à ce sujet Guido van Rossum, le créateur du langage Python : I don’t believe in recursion as the basis of all programming.
This is a fundamental belief of certain computer scientists, especially those who (...) like to teach programming by starting with a "cons" cell and
recursion. But to me, seeing recursion as the basis of everything else is just a nice theoretical approach to fundamental mathematics (...), not a
day-to-day tool.
Jean-Pierre Becirspahic
2.4 informatique commune
>>> hanoi(4)
Déplacer le disque 1 de la tige 1 vers la tige 2.
Déplacer le disque 2 de la tige 1 vers la tige 3.
Déplacer le disque 1 de la tige 2 vers la tige 3.
Déplacer le disque 3 de la tige 1 vers la tige 2.
Déplacer le disque 1 de la tige 3 vers la tige 1.
Déplacer le disque 2 de la tige 3 vers la tige 2.
Déplacer le disque 1 de la tige 1 vers la tige 2.
Déplacer le disque 4 de la tige 1 vers la tige 3.
Déplacer le disque 1 de la tige 2 vers la tige 3.
Déplacer le disque 2 de la tige 2 vers la tige 1.
Déplacer le disque 1 de la tige 3 vers la tige 1.
Déplacer le disque 3 de la tige 2 vers la tige 3.
Déplacer le disque 1 de la tige 1 vers la tige 2.
Déplacer le disque 2 de la tige 1 vers la tige 3.
Déplacer le disque 1 de la tige 2 vers la tige 3.
Figure 4 – Une solution du problème des tours de Hanoï avec quatre disques.
La fonction merge fusionne deux tableaux triés par ordre croissant dans un troisième tableau trié lui aussi par
ordre croissant (cette fonction est séparée du corps principal de l’algorithme pour accroitre la lisibilité de la
structure récursive).
Quel est le coût temporel de cette fonction ? La fonction merge est à l’évidence de coût linéaire vis-à-vis de la
somme des longueurs des deux tableaux passés en paramètre. Si C(n) désigne le coût du tri d’un tableau de
longueur n, on dispose de la relation :
Ce type de relation de récurrence est typique des méthodes dites « diviser pour régner » ; il est possible de
prouver que cette relation implique que C(n) = Θ(n log n). Nous verrons dans le chapitre suivant qu’il n’est pas
possible de faire mieux dans le cadre des algorithmes de tri par comparaison.
fonction :
appel retour
contexte en A
programme :
A pile d’exécution
Au moment où débute cette bifurcation, il est nécessaire que le processeur sauvegarde un certain nombre
d’informations : adresse de retour, état des paramètres et des variables, etc. Toutes ces données forment ce
qu’on appelle le contexte du programme, et elles sont stockées dans une pile qu’on appelle la pile d’exécution 3 . À
la fin de l’exécution de la fonction, le contexte est sorti de la pile pour être rétabli et permettre la poursuite de
l’exécution du programme.
Lors de l’exécution d’une fonction récursive, chaque appel récursif conduit au moment où il se produit à un
empilement du contexte dans la pile d’exécution. Lorsqu’au bout de n appels se produit la condition d’arrêt, les
différents contextes sont progressivement dépilés pour poursuivre l’exécution de la fonction. La figure 7 illustre
les trois premiers appels récursifs de la fonction récursive multiply dont le code est présenté figure 2, avec
pour paramètres x = 105 et y = 253 (pour des raisons de lisibilité, seules les valeurs de ces deux paramètres
sont présentées dans le contexte).
multiply(26, 1012) :
x = 26, y = 1012
x = 105, y = 253
Il est donc important de prendre conscience qu’une fonction récursive va s’accompagner d’un coût spatial qui
va croître avec le nombre d’appels récursifs (en général linéairement, mais ce n’est pas une règle générale, tout
dépend du contenu du contexte) ; ce coût ne doit pas être oublié lorsqu’on fait le bilan du coût d’une fonction
récursive.
3. Suivant les langages et leurs implémentations, il peut y avoir une pile d’exécution par fonction ou une seule pile globale pour tout le
programme.
Jean-Pierre Becirspahic
2.6 informatique commune
Syntaxe
Un décorateur est tout simplement une fonction qui prend en argument une fonction et renvoie une nouvelle
fonction. Par exemple, si madeco est un décorateur et si je définis une fonction avec la syntaxe :
@madeco
def mafonction(...):
....
En ce qui nous concerne, nous allons ajouter avant l’exécution d’une fonction la liste de ses paramètres, et après
l’exécution le résultat de la fonction, à l’aide du décorateur suivant :
def trace(func):
def wrapper(*args):
print(' ' * wrapper.space, end='')
print('{} <− {}'.format(func.__name__, str(args)))
wrapper.space += 1
val = func(*args)
wrapper.space −= 1
print(' ' * wrapper.space, end='')
print('{} −> {}'.format(func.__name__, str(val)))
return val
wrapper.space = 0
return wrapper
(L’attribut space permet de décaler les différents niveaux d’imbrication des appels récursifs.)
Il suffit désormais d’ajouter @trace devant la définition d’une fonction récursive pour visualiser la trace des
appels de celle-ci.
On trouvera figure 8 le résultat des codes multiply(105, 253) et mergesort([6, 2, 4, 3, 5, 1]).
la pile d’exécution (le contexte n’est plus sauvegardé) et donc permettre d’envisager des appels récursifs très
nombreux sans craindre l’épuisement de l’espace alloué à la pile. Malheureusement, Guido van Rossum, le
créateur de Python, est farouchement opposé à l’optimisation de la récursivité terminale, et il n’y a donc aucun
intérêt en Python de chercher à obtenir des versions terminales des algorithmes récursifs. Nous n’en parlerons
donc plus.
Jean-Pierre Becirspahic
2.8 informatique commune
Cette description se prête à merveille à une programmation de nature récursive, et il est tentant d’écrire le code
que l’on trouve figure 10.
Bien que particulièrement limpide, ce code se révèle mauvais car le calcul de t[:k] et de t[k+1:] est de coût
linéaire, tant temporel que spatial (car on procède à une recopie de la moitié de tableau dans un autre espace
mémoire). La relation de récurrence qui régit le coût est donc de la forme : C(n) = C(n/2) + O(n), ce qui conduit
à C(n) = O(n). Cet algorithme est de coût linéaire, donc du même ordre que l’algorithme de recherche dans un
tableau non trié, et bien loin du coût logarithmique de l’algorithme itératif étudié en première année.
Il faut donc écrire une version récursive de l’algorithme qui ne procède à aucune recopie de tableau, et pour
ce faire on doit généraliser le problème en écrivant une fonction qui recherche x dans la partie du tableau
t[i . . . j − 1] en comparant x à tk avec k = b(i + j)/2c :
– si x = tk , la recherche est terminée ;
– si x < tk la recherche se poursuit dans t[i . . . k − 1] ;
– si x > tk la recherche se poursuit dans t[k + 1 . . . j − 1].
Cette fois toutes les opérations qui précèdent les appels récursifs sont de coût constant donc le coût vérifie une
relation du type C(n) = C(n/2) + O(1) qui donne C(n) = O(log n) (coût tant temporel que spatial).
Malheureusement, les performances de cette fonction se dégradent extrêmement rapidement (voir figure 12) et
5
durée du calcul en secondes
0
0 5 10 15 20 25 30 35
valeur de n
au lieu d’un coût linéaire attendu on obtient un coût temporel d’apparence exponentiel.
Un début d’explication est donnée lorsqu’on trace cette fonction pour calculer f6 :
On constate que f0 est calculé cinq fois, f1 huit fois, f2 cinq fois, f3 trois fois, f4 deux fois, f5 une fois et f6 une
fois, ce qui fait (en tout) 25 appels à la fonction fib au lieu des 7 appels attendus.
Par exemple, f4 est calculé une première fois pour calculer f5 = f4 + f3 et une deuxième fois pour calculer
f6 = f5 + f4 , f3 va être calculé une fois pour chacun des deux calculs de f4 et une fois pour calculer f5 dont trois
fois en tout, etc.
Précisons les choses en calculant le nombre an d’appels à la fonction fib pour calculer fn . On dispose des
relations :
a0 = a1 = 1 et ∀n > 2, an = an−1 + an−2 + 1.
1
Cette suite se résout en an = 2fn+1 − 1. Sachant que fn ∼ √ ϕn (où ϕ est le nombre d’or) on obtient an = Θ(ϕn ) ;
5
le coût de cette fonction, tant temporel que spatial, est exponentiel !
Jean-Pierre Becirspahic
2.10 informatique commune
def fib(n):
def aux(n):
if n == 0:
return (0, 1)
else:
(x, y) = aux(n−1)
return (y, x + y)
return aux(n)[0]
mais nous allons rejeter cette solution, qui s’éloigne de la simplicité de la première version récursive que nous
souhaitons préserver.
La solution que nous allons adopter consiste à mémoriser le résultat des calculs une fois qu’ils auront été
calculés de manière à ne pas refaire deux fois le même calcul. La structure de données qui s’impose ici est
la structure de dictionnaire, qui est constituée de paires associant une clé à une valeur. Dans le cas qui nous
intéresse, la clé est l’entier n et la valeur, l’entier fn .
– d = {c1: v1, c2: v2, c3: v3} crée un dictionnaire d comportant pour l’instant trois
paires d’association ;
– d[c2] renvoie la valeur (ici v2 ) associée à la clé c2 ou déclenche l’exception KeyError si
l’association n’existe pas ;
– d[c4] = v4 permet d’ajouter une nouvelle paire d’association (ou de remplacer la précé-
dente association si c4 est déjà dans le dictionnaire) ;
– del d[c4] supprime une association.
Seules restrictions : il n’est pas permis d’avoir plus d’une entrée par clé, et ces dernières ne
doivent pas être des objets mutables.
Nous ne détaillerons pas l’implémentation des dictionnaires qui est complexe, et nous admettrons que le coût
de l’ajout comme de la lecture dans un dictionnaire est en moyenne constant.
Nous pouvons maintenant réécrire la fonction fib , en la faisant précéder de la création d’un dictionnaire.
Ensuite, le calcul de fn se déroulera de la façon suivante : on commence par regarder si n est déjà présent dans
le dictionnaire, et si ce n’est pas le cas (et uniquement dans ce cas) on calcule fn à l’aide de la formule récursive.
Une fois calculée, l’association (n, fn ) sera intégrée au dictionnaire :
d_fib = {0: 0, 1: 1}
def fib(n):
if n not in d_fib:
d_fib[n] = fib(n−1) + fib(n−2)
return d_fib[n]
Cette solution permet de concilier la simplicité de la solution récursive avec l’efficacité temporelle, au prix d’un
coût spatial linéaire (constitué du dictionnaire et de la pile d’exécution) :
>>> fib(10)
55
>>> d_fib
{0: 0, 1: 1, 2: 1, 3: 2, 4: 3, 5: 5, 6: 8, 7: 13, 8: 21, 9: 34, 10: 55}
Récursivité 2.11
Décorateur et mémoïsation
Il est possible de donner une solution encore plus élégante en utilisant un décorateur, bien adapté à la
mémoïsation. En effet, il suffit de vérifier l’existence d’une association avant l’application de la fonction, et
d’ajouter une paire d’association après avoir calculé la nouvelle valeur. Ceci nous conduit à définir le décorateur
suivant :
def memoise(func):
cache = {}
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
Il suffit dès lors de décorer une définition récursive pour obtenir un algorithme aussi efficace qu’un algorithme
itératif (mais au prix bien sûr d’un coût spatial) :
@memoise
def fib(n):
if n < 2:
return n
return fib(n−1) + fib(n−2)
Autre exemple classique, le calcul des coefficients binomiaux à l’aide de la formule de Pascal doit impérative-
ment être mémoïsée sous peine d’obtenir un coût temporel exponentiel :
@memoise
def binom(n, p):
if p == 0 or n == p:
return 1
return binom(n−1, p−1) + binom(n−1, p)
!
30
Sans mémoïsation, il faut près de 2 minutes pour calculer avec un processeur Intel Core 2 Duo à 2, 13 GHz
15
alors que le calcul demande moins d’une milli-seconde avec mémoïsation.
3. Exercices
Exercice 1 On considère la fonction récursive suivante :
def f(n):
if n > 100:
return n−10
return f(f(n + 11))
Prouver sa terminaison lorsque n ∈ N, et déterminer ce qu’elle calcule (sans utiliser l’interprète de commande).
Exercice 2 Prouver la terminaison de la fonction G de Hofstadter, définie sur N de la façon suivante :
def g(n):
if n == 0:
return 0
return n − g(g(n − 1))
n+1
(Difficile.) Prouver que g(n) = où α est le nombre d’or.
α
Jean-Pierre Becirspahic
2.12 informatique commune
n n bn/2c
Exercice 3 Écrire une fonction récursive qui calcule a en exploitant la relation : a = a × adn/2e , puis une
seconde fonction qui utilise en plus la remarque suivante :
bn/2c
si n est pair
dn/2e =
bn/2c + 1 sinon
14
13
9
8 12
5
4 7 11
2
N
0 1 3 6 10
Rédiger une fonction récursive qui retourne le numéro du point de coordonnées (x, y).
Rédiger la fonction réciproque, là encore de façon récursive.
Exercice 5 On suppose donné un tableau t[0 · · · n − 1] (contenant au moins trois éléments) qui possède la
propriété suivante : t0 > t1 et tn−2 6 tn−1 . Soit k ∈ ~1, n − 2 ; on dit que tk est un minimum local lorsque tk 6 tk−1
et tk 6 tk+1 .
1. Justifier l’existence d’un minimum local dans t.
2. Il est facile de déterminer un minimum local en coût linéaire : il suffit de procéder à un parcours du
tableau. Mais pourriez-vous trouver un algorithme récursif qui en trouve un en coût logarithmique ?
Exercice 6 Les processeurs graphiques possèdent en général une fonction de bas niveau appelée blit (ou
transfert de bloc) qui copie rapidement un bloc rectangulaire d’une image d’un endroit à un autre.
L’objectif de cet exercice est de faire tourner une image carrée de n × n pixels de 90° dans le sens direct en
adoptant une stratégie récursive : découper l’image en quatre blocs de tailles n/2 × n/2, déplacer chacun des ces
blocs à sa position finale à l’aide de 5 blits, puis faire tourner récursivement chacun de ces blocs :
C D
A B B D
A B
C D A C
On supposera dans tout l’exercice que n est une puissance de 2.
1. Exprimer en fonction de n le nombre de fois que la fonction blit est utilisée.
2. Quel est le coût total de cet algorithme lorsque le coût d’un blit d’un bloc k × k est en Θ(k 2 ) ?
3. Et lorsque ce coût est en Θ(k) ?
En supposant qu’une image est représentée par une matrice numpy n × n, rédiger une fonction qui adopte cette
démarche pour effectuer une rotation de 90° dans le sens direct (on simulera un blit par la copie d’une partie de
la matrice vers une autre en décrivant ces parties par le slicing).
Récursivité 2.13
Exercice 7 On suppose disposer d’une fonction circle([x, y], r) qui trace à l’écran un cercle de centre
(x, y) de rayon r. Définir deux fonctions récursives permettant de tracer les dessins présentés figure 14 (chaque
cercle est de rayon moitié moindre qu’à la génération précédente).
10
20
5
0 10
5
0
10
15 10
20
20
25
10 5 0 5 10 15 20 25 20 10 0 10 20
Exercice 8 On suppose disposer d’une fonction polygon([xa, ya], [xb, yb], [xc, yc]) qui trace le
triangle plein dont les sommets ont pour coordonnées (xa , ya ), (xb , yb ), (xc , yc ). Définir une fonction récursive
permettant le tracé présenté figure 15 (tous les triangles sont équilatéraux).
Jean-Pierre Becirspahic