Chương 1·Bài 19

Hashing

Cho xâu ss dài nn và qq câu hỏi, mỗi câu cho hai đoạn cùng độ dài của ss và hỏi chúng có giống nhau không. So từng ký tự tốn O(n)O(n) cho một câu, cả bài n⋅qn \cdot q; với n=q=2⋅105n = q = 2 \cdot 10^5 là 4⋅10104 \cdot 10^{10}, quá xa giới hạn một giây. Hash (băm) gán cho mỗi xâu một số nguyên cỡ 64 bit, một “dấu vân tay”: hai xâu bằng nhau luôn có cùng dấu vân tay, hai xâu khác nhau có dấu vân tay khác nhau với xác suất rất cao. Sau một lượt chuẩn bị O(n)O(n), dấu vân tay của mọi đoạn lấy được trong O(1)O(1), và so hai đoạn thành so hai số.

Cái giá là thuật toán có xác suất sai. Bài này tính xác suất đó, chọn tham số để nó không xảy ra trong thực tế, và chỉ ra những cách chọn quen thuộc mà sai khiến hash bị bẻ. Cùng ý tưởng ấy, thay thứ tự bằng trọng số ngẫu nhiên, còn so được tập và đa tập mà không cần sắp xếp.

Hash đa thức và hash của xâu con

Hãy xem một xâu như một số viết trong hệ cơ số bb. Với a = 1, b = 2, c = 3 và cơ số 10, xâu abc là số 123, xâu cab là số 312; hai xâu bằng nhau khi và chỉ khi hai số bằng nhau. Nhưng số có nn chữ số cũng dài như xâu, nên ta lấy phần dư theo một số MM:

H(s)=(s1bn−1+s2bn−2+⋯+sn−1b+sn) mod M.H(s) = \left(s_1 b^{n-1} + s_2 b^{n-2} + \dots + s_{n-1} b + s_n\right) \bmod M .

Ở đây sis_i là giá trị số của ký tự thứ ii (mã ASCII, hoặc c - 'a' + 1), bb là cơ số, MM là modulo. Cách tính gọn là sơ đồ Horner: H←(H⋅b+si) mod MH \leftarrow (H \cdot b + s_i) \bmod M với i=1,…,ni = 1, \dots, n. Hai xâu bằng nhau thì hash bằng nhau, luôn luôn; nên hai hash khác nhau chứng minh được hai xâu khác nhau. Ngược lại, hai xâu khác nhau vẫn có thể trùng hash, gọi là va chạm (collision), không tránh hẳn được vì chỉ có MM giá trị hash. Giá trị ký tự phải ≥1\ge 1: nếu gán a bằng 0 thì a, aa, aaa đều có hash 0, giống chữ số 0 đứng đầu không đổi giá trị của một số.

Hash của xâu con. Giống tổng tiền tố (bài 02), dựng trước hash của mọi tiền tố, h0=0h_0 = 0 và hi=(hi−1⋅b+si) mod Mh_i = (h_{i-1} \cdot b + s_i) \bmod M. Trong hrh_r, ký tự sjs_j mang trọng số br−jb^{r-j}, đúng trọng số mà hash của s[l..r]s[l..r] cần; phần thừa là s1,…,sl−1s_1, \dots, s_{l-1}, chính là hl−1h_{l-1} bị đẩy sang trái r−l+1r - l + 1 vị trí. Vậy

H(s[l..r])=(hr−hl−1⋅br−l+1) mod M.H(s[l..r]) = \left(h_r - h_{l-1} \cdot b^{r-l+1}\right) \bmod M .

Xem trong hệ thập phân cho dễ thấy: với ss = abacab, các tiền tố là 0,1,12,121,1213,12131,1213120, 1, 12, 121, 1213, 12131, 121312; đoạn [3,5][3, 5] là aca, số của nó là 12131−12⋅103=13112131 - 12 \cdot 10^3 = 131.

Với b = 10 và M lớn, hash đọc được như số thập phân; đổi l, r để lấy hash đoạn khác, hoặc đổi b, M.

Ta cần thêm mảng lũy thừa pwk=bk mod M\mathrm{pw}_k = b^k \bmod M. Dựng cả hai mảng tốn O(n)O(n), rồi mỗi đoạn lấy hash trong O(1)O(1). Hai chỗ hay sai: phép trừ modulo có thể ra số âm nên phải cộng thêm MM; và chỉ so hash của hai đoạn cùng độ dài. Khi một cửa sổ cố định độ dài mm trượt đi, ta cũng có thể cập nhật thẳng H←(H−sibm−1)b+si+mH \leftarrow (H - s_i b^{m-1}) b + s_{i+m} (hash cuộn, rolling hash), nhưng mảng tiền tố làm được cùng việc và lấy được cả đoạn độ dài tùy ý.

Ví dụDấu Vân Tay Của Xâu

Đề. Cho xâu ss gồm n≤50n \le 50 chữ thường, value(a)=1,…,value(z)=26\mathrm{value}(a) = 1, \dots, \mathrm{value}(z) = 26. In H(s)=∑i=0n−1value(si)⋅31i mod 1234567891H(s) = \sum_{i=0}^{n-1} \mathrm{value}(s_i) \cdot 31^i \bmod 1234567891.

