• Aucun résultat trouvé

Dans cette section, on aborde une autre classe abstraite que celle de pile. La pile fonctionne sur le principe LIFO (last-in, first out), la file fonctionne suivant le principe FIFO (first-in, first-out). Il y a plusieurs réalisations possibles, on en donne une qui utilise deux listes. L’ajout d’un élément à la file se fait uniquement avecappend dans la première liste, la suppression se fait avecpop dans la deuxième liste.

Deuxième liste

Première liste Entrée (avecappend)

Sortie (avecpop)

Lorsque la deuxième liste est vide et qu’on veut défiler (sortir un élément de la file), il est nécessaire de transférer les éléments de la première liste dans la deuxième. Pour conserver l’ordre dans la file, il est nécessaire d’inverser l’ordre des éléments lors du transfert. Voici les opérations de file à écrire :

— creer_file() : construire une file vide.

— est_vide(F) : renvoie un booléen suivant si la file F est vide ou non.

— defiler(F) : défile F en sortant l’élément en tête de file, et le renvoie. Si la file est vide, renvoie une erreur. — enfiler(F,x) : ajoute l’élément x à la queue de la file F.

11.6. STRUCTURE DE FILE Lycée Masséna

Et voici une implémentation :

def creer_file():

return [],[] #couple de listes def est_vide_file(F):

return F[0]==[] and F[1]==[] def enfiler(F,x):

F[0].append(x) def defiler(F):

assert not est_vide_file(F), "file vide" if F[1]==[]:

F[1][:]=F[0][::-1] # contenu de la deuxième liste remplacé par celui de la première, à l'envers. F[0][:]=[] # première liste vidée.

return F[1].pop()

Bien sûr, une classe serait tout indiquée. En terme de complexité, on peut montrer que toutes les opérations se font en temps constant (amorti).

Chapitre 12

Récursivité

12.1

Principes de la récursivité

12.1.1

Définition

La définition d’une fonction récursive est plutôt simple : c’est une fonction qui s’appelle elle-même. Prenons un exemple classique : le calcul de la factorielle. On définit, pour n ≥ 0, n! =Qn

i=1i. Informatiquement, on peut donc

définir la factorielle comme :

def fact(n):

assert n>=0, "n doit etre positif" f=1

for i in range(1,n+1): f=f*i

return f

Une autre définition mathématique classique de la factorielle se fait par récurrence :

n! = 

1 si n = 0 n × (n − 1)! sinon.

En reprenant quasiment mot pour mot cette dernière définition, on obtient la fonction Python suivante

def fact_rec(n):

assert n>=0, "n doit etre positif" if n==0:

return 1 else:

return n*fact_rec(n-1)

qui fonctionne tout aussi bien ! On distingue clairement deux cas dans cette fonction : — le cas n = 0, appelé cas terminal ;

— le cas n > 0, qui produit un appel récursif à la fonctionfact_rec.

12.1.2

La pile d’exécution

En informatique, la pile d’exécution (ou pile d’appels, call stack en anglais) est une structure de données de type pile, qui sert à enregistrer des informations au sujet des fonctions actives dans un programme. Une fonction active est une fonction dont l’exécution n’est pas encore terminée.

L’utilisation principale de la pile d’appels est de garder la trace de l’endroit où chaque fonction active doit retourner à la fin de son exécution. En pratique, lorsqu’une fonction est appelée par un programme, son adresse de retour (adresse de l’instruction qui suit l’appel) est empilée sur la pile d’appels. En plus d’emmagasiner des adresses de retour, la pile d’exécution stocke aussi d’autres valeurs, comme les variables locales de la fonction, les paramètres de la fonction, etc...

En particulier, lors d’appels imbriqués c’est à dire lorsqu’une fonction f appelle une fonction g, ce qui est relatif à l’appel de la fonction g est placé juste au dessus de ce qui est relatif à la fonction f . Lorsque g termine son exécution,

12.1. PRINCIPES DE LA RÉCURSIVITÉ Lycée Masséna

ce qui est relatif à l’exécution de g est dépilé. Comme l’adresse de retour est contenu dans la pile d’appel, l’exécution de f peut reprendre juste après l’endroit où g a été appelée.

Une fonction récursive est essentiellement une fonction qui s’appelle elle-même, ainsi les appels successifs à f s’empile dans la pile d’appels (voir figure 12.1).

programme principal n=4, fact_rec(4)=4*fact_rec(3) n=3, fact_rec(3)=3*fact_rec(2) n=2, fact_rec(2)=2*fact_rec(1) n=1, fact_rec(1)=1*fact_rec(0) n=0, fact_rec(0)=1

Figure 12.1 – La pile d’exécution lors de l’appel de fact_rec(4).

