Connexion élèves

Choisir le(s) module(s) à installer :

Récursivité

On parle de récursivité d'un algorithme lorsqu'il utilise une fonction qui vérifie la particularité suivante :

Une fonction est dite récursive lorsqu'à un moment de son exécution, elle fait appel à elle-même.

Cet appel peut se faire une fois, deux fois, ou plusieurs fois de suite : la fonction se retrouve "imbriquée dans elle-même" !

Étrange particularité...pour mieux illustrer ce concept "d'appel à soi-même", voila quelques exemples de récursivité en dehors du domaine de l'informatique :

La Vache Qui Rit

Dans le logo de cette célèbre marque de fromage fondu, l'image de la vache se retrouve à l'identique dans ses boucles d'oreilles, et dans l'image sur ces dernières, on retrouve la même image sur les boucles d'oreille, etc...

Triangle de Sierpinski

Le triangle de Sierpiński est un exemple de figure fractale; elle est constituée en répétant le même motif quelque soit l'échelle à laquelle on la regarde.

Dans tous ces exemples, on se sert d'un objet pour fabriquer l'objet lui-même ! C'est ça le principe de la récursivité...mais en informatique, au lieu d'une "mise en abyme" infinie comme dans ces exemples, il faut impérativement que ce "bouclage sur soi-même" s'arrête à un moment ou un autre !