Lời giải. Đề viết đa thức theo chiều ngược với Horner: ký tự đầu mang lũy thừa thấp nhất. Cứ duyệt từ trái sang, giữ p=31i mod Mp = 31^i \bmod M và cộng dần value(si)⋅p\mathrm{value}(s_i) \cdot p. Mọi giá trị giữ dưới MM ngay từ đầu; pp tới 1,2⋅1091{,}2 \cdot 10^9 nên nhân với 31 phải bằng ll, int đã tràn. Với abcde: 1+2⋅31+3⋅961+4⋅29791+5⋅923521=47397151 + 2 \cdot 31 + 3 \cdot 961 + 4 \cdot 29791 + 5 \cdot 923521 = 4739715.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll M = 1234567891;
ll n;
string s;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> s;
    ll h = 0, p = 1;
    for (ll i = 0; i < n; i++) {
        h = (h + (s[i] - 'a' + 1) * p) % M;
        p = p * 31 % M;
    }
    cout << h << '\n';
}
5
abcde

Chọn tham số: va chạm, tràn số và cài đặt

Lấy hai xâu khác nhau s≠ts \ne t cùng độ dài nn. Chúng trùng hash khi và chỉ khi D(b)D(b) chia hết cho MM, với D(x)=(s1−t1) xn−1+(s2−t2) xn−2+⋯+(sn−tn)D(x) = (s_1 - t_1)\, x^{n-1} + (s_2 - t_2)\, x^{n-2} + \dots + (s_n - t_n). DD có bậc không quá n−1n - 1 và không phải đa thức không. Khi MM là số nguyên tố, DD có nhiều nhất n−1n - 1 nghiệm theo modulo MM, nên nếu cơ số bb được bốc ngẫu nhiên đều trong [0,M)[0, M) sau khi hai xâu đã cố định, thì xác suất hai xâu trùng hash không quá

n−1M.\frac{n-1}{M} .

Với M=109+7M = 10^9 + 7 và n=105n = 10^5, cận này là 10−410^{-4}; với M=261−1M = 2^{61} - 1 nó là 4,3⋅10−144{,}3 \cdot 10^{-14}. Hai điều kiện của chứng minh cũng là hai quy tắc chọn tham số: MM phải nguyên tố, và cơ số phải ngẫu nhiên, bốc lúc chạy chương trình (cơ số cố định thì ai biết nó dựng được hai xâu trùng hash, đúng việc người ta làm ở các kỳ thi cho phép hack).

Cận trên chỉ nói về một cặp. Nhiều thuật toán so mọi cặp cùng lúc: bỏ hash của mm xâu vào một tập, hoặc sắp xếp rồi tìm hai hash bằng nhau. Có ≈m2/2\approx m^2/2 cặp, mỗi cặp trùng với xác suất cỡ 1/M1/M, nên trung bình có khoảng m2/(2M)m^2/(2M) cặp trùng nhầm; con số này vượt 1 là gần như chắc chắn có trùng. Hiện tượng này gọi là nghịch lý ngày sinh (birthday paradox). Với M≈109M \approx 10^9 và m=105m = 10^5, xác suất trùng là 99,3%99{,}3\%; với M=261−1M = 2^{61} - 1 chỉ cỡ 2⋅10−92 \cdot 10^{-9}. Quy tắc: so qq cặp định sẵn thì modulo 10910^9 cho xác suất sai khoảng q/109q/10^9; so mọi cặp trong mm hash thì cần M≫m2M \gg m^2, tức cỡ 101810^{18}: một modulo 261−12^{61} - 1, hoặc hai modulo nguyên tố cỡ 10910^9 với hai cơ số độc lập.

Hỏi nhanh

Ta bỏ hash (modulo 109+710^9 + 7, cơ số ngẫu nhiên) của 10510^5 xâu con đôi một khác nhau vào một tập để đếm xâu phân biệt. Coi hash như số ngẫu nhiên đều trong [0,M)[0, M). Xác suất có hai xâu khác nhau mà trùng hash gần với giá trị nào nhất?

Hash tràn số bị bẻ. Phép % chậm, nên nhiều người dùng unsigned long long và để phép tính tự tràn, tức tính theo modulo 2642^{64}. Nhưng 2642^{64} không phải số nguyên tố, và có hẳn một cặp xâu trùng hash với mọi cơ số lẻ. Đặt $S_0 = $ a và Sk=Sk−1Sk−1‾S_k = S_{k-1}\overline{S_{k-1}}, với X‾\overline{X} là XX đổi a và b cho nhau: chuỗi Thue-Morse ab, abba, abbabaab, ...

Hash tính thật theo modulo 2⁶⁴ cho xâu Thue-Morse và xâu đảo của nó; đổi cơ số lẻ để thử.

Từ H(XY)=H(X) b∣Y∣+H(Y)H(XY) = H(X)\, b^{|Y|} + H(Y) suy ra hiệu Dk=H(Sk)−H(Sk‾)D_k = H(S_k) - H(\overline{S_k}) thỏa Dk=Dk−1(b2k−1−1)D_k = D_{k-1}(b^{2^{k-1}} - 1), tức DkD_k bằng tích (b−1)(b2−1)(b4−1)⋯(b2k−1−1)(b - 1)(b^2 - 1)(b^4 - 1) \cdots (b^{2^{k-1}} - 1), đổi dấu. Với bb lẻ, thừa số i=0i = 0 chẵn, còn với i≥1i \ge 1 thừa số b2i−1b^{2^i} - 1 chia hết cho 2i+22^{i+2}. Số thừa số 2 của D10D_{10} ít nhất là 1+(3+4+⋯+11)=641 + (3 + 4 + \dots + 11) = 64: hai xâu S10S_{10} và S10‾\overline{S_{10}}, dài 1024, trùng hash modulo 2642^{64} với mọi cơ số lẻ. Cơ số chẵn còn tệ hơn, vì b64≡0b^{64} \equiv 0 nên chỉ 64 ký tự cuối còn ảnh hưởng.

