T 3.4 Parcours récursifs d'itérables

Le Triptique CAC

Dans tous les algorithmes que vous avez rencontrés, certaines techniques reviennent souvent. Notamment l'utilisation de :

  • Ccompteur : exemple : compter les valeurs paires dans une liste
  • Aaccumulateur : exemple : faire la somme des éléments d'une liste
  • Ccurseur : exemple : rechercher le max des valeurs d'une liste ou rechercher l'indice du max (ce n'est pas tout à fait la même chose)
Vous avez appris à manier ces outils dans des contextes variés. Parfois il faut utiliser deux de ces techniques en même temps.
Par exemple, calculer la moyenne des valeurs paires d'une liste demande un compteur (pour compter les valeurs paires) et un accumulateur (pour additionner les valeurs paires), et on renvoie le rapport somme des valeurs / nombre de valeurs

Dans les cours précédents vous avez appris à utiliser la récursion. Ici nous allons l'appliquer à des itérables (listes, chaîne de caractères etc...)

Transposons ces notions en récursif (pour des parcours de listes)


compteur récursif
Reprenons un exemple de Compteur en impératif: compter le nombre de valeurs paires d'une liste.
Exemple : Un exemple d'utilisation d'un Compteur. nbMult2 est le compteur.


Comment faire la même chose en mode récursif


Notre compteur nbMult2 dans la fonction nbrePairs(lst) peut se concevoir de façon récursive comme :
nbMult2 = (1 si l[0] est pair, 0 sinon) + nbrePairs du reste de la liste
Si le 1er nombre est pair on renvoie 1 + nombre pair du reste
Si le 1er nombre est impair on renvoie 0 + nombre pair du reste

Par exemple : nb=nbPairs([1, 2, 3, 4, 6]) :


renvoyer 0 + nbPairs([2, 3, 4, 6])................................. # 0 car 1 est impair
renvoyer 0 + (1 + nbPairs(3, 4, 6]) )...............................# 1 car 2 est pair
renvoyer 0 + (1+ ( 0 + nbPairs([4, 6]) )........................# 0 car 3 est impair
renvoyer 0 + (1+ ( 0 + 1 nbPairs([6]) ) ) ) ................. # 1 car 4 est pair
renvoyer 0 + (1+ ( 0 + ( 1 + ( 1 + ( nbPairs([]) ) ) ) ).. # 1 car 6 est pair
renvoyer 0 + (1+ ( 0 + ( 1 + ( 1 + ( 0 ) ) ) ).. # fin, on appelle plus la fonction !

Et quand il n'y a aucun élément (liste vide) : nbPairs([]) = 0

  • La longueur de la liste décroit, la liste finira par être vide
  • le nombre d'élément pairs es bien égal à ce qu'on renvoie
Exemple : Un exemple de transposition du compteur en mode récursif

La condition d'arrêt : si lst est vide, nb=0, s'écrit :

if len(lst) == 0:
    return 0

Exercices avec un compteur

Exercice de code : combien de fois ?
Ecrire une fonction récursive qui compte le nombre d'occurrence d'une lettre dans un texte.
Exercice de code : Longueur
compter le nombre d'éléments dans un iterable mutable (liste ou chaine).

Dans cette version utilisant le slicing, on envisage seulement les cas d'itérable str ou list


Accumulateur récursif

Les accumulateurs et la récursion


Reprenons un exemple d'accumulateur en impératif : calculer la somme des éléments d'une liste
Exemple : Un exemple d'utilisation d'un Accumulateur : somme est l'accumulateur.

Notre fonction somme(lst) ci-dessus se comprend de façon récursive comme :

somme = lst[0] + somme du reste de la liste = lst[0] + ( lst[1] + somme du reste ) = etc...

jusqu'à ce que le reste ne contienne aucun élément (liste vide) et la somme sera alors 0.

Exemple :

 somme = somme([2, 4, 1, 5]) 
       = 2 + somme([4, 1, 5])
       = 2 + (4 + somme([1, 5]) ) 
       = 2 + (4 + (1 + somme([5]) ) )
       = 2 + (4 + (1 + (5 + somme([]) ) ) )
       = 2 + (4 + (1 + (5 + 0) ) ) ) = 10

le cas de base sera : si la liste est vide, renvoyer 0

Assurément :

  • La liste perd un élément à chaque appel, elle finira par être vide
  • somme(tous les éléments) est bien égal à lst[0] + sommes des autres éléments

Exercice

Codez la fonction somme en récursif

Exercice de code : Somme des éléments d'une liste (récursivement)

Regardez bien ce que nous avons écrit :
somme = lst[0] + somme du reste devient, dans python :

return lst[0] + somme(lst[1:])
Et pour la condition d'arrêt :
jusqu'à ce que le reste ne contienne aucun élément s'écrit :

if len(lst) == 0 :
    return 0

Des coûts cachés...

Dans les exemples que nous avons traité, nous avons utilisé le slicing avec des instructions comme :

somme(lst[1:])
Pour exécuter une telle instruction, l'interpréteur python va dupliquer la liste (sauf le premier élément). La complexité de cette opération est O(n). Si cette opération doit être réalisé à chaque appel et qu'on fait N appels, on se retrouve avec une complexité en O(n²) alors qu'on a un algorithme en O(n). C'est donc bien commode mais plutôt désastreux.

Nous allons voir comment éviter ces copies de lites, donc coder sans slicing.

Commençons par un exemple.

On veux écrire une fonction estTriee(lst) qui renvoie True si la liste est triée (croissante) et False sinon.

Une version impérative pourrait être :

Exemple
Détermine (en impératif) si une liste est triée ou non

Ce problème est clairement récursif. On compare 2 éléments (on commence par les 2 permiers) et on vérifie l'ordre. Si l'ordre est mauvais on renvoie False, sinon au continue sur le reste de la liste.

Il n'y a pas à proprement parler de curseur dans cette fonction, mais la technique reste quand même assez similaire. Une variante serait de renvoyer la position du premier élément non ordonné, et on aurait vraiment un curseur. Mais nous allons rester sur la version plus simple donnée ci-dessus..

1ere version : en utilisant des slices, pour découper la partie de liste restant à traiter.

Exemple
Détermine (en récursif) si une liste est triée ou non

Ce code fonctionne et il est assez similaire à la version non récursive.

L'algorithme est clairement de complexité linéraire (n-1 comparaison)

Mais notre code n'est pas de complexité linéaire ! En effet, à chaque exécution de estTriee, on effectue une copie de lst.

Pour une longueur N, je dois rappeler estTriee avec une longueur n-1, et je dois copier n-1 élément dans une nouvelle liste. Le temps d'éxécution peut s'ecrire :

T(n) = T-1) + T(n-1) + n

