Đây là bản xem thử. Phiên bản đầy đủ trong ứng dụng có bài tập, AI chấm ngay và theo dõi tiến độ học.

Bài học

Băm xâu (String hashing) — polynomial rolling hash

Mục tiêu bài học

Sau bài này, em tiền xử lý được một xâu trong `O(n)` để trả lời câu hỏi "hai đoạn con này có bằng nhau không" trong `O(1)` mỗi truy vấn, thay vì so từng ký tự mỗi lần hỏi — và biết khi nào một modulo nhỏ không còn an toàn, phải chuyển sang double hashing.
Cần nhớ trước

Bài này cần: thao tác trên mảng, số học modulo cơ bản (phép cộng, nhân, trừ có modulo — học ở Tin học 10), và khái niệm xâu ký tự. Không cần biết trước gì về hàm băm.

Ở mức chuẩn lớp 12, so sánh hai xâu hay hai đoạn con thường dừng ở việc gọi thẳng `s1 == s2` hoặc duyệt từng ký tự — chấp nhận chi phí `O(n)` mỗi lần so sánh vì đề chuẩn chỉ hỏi vài lần. Đề HSG QG đổi luật chơi: cho một xâu `n` lên tới `10^5`–`10^6`, rồi hỏi `q` truy vấn dạng "đoạn `[l1,r1]` và đoạn `[l2,r2]` có giống hệt nhau không", với `q` cũng cỡ `10^5`. So từng ký tự cho mỗi truy vấn tốn `O(n)`, nhân với `q` truy vấn là `O(nq)` — có thể chạm `10^11` phép so sánh, chắc chắn quá giờ.

Bài toán mở đầu — vì sao so sánh trực tiếp không đủ

Giả sử xâu `s` dài `10^5` ký tự, và đề hỏi liên tiếp `10^5` truy vấn so sánh hai đoạn con bất kỳ. Nếu mỗi truy vấn so từng ký tự, chương trình phải làm tối đa `10^5 × 10^5 = 10^{10}` phép so sánh — vượt xa những gì `10^8` phép toán mỗi giây có thể xử lý trong 1 giây cho phép. Cái ta cần là một cách "tóm tắt" mỗi đoạn con thành một con số duy nhất, sao cho hai đoạn bằng nhau thì con số đó bằng nhau (gần như chắc chắn), và tính con số đó cho bất kỳ đoạn nào cũng chỉ mất `O(1)` sau một bước tiền xử lý.

Ý tưởng cốt lõi — xâu là một số viết theo một cơ số nào đó

