ปูพื้นฐาน
โครงสร้างที่คนมองว่าไว้เก็บคำ แต่ของจริงคือแผนที่ของสถานะ บทนี้ให้ทั้งการใช้แบบตรงไปตรงมา การพลิกไปใช้กับตัวเลขฐานสอง และวิธีคำนวณหน่วยความจำก่อนตัดสินใจใช้ ซึ่งเป็นขั้นที่คนข้ามแล้วตกทีหลัง
เวลามีคำอยู่เป็นหมื่นเป็นแสนคำ แล้วต้องถามซ้ำ ๆ ว่า "คำนี้อยู่ในคลังไหม" หรือ
"มีกี่คำในคลังที่เป็นส่วนหัวของสตริงนี้" การไล่เทียบทีละคำจะเป็น จำนวนคำ × ความยาว ต่อหนึ่งคำถาม
ซึ่งช้าเกินไปทันทีที่คำถามเยอะ
ไทร (trie) แก้ปัญหานี้ด้วยการเอาคำทั้งหมดมาซ้อนกันเป็นต้นไม้ตามส่วนหัวที่ใช้ร่วมกัน ค่าใช้จ่ายของคำถามหนึ่งคำถามจะเหลือแค่ความยาวของสตริงที่ถาม ไม่เกี่ยวกับว่าคลังใหญ่แค่ไหนเลย
แกะคำศัพท์
trie อ่านว่า "ไทร" ชื่อนี้ตัดมาจากกลางคำว่า retrieval ที่แปลว่า การค้นคืน คนตั้งชื่อคือ Edward Fredkin (อ่านว่า "เอ็ดเวิร์ด เฟรดคิน" นักวิทยาการคอมพิวเตอร์) เขาอยากให้อ่านว่า "ทรี" เหมือน tree เพราะมันคือต้นไม้ แต่คนใช้จริงกลับอ่าน "ไทร" เพื่อไม่ให้ชนกับคำว่า tree เวลาพูดกัน
ปมรากคือสตริงว่าง ปมลูกของปม p คือ p ที่ต่อท้ายด้วยหนึ่งตัวอักษร
ดังนั้นปมหนึ่งปมคือคำนำหน้าหนึ่งอัน และเส้นทางจากรากลงมาถึงปมคือการสะกดคำนำหน้านั้น
ปมที่เป็นจุดจบของคำจริง ๆ ในคลังจะติดธงไว้
| รายการ | จำนวน |
|---|---|
| ตัวอักษรรวมทุกคำ | 15 |
| ปมของไทร (ไม่นับราก) | 8 |
| ปมที่ประหยัดไปได้เพราะใช้ส่วนหัวร่วมกัน | 7 |
c, ca, car, card, cat, d, do, dog ระวัง
ไทรที่เก็บลูกเป็นอาเรย์ 26 ช่องต่อปม กินหน่วยความจำ จำนวนปม × 26 × 4 ไบต์
คำแสนคำ คำละยี่สิบตัวอักษร ถ้าไม่มีส่วนหัวซ้ำกันเลยจะเป็นสองล้านปม คูณแล้วได้ราว 208 เมกะไบต์
ซึ่งเกินลิมิตของโจทย์ส่วนใหญ่
ก่อนเลือกไทรทุกครั้ง ให้คูณเลขนี้ออกมาก่อน ถ้าเกิน ก็มีทางเลือกคือ
ใช้ map ต่อปม (ช้ากว่าแต่ประหยัด) หรือถ้าตัวอักษรมีน้อยชนิด (เช่นสองชนิด) ก็เก็บแค่สองช่อง
คลังเล็ก ๆ ลองพิมพ์ทีละตัวแล้วดูว่าเหลือกี่คำ
อยากชี้ไปที่คำว่า car
พิมพ์ทีละตัวอักษร แล้วดูว่าคลังเหลือกี่คำที่ยังเข้าได้
ส่วนหัวตอนนี้ (ยังไม่พิมพ์) · ยังชี้ได้ 0 คำ
ราคาของการพิมพ์แต่ละตัวคือหนึ่งก้าวบนไทร ไม่ว่าคลังจะมีกี่คำ
สังเกตว่าจำนวนคำที่ยังเข้าได้ ลดลงเรื่อย ๆ ตามที่พิมพ์ ตัวเลขนั้นแหละคือของที่ควรเก็บไว้ที่ปมของไทร
ให้คำในคลัง n คำ แล้วมีคำถาม q ข้อ แต่ละข้อให้สตริงมาหนึ่งตัว
ตอบว่ามีกี่คำในคลังที่เป็นส่วนหัวของสตริงนั้น (คำในคลังซ้ำกันได้)
อินพุต / ขอบเขต / เอาต์พุต
n และ q ตามด้วยคำในคลัง n บรรทัด แล้วคำถาม q บรรทัดa-z| Input | Output |
|---|---|
| 5 1 cat car card dog do card | 2 |
อ่านตัวอย่างนี้ยังไง
บรรทัดแรกคือจำนวนคำในคลังกับจำนวนคำถาม จากนั้นคือคำในคลังบรรทัดละคำ แล้วคำถามบรรทัดละคำ เอาต์พุตคือจำนวนคำ ไม่ใช่ตัวคำ
คำว่า "ส่วนหัว" หมายถึงคำที่ตรงกับตัวอักษรต้น ๆ ของคำถามพอดี และตัวคำถามเองก็นับ
ถ้ามันอยู่ในคลัง ชุดนี้คำถามคือ card คำในคลังที่เป็นส่วนหัวของมันคือ
car, card รวม 2 คำ
วิธีอ่านที่ตรงกับโครงสร้างไทรที่สุดคือเดินตามตัวอักษรของคำถามทีละตัว แล้วถามที่ทุกจุดว่า "ตรงนี้มีคำในคลังจบพอดีไหม" ซึ่งเป็นการเดินครั้งเดียว ไม่ใช่ไล่เทียบทีละคำในคลัง
ใบ้
คำในคลังที่เป็นส่วนหัวของ card คือคำที่อยู่บนเส้นทางจากรากลงไปหา card พอดี แล้วเราจะเดินเส้นทางนั้นครั้งเดียวโดยเก็บอะไรระหว่างทางดี
ที่มาของท่านี้ · ขั้นที่คนข้ามแล้วตกทีหลัง
ก่อนตัดสินใจใช้ไทร มีขั้นหนึ่งที่ต้องทำเสมอและคนข้ามกันบ่อยที่สุด คือ คูณเลขหน่วยความจำออกมาดูก่อน ไทรไม่ได้แพงที่เวลา มันแพงที่หน่วยความจำ และมันแพงแบบที่ไม่โผล่มาให้เห็นจนกว่าจะส่งข้อสอบแล้วโดนตีกลับ
ลองคิดกรณีที่แย่ที่สุดดู คำหนึ่งแสนคำ คำละยี่สิบตัวอักษร และไม่มีคำไหนใช้ส่วนหัวร่วมกันเลย นั่นคือ สองล้านปม ถ้าแต่ละปมเก็บลูกเป็นอาเรย์ยาว 26 ช่อง ช่องละสี่ไบต์ ก็คือ 2,000,000 × 26 × 4 ได้ราว 208 เมกะไบต์ ซึ่งเกินลิมิตของโจทย์ส่วนใหญ่ไปแล้ว
รู้เลขนี้ก่อนมีประโยชน์ตรงที่มันบอกว่าต้องแก้ทางไหน จะลดขนาดตัวอักษรลง จะเปลี่ยนลูกจากอาเรย์เป็นแมป แลกความเร็วกับที่ หรือจะเลิกใช้ไทรไปใช้การเรียงกับการค้นแบบไบนารีแทน ทั้งสามทางตัดสินใจได้ตั้งแต่ก่อนเขียนโค้ด
บทเรียนที่ยกไปข้ออื่นได้คือ โครงสร้างที่เก็บปมต่อตัวอักษรต้องประเมินที่ก่อนเสมอ และประเมินด้วยการคูณจริง ไม่ใช่ด้วยความรู้สึกว่าหนึ่งแสนคำมันไม่เยอะ
เดินจากรากไปตามตัวอักษรของคำถาม ทุกปมที่เดินผ่านคือส่วนหัวหนึ่งอัน ก็แค่บวกธง "ปมนี้เป็นจุดจบของคำในคลังกี่คำ" สะสมไป ถ้าเดินไปเจอทางตัน แปลว่าส่วนหัวที่ยาวกว่านั้นไม่มีในคลังแน่ ๆ หยุดได้เลย
ในตัวอย่าง คำในคลังที่เป็นส่วนหัวของ card คือ car, card
รวม 2 คำ ส่วน cat ไม่นับ เพราะเส้นทางแยกออกไปคนละกิ่งตั้งแต่ตัวที่สาม
// นับว่าในคลัง มีกี่คำที่เป็นส่วนหัวของสตริงคำถาม (ตอบทุกคำถามในเวลารวมเชิงเส้น)
#include <bits/stdc++.h>
using namespace std;
struct Node { int ch[26]; int words; Node() { memset(ch, -1, sizeof ch); words = 0; } };
vector<Node> T;
int newNode() { T.push_back(Node()); return (int)T.size() - 1; }
int main() {
int n, q;
scanf("%d %d", &n, &q);
T.reserve(1 << 20); newNode();
for (int i = 0; i < n; i++) {
char buf[1005]; scanf("%s", buf);
int cur = 0;
for (char* p = buf; *p; p++) {
int k = *p - 'a';
if (T[cur].ch[k] < 0) T[cur].ch[k] = newNode();
cur = T[cur].ch[k];
}
T[cur].words++; // ปมนี้เป็นจุดจบของคำในคลังหนึ่งคำ
}
while (q--) {
char buf[1005]; scanf("%s", buf);
int cur = 0; long long ans = 0;
for (char* p = buf; *p && cur >= 0; p++) {
cur = T[cur].ch[*p - 'a'];
if (cur < 0) break;
ans += T[cur].words; // ทุกปมที่เดินผ่านคือส่วนหัวหนึ่งอัน
}
printf("%lld\n", ans);
}
} สังเกตว่าต้นทุนต่อคำถามคือความยาวของคำถามอย่างเดียว ไม่ว่าคลังจะมีล้านคำก็ตาม นี่คือสิ่งที่ไทรซื้อมาให้
ผมสุ่มคลังเล็ก ๆ 600 ชุดเทียบกับตัวไล่เทียบทีละคำ ตรงกันหมด
ให้จำนวนเต็มไม่ติดลบ n ตัว หาคู่ที่ a XOR b มากที่สุด
(XOR อ่านว่า "เอ็กซ์ออร์" คือการเทียบทีละบิต ได้ 1 เมื่อสองบิตต่างกัน)
| Input | Output |
|---|---|
| 6 3 10 5 25 2 8 | 28 |
อ่านตัวอย่างนี้ยังไง
อินพุตคือจำนวนตัวเลขแล้วตามด้วยตัวเลข เอาต์พุตคือค่า XOR ที่มากที่สุด ไม่ใช่คู่ที่ให้ค่านั้น และคู่ต้องเป็นเลขคนละตัวในรายการ
ชุดนี้คู่ที่ชนะคือ 5 กับ 25 ได้ 28 กฎง่าย ๆ ที่คนคิดออกก่อนคือ "จับตัวมากสุดกับตัวน้อยสุด" ซึ่งชุดนี้ได้แค่ 27 เพราะ XOR ไม่ได้สนใจว่าเลขไหนใหญ่ มันสนใจว่าบิตไหนต่างกัน
พอเขียนทุกตัวเป็นเลขฐานสองความยาวเท่ากัน โจทย์นี้ก็กลายเป็นโจทย์สตริงทันที และคำถามกลายเป็น "หาคำที่ต่างจากคำนี้ให้ได้มากที่สุดตั้งแต่ตัวหน้า ๆ"
ใบ้
ข้อนี้ดูไม่เกี่ยวกับสตริงเลย แต่ลองเขียนเลขทุกตัวเป็นสตริงฐานสองความยาวเท่ากันดู แล้วถามว่าถ้าอยากให้บิตซ้ายสุดของผลลัพธ์เป็น 1 เราต้องหาคู่ที่มีบิตซ้ายสุดเป็นอะไร
เขียนทุกเลขเป็นสตริงฐานสองแล้วยัดลงไทรที่มีลูกแค่สองช่องคือ 0 กับ 1 จากนั้นสำหรับเลขแต่ละตัว ให้เดินลงไทรจากบิตสูงสุด โดยพยายามเลี้ยวไปทางที่บิตตรงข้ามกับตัวเองเสมอ เพราะบิตที่ต่างกันให้ผล XOR เป็น 1
ที่ท่านี้ถูกต้องเพราะบิตซ้ายสุดมีน้ำหนักมากกว่าบิตที่เหลือรวมกันทั้งหมด ได้บิตซ้ายเป็น 1 หนึ่งบิต คุ้มกว่าได้บิตขวาเป็น 1 ทุกบิต การเลือกแบบโลภทีละบิตจึงไม่มีทางพลาด
| ค่า | ฐานสอง |
|---|---|
| 3 | 00011 |
| 10 | 01010 |
| 5 | 00101 |
| 25 | 11001 |
| 2 | 00010 |
| 8 | 01000 |
00101 เทียบกับ 11001 ต่างกันที่บิตซ้ายสุด
ซึ่งเป็นสิ่งเดียวที่ท่าโลภมองหา)
// คู่ที่ให้ค่า XOR มากที่สุด ใช้ไทรฐานสอง เดินจากบิตสูงลงบิตต่ำ
#include <bits/stdc++.h>
using namespace std;
const int BITS = 30;
struct Node { int ch[2]; Node() { ch[0] = ch[1] = -1; } };
vector<Node> T;
int newNode() { T.push_back(Node()); return (int)T.size() - 1; }
void insert(int v) {
int cur = 0;
for (int b = BITS; b >= 0; b--) {
int k = (v >> b) & 1;
if (T[cur].ch[k] < 0) T[cur].ch[k] = newNode();
cur = T[cur].ch[k];
}
}
int bestAgainst(int v) {
int cur = 0, res = 0;
for (int b = BITS; b >= 0; b--) {
int k = (v >> b) & 1;
if (T[cur].ch[k ^ 1] >= 0) { res |= 1 << b; cur = T[cur].ch[k ^ 1]; } // ได้บิตนี้เป็นหนึ่ง
else cur = T[cur].ch[k]; // จำใจเดินทางเดียวที่มี
}
return res;
}
int main() {
int n; scanf("%d", &n);
T.reserve(32 * n + 8); newNode();
vector<int> a(n);
for (auto& v : a) scanf("%d", &v);
int ans = 0;
insert(a[0]);
for (int i = 1; i < n; i++) { ans = max(ans, bestAgainst(a[i])); insert(a[i]); }
printf("%d\n", ans);
}
โครงที่ใช้คือใส่ทีละตัว ถามทีละตัว คือถามด้วยตัวที่ i กับไทรที่มีแค่ตัวก่อนหน้า
ทำแบบนี้ทุกคู่จะถูกพิจารณาพอดีครั้งเดียว ไม่ต้องกันการจับคู่กับตัวเอง
ผมสุ่มชุดตัวเลขเล็ก ๆ 1,500 ชุดเทียบกับตัวไล่ทุกคู่ ตรงกันหมด
ก่อนจะรู้จักไทร คนส่วนใหญ่คิดออกอีกทางหนึ่งก่อน และทางนั้นถูกจริงและเขียนสั้นกว่ามาก คือถ้าคำถามคือ "มีคำในคลังกี่คำที่เริ่มด้วยข้อความนี้" ก็เอาคำนำหน้าทุกอันของทุกคำ ไปยัดลงตารางแฮชพร้อมตัวนับไว้เลย แล้วตอบคำถามด้วยการค้นหาหนึ่งครั้ง
คำยาว L ตัวมีคำนำหน้า L อัน ดังนั้นคลัง n คำสร้างรายการ
n · L รายการ ซึ่งที่ n = 100,000 และ
L = 30 คือ 3,000,000 รายการ
ยังไหวอยู่ แต่ทุกรายการเป็นสตริง ไม่ใช่ตัวเลข
// นับคำในคลังที่มีคำนำหน้าตามที่ถาม โดยใช้ตารางแฮชของคำนำหน้าทุกอัน
// อินพุต: n q, n คำในคลัง, q คำถาม (คำนำหน้า)
// เอาต์พุต: จำนวนคำในคลังที่เริ่มด้วยคำนำหน้านั้น ทีละบรรทัด
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, q;
if (scanf("%d %d", &n, &q) != 2) return 0;
unordered_map<string, int> cnt;
cnt.reserve(1 << 15);
for (int i = 0; i < n; i++) {
char buf[64];
scanf("%s", buf);
string w = buf;
// ยัดคำนำหน้าทุกอันของคำนี้ลงตาราง คำยาว L สร้าง L รายการ
string cur;
for (char c : w) {
cur += c;
cnt[cur]++;
}
}
for (int i = 0; i < q; i++) {
char buf[64];
scanf("%s", buf);
auto it = cnt.find(string(buf));
printf("%d\n", it == cnt.end() ? 0 : it->second);
}
return 0;
} ทางนี้แพ้ไทรอยู่สามเรื่อง และคุ้มที่จะรู้ว่าแพ้เพราะอะไร
L ต้องอ่านตัวอักษรครบ
L ตัวเพื่อคำนวณค่าแฮชอยู่แล้ว แล้วยังต้องเทียบสตริงเต็มตอนชนช่องเดียวกัน
ส่วนไทรเดินทีละตัวอักษร L ก้าวโดยไม่มีการแฮชและไม่มีการเทียบสตริงเลย
บทเรียนที่ยกไปใช้ได้คือ ตารางแฮชแลกความง่ายด้วยการทิ้งโครงสร้างของข้อมูลไป มันเก็บทุกคำนำหน้าเป็นก้อนแยกกันที่ไม่รู้จักกัน ส่วนไทรเก็บความสัมพันธ์ระหว่างคำนำหน้าไว้ด้วย พอโจทย์ถามอะไรที่ต้องใช้ความสัมพันธ์นั้น ทางที่ทิ้งมันไปแล้วจะตอบไม่ได้ เจอโจทย์สตริงครั้งหน้าให้ถามก่อนว่า คำถามนี้ต้องรู้แค่ "มีหรือไม่มี" หรือต้องรู้ว่า "เดินต่อไปไหนได้"
ญาติสนิทของไทรที่ควรรู้ว่ามีอยู่ คือแฮชนำหน้า ซึ่งตอบคำถามคนละแบบ ไทรเก่งเรื่อง "ส่วนหัวร่วมกัน" ส่วนแฮชเก่งเรื่อง "ช่วงตรงกลางสองช่วงเหมือนกันไหม" อ่านได้ที่ แฮชสตริง
ไทรคือการเอาคำทั้งคลังมาซ้อนกันตามส่วนหัว ทำให้คำถามที่เกี่ยวกับส่วนหัวมีราคาเท่ากับความยาวของคำถาม ไม่เกี่ยวกับขนาดของคลัง และเพราะปมหนึ่งปมคือคำนำหน้าหนึ่งอัน มันจึงใช้เป็นแผนที่ของสถานะได้ด้วย ไม่ใช่แค่ที่เก็บคำ
ในหน้านี้