Cho một xâu và nhiều câu hỏi, mỗi câu cho hai đoạn cùng độ dài và hỏi chúng có giống nhau không. So từng ký tự tốn mỗi câu; với là phép. Làm sao so hai đoạn dài bằng một phép so sánh duy nhất?
Hash (băm) gán cho mỗi xâu một số nguyên cỡ 64 bit, như 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ị , dấu vân tay của mọi đoạn lấy được trong , 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
Hãy xem một xâu như một số viết trong hệ cơ số . 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ó chữ số cũng dài như xâu, nên ta lấy phần dư theo một số :
Ở đây là giá trị số của ký tự thứ (mã ASCII, hoặc c - 'a' + 1), là cơ số, là modulo. Cách tính gọn là sơ đồ Horner: với . 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ó giá trị hash. Giá trị ký tự phải : 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ố, và . Trong , ký tự mang trọng số , đúng trọng số mà hash của cần; phần thừa là , chính là bị đẩy sang trái vị trí. Vậy
Xem trong hệ thập phân cho dễ thấy: với = abacab, các tiền tố là ; đoạn là aca, số của nó là .
Trong hình, với và lớn, hash đọc được như số thập phân. Đổi , để lấy hash đoạn khác, hoặc đổi , .
Ta cần thêm mảng lũy thừa . Dựng cả hai mảng tốn , rồi mỗi đoạn lấy hash trong . Hai chỗ hay sai: phép trừ modulo có thể ra số âm nên phải cộng thêm ; và chỉ so hash của hai đoạn cùng độ dài. Khi một cửa sổ cố định độ dài trượt đi, ta cũng có thể cập nhật thẳng (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 gồm chữ thường, . In .
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ữ và cộng dần . Mọi giá trị giữ dưới ngay từ đầu; tới nên nhân với 31 phải bằng ll, int đã tràn. Với abcde: .
#include <bits/stdc++.h>
using namespace std;
#define 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
4739715
Chọn tham số
Lấy hai xâu khác nhau cùng độ dài . Chúng trùng hash khi và chỉ khi chia hết cho , với . có bậc không quá và không phải đa thức không. Khi là số nguyên tố, có nhiều nhất nghiệm theo modulo , nên nếu cơ số được bốc ngẫu nhiên đều trong sau khi hai xâu đã cố định, thì xác suất hai xâu trùng hash không quá
Với và , cận này là ; với nó là . Hai điều kiện của chứng minh cũng là hai quy tắc chọn tham số: 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 xâu vào một tập, hoặc sắp xếp rồi tìm hai hash bằng nhau. Có cặp, mỗi cặp trùng với xác suất cỡ , nên trung bình có khoảng 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 và , xác suất trùng là ; với chỉ cỡ . Quy tắc: so cặp định sẵn thì modulo cho xác suất sai khoảng ; so mọi cặp trong hash thì cần , tức cỡ : một modulo , hoặc hai modulo nguyên tố cỡ với hai cơ số độc lập.
Hỏi nhanh
Ta bỏ hash (modulo , cơ số ngẫu nhiên) của 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 . 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?
Có cặp, mỗi cặp trùng với xác suất cỡ , nên trung bình có khoảng 5 cặp trùng, và gần như chắc chắn có ít nhất một cặp (tính kỹ ra khoảng ).
Hai đáp án đầu đều là xác suất sai của một phép so sánh, trong khi tập phải chịu mọi cặp cùng lúc. Đáp án 5% là cảm giác “ít xâu so với nhiều giá trị”, nhưng số cặp tăng theo bình phương số xâu. Với , cùng xâu cho xác suất cỡ .
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 . Nhưng 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à , với là đổi a và b cho nhau: chuỗi Thue-Morse ab, abba, abbabaab, ...
Hình dưới tính hash thật theo modulo cho xâu Thue-Morse và xâu đảo của nó. Đổi cơ số lẻ để thử.
Từ suy ra hiệu thỏa , tức bằng tích , đổi dấu. Với lẻ, thừa số chẵn, còn với thừa số chia hết cho . Số thừa số 2 của ít nhất là : hai xâu và , dài 1024, trùng hash modulo với mọi cơ số lẻ. Cơ số chẵn còn tệ hơn, vì nên chỉ 64 ký tự cuối còn ảnh hưởng.
#include <bits/stdc++.h>
using namespace std;
#define 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';
}
khac 1024
31 trung
131 trung
911382323 trung
1000000007 trung
123456789012345 trung
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 và . Điều gì xảy ra?
Chứng minh ở trên không phụ thuộc cơ số lẻ nào: chia hết cho với mọi lẻ. Vì vậy hai hash tràn số, dù cơ số khác nhau, cùng trùng trên cặp xâu này.
Người ra đề không cần đoán cơ số. Với modulo nguyên tố, nghiệm của rất ít nên cơ số ngẫu nhiên mới có tác dụng; với , mọi cơ số lẻ đều là nghiệm.
Cài đặt với . Đây là một số nguyên tố, và tích hai số dưới nhỏ hơn , nên nhân bằng __int128. Vì , tích cho , nhỏ hơn , trừ 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ỡ với hai cơ số ngẫu nhiên, ghép hai hash vào một số 64 bit để so như một.
Dùng modulo lớn có làm chậm không?
Ba cách chênh nhau chưa tới 1,3 lần, và modulo không chậm hơn một modulo . Quy tắc: không có lý do tiết kiệm modulo; luôn dùng hay hai modulo.
Ví dụNhững Điểm Khớp
Đề. Cho văn bản () và mẫu (), gồm chữ cái Latin và chữ số. In mọi chỉ số (đánh số từ 0) mà bắt đầu tại khớp , kể cả các lần chồng lấn.
Lời giải. Thuật toán Rabin-Karp: hash của một lần, rồi so với hash của mọi cửa sổ dài của , lấy ra trong từ mảng hash tiền tố. Hai xâu phải dùng cùng cơ số . Số cặp được so chỉ là cặp định sẵn (mỗi cửa sổ với ), nên với modulo xác suất sai không đáng kể.
Bước 1: nhân theo modulo . Tích hai số dưới cần 122 bit, nên tính trong __int128. Vì , 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.
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 ký tự đầu. Hash của đoạn là f[r] trừ f[l - 1] đã dịch chỗ (nhân ); cộng MOD trước khi trừ để không âm. Mã ASCII của chữ và số đều nên dùng thẳng làm giá trị ký tự.
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í (đếm từ 1) của mảng hash là chỉ số của đề.
for (ll i = 1; i + m - 1 <= n; i++)
if (get(h, i, i + m - 1) == g[m]) cout << i - 1 << '\n';
#include <bits/stdc++.h>
using namespace std;
#define 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
0
2
5
7
9
Cả bài .
Hash và chặt nhị phân
Hash so hai đoạn trong , nên mọi tính chất kiểu “hai đoạn dài bằng nhau” kiểm được nhanh. Khi tính chất đó đơn điệu theo , ta chặt nhị phân (bài 05) trên .
Tiền tố chung dài nhất. Gọi là lớn nhất để . Nếu ký tự đầu của hai hậu tố bằng nhau thì ký tự đầu cũng bằng nhau, nên chặt nhị phân trên cho đáp án sau lần so hash. So hai hậu tố theo thứ tự từ điển cũng xong ngay: tìm rồi so ký tự kế tiếp. Với hàm get như trên:
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 = abracadabra: , , .
Trong hình, mỗi bước so hash của hai đoạn dài ; ô 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 là xâu đảo của , ; đoạn viết ngược chính là , nên dựng hash cho cả (mảng h) và (mảng g) rồi so get(h, l, r) với get(g, n + 1 - r, n + 1 - l). Với = dabacab, = bacabad: đoạn là aba, ứng với cũng là aba.
Trong hình, mỗi đường nối một ký tự của với đúng vị trí của nó trong . Kéo , để 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 ” không đơn điệu trên mọi : 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 được palindrome dài . 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 cửa sổ, cả bài .
Hỏi nhanh
Xâu 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?
Bỏ ký tự đầu và ký tự cuối của palindrome dài 5 thì phần ở giữa, dài 3, vẫn đọc xuôi ngược như nhau.
Ba khẳng định còn lại đều sai với = abcba: nó có xâu đối xứng dài 5 nhưng không có xâu đối xứng dài 2, 4 hay 6. Vì vậy không chặt nhị phân thẳng trên mọi độ dài được; phải tách độ dài lẻ và độ dài chẵn.
Ví dụĐoạn Lặp Dài Nhất
Đề. Cho xâu gồm chữ thường, dài . 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 là “có một xâu con dài xuất hiện tại hai vị trí khác nhau”. Nếu đúng thì 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 trong , mỗi lần kiểm bằng hash. Với ababa: cho abab, baba, khác nhau; có aba hai lần.
Bước 1: hash tiền tố. Như ví dụ trước, mul nhân theo modulo và get(l, r) cho hash của đoạn trong . Mảng lũy thừa và mảng hash tính chung một vòng.
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 , 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 cặp cửa sổ có thể trùng hash, nên một modulo sẽ gần như chắc chắn báo trùng nhầm; với , số cặp trùng nhầm kỳ vọng chỉ 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 lớn nhất có check(L) đúng; không có thì giữ 0.
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;
}
#include <bits/stdc++.h>
using namespace std;
#define 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
3
Khoảng lần kiểm, mỗi lần sắp hash: .
Hash 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 và các truy vấn : đoạn có phải một hoán vị của không? Sắp xếp từng đoạn tốn mỗi truy vấn.
Cách làm là bỏ hẳn đa thức. Gán cho mỗi giá trị một trọng số ngẫu nhiên 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 . 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 lần đóng góp . Tổng tiền tố của các trọng số cho hash của mọi đoạn trong ; đoạn là hoán vị của khi hash của nó bằng . Trọng số tính từ chính giá trị 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.
#include <bits/stdc++.h>
using namespace std;
#define 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
YES
NO
NO
YES
NO
NO
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ị với số lần chênh , ; hiệu hai hash có dạng , và với là lũy thừa 2 lớn nhất chia hết , phương trình có không quá nghiệm trong giá trị. Với xác suất sai nhỏ hơn 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: . 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ì nên hai số 5 triệt tiêu nhau: . XOR chỉ nhớ một giá trị xuất hiện lẻ hay chẵn lần, không đếm được số lần, nên không dùng được cho đa tập; ta phải dùng phép cộng, hoặc chỉ XOR lần xuất hiện đầu của mỗi giá trị khi bài hỏi về tập.
Cặp và là hoán vị nên cùng hash là đúng, không thuộc nhóm “dù không phải hoán vị”. Hai cặp còn lại có hash so với , và so với : khác nhau, trừ phi xui xẻo rất hiếm.
Ví dụTập Tiền Tố
Đề. Hai dãy , cùng phần tử, giá trị tới . Mỗi trong truy vấn hỏi tập các giá trị trong có bằng tập các giá trị trong 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 . Vậy mỗi truy vấn chỉ là so hai số đã tính sẵn. Với ví dụ, và 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.
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 vừa được thêm lần đầu.
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].
#include <bits/stdc++.h>
using namespace std;
#define 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
No
Yes
Yes
No
Yes
No
Dựng vì set, mỗi truy vấn .
Dãy số, bảng, cập nhật
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 , 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 khớp nếu có để với mọi . 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ố , rồi coi mỗi hash hàng là một ký tự và hash theo chiều dọc bằng cơ số 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 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 . Kiểm tra palindrome có cập nhật cần hai cây, một cho và một cho xâu đảo.
Ví dụMẫu Cao Độ Dịch Chuyển
Đề. Cho dãy gồm số và dãy gồm số (, giá trị tới ). Đếm số vị trí mà có (tùy ý, được khác nhau giữa các vị trí) để với mọi .
Lời giải. Đổi hai dãy thành dãy hiệu: dài , dài . Một đoạn của khớp (sau khi cộng cùng một ) khi và chỉ khi đoạn hiệu tương ứng dài bằng : phép cộng 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ụ, khớp ở đầu và ở vị trí 3 của dãy hiệu .
Bước 1: hai trường hợp biên. thì dãy hiệu rỗng và mọi vị trí đều khớp, đáp án là . Khi thì vòng so ở bước 3 không chạy lần nào, đáp án tự là 0.
if (m == 1) {
cout << n << '\n';
return 0;
}
Bước 2: hash của hai dãy hiệu. Hiệu nằm trong ; cộng cho thành số dương rồi dùng làm giá trị “ký tự”.
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 .
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++;
#include <bits/stdc++.h>
using namespace std;
#define 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
2
Cả bài .
Khi đi thi
- “Hai đoạn có bằng nhau không” với nhiều truy vấn, hay tính chất đơn điệu theo độ dài (xâu lặp, tiền tố chung, đối xứng): hash tiền tố cộng chặt nhị phân. Nếu KMP hay hàm Z (bài 21, 48) giải được thì ưu tiên chúng, vì không có xác suất sai.
- Mặc định dùng modulo (hoặc hai modulo nguyên tố cỡ ) với cơ số ngẫu nhiên. Không dùng hash tràn số ; không dùng một modulo khi so mọi cặp trong hàng chục nghìn hash; giá trị ký tự phải ; sau phép trừ nhớ cộng .
- Chỉ hỏi tập hoặc đa tập, không quan tâm thứ tự: 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).
Tóm tắt
- , , : dựng , lấy hash đoạn .
- 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 nguyên tố và cơ số ngẫu nhiên, một cặp sai với xác suất không quá , còn so mọi cặp trong hash cần (nghịch lý ngày sinh).
- Hash tràn số bị chuỗi Thue-Morse độ dài 1024 bẻ với mọi cơ số; dùng 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 .
- 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
Đọc thêm
- Hash: A String Matching AlgorithmVNOI Wiki
- String Hashingcp-algorithms
- 字符串哈希OI Wiki
- HashingUSACO Guide
- Anti-hash testZlobober, Codeforces
- Analysis of polynomial hashingpurplesyringa, Codeforces