La récursivité s'oppose à la programmation itérative, où les instructions s’exécutent les unes après les autres jusqu'à arriver au résultat recherché; nous verrons quand il est possible de transformer un programme itératif en programme récursif ( l'inverse étant généralement beaucoup plus difficile ), et dans quel cas lequel de ces paradigmes est le "meilleur" à utiliser.

Exemples d'introduction

Un premier exemple un peu inutile...

Considérons le code suivant :

def f(n): if n == 0: print("Bye-bye !") else : print("Salut !") f(n-1) f(5)

Ah oui, super...moi je peux faire ça avec une simple boucle...

Et effectivement, une boucle peut facilement être transformée en un algorithme récursif ( et vice et versa ), mais ce qui nous intéresse ici, c'est : comment ça marche ???

Examinons pour cela le déroulement du script :

  1. on commence par un appel à la fonction f(n) en lui passant l'argument 5
  2. dans la fonction, on trouve une condition, qui n'est pas alors vérifiée
  3. ...donc le else est exécuté : on affiche donc "Salut !"
  4. et puis....on rappelle la fonction f(n) à partir d'elle-même !
    Sauf que cette fois, on lui passe comme argument 5 - 1 = 4; le processus recommence donc à l'étape 2, puis de même avec les arguments 3, puis 2, puis 1
  5. vient alors un moment ou l'argument passé est égal à 0; la condition est alors vérifiée : on affiche Bye-Bye !, et....tout s'arrête : la fonction f(n) n'est plus rappelée.

Et bien voila, le résultat de la fonction f(n) a été obtenu en faisant des appels successifs à elle-même, et c'est ça la base de la récursivité.

Cet exemple n'est pas très intéressant par rapport à une simple boucle, car chaque récursion ( = appels successifs de la fonction à elle-même ) ne fait qu'appeler la fonction, avec un paramètre différent à chaque fois certes, mais sans rien lui retourner...
L'étude de l'exemple suivant, déjà plus "évolué, va nous permettre également de préciser la manière de concevoir un algorithme récursif et tout le vocabulaire qui tourne autour de la récursivité.

Un deuxième exemple un peu plus utile...

On veut maintenant écrire une fonction qui calcule la somme des entiers de 1 jusqu'à N inclus, paramètre passé à la fonction.

Par exemple, pour N = 10, la fonction calculera la somme : 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 = 55

Algorithme itératif

On utilise une boucle dont l'indice varie de 1 jusqu'à N, et on ajoute à chaque tour de boucle la valeur de cet indice à une variable somme :

def somme(N): somme = 0 for i in range(1, N+1): somme = somme + i return somme print(somme(10))

Algorithme récursif

Cas général

Il faut voir les choses sous un autre angle, et réfléchir cette fois en terme de relation qui existe entre les différentes valeurs additionnées.
On peut ainsi écrire :

De même :

Etc, etc...Autrement dit, on a pour un entier N quelconque : somme(N) = N + somme(N - 1). La somme totale est égale à l'entier le plus grand + la somme des entiers inférieurs, elle-même égale à l'entier inférieur + la somme des entiers inférieurs, elle-même égale à etc, etc...

Et voila la relation ( on parle en mathématiques de relation de récurrence ) que nous allons utiliser pour obtenir la somme des N entiers :


def somme_recurs(N):
	
	return N + somme_recurs( N - 1 )
			

→ la fonction va s'appeler elle-même "en chaîne", en se passant à chaque appel un argument différent; le résultat retourné par la fonction sera obtenu à partir de celui retourné par le niveau "inférieur" de récursion, qui lui-même sera obtenu à partir du résultat du niveau encore en dessous, et ainsi de suite...

Principe récursion

Chaque appel de la fonction n'est donc pas immédiatement suivi du renvoi de son résultat : il est "en attente" du calcul du résultat suivant...Voila pourquoi on parle d'appels récursifs de la fonction = appel réalisé alors que l’interprétation d’un appel précédent de la même fonction n’est pas terminé.

Mais du coup, comment le programme peut-il se "souvenir" des résultats des différents niveaux de récursions ? Chaque appel successif de la fonction récursive "n'écrase"-t-il pas le résultat du précédent ?

En réalité, non : un mécanisme est prévu pour cela, la pile d'exécution. A chaque appel d'une fonction, l'ordinateur stocke ( il "empile" ) quelque part en mémoire différentes valeurs, notamment l'adresse mémoire correspondant au point du programme où la fonction a été appelée, de façon à savoir "où" retourner une fois le travail de cette dernière terminé; il y stocke également le résultat retourné par la fonction, tant que le retour de la fonction n'est pas terminé. Les différents résultats d'une récursion, "empilés" successivement ne sont donc pas oubliés, et sont "dépilés" au fur et à mesure de la "remontée" de chaque niveau.

Vous étudierez le fonctionnement général d'une pile, c'est au programme...

Cas de base

On voit donc qu'il manque encore à notre algorithme une chose indispensable : il faut que les appels successifs de la fonction à elle-même s'arrêtent à un moment, de façon à "déclencher" la "remontée" progressive des résultats d'un niveau de récursion au niveau supérieur, sinon les appels se prolongeront à l'infini sans que l'on n'obtienne aucun résultat !.

Il faut donc prévoir à tout algorithme récursif une condition d'arrêt, que l'on appelle également cas de base. Ce cas correspond à la situation où il n'est pas nécessaire d'effectuer de calcul pour obtenir le résultat qui sera renvoyé au niveau supérieur.

Dans notre exemple, c'est lorsque N = 0 qu'il faut arrêter l'algorithme : en effet, il n'y alors plus rien à calculer.
A ce moment, la fonction doit donc renvoyer 0 ( on n'ajoute plus rien à la somme ) : il n'y a plus de nouvel appel à la fonction, la récursion s'arrête alors, et les différents résultats sont remontés successivement vers les niveaux supérieurs.

La fonction complète :


def somme_recurs(N):
    if N == 0:
        return 0
            
    return N + somme_recurs( N - 1 )
        
print(somme_recurs(10))
			

Tracé des appels successifs

Cela semble peut-être un peu "magique", mais en fait tout est bien entendu parfaitement logique; voila l'arbre des appels et des retours successifs dans le cas où N = 4 ( résultat = 10 ) :

Pensez à installer le module recviz au préalable.

from recviz import recviz @recviz def somme_recurs(N): if N == 0: return 0 return N + somme_recurs( N - 1 ) print(somme_recurs(4))
Trace récursion pour N = 4

Principes de la récursivité

En résumé, une fonction récursive doit comporter :

  • une condition d'arrêt ou cas de base dans lequel aucun autre appel n’est effectué, et qui "déclenche" la remontée des résultats vers les niveaux supérieurs de la récursion.
    Ce cas correspond au résultat de la fonction lorsqu'il est simple à calculer : la valeur renvoyée par la fonction est alors directement définie.
  • Un cas général dans lequel un ou plusieurs autres appels sont effectués.
    A chaque appel récursif, la valeur d'au moins un des paramètres de la fonction doit changer.
  • La chaîne d’appel doit se terminer, c'est à dire conduire obligatoirement au cas de base.

Pour illustrer le dernier critère, voila un exemple de récursion qui peut poser problème ( c'est notre tout premier exemple ) :


def f(n):
	# condition d'arrêt
	if n == 0:
		print("Bye-bye !")
	else :
		print("Salut !")
		return f(n-1)
			

Tout est là, cas de base, appel récursif. L'appel de cette fonction avec un paramètre n positif ne pose effectivement aucun problème :

	
	>>> f(5)
			
	Salut !
	Salut !
	Salut !
	Salut !
	Salut !
	Bye-bye !

	>>> 		
			

Par contre, avec n négatif, la condition d'arrêt ne sera jamais vérifiée : la chaîne des appels ne terminera jamais !

	
	>>> f(-5)
	
	Salut !
	Salut !
	.....
	.....
	Salut !
	Salut !
	Salut !
	Salut !
	Traceback (most recent call last):
	  File "test.py", line 8, in 
	    f(-5)
	  File "test.py", line 6, in f
	    f(n-1)
	  File "test.py", line 6, in f
	    f(n-1)
	  File "test.py", line 6, in f
	    f(n-1)
	  [Previous line repeated 1009 more times]
	  File "test.py", line 5, in f
	    print("Salut !")
	RecursionError: maximum recursion depth exceeded while pickling an object
	>>> 
			

( L'erreur alors rencontrée correspond à un "débordement de pile" et est expliquée ci-dessous.)

Sachant que d'après les travaux Alan Turing, il n'existe aucun moyen algorithmique pour prouver qu'un programme se terminera ( voir le chapitre sur la calculabilité ), c'est au concepteur de l'algorithme de vérifier que celui-ci est correctement écrit...

Recursive forgot

Comparaison itératif / récursif

Que dire ce cette façon d'envisager les algorithmes ? Il se trouve que de nombreux algorithmes itératifs peuvent se transformer en leur version récursive : c'est le cas par exemple des boucles for :

Version itérative Version récursive

def f(a, n):
	m = 0
	for i in range(n):
		m = m + a 
	return m
						

def f(a, n):
	if n == 0:
		return 0 
	return a + f(a, n-1)
						

( Que fait cette fonction ? Ce sera le but d'un exercice que vous traiterez ci-dessous...mais vous avez peut-être déjà trouvé ?)

Que faut-il mieux utiliser alors ?

Inconvénients Avantages
  • Cette façon de concevoir un programme semble plus compliquée que la méthode itérative, et c'est vrai qu'il faut faire un effort pour bien saisir ce concept qui n'est pas intuitif.
  • Pour stocker les calculs intermédiaires, la récursivité fait abondamment usage de la pile d’exécution.
    Plus il y a d'appels récursifs à une fonction, plus la pile d’exécution se trouve mise à contribution, hors elle a une taille limitée ( en Python, c'est par défaut environ 1000 appels récursifs mais cela peut être augmenté ) : une récursion "trop profonde" peut donc conduire à "faire déborder" la pile ce qui fait planter le programme; la version itérative du même programme ne présenterait pas ce problème.
  • du point de vue de la complexité, un algorithme récursif n'est souvent pas plus performant que son équivalent itératif ( les appels de fonction sont en effet "plus coûteux" qu'un test ou une opération arithmétique ).
    Mais l'inverse est aussi vrai...on ne peut donc pas généraliser sur l'efficacité de tel ou tel paradigme.
  • Les fonctions récursives peuvent parfois grandement améliorer la lisibilité du code : elles nécessitent moins d'instructions que leur version itérative ( comparez les deux versions de notre premier exemple ).
  • elles sont parfois plus facile à concevoir lorsque le problème à résoudre s'y prête bien. Dès lors que la résolution d'un problème peut être faite en résolvant une version simplifiée du problème initial, il y a lieu de penser à la récursion.
    Dans notre exemple ( la somme des entiers ), le résultat peut être connu en utilisant le résultat de la somme des n−1 premiers nombres entiers, problème plus simple à résoudre.
    Il arrive même qu’un problème ait une solution récursive évidente alors qu’il est très difficile d’en trouver une solution itérative.
  • elles permettent de prouver plus facilement qu'un algorithme se termine : en effet, si on prouve que l'algorithme est correct au niveau n-1, alors il le sera aussi au niveau n ( et à tous les autres niveaux de la récursion )...
    On fait parfois l'analogie avec la montée sur une échelle :
    • je sais monter du sol sur le premier barreau d'une échelle
    • ...alors je sais monter du premier au deuxième barreau, du deuxième au troisième, etc...
    • ...donc je sais monter sur une échelle.
  • La plupart des traitement sur les tableaux peuvent se mettre très simplement sous forme récursive :
    • Tris (sélection, insertion)
    • Recherche d'une valeur ( séquentielle, dichotomique,... )
    • Inversion
    • ....
  • de même, le parcours de structures de données complexes comme les graphes et les arbres se traitent très simplement par récursivité.

La réponse à la question posée ( itératif ou récursif ? ) est donc la suivante :

  • si l'algorithme s'exprime plus naturellement en récursif ( vous verrez des exemples ), alors utiliser la récursivité, sinon pas la peine de se compliquer la vie ( même si c'est intéressant :-)).
  • si il y a des risques de récursion trop longue, donc de débordement de pile, alors préférer l'itératif, sinon ce peut être intéressant d'utiliser la récursivité, d'autant qu'il existe souvent des optimisations ( du domaine de la programmation dynamique ) qui permettent de diminuer les niveaux de récursion.

D'autres exemples

La fonction mystère

On considère la fonction récursive de l'exemple du paragraphe 3.

  1. identifier dans cet algorithme :
    • le cas général
    • le cas de base
  2. en vous inspirant du tracé des appels/retours fait pour l'exemple du paragraphe 1.1, tracer les résultats retournés par les appels récursifs de la fonction. Conclure sur son rôle !

SOLUTION

Une fonction récursive peut bien entendu avoir plusieurs paramètres, dont les valeurs initiales peuvent être précisées par défaut... mais chacun ne sera pas forcément modifié à chaque appel récursif.

Les exercices suivants sont des exemples de telles fonctions.

La fonction puissance

On rappelle que la puissance x d'un nombre y est définie par : yx = y * y * y * ..... * y ( x fois )

  1. écrire une fonction itérative qui calcule la puissance x d'un entier y, valeurs passées en paramètres.
  2. écrire la fonction récursive qui réalise le même travail; pour cela :
    • déterminer la relation de récurrence qui existe entre le niveau n et le niveau n-1 de la récursion ( autrement dit : trouver le lien qu'il y a entre yx et yx-1 ).
    • déterminer le cas de base et le résultat qui doit alors être renvoyé
  3. vérifiez bien entendu que vos deux algorithmes donnent le même résultat pour deux mêmes valeurs x et y !
def puissance_iterative(y: int, x: int) -> int: pass def puissance_recursive(y: int, x: int) -> int: pass

Construire une chaîne

Écrire une fonction chaine_numerique() qui construit récursivement une chaîne constituée des nombres de i jusqu'à n, valeurs passées en paramètres de la fonction.

Par exemple, chaine_numerique(3, 12) doit renvoyer "3456789101112".

def chaine_numerique(i: int, n: int) -> str: pass

SOLUTION

Caractère dans une chaîne

Écrire une fonction est_dans() qui :

  • prend comme paramètre une chaîne de caractères et un caractère donné
  • détermine récursivement si le caractère se trouve dans la chaîne ( renvoie True ) ou pas ( renvoie False ).

Ne pas oublier qu'une chaîne de caractère n'est pas mutable : on ne peut donc pas lui "enlever de caractère"...

Une indication : il va falloir parcourir la chaîne depuis son premier caractère jusqu'à son dernier, en testant à chaque fois si le caractère est celui recherché; la récursion remplace donc dans ce cas une simple boucle.
On va donc se servir d'un paramètre supplémentaire ( en plus de la chaîne à "explorer" ) dans la fonction ( ici, le paramètre i ), qui servira de "pointeur d'index" pour parcourir la chaîne; ce "pointeur" sera incrémenté à chaque appel récursif, jusqu'à ce que l'on atteigne la fin de la chaîne ( cas de base 😏 ! ).
On donne à ce paramètre une valeur par défaut ( 0 ), ce qui permet d'omettre de le passer en argument lors du premier appel de la fonction depuis le programme principal, améliorant ainsi l'interface de la fonction du point de vue de l'utilisateur.

On peut donner à une fonction un nombre quelconque de paramètre(s) ayant une valeur par défaut. Mais il faut bien les indiquer après les paramètres sans valeur dans la liste des paramètres de la fonction.

def est_dans(chaine: str, car: str, i = 0) -> bool: pass

SOLUTION

Pendule récursive