C ++ (а-ля Кнут)
Мне было любопытно, как продвинется программа Кнута, поэтому я перевел его (первоначально Паскаль) программу на 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;
}
Отличия от программы Кнута:
- Я объединил 4 массивы Кнута
link
, sibling
, count
и ch
в массив аstruct Node
(найти его легче понять этот путь).
- Я изменил текстовое включение разделов при буквальном программировании (в стиле WEB) на более обычные вызовы функций (и пару макросов).
- Нам не нужно использовать странные соглашения / ограничения ввода / вывода Паскаля, поэтому использование
fread
иdata[i] | 32 - 'a'
вместо него можно как и в других ответах, вместо обходного пути Pascal.
- Если во время работы программы мы превышаем пределы (исчерпываем пространство), оригинальная программа Кнута изящно справляется с этим, отбрасывая более поздние слова и печатая сообщение в конце. (Не совсем правильно говорить, что Макилрой «критиковал решение Кнута как не способного обработать полный текст Библии»; он лишь указывал, что иногда в тексте могут встречаться очень часто встречающиеся слова, такие как слово «Иисус»). "в Библии, поэтому условие ошибки не является безобидным.) Я выбрал более шумный (и в любом случае более простой) подход к простому завершению программы.
- Программа объявляет константу TRIE_SIZE для управления использованием памяти, которую я увеличил. (Константа 32767 была выбрана для исходных требований: «пользователь должен быть в состоянии найти 100 наиболее часто встречающихся слов в техническом документе на двадцать страниц (примерно 50-килобайтный файл)», а также потому, что Паскаль хорошо справляется с целым числом в диапазоне набирает и упаковывает их оптимально. Мы должны были увеличить его в 25 раз до 800 000, поскольку входные данные теста теперь в 20 миллионов раз больше.)
- Для окончательной печати строк мы можем просто пройтись по дереву и выполнить добавление немой (возможно, даже квадратичной) строки.
Кроме того, это в значительной степени в точности программа Кнута (использующая его структуру хеш-данных / упакованных данных и сортировку блоков), и она выполняет почти те же операции (что и программа Кнута на языке Паскаль) во время циклического перебора всех символов на входе; обратите внимание, что он не использует внешний алгоритм или библиотеки структур данных, а также что слова одинаковой частоты будут печататься в алфавитном порядке.
тайминг
Составлено с
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...]
(Верхняя строка - это решение Anders Kaseorg по Rust; нижняя часть - вышеуказанная программа. Это временные интервалы из 100 запусков со средним, минимальным, максимальным, медианным и квартилями.)
Анализ
Почему это быстрее? Дело не в том, что C ++ быстрее, чем Rust, или в том, что программа Кнута является самой быстрой из возможных - на самом деле, программа Кнута медленнее при вставках (как он упоминает) из-за трип-упаковки (для сохранения памяти). Я подозреваю, что причина в том, что Кнут пожаловался в 2008 году :
Пламя о 64-битных указателях
Совершенно идиотично иметь 64-битные указатели, когда я компилирую программу, которая использует менее 4 гигабайт оперативной памяти. Когда такие значения указателя появляются внутри структуры, они не только тратят впустую половину памяти, они фактически отбрасывают половину кэша.
В вышеприведенной программе используются 32-битные индексы массивов (а не 64-битные указатели), поэтому структура «Узел» занимает меньше памяти, поэтому в стеке больше узлов и меньше пропусков кэша. (На самом деле была некоторая работа над этим, как с x32 ABI , но он, кажется, не в хорошем состоянии, хотя идея, очевидно, полезна, например, см. Недавнее объявление о сжатии указателей в V8 . Ну, хорошо.) Так далее giganovel
эта программа использует 12,8 МБ для (упакованного) времени, по сравнению с 32,18 МБ программы Rust для его времени (вкл giganovel
). Мы можем увеличить масштаб в 1000 раз (скажем, от «гигановела» до «терановела») и все же не превысить 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-Phen-a -tion от Com-put-er .
Контекст колонок Бентли, программы Кнута и обзора Макилроя (только небольшая часть которого была посвящена философии Unix) яснее в свете предыдущих и последующих колонок , а также предыдущего опыта Кнута, включая компиляторы, TAOCP и TeX.
Там целая книга Упражнения в стиле программирования , в которой показаны различные подходы к этой конкретной программе и т. Д.
У меня есть незаконченное сообщение в блоге, подробно описывающее пункты выше; может отредактировать этот ответ, когда это будет сделано. Между тем, публиковать этот ответ здесь в любом случае, по случаю (10 января) дня рождения Кнута. :-)