C++
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using ull = unsigned long long;

ull calc(string s, ull b) {
    ull h = 0;
    for (char c : s) h = h * b + (c - 'a' + 1);
    return h;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    string a, b;
    for (ll i = 0; i < 1024; i++) {
        bool bit = __builtin_popcountll(i) % 2;
        a += bit ? 'b' : 'a';
        b += bit ? 'a' : 'b';
    }
    cout << (a == b ? "giong" : "khac") << ' ' << a.size() << '\n';
    for (ull base : {31ULL, 131ULL, 911382323ULL, 1000000007ULL, 123456789012345ULL})
        cout << base << ' ' << (calc(a, base) == calc(b, base) ? "trung" : "khac") << '\n';
}

Bốc cơ số lẻ ngẫu nhiên không cứu được, vì lỗi nằm ở modulo chứ không ở cơ số; thêm một hash tràn số thứ hai cũng vô ích. Test này có từ một kỳ thi học sinh ở Saint Petersburg năm 2003; nhiều thế hệ thí sinh hash tràn số bị WA vì nó mà không hiểu lý do, cho tới khi Zlobober giải thích trên Codeforces.

Hỏi nhanh

Một thí sinh hash bằng unsigned long long tràn số, nhưng mỗi lần chạy bốc một cơ số lẻ ngẫu nhiên lớn, và dùng thêm hash thứ hai cũng tràn số với một cơ số ngẫu nhiên khác. Người ra đề cài sẵn hai xâu S10S_{10} và S10‾\overline{S_{10}}. Điều gì xảy ra?

Cài đặt với M=261−1M = 2^{61} - 1. Đây là một số nguyên tố, và tích hai số dưới 2612^{61} nhỏ hơn 21222^{122}, nên nhân bằng __int128. Vì 261≡1(modM)2^{61} \equiv 1 \pmod M, tích t=hi⋅261+lot = \mathrm{hi} \cdot 2^{61} + \mathrm{lo} cho t≡hi+lot \equiv \mathrm{hi} + \mathrm{lo}, nhỏ hơn 2M2M, trừ MM một lần là xong. Cơ số bốc ngẫu nhiên một lần khi chạy, dùng chung cho mọi xâu cần so với nhau. Khi trình biên dịch không có __int128, thay bằng hai modulo nguyên tố cỡ 10910^9 với hai cơ số ngẫu nhiên, ghép hai hash vào một số 64 bit để so như một.

Ví dụNhững Điểm Khớp

Đề. Cho văn bản TT (∣T∣≤106|T| \le 10^6) và mẫu PP (∣P∣≤104|P| \le 10^4), gồm chữ cái Latin và chữ số. In mọi chỉ số ii (đánh số từ 0) mà TT bắt đầu tại ii khớp PP, kể cả các lần chồng lấn.

Lời giải. Thuật toán Rabin-Karp: hash của PP một lần, rồi so với hash của mọi cửa sổ dài ∣P∣|P| của TT, lấy ra trong O(1)O(1) từ mảng hash tiền tố. Hai xâu phải dùng cùng cơ số BB. Số cặp được so chỉ là 10610^6 cặp định sẵn (mỗi cửa sổ với PP), nên với modulo 261−12^{61} - 1 xác suất sai không đáng kể.

Bước 1: nhân theo modulo 261−12^{61} - 1. Tích hai số dưới 2612^{61} cần 122 bit, nên tính trong __int128. Vì 261≡12^{61} \equiv 1, phần bit cao (dịch phải 61) cộng thẳng vào phần bit thấp (61 bit cuối) là đã lấy dư gần xong; một lần trừ cuối cho kết quả nhỏ hơn modulo.

C++
ull mul(ull a, ull b) {
    __int128 t = (__int128)a * b;
    ull r = (ull)(t & MOD) + (ull)(t >> 61);
    return r >= MOD ? r - MOD : r;
}

Bước 2: hash tiền tố và hash một đoạn. f[i] là hash của ii ký tự đầu. Hash của đoạn [l,r][l, r] là f[r] trừ f[l - 1] đã dịch r−l+1r - l + 1 chỗ (nhân Br−l+1B^{r-l+1}); cộng MOD trước khi trừ để không âm. Mã ASCII của chữ và số đều ≥1\ge 1 nên dùng thẳng làm giá trị ký tự.

C++
void build(string s, ull f[]) {
    for (ll i = 1; i <= (ll)s.size(); i++) f[i] = (mul(f[i - 1], B) + s[i - 1]) % MOD;
}

ull get(ull f[], ll l, ll r) {
    return (f[r] + MOD - mul(f[l - 1], pw[r - l + 1])) % MOD;
}

Bước 3: so mọi cửa sổ. Cửa sổ bắt đầu ở vị trí ii (đếm từ 1) của mảng hash là chỉ số i−1i - 1 của đề.

C++
for (ll i = 1; i + m - 1 <= n; i++)
    if (get(h, i, i + m - 1) == g[m]) cout << i - 1 << '\n';
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using ull = unsigned long long;

const ull MOD = (1ULL << 61) - 1;
const ll N = 1e6 + 5;
mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());
ull B = rng() % (MOD - 1000) + 500, h[N], g[N], pw[N];

ull mul(ull a, ull b) {
    __int128 t = (__int128)a * b;
    ull r = (ull)(t & MOD) + (ull)(t >> 61);
    return r >= MOD ? r - MOD : r;
}

void build(string s, ull f[]) {
    for (ll i = 1; i <= (ll)s.size(); i++) f[i] = (mul(f[i - 1], B) + s[i - 1]) % MOD;
}

