ปูพื้นฐาน

แฮชสตริง: เทียบข้อความสองช่วงในเวลาคงที่ โดยรู้ตัวว่ากำลังแลกอะไรอยู่

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

บทปูพื้นฐาน ★★☆☆☆ hashingstringพื้นฐาน อ่าน 13 นาที 6 กันยายน 2026

อาการ · คำถามที่ตอบครั้งเดียวก็แพงแล้ว

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

การเทียบหนึ่งครั้งกินเวลาเท่าความยาวช่วง คำถามที่ถามช่วงยาวทั้งเส้นจึงกิน 200,000 ครั้ง และถาม 200,000 ข้อ คือ 40.0 พันล้าน ครั้ง ปัญหาไม่ได้อยู่ที่เราเขียนโค้ดไม่ดี ปัญหาคือเราจ่ายค่าความยาวช่วงซ้ำทุกครั้งที่ถูกถาม ทั้งที่ข้อความไม่เคยเปลี่ยน

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

ลองเอง · สองช่วงนี้เหมือนกันไหม

ข้อความสั้น ๆ ลองเทียบด้วยตาก่อน

คำถามตอนนี้

ดูสองช่วงที่ถูกไฮไลต์ แล้วตอบว่าเหมือนกันไหม

ตอบถูกแล้ว 0 จาก 0 ข้อ

ช่วงแรกเป็นสีเขียว ช่วงที่สองเป็นสีฟ้า ช่องที่ทับกันทั้งสองช่วงเป็นสีทอง

ตอนตอบด้วยตา คุณต้องกวาดดูกี่ตัวอักษรต่อหนึ่งคำถาม แล้วถ้าโจทย์ถามสองแสนครั้งล่ะ

แนวคิด · มองข้อความเป็นตัวเลขฐานหนึ่ง

เลข 4913 ในฐานสิบคือ 4×1000 + 9×100 + 1×10 + 3 เราอ่านมันเป็นตัวเลขก้อนเดียวได้ ทั้งที่มันเป็นลำดับของหลักสี่หลัก ข้อความก็ทำแบบเดียวกันได้ เลือกเลขฐานมาสักตัวเรียกว่า B แล้วมองตัวอักษรแต่ละตัวเป็นหลักหนึ่งหลัก

ค่าที่ได้เรียกว่าแฮช (hash) อ่านว่า "แฮช" แปลตรงตัวว่าสับให้ละเอียด เป็นคำเดียวกับที่ใช้เรียกอาหาร จำพวกเนื้อสับ ความหมายในทางคอมพิวเตอร์คือการยุบข้อมูลก้อนใหญ่ให้เหลือตัวเลขก้อนเดียวที่ใช้แทนมันได้ ส่วนวิธีที่ใช้ในบทนี้เรียกเต็ม ๆ ว่า polynomial hashing เพราะสูตรของมันคือพหุนามตัวหนึ่ง ที่มีตัวอักษรเป็นสัมประสิทธิ์

เก็บค่าสะสมไว้ล่วงหน้าแบบเดียวกับผลรวมสะสมในบทก่อน โดย h[i] คือค่าของข้อความตั้งแต่ตัวแรกถึงตัวที่ i

เอาข้อความ abcab มาไล่ให้เห็นจริง โดยให้ a เป็น 1, b เป็น 2, c เป็น 3 และใช้ B = 31 ตารางนี้ยังไม่มีมอดุลัส ตัวเลขจึงตรวจด้วยมือได้

แฮชนำหน้าของ abcab
i ตัวอักษร คิดยังไง h[i]
0 · ค่าตั้งต้น 0
1 a 0 × 31 + 1 1
2 b 1 × 31 + 2 33
3 c 33 × 31 + 3 1026
4 a 1026 × 31 + 1 31807
5 b 31807 × 31 + 2 986019
ตัวอักษรแปลงเป็น 1, 2, 3 แทนที่จะเป็น 0, 1, 2 ด้วยเหตุผลเดียว ถ้า a เป็นศูนย์ ข้อความ a, aa, aaa จะได้ค่าเท่ากันหมดคือศูนย์