Băm xâu (string hashing) coi mỗi ký tự là một chữ số, và cả xâu là một số nguyên viết theo cơ số `b`: `hash(s) = s[0]·b^(n-1) + s[1]·b^(n-2) + ... + s[n-1] mod p`. Cách viết này quen thuộc hơn em nghĩ — nó giống hệt cách một số thập phân `present = 3·10^2 + 1·10 + 4` là "314", chỉ khác cơ số `b` và có thêm phép lấy dư `mod p` để con số không phình to vô hạn khi `n` lớn. Hai đoạn xâu bằng nhau thì hai con số này bằng nhau; ngược lại, nếu `b` và `p` chọn đủ tốt, hai đoạn khác nhau cho ra hai con số khác nhau với xác suất áp đảo.
Điểm mấu chốt để đạt `O(1)` mỗi truy vấn không phải tính lại công thức trên cho từng đoạn, mà là tiền xử lý MỘT LẦN hai mảng: mảng hash tiền tố `h[i]` (hash của `i` ký tự đầu) và mảng lũy thừa `pw[i] = b^i mod p`. Khi đó hash của đoạn con `[l, r]` bất kỳ suy ra trực tiếp từ hai mảng này bằng một phép trừ và một phép nhân — không cần quét lại xâu.
Quy trình tiền xử lý băm xâu1Chọn cơ số b vàmodulo pb nguyên tố > bảngchữ cái, p nguyên tốlớn (~109)2Tính mảng hashtiền tố h[]h[i] = h[i-1]·b + giátrị ký tự thứ i, modp — O(n)3Tính mảng lũythừa pw[]pw[i] = bi mod p —O(n), tính song songvới h[]4Trả lờigetHash(l, r)h[r] -h[l-1]·pw[r-l+1], modp — O(1) mỗi truy vấn
Bốn bước biến so sánh O(n) mỗi truy vấn thành O(1) mỗi truy vấn, sau một lần tiền xử lý O(n).
Đặc thù Python khi xử lý xâu
Đặc thù Python đáng chú ý: số nguyên Python có ĐỘ CHÍNH XÁC TÙY Ý (arbitrary-precision) — `h[i-1] * b` không bao giờ tự "tràn" như kiểu `int` 32-bit hay `long long` 64-bit của C++, dù giá trị trước khi lấy `mod p` có thể tạm thời rất lớn. Vì vậy modulo `p` trong bài này chỉ nhằm GIỮ CON SỐ NHỎ để tính nhanh và để so sánh gọn — không phải để "tránh tràn số" như khi viết C++ (nơi quên `mod` đúng lúc có thể gây tràn `int` và cho hash sai). Một đặc thù khác cũng đáng nhớ khi xử lý xâu trong Python: `str` là kiểu BẤT BIẾN (immutable), nên nối chuỗi lặp lại bằng `+=` trong một vòng lặp (`ket_qua += ky_tu`) tạo một chuỗi MỚI mỗi lần, tổng chi phí `O(n²)` cho `n` lần nối — luôn dùng một `list` rồi `''.join(list)` MỘT LẦN ở cuối khi cần dựng một xâu dài từ nhiều mảnh nhỏ.
Ví dụ
Ví dụ 1 — Xây mảng hash tiền tố và so sánh hai đoạn con trong O(1)
  1. 1

    Bài chuẩn chỉ hỏi "hai xâu này có bằng nhau không" một lần — gọi thẳng so sánh chuỗi là đủ. Đề HSG cho một xâu cố định và hàng chục nghìn truy vấn so sánh các đoạn con KHÁC NHAU của cùng xâu đó — số lần so sánh lặp lại buộc em phải tiền xử lý một lần rồi trả lời cực nhanh, thay vì so trực tiếp mỗi lần.

  2. 2
    Với xâu `s = "abcab"`, ánh xạ `a→1, b→2, c→3`, chọn cơ số `b = 31`, modulo `p = 101` (modulo NHỎ, chỉ để tính tay minh họa cơ chế — bài thật luôn dùng modulo lớn cỡ `10^9`, xem callout bên dưới). Với quy ước `h[0] = 0`, công thức truy hồi `h[i] = h[i-1]·b + giá_trị(s[i]) mod p`.
  3. 3
    Hai mảng `h` và `pw` được tính song song trong cùng một vòng lặp `O(n)`: ```python def tien_xu_ly_hash(s, base, mod): """Tinh mang hash tien to h[0..n] va mang luy thua pw[0..n] cua base modulo mod. h[i] = hash cua tien to do dai i (s[0..i-1], 0-indexed trong Python); h[0] = 0. Ky tu duoc anh xa a=1..z=26 de tranh hash('') == hash('aaa...').""" n = len(s) h = [0] * (n + 1) pw = [1] * (n + 1) for i in range(1, n + 1): h[i] = (h[i - 1] * base + (ord(s[i - 1]) - ord("a") + 1)) % mod pw[i] = (pw[i - 1] * base) % mod return h, pw ```
  4. 4

    Với ví dụ trên: h = [0, 1, 33, 16, 93, 57]pw = [1, 31, 52, 97, 78, 95] (chỉ số i chạy từ 0 đến 5, ứng với 0 đến 5 ký tự đầu đã xử lý).

  5. 5
    Hàm `getHash(l, r)` lấy hash một đoạn con bất kỳ (1-indexed) chỉ bằng một phép trừ và một phép nhân, không quét lại xâu: ```python def get_hash(h, pw, mod, l, r): """Hash cua doan con s[l..r] (1-indexed, tinh ca hai dau) trong O(1), dung mang h va pw da tien xu ly: hash(l,r) = h[r] - h[l-1]*b^(r-l+1).""" return (h[r] - h[l - 1] * pw[r - l + 1]) % mod ``` và hàm so sánh hai đoạn dùng lại `getHash` hai lần: ```python def so_sanh_doan_bang_hash(l1, r1, l2, r2, h, pw, mod): """Hai doan con [l1,r1] va [l2,r2] co bang nhau khong -- O(1) sau tien xu ly, thay vi so tung ky tu O(do dai doan).""" if r1 - l1 != r2 - l2: return False return get_hash(h, pw, mod, l1, r1) == get_hash(h, pw, mod, l2, r2) ```
  6. 6
    Ta cần so sánh đoạn `s[1..2] = "ab"` với đoạn `s[4..5] = "ab"`. Áp dụng công thức `hash(l,r) = h[r] - h[l-1]·pw[r-l+1] mod p`: với `(l,r)=(1,2)`, `hash = h[2] - h[0]·pw[2] = 33 - 0 = 33`. Với `(l,r)=(4,5)`, `hash = h[5] - h[3]·pw[2] = 57 - 16·52 = 57 - 832 ≡ 33 (mod 101)`. Hai giá trị bằng nhau (đều bằng 33), và quan sát trực tiếp cũng xác nhận `s[1..2] = s[4..5] = "ab"`. Vậy hai đoạn con bằng nhau, kết luận đạt được chỉ bằng phép toán trên mảng đã tiền xử lý, không quét lại xâu.
