Les tris peuvent être un goulot d'étranglement dans les programmes Perl. Les performances peuvent varier de plusieurs ordres de grandeur, selon la manière dont le tri est réalisé.
Dans ce document, nous examinerons en détails la fonction interne
sort
et verrons comment lui conserver de bonnes performances avec
des données aussi bien simples que complexes. Nous analyserons ensuite
et comparerons plusieurs idiotismes perliens bien connus (tels que la
manoeuvre Or-cache et la transformée Schwartzienne). Puis nous
montrerons comment améliorer leurs performances en pack
-ant leurs
clefs de tri dans une seule chaîne. Enfin, nous présenterons une
nouvelle approche utilisant la fonction sort
avec des clefs packées
et sans fonction externe de tri. Cette approche surclasse toutes les
autres question performances, et est facile à implémenter soit
directement, soit en utilisant un module créé dans ce but,
Sort::Records
.
[NdT] Ceci est la traduction d'un excellent article écrit par Uri Guttman et Larry Rosler et que vous pouvez retrouver en VO à : http://www.sysarch.com/perl/sort_paper.html. Publié avec l'aimable autorisation des auteurs.
Trier, c'est réorganiser une liste selon un ordre défini par une
séquence croissante (ou décroissante) monotone de clefs de tri, où
chaque clef de tri est obtenue en appliquant une fonction à l'élément
correspondant de la liste. Nous utiliserons le terme clef de tri
pour éviter les confusions avec les clefs d'un hash.
Un tri réarrange une liste afin de faciliter d'autres traitements tels que la recherche d'un élément particulier. Les tris servent aussi pour faciliter la lecture aux humains ; ils permettent aussi de mieux embrasser les données et de localiser une donnée.
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 oeuvre 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 traite de la manière de créer des tris efficaces en
Perl. Nous commencerons avec un bref aperçu des tris (y compris la
théorie des algorithmes de base et leur efficacité respective), le
traitement des clefs de tris et les tris externes à perl. Nous
décrirons ensuite la fonction sort
[1] ainsi que son utilisation
dans les cas triviaux. Nous traiterons ensuite des clefs de tris
complexes, qui posent la question de l'optimisation de leur
traitement. Enfin nous introduirons une méthode qui déplace le
traitement des clefs de tris en dehors de la fonction sort
, ce qui
est la méthode de tri la plus efficace en Perl. Nous présenterons un
module qui implémente cette technique de tri et fournit de puissantes
méthodes pour l'extraction de clefs de tri (le traitement des données
afin de produire les clefs de tri).
Une discussion complète sur la théorie et les algorithmes de tris dépasse le cadre de cet article. Cette section donnera juste ce qu'il faut de théorie et terminologie pour expliquer les méthodes de comparaison des techniques de tri.
La complexité d'un algorithme est la mesure des ressources nécessaires pour exécuter ledit algorithme -- typiquement, il y a une opération critique qu'il faut exécuter un certain nombre de fois. Une partie de la théorie des algorithmes consiste à trouver quelle opération est le facteur limitant, puis à formuler une fonction qui décrit le nombre de fois que cette opération est exécutée. Cette fonction de complexité est communément écrite avec la notation grand-O -- O(f(N)) -- où O est lu ordre de et f(N) est une fonction de N, la taille des données en entrée.
Les comparaisons O(f(N)) ont quelques propriétés inhabituelles. La taille réelle de N importe généralement peu pour l'algorithme, mais son influence sur le comportement de f(N) est déterminante. Si l'ordre d'un algorithme est O(N*log(N) + N), l'effet de N sur la valeur de la fonction est négligeable par rapport à N*log(N) quand N est suffisamment grand. Et l'ordre de cet algorithme sera donc O(N*log(N). Quelquefois, la fonction calculée pour l'ordre d'un algorithme est un polynôme de N, mais dans ce cas on ne prend en considération que le terme de plus haute puissance, et le coefficient de cette puissance n'est pas montré. De même, si deux algorithmes ont le même ordre mais que l'un effectue plus de travail à chaque opération, ils sont tout de même équivalents en terme d'ordre, même s'il peut y avoir une différence importante de vitesse lors de leur application. Ce dernier point est essentiel dans les techniques que nous montrerons pour optimiser les tris en Perl qui ont tous la même fonction grand-O, O(N*log(N)).
Voici quelques algorithmes connus et leur fonction d'ordre (adapté de [2]) :
Notation Nom Exemple O(1) constant index de tableau ou de hash O(log(N)) logarithmique recherche dichotomique O(N) linéaire comparaison de chaînes O(N*log(N)) n log n tri avancé O(N**2) quadratique tri simple O(N**3) cubique multiplication de matrices O(2**N) exponentiel partionnement d'ensemble
L'opération élémentaire dans un tri consiste à déterminer dans quel ordre mettre deux données en entrées. La comparaison peut être aussi simple que trouver si deux nombres sont égaux ou trouver lequel est le plus grand (ou l'opération similaire sur les chaînes), ou elle peut être complexe.
Les algorithmes de tris simples (tri bulle ou tri par insertion) comparent chaque élément avec tous les autres, donc leur complexité est O(N**2). Même avec l'optimisation du triangle ($x est égal à $x, et $x comparé à $y est l'opposé de $y comparé à $x), qui réduit la fonction à O((N*(N-1))/2), la complexité reste O(N**2) ainsi qu'expliqué ci-dessus.
Mais ces algorithmes ont leur utilité. Quand N est petit, ils peuvent être plus rapides que les autres méthodes, car les complexités ajoutées de O(1) et O(N) des tris avancés peuvent dépasser le comportement en O(N**2) des tris simples. « Les algorithmes complexes sont lents pour les petites valeurs de N, et N est généralement petit. Les algorithmes complexes ont de grandes constantes. » [3]. Les cas réellement importants surviennent avec des valeurs élevées de N.
Les méthodes de tri avancé partitionnent les enregistrements à trier
en ensembles plus petits, afin de réduire le nombre de comparaisons
nécessaires. Leur complexité est O(N*log(N)), fournissant un gain
réellement important pour les valeurs de N suffisamment
grandes. Ces algorithmes incluent le tri arborescent
, le tri
Shell
et le quicksort
. [4]
Quelques algorithmes de tri spécialisés (tels la méthode de tri
digital
ou radix sort
) travaillent en comparant des parties de
clefs de tri numériques et peuvent obtenir une complexité linéaire
(O(N)) [5]. Ces méthodes cependant ne peuvent servir dans tous les
cas, et nous ne les étudierons donc pas.
Une propriété des algorithmes de tri est leur stabilité. Un tri stable préserve l'ordre relatif dans les données triées de deux éléments égaux (en terme de tri). Certains problèmes mettant en oeuvre des tris requièrent la stabilité. Les algorithmes de tri simples sont généralement stables, les algorithmes avancés ne le sont pas. Nous montrerons comment créer des tris stables en Perl.
Quand les données originelles ne peuvent être facilement déplacées par l'algorithme de tri, il faut trier leur index et ensuite utiliser les index triés pour créer une liste d'éléments triés. Certains opérateurs de tri dans les autres langages (et on pense ici à APL) renvoient simplement des index triés, et c'est à l'utilisateur de les utiliser correctement. Nous montrerons comment créer un tri d'index efficace en Perl et les endroits où il est utile de procéder ainsi.
Dans le cas d'un tri scalaire sur les éléments (avec une comparaison s'effectuant sur l'élément en entier), la clef de tri est tout simplement l'élément. Plus généralement, la clef de tri est basée sur certaines propriétés fonctions de tout ou partie de l'élément. De telles sous-clefs peuvent être extraites de propriétés internes de l'élément (champs d'un enregistrement) ou dérivée de propriétés externes de l'élément telles que la date de modification d'un fichier dont le nom est l'élément lui-même, ce qui est coûteux à rechercher depuis le système de fichiers.
Afin d'éviter de répéter le calcul des clefs de tris, le tri doit retenir les associations entre les enregistrements et leur clef de tri extraite ou dérivée. La théorie et les algorithmes de tri ignorent généralement le coût de cette association, mais il est un facteur constant de l'opération de comparaison. Nous verrons par la suite que dans les cas réels, supprimer ce surcoût ou le réduire de O(N*log(N)) à O(N) apporte un gain de temps appréciable, surtout lorsque N augmente.
Les clefs de tri complexes peuvent augmenter énormément le surcoût de chaque comparaison. En particulier quand on effectue un sous-tri sur des sous-clefs. Extraire et comparer des clefs de tri complexes peut être coûteux.
Aucune implémentation d'un algorithme de tri universel ne peut supporter l'extraction et la comparaison de différents types de clefs de tri de manière efficace. Et donc, la plupart des implémentations fournissent une interface permettant d'appeler une fonction de tri -- une fonction de comparaison personnelle à laquelle on passe deux opérandes. Ces opérandes peuvent être les enregistrement eux-mêmes, des références ou les index de ces enregistrements. La comparaison renvoie une valeur négative, positive ou nulle selon l'ordre relatif des clefs de tri des deux enregistrements. Le programmeur est responsable du pré-traitement des enregistrements afin de générer les clefs de tri, ainsi que du post-traitement pour retrouver les données originales. Cette fonction générique de tri s'occupe juste des comparaisons et de remettre les opérandes dans le bon ordre.
Comme la fonction interne de tri de Perl a un ordre de O(N*log(N)), l'efficacité d'un tri proviendra de l'optimisation de l'extraction et de la comparaison de clefs de tri. Cet article traite principalement des méthodes permettant de rendre l'extraction et la comparaison aussi efficace que possible.
Tout système d'exploitation digne de ce nom propose un utilitaire de
tri. Les différents Unix/POSIX proposent typiquement une commande
sort
à la fois rapide et souple pour l'extraction de clefs de tri à
partir de fichiers texte. Il peut être plus facile à coder
et plus efficace d'utiliser cette commande en lieu et place de la
fonction de tri de Perl, même en considérant le surcoût dû au
transfert des données dans et depuis le second processus.
De nombreux fournisseurs vendent des extensions commerciales de tri hautement optimisées, profitant de décennies d'optimisations et pouvant gérer de gros volumes de données. Mais ces extensions sont chères et généralement mal adaptées à un usage depuis un programme Perl.
Ces tris externes sont capables de gérer efficacement de gros volumes de données, en utilisant des médias externes tels que disques ou bandes magnétiques comme moyen de stockage intermédiaire si nécessaire. Au contraire de Perl, qui requiert que la liste entière des opérandes soit en mémoire (vive ou -- bien que plus coûteux en terme de rapidité -- virtuelle) en même temps. Perl n'est donc pas l'outil idéal pour les énormes tris (avec « énorme » défini par les limites de votre système en termes de mémoire), nous les négligerons donc par la suite.
La fonction interne de tri de Perl utilise une implémentation de
l'algorithme quicksort similaire (mais plus robuste) à la fonction
qsort
de la bibliothèque ANSI/ISO Standard C [6]. Dans sa forme la
plus simple, sort
ne requiert aucune fonction de tri :
@out = sort @in;
Par défaut, le tri est effectué en respectant un ordre lexicographique
ascendant, utilisant de manière efficace comme opération de
comparaison la fonction C memcmp
(qui compare simplement des
séquences d'octets non signés). Si une locale est spécifiée, il
utilise à la place la fonction C plus compliquée (et plus lente)
strcoll
.
Si vous voulez un autre ordre que celui-ci, vous devez fournir une fonction de comparaison personnalisée. La fonction de comparaison peut être spécifiée soit comme un bloc de code, soit comme le nom d'une routine, ou comme un typeglob référant une routine. Dans Perl 5.6, une variable scalaire contenant une référence sur du code peut aussi être utilisée pour spécifier la fonction de comparaison.
Afin d'optimiser l'appel de la fonction de comparaison, Perl saute le
passage d'arguments classique via @_
, et utilise à la place une
méthode spéciale et plus efficace. A l'intérieur de la fonction de
tri, les variables de package $a
et $b
sont des alias sur les
deux opérandes à comparer. La fonction de tri doit renvoyer un nombre
inférieur, égal ou supérieur à zéro selon le résultat de la
comparaison entre les clefs de tri $a et $b. Les variables $a et $b ne
doivent en aucun cas être modifiées, sinon l'algorithme de tri a de
fortes chances de renvoyer n'importe quoi.
Même le tri personnalisé le plus simple sera moins efficace en Perl que d'utiliser la comparaison par défaut. Le tri interne n'utilise que du code C dans le binaire perl, alors que n'importe quelle fonction de comparaison doit exécuter du code Perl. Une optimisation bien connue consiste à minimaliser le code Perl exécuté afin d'essayer de rester dans le binaire perl autant que possible. Nous étudierons par la suite plusieurs techniques permettant de réduire le code Perl exécuté.
Le but premier de cet article est d'effectuer de nombreux tests en utilisant la fonction de tri interne de Perl. Voici comment un tri lexicographique ascendant utilisant une fonction de tri personnalisée :
@out = sort { $a cmp $b } @in;
Pour une comparaison, reportez-vous aux lignes "Défaut" et "Explicite" dans le test de performances A1 de l'annexe A. La méthode par défaut est environ deux fois plus rapide que la méthode explicite.
Nous appelons tris triviaux les tris utilisant comme clef de tri tout ou partie de l'enregistrement, et effectuant un traitement minimum de l'enregistrement. Pour effectuer en Perl des tris triviaux autrement que lexicographiques ascendants, il suffit de créer la fonction de tri correspondante. Voici quelques exemples simples mais utiles de fonctions de comparaison.
L'exemple le plus simple est le tri numérique ascendant, qui utilise
le bien nommé opérateur vaisseau spatial
:
@out = sort { $a <=> $b } @in;
Un tri numérique est requis car l'ordre lexicographique de la liste (1, 10, 2) ne correspond pas à son ordre numérique (1, 2, 10).
Si vous voulez trier en ordre descendant, vous pouvez utiliser trois techniques. La pire est d'inverser le résultat de la comparaison dans la fonction de comparaison. Inverser l'ordre de comparaison en échangeant $a et $b est beaucoup plus avantageux : cette implémentation sera aussi rapide que le tri correspondant ascendant.
# tri numérique descendant. @out = sort { $b <=> $a } @in; # tri lexicographique descendant. @out = sort { $b cmp $a } @in;
La meilleure méthode est d'utiliser la fonction reverse
sur le
résultat du tri interne lexicographique ascendant.
@out = reverse sort @in;
Ceci est plus efficace que d'utiliser le tri lexicographique
descendant explicite, pour la raison citée plus haut : le tri par
défaut est plus rapide que d'utiliser une fonction de tri. La fonction
reverse
est efficace car elle ne fait que déplacer des pointeurs.
Un autre exemple trivial consiste à trier sur une sous-chaîne de
l'élément, en utilisant la fonction substr
. Ceci peut être plus
rapide que le tri avancé équivalent que nous présentons par la suite,
car un appel à substr
peut être plus rapide qu'une consultation de
hash (Manoeuvre Or-cache) ou un déréférencement de tableau
(Transformée Schwartzienne).
@out = sort { substr($a, 4, 6) cmp substr($b, 4, 6) } @in;
Un problème courant consiste à trier sans tenir compte de la
casse. Ceci peut être facilement résolu en utilisant la fonction lc
ou uc
. L'utilisation de l'une ou l'autre donnera les mêmes
résultats.
@out = sort { lc $a cmp lc $b } @in;
Le banc de tests A1 analyse ces exemples selon la taille du jeu de
données. Le comportement en O(N*log(N)) est évident, de même que le
coût d'utiliser dans la fonction de tri une fonction, fut-elle aussi
simple que les fonctions internes substr
ou lc
.
Les exemples triviaux ci-dessus trient la liste en entrée en utilisant comme clef de tri la chaîne en entier ou une sous-chaîne (pour un tri lexicographique) ou l'élément interprété comme nombre (tri numérique). De manière plus générale, la clef de tri est basée sur des propriétés qui sont fonctions de plusieurs parties de l'élément. Les sous-clefs individuelle peuvent être combinées dans une seule sous-clef ou peuvent être comparées ensemble.
Une chaîne peut être divisée en champs, dont certains peuvent servir
de sous-clef. Par exemple, la commande sort
des Unix/POSIX permet
d'extraire des champs dans le flux en entrée. La fonction sort
de
Perl ne fournit pas ces facilités, et c'est donc au programmeur d'y
remédier. Un module du CPAN s'attache au tris de champs [7].
Si vos données sont des enregistrement qui sont des chaînes complexes ou des références à des tableaux ou des hashes, vous devez comparer les parties pertinentes des enregistrements. Ceci est appelé tri d'enregistrements (les tris de champs en sont un sous-ensemble).
Dans les exemples qui suivent, il faut substituer CLEF() par le code
Perl qui effectue l'extraction de la clef de tri. Il vaut mieux de ne
pas la faire sous forme d'appel de fonction, car ces appels depuis des
fonctions de tri peuvent être prohibitifs. Les appels de fonctions
internes de Perl (tels que les appels à lc
dans l'exemple
précédent) sont équivalents à des opérateurs Perl, et donc moins
coûteux.
Pour les tris de chaîne, $a et $b sont positionnés à ces chaînes, et
donc pour extraire les clefs de tri, vous devez généralement effectuer
diverses opérations de chaînes sur les enregistrements. Les fonctions
communément utilisées incluent split
, substr
, unpack
et
m//
. Voici un exemple, triant par nom d'utilisateur une liste de
lignes de fichier passwd
. Les champs sont séparés par le caractère
deux-points, et le nom d'utilisateur est le premier champ.
@out = sort { (split ':', $a, 2)[0] cmp (split ':', $b, 2)[0] } @pw_lines;
Souvent vous devez trier des enregistrements par clef de tri primaire, puis pour tous les éléments ayant la même clef de tri primaire, il vous faut trier sur une clef de tri secondaire. Vous pouvez le faire de manière horriblement inefficace en triant d'abord par la clef de tri primaire, puis trier tous les enregistrements d'une clef de tri primaire donnée par sa clef de tri secondaire. Mais il vaut mieux utiliser une méthode de tri multi-critères. Ceci veut dire l'extraction des clefs pour chaque champ et la comparaison des clefs dans l'ordre de priorité voulu. Et donc deux enregistrements avec la même clef de tri primaire sont immédiatement comparés suivant leurs clefs secondaires. On peut généraliser cette approche pour les tris avec plus de deux sous-clefs de tri.
Perl dispose d'une fonctionnalité intéressante permettant de
simplifier les tris multi-critères. Les opérateurs ||
et or
(opérateur logique court-circuit ou
) renvoient la valeur du premier
opérande logiquement vrai qu'ils rencontrent. Si un couple de clefs
primaires sont égaux comparativement parlant, la valeur de retour de
la fonction de comparaison sera le résultat de la comparaison des
clefs de tri secondaires.
Un petit dessin valant mieux qu'un long discours, un exemple illustrera mieux cet empilement de comparaisons. Voici un tri reposant sur une comparaisons de trois sous-clefs :
@out = sort { # Comparaison des clefs primaires CLEF1($a) cmp KEY1($b) || # ou si elles sont égales renvoyer # la comparaison en ordre numérique # descendant CLEF2($b) <=> CLEF2($a) || # ou si elles sont égales renvoyer # la troisième comparaison en ordre # lexicographique ascendant CLEF3($a) cmp CLEF3($b) } @in;
Dans les deux exemples précédents, nous avons montré un tri avec une
extraction de clef coûteuse (via split), et un tri
multi-critères. Combinons-les maintenant. Pour coller à la réalité,
nous allons résoudre un problème qui a soulevé de nombreux débats dans
comp.lang.perl.misc : trier une liste d'adresses IP sous forme
décimale pointée. Chaque élément de la list est une chaîne de la forme
nnn.nnn.nnn.nnn\tabc.xyz.com\n
, où nnn
représente un entier
entre 0 et 255, avec ou sans zéro initial.
L'approche la plus naïve consiste à considérer chacun des quatre champs numériques comme clef de tri, et trier successivement chacune de ces clefs de tri.
@out = sort { my @a = $a =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/; my @b = $b =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/; $a[0] <=> $b[0] || $a[1] <=> $b[1] || $a[2] <=> $b[2] || $a[3] <=> $b[3] } @in;
Cette approche est lente même pour de petites listes, à cause de toutes les opérations supplémentaires exécutées dans la fonction de comparaison pour chacune des O(N*log(N)) comparaisons.
Afin d'améliorer les performances, nous allons créer pour chaque adresse IP une chaîne d'octets à partir de chacune des quatre sous-clefs, que nous pourrons alors utiliser pour trier le tableau de manière ascendante monotone.
Pour produire la clef la plus courte possible (une chaîne de quatre octets) nécessitant le moins de calculs en Perl, nous utiliserons :
pack 'C4' => $string =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/;
Cette expression utilise l'opérateur virgule fantaisie =>
, que
vous pouvez lire appliqué à. Il nous suffit alors de trier ces clefs
de tris par ordre lexicographique. Les quatre entiers auraient pu être
combinés dans un entier 32-bits par multiplication ou décalage de
bits, puis comparés numériquement. Mais nous recherchons autant que
faire se peut le tri lexicographique.
Nous nous rapprochons donc d'un tri efficace avec la solution suivante :
@out = sort { pack('C4' => $a =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/) cmp pack('C4' => $b =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/) } @in;
Le benchmark A2 montre que la comparaison des clefs secondaires par paires est moins rapide que de les packer et comparer les chaînes d'octets. Cette observation est valable pour toutes les méthodes de tri. Dans les tests suivants concernant des tris avancés pour ce problème, nous utiliserons donc des clefs de tri packées.
Néanmoins, les tris naïfs sont toujours tristement inefficaces, car chacune des clefs de tri sont recalculées chaque fois qu'un opérande est comparé à un autre. Nous devons donc calculer chaque clef de tri une seule fois et nous rappeler du résultat.
Comme tous les tris en Perl utilisent la fonction interne sort
, et
donc le même algorithme quicksort, tous les tris en Perl sont de
l'ordre de O(N*log(N)). Nous ne pouvons pas améliorer ce comportement,
nous devons donc nous tourner ailleurs pour gagner en
efficacité. Comme la complexité est fixée, améliorer les facteurs
constants peut être réellement bénéfique, et, dans le monde réel,
fournir des améliorations substantielles en terme de rapidité. Quand
une fonction de tri doit générer une clef de tri complexe, ceci est
réalisé O(N*log(N)) fois, alors qu'il n'y a que N éléments, et donc N
clefs de tri. Et si nous arrivions à extraire la clef de tri une seule
fois par enregistrement, et à savoir quelle clef de tri correspond à
quel élément ?
De manière évidente, nous allons donc associer les clefs avec les éléments dont elles sont dérivées à l'aide d'un hash. Le hash peut être construit dans une première passe sur les données. Si la taille approximative des données est connue, pré-allouer le hash peut encore améliorer les performances.
keys my %cache = @in; $cache{$_} = CLEF($_) for @in;
On peut construire le cache de manière plus efficace en utilisant une tranche de hash :
keys my %cache = @in; @cache{@in} = map CLEF($_) => @in;
La fonction de comparaison va donc simplement trier selon les valeurs des clefs de tri cachées.
@out = sort { $cache{$a} cmp $cache{$b} } @in;
En fait, nous avons simplement remplacé de longs calculs compliqués dans la fonction de tri par une rapide consultation de hash (O(1)).
Et si vous souhaitez réaliser une comparaison multi-critères plus complexe, vous devez soit utiliser un cache séparé pour chacune des sous-clefs ou combiner les clefs de tri secondaires comme nous le verrons plus tard. Voici un exemple de la première solution :
keys my %cache1 = @in; keys my %cache2 = @in; $cache1{$_} = CLEF1($_), $cache2{$_} = CLEF2($_) for @in; @out = sort { $cache1{$a} cmp $cache1{$b} || $cache2{$b} <=> $cache2{$a} } @in;
Ou alors, un cache multi-niveaux peut être utilisé, qui sacrifie un peu de vitesse pour sauver un peu d'espace :
keys my %cache = @in; $cache{@in} = map [ CLEF0($_), CLEF1($_) ] => @in; @out = sort { $cache{$a}[0] cmp $cache{$b}[0] || $cache{$b}[1] <=> $cache{$a}[1] } @in;
Un point important à propos des tris cachés est qu'aucun post-traitement n'est nécessaire pour retrouver les éléments triés. Cette méthode trie les enregistrements réels, mais utilise le cache pour réduire l'extraction des clefs de tri en O(N).
La Manoeuvre Or-Cache (inventée par Joseph N. Hall [8]) élimine la
phase de prétraitement des données, qui peut éviter une copie des
données si elles sont lues directement depuis un fichier. Il effectue
l'extraction des clefs de tri une fois par enregistrement, étant donné
qu'il vérifie dans le hash si l'extraction a déjà été effectuée
auparavant. Le test et le stockage des clefs de tri est effectué par
l'opérateur ||=
(assignement ou-logique court-circuité), qui évalue
et assigne l'expression de droite à la lvalue de gauche, si ladite
lvalue est fausse. ([NdT] Le nom orcish
est un jeu de mots avec
or-cache
). Nous nous retrouvons donc avec la fonction de tri
suivante :
keys my %or_cache = @in; @out = sort { ($or_cache{$a} ||= CLEF($a)) cmp ($or_cache{$b} ||= CLEF($b)) } @in;
La première partie de la fonction de tri vérifie si la clef de tri de $a est cachée. 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).
Et voici maintenant un exemple de comparaison multi-critères utilisant deux caches :
keys my %or_cache1 = @in; keys my %or_cache2 = @in; @out = sort { ($or_cache1{$a} ||= CLEF1($a)) cmp # clef de tri primaire ($or_cache1{$b} ||= CLEF1($b)) || # ou ($or_cache2{$b} ||= CLEF2($b)) <=> # clef de tri secondaire (numérique) ($or_cache2{$a} ||= CLEF2($a)) } @in;
La Manoeuvre 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. 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 Manoeuvre Or-Cache (se reporter au banc de tests A3).
Une manoeuvre plus efficace de cacher les clefs de tri, sans utiliser
de variables temporaires nommées, fut popularisée par Randal
L. Schwartz, et fut surnommée Transformée Schwartzienne
[9, 10]
(elle aurait du en fait s'appeler Transformée de Schwartz
, selon le
modèle utilisé pour les transformées de Fourrier et de Laplace, mais
il est trop tard maintenant pour changer le nom).
L'invention majeure de la ST consiste à 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 (comme ce que nous avons fait auparavant pour calculer le cache des clefs de tri).
@out = map $_->[0] => sort { $a->[1] cmp $b->[1] } map [ $_, CLEF($_) ] => @in;
La ST ne trie pas en fait les données réelles. Elle trie les références à des tableaux anonymes contenant l'élément de 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 ST 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 ou
entre les différentes comparaisons des clefs de tri secondaires, comme
dans la Manoeuvre Or-cache ou le tri naïf.
@out = map $_->[0] => sort { $a->[1] cmp $b->[1] || $b->[2] <=> $a->[2] } map [ $_, CLEF1($_), CLEF2($_) ] => @in;
Pour une déconstruction lumineuse de la ST et une reconstruction non moins éclairée, se référer au [11].
Toutes les techniques avancées de tri décrites auparavant sauvegardent les opérandes à trier avec leurs clefs de tri (dans les tris cachés, 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îne d'octet, en utilisant la concaténation.
Cette optimisation peu connue améliore la ST en éliminant la fonction de tri et en s'appuyant donc sur le tri lexicographique par défaut, qui, comme nous l'avons montré auparavant, est très rapide. Cette méthode est celle utilisée dans le module Sort::Records.
Afin d'accomplir ceci, nous allons modifier la ST en remplaçant le tableau anonyme par des chaînes d'octets.
Nous packons 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.
Plusieurs méthodes peuvent être utilisées, seules ou en les combinant, afin de construire les chaînes d'octet. Les techniques pour calculer les clefs selon le type de données sont présentées dans l'annexe B.
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érandes selon plusieurs méthodes, y compris un
appel à substr
(comme dans l'exemple suivant), ce qui est
certainement le plus rapide, split
, unpack
ou une expression
rationnelle.
@out = map substr($_, 4) => sort map pack('C4' => /(\d+)\.(\d+)\.(\d+)\.(\d+)/) . $_ => @in;
Le banc de tests A4 compare les deux techniques génériques les plus avancées, ST et défaut-packé. Les tris multi-passes sont mesurés en tant qu'instruction seule ainsi que le temps relatif mis par chacune des instructions le composant.
Le tri interne packé est environ deux fois plus rapide que la ST, ce qui en fait l'algorithme Perl générique le plus rapide.
Nous avons montré auparavant les tris simples utilisant les fonctions
substr
ou lc
. Même dans ces cas, le tri interne packé est plus
efficace dès qu'il s'applique à plus de quelques éléments. Se reporter
au banc de tests A5, qui montre un comportement quasi en O(N) pour le
tri interne packé sur des échantillons de taille variée, car le tri
est bien plus rapide que l'extraction des clefs.
Considérons maintenant le problème classique consistant à trier une structure à deux dimensions, une liste de références à des tableaux ou des hashes, où les clefs de tris sont fonctions des valeurs des membres de la structure.
Si nous devions utiliser la méthode de tri interne packé, les références seraient converties en chaînes et concaténées aux clefs de tri. Après le tri, les opérandes pourraient être retrouvées en tant que chaînes, mais ne seraient plus utilisables en tant que références. Nous devons à la place utiliser les index des membres à la place des opérandes à trier.
Le banc de test suivant compare un tri clef packées ST et un tri indexé utilisant la méthode de tri interne packé. La liste à trier comprend des références vers des tableaux, dont chacun a deux éléments : une adresse IP (servant de clef de tri primaire), et un nom de domaine (servant de clef de tri secondaire). Ce sont les mêmes données que celles utilisées dans les bancs de test précédents, éclatées dans deux éléments de tableau.
@out = map $_->[0] => sort { $a->[1] cmp $b->[1] } map [ $_, pack('C4 A' => $_->[0] =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/, $_->[1]) ] => @in; my $i = 0; keys my %h = @in; @h{ map pack( 'C4 A x N' => $_->[0] =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/, $_->[1], $i++ ) => @in } = @in; @out = @h{ sort keys %h };
Le tri indexé est plus rapide que la ST une fois de plus (se reporter au benchmark A6).
Dans le tri indexé, l'auto-incrémentation de l'index $i assure qu'aucun élément de tableau n'aura de clef de tri packée identique. Elle assure de plus que le tri sera stable.
Tout tri en Perl peut être stabilisé en utilisant un index de cette sorte comme clef de tri décisive. Ceci offre de surcroît un avantage possible pour le tri indexé. Les éléments à trier (qui peuvent être de grandes chaînes) n'ont pas besoin d'être concaténées aux clefs de tri, ce qui créerait une deuxième copie de chaque élément. En utilisant le tri indexé, les enregistrements peuvent être récupérés après le tri depuis les données initiales, en utilisant les index triés.
Uri Guttman ([NdT] l'un des auteurs de l'article) a créé le module Sort::Records [12] qui combine la technique de tri interne packé avec l'extraction automatique des clef de tri en utilisant une syntaxe attribut / valeur. Le module crée une fonction qui convertit les clefs de tri en une chaîne d'octets qui peut être triée en utilisant la fonction interne de tri. Une référence à cette fonction est sauvée, et l'objet peut être utilisé pour trier d'autres données ou peut être clonée.
Packer les clefs de tri dans des chaînes pouvant être comparées lexicographiquement améliore les performances de toutes les techniques de tri, par rapport aux méthodes comparant les clefs de tri par paires.
Concaténer les opérandes aux clefs de tri permet d'utiliser la fonction de tri par défaut de Perl, en ordre lexicographique ascendant (sans fonction de tri personnalisée). Ceci permet d'effectuer des tris considérablement plus rapides que la Manoeuvre Or-cache ou la Transformée Schwartzienne. L'opération de tri peut même approcher d'un comportement en O(N) sur des données de grande taille, car le temps O(N*log(N)) du tri en lui-même peut être petit comparé au temps requis pour extraire les clefs de tri.
Le tri interne packé peut être utilisé explicitement, ou via le module Sort::Records.
L'idée de cet article a été soulevée par Michal Rutka [13]. John Porter a participé à l'élaboration du projet. Tom Christiansen a fourni de précieux commentaires sur l'avant-dernière version.
[NdT] Je tiens à remercier ici Uri Guttman et Larry Rosler pour m'avoir permis de traduire cet article.
La page de manuel de la fonction interne sort, http://www.perl.com/CPAN/doc/manual/html/pod/perlfunc/sort.html
Kernighan, B. W. & Pike, R., (1999). The Practice of Programming, p. 41. Addison-Wesley.
Pike, R. (1989). Notes on Programming in C, http://wwwwbs.cs.tu-berlin.de/~jutta/c/pikestyle.html
Knuth, D. E. (1998). The Art of Computer Programming : Sorting and Searching (Vol 3, 2nd ed), chap. 5. Addison-Wesley.
Sedgewick, R. (1983). Algorithms, chap. 10. Addison-Wesley.
ANSI/ISO 9899-1992, sect. 4.10.5.2. American National Standards Institute.
Hall, J. N., Sort::Fields -- Sort lines containing delimited fields, http://www.perl.com/CPAN/modules/by-module/Sort/JNH/
Hall, J. N. (1998). Effective Perl Programming, p. 48. Addison-Wesley.
How do I sort an array by (anything)?, http://www.perl.com/CPAN/doc/manual/html/pod/perlfaq4.html#How_do_I_sort_an_array_by_anyth
Christiansen, T. & Torkington, N. (1998). The Perl Cookbook, Recipe 4.15: "Sorting a List by Computable Field". O'Reilly.
Christiansen, T., Far More Than Everything You've Ever Wanted to Know About Sorting, http://www.perl.com/CPAN/doc/FMTEYEWTK/sort.html
The Sort::Records module and benchmarks are available at http://www.sysarch.com/perl/sort/
Rutka, M., in comp.lang.perl.misc. http://x4.dejanews.com/[ST_rn=ps]/getdoc.xp?AN=397853353)
L'utilité d'un banc de test repose sur l'isolation judicieuse des variables appropriées, dans l'algorithme testé et dans l'ensemble des valeurs. Différentes implémentations peuvent donner des résultats relatifs différents même avec le même algorithme et les mêmes données. Les tests suivants doivent donc être vérifiés suivant vos propres conditions. Bref, ça dépend.
Dans les bancs de test suivants, toutes les données représentes le temps CPU (en microsecondes) par ligne de données en entrée, qui compte en moyenne 35 caractères. Tous les tableaux nommés et les hashes sont pré-alloués, ce qui réduit la variance des mesures dûe à l'allocation du stockage.
+------------------------------------------+ | Méthode | Code | |---------------+--------------------------| | Contrôle | @out = @in; | |---------------+--------------------------| | Défaut | @out = sort @in; | |---------------+--------------------------| | Reverse | @out = reverse sort @in; | |---------------+--------------------------| | Explicite | @out = sort | | | { $a cmp $b } @in; | |---------------+--------------------------| | Sous-chaîne | @out = sort { | | | substr($a, 4, 6) cmp | | | substr($b, 4, 6) } | | | @in; | |---------------+--------------------------| | Insensibilité | @out = sort | | à la casse | { lc $a cmp lc $b } | | | @in; | +------------------------------------------+ +--------------------------------------------+ | Nombre de lignes | 100 | 1000 | 10K | 100K | |------------------+-----+------+-----+------| | Contrôle | 5 | 6 | 7 | 8 | |------------------+-----+------+-----+------| | Défaut | 9 | 13 | 19 | 25 | |------------------+-----+------+-----+------| | Reverse | 9 | 14 | 19 | 26 | |------------------+-----+------+-----+------| | Explicite | 17 | 25 | 37 | 50 | |------------------+-----+------+-----+------| | Sous-chaîne | 33 | 49 | 68 | 85 | |------------------+-----+------+-----+------| | Casse | 43 | 64 | 92 | 120 | +--------------------------------------------+
+---------------------------------------------+ | Nombre de lignes | 100 | 1000 | 10K | 100K | |------------------+-----+------+------+------| | Clefs de tri | 697 | 1251 | 1732 | 2758 | | séparées | | | | | |------------------+-----+------+------+------| | Clefs de tri | 583 | 1002 | 1363 | 1814 | | packées | | | | | +---------------------------------------------+
+----------------------------------------------+ | Nombre de lignes | 100 | 1000 | 10K | 100K | |--------------------+-----+------+-----+------| | Mise en cache | 66 | 75 | 85 | 74 | |--------------------+-----+------+-----+------| | Tri | 49 | 87 | 122 | 164 | |--------------------+-----+------+-----+------| | Total cache+tri | 116 | 163 | 215 | 240 | |--------------------+-----+------+-----+------| | Manoeuvre Or-cache | 125 | 168 | 221 | 256 | +----------------------------------------------+
+---------------------------------------------+ | Nombre de lignes | 100 | 1000 | 10K | 100K | | ------------------+-----+------+-----+------| | ST | | | | | |-------------------+-----+------+-----+------| | Mise en cache | 80 | 84 | 84 | 75 | |-------------------+-----+------+-----+------| | Tri | 27 | 47 | 76 | 97 | |-------------------+-----+------+-----+------| | Récupération | 13 | 18 | 20 | 17 | |-------------------+-----+------+-----+------| | Une instruction | 116 | 150 | 177 | 191 | |-------------------+-----+------+-----+------| | Tri interne packé | | | | | |-------------------+-----+------+-----+------| | Package | 61 | 63 | 65 | 67 | |-------------------+-----+------+-----+------| | Tri | 9 | 12 | 18 | 25 | |-------------------+-----+------+-----+------| | Récupération | 12 | 12 | 13 | 12 | |-------------------+-----+------+-----+------| | Une instruction | 73 | 79 | 86 | 93 | +---------------------------------------------+
+--------------------------------------------+ | Sous-chaîne | @out = map substr($_, 6) => | | packée | sort map | | | substr($_, 4, 6) . $_ => | | | @in; | |--------------+-----------------------------| | Packée | @out = map substr($_, | | insensible | 1 + rindex $_, "\0")=> | | à la casse | sort map "\L$_\E\0$_" => | | | @in; | +--------------------------------------------+ +-------------------------------------------+ | Nombre de lignes | 10| 100| 1000| 10K|100K| |------------------+---+----+-----+----+----| | Sous-chaîne | 17| 33| 49| 68| 85| |------------------+---+----+-----+----+----| | substr packé | 21| 23| 27| 35| 42| |------------------+---+----+-----+----+----| | Insens. | 20| 43| 64| 92| 120| |------------------+---+----+-----+----+----| | Insens. packé | 26| 29| 32| 38| 47| +-------------------------------------------+
+--------------------------------------------+ | Nombre de lignes | 100 | 1000 | 10K | 100K | |------------------+-----+------+-----+------| | ST | 243 | 314 | 359 | 435 | |------------------+-----+------+-----+------| | Index | 200 | 285 | 323 | 259 | +--------------------------------------------+
Voici la phase de prétraitement (le premier map
exécuté).
@sorted = map ... => sort map CLEF($_) . $_ => @data;
Pour créer une clef de tri et son opérande concaténé, n'importe quelle
combinaison de concaténation, interpolation, pack
ou sprintf
peut être utilisée, les deux derniers avec des formats simples ou
composés.
interpolation simple
pack
avec un format A*
ou An
sprintf
avec un format %s
ou %n.ns
Les petites chaînes peuvent être complémentées avec des octets null
(\0
) grâce aux formats du genre a5
.
D'abord complémenter la chaîne.
$string ^ "\xFF" x length $string
Utiliser les octets null
pour terminer les clefs secondaires de
longueur variable, afin d'assurer l'ordre lexicographique. Si une
chaîne peut contenir un octet null
, alors elle doit être de longueur
fixe. Si toutes les clefs secondaires peuvent contenir un octet null
,
alors toutes les clefs secondaires doivent être de longueur fixe.
Terminer la chaîne avec un octet null, afin de le séparer des clefs secondaires suivantes ou de l'opérande.
interpolation : "$string\0"
pack avec format 'A* x'
ou 'An x'
sprintf avec format "%s\0"
ou "%n.ns\0"
Faire une première passe afin de trouver la longueur maximale d'une chaîne.
my $len = 0; $len < length and $len = length for map CLEF($_) => @data;
Plus subtil et un peu plus rapide :
my $len = ""; $len ^= $_ for map CLEF($_) => @data; $len = length $len;
Puis, complémenter chaque chaîne selon cette longueur (toute chaîne plus courte sera automatiquement complétée avec des octets nulls).
$string ^ "\xFF" x $len
interpolation : "\L$string\E"
pack ou sprintf : lc $string
Prendre l'opposé des valeurs, puis utiliser l'une des formules ci-dessous.
Packer avec le format 'N'
(Préféré : 4 octets, gros boutiste)
sprintf avec le format '%.10u'
ou '%.8x'
(Plus lisible, mais plus long)
Passer en non signés en xor-ant le bit de signe, puis traiter comme les non signés.
$number ^ (1 << 31)
Packer avec le format 'n'
(Préféré : seulement 2 octets, gros boutiste)
sprintf
avec le format '%.5hu'
ou '%.4hx'
(Plus lisible, mais plus long)
Passer en non signés en xor
-ant le bit de signe, puis traiter comme
les non-signés.
$number ^ (1 << 15)
Packer avec le format 'C'
sprintf avec le format '%c'
Packer avec le format 'c'
Prendre l'opposé de la valeur, et utiliser la formule ci-dessous.
Ce code assume que les flottants sont représentés en binaire selon le format IEEE. Créer une fonction qui packe les doubles en ordre réseau (gros-boutiste). Les flottants peuvent être traités de la même manière.
BEGIN { my $gros_boutiste = pack('N', 1) eq pack('L', 1); sub tri_double ($) { ( gros_boutiste ? pack 'd', $_[0] : reverse pack 'd', $_[0] ) ^ ($_[0] < 0 ? "\xFF" x 8 : "\x80" . "\x00" x 7) } }
Cette section concerne la phase de post-traitement (le deuxième map exécuté).
@sorted = map RESTAURE($_) => sort map ... => @data;
Si toutes les clefs secondaires ont une longueur connue, utiliser la longueur totale :
@sorted = map substr($_, $length) => ...
@sorted = map unpack("x$length A*", $_) => ...
Si l'une des clefs secondaires a une longueur non fixe, il faut
s'assurer que le dernier caractère dans la clef de tri complète packée
est un octet null
, puis le rechercher en partant de la droite.
Copyright © Les Mongueurs de Perl, 2001-2011
pour le site.
Les auteurs conservent le copyright de leurs articles.