Pour comprendre cette formule, prenons une analogie :

Je dois éplucher n pommes de terre. Le temps que je vais mettre est en principe égal au temps d'éplucher une pomme de terre + le temps d'éplucher les n-1 restantes.

T(n) = T(1) + T(n-1) nous donne une complexité linéaire

Mais imaginons que après chaque pomme de terre épluchée, je doive déplacer une à une toutes les pommes de terre restantes dans une autre pièce avant de poursuivre l'épluchage...

Le temps total sera alors :

T(n) = T(1) + T(déplacer une a une) + T(n-1)

Et comme je dois toutes les déplacer, le temps de déplacement sera proportionnel à n -1. Notons T' le temps pour déplacer une pomme de terre :

T(n) = T(1) + (n - 1) T' + T(n - 1)

Et donc

T(n) = T(1) + (n-1) T' + [  T(1) + (n-2) T' + T(n-2) ]
     = 2 . T(1) + [(n-1) + (n-2)] T' + T(n-2)
     = 2 . T(1) + [(n-1) + (n-2)] T' + [  T(1) + (n-3) T' + T(n-3) ]                      
     = 3 . T(1) + [(n-1) + (n-2) + (n-3)] T' + T(n-3)

Etc.... on arrivera à :

T(N) = N . T(1) + (1 + 2 + ... + n-1) T'

et on reconnait la somme \sum\limits_{n = 1}^{N-1} n = \frac{N(N-1)}{2} = 0(N^2)

Cette formule de récurrence mène donc à une complexité en O(n²).

Retenir :

Si le cas général est en temps constant : T(N) = O(1) + T(N-1) -> complexité linéaire O(N)

Si le cas général est en temps proportionel à N: T(N) = O(N) + T(N-1) -> complexité quadratique O(N²)


Comment éviter le slicing ?

Pour éviter le slicing, il va falloir utiliser les indices. En impératif, nous disposons de l'itérateur qui parcours aisément la liste. En récursif c'est plus délicat, et pour y parvenir nous allons devoir ajouter un attribut à notre fonction.

Au départ il vaudra 0, et nous allons lui attribuer cette valeur par défaut.