Trạng thái mảng ở bước 1: khởi tạo h[0] = 0, chưa xử lý ký tự nào của "abcab"i=0i=1i=2i=3i=4i=50
Bước 1: khởi tạo h[0] = 0, chưa xử lý ký tự nào của "abcab"
Trạng thái mảng ở bước 2: đã xử lý 3 ký tự đầu "abc": h=[0,1,33,16]i=0i=1i=2i=3i=4i=5013316
Bước 2: đã xử lý 3 ký tự đầu "abc": h=[0,1,33,16]
Trạng thái mảng ở bước 3: xử lý xong toàn bộ "abcab": h=[0,1,33,16,93,57]i=0i=1i=2i=3i=4i=50133169357
Bước 3: xử lý xong toàn bộ "abcab": h=[0,1,33,16,93,57]

Ứng dụng thứ hai — chu kỳ nhỏ nhất của xâu bằng hash

So sánh đoạn con không phải ứng dụng duy nhất. Một bài toán quen thuộc khác: tìm chu kỳ nhỏ nhất của xâu `s` — số `k` nhỏ nhất sao cho `s` là chính `k` ký tự đầu lặp lại nhiều lần (ví dụ `"abcabcabc"` có chu kỳ nhỏ nhất là 3). Nhận xét cốt lõi: `s` có chu kỳ `k` (với `n` chia hết cho `k`) khi và chỉ khi đoạn `s[1..n-k]` bằng đoạn `s[k+1..n]` — mà so sánh hai đoạn bằng nhau chính là việc `getHash` vừa làm được trong `O(1)`.
Ví dụ
Ví dụ 2 — Tìm chu kỳ nhỏ nhất của xâu bằng hash
  1. 1
    Bài chuẩn dừng ở nhận biết bằng mắt một chuỗi có "lặp lại" hay không với xâu ngắn. Đề HSG cho xâu dài đến `10^5`–`10^6` ký tự và yêu cầu chỉ RA con số chu kỳ nhỏ nhất chính xác, với nhiều xâu cần kiểm tra — buộc phải có thuật toán, không còn "nhìn là thấy".
  2. 2
    Thử lần lượt mọi ước số `k` của `n` theo thứ tự tăng dần. Với mỗi `k`, kiểm tra `s[1..n-k] = s[k+1..n]` bằng MỘT lần so sánh hash — đúng ngay lập tức `k` đó là chu kỳ nhỏ nhất (vì ta thử theo thứ tự tăng dần). Nếu không ước nào thỏa (trừ chính `n`), xâu không có chu kỳ thực sự nào khác chính nó.
  3. 3
    Chỉ cần một vòng lặp qua các ước của `n`, mỗi bước gọi `getHash` hai lần: ```python def chu_ky_nho_nhat_hash(s, h, pw, mod): """Chu ky nho nhat k (1<=k<=n, n chia het k) sao cho s[i]==s[i+k] voi moi i hop le -- thu tung uoc k cua n theo thu tu tang dan, kiem tra bang MOT lan so sanh hash s[1..n-k] voi s[k+1..n].""" n = len(s) for k in range(1, n + 1): if n % k != 0: continue if k == n: return k if get_hash(h, pw, mod, 1, n - k) == get_hash(h, pw, mod, k + 1, n): return k return n ```
  4. 4
    Với `s = "abcabcabc"` (`n = 9`), các ước của 9 theo thứ tự tăng là 1, 3, 9. Với `k=1`: so `s[1..8]="abcabcab"` với `s[2..9]="bcabcabc"` — khác nhau, loại. Với `k=3`: so `s[1..6]="abcabc"` với `s[4..9]="abcabc"` — BẰNG NHAU. Vậy chu kỳ nhỏ nhất của `"abcabcabc"` là `k=3`, đúng như quan sát trực tiếp (xâu là "abc" lặp 3 lần).