ull get(ull f[], ll l, ll r) {
    return (f[r] + MOD - mul(f[l - 1], pw[r - l + 1])) % MOD;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    string t, p;
    cin >> t >> p;
    ll n = t.size(), m = p.size();
    pw[0] = 1;
    for (ll i = 1; i <= n; i++) pw[i] = mul(pw[i - 1], B);
    build(t, h);
    build(p, g);
    for (ll i = 1; i + m - 1 <= n; i++)
        if (get(h, i, i + m - 1) == g[m]) cout << i - 1 << '\n';
}
ababaabababa
aba

Cả bài O(∣T∣+∣P∣)O(|T| + |P|).

Hash và chặt nhị phân

Hash so hai đoạn trong O(1)O(1), nên mọi tính chất kiểu “hai đoạn dài LL bằng nhau” kiểm được nhanh. Khi tính chất đó đơn điệu theo LL, ta chặt nhị phân (bài 05) trên LL.

Tiền tố chung dài nhất. Gọi lcp(i,j)\mathrm{lcp}(i, j) là LL lớn nhất để s[i..i+L−1]=s[j..j+L−1]s[i..i+L-1] = s[j..j+L-1]. Nếu LL ký tự đầu của hai hậu tố bằng nhau thì L−1L - 1 ký tự đầu cũng bằng nhau, nên chặt nhị phân trên LL cho đáp án sau O(log⁡n)O(\log n) lần so hash. So hai hậu tố theo thứ tự từ điển cũng xong ngay: tìm lcp\mathrm{lcp} rồi so ký tự kế tiếp. Với hàm get như trên:

C++
ll lcp(ll i, ll j) {
    ll l = 0, r = n - max(i, j) + 1, res = 0;
    while (l <= r) {
        ll mid = (l + r) >> 1;
        if (get(h, i, i + mid - 1) == get(h, j, j + mid - 1)) l = (res = mid) + 1;
        else r = mid - 1;
    }
    return res;
}

Với ss = abracadabra: lcp(1,8)=4\mathrm{lcp}(1, 8) = 4, lcp(1,4)=1\mathrm{lcp}(1, 4) = 1, lcp(3,5)=0\mathrm{lcp}(3, 5) = 0.

Mỗi bước so hash của hai đoạn dài L; ô xanh là đoạn bằng nhau, ô đỏ là khác.

Đối xứng. Một đoạn là palindrome nếu nó bằng chính nó viết ngược. Gọi tt là xâu đảo của ss, ti=sn+1−it_i = s_{n+1-i}; đoạn s[l..r]s[l..r] viết ngược chính là t[n+1−r..n+1−l]t[n+1-r..n+1-l], nên dựng hash cho cả ss (mảng h) và tt (mảng g) rồi so get(h, l, r) với get(g, n + 1 - r, n + 1 - l). Với ss = dabacab, tt = bacabad: đoạn [2,4][2, 4] là aba, ứng với t[4..6]t[4..6] cũng là aba.

Mỗi đường nối một ký tự của s với đúng vị trí của nó trong t; kéo l, r để thử đoạn khác.

Xâu con đối xứng dài nhất thì cần cẩn thận. Tính chất “có xâu con đối xứng dài LL” không đơn điệu trên mọi LL: abcba có xâu đối xứng dài 5, 3, 1 nhưng không có dài 4 hay 2. Cái đúng là đơn điệu trong từng loại chẵn lẻ, vì bỏ ký tự đầu và cuối của một palindrome dài L≥3L \ge 3 được palindrome dài L−2L - 2. Vậy chặt nhị phân riêng cho độ dài lẻ và độ dài chẵn, mỗi lần kiểm quét O(n)O(n) cửa sổ, cả bài O(nlog⁡n)O(n \log n).

Hỏi nhanh

Xâu ss có một xâu con đối xứng dài 5. Khẳng định nào dưới đây chắc chắn đúng?

Ví dụĐoạn Lặp Dài Nhất

Đề. Cho xâu ss gồm chữ thường, dài n≤2⋅105n \le 2 \cdot 10^5. In độ dài lớn nhất của một xâu con xuất hiện ở ít nhất hai vị trí bắt đầu khác nhau (được chồng lấn, như aba trong ababa); không có thì in 0.

Lời giải. Gọi rep(L)\mathrm{rep}(L) là “có một xâu con dài LL xuất hiện tại hai vị trí khác nhau”. Nếu rep(L)\mathrm{rep}(L) đúng thì rep(L−1)\mathrm{rep}(L - 1) cũng đúng: bỏ ký tự cuối của cả hai lần xuất hiện. Vậy chặt nhị phân LL trong [1,n−1][1, n - 1], mỗi lần kiểm rep(L)\mathrm{rep}(L) bằng hash. Với ababa: L=4L = 4 cho abab, baba, khác nhau; L=3L = 3 có aba hai lần.

Bước 1: hash tiền tố. Như ví dụ trước, mul nhân theo modulo 261−12^{61} - 1 và get(l, r) cho hash của đoạn [l,r][l, r] trong O(1)O(1). Mảng lũy thừa và mảng hash tính chung một vòng.

C++
pw[0] = 1;
for (ll i = 1; i <= n; i++) {
    pw[i] = mul(pw[i - 1], B);
    h[i] = (mul(h[i - 1], B) + s[i - 1]) % MOD;
}

Bước 2: kiểm một độ dài. Lấy hash của mọi cửa sổ dài LL, sắp xếp; có lặp khi hai hash bằng nhau đứng cạnh nhau. Đây là tình huống “so mọi cặp”: khoảng n2/2=2⋅1010n^2 / 2 = 2 \cdot 10^{10} cặp cửa sổ có thể trùng hash, nên một modulo 10910^9 sẽ gần như chắc chắn báo trùng nhầm; với 261−12^{61} - 1, số cặp trùng nhầm kỳ vọng chỉ cỡ 10−810^{-8}.