ตัดช่วงกลาง · เลื่อนแล้วลบ

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

h[4] a b c a b = 31807 h[1] × B^3 a 0 0 0 0 = 29791 ผลลบ 0 b c a 0 = 2016 ลบกัน
ก้อนใหญ่มีส่วนนำหน้าลอยอยู่ที่หลักสูง คูณส่วนนำหน้าด้วย B ยกกำลังความยาวช่วง เพื่อดันมันไปยืนตรงหลักเดียวกัน แล้วลบออก เหลือเฉพาะช่วงที่ต้องการ (วาดประกอบโดยผู้เขียน)

ตรวจด้วยมือได้เลยว่าเลข 2016 ที่ได้ คือค่าของ bca จริง ๆ ถ้ากางพหุนามออกมาตรง ๆ

กางค่าของ bca ออกทีละหลัก
ตัวอักษรค่าหลักผลคูณ
b 2 961 1922
c 3 31 93
a 1 1 1
รวม · · 2016
ตรงกับผลลบในภาพข้างบนพอดี นี่คือเหตุผลที่การเทียบสองช่วงกลายเป็นการเทียบเลขสองตัว และในข้อความสั้น ๆ นี้ก็มีของซ้ำให้เห็นแล้ว ช่วง ab ที่ตำแหน่ง 0 กับที่ตำแหน่ง 3 ได้ค่าเท่ากันคือ 33

สามอย่างที่ต้องเลือกให้ถูก

หนึ่ง มอดุลัส

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

ค่าที่ผมใช้ประจำคือ 2^61 − 1 ซึ่งเป็นจำนวนเฉพาะ และมีคุณสมบัติที่ทำให้การคูณเร็วเป็นพิเศษ คือผลคูณ 122 บิตพับกลับเข้ามาได้ด้วยการเลื่อนบิตกับบวก ไม่ต้องเรียกคำสั่งหารจริงเลย ส่วนมอดุลัสยอดนิยมอย่าง 10^9 + 7 ก็ใช้ได้ แต่เล็กกว่ามาก จนต้องทำสองชุดคู่กัน (double hashing) ถึงจะปลอดภัยพอ ๆ กัน

สอง ฐานต้องสุ่ม

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

สาม ยอมรับว่ามันผิดได้

แฮชยุบข้อความยาว ๆ ให้เหลือเลขก้อนเดียว ข้อความที่ต่างกันจึงมีทางได้เลขเดียวกัน เรียกว่าการชนกัน (collision) เราไม่ได้กำจัดมัน เราแค่ทำให้โอกาสเกิดเล็กจนไม่ต้องสนใจ

คิดคร่าว ๆ ได้ว่าถ้าโปรแกรมเทียบข้อความที่ต่างกันทั้งหมด k คู่ โอกาสที่จะมีสักคู่หนึ่งชนกัน ไม่เกิน k หารด้วยมอดุลัส ที่ k เท่ากับหนึ่งหมื่นล้านคู่ ซึ่งมากกว่าที่โจทย์ไหนจะทำได้จริง ตัวเลขที่ได้คือประมาณ 4.3e-9 หรือราวหนึ่งในสองร้อยล้าน

เทียบสองท่า · นับเป็นจำนวนครั้งที่แตะตัวอักษร
ท่าเตรียมตอนต้นเทียบหนึ่งครั้งรวมตอบถูกเสมอไหม
วนเทียบทีละตัวอักษร ไม่ต้องเตรียม ยาวช่วง สูงสุด n 40.0 พันล้าน ถูกเสมอ
แฮชนำหน้า n คงที่ 400,000 ผิดได้ราว 4.3e-9
คิดที่ n = 200,000 และคำถาม 200,000 ข้อ แถวล่างเร็วกว่าหนึ่งแสนเท่า โดยจ่ายด้วยความถูกต้องที่ไม่ใช่ร้อยเปอร์เซ็นต์ ซึ่งเป็นการแลกที่คุ้มเกือบทุกครั้ง แต่ต้องรู้ตัวว่ากำลังแลกอยู่

