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)))