Une fois arrivé à un cas terminal (ne produisant pas d’appel récursif), le nombre d’éléments de la pile d’appels se réduit. Dans le cas de la fonction factorielle, comme celle-ci ne rappelle qu’une fois, dés lors qu’on a commencé à dépiler on ne s’arrête plus (voir la figure 12.2)

programme principal n=4, fact_rec(4)=4*fact_rec(3)

n=3, fact_rec(3)=3*2

Figure 12.2 – La pile d’exécution lors de l’appel de fact_rec(4) : on a dépilé les appels relatifs à n = 0, n = 1 et n = 2.

Enfin, la récursion s’arrête lorsqu’on dépile l’élément correspondant au premier appel de la fonction (figure 12.3).

programme principal n=4, fact_rec(4)=24

Figure 12.3 – La pile d’exécution lors de l’appel de fact_rec(4) : juste avant dépilage de l’appel initial. On voit que ce le nombre d’appels imbriqués réalisés par une fonction récursive peut être important : il faut stocker ces appels, ce qui est coûteux en mémoire. En Python, il est impossible de dépasser un certain nombre d’appels récursifs, on en reparle dans la sous-section qui suit.

12.1.3

D’autres exemples

Algorithme d’Euclide. Soient a et b deux entiers naturels, avec a > 0. Si b 6= 0, on a la relation PGCD(a, b) = PGCD(b, r), où r est le reste dans la division euclidienne de a par b. L’algorithme usuel de calcul du PGCD consiste donc à faire des divisions euclidiennes :

def PGCD(a,b): while b>0:

a,b=b,a%b return a

La version récursive repose sur le même principe :

def PGCD(a,b): if b==0:

return a else:

Calcul de puissances. On a vu en première année deux méthodes pour calculer xn, un algorithme d’exponentiation

naïf (faisant usage d’une bouclefor, calculant toutes les puissances de x entre 1 et n), et un algorithme d’exponentiation rapide (basé sur la décomposition en binaire de n). Les deux admettent des équivalents récursifs :

