DeMen Blog #1: “Bàn về Hash: Khi Hash không chỉ là so sánh hai xâu.”
Tác giả: Võ Khắc Triệu (DeMen100ns)
Hash là một thuật toán để so sánh hai xâu trong O(1) với độ sai rất nhỏ.
Không, Hash không phải như thế này.
0. Vậy rốt cuộc Hash là gì?
Wikipedia định nghĩa hàm băm (Hash function) như sau:
A hash function is any function that can be used to map data of arbitrary size to fixed-size values.
Tạm dịch: Hàm băm (Hash function) là một hàm ánh xạ, trong đó một giá trị sẽ được biểu diễn bằng một giá trị hash nằm trong một khoảng giá trị cố định.

Ví dụ về hash function (Nguồn: Wikipedia)
Mục đích của việc hash là để giảm chi phí hay độ phức tạp cho việc so sánh hai giá trị cụ thể nào đó. Tuy nhiên, có thể sẽ có một số cặp giá trị khác nhau có cùng giá trị hash giống nhau và chúng ta chấp nhận việc này như một lỗi, đồng thời cố gắng tạo ra hàm hash để giảm xác suất xảy ra lỗi.
Tính chất:
- Nếu hai biến có giá trị giống nhau thì sẽ có giá trị hash giống nhau.
- Nếu hai biến có giá trị hash giống nhau thì khả năng cao sẽ có giá trị giống nhau (Phụ thuộc vào hàm hash).
Với thuật toán dùng để so sánh 2 xâu giống nhau, tên đầy đủ của thuật toán là Rolling Hash với việc lấy số dư của các hàm đa thức cho một số lớn (thường là nguyên tố) để tính ra giá trị hash.
Tuy nhiên, đây không phải là hàm hash duy nhất. Trên thực tế, có rất nhiều hàm hash khác nhau, mỗi hàm đều có một hoặc một vài ứng dụng khác nhau.
Trong bài viết này, chúng ta sẽ bàn về một số các hàm hash khác và các ứng dụng của nó.
1. XOR Hashing
Ý tưởng
Trước khi đọc tiếp, các bạn có thể tìm hiểu về phép toán tử bitwise XOR tại đây
Ta tạo một hàm hash , với mỗi giá trị thì tương ứng với một số ngẫu nhiên (thường là hoặc ).
Xét một tập gồm chứa phần tử. Ta gọi giá trị Hash XOR của tập hay là tổng XOR của giá trị hash của các phần tử trong tập, hay:
Thao tác XOR các giá trị hash của các phần tử trong một tập được gọi là XOR Hashing.
Ta sẽ xét một bài toán đơn giản để hiểu về cách XOR Hashing hoạt động.
Bài toán
Xét mảng gồm phần tử, trả lời truy vấn sau:
- : Cho biết tất cả các giá trị từ đến có xuất hiện trong đoạn của mảng không?
Giới hạn:
Ta thấy, trong bài toán này, thứ tự của các vị trí không quan trọng, chỉ có tập các giá trị của đoạn mới quan trọng. Như vậy, với mỗi truy vấn ta sẽ kiểm tra xem tập có giống với tập hay không.
Lời giải
Giả sử và là hai tập giống nhau. Khi này ta có:
Với là phép bitwise XOR.
Khi này, ta chỉ cần kiểm tra điều kiện có thỏa hay không là được.
Ngạc nhiên thay, điều kiện nhìn tưởng chừng dễ sai này lại đúng một cách khó tin. Hãy cùng thử chứng minh lời giải này nhé.
Tính chất
Xét hai tập và độ dài . Vì XOR là một phép toán có tính chất giao hoán nên nếu và có cùng tập các phần tử thì:
Với tính chất trên, ta thấy được nếu hai tập và có cùng tập giá trị thì giá trị Hash XOR của hai tập sẽ bằng nhau.
Xác suất sai
Khi hai tập và khác nhau, ta chứng minh được xác suất trùng hash của hai tập sẽ là , một xác suất rất bé khi đủ lớn. Thật vậy, ta chứng minh như sau:
Với mỗi bit giá trị, có khả năng bit đó sẽ là và bit đó sẽ là . Tổng XOR của một dãy bit như vậy cũng sẽ có khả năng bit đó sẽ là và bit đó sẽ là .
Trở lại bài toán, vì về phải của là một số cố định và vế trái của là một số ngẫu nhiên nên xác suất hai vế bằng nhau trong trường hợp tập và khác nhau sẽ là hay .
Cài đặt
#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 5;const int MAXA = 1e6;
mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());
long long a[N], HXOR[N];long long pre[N], pre_a[N];
signed main(){ ios_base::sync_with_stdio(0); cin.tie(0);
int n, q; cin >> n >> q; for(int i = 1; i <= n; ++i){ cin >> a[i]; }
for(int i = 1; i <= MAXA; ++i){ HXOR[i] = rng(); }
for(int i = 1; i <= n; ++i){ pre[i] = pre[i - 1] ^ HXOR[i]; pre_a[i] = pre_a[i - 1] ^ HXOR[a[i]]; }
while (q--){ int l, r; cin >> l >> r; if (pre[r - l + 1] == (pre_a[l - 1] ^ pre_a[r])){ cout << "YES" << '\n'; } else { cout << "NO" << '\n'; } }}Ứng dụng
Ưu điểm lớn nhất XOR Hashing là có thể tạo ra một khoảng giá trị hash rất lớn (thường tới hay ) và giá trị Hash XOR được rải đều nên khả năng sai là rất thấp và rất dễ cài đặt.
XOR Hashing thường có hai ứng dụng chính:
- Kiểm tra các giá trị trong hai tập khác nhau có cùng số lần lặp lại hay không.
- Các bài toán liên quan đến phép bitwise XOR.
Mở rộng
XOR Hashing thường sẽ gặp vấn đề khi giải các bài toán có các giá trị bị lặp lại do tính chất . Mở rộng ra một chút, ta có thể dùng k$$-$$nary XOR (phép XOR trên hệ cơ số ) để xử lý các bài toán với các giá trị bị lặp lại không quá lần.
2. Sum Hashing
Ý tưởng
Tương tự với XOR Hashing, ta tạo một hàm hash , với mỗi giá trị thì tương ứng với một số ngẫu nhiên.
Xét tập gồm một số giá trị (có thể lặp lại). Ta gọi giá trị Hash Sum của tập hay là tổng của giá trị hash của các phần tử trong tập, hay:
Thao tác cộng các giá trị hash của các phần tử trong một tập được gọi là Sum Hashing.
Ta cũng sẽ xét một bài toán đơn giản để hiểu về cách Sum Hashing hoạt động.
Bài toán
Xét mảng gồm phần tử, thực hiện truy vấn có dạng sau:
- Cho biết mỗi giá trị trong đoạn của mảng có lặp lại số lần là bội số của hay không.
Giới hạn: .
Lời giải
Với bài toán này thì XOR Hashing không phải một ý tưởng tốt vì tính lặp lại của giá trị và có thể rất lớn.
Tuy nhiên, ta có thể tận dụng tính chất sau:
Tính chất
Nếu mỗi giá trị trong đoạn của mảng có lặp lại số lần là bội số của thì
Như vậy, ta có thể sử dụng Sum Hashing để kiểm tra điều kiện có thỏa hay không.
Xác suất sai
Dễ thấy xác suất sai đạt lớn nhất khi .
Giả sử tồn tại giá trị trong đoạn của mảng lặp lại số lần không phải là bội số của thì xác suất để điều kiện thỏa là .
Xác suất sai lúc này còn khá lớn, tuy nhiên ta có thể tạo ra hàm hash khác nhau và kiểm tra điều kiện cho tất cả chúng.
Xác suất sai khi sử dụng hàm hash khác nhau sẽ là , với sẽ đủ tốt.
Cài đặt
#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 5;const int MAXA = 1e6;
mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());
long long a[N], H[51][N];long long pre[51][N];
signed main(){ ios_base::sync_with_stdio(0); cin.tie(0);
int n, q; cin >> n >> q; for(int i = 1; i <= n; ++i){ cin >> a[i]; }
for(int t = 1; t <= 50; ++t){ for(int i = 1; i <= MAXA; ++i){ H[t][i] = rng() % (long long)(1e12); } for(int i = 1; i <= n; ++i){ pre[t][i] = pre[t][i - 1] + H[t][a[i]]; } }
while (q--){ int l, r, k; cin >> l >> r >> k;
bool f = true; for(int t = 1; t <= 50; ++t){ f &= ((pre[t][r] - pre[t][l - 1]) % k == 0); }
if (f){ cout << "YES" << '\n'; } else{ cout << "NO" << '\n'; } }}Ứng dụng
Hiển nhiên, với hai tập và giống nhau thì:
Ta có thể sử dụng tính chất này để so sánh hai tập mà không bị ràng buộc điều kiện như XOR Hashing. Tuy vậy, ta vẫn có thể kết hợp thêm XOR Hashing như một điều kiện phụ để giảm xác suất sai của thuật toán.
3. Modulo Hashing
Ý tưởng
Xét một giá trị rất lớn nào đó. Ta có thể biểu diễn bằng số dư của nó khi chia cho một giá trị nào đó mà vẫn đảm bảo được các tính của giá trị gốc, hay:
Xét bài toán sau:
Bài toán
Hãy cho biết hay không, biết rằng và .
Giới hạn:
Lời giải
Trong bài toán này, việc tính trực tiếp sẽ tốn chi phí rất lớn và cài đặt rất khó do việc phải dùng Bignum.
Tuy nhiên, ta có thể sử dụng Modulo Hashing, trong đó, nếu thì:
Ta có thể tính và một cách dễ dàng.
Xác suất sai
Dễ thấy, nếu thì khả năng sẽ là .
Với đủ lớn thì xác suất sai sẽ trở nên rất nhỏ.
Cài đặt
#include <bits/stdc++.h>
using namespace std;
const int MOD = 1e9 + 9;
signed main(){ ios_base::sync_with_stdio(0); cin.tie(0);
int HA = 0, HB = 0; int n, m; cin >> n >> m;
for(int i = 1; i <= n; ++i){ int pi; cin >> pi; HA = (HA * 1LL * pi) % MOD; }
for(int i = 1; i <= m; ++i){ int qi; cin >> qi; HB = (HB * 1LL * qi) % MOD; }
if (HA == HB){ cout << "YES"; } else { cout << "NO"; }}Ứng dụng
Ứng dụng chính của Modulo Hashing là việc so sánh bằng nhau giữa hai số rất lớn (có thể lên tới hoặc lớn hơn), với mỗi số được tính bằng một dãy các phép tính của các số nguyên nhỏ hơn (thường là hoặc ). Ví dụ điển hình của ứng dụng này chính Rolling Hash.
Chọn số mod phù hợp
- Số mod nên là số nguyên tố.
- Các bạn có thể kết hợp sử dụng nhiều số mod.
Các bạn có thể xem thêm tại bài viết này của rng_58.
4. Hash nhà làm
Tóm lại, bản chất của hash chính là một hàm ánh xạ. Với từng bài toán, các bạn có thể tự tạo ra hàm hash phù hợp để giải quyết bài toán đó. Hàm hash cần phải đảm bảo tính chất ánh xạ và có xác suất sai đủ thấp (chứng minh hoặc thực nghiệm). Ngoài ra ta có thể kết hợp nhiều hàm hash với nhau để giảm xác suất sai xuống.
Bài tập
- Toph - EP-Palindrome
- Codeforces 1175F - The Number of Subpermutations
- Codeforces 869E - The Untended Antiquity
- Codeforces 1418G - Three Occurrences
- Codeforces 1622F - Quadratic Set
- Codeforces 1746F - Kazaee
- Meta Hacker Cup Round 2 - Problem A2
- Gym 101986F - Pizza Delivery
- CSES 1700 - Tree Isomorphism I
- CSES 1701 - Tree Isomorphism II
- CSES 1203 - Visiting Cities
