ปูพื้นฐาน

ไทร: ทำให้คำถามเรื่องส่วนหัวมีราคาเท่าความยาวคำถาม ไม่ว่าคลังจะใหญ่แค่ไหน

โครงสร้างที่คนมองว่าไว้เก็บคำ แต่ของจริงคือแผนที่ของสถานะ บทนี้ให้ทั้งการใช้แบบตรงไปตรงมา การพลิกไปใช้กับตัวเลขฐานสอง และวิธีคำนวณหน่วยความจำก่อนตัดสินใจใช้ ซึ่งเป็นขั้นที่คนข้ามแล้วตกทีหลัง

บทปูพื้นฐาน ★★☆☆☆ triestringพื้นฐาน อ่าน 10 นาที 7 กันยายน 2026

ปัญหาที่บทนี้แก้

เวลามีคำอยู่เป็นหมื่นเป็นแสนคำ แล้วต้องถามซ้ำ ๆ ว่า "คำนี้อยู่ในคลังไหม" หรือ "มีกี่คำในคลังที่เป็นส่วนหัวของสตริงนี้" การไล่เทียบทีละคำจะเป็น จำนวนคำ × ความยาว ต่อหนึ่งคำถาม ซึ่งช้าเกินไปทันทีที่คำถามเยอะ

ไทร (trie) แก้ปัญหานี้ด้วยการเอาคำทั้งหมดมาซ้อนกันเป็นต้นไม้ตามส่วนหัวที่ใช้ร่วมกัน ค่าใช้จ่ายของคำถามหนึ่งคำถามจะเหลือแค่ความยาวของสตริงที่ถาม ไม่เกี่ยวกับว่าคลังใหญ่แค่ไหนเลย

แกะคำศัพท์

trie อ่านว่า "ไทร" ชื่อนี้ตัดมาจากกลางคำว่า retrieval ที่แปลว่า การค้นคืน คนตั้งชื่อคือ Edward Fredkin (อ่านว่า "เอ็ดเวิร์ด เฟรดคิน" นักวิทยาการคอมพิวเตอร์) เขาอยากให้อ่านว่า "ทรี" เหมือน tree เพราะมันคือต้นไม้ แต่คนใช้จริงกลับอ่าน "ไทร" เพื่อไม่ให้ชนกับคำว่า tree เวลาพูดกัน

โครงสร้างที่ต้องเห็นในหัวก่อน

ปมรากคือสตริงว่าง ปมลูกของปม p คือ p ที่ต่อท้ายด้วยหนึ่งตัวอักษร ดังนั้นปมหนึ่งปมคือคำนำหน้าหนึ่งอัน และเส้นทางจากรากลงมาถึงปมคือการสะกดคำนำหน้านั้น ปมที่เป็นจุดจบของคำจริง ๆ ในคลังจะติดธงไว้

. c a t r d d o g cat car card do dog
คำที่ใช้ส่วนหัวร่วมกันจะใช้ปมร่วมกัน คำ 5 คำที่รวมกันยาว 15 ตัวอักษร จึงเหลือปมแค่ 8 ปม และปมที่วงไว้คือจุดจบของคำจริง (วาดประกอบโดยผู้เขียน)
คำ 5 คำ กลายเป็นไทรกี่ปม
รายการจำนวน
ตัวอักษรรวมทุกคำ15
ปมของไทร (ไม่นับราก)8
ปมที่ประหยัดไปได้เพราะใช้ส่วนหัวร่วมกัน7
จำนวนปมไม่มีวันเกินผลรวมความยาวของทุกคำ นี่คือเพดานหน่วยความจำที่ใช้ประเมินได้ตรง ๆ ปมทั้งหมดคือ c, ca, car, card, cat, d, do, dog

ระวัง

ไทรที่เก็บลูกเป็นอาเรย์ 26 ช่องต่อปม กินหน่วยความจำ จำนวนปม × 26 × 4 ไบต์ คำแสนคำ คำละยี่สิบตัวอักษร ถ้าไม่มีส่วนหัวซ้ำกันเลยจะเป็นสองล้านปม คูณแล้วได้ราว 208 เมกะไบต์ ซึ่งเกินลิมิตของโจทย์ส่วนใหญ่

ก่อนเลือกไทรทุกครั้ง ให้คูณเลขนี้ออกมาก่อน ถ้าเกิน ก็มีทางเลือกคือ ใช้ map ต่อปม (ช้ากว่าแต่ประหยัด) หรือถ้าตัวอักษรมีน้อยชนิด (เช่นสองชนิด) ก็เก็บแค่สองช่อง