def expo(x,n): if n==0: return 1 else: return x*expo(x,n-1) def expo_rapide(x,n): if n==0: return 1 else: y=expo_rapide(x,n//2) if n%2==0: return y*y else: return y*y*x

12.1.4

Limites de la récursivité

L’usage de la récursivité présente deux inconvénients : il faut faire attention à ce que les appels récursifs ne se chevauchent pas, et prendre garde à ne pas faire un trop grand nombre d’appels récursifs imbriqués.

Chevauchement des appels récursifs. Considérons l’exemple suivant : soit (Fn)n∈Nla suite définie par

Fn =



1 si n = 0 ou 1. Fn−2+ Fn−1 sinon

Vous aurez probablement reconnu la fameuse suite de Fibonacci. Une transcription récursive en Python s’obtient aisément : def fib_rec(n): assert n>=0 if n==0 or n==1: return 1 return fib_rec(n-1)+fib_rec(n-2) n n − 1 n − 2 .. . 1 0 .. . n − 3 .. . ... n − 2 n − 3 .. . ... n − 4 .. . ... 1 0

Figure 12.4 – Un arbre d’appels récursifs

Le problème de l’algorithme précédent est le nombre d’appels récursifs effectués. Notons An le nombre d’appels

récursifs nécessaires pour le calcul de Fn. Alors, la suite (An) vérifie la relation de récurrence A0 = A1 = 0 et pour

tout n ≥ 2, An = 2 + An−1+ An−2, soit An+ 2 = (An−2+ 2) + (An−1+ 2). Autrement dit, la suite (An+ 2)n∈N

coïncide avec la suite (2Fn)n∈N. On peut donner une expression explicite de An, à savoir :

Pour tout entier naturel n, An =

2 √ 5   √ 5 + 1 2 !n+1 − 1 − √ 5 2 !n+1 − 2

mais ce qui importe, c’est que An ∼ n→+∞C ×

√

5+1 2

n

où C est une constante strictement positive. Un appel récursif n’est jamais gratuit (et possède un coût minoré par une constante strictement positive), ainsi le calcul de Fnpar cette

12.1. PRINCIPES DE LA RÉCURSIVITÉ Lycée Masséna

méthode est de complexité exponentielle, puisque

√ 5+1

2 > 1. Il vaut mieux utiliser une méthode itérative dans ce cas,

comme la suivante1: def fib(n): a,b=1,1 for i in range(n-1): a,b=b,a+b return b

On peut observer que la version itérative est moins claire que la version récursive. Elle a l’avantage de s’effectuer en temps linéaire2en n. En conclusion, il faut faire attention à ne pas faire des appels récursifs qui se recoupent, sous peine de voir la complexité exploser !

Taille de la pile (Python). Supposons que l’on souhaite calculer n!, pour n relativement grand3, disons 10000. A

priori, les deux versions (itérative et récursive) font l’affaire. Or l’appel àfact_rec(10000) produit un message d’erreur dont l’intitulé est RuntimeError: maximum recursion depth exceeded. En d’autres termes, le nombre maximal d’appels de fonctions imbriqués a été atteint. En Python, ce niveau est fixé à 1000. Cette valeur arbitraire peut-être augmentée4mais elle est là pour éviter un dépassement de capacité de la pile d’appels : le fameux « stack overflow »5.

Temps d’exécution : itératif contre récursif. Stocker systématiquement l’état d’une fonction avant chaque appel récursif dans la pile d’appels n’est pas gratuit, en temps comme en mémoire. Si la formulation itérative s’obtient facilement, il est en général préférable de l’utiliser.

12.1.5

Avantage de la récursivité

On a déja vu un avantage des fonctions récursives : leur formulation est plus simple que leur équivalent itératif. Montrons un exemple de problème pour lequel une solution récursive est très adaptée, mais pour lequel une solution itérative n’est pas facile à trouver : le problème des tours de Hanoï.

On dispose de n disques troués en leur centre, numérotés de 1 à n, de diamètres croissants. On se donne également 3 piquets (numérotés de A à C). Initialement, tous les disques sont enfilés sur le premier piquet, le plus grand étant à la base, le plus petit au sommet, comme sur la figure 12.5.

7 6 5 4 3 2 1

piquet A piquet B piquet C

Figure 12.5 – Le jeu de Hanoï : comment déplacer les 7 disques du piquet A au piquet C, en suivant les règles ? Le but du jeu est d’amener les disques sur le troisième piquet, en suivant les règles suivantes :

— déplacer les disques un à un d’un piquet à un autre ;

— un disque ne doit jamais être posé sur un disque de diamètre inférieur.

La figure 12.6 montre les 7 mouvements à effectuer pour résoudre le jeu avec seulement 3 disques. Il est facile de voir qu’il faut 127 mouvements pour le jeu à 7 disques (voir la suite).

On cherche à donner les mouvements de disques à effectuer pour résoudre le jeu. De manière itérative, il n’est pas évident à résoudre, mais il est très facile de le faire lorsqu’on pense à la récursivité. Soient i, j et k trois caractères tels que {i, j, k} = {A, B, C}, et n ∈ N. Pour faire passer n disques du piquet i au piquet j :

1. on peut cependant s’en sortir avec une version récursive ayant un coût linéaire.

2. Une autre remarque : cette fonction permet de calculer tous les termes de la suite. On peut faire mieux si on cherche uniquement le n-ième, voir TD !

3. Rappelons à toute fin utile que les entiers ne sont pas bornés en Python. 4. À l’aide de la fonction setrecursionlimit du module sys.

3 2 1 3 2 1 3 2 1 3 2 1 2 1 3 1 2 3 1 3 2 3 2 1

Figure 12.6 – Résolution du jeu de Hanoi pour n = 3

— il n’y a rien à faire si n = 0 ;

— pour n ≥ 1, il suffit de faire passer les n − 1 disques numérotés de 1 à n − 1 du piquet i au piquet k, de déplacer ensuite le disque n du piquet i au piquet j, puis de refaire passer les n − 1 disques du piquet k au piquet j. Le fait de travailler avec les disques les plus petits permet de ne pas violer la deuxième règle.

Écrivons donc une fonction Python qui imprime à l’écran la suite des mouvements à effectuer pour résoudre le jeu. Un mouvement est décrit commei -> j, ce qui signifie faire passer le disque supérieur du piquet i au piquet j :

def deplacement(i,j): print(i, " -> ", j)

La discussion précédente invite à écrire une fonctionhanoi(n) résolvant le jeu à n disques, qui fait un unique appel à une fonction récursive interneaux(n,i,j,k) qui doit donner la suite des mouvements permettant de faire passer n disques du piquet i au piquet j, avec {i, j} ⊂ {A, B, C}. Pour des raisons de commodité, il est pratique d’indiquer le dernière lettre parmi {A, B, C} dans une variable (k) :

def hanoi(n):

""" problème de Hanoi: déplacer une pile de n disques du piquet 1 au piquet 3 """ def aux(n,i,j,k):

""" deplacer n disques du piquet i au piquet j, le piquet restant étant k. {i,j,k}={1,2,3}""" if n!=0: aux(n-1,i,k,j) deplacement(i,j) aux(n-1,k,j,i) aux(n,"A","C","B") Testons avec n = 3 : >>> hanoi(3) A -> C A -> B C -> B A -> C B -> A B -> C A -> C

On retrouve les mouvements de la figure 12.6. Il est facile de montrer par récurrence que le nombre de mouvements produits est 2n− 1 : c’est optimal6.

Comme on le voit sur cet exemple, les fonctions où plusieurs appels récursifs sont nécessaires ne sont pas vraiment faciles à traduire de façon itérative (à moins d’utiliser une pile pour essentiellement réécrire la récursivité...) : c’est un avantage de l’emploi de fonctions récursives.