Mở rộng dành cho HSG
Mở rộng dành cho HSG: modulo nhỏ KHÔNG an toàn. Kỹ thuật sau không nằm trong yêu cầu chuẩn của lớp 12, nhưng gần như bắt buộc trong đề HSG QG vì đề luôn có thể chứa bộ test cố ý gây đụng độ (anti-hash test). Với bảng chữ chỉ gồm hai ký tự 'a', 'b' và độ dài xâu là 6, có đúng `2^6 = 64` xâu phân biệt. Nếu chọn modulo nhỏ như `p = 31` (nhỏ hơn 64), theo NGUYÊN LÝ DIRICHLET, duyệt hết 64 xâu đó chắc chắn gặp ít nhất một cặp trùng hash — không phải "may rủi", mà là hệ quả toán học bắt buộc khi số ngăn (64 xâu) nhiều hơn số hộp (31 giá trị hash có thể).
Ví dụ
Ví dụ 3 — Chứng minh đụng độ bằng Dirichlet, và double hashing giải quyết ra sao
  1. 1
    Bài chuẩn không cần lo về đụng độ hash vì hiếm khi kiểm tra nghiêm ngặt. Đề HSG QG thường có bộ test chuyên biệt (test chống hash) được thiết kế để hai xâu KHÁC NHAU cho cùng một giá trị hash đơn — nếu lời giải chỉ dùng một cặp `(b, p)`, bài sẽ sai ở đúng bộ test đó dù thuật toán về nguyên lý là đúng.
  2. 2
    Tìm một cặp đụng độ cụ thể bằng cách duyệt hết `2^6=64` xâu độ dài 6 trên bảng chữ 'a','b' với modulo nhỏ `p=31` (minh họa, không phải giá trị dùng khi thi) — dừng ngay khi gặp hai xâu khác nhau cùng hash: ```python def tim_va_cham_hash_don(mod_nho, base_nho, do_dai): """Minh hoa NGUYEN LY DIRICHLET: voi bang chu 'ab' va do_dai ky tu co 2^do_dai xau phan biet. Neu mod_nho < 2^do_dai, duyet HET moi xau chac chan gap it nhat mot cap trung hash -- day la LY DO CAN double hashing, khong phai vi du ngau nhien.""" bang = {} for to_hop in itertools.product("ab", repeat=do_dai): s = "".join(to_hop) h, _ = tien_xu_ly_hash(s, base_nho, mod_nho) key = h[do_dai] if key in bang and bang[key] != s: return bang[key], s bang[key] = s return None ```
  3. 3
    Chạy hàm trên cho ra cặp `s1 = "aaaaaa"` và `s2 = "aaaaba"` — hai xâu khác nhau (khác ở ký tự thứ 5), nhưng với `base=31, mod=31` cả hai đều có `hash = 1`.
  4. 4
    Dùng HAI cặp `(base, mod)` độc lập, coi một đoạn "có thể bằng nhau" chỉ khi CẢ HAI thành phần hash đều trùng: ```python def hash_kep(s): """Mo rong danh cho HSG: hai cap (base, mod) DOC LAP -- tra ve bo 4 mang de tinh hash kep cua bat ky doan con nao.""" h1, pw1 = tien_xu_ly_hash(s, BASE1, MOD1) h2, pw2 = tien_xu_ly_hash(s, BASE2, MOD2) return h1, pw1, h2, pw2 ``` ```python def get_hash_kep(h1, pw1, h2, pw2, l, r): """Cap (hash1, hash2) cua doan [l,r] -- hai doan chi duoc coi la 'co the bang nhau' khi CA HAI thanh phan trung nhau.""" return (get_hash(h1, pw1, MOD1, l, r), get_hash(h2, pw2, MOD2, l, r)) ```
  5. 5
    Với `base1=131, mod1≈10^9+7` và `base2=137, mod2≈998244353`, cặp hash kép của `"aaaaaa"` là `(876254690, 700861134)`, còn của `"aaaaba"` là `(876254821, 700861271)` — hai cặp này KHÁC NHAU ở cả hai thành phần.
  6. 6
    Xét `s_1="aaaaaa"` và `s_2="aaaaba"`. Theo hash đơn với `base=31, mod=31`: `hash(s_1)=hash(s_2)=1` — đụng độ thật sự (1), không phải giả định. Theo hash kép với hai modulo lớn: `hash_kép(s_1)=(876254690, 700861134) ≠ (876254821, 700861271)=hash_kép(s_2)` (2). Từ (1) và (2) suy ra: modulo nhỏ làm lộ đụng độ mà modulo lớn và double hashing đều tránh được. Vậy double hashing chỉ cần thiết khi đề có khả năng chứa test chống hash (n lớn, đề công khai là dạng thi đấu) — với modulo cỡ `10^9` đơn lẻ, xác suất đụng độ ngẫu nhiên đã cực nhỏ cho dữ liệu KHÔNG cố ý; double hashing chỉ thêm hằng số thời gian gấp đôi để đổi lấy an toàn trước test cố ý.