ลองเอง · พิมพ์ให้สั้นที่สุดที่ยังชี้คำเดียวได้

คลังเล็ก ๆ ลองพิมพ์ทีละตัวแล้วดูว่าเหลือกี่คำ

อยากชี้ไปที่คำว่า car

พิมพ์ทีละตัวอักษร แล้วดูว่าคลังเหลือกี่คำที่ยังเข้าได้

ส่วนหัวตอนนี้ (ยังไม่พิมพ์) · ยังชี้ได้ 0 คำ

ราคาของการพิมพ์แต่ละตัวคือหนึ่งก้าวบนไทร ไม่ว่าคลังจะมีกี่คำ

สังเกตว่าจำนวนคำที่ยังเข้าได้ ลดลงเรื่อย ๆ ตามที่พิมพ์ ตัวเลขนั้นแหละคือของที่ควรเก็บไว้ที่ปมของไทร

โจทย์ฝึกข้อที่ 1 · นับส่วนหัวที่อยู่ในคลัง

ให้คำในคลัง n คำ แล้วมีคำถาม q ข้อ แต่ละข้อให้สตริงมาหนึ่งตัว ตอบว่ามีกี่คำในคลังที่เป็นส่วนหัวของสตริงนั้น (คำในคลังซ้ำกันได้)

อินพุต / ขอบเขต / เอาต์พุต

EXAMPLE
InputOutput
5 1
cat
car
card
dog
do
card
2

อ่านตัวอย่างนี้ยังไง

บรรทัดแรกคือจำนวนคำในคลังกับจำนวนคำถาม จากนั้นคือคำในคลังบรรทัดละคำ แล้วคำถามบรรทัดละคำ เอาต์พุตคือจำนวนคำ ไม่ใช่ตัวคำ

คำว่า "ส่วนหัว" หมายถึงคำที่ตรงกับตัวอักษรต้น ๆ ของคำถามพอดี และตัวคำถามเองก็นับ ถ้ามันอยู่ในคลัง ชุดนี้คำถามคือ card คำในคลังที่เป็นส่วนหัวของมันคือ car, card รวม 2 คำ

วิธีอ่านที่ตรงกับโครงสร้างไทรที่สุดคือเดินตามตัวอักษรของคำถามทีละตัว แล้วถามที่ทุกจุดว่า "ตรงนี้มีคำในคลังจบพอดีไหม" ซึ่งเป็นการเดินครั้งเดียว ไม่ใช่ไล่เทียบทีละคำในคลัง

คลังมี cat car card dog do c c ไม่มีคำจบ 0 a ca ไม่มีคำจบ 0 r car มีคำจบตรงนี้ 1 d card มีคำจบตรงนี้ 2 เดินจบได้ 2 ซึ่งคือเอาต์พุต
เดินตามตัวอักษรของคำถามทีละตัว จุดสีเขียวคือจุดที่มีคำในคลังจบพอดี ตัวเลขล่างคือจำนวนที่นับได้จนถึงตรงนั้น ตัวสุดท้ายคือเอาต์พุต

ใบ้

คำในคลังที่เป็นส่วนหัวของ card คือคำที่อยู่บนเส้นทางจากรากลงไปหา card พอดี แล้วเราจะเดินเส้นทางนั้นครั้งเดียวโดยเก็บอะไรระหว่างทางดี

เฉลยข้อที่ 1

ที่มาของท่านี้ · ขั้นที่คนข้ามแล้วตกทีหลัง

ก่อนตัดสินใจใช้ไทร มีขั้นหนึ่งที่ต้องทำเสมอและคนข้ามกันบ่อยที่สุด คือ คูณเลขหน่วยความจำออกมาดูก่อน ไทรไม่ได้แพงที่เวลา มันแพงที่หน่วยความจำ และมันแพงแบบที่ไม่โผล่มาให้เห็นจนกว่าจะส่งข้อสอบแล้วโดนตีกลับ

ลองคิดกรณีที่แย่ที่สุดดู คำหนึ่งแสนคำ คำละยี่สิบตัวอักษร และไม่มีคำไหนใช้ส่วนหัวร่วมกันเลย นั่นคือ สองล้านปม ถ้าแต่ละปมเก็บลูกเป็นอาเรย์ยาว 26 ช่อง ช่องละสี่ไบต์ ก็คือ 2,000,000 × 26 × 4 ได้ราว 208 เมกะไบต์ ซึ่งเกินลิมิตของโจทย์ส่วนใหญ่ไปแล้ว