ระวัง

จุดที่พลาดบ่อยที่สุดคือช่วงกลับด้าน ถ้าช่วงที่สนใจในข้อความปกติคือ [l, r] ช่วงเดียวกันในข้อความที่กลับด้านแล้วอยู่ที่ [n − 1 − r, n − 1 − l] ไม่ใช่ [l, r] เขียนผิดตรงนี้แล้วโปรแกรมยังรันปกติและตอบถูกบ้างผิดบ้าง ซึ่งเป็นบักที่หาเจอยากที่สุดถ้าไม่มีตัวสุ่มเทียบ

เขียนเป็นโค้ด

hashing.cpp
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;

// มอดุลัส 2^61 - 1 เป็นจำนวนเฉพาะ และคูณกันแล้ว "พับ" ด้วยการเลื่อนบิตได้ ไม่ต้องหารจริง
const ull MOD = (1ULL << 61) - 1;
static inline ull add(ull a, ull b) { a += b; if (a >= MOD) a -= MOD; return a; }
static inline ull sub(ull a, ull b) { return add(a, MOD - b); }
static inline ull mul(ull a, ull b) {
    __uint128_t c = (__uint128_t)a * b;
    return add((ull)(c & MOD), (ull)(c >> 61));   // ส่วนบน 3 บิต บวกกลับเข้าส่วนล่าง 61 บิต
}

int n;
ull B;
vector<ull> h, pw;

void build(const string& s) {
    n = s.size();
    h.assign(n + 1, 0);
    pw.assign(n + 1, 1);
    for (int i = 0; i < n; i++) {
        h[i + 1] = add(mul(h[i], B), (ull)(s[i]));
        pw[i + 1] = mul(pw[i], B);
    }
}

// ค่าแฮชของ s[l .. l+len-1] โดย l นับจากศูนย์
ull cut(int l, int len) { return sub(h[l + len], mul(h[l], pw[len])); }

int main() {
    string s;
    int q;
    cin >> s >> q;

    // ฐานสุ่มใหม่ทุกครั้งที่รัน คนแต่งอินพุตมาชนแฮชไม่ได้ถ้าไม่รู้ว่าฐานคืออะไร
    mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());
    B = rng() % (MOD - 300) + 256;
    build(s);

    while (q--) {
        int l1, l2, len;
        cin >> l1 >> l2 >> len;                   // ถามว่าสองช่วงนี้เหมือนกันไหม
        cout << (cut(l1, len) == cut(l2, len) ? "YES" : "NO") << '\n';
    }
    return 0;
}

โจทย์ฝึก · ไล่จากง่ายไปยาก

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

ฝึกข้อ 1 ★☆☆☆☆ · นับหน้าต่างที่ไม่ซ้ำกัน

โจทย์กำหนด

ให้ข้อความยาว n ≤ 200 000 กับเลข k ถามว่าข้อความย่อยความยาว k ที่ตัดจากมัน มีทั้งหมดกี่แบบที่ไม่ซ้ำกัน เช่น abcab กับ k = 2 ตัดได้ 4 ชิ้น แต่มี 3 แบบ

EXAMPLE
InputOutput
abcabcbb
3
5

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

อินพุตคือข้อความหนึ่งบรรทัดแล้วค่า k เอาต์พุตคือจำนวนแบบที่ไม่ซ้ำกัน ไม่ใช่จำนวนชิ้นที่ตัดได้ ข้อความย่อยในที่นี้คือตัวอักษรที่ติดกันเท่านั้น ไม่ใช่การหยิบข้ามตัว

