Cho một danh bạ và câu hỏi: có bao nhiêu tên bắt đầu bằng ngu? Một câu thì duyệt hết các tên là xong. Nhưng đề thường hỏi câu trên tên, và so tiền tố với từng tên cho mỗi câu là cỡ phép so sánh.
Các tên nguyen, nguyet, ngoc có chung đoạn đầu, nên ta chỉ cần đi đoạn chung ấy một lần. Trie (cây tiền tố, prefix tree) gộp mọi tiền tố giống nhau thành một đường đi: mỗi cạnh là một chữ cái, mỗi nút là một tiền tố. Hỏi một tiền tố chỉ tốn thời gian bằng độ dài của nó.
Cũng cấu trúc ấy, dựng trên các bit của số thay cho chữ cái, giải các bài XOR lớn nhất, XOR dưới ngưỡng và số thứ sau phép XOR.
Cây tiền tố
Gốc của Trie là xâu rỗng. Từ nút ứng với tiền tố , cạnh mang chữ dẫn tới nút ứng với . Cạnh chỉ được tạo khi có xâu thật sự đi qua nó, nên Trie của các xâu an, and, ant, bad, be có an và and dùng chung đường a → n, rồi tách thành hai nhánh d và t.
Một nút có thể vừa là từ hoàn chỉnh vừa là tiền tố của từ khác: an là từ, đồng thời là tiền tố của and và ant. Vì vậy chỉ biết “đường đi tồn tại” thì trả lời được câu hỏi tiền tố, nhưng chưa trả lời được câu hỏi “từ đã được thêm chưa”: nếu chỉ thêm and thì đường a → n vẫn tồn tại dù an chưa từng được thêm. Mỗi nút cần thêm một trường en đánh dấu có từ kết thúc tại đây (đếm số lần nếu cho phép trùng).
Cài đặt bằng mảng. Đánh số nút , gốc là 0. nxt[u][c] là nút con của theo chữ ; bằng 0 nghĩa là chưa có cạnh (gốc không là con của ai nên dùng 0 làm “trống” được). Tạo nút mới là lấy số tiếp theo ++sz.
Mỗi ký tự chèn vào tạo nhiều nhất một nút, nên số nút không vượt , với là tổng độ dài các xâu. Bộ nhớ là số nguyên: cho khoảng 104 MB, cần kiểm với giới hạn bộ nhớ.
int nxt[N][26], sz;
bool en[N];
void insert(string s) {
int u = 0;
for (char ch : s) {
int c = ch - 'a';
if (!nxt[u][c]) nxt[u][c] = ++sz;
u = nxt[u][c];
}
en[u] = 1;
}
Bảng chữ cái lớn hoặc thưa thì có người để mỗi nút một map. Tiết kiệm chỗ, nhưng chậm hơn thấy rõ:
Mảng nhanh gấp khoảng 4 lần. Bảng chữ cái nhỏ thì cứ dùng mảng, chỉ đổi sang map khi bộ nhớ không đủ.
Hỏi nhanh
Đã chèn các từ an, and, ant. Muốn trả lời chính xác search("an"), cần kiểm tra điều gì tại nút an?
Ba từ đều đi qua an, nên “có xâu đi qua” chỉ khẳng định an là tiền tố, không khẳng định nó được thêm như một từ. Dấu en ghi nhận đúng điểm kết thúc. Số con không liên quan: một từ có thể kết thúc ở lá hoặc ở nút có con, và nếu chỉ thêm and, ant thì nút an vẫn có hai con mà an chưa phải từ.
Ví dụKho Từ Động
Đề. Kho từ ban đầu rỗng, lệnh insert s, search s (đã thêm đúng từ chưa) và prefix s (có từ nào bắt đầu bằng ). Tổng độ dài mọi chuỗi không vượt .
Lời giải. Trie chữ thường: nút 0 là gốc, nxt[u][c] là con của theo chữ (0 nghĩa là chưa có), en[u] bật khi có một từ kết thúc đúng tại . Mỗi lệnh đi theo các ký tự của .
Bước 1: thêm một từ. Đi từ gốc theo từng ký tự, cạnh nào còn thiếu thì tạo nút mới, cuối cùng bật en ở nút cuối.
int u = 0;
for (char ch : s) {
int c = ch - 'a';
if (!nxt[u][c]) nxt[u][c] = ++sz;
u = nxt[u][c];
}
en[u] = 1;
Bước 2: đi theo một xâu. walk trả về nút ứng với , hoặc nếu đường đứt giữa chừng (có ký tự không có cạnh).
int walk(string s) {
int u = 0;
for (char ch : s) {
u = nxt[u][ch - 'a'];
if (!u) return -1;
}
return u;
}
Bước 3: hai loại câu hỏi. prefix s đúng khi đi được hết : có ít nhất một từ đi qua nút đó. search s cần thêm en bật, vì đi được hết chỉ nói là tiền tố của một từ nào đó.
int u = walk(s);
bool ok = u != -1 && (op == "prefix" || en[u]);
Với ví dụ, sau insert can, search ca là NO vì ca chưa phải từ, còn prefix ca là YES.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e6 + 5;
int nxt[N][26], sz;
bool en[N];
int walk(string s) {
int u = 0;
for (char ch : s) {
u = nxt[u][ch - 'a'];
if (!u) return -1;
}
return u;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
ll q;
cin >> q;
while (q--) {
string op, s;
cin >> op >> s;
if (op == "insert") {
int u = 0;
for (char ch : s) {
int c = ch - 'a';
if (!nxt[u][c]) nxt[u][c] = ++sz;
u = nxt[u][c];
}
en[u] = 1;
} else {
int u = walk(s);
bool ok = u != -1 && (op == "prefix" || en[u]);
cout << (ok ? "YES" : "NO") << '\n';
}
}
}
10
insert can
search can
search ca
prefix ca
insert cap
prefix cat
insert ca
search ca
prefix c
search cap
YES
NO
YES
NO
YES
YES
YES
Mỗi lệnh , tổng ; số nút không vượt tổng độ dài các từ đã thêm.
Đếm qua nút
Trường thứ hai hay dùng là cnt[u]: số xâu đã chèn có đường đi qua nút . Khi chèn, tăng cnt ở mỗi nút vừa đi tới. Khi hỏi tiền tố : đường đứt thì đáp án 0, ngược lại cnt ở nút cuối là số xâu bắt đầu bằng . Chèn sun, sum, s, moon, sun cho nút s có , su có 3, sun có 2; từ s dừng ở nút s nên làm tăng cnt của s mà không đụng tới su.
Bất biến sau mỗi lần chèn: cnt[u] bằng đúng số xâu đã xử lý có là một tiền tố, vì chèn một xâu tăng đúng các nút trên đường của nó. Xóa một xâu là giảm cnt trên đường, với điều kiện xâu đó thật sự có mặt.
Đếm cặp. Nút ở độ sâu có cnt[u] xâu đi qua thì cả cặp trong số đó chung ít nhất ký tự đầu. Một cặp có tiền tố chung dài đi qua cùng nhau đúng một nút ở mỗi độ sâu , nên tổng trên mọi nút khác gốc bằng tổng độ dài tiền tố chung của mọi cặp. Cách tính tại chỗ: khi chèn một xâu mới, cộng cnt của mỗi nút nó đi qua trước khi tăng, tức là cộng số xâu cũ còn chung tiền tố tới độ sâu đó.
Hỏi nhanh
Sau khi chèn sun, sum, s, giá trị cnt ở nút su là bao nhiêu?
cnt đếm xâu đi qua nút. sun và sum đi qua su; s kết thúc ở nút s. Việc su chưa được thêm như một từ chỉ ảnh hưởng en, không ảnh hưởng cnt. Con số 3 là cnt của nút s, không phải su.
Ví dụTổng Độ Dài Tiền Tố Chung
Đề. Cho xâu (tổng độ dài không quá ). In tổng độ dài tiền tố chung dài nhất của mọi cặp vị trí .
Lời giải. Đúng phép đếm cặp ở trên: chèn lần lượt từng xâu, ở mỗi ký tự cộng cnt của nút vừa tới (số xâu cũ đã đi qua nút đó) rồi mới tăng nó. Mỗi cặp với được cộng đúng một lần ở mỗi độ sâu chung, lúc chèn xâu . Hai xâu trùng nội dung vẫn là hai vị trí, và cách đếm tự xử lý đúng. Đáp án tới cỡ , phải ll. Với ví dụ, cặp (sun, sun) góp 3, (sun, sum) góp 2 hai lần, mỗi cặp có s góp 1: tổng 10.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 3e5 + 5;
int nxt[N][26], sz;
ll cnt[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
ll n, ans = 0;
cin >> n;
while (n--) {
string s;
cin >> s;
int u = 0;
for (char ch : s) {
int c = ch - 'a';
if (!nxt[u][c]) nxt[u][c] = ++sz;
u = nxt[u][c];
ans += cnt[u];
cnt[u]++;
}
}
cout << ans << '\n';
}
5
sun
sum
s
moon
sun
10
XOR lớn nhất
Trie không bắt buộc là cây trên chữ cái. Viết mỗi số không âm bằng đúng bit (thêm số 0 ở đầu cho đủ), từ bit cao xuống bit thấp, rồi chèn như một xâu trên bảng chữ cái . Đây là Trie nhị phân: mỗi nút có hai con, độ sâu đúng , và mỗi số là một đường từ gốc tới lá.
Muốn tìm trên các số trong Trie, đi từ gốc và quyết định từng bit từ cao xuống: ở bit , nếu có nhánh mang bit ngược với bit của thì đi nhánh đó, kết quả nhận bit 1; không có thì đành đi nhánh cùng bit. Tham lam này đúng vì bit lớn hơn tổng mọi bit thấp, : một kết quả có bit bằng 1 luôn lớn hơn mọi kết quả có bit bằng 0 và cùng các bit cao hơn (bài 14). Mỗi truy vấn đúng bước, với số dưới là 31 bước.
Hỏi nhanh
Khi tìm XOR lớn nhất với , tại bit 8 có đúng một số còn trong nhánh ngược bit với , và rất nhiều số ở nhánh cùng bit. Nên đi nhánh nào?
Bit 1 ở vị trí 8 đóng góp , còn mọi bit thấp hơn cộng lại tối đa . Vậy kết quả có bit 8 bằng 1 lớn hơn mọi kết quả có bit 8 bằng 0, bất kể bên kia có bao nhiêu ứng viên. Số ứng viên, số nút hay thứ tự xét bit thấp đều không đổi được thứ tự của số nhị phân.
Ví dụCặp XOR Lớn Nhất
Đề. Cho số . In trên mọi cặp .
Lời giải. Duyệt mọi cặp là . Chèn các số vào Trie nhị phân theo 31 bit, từ bit cao xuống bit thấp; với mỗi , đi tham lam như trên để tìm , rồi lấy lớn nhất. Có thể chèn tất cả trước rồi mới hỏi, vì cặp chỉ cho 0, không làm sai cực đại. Với ví dụ, là lớn nhất.
Bước 1: chèn một số. Đi từ bit 30 xuống bit 0, mỗi bit là một cạnh 0 hoặc 1.
void insert(ll x) {
int u = 0;
for (ll b = 30; b >= 0; b--) {
int c = (x >> b) & 1;
if (!nxt[u][c]) nxt[u][c] = ++sz;
u = nxt[u][c];
}
}
Bước 2: XOR lớn nhất với . Ở mỗi bit, muốn bit tương ứng của kết quả bằng 1 thì phải đi sang nhánh ngược bit của ; nhánh đó có thì đi và bật bit của kết quả, không có thì đành đi nhánh cùng bit. Tham lam đúng vì bit cao quyết định trước: lớn hơn tổng mọi bit thấp hơn.
ll query(ll x) {
int u = 0;
ll res = 0;
for (ll b = 30; b >= 0; b--) {
int c = (x >> b) & 1;
if (nxt[u][c ^ 1]) {
res |= 1LL << b;
u = nxt[u][c ^ 1];
} else u = nxt[u][c];
}
return res;
}
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 * 31 + 5;
int nxt[N][2], sz;
ll n, a[200005];
void insert(ll x) {
int u = 0;
for (ll b = 30; b >= 0; b--) {
int c = (x >> b) & 1;
if (!nxt[u][c]) nxt[u][c] = ++sz;
u = nxt[u][c];
}
}
ll query(ll x) {
int u = 0;
ll res = 0;
for (ll b = 30; b >= 0; b--) {
int c = (x >> b) & 1;
if (nxt[u][c ^ 1]) {
res |= 1LL << b;
u = nxt[u][c ^ 1];
} else u = nxt[u][c];
}
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 0; i < n; i++) {
cin >> a[i];
insert(a[i]);
}
ll ans = 0;
for (ll i = 0; i < n; i++) ans = max(ans, query(a[i]));
cout << ans << '\n';
}
5
7 2 12 25 10
30
thời gian; số nút tối đa , mỗi nút hai số int, khoảng 50 MB.
Cùng ý tưởng giải bài “XOR lớn nhất của một đoạn con”: XOR đoạn là với là XOR tiền tố (bài 02), nên chèn dần các vào Trie, mỗi hỏi XOR lớn nhất với các đã có rồi mới chèn.
Xóa và đếm
Để Trie nhị phân chứa một đa tập thay đổi được, giữ cnt[u] là số phần tử hiện có đi qua nút . Thêm là tăng cnt trên đường, xóa một bản sao là giảm. Nút có thể đã tồn tại nhưng cnt bằng 0 khi mọi phần tử của nó đã bị xóa; lúc đi tham lam phải hỏi “nhánh này còn phần tử không” bằng cnt > 0, không phải “cạnh này đã từng được tạo chưa”. Quên điều đó là lỗi khó thấy nhất: chương trình chọn một giá trị đã xóa và in ra một XOR không tồn tại.
Đếm có . Đi theo bit từ cao xuống, giữ nút là tập các mà trùng với ở mọi bit cao hơn đã xét. Ở bit , gọi là bit của . Nếu bit của bằng 1 thì mọi trong nhánh cho có bit bằng 0, nhỏ hơn chắc chắn: cộng cả cnt của nhánh , rồi đi tiếp nhánh để giữ bằng . Nếu bit của bằng 0 thì phải đi nhánh (nhánh kia làm vượt ). Hết bit thì , không tính vì bất đẳng thức chặt. Đây đúng là cách đếm theo vị trí rẽ nhánh của quy hoạch động chữ số (bài 13), chạy trên cây.
Ví dụĐếm XOR Dưới Ngưỡng
Đề. Đa tập ban đầu rỗng, sự kiện: 1 p thêm , 2 p xóa một bản sao của , 3 p l in số bản sao có . Mọi giá trị không quá .
Lời giải. Trie nhị phân 27 bit, mỗi nút giữ cnt như đã nói ở trên. Đếm số có bằng cách đi theo các bit của từ cao xuống. Ở bit , mọi trong nút hiện tại đã có trùng với ở các bit cao hơn.
- Bit của bằng 1: các có bit cùng bit của cho bằng 0 ở bit này, nhỏ hơn ngay, nên cộng cả
cntcủa nhánh đó; rồi đi sang nhánh ngược bit để tiếp tục trùng với . - Bit của bằng 0: chỉ nhánh cùng bit giữ được trùng với ; nhánh ngược bit cho bit 1, lớn hơn , bỏ.
Đi hết 27 bit mà vẫn trùng thì , không tính. Gặp nhánh chưa tồn tại thì dừng sớm. Với ví dụ, đa tập và , : hai lần, thì không, đáp án 2.
Bước 1: thêm, xóa. add(x, d) cộng ( hoặc ) vào cnt trên đường của , tạo nút khi cần.
void add(ll x, int d) {
int u = 0;
for (ll b = 26; b >= 0; b--) {
int c = (x >> b) & 1;
if (!nxt[u][c]) nxt[u][c] = ++sz;
u = nxt[u][c];
cnt[u] += d;
}
}
Bước 2: đếm theo các bit của .
ll calc(ll p, ll l) {
int u = 0;
ll res = 0;
for (ll b = 26; b >= 0; b--) {
int c = (p >> b) & 1;
if ((l >> b) & 1) {
if (nxt[u][c]) res += cnt[nxt[u][c]];
u = nxt[u][c ^ 1];
} else u = nxt[u][c];
if (!u) break;
}
return res;
}
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 * 27 + 5;
int nxt[N][2], cnt[N], sz;
void add(ll x, int d) {
int u = 0;
for (ll b = 26; b >= 0; b--) {
int c = (x >> b) & 1;
if (!nxt[u][c]) nxt[u][c] = ++sz;
u = nxt[u][c];
cnt[u] += d;
}
}
ll calc(ll p, ll l) {
int u = 0;
ll res = 0;
for (ll b = 26; b >= 0; b--) {
int c = (p >> b) & 1;
if ((l >> b) & 1) {
if (nxt[u][c]) res += cnt[nxt[u][c]];
u = nxt[u][c ^ 1];
} else u = nxt[u][c];
if (!u) break;
}
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
ll q;
cin >> q;
while (q--) {
ll t, p;
cin >> t >> p;
if (t == 1) add(p, 1);
else if (t == 2) add(p, -1);
else {
ll l;
cin >> l;
cout << calc(p, l) << '\n';
}
}
}
8
1 5
1 2
1 5
3 4 4
2 5
3 4 4
3 7 3
2 5
2
1
1
Mỗi sự kiện 27 bước.
Cắt xâu bằng Trie
Một bài quen: đếm số cách cắt xâu thành các từ của một từ điển. Gọi là số cách ghép đúng ký tự đầu của , . Từ vị trí , mọi từ khớp với bắt đầu tại cho một bước chuyển . Thử từng từ của từ điển ở mỗi vị trí là phép so xâu, quá chậm khi .
Trie liệt kê đúng những từ khớp tại mà không thử từ nào thừa: đi xuống Trie theo , mỗi lần tới một nút có en bật là gặp một từ kết thúc ở vị trí ấy, và dừng ngay khi đường đứt. Mỗi vị trí đi xuống tối đa độ dài từ dài nhất bước, tổng , không phụ thuộc số từ. Cùng khuôn ấy dùng cho bài cắt xâu ít đoạn nhất, hay giải mã một xâu đã ghép từ các từ (truy vết dp để in các từ).
Ví dụGhép Chuỗi Từ Từ Điển
Đề. Cho xâu () và từ đôi một khác nhau (tổng độ dài ). Đếm số cách viết thành phép nối các từ (mỗi từ dùng bao nhiêu lần cũng được), theo modulo .
Lời giải. là số cách viết ký tự đầu của thành phép nối các từ, . Từ vị trí , đi xuống Trie theo ; mỗi nút có en bật tại ký tự là một từ khớp đúng đoạn , nên cộng vào . Trie cho mọi từ bắt đầu tại trong một lượt đi, thay vì so từng từ. Với ví dụ aaaa và từ điển a, aa, aaa: có 7 cách, chính là số cách viết 4 thành tổng có thứ tự các số 1, 2, 3.
Bước 1: dựng Trie các từ.
while (k--) {
string w;
cin >> w;
int u = 0;
for (char ch : w) {
int c = ch - 'a';
if (!nxt[u][c]) nxt[u][c] = ++sz;
u = nxt[u][c];
}
en[u] = 1;
}
Bước 2: quy hoạch động đẩy tới. Chỉ đẩy từ các khác 0; đường đứt (không còn từ nào có tiền tố này) thì dừng sớm.
dp[0] = 1;
for (ll i = 0; i < n; i++) {
if (!dp[i]) continue;
int u = 0;
for (ll j = i; j < n; j++) {
u = nxt[u][s[j] - 'a'];
if (!u) break;
if (en[u]) dp[j + 1] = (dp[j + 1] + dp[i]) % MOD;
}
}
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e6 + 5, MOD = 1e9 + 7;
int nxt[N][26], sz;
bool en[N];
ll dp[5005];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
string s;
ll k;
cin >> s >> k;
while (k--) {
string w;
cin >> w;
int u = 0;
for (char ch : w) {
int c = ch - 'a';
if (!nxt[u][c]) nxt[u][c] = ++sz;
u = nxt[u][c];
}
en[u] = 1;
}
ll n = s.size();
dp[0] = 1;
for (ll i = 0; i < n; i++) {
if (!dp[i]) continue;
int u = 0;
for (ll j = i; j < n; j++) {
u = nxt[u][s[j] - 'a'];
if (!u) break;
if (en[u]) dp[j + 1] = (dp[j + 1] + dp[i]) % MOD;
}
}
cout << dp[n] << '\n';
}
aaaa
3
a
aa
aaa
7
Mỗi vị trí đi xuống tối đa bước, tổng trong trường hợp xấu; dựng Trie .
Khi đi thi
- Thấy nhiều câu hỏi về tiền tố trên một tập xâu, hay XOR lớn nhất, đếm XOR theo ngưỡng trên một tập số: nghĩ tới Trie, xâu thì theo chữ cái, số thì theo bit từ cao xuống.
- Phân biệt rõ
cnt(số xâu đi qua) vàen(số xâu kết thúc); cập nhậtcntở nút vừa đi tới, không ở gốc. Trie có xóa thì đi nhánh nào cũng kiểmcnt > 0. - Ước lượng số nút bằng tổng độ dài (hoặc với Trie bit) và nhân cỡ bảng chữ cái để biết bộ nhớ; với nhiều test, xóa lại đúng các nút đã dùng.
Tóm tắt
- Gốc là xâu rỗng; cạnh chữ nối tiền tố tới ; số nút không quá .
enđánh dấu từ hoàn chỉnh;cntđếm số xâu đi qua nút, cho ngay số xâu mang một tiền tố.- Tổng trên mọi nút là tổng độ dài tiền tố chung của mọi cặp; cộng
cnttrước khi tăng lúc chèn. - Trie nhị phân: XOR lớn nhất bằng tham lam từ bit cao; đa tập có xóa giữ
cnt; đếm bằng rẽ nhánh theo bit của . - Trong quy hoạch động trên xâu, Trie liệt kê mọi từ khớp tại một vị trí trong .
Bài tập
Đọc thêm
- TrieVNOI Wiki
- 字典树 (Trie)OI Wiki
- TriesPrinceton Algorithms
- Aho-Corasickcp-algorithms