รู้เลขนี้ก่อนมีประโยชน์ตรงที่มันบอกว่าต้องแก้ทางไหน จะลดขนาดตัวอักษรลง จะเปลี่ยนลูกจากอาเรย์เป็นแมป แลกความเร็วกับที่ หรือจะเลิกใช้ไทรไปใช้การเรียงกับการค้นแบบไบนารีแทน ทั้งสามทางตัดสินใจได้ตั้งแต่ก่อนเขียนโค้ด

บทเรียนที่ยกไปข้ออื่นได้คือ โครงสร้างที่เก็บปมต่อตัวอักษรต้องประเมินที่ก่อนเสมอ และประเมินด้วยการคูณจริง ไม่ใช่ด้วยความรู้สึกว่าหนึ่งแสนคำมันไม่เยอะ

เดินจากรากไปตามตัวอักษรของคำถาม ทุกปมที่เดินผ่านคือส่วนหัวหนึ่งอัน ก็แค่บวกธง "ปมนี้เป็นจุดจบของคำในคลังกี่คำ" สะสมไป ถ้าเดินไปเจอทางตัน แปลว่าส่วนหัวที่ยาวกว่านั้นไม่มีในคลังแน่ ๆ หยุดได้เลย

ในตัวอย่าง คำในคลังที่เป็นส่วนหัวของ card คือ car, card รวม 2 คำ ส่วน cat ไม่นับ เพราะเส้นทางแยกออกไปคนละกิ่งตั้งแต่ตัวที่สาม

prefix_count.cpp
// นับว่าในคลัง มีกี่คำที่เป็นส่วนหัวของสตริงคำถาม  (ตอบทุกคำถามในเวลารวมเชิงเส้น)
#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 ชุดเทียบกับตัวไล่เทียบทีละคำ ตรงกันหมด

โจทย์ฝึกข้อที่ 2 · คู่ที่ให้ค่า XOR มากที่สุด

ให้จำนวนเต็มไม่ติดลบ n ตัว หาคู่ที่ a XOR b มากที่สุด (XOR อ่านว่า "เอ็กซ์ออร์" คือการเทียบทีละบิต ได้ 1 เมื่อสองบิตต่างกัน)

EXAMPLE
InputOutput
6
3 10 5 25 2 8
28

อ่านตัวอย่างนี้ยังไง

อินพุตคือจำนวนตัวเลขแล้วตามด้วยตัวเลข เอาต์พุตคือค่า XOR ที่มากที่สุด ไม่ใช่คู่ที่ให้ค่านั้น และคู่ต้องเป็นเลขคนละตัวในรายการ

ชุดนี้คู่ที่ชนะคือ 5 กับ 25 ได้ 28 กฎง่าย ๆ ที่คนคิดออกก่อนคือ "จับตัวมากสุดกับตัวน้อยสุด" ซึ่งชุดนี้ได้แค่ 27 เพราะ XOR ไม่ได้สนใจว่าเลขไหนใหญ่ มันสนใจว่าบิตไหนต่างกัน

พอเขียนทุกตัวเป็นเลขฐานสองความยาวเท่ากัน โจทย์นี้ก็กลายเป็นโจทย์สตริงทันที และคำถามกลายเป็น "หาคำที่ต่างจากคำนี้ให้ได้มากที่สุดตั้งแต่ตัวหน้า ๆ"

เทียบทีละหลัก ต่างกันได้ 1 เหมือนกันได้ 0 5 25 ได้ 28 0 1 1 0 1 1 1 0 1 0 0 0 1 1 0
คู่ที่ชนะเขียนเป็นเลขฐานสอง เทียบทีละหลัก หลักที่สองบิตต่างกันจะได้ 1 ยิ่งได้ 1 ที่หลักซ้ายเร็วเท่าไร ค่ายิ่งมาก

ใบ้

ข้อนี้ดูไม่เกี่ยวกับสตริงเลย แต่ลองเขียนเลขทุกตัวเป็นสตริงฐานสองความยาวเท่ากันดู แล้วถามว่าถ้าอยากให้บิตซ้ายสุดของผลลัพธ์เป็น 1 เราต้องหาคู่ที่มีบิตซ้ายสุดเป็นอะไร

เฉลยข้อที่ 2

เขียนทุกเลขเป็นสตริงฐานสองแล้วยัดลงไทรที่มีลูกแค่สองช่องคือ 0 กับ 1 จากนั้นสำหรับเลขแต่ละตัว ให้เดินลงไทรจากบิตสูงสุด โดยพยายามเลี้ยวไปทางที่บิตตรงข้ามกับตัวเองเสมอ เพราะบิตที่ต่างกันให้ผล XOR เป็น 1