ข้อความยาว 8 ตัวกับ k เท่ากับ 3 จะเลื่อนหน้าต่างได้ 6 ตำแหน่ง แต่บางตำแหน่งได้ข้อความซ้ำกับที่เคยเจอแล้ว จึงเหลือ 5 แบบ

abcabcbb a b c a b c b b abc แบบใหม่ a b c a b c b b bca แบบใหม่ a b c a b c b b cab แบบใหม่ a b c a b c b b abc เคยเจอแล้ว a b c a b c b b bcb แบบใหม่ a b c a b c b b cbb แบบใหม่ นับแบบใหม่ได้ 5 ซึ่งคือเอาต์พุต
หน้าต่างทุกตำแหน่งที่ตัดได้ แถวสีเขียวคือแบบที่เพิ่งเจอครั้งแรก แถวสีจางคือแบบที่เคยเจอแล้ว จำนวนแถวสีเขียวคือเอาต์พุต

คำใบ้

ถ้าเก็บข้อความย่อยเป็นสตริงลงเซ็ต ทุกครั้งที่ใส่ต้องคัดลอกตัวอักษร k ตัว รวมแล้วยังเป็น n × k อยู่ดี แล้วถ้าเก็บอย่างอื่นที่ไม่ใช่ตัวสตริงล่ะ

ที่มาของท่านี้ · และตัวเลขที่ผมทดลองเอง

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

นี่คือเหตุผลที่โค้ดในบทนี้ใช้มอดุลัส 2⁶¹ − 1 ไม่ใช่ 10⁹ + 7 ที่คนใช้กันติดมือ ผมอยากรู้ว่าตัวหลังอันตรายแค่ไหนจริง ๆ เลยทดลองตรง ๆ คือสุ่มค่าในช่วง 10⁹ + 7 ทีละตัวจนกว่าจะเจอค่าที่เคยออกไปแล้ว ทำสามสิบรอบ ผลคือค่ากลางอยู่ที่ราว 36,700 ตัว และรอบที่โชคร้ายที่สุดชนกันตั้งแต่ตัวที่ 901

เอาไปวางข้างขอบเขตของข้อนี้ ข้อความยาวสองแสนตัวให้หน้าต่างได้ราวสองแสนอัน ซึ่งมากกว่าเส้นค่ากลางนั้นห้าเท่า ถ้าใช้ 10⁹ + 7 ตัวเดียว การชนกันจึงไม่ใช่โชคร้ายที่นาน ๆ เกิดที มันคือสิ่งที่คาดหมายได้ และเวลามันเกิด คำตอบจะน้อยกว่าความจริงไปเงียบ ๆ เพราะของสองแบบถูกนับเป็นแบบเดียว พอเปลี่ยนเป็น 2⁶¹ − 1 เส้นเดียวกันนั้นขยับไปอยู่ที่ระดับพันล้าน ซึ่งพ้นขอบเขตของโจทย์ไปไกล

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

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

ฝึกข้อ 1 · แฮชลงเซ็ต
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const ull MOD = (1ULL << 61) - 1;
static inline ull add(ull a, ull b) { a += b; if (a >= MOD) a -= MOD; return a; }
static inline ull sub(ull a, ull b) { return add(a, MOD - b); }
static inline ull mul(ull a, ull b) {
    __uint128_t c = (__uint128_t)a * b;
    return add((ull)(c & MOD), (ull)(c >> 61));
}

int main() {
    int k;
    string s;
    cin >> k >> s;
    int n = s.size();
    if (k > n) { cout << 0 << '\n'; return 0; }

    mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());
    ull B = rng() % (MOD - 300) + 256;
    vector<ull> h(n + 1, 0), pw(n + 1, 1);
    for (int i = 0; i < n; i++) {
        h[i + 1] = add(mul(h[i], B), (ull)(s[i]));
        pw[i + 1] = mul(pw[i], B);
    }
    auto cut = [&](int l, int len) { return sub(h[l + len], mul(h[l], pw[len])); };

    unordered_set<ull> seen;
    seen.reserve(n * 2);
    for (int i = 0; i + k <= n; i++) seen.insert(cut(i, k));
    cout << seen.size() << '\n';
    return 0;
}

