C'est peut-être l'un des défis de codage classiques qui ont trouvé un écho en 1986, lorsque le chroniqueur Jon Bentley a demandé à Donald Knuth d'écrire un programme qui trouverait k mots les plus fréquents dans un fichier. Knuth a implémenté une solution rapide utilisant des tentatives de hachage dans un programme de 8 pages pour illustrer sa technique de programmation lettrée. Douglas McIlroy de Bell Labs a critiqué la solution de Knuth comme ne pouvant même pas traiter un texte intégral de la Bible, et a répondu avec une seule ligne, ce n'est pas aussi rapide, mais fait le travail:
tr -cs A-Za-z '\n' | tr A-Z a-z | sort | uniq -c | sort -rn | sed 10q
En 1987, un article de suivi été publié avec encore une autre solution, cette fois par un professeur de Princeton. Mais cela ne pouvait même pas retourner le résultat d'une seule Bible!
Description du problème
Description originale du problème:
Étant donné un fichier texte et un entier k, vous devez imprimer les k mots les plus courants dans le fichier (et le nombre de leurs occurrences) en fréquence décroissante.
Clarifications supplémentaires sur les problèmes:
- Knuth a défini un mot comme une chaîne de lettres latines:
[A-Za-z]+
- tous les autres personnages sont ignorés
- les lettres majuscules et minuscules sont considérées comme équivalentes (
WoRd
==word
) - pas de limite de taille de fichier ni de longueur de mot
- les distances entre les mots consécutifs peuvent être arbitrairement grandes
- le programme le plus rapide est celui qui utilise le moins de temps CPU total (le multithreading n'aidera probablement pas)
Exemples de cas de test
Test 1: Ulysse de James Joyce concaténé 64 fois (fichier de 96 Mo).
- Téléchargez Ulysse depuis le projet Gutenberg:
wget http://www.gutenberg.org/files/4300/4300-0.txt
- Concaténer 64 fois:
for i in {1..64}; do cat 4300-0.txt >> ulysses64; done
- Le mot le plus fréquent est «le» avec 968832 apparitions.
Test 2: texte aléatoire spécialement généré giganovel
(environ 1 Go).
- Script du générateur Python 3 ici .
- Le texte contient 148391 mots distincts apparaissant de manière similaire aux langues naturelles.
- Mots les plus fréquents: «e» (11309 apparitions) et «ihit» (11290 apparitions).
Test de généralité: mots arbitrairement grands avec des écarts arbitrairement grands.
Implémentations de référence
Après avoir examiné le code Rosetta pour ce problème et réalisé que de nombreuses implémentations sont incroyablement lentes (plus lentes que le script shell!), J'ai testé quelques bonnes implémentations ici . Vous trouverez ci-dessous les performances ulysses64
ainsi que la complexité du temps:
ulysses64 Time complexity
C++ (prefix trie + heap) 4.145 O((N + k) log k)
Python (Counter) 10.547 O(N + k log Q)
AWK + sort 20.606 O(N + Q log Q)
McIlroy (tr + sort + uniq) 43.554 O(N log N)
Pouvez-vous battre ça?
Essai
Les performances seront évaluées à l'aide du MacBook Pro 13 pouces 2017 avec le standard Unix time
commande (heure "utilisateur"). Si possible, utilisez des compilateurs modernes (par exemple, utilisez la dernière version de Haskell, pas la version héritée).
Classements jusqu'à présent
Horaires, y compris les programmes de référence:
k=10 k=100K
ulysses64 giganovel giganovel
C++ (trie) by ShreevatsaR 0.671 4.227 4.276
C (trie + bins) by Moogie 0.704 9.568 9.459
C (trie + list) by Moogie 0.767 6.051 82.306
C++ (hash trie) by ShreevatsaR 0.788 5.283 5.390
C (trie + sorted list) by Moogie 0.804 7.076 x
Rust (trie) by Anders Kaseorg 0.842 6.932 7.503
J by miles 1.273 22.365 22.637
C# (trie) by recursive 3.722 25.378 24.771
C++ (trie + heap) 4.145 42.631 72.138
APL (Dyalog Unicode) by Adám 7.680 x x
Python (dict) by movatica 9.387 99.118 100.859
Python (Counter) 10.547 102.822 103.930
Ruby (tally) by daniero 15.139 171.095 171.551
AWK + sort 20.606 213.366 222.782
McIlroy (tr + sort + uniq) 43.554 715.602 750.420
Classement cumulatif * (%, meilleur score possible - 300):
# Program Score Generality
1 C++ (trie) by ShreevatsaR 300 Yes
2 C++ (hash trie) by ShreevatsaR 368 x
3 Rust (trie) by Anders Kaseorg 465 Yes
4 C (trie + bins) by Moogie 552 x
5 J by miles 1248 Yes
6 C# (trie) by recursive 1734 x
7 C (trie + list) by Moogie 2182 x
8 C++ (trie + heap) 3313 x
9 Python (dict) by movatica 6103 Yes
10 Python (Counter) 6435 Yes
11 Ruby (tally) by daniero 10316 Yes
12 AWK + sort 13329 Yes
13 McIlroy (tr + sort + uniq) 40970 Yes
* Somme des performances temporelles par rapport aux meilleurs programmes dans chacun des trois tests.
Meilleur programme à ce jour: ici (deuxième solution)
la source
Réponses:
[C]
Ce qui suit s'exécute en moins de 1,6 seconde pour le test 1 sur mon 2.8 Ghz Xeon W3530. Construit à l'aide de MinGW.org GCC-6.3.0-1 sur Windows 7:
Il prend deux arguments en entrée (chemin vers le fichier texte et pour k nombre de mots les plus fréquents à lister)
Il crée simplement un arbre ramifié sur des lettres de mots, puis au niveau des lettres foliaires, il incrémente un compteur. Vérifie ensuite si le compteur de feuilles actuel est supérieur au plus petit mot le plus fréquent dans la liste des mots les plus fréquents. (la taille de la liste est le nombre déterminé via l'argument de la ligne de commande) Si c'est le cas, promouvez le mot représenté par la lettre feuille comme l'un des plus fréquents. Tout cela se répète jusqu'à ce qu'aucune autre lettre ne soit lue. Après quoi la liste des mots les plus fréquents est sortie via une recherche itérative inefficace du mot le plus fréquent dans la liste des mots les plus fréquents.
Par défaut, il affiche actuellement le temps de traitement, mais à des fins de cohérence avec les autres soumissions, désactivez la définition de TIMING dans le code source.
De plus, je l'ai soumis à partir d'un ordinateur de travail et je n'ai pas pu télécharger le texte du test 2. Il devrait fonctionner avec ce test 2 sans modification, mais la valeur MAX_LETTER_INSTANCES devra peut-être être augmentée.
Pour le test 1, et pour les 10 premiers mots fréquents et avec le temps activé, il devrait imprimer:
la source
letters
tableau, tandis que l'implémentation de référence alloue dynamiquement les nœuds d'arbre.mmap
-ment devrait être plus rapide (~ 5% sur mon ordinateur portable linux):#include<sys/mman.h>
,<sys/stat.h>
,<fcntl.h>
, remplacer la lecture du fichier avecint d=open(argv[1],0);struct stat s;fstat(d,&s);dataLength=s.st_size;data=mmap(0,dataLength,1,1,d,0);
et commentezfree(data);
Rouille
Sur mon ordinateur, cela exécute giganovel 100000 environ 42% plus rapidement (10,64 s contre 18,24 s) que la solution C de Moogie «préfixe d'arbre + bacs». De plus, il n'a pas de limites prédéfinies (contrairement à la solution C qui prédéfinit les limites sur la longueur des mots, les mots uniques, les mots répétés, etc.).
src/main.rs
Cargo.toml
Usage
la source
-O3
et-Ofast
ne fait pas de différence mesurable.gcc -O3 -march=native -mtune=native program.c
.APL (Dyalog Unicode)
Les éléments suivants s'exécutent en moins de 8 secondes sur mon i7-4720HQ 2,6 GHz utilisant Dyalog APL 17.0 64 bits sous Windows 10:
Il demande d'abord le nom du fichier, puis k. Notez qu'une partie importante du temps d'exécution (environ 1 seconde) ne fait que lire le fichier.
Pour le chronométrer, vous devriez pouvoir diriger ce qui suit dans votre
dyalog
exécutable (pour les dix mots les plus fréquents):Il devrait imprimer:
la source
export MAXWS=4096M
. Je suppose qu'il utilise des tables de hachage? Parce que réduire la taille de l'espace de travail à 2 Go le ralentit de 2 secondes entières.∊
utilise une table de hachage selon cela , et je suis sûr que c'est le⌸
cas également en interne.WS FULL
, même si j'ai augmenté l'espace de travail à 6 Go.[C] Arbre de préfixe + bacs
NOTE: Le compilateur utilisé a un effet significatif sur la vitesse d'exécution du programme! J'ai utilisé gcc (MinGW.org GCC-8.2.0-3) 8.2.0. Lorsque vous utilisez lecommutateur -Ofast , le programme s'exécute presque 50% plus rapidement que le programme normalement compilé.
Complexité de l'algorithme
Depuis, je me rends compte que le tri des bacs que j'effectue est une forme de tri Pigeonhost, ce qui signifie que je peux déterminer la complexité Big O de cette solution.
Je le calcule:
La complexité de la construction de l'arbre est équivalente à la traversée de l'arbre, car à n'importe quel niveau le nœud correct à parcourir est O (1) (car chaque lettre est mappée directement sur un nœud et nous ne traversons toujours qu'un niveau de l'arbre pour chaque lettre)
Le tri des trous de pigeon est O (N + n) où n est la plage de valeurs clés, mais pour ce problème, nous n'avons pas besoin de trier toutes les valeurs, uniquement le nombre k, le pire des cas serait donc O (N + k).
La combinaison des deux donne O (1 + N + k).
La complexité spatiale pour la construction d'arbres est due au fait que le pire des cas est de 26 * M nœuds si les données se composent d'un mot avec M nombre de lettres et que chaque nœud a 26 nœuds (c'est-à-dire pour les lettres de l'alphabet). Ainsi O (26 * M) = O (M)
Pour le Pigeon Hole, le tri a une complexité spatiale de O (N + n)
La combinaison des rendements donne O (26 * M + N + n) = O (M + N + n)
Algorithme
Il prend deux arguments en entrée (chemin vers le fichier texte et pour k nombre de mots les plus fréquents à lister)
Sur la base de mes autres entrées, cette version a une très petite rampe de coût en temps avec des valeurs croissantes de k par rapport à mes autres solutions. Mais est sensiblement plus lent pour les faibles valeurs de k, mais il devrait être beaucoup plus rapide pour les grandes valeurs de k.
Il crée un arbre ramifié sur des lettres de mots, puis au niveau des lettres foliaires, il incrémente un compteur. Ajoute ensuite le mot à un groupe de mots de la même taille (après avoir d'abord supprimé le mot du groupe, il résidait déjà). Tout cela se répète jusqu'à ce qu'aucune autre lettre ne soit lue. Après quoi, les bacs sont inversés k fois à partir du plus grand bac et les mots de chaque bac sont sortis.
Par défaut, il affiche actuellement le temps de traitement, mais à des fins de cohérence avec les autres soumissions, désactivez la définition de TIMING dans le code source.
EDIT: report maintenant le remplissage des bacs jusqu'à la construction de l'arbre et l'optimisation de la construction de la sortie.
EDIT2: utilise désormais l'arithmétique des pointeurs au lieu de l'accès aux tableaux pour optimiser la vitesse.
la source
ulysses64
une fois, c'est donc un leader actuel.J
Exécuter en tant que script avec
jconsole <script> <input> <k>
. Par exemple, la sortie dugiganovel
aveck=100K
:Il n'y a pas de limite, sauf pour la quantité de mémoire système disponible.
la source
...
se produit en raison de la troncature de sortie par ligne. J'ai ajouté une ligne au début pour désactiver toute troncature. Il ralentit sur giganovel car il utilise beaucoup plus de mémoire car il y a plus de mots uniques.C ++ (à la Knuth)
J'étais curieux de savoir comment s'en sortirait le programme de Knuth, alors j'ai traduit son programme (à l'origine Pascal) en C ++.
Même si l'objectif principal de Knuth n'était pas la vitesse, mais pour illustrer son système WEB de programmation alphabétisée, le programme est étonnamment compétitif et conduit à une solution plus rapide que toutes les réponses jusqu'ici. Voici ma traduction de son programme (les numéros de "section" correspondants du programme WEB sont mentionnés dans des commentaires comme "
{§24}
"):Différences par rapport au programme de Knuth:
link
,sibling
,count
etch
dans un tableau d'unstruct Node
(est plus facile de comprendre cette façon).fread
etdata[i] | 32 - 'a'
comme dans les autres réponses ici, au lieu de la solution de contournement de Pascal.En dehors de cela, c'est à peu près exactement le programme de Knuth (en utilisant sa structure de données de tri haché / trié et le tri de seau), et fait à peu près les mêmes opérations (comme le ferait le programme Pascal de Knuth) tout en parcourant tous les caractères en entrée; notez qu'il n'utilise aucun algorithme externe ni bibliothèque de structure de données, et que les mots de même fréquence seront imprimés par ordre alphabétique.
Horaire
Compilé avec
Lorsqu'il est exécuté sur le plus grand testcase ici (
giganovel
avec 100 000 mots demandés), et comparé au programme le plus rapide publié ici jusqu'à présent, je le trouve légèrement mais toujours plus rapide:(La ligne du haut est la solution Rust d'Anders Kaseorg; la partie inférieure est le programme ci-dessus. Il s'agit des chronométrages de 100 exécutions, avec moyenne, min, max, médiane et quartiles.)
Une analyse
Pourquoi est-ce plus rapide? Ce n'est pas que C ++ est plus rapide que Rust, ni que le programme de Knuth est le plus rapide possible - en fait, le programme de Knuth est plus lent sur les insertions (comme il le mentionne) à cause du tri-pack (pour conserver la mémoire). Je soupçonne que la raison est liée à quelque chose dont Knuth s'est plaint en 2008 :
Le programme ci-dessus utilise des index de tableau 32 bits (pas des pointeurs 64 bits), donc la structure "Node" occupe moins de mémoire, donc il y a plus de Nodes sur la pile et moins de ratés de cache. (En fait, il y a eu un peu de travail à ce sujet en tant qu'ABI x32 , mais il ne semble pas être en bon état même si l'idée est évidemment utile, par exemple, voir l' annonce récente de la compression du pointeur dans V8 . Oh bien.)
giganovel
, ce programme utilise 12,8 Mo pour le trie (compressé), contre 32,18 Mo du programme Rust pour son trie (activégiganovel
). Nous pourrions passer à l'échelle 1000x (de "giganovel" à "teranovel" par exemple) et ne pas dépasser les indices 32 bits, cela semble donc un choix raisonnable.Variante plus rapide
Nous pouvons optimiser la vitesse et renoncer à l'emballage, de sorte que nous pouvons réellement utiliser le trie (non emballé) comme dans la solution Rust, avec des indices au lieu de pointeurs. Cela donne quelque chose de plus rapide et n'a pas de limites prédéfinies sur le nombre de mots, de caractères, etc.:
Ce programme, en dépit de faire quelque chose de beaucoup plus stupide pour le tri que les solutions ici, utilise (pour
giganovel
) seulement 12,2 Mo pour son tri et parvient à être plus rapide. Horaires de ce programme (dernière ligne), par rapport aux horaires précédents mentionnés:Je serais impatient de voir ce que cela (ou le programme de hachage) voudrait s'il était traduit en rouille . :-)
Plus de détails
À propos de la structure de données utilisée ici: une explication des essais de "compactage" est donnée sommairement dans l'exercice 4 de la section 6.3 (Recherche numérique, c'est-à-dire essais) dans le volume 3 de TAOCP, ainsi que dans la thèse de Frank Liang, étudiant de Knuth, sur la césure dans TeX : Word Hy-phen-a-tion par Com-put-er .
Le contexte des colonnes de Bentley, du programme de Knuth et de la revue de McIlroy (dont une petite partie seulement concernait la philosophie Unix) est plus clair à la lumière des colonnes précédentes et ultérieures , et de l'expérience antérieure de Knuth, y compris les compilateurs, TAOCP et TeX.
Il y a un livre entier Exercices in Programming Style , montrant différentes approches de ce programme particulier, etc.
J'ai un article de blog inachevé expliquant les points ci-dessus; pourrait modifier cette réponse une fois terminée. En attendant, postez cette réponse ici de toute façon, à l'occasion (10 janvier) de l'anniversaire de Knuth. :-)
la source
Segmentation fault: 11
des cas de test avec des mots et des lacunes arbitrairement grands; 2) même s'il me semble que je sympathise avec la «critique» de McIlroy, je suis bien conscient que l'intention de Knuth n'était que de montrer sa technique de programmation lettrée, tandis que McIlroy la critiquait du point de vue de l'ingénierie. McIlroy lui-même a admis plus tard que ce n'était pas une chose juste à faire.word_for
; corrigé maintenant. Oui, McIlroy, en tant qu'inventeur des tuyaux Unix, en a profité pour évangéliser la philosophie Unix de la composition de petits outils. C'est une bonne philosophie, comparée à l'approche monolithique frustrante de Knuth (si vous essayez de lire ses programmes), mais dans le contexte, c'était un peu injuste, également pour une autre raison: aujourd'hui, la méthode Unix est largement disponible, mais en 1986, elle était confinée Bell Labs, Berkeley, etc. ( « son entreprise fait les meilleurs prefabs dans l'entreprise »)Python 3
Cette implémentation avec un dictionnaire simple est légèrement plus rapide que celle qui en utilise
Counter
un sur mon système.la source
heapq
n'ajoute aucune performance à laCounter.most_common
méthode ouenumerate(sorted(...))
utilise également enheapq
interne.Counter.most_common
.[C] Arborescence des préfixes + liste des liens triés
Il prend deux arguments en entrée (chemin vers le fichier texte et pour k nombre de mots les plus fréquents à lister)
Sur la base de mon autre entrée, cette version est beaucoup plus rapide pour les plus grandes valeurs de k mais à un coût de performance mineur à des valeurs de k plus faibles.
Il crée un arbre ramifié sur des lettres de mots, puis au niveau des lettres foliaires, il incrémente un compteur. Vérifie ensuite si le compteur de feuilles actuel est supérieur au plus petit mot le plus fréquent dans la liste des mots les plus fréquents. (la taille de la liste est le nombre déterminé via l'argument de la ligne de commande) Si c'est le cas, promouvez le mot représenté par la lettre feuille comme l'un des plus fréquents. S'il s'agit déjà d'un mot le plus fréquent, remplacez-le par le suivant le plus fréquent si le nombre de mots est désormais plus élevé, ce qui permet de maintenir la liste triée. Tout cela se répète jusqu'à ce qu'aucune autre lettre ne soit lue. Après quoi la liste des mots les plus fréquents est sortie.
Par défaut, il affiche actuellement le temps de traitement, mais à des fins de cohérence avec les autres soumissions, désactivez la définition de TIMING dans le code source.
la source
12 eroilk 111 iennoa 10 yttelen 110 engyt
.C #
Celui-ci devrait fonctionner avec les derniers SDK .net .
Voici un exemple de sortie.
Au début, j'ai essayé d'utiliser un dictionnaire avec des clés de chaîne, mais c'était beaucoup trop lent. Je pense que c'est parce que les chaînes .net sont représentées en interne avec un codage à 2 octets, ce qui est un peu inutile pour cette application. Alors, je suis juste passé à des octets purs et à une horrible machine d'état de style goto. La conversion de casse est un opérateur au niveau du bit. La vérification de la plage de caractères se fait en une seule comparaison après soustraction. Je n'ai consacré aucun effort à optimiser le tri final, car j'ai constaté qu'il utilisait moins de 0,1% du temps d'exécution.
Corrigé: L'algorithme était essentiellement correct, mais il surestimait le nombre total de mots, en comptant tous les préfixes de mots. Étant donné que le nombre total de mots n'est pas une exigence du problème, j'ai supprimé cette sortie. Afin d'afficher tous les k mots, j'ai également ajusté la sortie. J'ai finalement décidé d'utiliser
string.Join()
puis d'écrire la liste entière à la fois. Étonnamment, c'est environ une seconde plus rapide sur ma machine que d'écrire chaque mot séparément pour 100k.la source
tolower
astuces de comparaison au niveau du bit et unique. Cependant, je ne comprends pas pourquoi votre programme rapporte des mots plus distincts que prévu. De plus, selon la description originale du problème, le programme doit sortir tous les k mots dans un ordre décroissant de fréquence, donc je n'ai pas compté votre programme pour le dernier test, qui doit sortir 100 000 mots les plus fréquents.Ruby 2.7.0-preview1 avec
tally
La dernière version de Ruby a une nouvelle méthode appelée
tally
. D'après les notes de version :Cela résout presque toute la tâche pour nous. Nous avons juste besoin de lire le fichier en premier et de trouver le maximum plus tard.
Voici le tout:
edit: ajouté
k
comme argument de ligne de commandeIl peut être exécuté avec
ruby k filename.rb input.txt
la version 2.7.0-preview1 de Ruby. Il peut être téléchargé à partir de divers liens sur la page des notes de publication ou installé avec rbenv à l'aide derbenv install 2.7.0-dev
.Exemple exécuté sur mon propre vieil ordinateur usé:
la source