Cours sur la récursivité

publicité
Récursivité
Notion de récurrence
Dans la vie de tous les jours nous accomplissons fréquemment des taches récurrentes : ce sont des activités
constituées d’étapes répétitives qui se succèdent selon une règle simple. Il suffit alors de connaitre le point de
départ de la tache, de savoir comment on passe d’une étape à la suivante pour réussir à parvenir à n’importe
quelle étape de la tache récurrente.
Ainsi en est-il de la marche : pour commencer il faut se tenir debout et faire un pas en avant en plaçant une
jambe devant l’autre ; quand on a déjà fait n pas on augmente la distance parcourue d’un pas en faisant un
nouveau pas vers l’avant en avançant la jambe qui est derrière.
On peut donner en mathématiques l’exemple d’une suite récurrente ( un ) où n est un entier naturel, définie
par la valeur de son terme initial u0 = 2 et la relation de récurrence qui donne (pour n > 0 ) son nième terme à
1
3 
partir de son n − 1ième (récurrence simple) : un =  un −1 +
 . Ces données suffisent à calculer n’importe quel
un−1 
2
élément de cette suite.
En mathématiques, un « raisonnement par récurrence » permet de démontrer une propriété Pn pour tout n
en démontrant d’une part la propriété P0 , et d’autre part que pour n > 0 la propriété Pn −1 implique la propriété
Pn .
Algorithme itératif ou algorithme récursif
On peut concevoir plusieurs algorithmes permettant de calculer le terme un de la suite ci-dessus.
• Une première méthode est l’algorithme itératif, utilisant une boucle inconditionnelle (boucle for) : on
calcule les termes successifs en partant du terme initial et en appliquant la relation de récurrence successivement
jusqu’au nième souhaité. Implémentation en python :
def u(n):
r=2
for i in range(n):
r=0.5*(r+3/r)
return r
# Valeur initiale de la suite
# S’exécute une fois si n=1
Un avantage de cet algorithme est sa démarche naturelle : on part du terme initial pour arriver jusqu’au nième .
Un autre avantage est qu’on n’utilise qu’une seule variable r pour stocker les valeurs successives des termes de
la suite.
Un inconvénient de cet algorithme est que son écriture masque la relation de récurrence, et n’est pas très
facile à lire. Au départ on affecte r avec le terme initial u0 ; si n = 0 , c’est la valeur qui est retournée par la
fonction ; dans l’affectation suivante qui suppose n > 0 : r=0.5*(r+3/r) le r à droite représente ui ( i variant de
0 à n − 1 ), tandis que le r à gauche représente ui +1 . A la dernière étape i = n − 1 et comme n − 1 + 1 = n , le
dernier terme calculé est bien un . Nous venons de prouver que cet algorithme se termine et retourne le résultat
attendu (terminaison et correction de l’algorithme).
• Une seconde méthode est l’algorithme récursif. Une fonction f est dite récursive si son appel (appel
principal) peut provoquer un ou plusieurs appels de f elle-même (appels récursifs). Un langage est dit récursif s’il
autorise la programmation de fonctions récursives. C’est le cas de la plupart des langages de programmation
actuels, et en particulier de python. La fonction récursive est très proche de la définition de la propriété
récurrente : si n est nul on retourne la valeur du terme initial u0 , et si n > 0 on calcule (et on retourne) un avec
la relation de récurrence par un appel récursif à la fonction pour n − 1 . Implémentation en python :
# Programmation naïve
def u(n) :
if n==0 :
return 2
else:
return 0.5*(u(n-1)+3/u(n-1))
# renvoie u(0)
# renvoie u(n) par appel récursif
# Programmation plus judicieuse
def u(n) :
if n==0 :
return 2
else:
x=u(n-1)
return 0.5*(x+3/x)
# stocke la valeur de u(n-1) pour éviter le 2ième appel récursif dans le retour
# et ainsi diminuer le nombre d’opérations réalisées (diminue la complexité)
Un avantage de cet algorithme est que sa lecture est plus facile. Sa terminaison et sa correction sont
également plus facile à montrer : il suffit d’un raisonnement par récurrence. Si n = 0 , u ( 0 ) se termine et
retourne bien u0 . Si pour n > 0 , u (n-1 ) se termine et retourne un −1 , alors dans l’appel à u (n) le bloc else: est
exécuté, se termine et retourne bien un .
Un inconvénient est que son fonctionnement est plus difficile à suivre, à « décortiquer » et qu’il utilise
davantage d’espace mémoire. A chaque appel récursif il définit un nouveau jeu de variables locales ( n et x ).
Dans une première étape les variables n locales successives vont en décroissant jusqu’à 0, tandis que les
variables x successives sont en attente d’affectation ; dans la deuxième étape les variables x locales sont
affectées successivement par les valeurs de un , où les n sont les variables locales allant en croissant : le
programme construit alors la suite comme dans la méthode itérative.
Complexité
Comparons les complexités des deux algorithmes récursifs précédents.
Programmation naïve
Le nombre d’opérations pour n = 0 vaut C ( 0 ) = 0 , et pour n > 0 il vaut C ( n ) = 2 × C ( n − 1) + 3 . Si on suppose
(
)
( (
))
(
)
que C ( n − 1 ) = 3 × 2n −1 − 1 , on en déduit alors C ( n ) = 2 × 3 × 2n−1 − 1 + 3 = 3 × 2n − 1 . Donc le nombre
(
)
d’opérations pour n > 0 vaut C ( n ) = 3 × 2n − 1 .
Programmation plus judicieuse
Le nombre d’opérations pour n = 0 vaut C ( 0 ) = 0 , et pour n > 0 il vaut C ( n ) = C ( n − 1) + 3 . Il s’agit une suite
arithmétique de raison 3, on en déduit C ( n ) = 3 × n . D’où l’intérêt de cette deuxième méthode !
Remarque : La suite de l’exemple étudié converge, et a pour limite 3 . En effet sa limite l vérifie l’équation :
1
l = ( l + 3 / l ) , ce qui s’écrit encore : 2l 2 = l 2 + 3 , soit l = 3 .
2
Lorsque n croît, un constitue donc une valeur approchante de 3 .
Autres exemples de fonctions récursives
Fonction factorielle
Elle est définie par son terme initial : u0 = 0! = 1 , et sa relation de récurrence :
un = n × un−1 = n × ( n − 1)! = n!
La programmation récursive est particulièrement simple. Implémentation en python :
def factorielle(n) :
if n==0:
return 1
else:
return n*factorielle(n-1)
Un des risques de la programmation récursive, lorsque le programme est mal écrit ou mal utilisé, est de le voir
entrer dans une boucle infinie : il ne s’arrête pas et peut aller jusqu'à la saturation de la mémoire. En python le
nombre maximum d’appels récursifs autorisé est de 1000. Au-delà de ce nombre apparait un message d’erreur.
Cela supprime le bouclage infini. Une telle limite peut paraitre faible, mais en pratique pour les algorithmes à
complexité logarithmique O(log(n)), c’est généralement suffisant.
Exemples de mauvaise utilisation des fonctions récursive u (n) ou factorielle (n) :
print(u(2.5))
print(u(-3))
print(factorielle(-1))
Dans le premier cas n n’est pas entier et sera décrémenté de 1 à chaque appel récursif. Il ne sera jamais entier
et ne passera jamais par la valeur 0. La fonction récursive ne se terminera pas avant les 1000 appels récursifs, et
le programme s’arrêtera sur un message d’erreur.
Dans les deux cas suivants n est entier mais négatif, et par décrémentation de 1 à chaque appel récursif il ne
passera jamais par la valeur 0. Là encore on atteint les 1000 appels récursifs et le message d’erreur.
Pour se prémunir de ce genre de problème on peut être tenté d’ajouter une vérification du caractère entier et
positif ou nul de la valeur de n dans la définition de la fonction récursive. C’est une fausse bonne idée, car cette
vérification se fera à chaque appel récursif et allongera le temps de calcul. C’est au moment de l’appel principal
qu’il convient de faire une vérification, car si n est entier positif ou nul à l’appel principal, il le restera pour tous
les appels récursifs. On peut rajouter une mise en garde dans l’aide en ligne de la fonction :
def factorielle(n):
"""Calcule n! . A l’appel principal, vérifier que n est un entier positif ou nul"""
if n==0:
return 1
else:
return n*factorielle(n-1)
Fonction puissance
La fonction puissance qui calcule x n , avec n entier positif ou nul, se prête bien à une programmation
récursive. Implémentation en python :
def puissance(x,n):
if n==0:
return 1
else:
return x*puissance(x,n-1)
Fonction exponentiation rapide
La programmation récursive s’applique aussi à des schémas de récurrence forte, c'est-à-dire effectuant des
appels à des valeurs strictement inférieures à n − 1 (on suppose toujours n entier positif ou nul). L’algorithme
d’exponentiation rapide fait partie de cette catégorie. Il est basé sur les propriétés suivantes :
 x 2k = x k 2