สุ่มเทียบกับตัวที่เก็บสตริงจริงลง set 400 เทส โดยจงใจใช้ตัวอักษรแค่สองแบบเพื่อบังคับให้มีของซ้ำเยอะ และมีทั้งกรณีที่ k ใหญ่กว่าความยาวข้อความ ตรงกันทั้งหมด


ฝึกข้อ 2 ★★☆☆☆ · ช่วงนี้อ่านกลับแล้วเหมือนเดิมไหม

โจทย์กำหนด

ให้ข้อความยาว n กับคำถาม q ข้อ แต่ละข้อให้ l กับ r ถามว่าช่วงนั้นเป็นพาลินโดรม (อ่านหน้าไปหลังกับหลังมาหน้าได้เหมือนกัน) หรือเปล่า ทั้ง n และ q ใหญ่ได้ถึงสองแสน

EXAMPLE
InputOutput
abacaba
4
1 7
2 4
1 4
4 4
YES
NO
NO
YES

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

อินพุตคือข้อความ แล้วจำนวนคำถาม แล้วคำถามบรรทัดละหนึ่งข้อในรูป l r ตำแหน่งเริ่มนับที่ 1 และช่วงนี้รวมทั้งตัวที่ l และตัวที่ r เอาต์พุตคือคำตอบของแต่ละคำถาม บรรทัดละหนึ่งคำตอบ

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

a 1 b 2 a 3 c 4 a 5 b 6 a 7 a b a c a b a abacaba กลับด้านเป็น abacaba YES a b a c a b a bac กลับด้านเป็น cab NO a b a c a b a abac กลับด้านเป็น caba NO a b a c a b a c กลับด้านเป็น c YES
แต่ละแถวคือหนึ่งคำถาม ตัวอักษรสีเข้มคือช่วงที่ถูกถาม ทางขวาคือช่วงนั้นเขียนกลับด้าน ถ้าสองอันตรงกันคำตอบคือ YES

คำใบ้

"อ่านกลับแล้วเหมือนเดิม" แปลว่าช่วงนี้เท่ากับช่วงนี้ที่กลับด้าน ซึ่งเป็นการเทียบสองอย่าง แต่เรามีตารางแฮชของข้อความต้นฉบับอยู่ชุดเดียว

สร้างตารางแฮชสองชุด ชุดหนึ่งของข้อความปกติ อีกชุดของข้อความที่กลับด้านไว้ตั้งแต่แรก แล้วเทียบช่วง [l, r] ของชุดแรกกับช่วงที่สะท้อนตำแหน่งกันของชุดที่สอง ตอบได้ในเวลาคงที่ต่อคำถาม

ฝึกข้อ 2 · สองตารางสวนทางกัน
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const ull MOD = (1ULL << 61) - 1;
static inline ull add(ull a, ull b) { a += b; if (a >= MOD) a -= MOD; return a; }
static inline ull sub(ull a, ull b) { return add(a, MOD - b); }
static inline ull mul(ull a, ull b) {
    __uint128_t c = (__uint128_t)a * b;
    return add((ull)(c & MOD), (ull)(c >> 61));
}

int n;
vector<ull> pw;
static inline ull cut(const vector<ull>& h, int l, int len) {
    return sub(h[l + len], mul(h[l], pw[len]));
}