ที่ท่านี้ถูกต้องเพราะบิตซ้ายสุดมีน้ำหนักมากกว่าบิตที่เหลือรวมกันทั้งหมด ได้บิตซ้ายเป็น 1 หนึ่งบิต คุ้มกว่าได้บิตขวาเป็น 1 ทุกบิต การเลือกแบบโลภทีละบิตจึงไม่มีทางพลาด

ตัวอย่าง 6 ตัว เขียนเป็นฐานสอง 5 บิต
ค่าฐานสอง
300011
1001010
500101
2511001
200010
801000
คู่ที่ดีที่สุดคือ 5 กับ 25 ให้ค่า 28 (00101 เทียบกับ 11001 ต่างกันที่บิตซ้ายสุด ซึ่งเป็นสิ่งเดียวที่ท่าโลภมองหา)
max_xor.cpp
// คู่ที่ให้ค่า 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 รายการ ยังไหวอยู่ แต่ทุกรายการเป็นสตริง ไม่ใช่ตัวเลข

prefix_map.cpp
// นับคำในคลังที่มีคำนำหน้าตามที่ถาม โดยใช้ตารางแฮชของคำนำหน้าทุกอัน
// อินพุต: 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;
}

ทางนี้แพ้ไทรอยู่สามเรื่อง และคุ้มที่จะรู้ว่าแพ้เพราะอะไร

ตัวตรวจของทางนี้

ผมสุ่มเทียบทางตารางแฮชกับการเดินเทียบทุกคำในคลังตรง ๆ 700 รอบ ตรงกันทุกรอบ ชุดสุ่มใช้ตัวอักษรแค่สองตัวและคำสั้น เพื่อบังคับให้มีคำนำหน้าซ้ำกันเยอะที่สุด ซึ่งเป็นบริเวณที่ตัวนับจะพลาดถ้านับซ้ำหรือนับขาด

brute_prefix_count.cpp
// ตัวตรวจอิสระ: เทียบคำนำหน้ากับทุกคำในคลังตรง ๆ
// ไม่มีตารางแฮช ไม่มีไทร
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, q;
    if (scanf("%d %d", &n, &q) != 2) return 0;
    vector<string> words(n);
    for (int i = 0; i < n; i++) {
        char buf[64];
        scanf("%s", buf);
        words[i] = buf;
    }
    for (int i = 0; i < q; i++) {
        char buf[64];
        scanf("%s", buf);
        string p = buf;
        int c = 0;
        for (const string& w : words)
            if (w.size() >= p.size() && w.compare(0, p.size(), p) == 0) c++;
        printf("%d\n", c);
    }
    return 0;
}

บทเรียนที่ยกไปใช้ได้คือ ตารางแฮชแลกความง่ายด้วยการทิ้งโครงสร้างของข้อมูลไป มันเก็บทุกคำนำหน้าเป็นก้อนแยกกันที่ไม่รู้จักกัน ส่วนไทรเก็บความสัมพันธ์ระหว่างคำนำหน้าไว้ด้วย พอโจทย์ถามอะไรที่ต้องใช้ความสัมพันธ์นั้น ทางที่ทิ้งมันไปแล้วจะตอบไม่ได้ เจอโจทย์สตริงครั้งหน้าให้ถามก่อนว่า คำถามนี้ต้องรู้แค่ "มีหรือไม่มี" หรือต้องรู้ว่า "เดินต่อไปไหนได้"

เอาไปใช้ที่ไหนในคลังนี้

ญาติสนิทของไทรที่ควรรู้ว่ามีอยู่ คือแฮชนำหน้า ซึ่งตอบคำถามคนละแบบ ไทรเก่งเรื่อง "ส่วนหัวร่วมกัน" ส่วนแฮชเก่งเรื่อง "ช่วงตรงกลางสองช่วงเหมือนกันไหม" อ่านได้ที่ แฮชสตริง

สรุปบรรทัดเดียว

ไทรคือการเอาคำทั้งคลังมาซ้อนกันตามส่วนหัว ทำให้คำถามที่เกี่ยวกับส่วนหัวมีราคาเท่ากับความยาวของคำถาม ไม่เกี่ยวกับขนาดของคลัง และเพราะปมหนึ่งปมคือคำนำหน้าหนึ่งอัน มันจึงใช้เป็นแผนที่ของสถานะได้ด้วย ไม่ใช่แค่ที่เก็บคำ