C ++ (a la Knuth)
Мені було цікаво, як пройде програма Кнут, тому я переклав його (спочатку Паскаль) програму на C ++.
Незважаючи на те, що основною метою Кнута була не швидкість, а проілюструвати його WEB-систему грамотного програмування, програма напрочуд є конкурентоспроможною і призводить до швидшого вирішення, ніж будь-який з відповідей тут. Ось мій переклад його програми (відповідні номери розділу "WEB-програми" згадуються в коментарях типу " {§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;
}
Відмінності від програми Knuth:
- Я об'єднав 4 масиви Кнута
link
, sibling
, count
і ch
в масив struct Node
(знайти його легше зрозуміти цей шлях).
- Я змінив текст-програмування (у WEB-стилі) текстуального включення розділів на більш звичайні виклики функцій (і пару макросів).
- Нам не потрібно використовувати стандартні конвенції / обмеження щодо вводу / виводу Паскаля, тому використовуючи, як
fread
і data[i] | 32 - 'a'
в інших відповідях тут, замість методу Pascal.
- Якщо ми перевищуємо обмеження (не вистачає місця) під час роботи програми, оригінальна програма Knuth вирішує це витончено, видаляючи пізніші слова та друкуючи повідомлення в кінці. (Не зовсім правильно сказати, що Макілрой "критикував рішення Кнута як навіть не в змозі опрацювати повний текст Біблії"; він лише вказував, що іноді в тексті можуть зустрічатися часті слова, наприклад слово "Ісус "в Біблії, тому умова помилки не є нешкідливим.) Я прийняв шумніший (і в будь-якому випадку легший) підхід просто припинити програму.
- Програма оголошує постійний TRIE_SIZE для управління використанням пам'яті, яку я наткнувся. (Константа 32767 була обрана для оригінальних вимог - "користувач повинен мати можливість знайти 100 найчастіших слів у технічному документі на двадцяти сторінках (приблизно 50-байтний байт)" і тому, що Pascal добре справляється з величиною цілого числа типи та пакети їх оптимально. Нам довелося збільшити його в 25 разів до 800 000, оскільки тестовий вклад зараз у 20 мільйонів разів більший.)
- Для остаточного друку рядків ми можемо просто пройтися по трійці і зробити тупу (можливо навіть квадратичну) рядок.
Крім цього, це майже точно програма Knuth (використовуючи його хеш-трійку / упаковану структуру даних трие та сортування відра), і робить майже ті ж операції (як програма Pascal Kn Knut), перебираючи всі символи на вході; зауважте, що він не використовує зовнішніх бібліотек алгоритму чи структури даних, а також, що слова однакової частоти друкуються в алфавітному порядку.
Хронометраж
Укладено з
clang++ -std=c++17 -O2 ptrie-walktrie.cc
Коли я запускаю найбільший тестовий зразок тут ( giganovel
зі 100 000 запитуваних слів) і порівнюючи з найшвидшою програмою, розміщеною тут, я вважаю це трохи, але стабільно швидше:
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...]
(Верхній рядок - це рішення іржі Андерса Касеорга; нижнє - вищевказана програма. Це таймінги від 100 пробігів із середнім значенням, хв, макс, медіаною та четвертилами.)
Аналіз
Чому це швидше? Справа не в тому, що C ++ швидше, ніж Руст, або що програма Knuth є найшвидшою - насправді програма Knuth повільніше вставляє (як він згадує) через пакування трійки (для збереження пам'яті). Я підозрюю, що причина пов’язана з чимось скаргою на Кнут у 2008 році :
Полум'я про 64-бітні покажчики
Абсолютно ідіотським є наявність 64-бітних покажчиків, коли я складаю програму, яка використовує менше 4 гігабайт оперативної пам’яті. Коли такі значення вказівника з’являються всередині структури, вони не лише витрачають половину пам’яті, вони ефективно викидають половину кешу.
Програма, що описана вище, використовує 32-бітні індекси масиву (не 64-бітні вказівники), тому структура "Node" займає менше пам'яті, тому на стеку є більше вузлів і менше пропусків кешу. (Насправді, була якась робота над цим як ABI x32 , але, здається, він не в хорошому стані хоча ідея, очевидно, корисна, наприклад, див. Нещодавнє оголошення про стиснення покажчика у V8 . Ну добре.) Так далі giganovel
, ця програма використовує 12,8 Мб для (упакованого) тріє, порівняно з 32,18 МБ програми Руста для його трие (включеного giganovel
). Ми могли б збільшити масштаб 1000x (від "giganovel" до "teranovel" скажімо) і все ж не перевищувати 32-бітні індекси, тому це видається розумним вибором.
Швидший варіант
Ми можемо оптимізувати швидкість та відмовитись від упаковки, тому ми можемо фактично використовувати трійник (не упакований), як у рішенні Rust, із індексами замість покажчиків. Це дає щось швидше і не має заздалегідь встановлених обмежень на кількість чітких слів, символів тощо:
#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;
}
Ця програма, незважаючи на те, що для сортування робиться щось набагато тупіше, ніж рішення, тут використовується (для giganovel
) лише 12,2 Мб для свого тріє, і вдається бути швидшим. Терміни дії цієї програми (останній рядок) порівняно з попередніми часовими таймінгами:
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 . :-)
Подальші деталі
Про структуру даних, що використовується тут: пояснення "упаковки" спроб подано чітко у вправі 4 розділу 6.3 (Цифровий пошук, тобто спроби) в томі 3 TAOCP, а також у тезі студента Кнута Френка Лянга про перенесення переносу в TeX : Слово Hy-fen-a-ція від Com-put-er .
Контекст колонок Бентлі, програми Кнут та рецензії Макілроя (лише незначна частина яких стосувалася філософії Unix) чіткіше з огляду на попередні та пізніші колонки , а також попереднього досвіду Knuth, включаючи компілятори, TAOCP та TeX.
Існує ціла книга вправ у стилі програмування , де показані різні підходи до цієї конкретної програми тощо.
У мене незавершена публікація в блозі, яка детально розглядає моменти вище; може змінити цю відповідь, коли це буде зроблено. Тим часом, опублікувавши цю відповідь у будь-якому випадку, з нагоди (10 січня) дня народження Кнут. :-)