So sánh thời gian 2000 truy vấn getHash O(1) và 2000 lần tính lại hash từ đầuO(1) n=20000.21 msO(1) n=80000.23 msO(1) n=200000.22 mstính lại n=2000119.5 mstính lại n=8000474.4 mstính lại n=200001223.8 ms
Đo trên máy chạy bài giảng này: 2000 truy vấn getHash O(1) mất khoảng 0.21–0.22 ms bất kể n, còn tính lại hash từ đầu cho mỗi truy vấn tăng tuyến tính theo n, tới 1224 ms ở n=20000.
Sai lầm thường gặp
Sai lầm thường gặp: quên trừ đi phần "thừa" khi tính hash đoạn con. Công thức đúng là `h[r] - h[l-1]·pw[r-l+1]`, KHÔNG phải `h[r] - h[l-1]` — vì `h[r]` và `h[l-1]` được tính với hai độ dài lũy thừa khác nhau của cơ số `b`, phải nhân `h[l-1]` với `pw[r-l+1]` để đưa về cùng "thang đo" trước khi trừ. Bỏ qua bước nhân này cho kết quả sai nhưng KHÔNG báo lỗi — chương trình vẫn chạy và in ra một số, chỉ là số sai.

Luyện tập có hướng dẫn

Luyện tập độc lập

Thử thách tổng hợp

Ghi nhớ

Băm xâu biến một đoạn con thành một con số qua công thức đa thức theo cơ số `b`, modulo `p`. Tiền xử lý một lần hai mảng `h[]` và `pw[]` trong `O(n)` cho phép trả lời so sánh đoạn, tìm chu kỳ, và nhiều bài toán khác trong `O(1)` mỗi truy vấn. Modulo càng nhỏ càng dễ bị Dirichlet ép đụng độ; đề có khả năng chống hash thì cần double hashing.

Băm xâu trả lời tốt câu hỏi "có bằng nhau không", nhưng không tự nó chỉ ra VỊ TRÍ một mẫu xuất hiện trong văn bản dài. Bài tiếp theo học một công cụ chuyên biệt cho đúng việc đó — thuật toán KMP, tìm mọi vị trí xuất hiện trong O(n+m) mà không cần thử lại từ đầu.