2
 x 2 k +1 = x ⋅ x k

n
Lorsque n = 0 , x n vaut 1. Lorsque n > 0 , pour calculer x n , on pose k =   (partie entière de n sur 2), et on
2 
k
k
k
fait un appel récursif à x . Si n est pair on calcule x ⋅ x , sinon on calcule x ⋅ x k ⋅ x k : le résultat est donc bien x n .
k décroit nécessairement au fil des appels récursifs jusqu’à la valeur 0, correspondant alors au dernier appel
récursif.
Implémentation en python :
( )
( )
def expo_rapide(x,n):
if n==0:
return 1
else:
r=expo_rapide(x,n//2)
if n%2==0:
return r*r
else:
return x*r*r
Comparons la complexité des deux dernières fonctions.
Par exemple considérons le calcul de x 37 . Avec la fonction puissance, l’appel principal requiert une opération,
et chaque appel récursif également sauf le dernier pour n − 1 = 0 . Cette fonction effectue donc 37 opérations.
(Complexité O(n))
La fonction expo_rapide va chercher à calculer successivement :
Pour x 37 -> x × x 18 × x 18
Pour x 18 -> x 9 × x 9
Pour x 9 -> x × x 4 × x 4
Pour x 4 -> x 2 × x 2
Pour x 2 -> x × x
Pour x -> 1 × x
Au total cette fonction effectue donc seulement 8 opérations au lieu de 37 ! (Complexité O(log2(n)))
Téléchargement