int main() {
    string s;
    int q;
    cin >> s >> q;
    n = s.size();

    mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());
    ull B = rng() % (MOD - 300) + 256;

    string r = s;
    reverse(r.begin(), r.end());
    vector<ull> hf(n + 1, 0), hb(n + 1, 0);
    pw.assign(n + 1, 1);
    for (int i = 0; i < n; i++) {
        hf[i + 1] = add(mul(hf[i], B), (ull)s[i]);
        hb[i + 1] = add(mul(hb[i], B), (ull)r[i]);
        pw[i + 1] = mul(pw[i], B);
    }

    while (q--) {
        int l, rr;
        cin >> l >> rr;
        l--; rr--;                                  // โจทย์นับจาก 1 โค้ดนับจาก 0
        int len = rr - l + 1;
        // ช่วง [l, rr] เมื่ออ่านกลับด้าน ไปอยู่ที่ [n-1-rr, n-1-l] ของสตริงที่กลับแล้ว
        bool ok = cut(hf, l, len) == cut(hb, n - 1 - rr, len);
        cout << (ok ? "YES" : "NO") << '\n';
    }
    return 0;
}

สุ่มเทียบกับตัวที่กลับสตริงจริงแล้วเทียบตรง ๆ 400 เทส โดยจงใจให้ a ออกบ่อยกว่าตัวอื่น เพื่อบังคับให้มีพาลินโดรมโผล่ถี่ ๆ ตรงกันทั้งหมด

ท่านี้คือครึ่งหนึ่งของข้อ สี่เหลี่ยมพิฆาต ตรง ๆ ต่างกันแค่ที่นั่นเทียบ แถวบนกับแถวล่างของจัตุรัส แทนที่จะเทียบช่วงกับตัวมันเอง


ฝึกข้อ 3 ★★★☆☆ · ท่อนที่ยาวที่สุดที่โผล่ซ้ำ

โจทย์กำหนด

ให้ข้อความยาว n ≤ 200 000 หาความยาวของข้อความย่อยที่ยาวที่สุด ซึ่งปรากฏในข้อความอย่างน้อยสองครั้ง สองครั้งนั้นซ้อนทับกันได้ ถ้าไม่มีเลยให้ตอบ 0

EXAMPLE
InputOutput
banana3

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

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

ชุดนี้ท่อนที่ยาวที่สุดที่โผล่สองครั้งคือ ana ซึ่งเริ่มที่ตำแหน่ง 2 และ 4 ยาว 3 ตัว

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

มีท่อนซ้ำไหมในแต่ละความยาว
ความยาวท่อนที่โผล่ซ้ำโผล่ที่ตำแหน่ง
6 ไม่มี -
5 ไม่มี -
4 ไม่มี -
3 ana 2, 4
2 an 2, 4
1 a 2, 4, 6
อ่านจากบนลงล่างจะเห็นว่าพอเริ่มมีของซ้ำแล้วก็มีตลอด ไม่มีสลับไปมา คำตอบคือความยาวมากที่สุดที่ยังมีของซ้ำ ซึ่งคือ 3

คำใบ้

ฝึกข้อ 1 ตอบได้แล้วว่า "ความยาว k มีของซ้ำไหม" ในเวลา O(n) ทีนี้ลองถามว่า ถ้าความยาว 10 มีของซ้ำ แล้วความยาว 9 จะมีของซ้ำด้วยหรือเปล่า

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

แบ่งครึ่งหาความยาวที่ยาวที่สุดที่ยังตอบว่ามี แต่ละครั้งเรียกฟังก์ชันตรวจที่ทำงาน O(n) ทั้งข้อจึงเป็น O(n log n)

ฝึกข้อ 3 · แบ่งครึ่งค้นความยาว
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const ull MOD = (1ULL << 61) - 1;
static inline ull add(ull a, ull b) { a += b; if (a >= MOD) a -= MOD; return a; }
static inline ull sub(ull a, ull b) { return add(a, MOD - b); }
static inline ull mul(ull a, ull b) {
    __uint128_t c = (__uint128_t)a * b;
    return add((ull)(c & MOD), (ull)(c >> 61));
}

int n;
vector<ull> h, pw;
static inline ull cut(int l, int len) { return sub(h[l + len], mul(h[l], pw[len])); }

