C ++ (a la Knuth)
Tenía curiosidad por saber cómo le iría al programa de Knuth, así que traduje su programa (originalmente Pascal) a C ++.
Aunque el objetivo principal de Knuth no era la velocidad, sino ilustrar su sistema WEB de programación alfabetizada, el programa es sorprendentemente competitivo y lleva a una solución más rápida que cualquiera de las respuestas hasta ahora. Aquí está mi traducción de su programa (los números de "sección" correspondientes del programa WEB se mencionan en comentarios como " {§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;
}
Diferencias con el programa de Knuth:
- Combiné 4 matrices de Knuth
link
, sibling
, count
y ch
en una matriz de un struct Node
(resulta más fácil de entender de esta manera).
- Cambié la transclusión textual de secciones de programación alfabetizada (estilo WEB) en llamadas a funciones más convencionales (y un par de macros).
- No necesitamos usar las convenciones / restricciones extrañas de E / S estándar de Pascal, por lo tanto, use
fread
y data[i] | 32 - 'a'
como en las otras respuestas aquí, en lugar de la solución alternativa de Pascal.
- En caso de que excedamos los límites (sin espacio) mientras el programa se está ejecutando, el programa original de Knuth lo trata con gracia al soltar las palabras posteriores e imprimir un mensaje al final. (No es correcto decir que McIlroy "criticó la solución de Knuth porque ni siquiera podía procesar un texto completo de la Biblia"; solo estaba señalando que a veces pueden aparecer palabras frecuentes muy tarde en un texto, como la palabra "Jesús "en la Biblia, por lo que la condición de error no es inocuo.) He tomado el enfoque más ruidoso (y de todos modos más fácil) de simplemente terminar el programa.
- El programa declara un TRIE_SIZE constante para controlar el uso de la memoria, que incrementé. (La constante de 32767 había sido elegida para los requisitos originales: "un usuario debería poder encontrar las 100 palabras más frecuentes en un documento técnico de veinte páginas (aproximadamente un archivo de 50K bytes)" y porque Pascal trata bien con el entero a rango los escribe y los empaqueta de manera óptima. Tuvimos que aumentarlo 25x a 800,000 ya que la entrada de prueba ahora es 20 millones de veces más grande).
- Para la impresión final de las cadenas, podemos caminar por el trie y hacer un agregado de cadena tonto (posiblemente incluso cuadrático).
Aparte de eso, este es más o menos exactamente el programa de Knuth (usando su estructura de datos hash trie / empaquetado trie y tipo de cubeta), y realiza prácticamente las mismas operaciones (como lo haría el programa Knuth's Pascal) mientras recorre todos los caracteres en la entrada; tenga en cuenta que no utiliza algoritmos externos o bibliotecas de estructura de datos, y también que las palabras de igual frecuencia se imprimirán en orden alfabético.
Sincronización
Compilado con
clang++ -std=c++17 -O2 ptrie-walktrie.cc
Cuando se ejecuta en el caso de prueba más grande aquí ( giganovel
con 100,000 palabras solicitadas), y se compara con el programa más rápido publicado aquí hasta ahora, lo encuentro un poco, pero consistentemente más rápido:
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 línea superior es la solución Rust de Anders Kaseorg; la inferior es el programa anterior. Estos son tiempos de 100 ejecuciones, con medias, mínimas, máximas, medianas y cuartiles).
Análisis
¿Por qué es esto más rápido? No es que C ++ sea más rápido que Rust, o que el programa de Knuth sea el más rápido posible; de hecho, el programa de Knuth es más lento en las inserciones (como él menciona) debido al empaquetado de trie (para conservar la memoria). Sospecho que la razón está relacionada con algo de lo que Knuth se quejó en 2008 :
Una llama sobre punteros de 64 bits
Es absolutamente idiota tener punteros de 64 bits cuando compilo un programa que usa menos de 4 gigabytes de RAM. Cuando tales valores de puntero aparecen dentro de una estructura, no solo desperdician la mitad de la memoria, sino que también desechan la mitad de la memoria caché.
El programa anterior utiliza índices de matriz de 32 bits (no punteros de 64 bits), por lo que la estructura "Nodo" ocupa menos memoria, por lo que hay más nodos en la pila y menos errores de caché. (De hecho, hubo algo de trabajo en esto como el x32 ABI , pero parece que no está en buen estado a pesar de que la idea es obviamente útil, por ejemplo, vea el reciente anuncio de compresión de puntero en V8 . Oh, bueno). giganovel
, este programa usa 12.8 MB para el trie (empaquetado), en comparación con los 32.18MB del programa Rust para su trie (activado giganovel
). Podríamos escalar 1000x (desde "giganovel" hasta "teranovel", por ejemplo) y aún no superar los índices de 32 bits, por lo que esta parece una opción razonable.
Variante más rápida
Podemos optimizar la velocidad y renunciar al empaque, por lo que en realidad podemos usar el trie (no empaquetado) como en la solución Rust, con índices en lugar de punteros. Esto da algo que es más rápido y no tiene límites preestablecidos en el número de palabras distintas, caracteres, 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;
}
Este programa, a pesar de hacer algo mucho más difícil de ordenar que las soluciones aquí, utiliza (para giganovel
) solo 12.2MB para su trie y logra ser más rápido. Tiempos de este programa (última línea), en comparación con los tiempos anteriores mencionados:
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...]
Estaría ansioso por ver qué le gustaría a este (o al programa hash-trie) si se tradujera a Rust . :-)
Más detalles
Acerca de la estructura de datos utilizada aquí: en el ejercicio 4 de la Sección 6.3 (Búsqueda digital, es decir, intentos) en el Volumen 3 de TAOCP, y en la tesis del alumno de Knuth Frank Liang sobre la separación silábica en TeX, se ofrece una explicación de los intentos de "empaquetamiento". : Palabra Hy-phen-a-ción por Com-put-er .
El contexto de las columnas de Bentley, el programa de Knuth y la revisión de McIlroy (solo una pequeña parte de la cual era sobre la filosofía de Unix) es más claro a la luz de las columnas anteriores y posteriores. , y la experiencia previa de Knuth incluyendo compiladores, TAOCP y TeX.
Hay un libro completo Ejercicios en estilo de programación , que muestra diferentes enfoques para este programa en particular, etc.
Tengo una publicación de blog inacabada que explica los puntos anteriores; podría editar esta respuesta cuando esté hecho. Mientras tanto, publicar esta respuesta aquí de todos modos, en la ocasión (10 de enero) del cumpleaños de Knuth. :-)