Article publié dans Linux Magazine 48, mars 2003.
Copyright © 2003 - Jérôme Quelin.
Les tris sont utilisés dans de nombreux types de programmes et sur toutes sortes de données. C'est une opération si banale et en même temps si complexe à mettre en œuvre de manière efficace, que les algorithmes de tris et la création d'implémentations optimales constituent une branche importante de l'informatique.
Cet article n'est pas destiné à comparer le tri à bulles avec le quicksort de Hoare-Knuth. Son but est de vous montrer comment tirer parti au mieux du tri standard de Perl. Nous verrons donc comment trier en Perl, ainsi que quelques idiotismes typiquement perliens.
Perl dispose en interne d'une fonction de tri, la bien nommée
sort
. Appelée sur une liste, elle va renvoyer une liste triée :
@lettres = qw( a z e r t y ); @out = sort @lettres; # @out vaut maintenant (a,e,r,t,y,z)
L'ordre de tri utilisé est l'ordre lexicographique ascendant (en d'autres termes, l'ordre des caractères dans la table ASCII).
La fonction sort
de Perl est très rapide, car elle utilise un
algorithme de tri évolué et est écrite complètement en C.
Par contre, la fonction sort
ne sait manipuler que des listes tenant
entièrement en mémoire (vive ou swap). Cela exclut donc les tris sur
d'énormes fichiers nécessitant des fichiers temporaires. De plus, la
liste n'est pas triée sur place, comme dans d'autres langages : la
fonction sort
produit une nouvelle liste.
Note : Si vous avez réellement besoin de trier des fichiers
énormes, vous pouvez utiliser le module File::Sort
de Chris Nandor,
qui utilise des fichiers temporaires et se contente d'une quantité
raisonnable de mémoire.
Tant que l'on veut trier selon l'ordre ASCIIbétique, la fonction sort
convient à nos besoins. Mais si on veut trier selon l'ordre numérique
(car (1,2,10) se trie en (1,10,2) suivant l'ordre lexicographique), ou
même selon un ordre totalement arbitraire (comme le nombre de « e »
dans un mot par exemple), alors il faut utiliser une fonction de tri
personnalisée.
Heureusement, Perl propose un moyen standard de réutiliser la fonction
sort
(ce qui est une forme de Paresse, l'une des trois vertus du
programmeur), qui est déjà optimisée. Ce moyen consiste à passer à
sort
le nom d'une fonction de comparaison. Laquelle fonction reçoit
deux arguments et doit renvoyer un nombre positif, négatif ou nul
selon que le premier argument est supérieur, inférieur ou égal au
second. Pour des raisons d'efficacité, les arguments ne sont pas
passés comme dans une fonction normale, mais comme des alias sur les
arguments à comparer, nommés respectivement $a
et $b
. Votre
fonction personnalisée doit donc comparer $a
et $b
, et renvoyer
-1, 0 ou 1 selon que $a
est inférieur, égal ou supérieur à
$b
. C'est le cas des opérateurs prédéfinis <=>
et cmp
.
Voici par exemple comment trier numériquement une liste :
sub par_num { return $a <=> $b } @out = sort par_num @in;
A noter que le nom de la fonction n'a pas besoin d'être mis entre
guillemets, même si use strict;
est actif. De même, les alias $a
et
$b
sont des variables globales, et n'ont pas besoin d'être déclarés.
Toutefois, depuis la version 5.6.0, vous avez le choix entre
l'ancienne méthode avec $a
et $b
et la nouvelle méthode,
utilisant une routine avec prototype et recevant les paramètres dans
@_
. Cette méthode est toutefois moins efficace que celle utilisant
les variables globales.
sub par_num ($$) { my ($gauche, $droit) = @_; return $gauche <=> $droit; } @out = sort par_num @in;
Note : Les prototypes en Perl permettent de spécifier qu'une
fonction attend des paramètres d'un type précis, et de l'appeler
ensuite comme un mot-clef de Perl. Syntaxiquement, le prototype est
spécifié par la parenthèse suivant le nom de la fonction, et avant sa
définition. Ici le prototype de la fonction de comparaison indique
qu'on attend deux scalaires. Reportez-vous à perldoc perlsub
pour
plus de détails.
La fonction sort
accepte aussi directement un bloc anonyme à la place
du nom de la fonction, qui jouera le rôle de la fonction de
comparaison :
@out = sort { $b <=> $a } @in; # ici, on trie en ordre numérique inversé # (remarquez l'ordre de $a et $b)
Le bloc anonyme étant totalement arbitraire, nous pouvons donc réaliser n'importe quel tri très facilement grâce à ce modèle. Voici par exemple un tri sur la date de modification des fichiers :
@out = sort { -M $a <=> -M $b } @fichiers;
Notez cependant qu'utiliser un bloc anonyme ou une fonction de comparaison
ralentit la fonction
sort
. En effet, la fonction sort
seule n'exécute que du code C, alors
que la fonction sort
accompagnée d'un bloc de comparaison oblige à
appeler du code Perl, moins rapide. Il faut donc privilégier les
fonctions internes de Perl pour une efficacité accrue.
@out = sort @in; # tri lexicographique @out = sort { $a cmp $b } @in; # même chose, explicite
Bien que ces deux instructions fassent la même chose, la version
explicite prend environ le double de temps que la version par
défaut. De même, si on veut trier selon un ordre lexicographique
descendant, il vaut mieux utiliser la combinaison sort
+
reverse
(la fonction reverse
ne faisant que déplacer des
pointeurs, elle est très rapide).
@out = reverse sort @in; # tri lexicographique descendant @out = sort { $b <=> $a } @in; # même chose, explicite
Ici encore, du fait du très faible surcoût de la fonction reverse, le
temps de traitement de la version reverse
est quasiment la moitié de
celui de la version explicite.
Comme la liste à trier peut contenir n'importe quelles données, y compris des références (cf article 5 de l'introduction à Perl - numéro 45), rien ne nous empêche d'interpréter les valeurs comme nous le voulons :
@out = sort { $a->[0] <=> $b->[0] } @in;
Ici, nous trions une liste de listes sur le premier champ de chacune de ces listes.
Si la liste à trier contenait des objets de type Employe
disposant
d'une méthode salaire
par exemple, nous aurions pu trier la liste
des employés par leur salaire de cette façon :
@out = sort { $b->salaire <=> $a->salaire } @employes; # on affiche les gros salaires d'abord
Voici enfin comment trier des lignes de fichiers passwd selon leur uid :
@out = sort { (split ':', $a, 4)[2] <=> (split ':', $b, 4)[2] } @pw_lines;
Bref, seule votre imagination limite ce que vous pouvez trier, comme pour beaucoup d'autres choses en Perl...
Ce modèle peut d'ailleurs être très facilement étendu si vous voulez
trier sur différents paramètres. Pour cela, nous allons utiliser
l'opérateur ||
, qui a la particularité intéressante de renvoyer la
première valeur vraie parmi ses opérandes (contrairement en C où les
valeurs sont booléanisées) et de court-circuiter les autres.
Ainsi, l'expression :
$foo || $bar
va renvoyer $foo
si $foo
est vrai, sinon $bar
. Et nous pouvons
facilement enchaîner :
$foo || $bar || $baz
qui renverra la première valeur vraie (c'est-à-dire non nulle)
entre $foo
, $bar
et $baz
.
Revenons à nos moutons. Si nous reprenons l'exemple consistant à trier les mots selon le nombre de « e », puis sur l'ordre alphabétique, nous allons utiliser la formule suivante :
@out = sort { $a =~ y/e// <=> $b =~ y/e// || $a cmp $b } @mots;
(la fonction y
, ou tr
, utilisée sans complément, compte le
nombre d'occurrences des caractères précisés).
Bon, je pense que vous avez compris, et nous pouvons donc passer à des sujets un peu plus retors.
Tout cela est bel et bon, mais les lecteurs avertis auront tout de
suite remarqué que nous recalculons lors de chaque test la clef de
tri, c'est à dire la valeur associée à la clef et sur laquelle le tri
va réellement se faire. Par exemple, dans le cas où nous trions les
noms de fichiers selon leur date de dernière modification, nous
accédons au système de fichiers chaque fois que nous voulons
comparer deux valeurs entre elles. Et comme chaque valeur est
typiquement comparée plusieurs fois avec d'autres valeurs, nous allons
chercher plusieurs la fois la même information dans le système de
fichiers. De même, l'exemple triant les lignes de fichier passwd
selon leur uid doit refaire deux split
à chaque comparaison, ce qui
est extrêmement inefficace et antiperformant.
Pour remédier à cela, nous allons donc passer par une phase de prétraitement des données, consistant à mettre dans un cache les clefs de tri. La fonction de comparaison ne cherchera plus les informations directement, mais utilisera le cache.
Une clef de tri étant associée à une valeur particulière, nous pensons immédiatement à un tableau associatif (ou hash).
Donc, pour reprendre notre exemple de date de modification :
$cache{$_} = -M for @fichiers; @out = sort { $cache{$a} <=> $cache{$b} } @fichiers;
(la première ligne crée une table de hachage dont les clefs sont les
noms de fichiers contenus dans la variable tableau @fichiers
et
dont la valeur associée est la date de modification du fichier)
Nous avons donc remplacé de longs calculs compliqués dans la fonction de comparaison par une rapide consultation de hash.
Nous pouvons même optimiser notre mise en cache, en pré-allouant notre hash et en utilisant une tranche de hash :
keys my %cache = @fichiers; @cache{@fichiers} = map { -M } @fichiers;
Ici, la première ligne crée directement une table de hachage dont les
clefs sont les noms de fichiers contenus dans la variable @fichiers
(l'opérateur keys
est utilisé ici en 'lvalue', c'est-à-dire à gauche
du signe d'affectation). La seconde ligne affecte les valeurs à ces
clefs (via une tranche de hash).
On gagne ainsi en vitesse car notre hash n'alloue de la mémoire
qu'une seule fois, au lieu de l'allouer au fur et à mesure de ses
besoins lorsqu'on ajoute de nouvelles clefs.
Note : lorsqu'on trie en fonction de la date de modification
des fichiers en utilisant un cache, et que quelqu'un d'autre modifie
l'un des fichiers de la liste en cours de tri, la nouvelle date de
modification ne sera pas prise en compte dans le tri et le fichier
sera trié en fonction de l'ancienne date de modification. Est-ce un
mal ou au contraire un bien ? En effet, il y avait des risques de
plantage du tri si la fonction de comparaison n'était pas cohérente
avec elle-même, c'est-à-dire si pour les mêmes valeurs de $a
et
$b
, elle répondait la première fois que $a lt $b
et la fois
suivante que $a ge $b
. En mettant en cache la date de modification
des fichiers, on trie selon l'état du système de fichiers dans un
passé très récent au lieu du présent, mais le tri est cohérent.
Et si nous voulons effectuer un tri multi-critères, il suffit de créer autant de hashs de cache qu'il y a de valeurs à comparer :
keys my %cache1 = keys my %cache2 = @mots; @cache1{@mots} = map { y/e// } @mots; @cache2{@mots} = map { y/a// } @mots; @out = sort { $cache1{$a} <=> $cache1{$b} || $cache2{$a} <=> $cache2{$b} } @mots;
Nous venons juste de trier nos mots selon le nombre de « e », puis selon le nombre de « a ».
Attention tout de même à ne pas tomber dans le piège consistant à mettre en cache des données qui sont plus rapides à retrouver qu'une consultation de hash. Par exemple, il est inutile de mettre en cache le nom lui-même :
keys my %cache = @mots; @cache{@mots} = map { y/e// } @mots; @out = sort { $cache{$a} <=> $cache{$b} || $a cmp $b # $a et $b servent de clef de tri } @mots;
De même, de nombreuses fonctions internes de perl sont très rapides et
ne nécessitent pas de mise en cache : lc
, uc
, substr
par
exemple (et bien d'autres) peuvent être plus rapides qu'une
consultation de hash.
Cette approche a tout de même un inconvénient : elle impose de lire les données deux fois, une fois pour la mise en cache et une fois lors du tri proprement dit. Ce comportement peut être gênant, si les données sont lues depuis un fichier par exemple.
La Manœuvre Or-cache (Orcish Maneuver en VO) résout ce problème
en éliminant la phase de pré-traitement, tout en n'effectuant
l'extraction de la clef de tri qu'une fois par valeur. Le test et le
stockage des clefs de tri est effectué par l'opérateur ||=
(affectation ou-logique avec court-circuit), qui évalue et assigne
l'expression de droite à la lvalue de gauche, si ladite lvalue
est fausse. Notre code devient donc :
keys my %cache = @fichiers; # optimisation toujours valable @out = sort { ( $cache{$a} ||= -M $a ) # extraction si nécessaire <=> # comparaison numérique ( $cache{$b} ||= -M $b ) # extraction si nécessaire } @fichiers;
La première partie de la fonction de tri vérifie si la clef de tri de
$a
est en cache. Si ce n'est pas le cas, elle extrait la valeur de la
clef de tri de l'opérande et la met en cache. La clef de tri de $a
est
alors comparée à la clef de tri de $b
(qui est retrouvée exactement de
la même manière).
Les tris multicritères se font en utilisant, là encore, plusieurs hashs de cache.
La Manœuvre Or-cache a cependant quelques défauts. Un test
supplémentaire est nécessaire afin de rechercher les valeurs des clefs
de tri depuis le cache. De plus, si une clef de tri a une valeur
fausse, elle sera recalculée à chaque fois. Ceci n'est généralement
pas un problème, car les clefs de tri extraites sont rarement fausses,
comme dans le cas de -M $fichier
. Dans le cas du nombre de « e »
dans les mots à trier, une simple astuce permet d'éluder le problème
du zéro : il suffit de trier par le nombre de « e » plus 1.
Cependant, sauf quand il faut absolument éviter de lire les
données deux fois, la méthode de tri avec cache explicite est toujours
un peu plus rapide que la Manœuvre Or-cache.
Une manœuvre plus efficace de mettre en cache les clefs de tri, sans utiliser de variables temporaires nommées, fut popularisée par le très célèbre Randal L. Schwartz, et fut surnommée Transformée Schwartzienne (Schwartzian Transform en VO).
L'invention majeure de la Transformée Schwartzienne est d'utiliser des tableaux anonymes pour conserver l'enregistrement et sa clef de tri. Les clefs de tri sont extraites une fois, durant une phase de prétraitement des données de la liste à trier.
@out = map { $_->[0] } sort { $a->[1] cmp $b->[1] } map { [ $_, -M ] } @fichiers;
Reprenons ce code plus en détails. Pour le comprendre, il faut le lire de bas en haut. La dernière ligne :
map { [ $_, -M ] } @fichiers
crée une liste temporaire de références symboliques vers des couples
(fichier, date). L'élément fichier
d'un couple (obtenu par $_) est
un élément de @fichiers
, tandis que l'élément date
(obtenue par
-M
, opérateur unaire appliqué à $_
par défaut) est sa date de dernière
modification.
Cette liste de paires de valeurs est ensuite triée par l'instruction sort. Sa fonction de tri :
{ $a->[1] cmp $b->[1] }
compare les seconds éléments ($a->[1]
et $b->[1]
) de chaque
couple. Le tri se fait donc bien par rapport aux dates.
L'instruction map de la première ligne :
map { $_->[0] }
n'a plus qu'à extraire de cette nouvelle liste triée les noms de
fichiers. Il s'agit des premiers éléments (d'où le $_->[0]
) de
chaque couple. Ainsi le tableau @out
reçoit-il une liste de noms de
fichiers triés.
La Transformée Schwartzienne ne trie pas en fait les données réelles. Elle trie les références à des tableaux anonymes contenant l'enregistrement et les clefs de tris. Nous devons donc effectuer un post-traitement pour retrouver les éléments triés depuis les tableaux anonymes.
Utiliser la Transformée Schwartzienne pour un tri multi-critères est
simple. Il suffit de sauvegarder chaque clef de tri secondaire dans
l'élément suivant du tableau anonyme. Dans la fonction de tri, il
suffit de faire un ||
entre les différentes comparaisons des clefs
de tri secondaires, comme dans les tris standards ou la Manœuvre
Or-cache.
@out = map { $_->[0] } sort { $a->[1] <=> $b->[1] || # nombre de e $a->[2] <=> $b->[2] || # nombre de a $a->[0] cmp $b->[0] } # tri lexicographique map { [ $_, y/e//, y/a// ] } @mots;
Les techniques de tri avancé décrites auparavant sauvegardent les
opérandes à trier avec leurs clefs de tri (dans les tris avec clefs
mises en cache, les opérandes sont les clefs d'un hash et les clefs de
tri sont les valeurs du hash ; dans la Transformée Schwartzienne,
les opérandes sont les premiers éléments des tableaux anonymes, et les
clefs de tri sont les autres éléments des tableaux). Nous allons
maintenant étendre cette idée pour sauver les opérandes à trier
avec leurs clefs de tris sous forme de chaînes d'octets, en
utilisant la concaténation. Ceci nous permettra d'utiliser la fonction
sort
sans fonction de comparaison, ce qui est plus rapide comme
nous l'avons vu précédemment.
Cette optimisation peu connue améliore la Transformée Schwartzienne en éliminant la fonction de tri et en s'appuyant donc sur le tri lexicographique par défaut, qui est très rapide.
Cette méthode a été étudiée par Uri Guttman et Larry Rosler dans un article, A Fresh Look at Efficient Perl Sorting, dont vous trouverez les références à la fin de l'article.
Afin d'accomplir ceci, nous allons modifier la Transformée
Schwartzienne en remplaçant le tableau anonyme par des chaînes
d'octets. Nous pack
ons donc les clefs dans des chaînes simples. Les
clefs secondaires sont simplement concaténées, convenablement
délimitées si nécessaire. Nous y adjoignons ensuite l'opérande à
trier.
Note : pack
er une liste de valeurs permet de les convertir
en chaîne selon un modèle fourni par l'utilisateur. Typiquement,
chaque valeur convertie ressemble à sa représentation machine :
ainsi, un entier sera représenté par une séquence de 4 octets sur une
machine 32 bits.
Il nous suffit alors de trier lexicographiquement ces chaînes, et nous retrouvons finalement les opérandes à la fin des chaînes.
On peut retrouver l'opérande grâce à un appel à substr
ou via
d'autres méthodes, selon la préférence de l'utilisateur.
Pour classer une liste de mots suivant leur quatrième lettre, puis leur deuxième lettre, nous utiliserons donc le code suivant :
@out = map { substr $_, 2 } sort map { substr($_, 3, 1) . # quatrième lettre substr($_, 1, 1) . # deuxième lettre $_ } # mot entier @in;
Remarquez que la fonction sort
est appelée sans code de comparaison,
et est de ce fait extrêmement rapide.
Cette optimisation est très facile à réaliser si les clefs de tri sont
elles-mêmes des chaînes. Dans le cas où ce sont des nombres, le mieux
est d'utiliser le module Sort::Records
qui implémente cette méthode
sur toutes sortes de données : chaînes de longueur fixe, de
longueur variable, entiers, réels, signés, non signés, etc.
Voilà, vous en savez maintenant (je l'espère) beaucoup plus sur les tris en Perl. Mais ce n'est pas pour autant qu'il faut utiliser des tris de partout, sans discrimination.
Par exemple, il n'est pas rare de voir les débutants utiliser le bout de code suivant pour trouver le plus petit et le plus grand des éléments d'une liste :
my ($min, $max) = (sort @tab)[0, -1]
Quelle perte de temps ! La complexité est loin d'être linéaire (elle dépend de l'algorithme de tri utilisé, et peut même aller jusqu'à être quadratique).
Il vaut mieux analyser une seule fois la liste en modifiant le minimum et le maximum si besoin :
my ($min,$max) = ($tab[0]) x 2; # init. sinon warnings foreach ( @tab ) { $min = $_ if $_ < $min; $max = $_ if $_ > $max; }
Ici la complexité est linéaire selon la taille du tableau. Si on ne veut qu'une seule de ces deux valeurs, on peut utiliser la construction plus idiomatique :
my $max = $tab[0]; $_ > $max and $max = $_ for @tab;
Et même encore mieux, le module List::Util
fournit la fonction
max
:
use List::Util qw( max ); my $max = max @tab;
Bref, les tris ont de l'intérêt, mais pas partout. Saisissez cette occasion de faire fonctionner votre cerveau ! :-)
Pour plus de détails, je vous invite à regarder :
perldoc -f sort
L'excellent article de Uri Guttman et Larry Rosler : A Fresh Look at Efficient Perl Sorting, disponible en anglais sur http://www.sysarch.com/perl/sort_paper.html
La traduction de cet article par votre serviteur est disponible ici : http://articles.mongueurs.net/traductions/guttman-rosler.html.
Le module Sort::Records
de Uri Guttman, qui implémente la technique
du tri interne par clef pack
ée.
Le module File::Sort
de Chris Nandor, qui permet de trier de gros
fichiers en utilisant peu de mémoire.
Jérôme Quelin <jquelin@mongueurs.net>
Jérôme Quelin est membre de l'association Les Mongueurs de Perl (http://www.mongueurs.net) dont l'objet est de promouvoir le langage Perl en France. Il tient à remercier les autres membres de l'association pour leur aide et les relectures effectuées.
Copyright © Les Mongueurs de Perl, 2001-2011
pour le site.
Les auteurs conservent le copyright de leurs articles.