C++
bool check(ll len) {
    vector<ull> v;
    for (ll i = 1; i + len - 1 <= n; i++) v.push_back(get(i, i + len - 1));
    sort(v.begin(), v.end());
    for (ll i = 1; i < (ll)v.size(); i++)
        if (v[i] == v[i - 1]) return 1;
    return 0;
}

Bước 3: chặt nhị phân độ dài. Tìm LL lớn nhất có check(L) đúng; không có thì giữ 0.

C++
ll l = 1, r = n - 1, res = 0;
while (l <= r) {
    ll mid = (l + r) >> 1;
    if (check(mid)) l = (res = mid) + 1;
    else r = mid - 1;
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using ull = unsigned long long;

const ull MOD = (1ULL << 61) - 1;
const ll N = 2e5 + 5;
mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());
ull B = rng() % (MOD - 1000) + 500, h[N], pw[N];
ll n;
string s;

ull mul(ull a, ull b) {
    __int128 t = (__int128)a * b;
    ull r = (ull)(t & MOD) + (ull)(t >> 61);
    return r >= MOD ? r - MOD : r;
}

ull get(ll l, ll r) {
    return (h[r] + MOD - mul(h[l - 1], pw[r - l + 1])) % MOD;
}

bool check(ll len) {
    vector<ull> v;
    for (ll i = 1; i + len - 1 <= n; i++) v.push_back(get(i, i + len - 1));
    sort(v.begin(), v.end());
    for (ll i = 1; i < (ll)v.size(); i++)
        if (v[i] == v[i - 1]) return 1;
    return 0;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> s;
    pw[0] = 1;
    for (ll i = 1; i <= n; i++) {
        pw[i] = mul(pw[i - 1], B);
        h[i] = (mul(h[i - 1], B) + s[i - 1]) % MOD;
    }
    ll l = 1, r = n - 1, res = 0;
    while (l <= r) {
        ll mid = (l + r) >> 1;
        if (check(mid)) l = (res = mid) + 1;
        else r = mid - 1;
    }
    cout << res << '\n';
}
5
ababa

Khoảng log⁡2n≈18\log_2 n \approx 18 lần kiểm, mỗi lần sắp nn hash: O(nlog⁡n⋅log⁡n)O(n \log n \cdot \log n).

Hash cho tập và đa tập

Hash đa thức nhạy với thứ tự: ab và ba có hash khác nhau. Có những bài chỉ quan tâm phần tử nào có mặt và bao nhiêu lần. Ví dụ, cho dãy a1,…,ana_1, \dots, a_n và các truy vấn (l,r)(l, r): đoạn a[l..r]a[l..r] có phải một hoán vị của 1,2,…,r−l+11, 2, \dots, r - l + 1 không? Sắp xếp từng đoạn tốn O(nlog⁡n)O(n \log n) mỗi truy vấn.

Cách làm là bỏ hẳn đa thức. Gán cho mỗi giá trị vv một trọng số ngẫu nhiên w(v)w(v) cỡ 64 bit, và lấy hash của đa tập là tổng các trọng số của các phần tử, theo modulo 2642^{64}. Phép cộng giao hoán nên thứ tự biến mất, còn số lần xuất hiện vẫn được giữ vì giá trị xuất hiện cc lần đóng góp c⋅w(v)c \cdot w(v). Tổng tiền tố của các trọng số cho hash của mọi đoạn trong O(1)O(1); đoạn là hoán vị của 1..k1..k khi hash của nó bằng w(1)+⋯+w(k)w(1) + \dots + w(k). Trọng số tính từ chính giá trị vv bằng hàm trộn splitmix64 cộng một hạt giống lấy từ đồng hồ, khỏi cần bảng.

C++
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using ull = unsigned long long;

const ll N = 2e5 + 5;
const ull SEED = chrono::steady_clock::now().time_since_epoch().count();
ll n, q;
ull pre[N], full[N];

ull w(ull v) {
    ull x = v + SEED + 0x9e3779b97f4a7c15ULL;
    x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
    x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
    return x ^ (x >> 31);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> q;
    for (ll i = 1; i <= n; i++) {
        ll a;
        cin >> a;
        pre[i] = pre[i - 1] + w(a);
        full[i] = full[i - 1] + w(i);
    }
    while (q--) {
        ll l, r;
        cin >> l >> r;
        cout << (pre[r] - pre[l - 1] == full[r - l + 1] ? "YES" : "NO") << '\n';
    }
}
6 6
3 1 2 5 4 2
1 3
2 4
3 6
1 5
4 5
6 6

Chương trình này cố ý để unsigned long long tràn, khác hẳn hash tràn số ở mục trước, và nó an toàn nhờ các trọng số coi như ngẫu nhiên, độc lập. Hai đa tập khác nhau ở ít nhất một giá trị uu với số lần chênh d≠0d \ne 0, ∣d∣≤n|d| \le n; hiệu hai hash có dạng d⋅w(u)+Xd \cdot w(u) + X, và với 2j2^j là lũy thừa 2 lớn nhất chia hết dd, phương trình d⋅w(u)≡−Xd \cdot w(u) \equiv -X có không quá 2j2^j nghiệm trong 2642^{64} giá trị. Với n<218n < 2^{18} xác suất sai nhỏ hơn 2−472^{-47} mỗi truy vấn. Với tập (bỏ qua số lần xuất hiện) ta XOR thay vì cộng, và chỉ XOR một giá trị vào hash ở lần xuất hiện đầu tiên của nó.

