C ++ (a la Knuth)
ฉันสงสัยว่าโปรแกรมของ Knuth จะเดินทางอย่างไรฉันจึงแปลโปรแกรม (เดิมคือ Pascal) เป็น C ++
แม้ว่าเป้าหมายหลักของ Knuth นั้นไม่ใช่ความเร็ว แต่เพื่อแสดงให้เห็นถึงระบบ 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:
- ฉันรวม Knuth 4 อาร์เรย์
link
, sibling
, count
และch
เป็น array ของหนึ่งstruct Node
(พบว่ามันง่ายต่อการเข้าใจวิธีนี้)
- ฉันเปลี่ยนการแปลงข้อความเป็นส่วน ๆ ของวรรณกรรม (สไตล์เว็บ) เป็นการเรียกฟังก์ชั่นทั่วไป (และมาโครสองสามตัว)
- เราไม่จำเป็นต้องใช้ข้อตกลง / ข้อ จำกัด I / O แปลก ๆ ของ Pascal ดังนั้นการใช้
fread
และdata[i] | 32 - 'a'
เหมือนในคำตอบอื่น ๆ ที่นี่แทนที่จะเป็นวิธีแก้ปัญหาของ Pascal
- ในกรณีที่เราเกินขีด จำกัด (หมดพื้นที่) ในขณะที่โปรแกรมกำลังทำงานอยู่โปรแกรมดั้งเดิมของ Knuth จะจัดการกับมันอย่างสง่างามโดยการวางคำต่อมาและพิมพ์ข้อความที่ท้าย (มันค่อนข้างไม่ถูกต้องที่จะบอกว่า McIlroy "วิพากษ์วิจารณ์การแก้ปัญหาของ Knuth ที่ไม่สามารถประมวลผลข้อความทั้งหมดในพระคัมภีร์" เขาเพียงชี้ให้เห็นว่าบางครั้งคำที่พบบ่อยอาจเกิดขึ้นช้ามากในข้อความเช่นคำว่า "Jesus "ในพระคัมภีร์ดังนั้นเงื่อนไขข้อผิดพลาดจึงไม่เป็นพิษเป็นภัยเลย) ฉันใช้วิธีที่น่าสนใจ (และง่ายกว่า) ในการยุติโปรแกรม
- โปรแกรมประกาศ TRIE_SIZE คงที่เพื่อควบคุมการใช้หน่วยความจำซึ่งฉันชน (ค่าคงที่ของ 32767 นั้นถูกเลือกสำหรับความต้องการดั้งเดิม - "ผู้ใช้ควรจะสามารถค้นหาคำที่พบบ่อยที่สุด 100 คำในกระดาษยี่สิบหน้าทางเทคนิค (ประมาณไฟล์ 50K ไบต์)" และเนื่องจาก Pascal จัดการได้ดีกับจำนวนเต็มในช่วง ประเภทและแพ็คให้เหมาะสมที่สุดเราต้องเพิ่ม 25x เป็น 800,000 เนื่องจากอินพุตทดสอบตอนนี้ใหญ่ขึ้น 20 ล้านเท่า)
- สำหรับการพิมพ์ครั้งสุดท้ายของสตริงเราก็สามารถเดินทั้งคู่และทำสตริงที่โง่ (อาจจะเป็นกำลังสอง) ผนวก
นอกเหนือจากนั้นนี่เป็นโปรแกรมของ Knuth (โดยใช้โครงสร้างข้อมูลแบบแฮช trie / ที่บรรจุแบบ trie และที่จัดเรียงกลุ่ม) และทำงานแบบเดียวกัน (เหมือนโปรแกรม Pascal ของ Knuth) ในขณะที่วนรอบอักขระทั้งหมดในอินพุต โปรดทราบว่าไม่มีการใช้อัลกอริธึมภายนอกหรือไลบรารีโครงสร้างข้อมูลและคำที่มีความถี่เท่ากันจะถูกพิมพ์ตามลำดับตัวอักษร
การจับเวลา
รวบรวมด้วย
clang++ -std=c++17 -O2 ptrie-walktrie.cc
เมื่อรันบน testcase ที่ใหญ่ที่สุดที่นี่ ( 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...]
(บรรทัดบนสุดคือโซลูชัน Rust ของ Anders Kaseorg; ด้านล่างเป็นโปรแกรมด้านบนนี่คือการจับเวลาจากการวิ่ง 100 ครั้งด้วยค่าเฉลี่ย, นาที, สูงสุด, ค่ามัธยฐานและควอไทล์)
การวิเคราะห์
ทำไมถึงเร็วกว่านี้? ไม่ใช่ว่า C ++ เร็วกว่า Rust หรือโปรแกรมของ Knuth นั้นเร็วที่สุด - อันที่จริงแล้วโปรแกรมของ Knuth นั้นช้าลงในการแทรก เหตุผลที่ฉันสงสัยว่าเกี่ยวข้องกับสิ่งที่ Knuth บ่นเกี่ยวกับในปี 2008 :
Flame เกี่ยวกับพอยน์เตอร์ 64- บิต
เป็นเรื่องงี่เง่าที่มีตัวชี้ 64- บิตเมื่อฉันรวบรวมโปรแกรมที่ใช้ RAM น้อยกว่า 4 กิกะไบต์ เมื่อค่าตัวชี้ดังกล่าวปรากฏขึ้นภายในโครงสร้างพวกเขาไม่เพียง แต่เสียหน่วยความจำเพียงครึ่งเดียว แต่ยังทิ้งครึ่งหนึ่งของแคชอย่างมีประสิทธิภาพ
โปรแกรมข้างต้นใช้ดัชนีอาร์เรย์แบบ 32 บิต (ไม่ใช่ตัวชี้แบบ 64- บิต) ดังนั้นโครงสร้าง "โหนด" จึงใช้หน่วยความจำน้อยกว่าดังนั้นจึงมีโหนดจำนวนมากบนสแต็กและมีแคชน้อยกว่า (อันที่จริงมีงานบางอย่างเกี่ยวกับเรื่องนี้เป็นx32 ABIแต่ดูเหมือนว่าจะไม่อยู่ในสภาพที่ดีแม้ว่าความคิดจะมีประโยชน์ชัดเจนเช่นดูการประกาศล่าสุดเกี่ยวกับการบีบอัดตัวชี้ใน V8ไม่เป็นไร) giganovel
โปรแกรมนี้ใช้ 12.8 MB สำหรับ trie (บรรจุ) เทียบกับ Rust ของ 32.18MB สำหรับ trie (บน)giganovel
) เราสามารถขยายขนาด 1,000 เท่า (จาก "giganovel" ถึง "teranovel" พูด) และยังคงไม่เกินดัชนี 32 บิตดังนั้นนี่จึงเป็นตัวเลือกที่สมเหตุสมผล
ตัวแปรที่เร็วกว่า
เราสามารถปรับให้เหมาะสมสำหรับความเร็วและนำหน้าการบรรจุดังนั้นเราจึงสามารถใช้ trie (ไม่บรรจุ) เช่นเดียวกับใน 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.2MB สำหรับ trie ของมันและจัดการให้เร็วขึ้น การจับเวลาของโปรแกรมนี้ (บรรทัดสุดท้าย) เปรียบเทียบกับการกำหนดเวลาก่อนหน้าที่กล่าวถึง:
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 และในวิทยานิพนธ์ของนักเรียนของ Knuth Frank เหลียงเกี่ยวกับการใส่ยัติภังค์ใน TeX : โปรแกรม Word Hy-phen-a-tion โดยดอทคอมใส่เอ้อ
บริบทของคอลัมน์ของเบนท์ลีย์โปรแกรมของ Knuth และการทบทวนของ McIlroy (เพียงส่วนเล็ก ๆ ซึ่งเกี่ยวกับปรัชญาของ Unix) นั้นชัดเจนขึ้นในแง่ของคอลัมน์ก่อนหน้าและหลังและประสบการณ์ก่อนหน้าของ Knuth รวมถึงผู้เรียบเรียง TAOCP และ TeX
มี แบบฝึกหัดหนังสือทั้งเล่มในสไตล์การเขียนโปรแกรมแสดงวิธีการต่าง ๆ ของโปรแกรมนี้ ฯลฯ
ฉันมีโพสต์บล็อกที่ยังไม่เสร็จอธิบายรายละเอียดในประเด็นข้างต้น อาจแก้ไขคำตอบนี้เมื่อทำเสร็จแล้ว ในขณะเดียวกันโพสต์คำตอบนี้ที่นี่ต่อไปในโอกาส (10 ม.ค. ) วันเกิดของ Knuth :-)