ปูพื้นฐาน
เครื่องมือที่ทำให้การเทียบสตริงเลิกแพงตามความยาว มาพร้อมสูตรตัดช่วง กับสามอย่างที่ทำให้โค้ดพังแบบไม่มีข้อความเตือน คือมอดุลัสที่เล็กไป ฐานที่ไม่ได้สุ่ม และช่วงกลับด้านที่คำนวณผิดตำแหน่ง พร้อมโจทย์ฝึก 3 ข้อ
คำถามที่ดูไม่มีพิษภัยเลยคือ "ข้อความช่วงนี้กับช่วงนั้น เหมือนกันไหม" เขียนตอบได้บรรทัดเดียวด้วยการวนเทียบทีละตัวอักษร และมันก็เร็วพอในชีวิตจริง จนกระทั่งโจทย์บอกว่าจะถามแบบนี้สองแสนครั้งบนข้อความยาวสองแสนตัว
การเทียบหนึ่งครั้งกินเวลาเท่าความยาวช่วง คำถามที่ถามช่วงยาวทั้งเส้นจึงกิน 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 ตารางนี้ยังไม่มีมอดุลัส ตัวเลขจึงตรวจด้วยมือได้
| 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 |
a เป็นศูนย์
ข้อความ a, aa, aaa จะได้ค่าเท่ากันหมดคือศูนย์
ค่าของช่วงที่ไม่ได้เริ่มจากหัวข้อความ หาได้ด้วยการเอาก้อนใหญ่มาลบส่วนนำหน้าออก แต่ต่างจากผลรวมสะสมตรงที่ ลบตรง ๆ ไม่ได้ เพราะส่วนนำหน้าในก้อนใหญ่ถูกคูณด้วยฐานไปแล้วหลายรอบ ต้องเลื่อนมันให้ตรงหลักกันก่อน
ตรวจด้วยมือได้เลยว่าเลข 2016 ที่ได้ คือค่าของ bca จริง ๆ ถ้ากางพหุนามออกมาตรง ๆ
| ตัวอักษร | ค่า | หลัก | ผลคูณ |
|---|---|---|---|
b | 2 | 961 | 1922 |
c | 3 | 31 | 93 |
a | 1 | 1 | 1 |
| รวม | · | · | 2016 |
ตัวเลขในตารางข้างบนโตเร็วมาก ข้อความยาวสองแสนตัวจะได้เลขที่ยาวเป็นแสนหลัก เก็บใน 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]
เขียนผิดตรงนี้แล้วโปรแกรมยังรันปกติและตอบถูกบ้างผิดบ้าง ซึ่งเป็นบักที่หาเจอยากที่สุดถ้าไม่มีตัวสุ่มเทียบ
#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;
} สามข้อนี้ตอบคนละคำถาม ข้อแรกถามว่า "เอาแฮชไปทำอย่างอื่นนอกจากเทียบสองช่วงได้ไหม" ข้อสองถามว่า "ถ้าต้องเทียบกับตัวเองที่กลับด้าน ต้องทำยังไง" ข้อสามถามว่า "แฮชไปรวมกับการค้นหาแบบทวิภาคได้ยังไง" ลองคิดเองก่อนแล้วค่อยแตะดูเฉลยครับ
โจทย์กำหนด
ให้ข้อความยาว n ≤ 200 000 กับเลข k ถามว่าข้อความย่อยความยาว k ที่ตัดจากมัน
มีทั้งหมดกี่แบบที่ไม่ซ้ำกัน เช่น abcab กับ k = 2 ตัดได้ 4 ชิ้น
แต่มี 3 แบบ
| Input | Output |
|---|---|
| abcabcbb 3 | 5 |
อ่านตัวอย่างนี้ยังไง
อินพุตคือข้อความหนึ่งบรรทัดแล้วค่า k เอาต์พุตคือจำนวนแบบที่ไม่ซ้ำกัน
ไม่ใช่จำนวนชิ้นที่ตัดได้ ข้อความย่อยในที่นี้คือตัวอักษรที่ติดกันเท่านั้น
ไม่ใช่การหยิบข้ามตัว
ข้อความยาว 8 ตัวกับ k เท่ากับ 3 จะเลื่อนหน้าต่างได้
6 ตำแหน่ง แต่บางตำแหน่งได้ข้อความซ้ำกับที่เคยเจอแล้ว จึงเหลือ 5 แบบ
คำใบ้
ถ้าเก็บข้อความย่อยเป็นสตริงลงเซ็ต ทุกครั้งที่ใส่ต้องคัดลอกตัวอักษร k ตัว
รวมแล้วยังเป็น n × k อยู่ดี แล้วถ้าเก็บอย่างอื่นที่ไม่ใช่ตัวสตริงล่ะ
ที่มาของท่านี้ · และตัวเลขที่ผมทดลองเอง
คำใบ้ชี้ไปที่การเก็บเลขแทนสตริง ซึ่งเป็นท่าที่ถูก แต่ข้อนี้เปลี่ยนความเสี่ยงของแฮชไปจากหัวข้อก่อนหน้าอย่างเงียบ ๆ ในโจทย์ที่ถามว่า "ช่วงนี้เท่ากับช่วงนั้นไหม" เราเทียบเฉพาะคู่ที่โจทย์ถาม แต่ข้อนี้เราโยนทุกอันลงเซ็ตแล้วปล่อยให้มันชนกันเอง ซึ่งเท่ากับเปิดให้ทุกคู่ที่เป็นไปได้มีสิทธิ์ชน
นี่คือเหตุผลที่โค้ดในบทนี้ใช้มอดุลัส 2⁶¹ − 1 ไม่ใช่ 10⁹ + 7 ที่คนใช้กันติดมือ
ผมอยากรู้ว่าตัวหลังอันตรายแค่ไหนจริง ๆ เลยทดลองตรง ๆ คือสุ่มค่าในช่วง 10⁹ + 7
ทีละตัวจนกว่าจะเจอค่าที่เคยออกไปแล้ว ทำสามสิบรอบ ผลคือค่ากลางอยู่ที่ราว 36,700 ตัว
และรอบที่โชคร้ายที่สุดชนกันตั้งแต่ตัวที่ 901
เอาไปวางข้างขอบเขตของข้อนี้ ข้อความยาวสองแสนตัวให้หน้าต่างได้ราวสองแสนอัน
ซึ่งมากกว่าเส้นค่ากลางนั้นห้าเท่า ถ้าใช้ 10⁹ + 7 ตัวเดียว การชนกันจึงไม่ใช่โชคร้ายที่นาน ๆ เกิดที
มันคือสิ่งที่คาดหมายได้ และเวลามันเกิด คำตอบจะน้อยกว่าความจริงไปเงียบ ๆ เพราะของสองแบบถูกนับเป็นแบบเดียว
พอเปลี่ยนเป็น 2⁶¹ − 1 เส้นเดียวกันนั้นขยับไปอยู่ที่ระดับพันล้าน ซึ่งพ้นขอบเขตของโจทย์ไปไกล
บทเรียนที่ยกไปข้ออื่นได้คือ ให้แยกให้ออกว่าโจทย์กำลังเทียบกี่คู่ เทียบตามที่ถูกถามกับโยนทุกอันลงเซ็ตเป็นคนละความเสี่ยงกัน และมอดุลัสที่ปลอดภัยของแบบแรก อาจไม่พอสำหรับแบบหลัง
เก็บค่าแฮชลงเซ็ตแทนที่จะเก็บตัวสตริง ตัวเลขหนึ่งก้อนใส่เซ็ตได้ในเวลาคงที่ ทั้งข้อจึงเหลือ O(n) โดยไม่ต้องแตะตัวอักษรซ้ำเลยหลังสร้างตารางแฮชเสร็จ
#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 ใหญ่กว่าความยาวข้อความ ตรงกันทั้งหมด
โจทย์กำหนด
ให้ข้อความยาว n กับคำถาม q ข้อ แต่ละข้อให้ l กับ r
ถามว่าช่วงนั้นเป็นพาลินโดรม (อ่านหน้าไปหลังกับหลังมาหน้าได้เหมือนกัน) หรือเปล่า ทั้ง n และ
q ใหญ่ได้ถึงสองแสน
| Input | Output |
|---|---|
| abacaba 4 1 7 2 4 1 4 4 4 | YES NO NO YES |
อ่านตัวอย่างนี้ยังไง
อินพุตคือข้อความ แล้วจำนวนคำถาม แล้วคำถามบรรทัดละหนึ่งข้อในรูป l r
ตำแหน่งเริ่มนับที่ 1 และช่วงนี้รวมทั้งตัวที่ l และตัวที่ r
เอาต์พุตคือคำตอบของแต่ละคำถาม บรรทัดละหนึ่งคำตอบ
ช่วงที่มีตัวอักษรตัวเดียวเป็นพาลินโดรมเสมอ ซึ่งเป็นเคสที่คนลืมบ่อยที่สุด และคำถามที่สองในตัวอย่างนี้แสดงให้เห็นว่าช่วงย่อยของพาลินโดรมไม่จำเป็นต้องเป็นพาลินโดรมด้วย
คำใบ้
"อ่านกลับแล้วเหมือนเดิม" แปลว่าช่วงนี้เท่ากับช่วงนี้ที่กลับด้าน ซึ่งเป็นการเทียบสองอย่าง แต่เรามีตารางแฮชของข้อความต้นฉบับอยู่ชุดเดียว
สร้างตารางแฮชสองชุด ชุดหนึ่งของข้อความปกติ อีกชุดของข้อความที่กลับด้านไว้ตั้งแต่แรก
แล้วเทียบช่วง [l, r] ของชุดแรกกับช่วงที่สะท้อนตำแหน่งกันของชุดที่สอง ตอบได้ในเวลาคงที่ต่อคำถาม
#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 ออกบ่อยกว่าตัวอื่น
เพื่อบังคับให้มีพาลินโดรมโผล่ถี่ ๆ ตรงกันทั้งหมด
ท่านี้คือครึ่งหนึ่งของข้อ สี่เหลี่ยมพิฆาต ตรง ๆ ต่างกันแค่ที่นั่นเทียบ แถวบนกับแถวล่างของจัตุรัส แทนที่จะเทียบช่วงกับตัวมันเอง
โจทย์กำหนด
ให้ข้อความยาว n ≤ 200 000 หาความยาวของข้อความย่อยที่ยาวที่สุด
ซึ่งปรากฏในข้อความอย่างน้อยสองครั้ง สองครั้งนั้นซ้อนทับกันได้ ถ้าไม่มีเลยให้ตอบ 0
| Input | Output |
|---|---|
| banana | 3 |
อ่านตัวอย่างนี้ยังไง
อินพุตคือข้อความบรรทัดเดียว เอาต์พุตคือความยาว ไม่ใช่ตัวข้อความย่อย และคำว่า "ซ้อนทับกันได้" แปลว่าสองครั้งที่โผล่นั้นใช้ตัวอักษรร่วมกันได้
ชุดนี้ท่อนที่ยาวที่สุดที่โผล่สองครั้งคือ ana
ซึ่งเริ่มที่ตำแหน่ง 2 และ 4 ยาว 3 ตัว
ตารางข้างล่างคือสิ่งที่ทำให้ข้อนี้ใช้การค้นหาคำตอบแบบไบนารีได้ ไล่ความยาวจากยาวไปสั้น พอเจอความยาวแรกที่มีของซ้ำแล้ว ความยาวที่สั้นกว่านั้นมีของซ้ำทุกอัน คำตอบจึงแบ่งครึ่งหาได้
| ความยาว | ท่อนที่โผล่ซ้ำ | โผล่ที่ตำแหน่ง |
|---|---|---|
| 6 | ไม่มี | - |
| 5 | ไม่มี | - |
| 4 | ไม่มี | - |
| 3 | ana | 2, 4 |
| 2 | an | 2, 4 |
| 1 | a | 2, 4, 6 |
คำใบ้
ฝึกข้อ 1 ตอบได้แล้วว่า "ความยาว k มีของซ้ำไหม" ในเวลา O(n)
ทีนี้ลองถามว่า ถ้าความยาว 10 มีของซ้ำ แล้วความยาว 9 จะมีของซ้ำด้วยหรือเปล่า
มีแน่นอน เพราะข้อความย่อยยาว 10 ที่โผล่สองครั้ง ย่อมมีท่อนหน้ายาว 9 ของมันโผล่สองครั้งตามไปด้วย
แปลว่าคำตอบของคำถาม "ความยาว k มีของซ้ำไหม" เป็นจริงเรื่อยมาจนถึงจุดหนึ่งแล้วเป็นเท็จตลอด
ซึ่งคือรูปที่ค้นหาแบบทวิภาคได้
แบ่งครึ่งหาความยาวที่ยาวที่สุดที่ยังตอบว่ามี แต่ละครั้งเรียกฟังก์ชันตรวจที่ทำงาน O(n)
ทั้งข้อจึงเป็น O(n log n)
#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 พอดี ตำแหน่งนั้นคือที่มันโผล่ ไม่มีการเทียบค่าที่อาจเท่ากันโดยบังเอิญเลย
เพราะทุกอย่างเทียบด้วยตัวอักษรจริง
ที่มันเร็วเพราะมันไม่เทียบซ้ำสิ่งที่เคยเทียบแล้ว อัลกอริทึมจำช่วงขวาสุดที่รู้แล้วว่าตรงกับ คำนำหน้าไว้หนึ่งช่วง พอถึงตำแหน่งใหม่ที่ยังอยู่ในช่วงนั้น มันหยิบคำตอบเก่ามาใช้ได้ทันที แล้วค่อยเทียบต่อจากจุดที่ของเก่าหมด ตัวชี้ขวาของช่วงจึงเดินไปข้างหน้าเท่านั้น งานทั้งการเดินมีเพดานที่ความยาวข้อความ ซึ่งเป็นวิธีนับต้นทุนแบบเดียวกับสองตัวชี้ ในบทหน้าต่างเลื่อน
// 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 ตอบได้เฉพาะคำถามที่ผูกกับคำนำหน้า ส่วนแฮชตอบคำถามที่กว้างกว่านั้น คือ เทียบช่วงไหนกับช่วงไหนก็ได้ในเวลาคงที่ ซึ่งเป็นสิ่งที่ Z ทำไม่ได้ตรง ๆ เกณฑ์เลือกจึงชัด ถ้าคำถามคือ "pattern นี้อยู่ที่ไหน" ให้ใช้ Z เพราะไม่มีความเสี่ยง ถ้าคำถามคือ "สองช่วงนี้เหมือนกันไหม" โดยช่วงเปลี่ยนไปเรื่อย ๆ ให้ใช้แฮช แล้วยอมจ่ายค่าความเสี่ยงด้วยการเลือกมอดุลัสให้ดี
มองข้อความเป็นตัวเลขฐาน B แล้วเก็บค่าสะสมไว้ล่วงหน้า การเทียบสองช่วงจะกลายเป็นการลบเลขสองครั้ง
โดยมีสามอย่างที่ต้องไม่ลืม มอดุลัสต้องใหญ่ ฐานต้องสุ่ม และช่วงกลับด้านอยู่ที่ n − 1 − r ไม่ใช่ที่เดิม
ในหน้านี้