Hỏi nhanh

Ta hash đa tập bằng XOR các trọng số ngẫu nhiên: G(A)=⨁x∈Aw(x)G(A) = \bigoplus_{x \in A} w(x). Cặp đoạn nào dưới đây chắc chắn cho cùng hash dù không phải hoán vị của nhau?

Ví dụTập Tiền Tố

Đề. Hai dãy AA, BB cùng N≤2⋅105N \le 2 \cdot 10^5 phần tử, giá trị tới 10910^9. Mỗi trong Q≤2⋅105Q \le 2 \cdot 10^5 truy vấn (x,y)(x, y) hỏi tập các giá trị trong A1..AxA_1..A_x có bằng tập các giá trị trong B1..ByB_1..B_y không.

Lời giải. Gán mỗi giá trị một trọng số ngẫu nhiên 64 bit; chữ ký của một tập là XOR trọng số của các giá trị phân biệt có mặt. Hai tập bằng nhau thì chữ ký bằng nhau. Hai tập khác nhau thì XOR hai chữ ký bằng XOR trọng số trên hiệu đối xứng khác rỗng, bằng 0 với xác suất 2−642^{-64}. Vậy mỗi truy vấn chỉ là so hai số đã tính sẵn. Với ví dụ, {5,1}\{5, 1\} và {1,5}\{1, 5\} bằng nhau nên truy vấn 2 là Yes.

Bước 1: trọng số của một giá trị. Hàm w trộn giá trị (cộng một hạt giống lấy từ đồng hồ) qua vài phép nhân và dịch bit, cho một số 64 bit trông như ngẫu nhiên; cùng giá trị luôn cho cùng trọng số. Hạt giống đổi mỗi lần chạy để không ai dựng được test làm trùng.

C++
ull w(ull v) {
    ull x = v + SEED + 0x9e3779b97f4a7c15ULL;
    x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
    x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
    return x ^ (x >> 31);
}

Bước 2: chữ ký tiền tố của tập. Quét dãy từ trái sang, chữ ký chỉ đổi ở vị trí giá trị xuất hiện lần đầu. Lần lặp sau phải giữ nguyên, nếu không XOR hai lần làm giá trị biến mất khỏi chữ ký. seen.insert(x).second cho biết xx vừa được thêm lần đầu.

C++
void build(ll a[], ull p[]) {
    set<ll> seen;
    for (ll i = 1; i <= n; i++) {
        p[i] = p[i - 1];
        if (seen.insert(a[i]).second) p[i] ^= w(a[i]);
    }
}

Bước 3: trả lời. So pa[x] với pb[y].

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using ull = unsigned long long;

const ll N = 2e5 + 5;
const ull SEED = chrono::steady_clock::now().time_since_epoch().count();
ll n, q, a[N], b[N];
ull pa[N], pb[N];

ull w(ull v) {
    ull x = v + SEED + 0x9e3779b97f4a7c15ULL;
    x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
    x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
    return x ^ (x >> 31);
}

void build(ll a[], ull p[]) {
    set<ll> seen;
    for (ll i = 1; i <= n; i++) {
        p[i] = p[i - 1];
        if (seen.insert(a[i]).second) p[i] ^= w(a[i]);
    }
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    for (ll i = 1; i <= n; i++) cin >> b[i];
    build(a, pa);
    build(b, pb);
    cin >> q;
    while (q--) {
        ll x, y;
        cin >> x >> y;
        cout << (pa[x] == pb[y] ? "Yes" : "No") << '\n';
    }
}
6
5 1 5 2 3 2
1 5 1 3 2 4
6
1 1
2 2
3 3
4 4
5 5
6 6

Dựng O(Nlog⁡N)O(N \log N) vì set, mỗi truy vấn O(1)O(1).

Hash cho dãy số, bảng và dữ liệu thay đổi

Dãy số. Hash đa thức dùng được cho dãy số nguyên: coi mỗi phần tử là một ký tự có giá trị trong [1,M)[1, M), cộng thêm một hằng số nếu có số âm hoặc số 0. Nhiều bài hỏi khớp mẫu sai khác một hằng số: đoạn a[l..l+m−1]a[l..l+m-1] khớp bb nếu có xx để al+i=bi+xa_{l+i} = b_i + x với mọi ii. Cộng cùng một số vào mọi phần tử không đổi hiệu hai phần tử liền kề, nên điều kiện tương đương là hai dãy hiệu bằng nhau; ta hash hai dãy hiệu rồi so khớp như xâu.

Bảng hai chiều. Để tìm một mẫu hình chữ nhật trong bảng ký tự, hash từng cửa sổ ngang của mỗi hàng bằng cơ số b1b_1, rồi coi mỗi hash hàng là một ký tự và hash theo chiều dọc bằng cơ số b2b_2 khác.

Dữ liệu bị sửa. Với mảng tiền tố, đổi một ký tự làm hỏng mọi hih_i phía sau. Khi cập nhật xen kẽ truy vấn, lưu hash trong cây phân đoạn (bài 17): mỗi nút giữ hash và độ dài đoạn của nó, ghép hai nút con theo H(L)⋅b∣R∣+H(R)H(L) \cdot b^{|R|} + H(R). Kiểm tra palindrome có cập nhật cần hai cây, một cho ss và một cho xâu đảo.

Ví dụMẫu Cao Độ Dịch Chuyển

