Tác giả: Hoàng Xuân Nhật & Vương Hoàng Long
Chia căn là tên gọi chung của một nhóm các thuật toán thường liên quan đến việc chia các đối tượng thành phần.
Sau đây ta sẽ xét một dạng đơn giản nhất: chia mảng ra làm đoạn, thường dùng để giải quyết các bài toán truy vấn.
Cho một mảng gồm phần tử là các số nguyên không âm. Bạn cần trả lời truy vấn, mỗi truy vấn có dạng yêu cầu tìm đếm số phần tử của A nằm trong đoạn có giá trị bằng . Giới hạn: .
Giả sử ta luôn có và , bài toán trên có thể giải đơn giản bằng cách tạo một mảng số phần tử của mảng có giá trị bằng .
Ta áp dụng ý tưởng này để giải bài toán tổng quát, bằng cách tạo ra mảng cnt, mỗi mảng quản lý một đoạn phần tử liên tiếp của . Để hiểu rõ hơn, ta có thể xem ví dụ sau.
Ta có mảng A gốm 13 phần tử, chỉ số được đánh bắt đầu từ 0. Ta sẽ chia mảng thành các đoạn có độ dài 4, đoạn cuối cùng sẽ chỉ chứa 1 phần tử. Nội dung mảng và các mảng đã được tính sẵn như trong hình sau:
Với cấu trúc trên, ta có thể dễ dàng trả lời các truy vấn. Ví dụ, xét truy vấn .
Có thể thấy, đoạn truy vấn sẽ luôn được chia thành các đoạn chứa đủ phần tử (trong trường hợp này là đoạn và ), và có thể thêm một số đoạn không đầy đủ ở hai đầu (ở đây là đoạn ).
Với những đoạn đầy đủ, ta cộng của chúng vào kết quả. Với những đoạn không đầy đủ, ta xét từng phần tử. Phần tử nào bằng 0, ta sẽ tăng biến đếm kết quả lên 1. Với truy vấn , ta có kết quả là .
Cấu trúc trên vẫn có thể giải bài toán này khi có thêm truy vấn chỉnh sửa một phần tử của , bạn chỉ cần thay đổi giá trị của một đoạn duy nhất chứa phần tử cần cập nhật.
Đầu tiên, ta phải trả lời được câu hỏi: tại sao lại chia thành đoạn, mà không phải ?
Gọi số đoạn ta chia ra là . Vậy mỗi đoạn sẽ có độ dài (ta tạm bỏ qua đoạn cuối).
Khi truy vấn, ta phải xét 2 thứ: một là những đoạn đầy đủ, nằm trong đoạn truy vấn. Hai là đoạn dư ra ở hai đầu của truy vấn.
Với những đoạn đầy đủ, trong trường hợp tệ nhất chúng ta phải xét cả đoạn. Mỗi đoạn ta cộng vào kết quả trong , vậy tổng cộng mất .
Với đoạn dư ra ở hai đầu, ta xét riêng từng phần tử mất . Các đoạn đều có độ dài , nên ta mất cho phần này.
Mỗi truy vấn ta mất thời gian là . Ta cần tìm giá trị sao cho đạt giá trị nhỏ nhất. Áp dụng bất đẳng thức AM-GM, giá trị này là nhỏ nhất khi . Thời gian để thực hiện truy vấn sẽ là .
Ta cần phải lưu những cấu trúc sau:
Khi giải bài toán, ta thường chia thành các hàm tiền xử lý để dựng ra cấu trúc dữ liệu, và các hàm trả lời các truy vấn.
const int BLOCK_SIZE = 320;
const int N = 1e5 + 2;
int n;
int cnt[N / BLOCK_SIZE + 2][N];
int a[N];
void preprocess()
{
for (int i = 0; i < n; ++i)
++cnt[i / BLOCK_SIZE][a[i]];
}
Sau khi đã tiền xử lý, hàm trả lời truy vấn có thể cài đặt như sau. Lưu ý, ta phải xét riêng trường hợp cả hai đầu của truy vấn nằm trong cùng một đoạn. Trong code bên dưới, tác giả dùng hàm count của thư viện C++ STL.
int query(int l, int r, int k)
{
int blockL = (l + BLOCK_SIZE - 1) / BLOCK_SIZE;
int blockR = r / BLOCK_SIZE;
if (blockL >= blockR)
return count(a + l, a + r + 1, k);
int sum = 0;
for (int i = blockL; i < blockR; ++i)
sum += cnt[i][k];
for (int i = l, lim = blockL * BLOCK_SIZE; i < lim; ++i)
if (a[i] == k) ++sum;
for (int i = blockR * BLOCK_SIZE; i <= r; ++i)
if (a[i] == k) ++sum;
return sum;
}
Thao tác cập nhật một phần tử có thể thêm vào như sau (với là vị trí cần cập nhật, và là giá trị mới):
void update(int u, int v)
{
int block = u / BLOCK_SIZE;
--cnt[block][a[u]];
a[u] = v;
++cnt[block][a[u]];
}
Tiếp nối bài toán đầu tiên, chúng ta hãy cùng đi sâu hơn vào các bài toán chia mảng ra làm đoạn nhưng có truy vấn cập nhật.
Lưu ý: Bài tập có cách giải tối ưu nhất sử dụng Segment Tree, tuy nhiên vì mục đích của bài viết này nên bài tập sẽ được giải bằng chia căn.
Các bạn có thể nộp bài ở đây
Cho một mảng gồm phần tử là các số nguyên. Bạn cần thực hiện truy vấn có dạng là với các phần tử trong đoạn từ đến , nếu , gán . Bạn cần in ra mảng sau khi thực hiện truy vấn. Giới hạn
Ghi chú: là viết tắt cho old value và new value.
Với giả sử trên, ta sẽ giải bài toán với đpt . Ta sẽ tạo mảng với ý nghĩa là các số ban đầu là thì hiện tại đã được đổi giá trị sang . Ban đầu với . Với mỗi truy vấn , ta sẽ làm như sau:
for (int i = 1; i <= 100; ++i) {
if (lazy[i] == oval) lazy[i] = nval;
}
Với thao tác cập nhật mảng lazy này, về mặt ý nghĩa, tất cả các số hiện đang có giá trị là sẽ được gán lại thành .
Sau khi thực hiện tất cả các truy vấn, chúng ta có thể lấy giá trị của các số trong mảng như sau:
for (int i = 1; i <= n; ++i) {
a[i] = lazy[a[i]];
}
Vậy là chúng ta đã giải xong bài toán với độ phức tạp .
Ta sẽ áp dụng ý tưởng trên vào để giải bài toán gốc. Ta cũng chia mảng thành đoạn. Xét một truy vấn ta có:
Vậy truy vấn của chúng ta sẽ được chia làm 3 phần (có thể rỗng) như sau:
Ta sẽ cập nhật lần lượt cho từng block đơn lẻ. Gọi block hiện tại là , ta sẽ làm tương tự như khi giải bài toán :
void blockUpdate(int id, int oval, int nval) {
for (int i = 1; i <= LIM; ++i) {
if (lazy[id][i] == oval) {
lazy[id][i] = nval;
}
}
}
Vậy là chúng ta đã cập nhật xong cho tất cả các block thuộc phần đầy đủ các block. Chú ý, việc cập nhật này chúng ta chỉ đánh dấu là các phần tử đang có giá trị là sẽ được thay đổi thành . Giá trị của các phần tử trong đoạn này sau cập nhật không có sự thay đổi nào (ý tưởng giống như Lazy Propagation.
Gọi block của phần dư bên trái là .
Vì phần dư bên trái không bao phủ trọn vẹn 1 block, nên chúng ta sẽ không thể dùng mảng để cập nhật được như ở trên. Thay vào đó chúng ta sẽ phải duyệt từng phần tử trong phần này và cập nhật (xét mỗi phần tử, nếu giá trị của nó là thì gán giá trị mới là ):
void manualUpdate(int L, int R, int oval, int nval) { // L R là đầu trái và đầu phải của phần dư bên trái
for (int i = L; i <= R; ++i) {
if (a[i] == oval) {
a[i] = nval;
}
}
}
Tuy nhiên, các phần tử trong phần dư bên trái này có thể đang chịu ảnh hưởng từ mảng của các truy vấn trước đó, nên chúng ta cần thực sự cập nhật các phần tử này bằng mảng , sau đó mới thực hiện (giống như bước Propagate trong Lazy Propagation).
void doLazy(int id) { // L R là đầu trái và đầu phải của phần dư bên trái
int L = id * BLOCK_SIZE;
int R = min(n - 1, (id + 1) * BLOCK_SIZE - 1);
for (int i = L; i <= R; ++i) {
a[i] = lazy[id][a[i]]; // thay đổi giá trị các phần tử bằng mảng lazy
}
for (int i = 1; i <= 100; ++i) {
lazy[id][i] = i; // đã cập nhật xong, reset lại mảng lazy về ban đầu
}
}
Vậy tổng kết lại, ta sẽ có hàm cập nhật cho phần dư bên trái (và cả *phần dư bên phải) như sau:
void manualUpdate(int L, int R, int oval, int nval) { // L R là đầu trái và đầu phải của phần dư bên trái
doLazy(R / BLOCK_SIZE); // R / BLOCK_SIZE chính là block của của phần này. L / BLOCK_SIZE = R / BLOCK_SIZE
for (int i = L; i <= R; ++i) {
if (a[i] == oval) {
a[i] = nval;
}
}
}
/* Chúng ta sẽ gọi hàm như dưới đây để cập nhật cho phần dư bên trái */
manualUpdate(l, blockL * BLOCK_SIZE - 1, oval, nval);
/* Chúng ta sẽ gọi hàm như dưới đây để cập nhật cho phần dư bên phải */
manualUpdate(blockR * BLOCK_SIZE, r, oval, nval);
Ghi chú: Vì hằng số của lời giải này tương đối lớn nên tác giả sẽ giữ hằng số trong độ phức tạp khi cần thiết
Ta sẽ cùng xem xét độ phức tạp của lời giải này:
Dễ thấy hàm có độ phức tạp là . Hàm này mỗi truy vấn có thể được gọi không quá lần, và có truy vấn nên tổng độ phức tạp của các lần gọi hàm này là . (1)
Hàm có độ phức tạp là do các phần dư có độ lớn . Cộng với phần for (int i = L; i <= R; ++i)
có độ phức tạp , hàm có độ phức tạp là .
Dễ thấy hàm sẽ được gọi đúng lần trong mỗi truy vấn. Vậy tổng độ phức tạp của việc gọi hàm này là . (2)
Vậy độ phức tạp của lời giải chia căn này sẽ là (1) + (2) = .
Các bạn có thể xem code mẫu ở đây
Các bạn có thể thử sức tại đây.
Chia căn còn rất nhiều dạng khác. Các bạn có thể đọc tiếp về kĩ thuật này tại Phần 2.