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}
"):
#include <iostream>
#include <cassert>
// Adjust these parameters based on input size.
const int TRIE_SIZE = 800 * 1000; // Size of the hash table used for the trie.
const int ALPHA = 494441; // An integer that's approximately (0.61803 * TRIE_SIZE), and relatively prime to T = TRIE_SIZE - 52.
const int kTolerance = TRIE_SIZE / 100; // How many places to try, to find a new place for a "family" (=bunch of children).
typedef int32_t Pointer; // [0..TRIE_SIZE), an index into the array of Nodes
typedef int8_t Char; // We only care about 1..26 (plus two values), but there's no "int5_t".
typedef int32_t Count; // The number of times a word has been encountered.
// These are 4 separate arrays in Knuth's implementation.
struct Node {
Pointer link; // From a parent node to its children's "header", or from a header back to parent.
Pointer sibling; // Previous sibling, cyclically. (From smallest child to header, and header to largest child.)
Count count; // The number of times this word has been encountered.
Char ch; // EMPTY, or 1..26, or HEADER. (For nodes with ch=EMPTY, the link/sibling/count fields mean nothing.)
} node[TRIE_SIZE + 1];
// Special values for `ch`: EMPTY (free, can insert child there) and HEADER (start of family).
const Char EMPTY = 0, HEADER = 27;
const Pointer T = TRIE_SIZE - 52;
Pointer x; // The `n`th time we need a node, we'll start trying at x_n = (alpha * n) mod T. This holds current `x_n`.
// A header can only be in T (=TRIE_SIZE-52) positions namely [27..TRIE_SIZE-26].
// This transforms a "h" from range [0..T) to the above range namely [27..T+27).
Pointer rerange(Pointer n) {
n = (n % T) + 27;
// assert(27 <= n && n <= TRIE_SIZE - 26);
return n;
}
// Convert trie node to string, by walking up the trie.
std::string word_for(Pointer p) {
std::string word;
while (p != 0) {
Char c = node[p].ch; // assert(1 <= c && c <= 26);
word = static_cast<char>('a' - 1 + c) + word;
// assert(node[p - c].ch == HEADER);
p = (p - c) ? node[p - c].link : 0;
}
return word;
}
// Increment `x`, and declare `h` (the first position to try) and `last_h` (the last position to try). {§24}
#define PREPARE_X_H_LAST_H x = (x + ALPHA) % T; Pointer h = rerange(x); Pointer last_h = rerange(x + kTolerance);
// Increment `h`, being careful to account for `last_h` and wraparound. {§25}
#define INCR_H { if (h == last_h) { std::cerr << "Hit tolerance limit unfortunately" << std::endl; exit(1); } h = (h == TRIE_SIZE - 26) ? 27 : h + 1; }
// `p` has no children. Create `p`s family of children, with only child `c`. {§27}
Pointer create_child(Pointer p, int8_t c) {
// Find `h` such that there's room for both header and child c.
PREPARE_X_H_LAST_H;
while (!(node[h].ch == EMPTY and node[h + c].ch == EMPTY)) INCR_H;
// Now create the family, with header at h and child at h + c.
node[h] = {.link = p, .sibling = h + c, .count = 0, .ch = HEADER};
node[h + c] = {.link = 0, .sibling = h, .count = 0, .ch = c};
node[p].link = h;
return h + c;
}
// Move `p`'s family of children to a place where child `c` will also fit. {§29}
void move_family_for(const Pointer p, Char c) {
// Part 1: Find such a place: need room for `c` and also all existing children. {§31}
PREPARE_X_H_LAST_H;
while (true) {
INCR_H;
if (node[h + c].ch != EMPTY) continue;
Pointer r = node[p].link;
int delta = h - r; // We'd like to move each child by `delta`
while (node[r + delta].ch == EMPTY and node[r].sibling != node[p].link) {
r = node[r].sibling;
}
if (node[r + delta].ch == EMPTY) break; // There's now space for everyone.
}
// Part 2: Now actually move the whole family to start at the new `h`.
Pointer r = node[p].link;
int delta = h - r;
do {
Pointer sibling = node[r].sibling;
// Move node from current position (r) to new position (r + delta), and free up old position (r).
node[r + delta] = {.ch = node[r].ch, .count = node[r].count, .link = node[r].link, .sibling = node[r].sibling + delta};
if (node[r].link != 0) node[node[r].link].link = r + delta;
node[r].ch = EMPTY;
r = sibling;
} while (node[r].ch != EMPTY);
}
// Advance `p` to its `c`th child. If necessary, add the child, or even move `p`'s family. {§21}
Pointer find_child(Pointer p, Char c) {
// assert(1 <= c && c <= 26);
if (p == 0) return c; // Special case for first char.
if (node[p].link == 0) return create_child(p, c); // If `p` currently has *no* children.
Pointer q = node[p].link + c;
if (node[q].ch == c) return q; // Easiest case: `p` already has a `c`th child.
// Make sure we have room to insert a `c`th child for `p`, by moving its family if necessary.
if (node[q].ch != EMPTY) {
move_family_for(p, c);
q = node[p].link + c;
}
// Insert child `c` into `p`'s family of children (at `q`), with correct siblings. {§28}
Pointer h = node[p].link;
while (node[h].sibling > q) h = node[h].sibling;
node[q] = {.ch = c, .count = 0, .link = 0, .sibling = node[h].sibling};
node[h].sibling = q;
return q;
}
// Largest descendant. {§18}
Pointer last_suffix(Pointer p) {
while (node[p].link != 0) p = node[node[p].link].sibling;
return p;
}
// The largest count beyond which we'll put all words in the same (last) bucket.
// We do an insertion sort (potentially slow) in last bucket, so increase this if the program takes a long time to walk trie.
const int MAX_BUCKET = 10000;
Pointer sorted[MAX_BUCKET + 1]; // The head of each list.
// Records the count `n` of `p`, by inserting `p` in the list that starts at `sorted[n]`.
// Overwrites the value of node[p].sibling (uses the field to mean its successor in the `sorted` list).
void record_count(Pointer p) {
// assert(node[p].ch != HEADER);
// assert(node[p].ch != EMPTY);
Count f = node[p].count;
if (f == 0) return;
if (f < MAX_BUCKET) {
// Insert at head of list.
node[p].sibling = sorted[f];
sorted[f] = p;
} else {
Pointer r = sorted[MAX_BUCKET];
if (node[p].count >= node[r].count) {
// Insert at head of list
node[p].sibling = r;
sorted[MAX_BUCKET] = p;
} else {
// Find right place by count. This step can be SLOW if there are too many words with count >= MAX_BUCKET
while (node[p].count < node[node[r].sibling].count) r = node[r].sibling;
node[p].sibling = node[r].sibling;
node[r].sibling = p;
}
}
}
// Walk the trie, going over all words in reverse-alphabetical order. {§37}
// Calls "record_count" for each word found.
void walk_trie() {
// assert(node[0].ch == HEADER);
Pointer p = node[0].sibling;
while (p != 0) {
Pointer q = node[p].sibling; // Saving this, as `record_count(p)` will overwrite it.
record_count(p);
// Move down to last descendant of `q` if any, else up to parent of `q`.
p = (node[q].ch == HEADER) ? node[q].link : last_suffix(q);
}
}
int main(int, char** argv) {
// Program startup
std::ios::sync_with_stdio(false);
// Set initial values {§19}
for (Char i = 1; i <= 26; ++i) node[i] = {.ch = i, .count = 0, .link = 0, .sibling = i - 1};
node[0] = {.ch = HEADER, .count = 0, .link = 0, .sibling = 26};
// read in file contents
FILE *fptr = fopen(argv[1], "rb");
fseek(fptr, 0L, SEEK_END);
long dataLength = ftell(fptr);
rewind(fptr);
char* data = (char*)malloc(dataLength);
fread(data, 1, dataLength, fptr);
if (fptr) fclose(fptr);
// Loop over file contents: the bulk of the time is spent here.
Pointer p = 0;
for (int i = 0; i < dataLength; ++i) {
Char c = (data[i] | 32) - 'a' + 1; // 1 to 26, for 'a' to 'z' or 'A' to 'Z'
if (1 <= c && c <= 26) {
p = find_child(p, c);
} else {
++node[p].count;
p = 0;
}
}
node[0].count = 0;
walk_trie();
const int max_words_to_print = atoi(argv[2]);
int num_printed = 0;
for (Count f = MAX_BUCKET; f >= 0 && num_printed <= max_words_to_print; --f) {
for (Pointer p = sorted[f]; p != 0 && num_printed < max_words_to_print; p = node[p].sibling) {
std::cout << word_for(p) << " " << node[p].count << std::endl;
++num_printed;
}
}
return 0;
}
Différences par rapport au programme de Knuth:
- I combiné 4 tableaux de Knuth
link
, sibling
, count
et ch
dans un tableau d'unstruct Node
(est plus facile de comprendre cette façon).
- J'ai changé la transcription textuelle de programmation littéraire (style WEB) des sections en appels de fonction plus conventionnels (et quelques macros).
- Nous n'avons pas besoin d'utiliser les conventions / restrictions d'E / S étranges standard de Pascal, donc utiliser
fread
etdata[i] | 32 - 'a'
comme dans les autres réponses ici, au lieu de la solution de contournement de Pascal.
- Dans le cas où nous dépassons les limites (à court d'espace) pendant que le programme est en cours d'exécution, le programme original de Knuth le traite avec grâce en supprimant les mots ultérieurs et en imprimant un message à la fin. (Il n'est pas tout à fait juste de dire que McIlroy "a critiqué la solution de Knuth comme ne pouvant même pas traiter un texte intégral de la Bible"; il faisait seulement remarquer que parfois des mots fréquents peuvent se produire très tard dans un texte, comme le mot "Jésus "dans la Bible, donc la condition d'erreur n'est pas inoffensive.) J'ai pris l'approche la plus bruyante (et de toute façon plus facile) de simplement mettre fin au programme.
- Le programme déclare une constante TRIE_SIZE pour contrôler l'utilisation de la mémoire, que j'ai augmentée. (La constante de 32767 avait été choisie pour les exigences d'origine - "un utilisateur devrait être capable de trouver les 100 mots les plus fréquents dans un document technique de vingt pages (environ un fichier de 50 Ko)" et parce que Pascal gère bien les nombres entiers à distance les taper et les emballer de manière optimale. Nous avons dû l'augmenter de 25x à 800 000, car l'entrée de test est maintenant 20 millions de fois plus importante.)
- Pour l'impression finale des chaînes, nous pouvons simplement parcourir le trie et faire un ajout de chaîne stupide (peut-être même quadratique).
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
clang++ -std=c++17 -O2 ptrie-walktrie.cc
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:
target/release/frequent: 4.809 ± 0.263 [ 4.45.. 5.62] [... 4.63 ... 4.75 ... 4.88...]
ptrie-walktrie: 4.547 ± 0.164 [ 4.35.. 4.99] [... 4.42 ... 4.5 ... 4.68...]
(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 :
Une flamme sur les pointeurs 64 bits
Il est absolument idiot d'avoir des pointeurs 64 bits lorsque je compile un programme qui utilise moins de 4 gigaoctets de RAM. Lorsque de telles valeurs de pointeur apparaissent à l'intérieur d'une structure, elles gaspillent non seulement la moitié de la mémoire, mais elles jettent en fait la moitié du cache.
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.:
#include <iostream>
#include <cassert>
#include <vector>
#include <algorithm>
typedef int32_t Pointer; // [0..node.size()), an index into the array of Nodes
typedef int32_t Count;
typedef int8_t Char; // We'll usually just have 1 to 26.
struct Node {
Pointer link; // From a parent node to its children's "header", or from a header back to parent.
Count count; // The number of times this word has been encountered. Undefined for header nodes.
};
std::vector<Node> node; // Our "arena" for Node allocation.
std::string word_for(Pointer p) {
std::vector<char> drow; // The word backwards
while (p != 0) {
Char c = p % 27;
drow.push_back('a' - 1 + c);
p = (p - c) ? node[p - c].link : 0;
}
return std::string(drow.rbegin(), drow.rend());
}
// `p` has no children. Create `p`s family of children, with only child `c`.
Pointer create_child(Pointer p, Char c) {
Pointer h = node.size();
node.resize(node.size() + 27);
node[h] = {.link = p, .count = -1};
node[p].link = h;
return h + c;
}
// Advance `p` to its `c`th child. If necessary, add the child.
Pointer find_child(Pointer p, Char c) {
assert(1 <= c && c <= 26);
if (p == 0) return c; // Special case for first char.
if (node[p].link == 0) return create_child(p, c); // Case 1: `p` currently has *no* children.
return node[p].link + c; // Case 2 (easiest case): Already have the child c.
}
int main(int, char** argv) {
auto start_c = std::clock();
// Program startup
std::ios::sync_with_stdio(false);
// read in file contents
FILE *fptr = fopen(argv[1], "rb");
fseek(fptr, 0, SEEK_END);
long dataLength = ftell(fptr);
rewind(fptr);
char* data = (char*)malloc(dataLength);
fread(data, 1, dataLength, fptr);
fclose(fptr);
node.reserve(dataLength / 600); // Heuristic based on test data. OK to be wrong.
node.push_back({0, 0});
for (Char i = 1; i <= 26; ++i) node.push_back({0, 0});
// Loop over file contents: the bulk of the time is spent here.
Pointer p = 0;
for (long i = 0; i < dataLength; ++i) {
Char c = (data[i] | 32) - 'a' + 1; // 1 to 26, for 'a' to 'z' or 'A' to 'Z'
if (1 <= c && c <= 26) {
p = find_child(p, c);
} else {
++node[p].count;
p = 0;
}
}
++node[p].count;
node[0].count = 0;
// Brute-force: Accumulate all words and their counts, then sort by frequency and print.
std::vector<std::pair<int, std::string>> counts_words;
for (Pointer i = 1; i < static_cast<Pointer>(node.size()); ++i) {
int count = node[i].count;
if (count == 0 || i % 27 == 0) continue;
counts_words.push_back({count, word_for(i)});
}
auto cmp = [](auto x, auto y) {
if (x.first != y.first) return x.first > y.first;
return x.second < y.second;
};
std::sort(counts_words.begin(), counts_words.end(), cmp);
const int max_words_to_print = std::min<int>(counts_words.size(), atoi(argv[2]));
for (int i = 0; i < max_words_to_print; ++i) {
auto [count, word] = counts_words[i];
std::cout << word << " " << count << std::endl;
}
return 0;
}
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:
target/release/frequent: 4.809 ± 0.263 [ 4.45.. 5.62] [... 4.63 ... 4.75 ... 4.88...]
ptrie-walktrie: 4.547 ± 0.164 [ 4.35.. 4.99] [... 4.42 ... 4.5 ... 4.68...]
itrie-nolimit: 3.907 ± 0.127 [ 3.69.. 4.23] [... 3.81 ... 3.9 ... 4.0...]
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. :-)