// มีสตริงย่อยความยาว len ที่โผล่ซ้ำไหม
bool has(int len) {
    if (len == 0) return true;
    unordered_set<ull> seen;
    seen.reserve(n * 2);
    for (int i = 0; i + len <= n; i++)
        if (!seen.insert(cut(i, len)).second) return true;
    return false;
}

int main() {
    string s;
    cin >> s;
    n = s.size();

    mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());
    ull B = rng() % (MOD - 300) + 256;
    h.assign(n + 1, 0);
    pw.assign(n + 1, 1);
    for (int i = 0; i < n; i++) {
        h[i + 1] = add(mul(h[i], B), (ull)s[i]);
        pw[i + 1] = mul(pw[i], B);
    }

    // ยาวขึ้นแล้วหายากขึ้นเสมอ คำตอบจึงแบ่งครึ่งค้นได้
    int lo = 0, hi = max(n - 1, 0);
    while (lo < hi) {
        int mid = (lo + hi + 1) / 2;
        if (has(mid)) lo = mid; else hi = mid - 1;
    }
    cout << lo << '\n';
    return 0;
}

สุ่มเทียบกับตัวที่ไล่ทุกความยาวและเก็บสตริงจริงลง set 500 เทส ตรงกันทั้งหมด รวมกรณีข้อความยาวตัวเดียวซึ่งต้องตอบ 0 และกรณีที่ทุกตัวอักษรเหมือนกันหมดซึ่งคำตอบคือ n − 1

ระวัง

การแบ่งครึ่งแบบ "หาค่ามากที่สุดที่ยังจริง" ต้องใช้ mid = (lo + hi + 1) / 2 ไม่ใช่ (lo + hi) / 2 ไม่งั้นตอนที่ hi เท่ากับ lo + 1 ค่า mid จะเท่ากับ lo แล้ววนไม่รู้จบ


ทางที่ไม่มีโอกาสชนเลย · แล้วทำไมยังเลือกแฮชอยู่ดี

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

ท่านั้นคือ Z-algorithm (อ่านว่า "แซดอัลกอริทึม") ซึ่งคำนวณอาเรย์ z[i] ที่แปลว่า คำนำหน้าร่วมที่ยาวที่สุดระหว่างข้อความทั้งเส้น กับข้อความเดียวกันที่ตัดหัวออก i ตัว

ค่านั้นตอบเรื่องการค้นหาได้ตรง ๆ ถ้าอยากรู้ว่า pattern โผล่ที่ไหนใน text ให้เอาสองสตริงมาต่อกันด้วยตัวคั่นที่ไม่มีในทั้งคู่ แล้วดูว่าตำแหน่งไหนใน text มีค่า z ยาวเท่ากับ pattern พอดี ตำแหน่งนั้นคือที่มันโผล่ ไม่มีการเทียบค่าที่อาจเท่ากันโดยบังเอิญเลย เพราะทุกอย่างเทียบด้วยตัวอักษรจริง

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

zfunc.cpp
// Z-algorithm: หาทุกตำแหน่งที่ pattern ปรากฏใน text แบบไม่มีโอกาสชนกันเลย
// อินพุต: บรรทัดแรก pattern บรรทัดสอง text
// เอาต์พุต: จำนวนที่เจอ แล้วตำแหน่งเริ่ม (เริ่มนับจาก 0) เรียงจากน้อยไปมาก
#include <bits/stdc++.h>
using namespace std;

/** z[i] = ความยาวของคำนำหน้าร่วมที่ยาวที่สุด ระหว่าง s กับ s ที่ตัดหัวออก i ตัว */
vector<int> zfunc(const string& s) {
    int n = (int)s.size();
    vector<int> z(n, 0);
    z[0] = n;
    int l = 0, r = 0;                    // ช่วงที่รู้แล้วว่าตรงกับคำนำหน้า
    for (int i = 1; i < n; i++) {
        if (i < r) z[i] = min(r - i, z[i - l]);   // ใช้ของที่เคยคิดไว้แล้ว
        while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++;
        if (i + z[i] > r) { l = i; r = i + z[i]; }
    }
    return z;
}

