5.4.8. Concaténation de deux listes Définissons maintenant

Transcription

5.4.8. Concaténation de deux listes Définissons maintenant
94
Programmation en OCaml
5.4.8. Concaténation de deux listes
Définissons maintenant la fonction concat qui met bout à bout deux listes. Ainsi,
si l1 et l2 sont deux listes quelconques, concat l1 l2 constitue la liste composée
d’abord des éléments de l1 (dans le même ordre) puis des éléments de l2 (dans le
même ordre).
Par exemple, concat [1 ; 2] [3 ; 4] doit calculer la liste [1 ; 2 ; 3 ; 4].
Cette liste se met aussi sous la forme 1: :[2 ; 3 ; 4], on remarque que c’est
exactement le résultat de l’expression 1: :(concat [2] [3 ; 4]), et [2] est le reste
de la liste initiale [1 ; 2]. Ceci nous suggère la solution récursive suivante :
# let rec concat l1 l2 = match l1 with
| [] -> l2
| x::l’1 -> x::(concat l’1 l2);;
val concat : ’a list -> ’a list -> ’a list = <fun>
# concat ["a"; "b"; "c"] ["d"; "ef"];;
- : string list = ["a"; "b"; "c"; "d"; "ef"]
# concat [1; 2; 3] [];;
- : int list = [1; 2; 3]
# let l = [3; 4] in concat l l;;
- : int list = [3; 4; 3; 4]
Cette fonction est prédéfinie en OCaml : (concat l1 l2) s’écrit l1 @ l2 en
utilisant la primitive de OCaml . Elle existe également dans le module List de la
bibliothèque standard, sous le nom append.
5.5. Manipulation de listes triées
Dans cette partie, nous isolons deux fonctions qui travaillent, non pas avec des
listes quelconques, mais avec des listes triées. Une liste est dite triée si ses éléments
apparaissent dans la liste dans l’ordre croissant (ou décroissant) selon une certaine
relation d’ordre. De telles listes sont fréquentes : par exemple un dictionnaire, le
fichier des élèves d’une promotion etc. Nous développons deux opérations, celle de
la recherche d’un élément dans une liste triée et celle de l’insertion d’un élément
dans une liste triée. La première opération peut bien sûr être réalisée au moyen de
la fonction appartient précédente, qui traite de n’importe quelle liste, triée ou non.
Mais on peut proposer un algorithme plus astucieux et plus efficace en moyenne, qui
exploite la caractéristique liste triée. De même, la fonction d’insertion reconstruit une
liste triée en plaçant le nouvel élément à sa place dans la liste.
Listes
95
5.5.1. Recherche d’un élément dans une liste triée
Lorsque l’on recherche un élément dans une liste triée, par exemple un mot dans
un dictionnaire, il est alors inutile de poursuivre, non seulement quand on a trouvé
l’élément recherché, mais aussi quand on a dépassé son emplacement possible, c’està-dire quand on a trouvé un élément strictement plus grand. En effet, la liste étant
triée dans l’ordre croissant, tous les éléments qui suivent sont, eux aussi, plus grands,
donc aucune chance de trouver l’élément recherché parmi ceux-ci. Par exemple, on
arrête de chercher le mot googler, issu du jargon Internet, dans le Petit Larousse 2003,
quand on a atteint le mot gopak1. Ainsi, on écrit la fonction app triée qui recherche
un élément dans une liste déjà triée en ordre croissant :
# let rec app_triée e l = match l with
| [] -> false
| x::l’ -> e=x || (e>x && (app_triée e l’));;
val app_triée : ’a -> ’a list -> bool = <fun>
# app_triée 0 [1; 2; 3];;
- : bool = false
# app_triée 2 [1; 2; 3];;
- : bool = true
# app_triée "googler"
["gonze"; "gopak"; "gopura"];;
- : bool = false
Si la liste n’est pas ordonnée selon l’ordre croissant, alors app triée peut rendre
un résultat incorrect comme le montre l’exemple suivant :
# app_triée 2 [3; 2; 1];;
- : bool = false
5.5.2. Insertion dans une liste triée
Écrivons à présent la fonction insérer qui insère un élément e « à sa place » dans
une liste triée dans l’ordre croissant. L’expression « à sa place » signifie que la liste
résultat est également triée dans l’ordre croissant. Par exemple on insère toto entre titi
et tutu dans une liste de mots ordonnée selon l’ordre alphabétique.
L’algorithme pour l’insertion s’appuie sur un raisonnement similaire à celui mis en
œuvre pour la recherche dans une liste triée. On s’intéresse ici à une liste, l, triée dans
1. gopak : danse folklorique ukrainienne,
gopura : pavillon pyramidal des temples hindouistes, dans le sud de l’Inde, Le Petit Larousse
2003.
96
Programmation en OCaml
l’ordre croissant. Si la liste est vide, l’insertion se fait sans difficulté : le résultat est
la liste singleton contenant le nouvel élément, cette liste résultat est bien évidemment
triée. Si la liste est non vide, si son élément de tête est plus grand que l’élément à
insérer, e, on peut sans conteste placer ce dernier en tête de l, la liste e: :l, résultat
de l’insertion, est bien triée dans l’ordre croissant puisque l est triée dans l’ordre
croissant et que e est plus petit que la tête de l (donc plus petit que tous les éléments
de l). Si maintenant, l’élément à insérer est plus grand ou égal à la tête de l, on
insérera e dans le reste de la liste initiale, qui est, lui-même, une liste triée croissante.
Supposons maintenant que l’appel récursif insérer e l’ a produit une liste triée
croissante. Ses éléments sont tous plus grands ou égaux à la tête de l, nommée x dans
le texte OCaml ci-dessous. Par conséquent, la liste résultat x: :(insérer e l’) est
triée dans l’ordre croissant.
Le raisonnement précédent établit que le résultat de l’insertion est une liste triée
croissante si l’argument est une liste triée croissante, il s’appuie sur un principe de
récurrence concernant les listes. Ce principe logique constitue l’outil logique de base
dès qu’il s’agit de démontrer, par exemple, qu’un programme manipulant des listes
satisfait sa spécification. Le lecteur pourra consulter un ouvrage de logique, [LAL 90]
par exemple, pour approfondir ce point.
La fonction insérer s’écrit de la façon suivante :
# let rec insérer e l = match l with
| [] -> [e]
| x::l’ ->
if e < x then e::l else x::(insérer e l’);;
val insérer : ’a -> ’a list -> ’a list = <fun>
Appliquons cette fonction sur une liste d’entiers, puis sur une liste de chaînes de
caractères :
# insérer 3 [1; 2; 4];;
- : int list = [1; 2; 3; 4]
"googler"
["gonze"; "gopak"; "gopura"];;
- : string list = ["gonze"; "googler"; "gopak"; "gopura"]
# insérer
# insérer "dupont"
["dupont"; "milou"; "tintin"; "tournesol"];;
- : string list =
["dupont"; "dupont"; "milou"; "tintin"; "tournesol"]
Et si l’élément est déjà présent, peu importe ! Une occurrence supplémentaire est
ajoutée à la liste.
Encore une fois, si la liste fournie à la fonction insérer n’est pas triée dans l’ordre
croissant, le résultat sera incorrect, comme dans l’exemple suivant :
# insérer 3 [4; 1; 2];;
- : int list = [3; 4; 1; 2]
Listes
97
5.6. Algorithmes de tri
Les listes qui sont fournies à un programme ne sont généralement pas triées. Or,
traiter des listes triées accélère notablement la plupart des algorithmes sur les listes. Il
est donc important de disposer d’algorithmes de transformation d’une liste quelconque
en une liste triée, que l’on appelle un algorithme de tri. Il existe plusieurs méthodes
de tri. Nous en présentons trois ci-dessous.
5.6.1. Tri par insertion
L’idée générale de cette méthode de tri est d’isoler un élément (la tête de la liste
par exemple), de trier les autres éléments puis d’insérer à sa place l’élément isolé.
On peut donc utiliser la fonction précédente insérer, pour trier une liste dans
l’ordre croissant : on prend le premier élément de la liste et on l’insère dans le reste
de la liste que l’on a trié par un appel récursif préalable. Le cas de base est le cas de la
liste vide qui est triée d’emblée. La fonction de tri par insertion s’écrit donc ainsi :
# let rec tri_insertion l = match l with
| [] -> []
| x::l’ -> insérer x (tri_insertion l’);;
val tri_insertion : ’a list -> ’a list = <fun>
# tri_insertion [1; 6; 1];;
- : int list = [1; 1; 6]
# tri_insertion ["milou"; "dupont"; "tintin";
"milou"; "tournesol"; "dupond";];;
- : string list =
["dupond"; "dupont"; "milou"; "milou"; "tintin";
"tournesol"]
Suivons le calcul de tri insertion [1 ; 6 ; 1], seuls les appels à la fonction
récursive tri insertion sont tracés :
tri_insertion [1; 6; 1] =
insérer 1 (tri_insertion [6; 1]) =
insérer 1 (insérer 6 (tri_insertion [1])) =
insérer 1 (insérer 6 (insérer 1 (tri_insertion []))) =
[])) =
insérer 1 (insérer 6 (insérer 1
[1])) =
insérer 1 (insérer 6
insérer 1
[1; 6] =
[1; 1; 6]
98
Programmation en OCaml
5.6.2. Tri par sélection
Cette méthode consiste à chercher le plus petit élément de la liste, qui se trouvera
donc en tête de la liste triée résultat, et de recommencer sur la liste d’où l’on a retiré
cet élément.
Nous avons donc besoin d’une fonction supprimer qui supprime la première
occurrence d’un élément donné dans une liste. Nous utiliserons également la fonction
min liste, écrite précédemment dans la section 5.4.4, pour déterminer le plus petit
élément d’une liste.
# let rec supprimer e l = match l with
| [] -> []
| x::l’ -> if x=e then l’ else x::(supprimer e l’);;
val supprimer : ’a -> ’a list -> ’a list = <fun>
# supprimer 4 [1;4;0;4;8];;
- : int list = [1; 0; 4; 8]
On peut alors écrire la fonction de tri tri sélection :
# let rec tri_sélection l = match l with
| [] -> []
| _ -> let m = min_liste l in
let l’ = supprimer m l in m::(tri_sélection l’);;
val tri_sélection : ’a list -> ’a list = <fun>
# tri_sélection [1; 3; 6; 1];;
- : int list = [1; 1; 3; 6]
# tri_sélection ["milou"; "dupont"; "tintin";
"milou"; "tournesol"; "dupond";];;
- : string list =
["dupond"; "dupont"; "milou"; "milou"; "tintin";
"tournesol"]
Remarquons que le cas général consiste en un appel récursif sur la liste initiale
privée d’un de ces éléments, l’ dans le texte de la fonction (mais ce n’est pas son
reste comme dans la plupart des fonctions récursives programmées dans ce chapitre).
Par conséquent, la taille de la liste l’ est plus petite que celle de la liste initiale,
on se rapproche alors du cas de base. Ceci assure la terminaison de la fonction
tri sélection.
5.6.3. Tri par fusion
L’idée de cette méthode est de couper la liste en deux parties à peu près égales, puis
de trier ces deux sous-listes séparément et enfin de fusionner ces deux listes triées,
Listes
99
c’est-à-dire de regrouper les éléments de ces deux listes triées en une seule liste triée.
Par exemple, les listes triées [3 ; 5 ; 6 ; 7 ; 8 ; 9] et [1 ; 2 ; 4 ; 7 ; 10 ; 11]
se fusionnent en la liste [1 ; 2 ; 3 ; 4 ; 5 ; 6 ; 7 ; 7 ; 8 ; 9 ; 10 ; 11].
La réalisation de cette méthode de tri nécessite donc l’écriture de trois fonctions :
diviser liste qui coupe une liste en deux, fusion qui fusionne deux listes triées,
et enfin tri fusion qui trie une liste dans l’ordre croissant.
L’objectif de la fonction diviser liste est de fournir, à partir d’une liste, deux
sous-listes dont la taille est la moitié de la taille de la liste initiale. La façon dont
les éléments de la liste initiale sont répartis dans les sous-listes importe peu. Nous
choisissons de séparer les éléments de position paire de ceux de position impaire. La
fonction va donc calculer un couple de deux sous-listes (l1, l2) où l1 contient les
éléments placés dans la liste initiale à une place impaire tandis que l2 contient les
éléments placés dans la liste initiale à une place paire. La fonction fait apparaître trois
cas. La liste initiale est vide : coupée en deux, elle donne deux listes vides. La liste
initiale ne contient qu’un seul élément : elle se coupe en elle-même et la liste vide. La
liste a au moins deux éléments x1 en tête (place impaire) et x2 en deuxième position
(place paire), le résultat s’obtient en coupant en deux selon la même méthode la liste
des autres éléments de la liste (diviser liste l’) et en plaçant x1 et x2 en tête de
chacune des deux sous-listes obtenues par l’appel récursif.
On écrit donc :
# let rec diviser_liste l = match l with
| [] -> ([], [])
| [x] -> ([x], [])
| x1::x2::l’ ->
let (l1, l2) = diviser_liste l’ in (x1::l1, x2::l2);;
val diviser_liste : ’a list -> ’a list * ’a list = <fun>
Écrivons à présent la fonction de fusion de deux listes triées :
# let rec fusion l1 l2 = match (l1, l2) with
| ([], []) -> []
| ([], _) -> l2
| (_, []) -> l1
| (x1::l’1, x2::l’2) ->
if x1 < x2
then x1::(fusion l’1 (x2::l’2))
else x2::(fusion (x1::l’1) l’2);;
val fusion : ’a list -> ’a list -> ’a list = <fun>
Le cas général de la fonction fusion consiste à interclasser les éléments des deux
listes déjà triées dans l’ordre croissant, les uns par rapport aux autres. En effet, si x1
(resp. x2) est le plus petit élément de l1 (resp. l2) et si x1 est plus petit que x2, alors
100
Programmation en OCaml
x1 sera plus petit que tous les éléments réunis de l1 et l2, on peut donc sans conteste
placer x1 en tête de la liste résultat de la fusion. Il reste ensuite à placer les autres
éléments de l1 (la sous-liste l’1) par rapport aux éléments de l2. Un raisonnement
symétrique s’applique dans le cas où x1 > x2.
L’étude de la terminaison de la fonction fusion se fait en considérant, non pas
l’un des deux arguments, mais le couple formé par les deux arguments. En effet, à
chaque appel récursif, l’une des deux listes perd son premier élément. Au bout d’un
nombre fini d’appels récursifs, on peut assurer que l’un des deux arguments sera la
liste vide, ce qui établit la terminaison de la fonction.
La fonction tri fusion est la traduction directe de la spécification informelle
énoncée en début de paragraphe couper la liste en deux, trier les deux sous-listes puis
fusionner.
# let rec tri_fusion l = match l with
| [] -> []
| [x] -> [x]
| _ -> let (l1, l2) = diviser_liste l in
fusion (tri_fusion l1) (tri_fusion l2);;
val tri_fusion : ’a list -> ’a list = <fun>
# tri_fusion [1; 3; 6; 1];;
- : int list = [1; 1; 3; 6]
# tri_fusion ["milou"; "dupont"; "tintin";
"milou"; "tournesol"; "dupond";];;
- : string list =
["dupond"; "dupont"; "milou"; "milou"; "tintin";
"tournesol"]
Le schéma de récursivité de la fonction tri fusion diffère de celui de la plupart
des fonctions récursives programmées dans ce chapitre. Le cas général consiste en
un appel récursif où les deux listes, passées en argument, sont des « moitiés » de la
liste initiale, différentes de celle-ci (le cas général est une liste comprenant au moins
deux éléments). Par conséquent, la taille de chacune de ces deux « moitiés » est plus
petite que celle de la liste initiale, on se rapproche alors des cas de base. Nous venons
ainsi de montrer la terminaison de la fonction tri fusion, certes d’une manière très
intuitive.
Listes
250
101
Comparaison des tris sur les listes
Sélection
Insertion
Fusion
Temps en secondes
200
150
100
50
0
0
4
4
1. 10
4
2. 10
4
3. 10
4. 10
Nombre d’éléments
Figure 5.3. Comparaison des tris sur les listes
5.6.4. Comparaison de ces différentes méthodes de tri
Voici un test effectué pour ces trois méthodes de tri sur une liste2 de 1 000, puis 2
000, puis 4 000, puis 8 000 et ainsi de suite jusqu’à 256 000 entiers.
Méthode
Temps (en secondes) pour une liste de longueur (en milliers)
1
2
4
Insertion 0.029
0.11
0.44
Sélection 0.027
0.15
0.47
Fusion
0.0009 0.0029 0.0066
8
1.5
2.3
0.017
16
8.6
12.1
0.05
32
56
81
0.15
64
280
282
0.20
128
1405
1397
0.48
256
7342
8462
1.1
La figure 5.3 correspond à un relevé précis pour des listes de 1 000 à 40 000 entiers.
2. Les éléments de la liste sont tirés au hasard par la fonction int du module Random et on itère
un nombre suffisant de fois l’algorithme de tri pour mesurer un temps raisonnablement fiable,
c’est-à-dire au moins de l’ordre de quelques secondes, que l’on divise ensuite par le nombre
d’itérations bien entendu
102
Programmation en OCaml
Tri par fusion sur les listes
2
Temps en secondes
1.5
1
0.5
0
0
5
1. 10
5
2. 10
5
3. 10
Nombre d’éléments
Figure 5.4. Le tri fusion
Les courbes3 des tris par sélection et insertion s’envolent au-dessus de la courbe
du tri par fusion qui est collée à l’axe des abscisses. Le tri fusion4 est beaucoup plus
rapide que les deux autres. En outre, plus la liste est longue, plus l’écart est important.
Par exemple, pour 10 000 éléments le rapport des temps entre le tri par insertion et le
tri par fusion est d’environ 165, alors que pour 40 000 éléments il est d’environ 950.
En revanche, le rapport des temps entre le tri par insertion et le tri par sélection est à
peu près constant, entre 1 et 2.
Le tri par fusion rejoint la catégorie des algorithmes dichotomiques de résolution
puisqu’il résout le problème en le décomposant en deux sous-problèmes similaires de
taille moitié (comme l’algorithme de calcul de la fonction puissance). Cela s’avère
une fois de plus une méthode efficace.
La figure 5.4 présente plus en détail le comportement du tri par fusion. Les
irrégularités y sont peu importantes et peu fréquentes. La scission de la liste prend
le même temps quelle que soit la liste, mais le temps nécessité par la fusion de deux
3. Obtenues avec l’outil xgraphic
4. Le tri sort du module List est un tri fusion 2 à 2,5 fois plus rapide que celui que nous
proposons ici, mais il est écrit de façon beaucoup plus astucieuse. Pour en savoir plus, on pourra
lire le code source du module List dans la distribution de OCaml
Listes
103
listes peut varier selon que tous les éléments d’une liste sont plus grands que ceux de
l’autre ou bien si la fusion entremêle successivement un élément de chacune des deux
listes comme les doigts des deux mains.
Par exemple, trions trois listes de 400 000 éléments composées :
1) des entiers de 1 à 400 000 rangés dans cet ordre ;
2) des entiers de 400 000 à 1 rangés dans cet ordre ;
3) des entiers rangés ainsi [400000 ; 200000 ; 399999 ; 199999 ; ... ;
200001 ; 1].
Le tri de la première liste nécessite 3.21 secondes, la deuxième 3.11 secondes et
la troisième 2.79 secondes. La différence de temps entre les deux premières listes est
négligeable, ce sont les listes pour lesquelles la fusion consiste à entremêler les souslistes. En revanche la troisième liste nécessite significativement moins de temps pour
le tri car la fusion consiste à faire passer tous les éléments de la première liste devant
ceux de la seconde donc il n’y a pas analyse et reconstruction de la seconde liste.
Le tri par insertion présente des sauts plus importants et cela tient au comportement
de la fonction insérer sur la liste. Selon que la liste est déjà triée ou bien triée dans
l’ordre inverse, on va tout de suite passer dans le cas de base de la fonction récursive
ou bien au contraire parcourir toute la liste pour aller ajouter l’élément en fin de liste.
Si la liste aléatoire du test présente des zones importantes triées dans un sens ou dans
l’autre, on obtient alors un changement significatif de la courbe. Le tableau ci-dessous
montre l’impact de l’ordre de la liste initiale sur le temps de tri par insertion :
Nombre d’éléments
10 000
Liste triée
0.001
Liste en épi
1.77
Liste aléatoire moyennée
2.69
Liste triée en ordre inverse 3.53
20 000
0.0032
12.15
17.67
24.29
40 000
0.011
88.3
120.5
175.4
La liste en épi est de la forme [1 ; n ; 2 ; n-1 ; ... ; n/2 ; n/2+1]. Le test
de la liste aléatoire moyennée consiste à tirer 10 listes aléatoires, à les trier et à diviser
le temps obtenu par 10.
En revanche le tri par sélection est insensible à la forme de la liste.
Pour une analyse plus précise de ces phénomènes, il est nécessaire de se reporter à
l’analyse de complexité d’un cours d’algorithmique, mais nous avons pu montrer ici
qu’une expérimentation soigneuse permet de toucher du doigt bien des phénomènes.