Récursivité

I/ Diviser pour régner

1) Tri par fusion

(Von Neumann, Goldstine 1947)

Le tri par fusion est un des algorithmes de tri les plus rapides connus. Il est particulièrement rapide si la taille de la liste à trier est une puissance de 2. Il se fait par ces deux étapes :

2) Fusion

Pour fusionner deux listes M et N (supposées déjà triées), on construit une liste L en plaçant dans l'ordre les éléments extraits de M et N :

def fusion(M: list,N: list) -> list:
    assert M==sorted(M) and N==sorted(N)
    L = []
    while len(M) and len(N):
        print(L,M,N)
        if M[0]<N[0]:
            L.append(M.pop(0))
        else:
            L.append(N.pop(0))
    if len(M)==0:
        L += N
    else:
        L += M
    return L

Le nombre de comparaisons, enfilements et défilements est la longueur (supposée commune) des deux listes à fusionner. La longueur de ces listes (au cours de la fusion) est un variant et le fait que L est et reste triée au cours de la fusion, est un invariant : la fusion est correcte, se termine et est de coût linéaire.

3) Tri

L'algorithme de tri par fusion se code alors ainsi :

def tri1(L: list) -> list:
    M = [L[i] for i in range(len(L)//2)]
    N = [L[i] for i in range(len(L)//2,len(L))]
    M.sort()
    N.sort()
    return fusion(M,N)

Mais l'efficacité de ce tri vient de l'utilisation de la récursivité.

4) Version récursive

def tri2(L: list) -> list:
    if len(L)<=1:
        return L
    else:
        M = [L[i] for i in range(len(L)//2)]
        N = [L[i] for i in range(len(L)//2,len(L))]
        M = tri2(M)
        N = tri2(N)
        return fusion(M,N)

5) Propriétés

a) Terminaison

En prenant comme variant la longueur des moitiés de liste, on prouve la terminaison de l'algorithme : cette longueur est divisée par 2 à chaque appel récursif.

b) Correction

L'invariant « la liste fusionnée est triée » permet de prouver que le tri par fusion est correct. C'est un invariant parce que

Par contagion, le tri par fusion est bien un tri.

c) Complexité

Pour une liste de longueur 16, on effectue

2+4+8+16=30 est plus petit que le double de 16 : le nombre d'appels de fonction est linéaire par rapport à la taille de la liste à trier.

Le nombre de comparaisons est donc 8×4=32. C'est la moitié de 16×4=16×log2(16) : le coût de l'algorithme de tri par fusion est n×log(n).

Un théorème dit qu'il n'existe aucun algorithme de tri qui soit plus rapide (de coût inférieur) que le tri par fusion.

II/ Programmation dynamique

1) Rendu de monnaie

Influencés par une civilisation extraterrestre, les Atlantes n'effectuaient leurs achats qu'avec 3 sortes de pièces d'or :

Comment faire l'appoint sur un certain nombre de drachmes atlantes, en utilisant le moins de pièces d'or possibles ?

2) Algorithme glouton

On rappelle ce qui a été vu en 1ère à ce sujet : l'algorithme glouton consiste, en Atlantide, à donner

Le nombre de pièces de chaque sorte est un attribut de la tirelire de chaque Atlante :

class Tirelire():
    def __init__(self,un=0,trois=0,quatre=0):
        self.un = un
        self.trois = trois
        self.quatre = quatre
    def __repr__(self):
        aff = '('+str(self.un)+'① , '+str(self.trois)+'③ ,'+str(self.quatre)+'④ )'
        return aff

L'algorithme glouton peut être récursif :

def rendu(centimes):
    if centimes>=4:
        bourse = rendu(centimes-4)
        bourse.quatre += 1
        return bourse
    elif centimes>=3:
        bourse = rendu(centimes-3)
        bourse.trois += 1
        return bourse
    elif centimes>=1:
        bourse = rendu(centimes-1)
        bourse.un += 1
        return bourse
    else:
        return Tirelire()

Pour vérifier l'effet on se propose d'enrichir l'affichage de la tirelire, afin de vérifier la somme de monnaie et aussi de voir le nombre total de pièces :

class Tirelire():
    def __init__(self,un=0,trois=0,quatre=0):
        self.un = un
        self.trois = trois
        self.quatre = quatre
    def pièces(self):
        return self.un+self.trois+self.quatre
    def total(self):
        return self.un+3*self.trois+4*self.quatre
    def __repr__(self):
        aff = '('+str(self.un)+'① , '+str(self.trois)+'③ ,'+str(self.quatre)+'④ )'
        aff += ' total '+str(self.total())+' cents en '+str(self.pièces())+' pièces'
        return aff

En faisant

for n in range(6,15):
    print(rendu(n))

On constate que l'algorithme fait l'appoint :

(2① , 0③ ,1④ ) total 6 cents en 3 pièces
(0① , 1③ ,1④ ) total 7 cents en 2 pièces
(0① , 0③ ,2④ ) total 8 cents en 2 pièces
(1① , 0③ ,2④ ) total 9 cents en 3 pièces
(2① , 0③ ,2④ ) total 10 cents en 4 pièces
(0① , 1③ ,2④ ) total 11 cents en 3 pièces
(0① , 0③ ,3④ ) total 12 cents en 3 pièces
(1① , 0③ ,3④ ) total 13 cents en 4 pièces
(2① , 0③ ,3④ ) total 14 cents en 5 pièces

Mais il ne le fait pas de façon optimale :

3) Algorithme optimal