A chaque appel de estTriee( lst, n )

  • on compare lst[n] à lst[n+1]
    • Si les deux valeur sont dans l'ordre on reprend en incrémentant n
    • sinon on renvoie False :

On s'arrêtera au dernier, qu'on aura pas besoin de comparer au suivant puisqu'il n'y a plus de suivant.

Le cas de base est donc : si n = len(lst) - 1 renvoyer True

Exemple
Détermine (en récursif et sans slice) si une liste est triée ou non

Ici le cas général et constitué d'un simple test de comparaison, une affectation, et un appel, c'est bien en temps constant : T(N) = T(1) + T(N-1)

La complexité du code est donc linéaire O(N).

Retenir :

Pour connaitre l'indice de l'élément en cours de traitement :

° Définir un paramètre avec valeur par défaut égale à 0

° A chaque appel, incrémenter ce paramètre

Exercices

Reprendre les 2 codes des accumulateurs et les réécrire en n'utilisant pas de slice.

Somme des éléments d'une liste

Exercice de code : Somme des éléments d'une liste (récursivement sans slice)
Le code fourni utilise des slice. Vous devez le modifier pour parcourir la liste sans utiliser de slicing.

Nombre d'élément pairs dans une liste

Exercice de code : Compter le nombre d'éléments pairs en récursif et sans slice
Le code fourni utilise des slices. Vous devez le modifier pour parcourir la liste sans utiliser de slicing.

Si vous n'aimez pas les paramètres par défaut....

Il existe une autre façon de procéder. L'idée est la même : ajouter un paramètre. Mais au lieu d'en ajouter un avec une valeur par défaut, on crée purement et simplement une autre fonction :

Exemple
Détermine (en récursif et sans slice) si une liste est triée ou non

RETENIR

Le problème des traitements récursifs d'itérables est un peu différent, on ne dispose pas d'un itérateur comme dans la boucle for, et pourtant on itère...

1er solution : on fait du slicing
exemple :

def somme_lst(lst) :   
     if len(lst) == 0 : 
         return 0    
     return lst[0]  + somme_lst(lst[1:])

C'est plutot simple, et élégant, mais cela à un coût ( en 0(n) ) et c'est donc peu recommandé d'autant plus si on prend aussi en compte la complexité en espace.

2eme solution : un paramètre par défaut (ou plusieurs)

exemple :

def somme_lst(lst, n = 0) :
    if n == len(lst) : 
        return 0
    return lst[n]  + somme_lst(lst, n+1)

3eme solution : une fonction intermédiaire ou une fonction locale

exemple 

def somme_lst(lst ) :
    return somme_lst_rec(lst, 0)

def somme_lst_rec(lst, n) :
    if n == len(lst) : 
        return 0
    return lst[n]  + somme_lst_rec(lst, n+1)

variante avec fonction locale :
def somme_lst(lst) :
    def somme_lst_rec(lst, n):
        if n == len(lst): 
            return 0 
        return lst[n]  + somme_lst_rec(lst, n+1)
    return somme_lst_rec(lst, 0)

Curseur récursif 1 : la fonction getMax

Les curseurs sont utilisés lorsqu'on doit scanner un itérables pour repérer un éléments selon un critère. Par exemple le plus grand, ou la dernière (ou première) occurrence d'une valeur, ou le premier (ou dernier) multiple d'un nombre, ou une clef d'un dictionnaire ayant une valeur donnée etc...

Les codes sont présentés pour une recherche du max mais peuvent aisément être adaptés pour la recherche du min.

Recherche du max : on déplace le curseur en notant la valeur la plus grande

Avant de débuter notons que rechercher le max dans une liste vide n'a pas de sens. Nous ne considérerons que des listes non vides

La version itérative :

  • on initialise vmax à lst[0]
  • Du 1er au dernier élément :
    • si l'élément est plus grand on actualise vmax
Exemple : Un exemple d'utilisation d'un Curseur. vmax est le curseur

Les curseurs et la récursion


Envisageons un exemple : la recherche de la plus grande valeur dans une liste de nombres : lst = [1, 3, 7, 2, 11, 4] le max est 11.

Si on pense en récursif ici, cela donne :

Exemple
Dans ce code, nous avons écrit :
       vmaxReste  = getMax(lst[1:])
if lst[0] >vmaxReste:
vmax = lst[0]
else :
vmax = vmaxReste
return vmax

