Lycée Victor Hugo MPSI-PCSI 2016-2017 Récursivité La récursivité est un concept très général qui s’applique à divers objets et diverses démarches et selon lequel dans la définition de cet objet ou la réalisation de cette démarche, il est fait appel à l’objet lui-même ou à la démarche elle-même (récurrence, auto-référence, ...). Par exemple, les cas suivants constituent des cas concrets de récursivité : — Processus dépendant de paramètres et faisant appel à ce même processus sur d’autres paramètres plus simples (par exemple : suites récurrentes) — Image contenant en elle-même des images similaires (voir figures ?? et ??) — Concept défini en invoquant le même concept — Algorithme qui s’appelle lui-même. (a) Vache (b) Arbre récursif Figure 1 – Exemples de dessins récursifs I Définitions récursives 1 Définition Une définition récursive d’un objet a pour caractéristique d’y voir figurer l’objet lui-même : objet == (une définition qui utilise le mot objet) 2 Exemples 1. Fonctions mathématiques définies récursivement (a) Multiplication de deux entiers naturels à l’école primaire (reformulation) :∀a ∈ N ∀n > 1, n × a = (n − 1) × a + a (b) Puissance d’un nombre décimal a (ou dans un groupe G) : ∀n > 1, an = an−1 × a (c) suite définie par récurrence : ∀n ∈ N, un+1 = f (un ) 1 Lycée Victor Hugo MPSI-PCSI 2016-2017 2. Arbre généalogique : concaténation de l’individu et des arbres généalogiques de son père et de sa mère 3. Palindrome : palindrome entouré de deux lettres identiques Question 1 Ces définitions sont-elles pertinentes ? Que manque-t-il ? II 1 Algorithme récursif Définition Un algorithme récursif se caractérise par : 1. une base de récursivité correspondant à un ou plusieurs cas particuliers à partir desquels la chaîne de récurrence (on dira plutôt d’induction) s’enclenche 2. le traitement des autres éléments faisant appel à un ou plusieurs appels récursifs Le processus est semblable à la récurrence utilisée en mathématiques qui comporte de même : 1. une phase d’initialisation 2. la propriété dite d’hérédité 2 Structure générale en langage Python A Cas particulier Ici le paramètre est un entier n, prend pour valeur initiale 0 et l’appel porte sur l’argument précédent n − 1 du paramètre def maFonction ( n ) : if n == 0 : return le r é sultat pour n = 0 else : return le r é sultat calcul é à l ’ aide d ’ un appel à maFonction (n -1) Ainsi pour l’exemple 1.(b), on définira : def puissance (a , n ) : if n == 0 : return 1 else : return a * puissance (a ,n -1) B Cas plus général Le codage de manière générale d’une fonction récursive s’écrira selon le canevas : maFonction ( x ) : if x == ( base ) : return ( r é sultat pour maFonction appliqu é e à la base ) else : ... return ( r é sultat calcul é à partir d ’ un ou plusieurs appels r é cursifs à maFonction ) Question 2 Quelle condition est souhaitable sur les arguments avec lesquels on appelle récursivement la fonction ? 2 Lycée Victor Hugo 3 MPSI-PCSI 2016-2017 Déroulement : création et exécution d’une pile Pour comprendre le processus de calcul, reprenons la fonction puissance et voyons ce qui se passe lors de l’appel puissance(5, 2) permettant de calculer 52 ; tout se passe à la manière d’une pile. — Au départ la pile est V ide. — Juste après l’appel puissance(5, 2), une place est réservée en mémoire à une adresse, notons-la A0, pour stocker puissance(5, 2) : l’état de la pile est donc après une action push Adresse A0 Valeur ? — Dans l’exécution de puissance(5, 2), on appelle puissance(5, 1) que l’on multiplie par 5 : une place est cette fois réservée en mémoire en haut de pile (nouveau push) à une adresse que nous noterons A1 pour stocker puissance(5, 1) ; et on laisse en attente en A0 le calcul 5 × V aleur1 où la V aleur1 est la valeur « pointée » en adresse A1 ; l’état de la mémoire est à présent : Adresse Valeur A1 ? A0 5 × V aleur1 — Dans l’exécution de puissance(5, 1) : même démarche qu’à l’étape précédente avec appel à puissance(5, 0) et un nouveau push : Adresse A2 A1 A0 Valeur ? 5 × V aleur2 5 × V aleur1 — Dans l’exécution de puissance(5, 0), on rend directement comme résultat 1 : Adresse A2 A1 A0 Valeur 1 5 × V aleur2 5 × V aleur1 — Dans la pile ainsi constituée, on a une valeur en haut de pile : ceci a pour effet de supprimer (action pop) la tête de pile et d’envoyer sa valeur comme argument à l’adresse A1 : Adresse A1 A0 Valeur 5 5 × V aleur1 — puis par le même procédé (nouvelle action pop) Adresse A0 Valeur 25 — La pile est de hauteur 1 et contient une valeur, nouvelle action pop qui rend la pile V ide : la valeur finale est renvoyée comme résultat : 25 4 Exercice : jeu de briques Coder une fonction Python qui rend le nombre de façons d’obtenir une ligne de briques de longueur n en n’utilisant que des briques de longueur 2 ou 3 : 7→3 8→4 10 → 7 3 Lycée Victor Hugo III MPSI-PCSI 2016-2017 Intérêt de la récursivité De nombreux problèmes sont résolus efficacement par l’ordinateur en ayant recours à des algorithmes récursifs. Citons par exemple : — la résolution du problème des tours de Hanoï — l’algorithme hautement efficace de tri par fusion d’une liste Les algorithmes récursifs sont souvent élégants et plus faciles à mettre en place, cependant ils sont souvent plus délicats à prouver ! IV 1 Problèmes soulevés par la récursivité Terminaison des algorithmes récursifs • Considérons la fonction Python définie par : def u ( n ) : if n == 0 : return 1 else : return n - u ( u (n -1) ) Question 3 Que va-t-il se passer ? D’où vient exactement le problème ? • Considérons à présent la célèbre suite de Syracuse (dite aussi de Collatz) définie par la récurrence (où c ∈ N∗ est une constante d’initialisation donnée) : a0 = c et ∀ n ∈ N, an+1 = a n 2 3a + 1 n si an est pair si an est impair On conjecture que pour toute valeur de la constante c, il existe un entier n tel que an = 1. Le plus petit entier n ainsi obtenu s’appelle la longueur du vol (on imagine le vol d’un oiseau, an représentant son altitude à l’instant n). A l’instar de cette suite, on définit alors la fonction Python suivante : def longueurDuVol ( c ) : assert c >0 , " le param è tre doit ê tre un naturel non nul " if c == 1: return 0 elif c % 2 == 0: return 1+ longueurDuVol ( c // 2) else : return 1+ longueurDuVol (3* c +1) Question 5 Quel problème pose cette fonction ? • Ces deux exemples nous fournissent une illustration du théorème suivant : Théorème Soit une fonction récursive dont le paramètre est un entier naturel. Pour qu’elle termine, il suffit que les appels récursifs portent sur des arguments strictement inférieurs à l’argument initial (et que le cas n = 0 soit un cas de base) 4 Lycée Victor Hugo MPSI-PCSI 2016-2017 • Lorsque la fonction ne porte pas sur un paramètre entier naturel, il est suffisant, pour que cette fonction termine, que : 1. l’ensemble des paramètres soit muni d’un ordre qui permet de dire qu’un argument est "avant" un autre, 2. les appels récursifs portent sur des arguments qui sont strictement "avant" l’argument initial. 3. et les premiers arguments soient traités dans les cas des bases On parle alors d’induction (au lieu de récurrence dans le cas de N) 2 Preuve des algorithmes récursifs Il s’agit de démontrer que la fonction programmée rend bien pour le résultat escompté. En général on réglera ce problème en même temps que celui de la terminaison. • Cas particulier : le paramètre est un entier naturel On utilisera un raisonnement par récurrence (simple, double, forte selon le type de récursivité utilisée dans la fonction) en posant : P(n) : l’algorithme termine et rend le résultat escompté pour la valeur n du paramètre • Cas général : le paramètre fait partie d’un ensemble ordonné On effectue ici un raisonnement par induction en posant : P(x) : l’algorithme termine et rend le résultat escompté pour la valeur x du paramètre Le raisonnement suit d’assez près le principe du raisonnement par récurrence, plus précisément celui de récurrence forte. La phase dite de base (similaire à l’initialisation d’un raisonnement par récurrence) sera : P(base) est vraie La phase dite d’induction (similaire à l’hérédité pour un raisonnement par récurrence) sera : Supposons que P(y) est vraie pour tout y situé avant x, alors [...] P(x) est vraie 3 Complexité des algorithmes récursifs Évaluation théorique de la complexité La complexité d’un algorithme récursif peut (sous quelques réserves) s’évaluer à l’aide du nombre d’opérations effectuées : il va dépendre de deux éléments : — le nombre d’appels récursifs effectués — le nombre d’opérations effectuées lors de l’appel de la fonction (ligne d’appel, souvent celle qui suit else:, et qui traite le cas général) Pour l’évaluer, on peut poser que C(n) est le nombre d’opérations effectuées, déterminer la formule de récurrence vérifiée par C(n) par relecture de l’algorithme puis utiliser ses connaissances en mathématiques pour obtenir sinon la formule explicite de C(n), du moins une comparaison O(...) avec une suite connue. Évaluation de la complexité Pour donner quelques idées, lorsque le paramètre dépend d’un entier naturel n : • S’il s’agit d’une récursivité simple usuelle et que la ligne d’appel ne nécessite qu’un nombre borné d’opérations, la complexité de l’algorithme sera O(n) • S’il s’agit d’une récursivité simple et que la ligne d’appel avec la valeur n nécessite un nombre d’opérations proportionnel à n, la complexité de l’algorithme sera O(n2 ) • S’il s’agit d’une récursivité double et que la ligne d’appel ne nécessite qu’un nombre borné d’opérations, la complexité de l’algorithme sera O(2n ) • S’il s’agit d’une récursivité simple pour laquelle l’appel est fait sur un paramètre voisin de n/2 (méthode appelée “Diviser pour régner”) et que la ligne d’appel ne nécessite qu’un nombre borné d’opérations, la complexité de l’algorithme sera O(log2 (n)) 5 Lycée Victor Hugo MPSI-PCSI 2016-2017 • S’il s’agit d’une récursivité simple pour laquelle l’appel est fait sur deux paramètres voisins de n/2 (méthode “Diviser pour régner”) et que la ligne d’appel nécessite un nombre d’opérations proportionnel à n, la complexité de l’algorithme sera O(n.log2 (n)) Attention, lorsqu’on dit que la ligne d’appel ne nécessite qu’un nombre borné d’opérations, cela signifie que la borne est indépendante de la valeur des paramètres d’entrée. Ces différents cas recouvrent de traiter la plupart des situations rencontrées cette année, notamment l’algorithme dit d’exponentiation rapide et les algorithmes de tris. Bilan Certains algorithmes récursifs sont donc très coûteux en complexité temporelle (il peut y avoir le même souci pour la complexité spatiale) et s’avèrent vite déraisonnables. On rappelle qu’un algorithme de complexité O(n3 ) est lent, et devient irrecevable lorsque sa complexité est exponentielle (O(k n ) (avec k > 1)). Le choix de programmation s’avère donc crucial : un double appel récursif pour calculer le terme général d’une suite définie par récurrence double est rédhibitoire (en O(2n )), mais en changeant la nature de la suite (i.e. en considérant directement deux termes successifs), on augmente un peu la complexité spatiale mais diminue drastiquement la complexité temporelle en faisant alors un seul appel récursif (en O(n)). Par ailleurs Python limite le nombre d’appels récursifs imbriqués (à des éléments distincts) à 1000. Pour de grandes valeurs de n, le seul recours possible est donc que ce nombre d’appels soit en O(log2 (n)) ce qui encourage les méthodes “Diviser pour régner”. Un ordinateur ultra-rapide ne pourra jamais récupérer les faiblesses d’un algorithme mal conçu. Illustration par un exemple La célèbre suite de Fibonacci définie par F0 = 0, F1 = 1 ∀ n ∈ N, Fn+2 = Fn+1 + Fn et aura ainsi pour complexité : • O(2n ) si on la programme sans trop réfléchir : def Fibo ( n ) : if n == 0: return 0 elif n == 1: return 1 else : return Fibo (n -1) + Fibo (n -2) • O(n) si on la programme sous forme itérative ou sous la forme récursive : def Fibo_double ( n ) : """ calcule le vecteur [ F ( n ) ,F ( n +1) ] """ if n == 0 : return [0 ,1] else : resul = Fibo_double (n -1) return [ resul [1] , resul [0]+ resul [1]] def Fibo2 ( n ) : return Fibo_double ( n ) [0] • O(log2 (n)) si on utilise une exponentiation rapide pour calculer la puissance de matrice. 6 Lycée Victor Hugo 4 MPSI-PCSI 2016-2017 Occupation en mémoire : complexité spatiale On a vu que le processus récursif a pour effet de créer en mémoire une pile avec un numéro d’adresse (en fait un pour chaque valeur distincte de l’argument) et une valeur stockée (ou en attente) à cette adresse. Ainsi chaque appel récursif à un argument nouveau alloue de la mémoire ; cet inconvénient est rarement rédhibitoire (il l’était autrefois lorsque la place en mémoire coûtait cher), mais rappelons que Python limite par défaut le nombre d’appels récursifs imbriqués à 1000, on peut changer cette limite : from sys import * set recurs ionlim it (1500) 5 Difficulté intrinsèque de la notion de récursivité et prises de tête La récursivité pose intrinsèquement des problèmes logiques sur lesquels se sont penchés de grands logiciens du XXe siècle (Bertrand Russell, Kurt Gödel), mais aussi les linguistes et les philosophes. Songez juste pour illustrer ce point à la difficulté logique posée par la phrase (récursive !) syntaxiquement correcte : « Cette phrase est fausse » ... On peut démontrer qu’aucun algorithme ne pourra répondre de manière universelle (c’est-à-dire pour toute fonction récursive) à la question “la fonction termine”. 7