Dans cette variante, on cherche à ajouter le moins de pièces possible. Pour cela on regarde d'abord les cas les plus simples :

def rendu(cents):
    if cents<3:
        return Tirelire(cents,0,0)
    elif cents==3:
        return Tirelire(0,1,0)
    else:
        t1 = rendu(cents-1)
        t3 = rendu(cents-3)
        t4 = rendu(cents-4)
        if t4.pièces()<t3.pièces():
            t4.quatre += 1
            return t4
        elif t3.pièces()<t1.pièces():
            t3.trois += 1
            return t3
        else:
            t1.un += 1
            return t1

On obtient bien un résultat optimal :

(0① , 2③ ,0④ ) total 6 cents en 2 pièces
(0① , 1③ ,1④ ) total 7 cents en 2 pièces
(0① , 0③ ,2④ ) total 8 cents en 2 pièces
(1① , 0③ ,2④ ) total 9 cents en 3 pièces
(0① , 2③ ,1④ ) total 10 cents en 3 pièces
(0① , 1③ ,2④ ) total 11 cents en 3 pièces
(0① , 0③ ,3④ ) total 12 cents en 3 pièces
(1① , 0③ ,3④ ) total 13 cents en 4 pièces
(0① , 2③ ,2④ ) total 14 cents en 4 pièces

Cet algorithme résout le problème, mais il prend beaucoup de temps pour cela. Pour le voir, on dessine l'arbre des appels de fonction (pour l'argument 8):

%3 0 8 1 4 0--1 8 5 0--8 20 7 0--20 2 0 1--2 4 1 1--4 6 3 1--6 9 1 8--9 11 2 8--11 13 4 8--13 14 0 13--14 16 1 13--16 18 3 13--18 21 3 20--21 23 4 20--23 30 6 20--30 24 0 23--24 26 1 23--26 28 3 23--28 31 2 30--31 33 3 30--33 35 5 30--35 36 1 35--36 38 2 35--38 40 4 35--40 41 0 40--41 43 1 40--43 45 3 40--45

Les feuilles de l'arbre sont les appels de fonction non récursifs. On compte que la fonction rendu est appelée

Cela fait 28 appels avec seulement 9 valeurs différentes de l'argument passé à la fonction. La programmation dynamique (Richard Bellman, 1954) consiste à minimiser le nombre d'appels de fonction, en remplaçant l'arbre par un graphe orienté :

%3 8 8 4 4 8->4 5 5 8->5 7 7 8->7 0 0 4->0 1 1 4->1 3 3 4->3 5->4 5->1 2 2 5->2 7->4 7->3 6 6 7->6 6->3 6->5 6->2

4) Mémoïsation

Pour ce faire, on remplace les appels à la fonction (sauf le premier) par une lecture en mémoire.

def rendu(cents):
    vu = []
    for k in range(cents+1):
        if k<3:
            vu += [(k,0,0)]
        elif k<4:
            vu += [(0,1,0)]
        else:
            t1 = vu[k-1]
            t3 = vu[k-3]
            t4 = vu[k-4]
            if sum(t4)<sum(t3):
                vu.append((t4[0],t4[1],t4[2]+1))
            elif sum(t3)<sum(t1):
                vu.append((t3[0],t3[1]+1,t3[2]))
            else:
                vu.append((t1[0]+1,t1[1],t1[2]))
    return Tirelire(*vu[-1])

Cet algorithme (qui n'est pas récursif) est de coût linéaire à la fois en temps et en place mémoire, alors que l'autre version est de coût logarithmique en place mémoire mais exponentiel en temps.

5) Programmation dynamique

La programmation dynamique permet de créer des algorithmes efficaces pour résoudre un problème tel que :

Elle consiste à résoudre (et stocker) les sous-problèmes puis aller vers le problème général, soit récursivement, soit itérativement.