Nous avons donc d'abord appelé getMax avant de renvoyer la bonne valeur. Pourquoi ?
On aurait put écrire : :
if lst[0] > getMax(lst[1:])
return lst[0]
else :
return getMax(lst[1:])

Mais quel problème cela pose-t-il ?
Pour vous aider, visualiser le code qui a été recopié ci-dessous, puis comparer en visualisant également le code précédant.
N'oubliez pas de choisir la langue anglaise.
Exemple

Et pour le faire sans slice ?

On introduit un second paramètre, dont la valeur par défaut est 0, qui est l'indice de l'élément en cours de traitement.

Par exemple, si n = 1 :

Et le cas de base sera n = len(lst) - 1 : un seul élément donc c'est lui le max, on renvoie lst[n]

Exercice de code

Curseur récursif 2 : la fonction idxMax

Cas de la recherche de l'indice du max : on déplace le curseur en notant l'indice

Dans le cas ou l'on recherche l'indice du max de façon récursive, nous allons devoir bien comprendre un point plus délicat : l'indice d'une valeur dans lst[1:] n'est pas le même que l'indice de la même valeur dans lst.

Ce qu'il faut bien comprend c'est le 1 + indice du max du reste.


La fonction s'écrira donc :

Exemple : Un exemple d'utilisation d'un Curseur en récursif

la ligne clé est :

        idxMaxReste = 1 + idxMax(l[1:])
L'utilisation du curseur est donc un peu plus compliquée à bien comprendre. En fait, idx est l'indice du max dans la liste, mais dans la fonction appelante, qui contient un élément de plus en tête, l'indice est idx + 1. Une fois bien compris ceci, l'utilisation de curseur ne devrait plus vous poser de difficulté.
La condition d'arrêt si len(l) == 1 : return 0 est intuitive

Une autre approche : un argument supplémentaire par défaut

Dans ces situations, comme pour le cas de la fonction estTriee, une autre approche peut se révéler plus intuitive, mais elle nécessite l'utilisation d'un paramètre supplémentaire qui doit avoir une valeur par défaut.

Rappelons le principe d'un paramètre avec valeur par défaut :

def f(x, n = 0):
    pass
# appel sans le second paramètre :
f(2) # x vaudra 2 et n vaudra 0
f(2) est identique à f(2,0) ou f(2, n=0 )

# appel avec 2 paramètres :
f(2, 3) ou f(2, n=3) # x vaudra 2 et n vaudra 3
La fonction sera définie ainsi :

def idxMax(lst: list, idx = 0)
Exemple : chercher l'indice du max dans [ 1 , 3 , 4 , 2 ]

1er appel : getIdxMax( [1 , 3 , 4 , 2 ] )

idx vaut 0 : on cherche dans toute la liste

On renvoie le plus grand entre lst[0] et getIdxMax(lst, 1)

2ème appel idx vaut 1, On renvoie l'indice associé à la plusgrande valeur (on compare lst[1] et lst[ getIdxMax(lst, 2) ]

Voir ci-dessous, par exemple un appel avec idx = 2 :

etc... et on arrête quand idx et l'indice du dernier élément, c'est-à-dire len(lst) - 1. Dans ce cas l'indice du max est idx puisqu'on ne cherche que parmi le dernier élément.

L'algorithme peut être décrit ainsi :

cas général :
idxMaxReste = indice_du_max(lst, idx + 1) (indice du plus grand dans le reste de la liste)
on renvoie idx si lst[idx] > lst[idxMaxReste] sinon on renvoie idxMaxReste
cas de base :
si idx est l'indice du dernier, il n'y a pas de reste de la liste. On renvoie idx

On se rend compte que les curseur récursifs sans slice sont bien plus simple qu'avec slice, et même plus naturel que la version itérative.

A vous :

Exemple : Un exemple d'utilisation d'un Curseur sans slice.

Les deux versions de ce code ne sont pas du tout équivalentes. La première utilise des slices lors de l'appel avec lst[1:]. Cela induit des coûts importants de temps de calculs et de coût en mémoire, car python va dupliquer la liste.

La seconde version utilise l'indice et ne requiert aucune copie de liste. Elle sera bien plus efficace en temps et en espace.

Exercice

On vous demande de coder une version récursive de la fonction appartient donnée ci-dessous :

Exemple : Recherche de l'appartenance

1ère version : avec slicing:

Exercice de code : Appartenance d'un élément à une liste (récursivement)

2ème version : Sans slicing (paramètre par défaut ou fonction intermédiaire) :

Exercice de code : Appartenance d'un élément à une liste (récursivement)