C ++ (a la Knuth)
Saya ingin tahu bagaimana program Knuth akan berjalan, jadi saya menerjemahkan programnya (awalnya Pascal) ke dalam C ++.
Meskipun tujuan utama Knuth bukanlah kecepatan tetapi untuk menggambarkan sistem WEB pemrograman terpelajarnya, program ini secara mengejutkan kompetitif, dan mengarah ke solusi yang lebih cepat daripada jawaban di sini sejauh ini. Inilah terjemahan saya dari programnya (nomor "bagian" yang sesuai dari program WEB disebutkan dalam komentar seperti "{§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;
}
Perbedaan dari program Knuth:
- Saya menggabungkan Knuth 4 array
link
, sibling
, count
danch
menjadi sebuah array dari struct Node
(merasa lebih mudah untuk memahami cara ini).
- Saya mengubah pemrograman teks melek huruf (WEB-style) bagian menjadi panggilan fungsi yang lebih konvensional (dan beberapa makro).
- Kita tidak perlu menggunakan konvensi / pembatasan I / O aneh Pascal standar, jadi gunakan
fread
dandata[i] | 32 - 'a'
seperti pada jawaban lain di sini, alih-alih solusi Pascal.
- Jika kita melampaui batas (kehabisan ruang) saat program sedang berjalan, program asli Knuth menghadapinya dengan anggun dengan menjatuhkan kata-kata kemudian, dan mencetak pesan di bagian akhir. (Tidak tepat mengatakan bahwa McIlroy "mengkritik solusi Knuth karena bahkan tidak dapat memproses teks lengkap dari Alkitab"; ia hanya menunjukkan bahwa kadang-kadang kata-kata yang sering terjadi mungkin muncul sangat terlambat dalam sebuah teks, seperti kata "Yesus" "dalam Alkitab, jadi kondisi kesalahannya tidak berbahaya.) Saya telah mengambil pendekatan yang lebih berisik (dan lebih mudah) dengan hanya menghentikan program.
- Program menyatakan TRIE_SIZE konstan untuk mengontrol penggunaan memori, yang saya temui. (Konstanta 32767 telah dipilih untuk persyaratan asli - "pengguna harus dapat menemukan 100 kata yang paling sering dalam makalah teknis dua puluh halaman (kira-kira file 50K byte)" dan karena Pascal menangani dengan baik dengan bilangan bulat yang berkisar ketik dan bungkus secara optimal. Kami harus meningkatkannya 25x menjadi 800.000 karena input uji sekarang 20 juta kali lebih besar.)
- Untuk pencetakan akhir dari string, kita hanya bisa berjalan di trie dan melakukan menambahkan string bodoh (bahkan mungkin kuadrat).
Terlepas dari itu, ini persis seperti program Knuth (menggunakan hash trie / struktur data trie yang dikemas dan bucket sort), dan melakukan operasi yang hampir sama (seperti yang dilakukan oleh program Pascal Knuth) sambil memutar semua karakter dalam input; perhatikan bahwa tidak menggunakan algoritma eksternal atau struktur data perpustakaan, dan juga kata-kata dengan frekuensi yang sama akan dicetak dalam urutan abjad.
Pengaturan waktu
Disusun dengan
clang++ -std=c++17 -O2 ptrie-walktrie.cc
Ketika dijalankan di testcase terbesar di sini ( giganovel
dengan 100.000 kata yang diminta), dan dibandingkan dengan program tercepat yang diposting di sini sejauh ini, saya merasa sedikit tetapi secara konsisten lebih cepat:
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...]
(Baris teratas adalah solusi Rust Anders Kaseorg; bagian bawah adalah program di atas. Ini adalah timing dari 100 run, dengan mean, min, max, median, dan kuartil.)
Analisis
Mengapa ini lebih cepat? Bukan karena C ++ lebih cepat dari Rust, atau bahwa program Knuth adalah yang tercepat mungkin - pada kenyataannya, program Knuth lebih lambat pada penyisipan (seperti yang dia sebutkan) karena pengepakan trie (untuk menghemat memori). Alasannya, saya curiga, terkait dengan sesuatu yang dikeluhkan Knuth pada 2008 :
A Flame Tentang 64-bit Pointer
Benar-benar bodoh memiliki pointer 64-bit ketika saya mengkompilasi sebuah program yang menggunakan RAM kurang dari 4 gigabytes. Ketika nilai pointer tersebut muncul di dalam sebuah struct, mereka tidak hanya membuang setengah memori, mereka secara efektif membuang setengah dari cache.
Program di atas menggunakan indeks array 32-bit (bukan 64-bit pointer), sehingga struct "Node" menempati lebih sedikit memori, sehingga ada lebih banyak Node pada stack dan lebih sedikit cache misses. (Bahkan, ada beberapa pekerjaan pada ini sebagai x32 ABI , tetapi tampaknya tidak dalam kondisi baik meskipun ide ini jelas berguna, misalnya melihat pengumuman baru dari kompresi pointer di V8 . Oh well.) Jadi pada giganovel
, program ini menggunakan 12,8 MB untuk trie (penuh), versus 32,18 MB program Rust untuk trie (aktif giganovel
). Kita dapat meningkatkan 1000x (dari "giganovel" ke "teranovel" katakan) dan masih tidak melebihi indeks 32-bit, jadi ini sepertinya pilihan yang masuk akal.
Varian lebih cepat
Kami dapat mengoptimalkan kecepatan dan melepaskan pengepakan, sehingga kami benar-benar dapat menggunakan trie (tidak dikemas) seperti pada solusi Rust, dengan indeks bukan pointer. Ini memberikan sesuatu yang lebih cepat dan tidak memiliki batasan pra-tetap pada jumlah kata, karakter dll:
#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;
}
Program ini, walaupun melakukan banyak hal untuk menyortir daripada solusi di sini, hanya menggunakan (untuk giganovel
) 12,2MB untuk ketiganya, dan mengelola untuk menjadi lebih cepat. Pengaturan waktu dari program ini (baris terakhir), dibandingkan dengan penentuan waktu sebelumnya yang disebutkan:
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...]
Saya ingin sekali melihat apa yang diinginkan (atau program hash-trie) ini jika diterjemahkan ke dalam Rust . :-)
Keterangan lebih lanjut
Tentang struktur data yang digunakan di sini: penjelasan tentang percobaan "pengepakan" diberikan secara ringkas dalam Latihan 4 dari Bagian 6.3 (Pencarian Digital, yaitu percobaan) dalam Volume 3 TAOCP, dan juga dalam tesis siswa Knuth, Frank Liang tentang hyphenation in TeX : Word Hy-phen-a-tion oleh Com-put-er .
Konteks kolom Bentley, program Knuth, dan ulasan McIlroy (hanya sebagian kecil tentang filosofi Unix) lebih jelas mengingat kolom sebelumnya dan kemudian , dan pengalaman Knuth sebelumnya termasuk kompiler, TAOCP, dan TeX.
Ada seluruh buku Latihan dalam Gaya Pemrograman , yang menunjukkan berbagai pendekatan untuk program khusus ini, dll.
Saya memiliki posting blog yang belum selesai menguraikan poin-poin di atas; mungkin mengedit jawaban ini setelah selesai. Sementara itu, memposting jawaban ini di sini, pada kesempatan (10 Januari) di hari ulang tahun Knuth. :-)