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 :
len(L)//2
dans le cas
présent) ;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.
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é.
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)
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.
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.
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.
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 ?
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 :
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):
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é :
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.
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.