C ++ (a la Knuth)
Ero curioso di sapere come sarebbe andato il programma di Knuth, quindi ho tradotto il suo programma (originariamente Pascal) in C ++.
Anche se l'obiettivo principale di Knuth non era la velocità, ma quello di illustrare il suo sistema WEB di programmazione alfabetica, il programma è sorprendentemente competitivo e porta a una soluzione più rapida di qualsiasi risposta finora finora. Ecco la mia traduzione del suo programma (i corrispondenti numeri di "sezione" del programma WEB sono citati in commenti come " {§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;
}
Differenze dal programma di Knuth:
- Ho combinato di Knuth 4 array
link
, sibling
, count
ech
in una matrice di unastruct Node
(trovo più facile da capire in questo modo).
- Ho modificato la inclusione testuale di sezioni di programmazione alfabetica (stile WEB) in chiamate di funzioni più convenzionali (e un paio di macro).
- Non abbiamo bisogno di usare le strane convenzioni / restrizioni I / O di Pascal standard, quindi usando
fread
edata[i] | 32 - 'a'
come nelle altre risposte qui, invece della soluzione alternativa di Pascal.
- Nel caso in cui superiamo i limiti (a corto di spazio) mentre il programma è in esecuzione, il programma originale di Knuth lo affronta con grazia facendo cadere le parole successive e stampando un messaggio alla fine. (Non è del tutto corretto affermare che McIlroy "ha criticato la soluzione di Knuth in quanto non è nemmeno in grado di elaborare un testo completo della Bibbia"; stava solo sottolineando che a volte parole frequenti possono capitare molto tardi in un testo, come la parola "Gesù "nella Bibbia, quindi la condizione di errore non è innocua.) Ho adottato l'approccio più rumoroso (e comunque più semplice) di semplicemente terminare il programma.
- Il programma dichiara un TRIE_SIZE costante per controllare l'utilizzo della memoria, che ho verificato. (La costante di 32767 era stata scelta per i requisiti originali - "un utente dovrebbe essere in grado di trovare le 100 parole più frequenti in un documento tecnico di venti pagine (all'incirca un file di 50 K byte)" e perché Pascal si occupa bene dell'intero con intervallo li digita e li impacchetta in modo ottimale. Abbiamo dovuto aumentarlo da 25x a 800.000 poiché l'input del test è ora 20 milioni di volte più grande.)
- Per la stampa finale delle stringhe, possiamo semplicemente camminare sul trie e fare un'appendice di stringa stupida (forse anche quadratica).
A parte questo, questo è praticamente esattamente il programma di Knuth (usando il suo hash trie / impacchettato struttura di dati trie e il bucket bucket), e fa praticamente le stesse operazioni (come farebbe il programma Pascal di Knuth) mentre scorre tutti i caratteri nell'input; si noti che non utilizza algoritmi esterni o librerie di strutture dati e che le parole di uguale frequenza verranno stampate in ordine alfabetico.
sincronizzazione
Compilato con
clang++ -std=c++17 -O2 ptrie-walktrie.cc
Quando corro sul più grande testcase qui ( giganovel
con 100.000 parole richieste) e confrontato con il programma più veloce pubblicato qui finora, lo trovo leggermente ma costantemente più veloce:
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 linea superiore è la soluzione Rust di Anders Kaseorg; la parte inferiore è il programma sopra. Questi sono i tempi di 100 corse, con media, min, max, mediana e quartili.)
Analisi
Perché è più veloce? Non è che C ++ sia più veloce di Rust o che il programma di Knuth sia il più veloce possibile - in effetti, il programma di Knuth è più lento sugli inserimenti (come menziona) a causa del trie-packing (per conservare la memoria). Il motivo, sospetto, è legato a qualcosa di cui Knuth si è lamentato nel 2008 :
Una fiamma di puntatori a 64 bit
È assolutamente idiota avere puntatori a 64 bit quando compilo un programma che utilizza meno di 4 gigabyte di RAM. Quando tali valori di puntatore compaiono all'interno di una struttura, non solo sprecano metà della memoria, ma eliminano effettivamente metà della cache.
Il programma sopra utilizza indici di array a 32 bit (non puntatori a 64 bit), quindi la struttura "Nodo" occupa meno memoria, quindi ci sono più nodi nello stack e meno errori nella cache. (In effetti, c'è stato un po 'di lavoro su questo come l' ABI x32 , ma sembra non essere in buono stato anche se l'idea è ovviamente utile, ad esempio vedere il recente annuncio di compressione del puntatore in V8 . Oh bene.) Quindi giganovel
, questo programma utilizza 12,8 MB per il trie (compresso), rispetto ai 32,18 MB del programma Rust per il suo trie (sugiganovel
). Potremmo aumentare di 1000 volte (da "giganovel" a "teranovel") e non superare comunque gli indici a 32 bit, quindi questa sembra una scelta ragionevole.
Variante più veloce
Siamo in grado di ottimizzare la velocità e rinunciare all'imballaggio, in modo da poter effettivamente utilizzare il trie (non imballato) come nella soluzione Rust, con indici anziché puntatori. Questo dà qualcosa che è più veloce e non ha limiti prestabiliti sul numero di parole distinte, caratteri ecc:
#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;
}
Questo programma, nonostante faccia qualcosa di molto più stupido per l'ordinamento rispetto alle soluzioni qui, utilizza (per giganovel
) solo 12,2 MB per il suo trie e riesce ad essere più veloce. Tempi di questo programma (ultima riga), rispetto ai tempi precedenti menzionati:
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...]
Sarei ansioso di vedere cosa vorrebbe questo (o il programma hash-trie) se tradotto in Rust . :-)
Maggiori dettagli
Informazioni sulla struttura dei dati qui utilizzata: una spiegazione dei tentativi di "impacchettare" è fornita in modo teso nell'esercizio 4 della sezione 6.3 (Ricerca digitale, ovvero tentativi) nel volume 3 del TAOCP, e anche nella tesi dello studente di Knuth Frank Liang sulla sillabazione in TeX : Word Hy-phen-a -tion di Com-put-er .
Il contesto delle colonne di Bentley, il programma di Knuth e la recensione di McIlroy (solo una piccola parte della quale riguardava la filosofia di Unix) è più chiaro alla luce delle colonne precedenti e successive , e della precedente esperienza di Knuth con compilatori, TAOCP e TeX.
C'è un intero libro Esercizi in stile di programmazione , che mostra diversi approcci a questo particolare programma, ecc.
Ho un post sul blog incompiuto che elabora i punti sopra; potrebbe modificare questa risposta al termine. Nel frattempo, pubblicando questa risposta qui, comunque, in occasione (10 gennaio) del compleanno di Knuth. :-)