int main() {
    string pat, txt;
    if (!(cin >> pat)) return 0;
    if (!(cin >> txt)) txt = "";
    // ต่อกันด้วยตัวคั่นที่ไม่มีในทั้งสองสตริง แล้วช่วงที่ z ยาวเท่า pattern คือที่เจอ
    string joined = pat + '\x01' + txt;
    vector<int> z = zfunc(joined);
    vector<int> at;
    int m = (int)pat.size();
    for (int i = m + 1; i < (int)joined.size(); i++)
        if (z[i] >= m) at.push_back(i - m - 1);
    printf("%d\n", (int)at.size());
    for (size_t i = 0; i < at.size(); i++) printf("%d%c", at[i], i + 1 == at.size() ? '\n' : ' ');
    if (at.empty()) printf("\n");
    return 0;
}
ตัวตรวจที่ทำให้กล้าเชื่อโค้ดข้างบน

บรรทัดที่พลาดง่ายที่สุดคือ z[i] = min(r - i, z[i - l]) ซึ่งเป็นบรรทัดที่ใช้ของเก่าซ้ำ ถ้าเขียนผิดเป็นการใช้ค่าเก่าทั้งก้อนโดยไม่ตัดด้วย r - i โค้ดจะยังตอบถูกในหลายกรณี และผิดเฉพาะบางรูป ตัวตรวจจึงเทียบตัวอักษรทุกตำแหน่งแบบซื่อ ๆ โดยไม่รู้จักฟังก์ชัน z เลย

brute_search.cpp
// ตัวตรวจอิสระของ Z: เทียบตัวอักษรทุกตำแหน่งแบบซื่อ ๆ
// ไม่รู้จักฟังก์ชัน z หรือการใช้ของเก่าซ้ำเลย
#include <bits/stdc++.h>
using namespace std;

int main() {
    string pat, txt;
    if (!(cin >> pat)) return 0;
    if (!(cin >> txt)) txt = "";
    vector<int> at;
    int n = (int)txt.size(), m = (int)pat.size();
    for (int i = 0; i + m <= n; i++) {
        bool ok = true;
        for (int j = 0; j < m && ok; j++) if (txt[i + j] != pat[j]) ok = false;
        if (ok) at.push_back(i);
    }
    printf("%d\n", (int)at.size());
    for (size_t i = 0; i < at.size(); i++) printf("%d%c", at[i], i + 1 == at.size() ? '\n' : ' ');
    if (at.empty()) printf("\n");
    return 0;
}

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

แล้วทำไมบทนี้ยังสอนแฮชเป็นท่าหลัก เพราะ Z ตอบได้เฉพาะคำถามที่ผูกกับคำนำหน้า ส่วนแฮชตอบคำถามที่กว้างกว่านั้น คือ เทียบช่วงไหนกับช่วงไหนก็ได้ในเวลาคงที่ ซึ่งเป็นสิ่งที่ Z ทำไม่ได้ตรง ๆ เกณฑ์เลือกจึงชัด ถ้าคำถามคือ "pattern นี้อยู่ที่ไหน" ให้ใช้ Z เพราะไม่มีความเสี่ยง ถ้าคำถามคือ "สองช่วงนี้เหมือนกันไหม" โดยช่วงเปลี่ยนไปเรื่อย ๆ ให้ใช้แฮช แล้วยอมจ่ายค่าความเสี่ยงด้วยการเลือกมอดุลัสให้ดี

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

มองข้อความเป็นตัวเลขฐาน B แล้วเก็บค่าสะสมไว้ล่วงหน้า การเทียบสองช่วงจะกลายเป็นการลบเลขสองครั้ง โดยมีสามอย่างที่ต้องไม่ลืม มอดุลัสต้องใหญ่ ฐานต้องสุ่ม และช่วงกลับด้านอยู่ที่ n − 1 − r ไม่ใช่ที่เดิม