C ++ (a la Knuth)
Knuth'un programının nasıl çalışacağını merak ettim, bu yüzden onun (aslında Pascal) programını C ++ 'a çevirdim.
Knuth'un ana hedefi hız değil, WEB okuryazar programlama sistemini göstermek için olsa da, program şaşırtıcı derecede rekabetçi ve şimdiye kadar buradaki cevaplardan daha hızlı bir çözüme yol açıyor. Benim programımın çevirisi (WEB programının ilgili "bölüm" numaraları " {§24}
" gibi yorumlarda belirtilmiştir ):
#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;
}
Knuth'un programından farklılıklar:
- Ben Knuth'un 4 diziler kombine
link
, sibling
, count
ve ch
bir bir diziye struct Node
(daha kolay bu şekilde anlamak bulabilirsiniz).
- Bölümlerin okuryazar programlama (WEB stili) metinsel dönüşümünü daha geleneksel işlev çağrılarına (ve birkaç makroya) değiştirdim.
- Biz bu yüzden kullanarak, standart Pascal garip I / O kuralları / kısıtlamaları kullanmak gerekmez
fread
ve data[i] | 32 - 'a'
bunun yerine Pascal Geçici çözümün, burada diğer yanıtlar olarak.
- Program çalışırken limitleri aşmamız durumunda (alan tükenirse), Knuth'un orijinal programı daha sonraki kelimeleri bırakarak ve sonunda bir mesaj yazdırarak bunu incelikle ele alır. (McIlroy'un "Knuth'un çözümünü İncil'in tam bir metnini bile işleyemediğini" eleştirdiğini söylemek doğru değil); sadece bazen "İsa" gibi bir metinde sıkça sık rastlanan kelimelerin çok geç olabileceğine işaret ediyordu. "İncil'de, bu yüzden hata durumu zararsız değildir.) Programı basitçe sonlandırmak için daha gürültülü (ve daha kolay) bir yaklaşım benimsedim.
- Program, kullandığım bellek kullanımını kontrol etmek için sabit bir TRIE_SIZE bildiriyor. (Orijinal gereksinimler için 32767 sabiti seçildi - "bir kullanıcı yirmi sayfalık bir teknik makalede (kabaca 50 bin baytlık bir dosyada) en sık kullanılan 100 kelimeyi bulabilmelidir" ve Pascal menzilli tamsayı ile iyi başa çıktığı için Test girişi artık 20 milyon kat daha büyük olduğu için 25x'i 800.000'e çıkarmamız gerekiyordu.)
- Dizelerin son baskısı için, sadece trie yürüyebilir ve aptal (muhtemelen ikinci dereceden) bir dize eki yapabiliriz.
Bunun dışında, bu tam olarak Knuth'un programıdır (hash trie / paketli trie veri yapısı ve kova sıralaması kullanılarak) ve girişteki tüm karakterler arasında döngü yaparken hemen hemen aynı işlemleri yapar (Knuth's Pascal programının yaptığı gibi); harici algoritma veya veri yapısı kitaplığı kullanmadığını ve eşit frekanstaki kelimelerin alfabetik sırada yazdırılacağını unutmayın.
Zamanlama
İle derlendi
clang++ -std=c++17 -O2 ptrie-walktrie.cc
Buradaki en büyük test çantasında ( giganovel
100.000 kelime talep edildiğinde) ve şimdiye kadar burada yayınlanan en hızlı programla karşılaştırıldığında, biraz ama tutarlı bir şekilde daha hızlı buluyorum:
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...]
(Üst satır Anders Kaseorg'un Rust çözümüdür; alt kısım yukarıdaki programdır. Bunlar ortalama, min, maks, medyan ve çeyreklerle 100 çalışmadan zamanlamalardır.)
analiz
Bu neden daha hızlı? C ++, Rust'dan daha hızlı değildir ya da Knuth'un programı mümkün olan en hızlı değildir - aslında, Knuth'un programı, üç paketlemeden dolayı (hafızayı korumak için) eklemelerde daha yavaştır. Şüphelendiğim neden, Knuth'un 2008'de şikayet ettiği bir şeyle ilgili :
64-Bit İşaretçiler Hakkında Bir Alev
4 gigabayttan daha az RAM kullanan bir program derlediğimde 64 bit işaretçiler kullanmak kesinlikle aptalca. Bu tür işaretçi değerleri bir yapının içinde göründüğünde, sadece belleğin yarısını boşa harcamakla kalmaz, aynı zamanda önbelleğin yarısını etkili bir şekilde atarlar.
Yukarıdaki program 32-bit dizi indekslerini (64-bit işaretçileri değil) kullanır, bu nedenle "Düğüm" yapısı daha az bellek kaplar, bu nedenle yığınta daha fazla Düğüm ve daha az önbellek kaçışı olur. (Aslında, orada bazı çalışmalar olarak bu konuda x32 ABI , ancak gibi görünüyor iyi bir durumda değil fikri örneğin bkz açıkçası kullanışlı olmasına rağmen son duyuru ve V8 ibre sıkıştırma . Neyse.) Yani üzerinde giganovel
, bu program (paketli) trie için 12.8 MB, Rust programının trie (açık giganovel
) için 32.18MB'ını kullanır . Biz 1000x ("giganovel" den "teranovel" demek) ölçeklendirmek ve hala 32-bit indeksleri aşmak olabilir, bu yüzden bu makul bir seçim gibi görünüyor.
Daha hızlı değişken
Hız için optimize edebilir ve ambalajdan vazgeçebiliriz, böylece (çözümlenmemiş) üçgeni Rust çözümünde olduğu gibi, işaretçiler yerine dizinlerle kullanabiliriz. Bu, daha hızlı olan ve farklı kelime, karakter vb. Sayısında önceden sabit bir sınırı olmayan bir şey verir :
#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;
}
Bu program, buradaki çözümlerden daha fazla sıralama için çok değerli bir şey yapmasına rağmen, giganovel
kendi süresi için sadece 12.2MB kullanır ve daha hızlı olmayı başarır. Bu programın zamanlamaları (son satır), belirtilen önceki zamanlamalarla karşılaştırıldığında:
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...]
Rust'a çevrildiyse bunun (ya da karma programın) ne istediğini görmek isterim . :-)
Daha fazla ayrıntı
Burada kullanılan veri yapısı hakkında: "paketleme" denemelerinin açıklaması, TAOCP Cilt 3'teki Bölüm 6.3 (Dijital Arama, yani denemeler) 4. Alıştırmada ve ayrıca Knuth öğrencisi Frank Liang'ın TeX tireleme tezinde açık bir şekilde verilmiştir. : Com-put-er tarafından yazılmış Hy-fen-a-Word .
Bentley'nin sütunları, Knuth'un programı ve McIlroy'un incelemesi (sadece küçük bir kısmı Unix felsefesi ile ilgili) bağlamı, önceki ve sonraki sütunlar ve Knuth'un derleyiciler, TAOCP ve TeX dahil önceki deneyimleri ışığında daha net .
Programlama Stili'nde , bu programa farklı yaklaşımlar gösteren, Alıştırmalar kitabının tamamı vardır .
Yukarıdaki noktalara dikkat çeken bitmemiş bir blog yayınım var; tamamlandığında bu yanıtı düzenleyebilir. Bu arada, bu cevabı zaten Knuth'un doğum günü vesilesiyle (10 Ocak) burada yayınlamak. :-)