Programmation Fonctionnelle, TP

Version & licenses
Creative Commons License

TP 3 : une version purement fonctionnelle de Timsort

Guyslain Naves

Timsort est l'algorithme de tri principal de Python et d'Android. Son principal avantage est d'utiliser les suites monotones de la séquence initiale pour alléger la charge de travail. Ainsi, sur une entrée déjà partiellement triée, son temps de calcul pourra être réduit en conséquence.

On peut schématiser l'algorithme en trois phases :

  1. couper la séquence en sous-séquences monotones (en temps $O(n)$),
  2. fusionner les séquences de longueurs similaires ($l_1 \leq l_2 \leq 2 l_1$ par exemple),
  3. puis fusionner les séquences restantes de la plus petite à la plus grande (en temps $O(n)$ aussi).

Ici, l'algorithme de fusion est exactement le même que celui du tri par fusion.

La partie la plus complexe est l'étape intermédiaire : il faut pouvoir repérer très rapidement des séquences de longueurs similaires. Ceci est réalisé en gardant une liste des séquences triée par longueur croissante.

L'algorithme d'origine est impératif : il trie des tableaux en utilisant d'autres tableaux tout en essayant de ne pas avoir une trop grande empreinte en mémoire. Nous allons écrire une version fonctionnelle : pas de tableaux, mais aussi nous n'essayerons pas d'optimiser l'utilisation de la mémoire.

Nous voulons de plus construire une fonction de tri de même type que la fonction de tri de la librairie standard :

  1. val List.fast_sort : ('elt -> 'elt -> int) -> 'elt list -> 'elt list

Les fonctions que nous allons écrire auront donc pour la plupart un argument compare : 'elt -> 'elt -> int, l'opérateur de comparaison.

Découper la liste en sous-séquences croissantes.

Préfixe croissant

Écrire une fonction qui calcule le plus long préfixe croissant d'une liste, retourne ce préfixe et le suffixe complémentaire.

  1. val longuest_increasing_prefix :
  2. ('elt -> 'elt -> int) -> 'elt list -> 'elt list * 'elt list

Préfixe décroissant

En déduire une fonction qui calcule le plus long préfixe décroissant :

  1. val longuest_decreasing_prefix :
  2. ('elt -> 'elt -> int) -> 'elt list -> 'elt list * 'elt list

Découpage monotone

Utiliser ces deux fonctions pour écrire une fonction partitionnant une liste en sous-séquences croissantes (les séquences décroissantes seront renversées). Seule la dernière séquence pourra être de longueur 1 .

  1. val monotone_sequence_partition :
  2. ('elt -> 'elt -> int) -> 'elt list -> 'elt list list

Fusion

Écrire l'algorithme de fusion de deux listes croissantes.

La pile des séquences

Pour repérer les séquences de longueurs similaires, nous utilisons une liste de séquences triées par ordre de longueur croissante. Ainsi, deux séquences de longueurs proches seront successives.

Plus exactement, nous allons avoir une liste de séquences $l_1,l_2,l_3,\ldots,l_k$ telles que :

  1. $|l_1| \leq |l_2| \leq |l_3| \leq \ldots, \leq |l_k|$,
  2. $ |l_i| + |l_{i+1}| < |l_{i+2}|$.

En effet, si $|l_i| + |l_{i+1}| \geq |l_{i+2}|$ c'est que $|l_{i+1}| \leq |l_{i+2}| \leq 2 |l_{i+1}|$, donc $l_{i+1}$ et $l_{i+2}$ sont de longueurs similaires et on peut les fusionner.

Si le deuxième invariant est violé, il suffit de réordonner les listes localement jusqu'à correction des deux invariants.

Insertion d'une séquence croissante

Notre but est donc, étant donné une telle liste, d'insérer une séquence supplémentaire tout en préservant les deux invariants, si nécessaire en fusionnant des listes de longueurs similaires.

Pour cela, on utilise un algorithme récursif. Si la séquence à insérer est plus longue que la première séquence de la liste, on l'insère dans la queue, puis on vérifie le deuxième invariant. Sinon on l'insère en début de liste et on vérifie aussi le deuxième invariant.

Lorsque le deuxième invariant est faux, on fusionne le deuxième et le troisième élément et on insère le résultat dans le reste.

Afin de ne pas recalculer la longueur des séquences chaque fois, la liste doit contenir des couples (séquence, longueur).

Implémenter cette stratégie à l'aide de deux fonctions mutuellement récursives.

  1. (* Les types donnés ici ne tiennent pas compte de la fonction de comparaison *)
  2. let rec check_invariant : ('elt list * int) list -> ('elt list * int) list
  3. = fun stack ->
  4. ...
  5. and insert_in_stack :
  6. ('elt list * int) list -> ('elt list * int) -> ('elt list * int) list
  7. = fun stack sequence ->
  8. ...

Réduction de la liste de séquences

Après insertion de toutes les séquences, il ne reste plus qu'à effectuer des opérations de fusion sur toutes les séquences, en partant des plus courtes, jusqu'à n'en avoir plus qu'une.

Coder une fonction de réduction selon ce principe :

  1. val reduce_stack : ('elt -> 'elt -> int) -> ('elt list * int) list -> 'elt list

Finaliser l'algorithme

Les principales parties de l'algorithme sont maintenant écrites, il ne reste plus qu'à écrire la fonction de tri :

  1. val timsort : ('elt -> 'elt -> int) -> 'elt list -> 'elt list

Pour cela, on découpe la liste en séquences croissantes, qu'on insère une à une dans une liste de séquences gràce à la fonction appropriée, puis on réduit la liste de séquences et on retourne le résultat de la réduction.

À vous de jouer !

Comparer votre fonction de tri avec celle de la librairie standard :

  1. val List.fast_sort : ('elt -> 'elt -> int) -> 'elt list -> 'elt list

Pour cela, vous pouvez utiliser la fonction suivante de module Unix :

  1. #load "unix.cma";; (* pour charger le module *)
  2. let time : float = Unix.gettimeofday ()

Essayer avec des listes bien mélangées, peu mélangées, etc.