Đề. Cho dãy aa gồm nn số và dãy bb gồm mm số (n,m≤2⋅105n, m \le 2 \cdot 10^5, giá trị tới 10910^9). Đếm số vị trí ll mà có xx (tùy ý, được khác nhau giữa các vị trí) để al+i=bi+xa_{l+i} = b_i + x với mọi ii.

Lời giải. Đổi hai dãy thành dãy hiệu: dai=ai+1−aida_i = a_{i+1} - a_i dài n−1n - 1, dbi=bi+1−bidb_i = b_{i+1} - b_i dài m−1m - 1. Một đoạn của aa khớp bb (sau khi cộng cùng một xx) khi và chỉ khi đoạn hiệu tương ứng dài m−1m - 1 bằng dbdb: phép cộng xx không đổi hiệu. Bài thành so khớp mẫu trên hai dãy số, giải bằng hash như ví dụ đầu. Với ví dụ, db=(2,1)db = (2, 1) khớp ở đầu và ở vị trí 3 của dãy hiệu (2,1,−6,2,1,5,2)(2, 1, -6, 2, 1, 5, 2).

Bước 1: hai trường hợp biên. m=1m = 1 thì dãy hiệu rỗng và mọi vị trí đều khớp, đáp án là nn. Khi m>nm > n thì vòng so ở bước 3 không chạy lần nào, đáp án tự là 0.

C++
if (m == 1) {
    cout << n << '\n';
    return 0;
}

Bước 2: hash của hai dãy hiệu. Hiệu nằm trong [−109,109][-10^9, 10^9]; cộng 109+110^9 + 1 cho thành số dương rồi dùng làm giá trị “ký tự”.

C++
for (ll i = 1; i < n; i++) {
    pw[i] = mul(pw[i - 1], B);
    h[i] = (mul(h[i - 1], B) + (ull)(a[i + 1] - a[i] + 1000000001)) % MOD;
}
for (ll i = 1; i < m; i++) g[i] = (mul(g[i - 1], B) + (ull)(b[i + 1] - b[i] + 1000000001)) % MOD;

Bước 3: so mọi cửa sổ dài m−1m - 1.

C++
ll len = m - 1, ans = 0;
for (ll i = 1; i + len - 1 <= n - 1; i++)
    if ((h[i + len - 1] + MOD - mul(h[i - 1], pw[len])) % MOD == g[len]) ans++;
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using ull = unsigned long long;

const ull MOD = (1ULL << 61) - 1;
const ll N = 2e5 + 5;
mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());
ull B = rng() % (MOD - 1000) + 500, h[N], g[N], pw[N];
ll n, m, a[N], b[N];

ull mul(ull x, ull y) {
    __int128 t = (__int128)x * y;
    ull r = (ull)(t & MOD) + (ull)(t >> 61);
    return r >= MOD ? r - MOD : r;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    for (ll i = 1; i <= m; i++) cin >> b[i];
    if (m == 1) {
        cout << n << '\n';
        return 0;
    }
    pw[0] = 1;
    for (ll i = 1; i < n; i++) {
        pw[i] = mul(pw[i - 1], B);
        h[i] = (mul(h[i - 1], B) + (ull)(a[i + 1] - a[i] + 1000000001)) % MOD;
    }
    for (ll i = 1; i < m; i++) g[i] = (mul(g[i - 1], B) + (ull)(b[i + 1] - b[i] + 1000000001)) % MOD;
    ll len = m - 1, ans = 0;
    for (ll i = 1; i + len - 1 <= n - 1; i++)
        if ((h[i + len - 1] + MOD - mul(h[i - 1], pw[len])) % MOD == g[len]) ans++;
    cout << ans << '\n';
}
8 3
5 7 8 2 4 5 10 12
1 3 4

Cả bài O(n+m)O(n + m).

Khi đi thi

  • Gặp “hai đoạn có bằng nhau không” với nhiều truy vấn, hoặc một tính chất đơn điệu theo độ dài (xâu lặp, tiền tố chung, đối xứng), hãy nghĩ tới hash tiền tố cộng chặt nhị phân; nhưng nếu một lượt KMP hay hàm Z (bài 21, 48) giải được thì ưu tiên chúng vì chúng không có xác suất sai.
  • Mặc định dùng modulo 261−12^{61} - 1 (hoặc hai modulo nguyên tố cỡ 10910^9) với cơ số ngẫu nhiên; không dùng hash đa thức tràn số 2642^{64}, không dùng một modulo 10910^9 khi so mọi cặp trong hàng chục nghìn hash, giá trị ký tự phải ≥1\ge 1 và sau mỗi phép trừ nhớ cộng MM.
  • Bài chỉ hỏi tập hoặc đa tập, không quan tâm thứ tự, thì gán trọng số ngẫu nhiên cho giá trị rồi cộng (đa tập) hoặc XOR các giá trị phân biệt (tập); hash đa thức không làm được việc này.

Tóm tắt

  • H(s)=∑sibn−i mod MH(s) = \sum s_i b^{n-i} \bmod M, hi=hi−1b+sih_i = h_{i-1} b + s_i, H(s[l..r])=hr−hl−1br−l+1H(s[l..r]) = h_r - h_{l-1} b^{r-l+1}: dựng O(n)O(n), lấy hash đoạn O(1)O(1).
  • Xâu bằng nhau thì hash bằng nhau; ngược lại chỉ đúng với xác suất cao: với MM nguyên tố và cơ số ngẫu nhiên, một cặp sai với xác suất không quá (n−1)/M(n-1)/M, còn so mọi cặp trong mm hash cần M≫m2M \gg m^2 (nghịch lý ngày sinh).
  • Hash tràn số 2642^{64} bị chuỗi Thue-Morse độ dài 1024 bẻ với mọi cơ số; dùng 261−12^{61} - 1 hoặc hai modulo nguyên tố, cơ số ngẫu nhiên.
  • Hash kết hợp chặt nhị phân cho tiền tố chung, xâu lặp, đối xứng (tách độ dài chẵn lẻ); mỗi bước so sánh O(1)O(1).
  • Tập và đa tập dùng trọng số ngẫu nhiên: cộng cho đa tập, XOR các giá trị phân biệt cho tập; dãy sai khác một hằng số thì so dãy hiệu.

Bài tập

1
Viền Của Xâucanuc80k1903Độ dài kk là viền khi hash của kk ký tự đầu bằng hash của kk ký tự cuối; thử mọi k<nk < n.
LQDOJ
2
Nhịp Lặpcanuc80k1904Độ dài pp là chu kỳ khi s[1..n−p]s[1..n-p] bằng s[p+1..n]s[p+1..n]; duyệt mọi pp và so hash, riêng p=np = n luôn đúng.
LQDOJ
3
Cửa Sổ Đối Xứngcanuc80k1905Đếm cửa sổ dài kk đối xứng bằng hash của ss và của xâu đảo.
LQDOJ
4
Nối Đuôi Thành Đối Xứngcanuc80k1906Tìm hậu tố đối xứng dài nhất bằng hash của xâu và xâu đảo; phần đứng trước nó viết ngược nối vào cuối. Đọc từng dòng tới hết dữ liệu.
LQDOJ
5
Viền Xuất Hiện Bên Trongcanuc80k1920Độ dài lớn nhất KK mà tiền tố dài KK còn xuất hiện ở bên trong là đơn điệu, chặt nhị phân được; đáp án là viền dài nhất không vượt KK.
LQDOJ
6
Thống Kê Các Viềncanuc80k1921Viền là các LL có hash tiền tố bằng hash hậu tố; số lần xuất hiện của tiền tố dài LL cho mọi LL: tính lcp của mỗi vị trí với vị trí 1 bằng chặt nhị phân rồi cộng dồn từ dài xuống ngắn.
LQDOJ
7
Số Xâu Con Khác Nhaucanuc80k1932Mọi xâu con đều lấy hash trong O(1)O(1); đếm hash phân biệt bằng sắp xếp theo từng độ dài, với modulo cỡ 101810^{18}.
LQDOJ
8
So Sánh Đa Tập Trên Hai Dãycanuc80k1944Hash cộng với trọng số ngẫu nhiên cho đa tập: so tổng trọng số của hai đoạn bằng tổng tiền tố.
LQDOJ
9
Đối Xứng Dài Nhấtcanuc80k1914Xâu đối xứng dài nhất: chặt nhị phân riêng độ dài lẻ và độ dài chẵn, kiểm tra bằng xâu đảo.
LQDOJ
10
Đoạn Lặp Không Giao Nhaucanuc80k1928Giống ví dụ Đoạn Lặp Dài Nhất nhưng hai lần xuất hiện không được chồng nhau: với mỗi hash, giữ vị trí xuất hiện đầu tiên.
LQDOJ
11
Vòng Xoay Nhỏ Nhấtcanuc80k1902Mọi phép xoay là một đoạn dài nn của s+ss + s; giữ phép xoay nhỏ nhất đã gặp và so mỗi phép xoay mới bằng lcp nhị phân.
LQDOJ
12
Bộ Nhớ Sai Một Ký Tựcanuc80k1912Với mỗi truy vấn, thử đổi từng ký tự sang hai ký tự còn lại, cập nhật hash trong O(1)O(1) rồi tra trong tập hash của các xâu cùng độ dài.
LQDOJ
13
Mẫu Trên Bảngcanuc80k1909Hash từng hàng theo chiều ngang rồi ghép các hash hàng theo chiều dọc bằng cơ số thứ hai.
LQDOJ
14
Mẫu Ký Tự Tương Ứngcanuc80k1935Mỗi chữ cái có một xâu chỉ báo 0/1; hai đoạn đẳng cấu khi hai bộ 26 hash khớp nhau sau khi sắp xếp.
LQDOJ
15
Đối Xứng Độngcanuc80k1919Cần cập nhật xen kẽ truy vấn: hash của đoạn lưu trong cây phân đoạn, ghép bằng H(L)⋅b∣R∣+H(R)H(L) \cdot b^{|R|} + H(R), kèm cây cho xâu đảo.
LQDOJ

Đọc thêm

  • Hash: A String Matching AlgorithmVNOI WikiBài tiếng Việt kinh điển của cộng đồng chuyên Tin: palindrome dài nhất, hoán vị vòng, xâu con lặp, đánh giá sai số bằng nghịch lý ngày sinh.
  • String Hashingcp-algorithmsTrình bày gọn hash đa thức, so đoạn không cần phép chia, đếm xâu con phân biệt và palindrome.
  • 字符串哈希OI WikiPhân tích xác suất va chạm, cách bẻ hash tràn số, và các ứng dụng gồm khớp mẫu cho phép kk lỗi.
  • HashingUSACO GuideCận xác suất bằng bổ đề Schwartz-Zippel, modulo 261−12^{61} - 1, hash XOR cho tập, kèm danh sách bài tập chọn lọc.
  • Anti-hash testZlobober, CodeforcesBài gốc về chuỗi Thue-Morse bẻ hash tràn số, và phần bình luận về nghịch lý ngày sinh.
  • Analysis of polynomial hashingpurplesyringa, CodeforcesPhân tích chặt chẽ hơn về xác suất va chạm, bậc của cơ số theo modulo, và các cách tấn công.