Nguồn: wcipeg, cp-algorithms, Tất tần tật về Cây Phân Đoạn (Segment Tree) - VNOI
Tác giả:
Reviewer:
LƯU Ý: Mọi số thứ tự trong bài viết đều được đánh số bắt đầu từ .
Cây phân đoạn (Segment Tree) là một cấu trúc dữ liệu rất linh hoạt được sử dụng nhiều trong các kỳ thi, đặc biệt là trong những bài toán xử lý trên dãy số.
Ở bài viết này, ta sẽ chỉ tìm hiểu về những kiến thức cơ bản của Segment Tree và một số bài tập thường gặp trong các kì thi.
Còn nếu bạn muốn tìm hiểu sâu hơn về Segment Tree thì bạn có thể tham khảo bài viết: Tất tần tật về Cây Phân Đoạn (Segment Tree) - VNOI.
Một trong những ứng dụng phổ biến nhất của Segment Tree là giải quyết bài toán . Trong bài toán này, ta được cho một mảng và truy vấn; mỗi truy vấn gồm cặp số và , yêu cầu tìm phần tử có giá trị nhỏ nhất trong đoạn từ đến của mảng .
Có nhiều giải pháp khác nhau để giải quyết bài toán này nhưng Segment Tree thường là lựa chọn thích hợp nhất, đặc biệt là khi có thêm hoạt động sửa đổi được xen kẽ với các truy vấn.
Giải pháp chia để trị
Ta có giải pháp chia để trị sau:
Nếu dãy đang xét chứa một phần tử, thì bản thân phần tử đó là giá trị nhỏ nhất trong dãy đó.
Nếu không, ta chia dãy đó thành hai dãy con liên tiếp nhỏ hơn, mỗi dãy con gần bằng một nửa kích thước của dãy ban đầu, và tìm giá trị nhỏ nhất tương ứng của chúng. Giá trị nhỏ nhất của dãy ban đầu chính là giá trị nhỏ hơn giữa hai giá trị nhỏ nhất của các dãy con.
Nhận thấy rằng, khi ta sửa đổi giá trị của một phần tử bất kỳ trong mảng thì số lượng đoạn cần tính lại không nhiều. Vậy nên ta sẽ lưu lại kết quả và chỉ tính lại khi thực sự cần thiết.
Tổng quát
Gọi là giá trị của phần tử thứ trong mảng, việc tìm giá trị nhỏ nhất có thể được viết dưới dạng hàm đệ quy như sau:
Do đó, khi ta sửa đổi giá trị của phần tử thứ trong mảng thì ta chỉ cần tính lại kết quả của các hàm với .
Giả sử rằng ta sử dụng hàm được định nghĩa ở trên để xác định , với là số lượng phần tử của mảng. Khi lớn, hàm gọi đệ quy này sẽ có hai "con", một trong số đó là lệnh gọi đệ quy và lệnh còn lại là . Mỗi hàm này sau đó sẽ lại có thêm hai "con" của riêng nó, và cứ tiếp tục như vậy cho đến khi đạt được trường hợp cơ sở (khi ).
Nếu ta biểu diễn các hàm gọi đệ quy này bằng cấu trúc cây, thì hàm sẽ là gốc, nó sẽ có hai con, mỗi con sẽ có thêm hai con nữa, v.v...; các trường hợp cơ sở sẽ là lá của cây. Khi đó, cấu trúc cây gọi đệ quy của hàm chính là cấu trúc của cây phân đoạn. Và việc sửa đổi giá trị phần tử trong mảng cũng chính là bản chất của thao tác cập nhật trên cây phân đoạn (sẽ được mô tả rõ hơn ở phần sau).
Bây giờ ta đã sẵn sàng để xác định cấu trúc của cây phân đoạn:
Có thao tác cơ bản trên cây phân đoạn:
Để có thể có thể lấy giá trị và sửa đổi dãy số, trước tiên ta cần phải xây dựng một cây phân đoạn hợp lệ.
Trước khi xây dựng cây phân đoạn, ta cần quyết định:
Lưu ý rằng một đỉnh là "đỉnh lá", nếu đoạn tương ứng của nó chỉ bao gồm một giá trị trong mảng ban đầu. Nó là đỉnh thấp nhất của cây phân đoạn. Giá trị của nó sẽ bằng phần tử tương ứng.
Có cách để xây dựng một cây phân đoạn:
Cách 1: Xây dựng "từ dưới lên trên"
Cách 2: Xây dựng "từ trên xuống dưới"
Bây giờ ta muốn sửa đổi một phần tử cụ thể trong mảng, giả sử ta muốn thực hiện việc gán . Và phải cập nhật lại cây phân đoạn, sao cho nó tương ứng với mảng mới đã sửa đổi.
Để làm như vậy, trước tiên ta cần sửa đổi nút lá tương ứng. Các nút lá khác không bị ảnh hưởng, vì mỗi nút lá chỉ được liên kết với một phần tử trong mảng. Nút cha của nút đã sửa đổi cũng bị ảnh hưởng, vì đoạn nó quản lý cũng chứa phần tử đã sửa đổi, và các nút tổ tiên của nó cũng vậy, v.v... cho đến nút gốc.
Nói cách khác, tất cả các nút nằm trên đường đi đơn từ gốc đến nút lá tương ứng đều bị ảnh hưởng. Ngoài ra, không còn nút nào khác bị ảnh hưởng. Do đó, với một dãy số gồm phần tử thì chiều cao của cây phân đoạn tương ứng sẽ là nên chỉ có nút cần được cập nhật.
Thao tác cập nhật cây phân đoạn có thể được thực hiện bằng cách sử dụng một hàm đệ quy:
Tương tự như thao tác xây dựng cây phân đoạn, cách cập nhật cây phân đoạn "từ dưới lên" cũng có thể thực hiện được.
Bây giờ, ta cần phải trả lời các truy vấn lấy giá trị. Ví dụ như: cho hai số nguyên và , hãy xác định phần tử có giá trị nhỏ nhất trong đoạn của mảng với khoảng thời gian là .
Do thao tác lấy giá trị này phức tạp hơn thao tác cập nhật cây phân đoạn nên ta sẽ lấy một ví dụ minh họa để dễ hình dung:
Dễ thấy rằng với . Do đó, ta phải xác định tập hợp ít nút nhất sao cho tổng tất cả các phạm vi mà các nút đó quản lí đúng bằng đoạn cần truy vấn. Sự kết hợp của tập hợp các nút đó chính xác là đáp án mà ta tìm kiếm.
Để làm như vậy, ta sẽ bắt đầu từ gốc và đệ quy qua các nút mà phạm vi quản lí của nó có ít nhất một phần tử chung với đoạn cần truy vấn.
Trong ví dụ trên với , xét hai nút con của gốc, ta nhận thấy rằng cả nút con bên trái và bên phải đều quản lí một số các phần tử con thuộc đoạn cần truy vấn (đoạn ). Do đó, ta sẽ thực hiện lệnh gọi đệ quy trên cả hai nút con của nút gốc. Khi đó, nút con bên trái của gốc đóng vai trò như một trường hợp cơ sở, vì phạm vi quản lí của nó được chứa hoàn toàn bên trong đoạn truy vấn; vậy nên nó được chọn (đánh dấu bằng màu xanh dương).
Còn với nút con bên phải của gốc (nút chứa ); ta nhận thấy rằng nút con bên trái của nút , tức là có quản lí một số các phần tử con thuộc đoạn cần truy vấn, nhưng nút con bên phải là thì không. Vì vậy, ta chỉ tiếp tục gọi đệ quy trên nút con bên trái là nút chứa và bỏ qua không gọi đệ quy nút con bên phải là nút chứa .
Khi đấy, nút con bên trái đó (nút ) sẽ là một trường hợp cơ sở khác, nó cũng được chọn (và đánh dấu bằng màu xanh dương).
Đệ quy kết thúc và giá trị nhỏ nhất của đoạn cần truy vấn chính là giá trị nhỏ nhất của các nút đã được chọn.
Thứ khiến ta cần phải cân nhắc ở đây chính là cách lưu trữ cây phân đoạn. Tất nhiên, ta có thể tạo ra một cấu trúc cây và danh sách cạnh, lưu trữ phạm vi quản lí của từng nút và các thông tin của nó. Tuy nhiên điều này đòi hỏi phải lưu trữ nhiều thông tin dư thừa.
Thay vào đó, ta sẽ sử dụng một thủ thuật đơn giản để làm cho việc này trở nên hiệu quả hơn rất nhiều. Ta sẽ chỉ lưu trữ các thông tin của từng nút vào trong một mảng. Thông tin của nút gốc lưu ở chỉ số , thông tin của hai nút con của nó lưu ở chỉ số và , thông tin của các nút con của hai nút đó sẽ lưu ở chỉ số từ đến , v.v... Dễ dàng nhận thấy, con bên trái của nút có chỉ số được lưu trữ tại chỉ số và con bên phải được lưu trữ tại chỉ số .
Ví dụ minh họa: Cho mảng , ta có (với là mảng biểu diễn cho Segment Tree, lưu lại thông tin của mỗi nút).
Điều này đơn giản hóa việc cài đặt rất nhiều. Ta không cần lưu hết toàn bộ cấu trúc của cây trong bộ nhớ. Mà nó sẽ được định nghĩa một cách ngầm định. Ta chỉ cần một mảng chứa thông tin của tất cả các phân đoạn.
Để dễ hình dung, ta lấy ví dụ cụ thể:
Cách đơn giản nhất là dùng một mảng duy trì giá trị các phần tử. Với truy vấn thì ta gán . Với truy vấn thì ta dùng một vòng lặp từ đến để tìm giá trị nhỏ nhất. Rõ ràng cách này có độ phức tạp là và không thể chạy trong thời gian cho phép.
Do đó, ta sẽ cài đặt Segment Tree để giải quyết bài toán trên như sau:
Cấu trúc dữ liệu:
maxN = 100007
.inf = 1000000007
.st[]
- Lưu thông tin của mỗi nút trên Segment Tree.#include <bits/stdc++.h>
using namespace std;
const int inf = 1e9 + 7;
const int maxN = 1e5 + 7;
int n, q;
int a[maxN];
int st[4 * maxN]; // Lí do sử dụng kích thước mảng là 4 * maxN sẽ được giải thích ở phần sau
// Thủ tục xây dựng cây phân đoạn
void build(int id, int l, int r) {
// Đoạn chỉ gồm 1 phần tử, không có nút con
if (l == r) {
st[id] = a[l];
return;
}
// Gọi đệ quy để xử lý các nút con của nút id
int mid = l + r >> 1; // (l + r) / 2
build(2 * id, l, mid);
build(2 * id + 1, mid + 1, r);
// Cập nhật lại giá trị min của đoạn [l, r] theo 2 nút con
st[id] = min(st[2 * id], st[2 * id + 1]);
}
// Thủ tục cập nhật
void update(int id, int l, int r, int i, int val) {
// i nằm ngoài đoạn [l, r], ta bỏ qua nút id
if (l > i || r < i) return;
// Đoạn chỉ gồm 1 phần tử, không có nút con
if (l == r) {
st[id] = val;
return;
}
// Gọi đệ quy để xử lý các nút con của nút id
int mid = l + r >> 1; // (l + r) / 2
update(2 * id, l, mid, i, val);
update(2 * id + 1, mid + 1, r, i, val);
// Cập nhật lại giá trị min của đoạn [l, r] theo 2 nút con
st[id] = min(st[2 * id], st[2 * id + 1]);
}
// Hàm lấy giá trị
int get(int id, int l, int r, int u, int v) {
// Đoạn [u, v] không giao với đoạn [l, r], ta bỏ qua đoạn này
if (l > v || r < u) return inf;
/* Đoạn [l, r] nằm hoàn toàn trong đoạn [u, v] mà ta đang truy vấn,
ta trả lại thông tin lưu ở nút id */
if (l >= u && r <= v) return st[id];
// Gọi đệ quy với các nút con của nút id
int mid = l + r >> 1; // (l + r) / 2
int get1 = get(2 * id, l, mid, u, v);
int get2 = get(2 * id + 1, mid + 1, r, u, v);
// Trả ra giá trị nhỏ nhất theo 2 nút con
return min(get1, get2);
}
int main() {
cin >> n;
for (int i = 1; i <= n; ++i) cin >> a[i];
build(1, 1, n);
cin >> q;
while (q--) {
int type, x, y;
cin >> type >> x >> y;
if (type == 1) update(1, 1, n, x, y); // Gán giá trị y cho phần tử ở vị trí x
else cout << get(1, 1, n, x, y) << '\n'; // In ra giá trị nhỏ nhất trong đoạn [x, y]
}
}
Ở phần Các thao tác trên cây phân đoạn có nhắc đến cách để cài đặt Segment Tree. Vì sự khác biệt về tốc độ của hai cách có thể là không đáng kể, nên bài viết này sẽ chỉ cài đặt theo cách thông thường nhất là sử dụng phương pháp đệ quy.
Segment Tree còn có một cách cài đặt khác sử dụng ít bộ nhớ hơn (sử dụng tối đa phần tử), cài đặt ngắn hơn và chạy nhanh hơn. Tuy nhiên thì nó không dễ hiểu bằng cách cài đặt trên. Bạn có thể tham khảo thêm tại đây: VNOI - Efficient and easy segment trees.
Ta xét trường hợp:
Do đó, số nút của cây cho dãy phần tử (với ) là không quá , giá trị này xấp xỉ . Bằng thực nghiệm, ta thấy dùng là đủ.
Tham khảo thêm các cách chứng minh khác tại đây: Codeforces – Blog entry 49939.
Thao tác xây dựng sẽ yêu cầu một số lượng hoạt động không đổi trên mỗi nút của cây phân đoạn. Với mảng có phần tử thì số lượng nút của cây phân đoạn xấp xỉ (đã chứng minh ở trên), và vì thao tác xây dựng mất thời gian tuyến tính nên sẽ có độ phức tạp là .
Ta có thể nhận thấy rằng thao tác xây dựng nhanh hơn so với việc thực hiện các thao tác cập nhật riêng biệt (việc duyệt từng phần tử của mảng để cập nhật sẽ mất độ phức tạp ).
Với thao tác cập nhật, một số lượng các hoạt động không đổi được thực hiện cho mỗi nút trên đường đi đơn từ gốc đến nút lá tương ứng với phần tử cần sửa đổi. Đồng nghĩa với việc ở mỗi độ sâu của cây, ta chỉ gọi đệ quy tới không quá nút con.
Phân tích đoạn code trên, ta xét các trường hợp:
Vì số lượng các nút trên đường đi đơn từ gốc đến nút lá tương ứng được giới hạn bởi chiều cao của cây là nên độ phức tạp của thao tác cập nhật sẽ là .
Trước tiên, ta hãy xem xét từng độ sâu của của cây. Từ đó có thể thấy rằng đối với mỗi độ sâu, ta chỉ truy cập không quá nút. Và vì chiều cao của cây là nên độ phức tạp của thao tác lấy giá trị là .
Ta có thể chứng minh rằng mệnh đề này (truy cập nhiều nhất bốn nút trong mỗi độ sâu) là đúng bằng phương pháp quy nạp:
Ở độ sâu đầu tiên, ta chỉ truy cập duy nhất một nút là nút gốc, vì vậy ở đây ta truy cập ít hơn nút.
Bây giờ, ta hãy xem xét một độ sâu bất kì. Theo giả thuyết quy nạp là ta thăm nhiều nhất nút. Nếu ta chỉ thăm nhiều nhất nút, thì ở độ sâu tiếp theo, số lượng nút được thăm nhiều nhất sẽ là nút. Điều đó thật hiển nhiên, bởi vì mỗi nút có thể tạo ra nhiều nhất lệnh gọi đệ quy.
Vì vậy, giả sử rằng ta truy cập hoặc nút trong độ sâu hiện tại. Từ các nút đó, ta sẽ phân tích kỹ hơn các nút nằm ở giữa (nghĩa là nút thứ hai từ trái sang với số lượng nút đang được truy cập là và nút thứ với số nút đang được truy cập là ) . Vì truy vấn yêu cầu lấy giá trị của một đoạn con liên tục, ta biết rằng các phân đoạn tương ứng với các nút đã thăm ở giữa sẽ được bao phủ hoàn toàn bởi phân đoạn của truy vấn. Do đó các nút này sẽ không thực hiện bất kỳ lệnh gọi đệ quy nào tới các nút con nữa. Vì vậy, chỉ nút bên trái nhất và nút bên phải nhất mới có khả năng tiếp tục gọi đệ quy. Và hai nút đó sẽ chỉ tạo ra nhiều nhất lệnh gọi đệ quy, vì vậy độ sâu tiếp theo cũng sẽ đáp ứng đúng mệnh đề.
Ta cũng có thể nói rằng một nhánh cây phân đoạn sẽ tiếp cận dần tới giới hạn bên trái của truy vấn và nhánh thứ hai tiếp cận tới giới hạn bên phải.
Do đó, ta sẽ chỉ truy cập nhiều nhất nút trên cây phân đoạn, và đó cũng chính là độ phức tạp của thao tác lấy giá trị.
Bạn được cho một mảng gồm số nguyên. Ban đầu tất cả các số của mảng đều là . Nhiệm vụ của bạn là xử lí loại truy vấn:
Với mỗi truy vấn loại , hãy in ra câu trả lời trên một dòng.
(với là số lượng truy vấn).
Vì ban đầu giá trị của tất cả phần tử trong mảng đều bằng nên ta không cần phải thực hiện thao tác xây dựng cây phân đoạn.
Nhận thấy rằng, trên cây phân đoạn, mỗi nút không phải lá sẽ chứa tổng giá trị tại các nút con của nó. Do đó, ta chỉ cần thay thế tất cả các phép toán min ở ví dụ trên (trong phần Cài đặt) bằng các phép toán cộng.
Cấu trúc dữ liệu:
maxN = 100007
.st[]
- Lưu thông tin của mỗi nút trên Segment Tree.#include <bits/stdc++.h>
using namespace std;
const int maxN = 1e5 + 7;
int n, q;
int a[maxN];
long long st[4 * maxN];
// Thủ tục cập nhật
void update(int id, int l, int r, int i, int val) {
if (l > i || r < i) return;
if (l == r) {
st[id] = val;
return;
}
int mid = l + r >> 1;
update(2 * id, l, mid, i, val);
update(2 * id + 1, mid + 1, r, i, val);
st[id] = st[2 * id] + st[2 * id + 1];
}
// Hàm lấy tổng giá trị
long long get(int id, int l, int r, int u, int v) {
if (l > v || r < u) return 0;
if (l >= u && r <= v) return st[id];
int mid = l + r >> 1;
long long get1 = get(2 * id, l, mid, u, v);
long long get2 = get(2 * id + 1, mid + 1, r, u, v);
return get1 + get2;
}
int main() {
cin >> n >> q;
while (q--){
int type, x, y;
cin >> type >> x >> y;
if (type == 1) update(1, 1, n, x, y);
else cout << get(1, 1, n, x, y) << '\n';
}
}
Độ phức tạp
Với mỗi truy vấn, ta sẽ mất cho mỗi thao tác trên Segment Tree. Do đó, độ phức tạp của thuật toán là .
Cho dãy số .
Hàm = , với .
Cho câu hỏi dạng , hãy tính các .
Ở bài toán này, mỗi nút của cây phân đoạn lưu lại các thông tin sau:
pre
: Tiền tố có tổng giá trị lớn nhất trên đoạn.suf
: Hậu tố có tổng giá trị lớn nhất trên đoạn.sum
: Tổng giá trị các phần tử trên đoạn.maxsum
: Tổng giá trị lớn nhất của đoạn con nằm trong đoạn mà nút đó quản lí.Bây giờ, ta cần phải xác định các phép toán để "hợp nhất" hai nút con:
pre
: Dễ thấy rằng tiền tố lớn nhất của nút cha sẽ bằng max tiền tố của nút con bên trái và tổng giá trị của nút con bên trái cộng với tiền tố của nút con bên phải.
suf
: Hậu tố lớn nhất của nút cha sẽ bằng max hậu tố của nút con bên phải và tổng giá trị của nút con bên phải cộng với hậu tố của nút con bên trái. Cách tính hậu tố tương tự như với tiền tố nên sẽ không minh họa lại nữa.
sum
: Ta có thể dễ dàng tính toán giống như ở Ví dụ 1 bằng cách lấy tổng giá trị nút con.
maxsum
:
Xét trường hợp:
Ở trường hợp đầu tiên thì ta chỉ cần trực tiếp lấy giá trị của đoạn con lớn nhất từ các nút con.
Ở trường hợp thứ , giá trị của đoạn con lớn nhất sẽ là tổng hậu tố lớn nhất của nút con bên trái với tiền tố lớn nhất của nút con bên phải.
Khi đó, giá trị của đoạn con lớn nhất nằm trong đoạn được quản lí bởi nút cha sẽ là giá trị lớn nhất của cả ba trường hợp trên.
Với mỗi truy vấn, đáp án chính là .
Cấu trúc dữ liệu:
inf = 1000000007
.maxN = 50007
.st[]
- Lưu thông tin của mỗi nút trên Segment Tree.#include <bits/stdc++.h>
using namespace std;
const int inf = 1e9 + 7;
const int maxN = 5e4 + 7;
// Thông tin của mỗi nút
struct node {
int pre, suf, sum, maxsum;
static node base() { return { -inf, -inf, 0, -inf }; }
// Hàm hợp nhất 2 nút con
static node merge(const node& a, const node& b) {
node res;
res.pre = max(a.pre, b.pre + a.sum);
res.suf = max(b.suf, a.suf + b.sum);
res.sum = a.sum + b.sum;
res.maxsum = max(a.maxsum, b.maxsum);
res.maxsum = max(res.maxsum, a.suf + b.pre);
return res;
}
};
int n, m;
int a[maxN];
node st[4 * maxN];
// Thủ tục xây dựng cây phân đoạn
void build(int id, int l ,int r) {
if (l == r) {
st[id] = { a[l], a[l], a[l], a[l] };
return;
}
int mid = l + r >> 1;
build(2 * id, l, mid);
build(2 * id + 1, mid + 1, r);
st[id] = node::merge(st[2 * id], st[2 * id + 1]);
}
// Hàm lấy giá trị
node get(int id, int l, int r, int u, int v){
if (l > v || r < u) return node::base();
if (l >= u && r <= v) return st[id];
int mid = l + r >> 1;
node g1 = get(2 * id, l, mid, u, v);
node g2 = get(2 * id + 1, mid + 1, r, u, v);
return node::merge(g1, g2);
}
int main() {
cin >> n;
for (int i = 1; i <= n; ++i) cin >> a[i];
build(1, 1, n);
cin >> m;
while (m--) {
int x, y;
cin >> x >> y;
cout << get(1, 1, n, x, y).maxsum << '\n';
}
}
Độ phức tạp
Để xây dựng cây phân đoạn, ta mất độ phức tạp . Với mỗi truy vấn, ta mất thêm độ phức tạp cho mỗi thao tác lấy giá trị trên Segment Tree.
Nhìn chung, độ phức tạp của thuật toán là .
Cho một dãy số có số. Bạn cần xử lí loại truy vấn:
(với là số lượng truy vấn).
Trong bài toán này, mỗi nút của cây phân đoạn là một multiset chứa các phần tử trong đoạn mà nó quản lí. Khi đó, để hợp nhất các nút con, ta chỉ cần insert toàn bộ phần tử của cả nút con vào nút cha.
Mỗi khi cập nhật cây phân đoạn, nếu ta duyệt từng phần tử của các nút con để insert vào nút cha thì với số lượng truy vấn , ta sẽ mất độ phức tạp lên tới hơn (chưa kể độ phức tạp của các thao tác trên multiset), vì số lượng phần tử của mỗi nút có thể lên tới .
Thay vào đó, ta nhận thấy rằng, khi thay đổi giá trị thành thì tất cả multiset của các nút quản lí phân đoạn chứa phần tử (các nút nằm trên đường đi đơn từ gốc đến nút lá tương ứng) đều sẽ phải xóa đi một giá trị và thêm vào một giá trị . Do đó, với truy vấn loại , khi cập nhật một nút, ta không cần phải insert lại toàn bộ phần tử của cả nút con, mà chỉ cần xóa đi một giá trị cũ trong multiset và chèn thêm giá trị mới.
Với truy vấn loại , ta thực hiện tương tự như thao tác lấy giá trị. Tuy nhiên, mỗi khi xét đến nút mà đoạn nó quản lí nằm hoàn toàn bên trong đoạn cần truy vấn (trường hợp cơ sở), ta sử dụng hàm để trả ra giá trị nhỏ nhất mà vẫn lớn hơn hoặc bằng trong multiset của nút đó.
Cấu trúc dữ liệu:
inf = 1000000007
.maxN = 100007
.st[]
- Lưu thông tin của mỗi nút trên Segment Tree.#include <bits/stdc++.h>
using namespace std;
const int inf = 1e9 + 7;
const int maxN = 1e5 + 7;
int n, m;
int a[maxN];
multiset <int> st[4 * maxN];
void build(int id, int l, int r) {
if (l == r) {
st[id].insert(a[l]);
return;
}
int mid = l + r >> 1;
build(2 * id, l, mid);
build(2 * id + 1, mid + 1, r);
st[id] = st[2 * id + 1];
for (auto x : st[2 * id]) st[id].insert(x);
}
void update(int id, int l, int r, int i, int old, int val) {
if (l > i || r < i) return;
if (l == r) {
st[id].clear();
st[id].insert(val);
return;
}
int mid = l + r >> 1;
update(2 * id, l, mid, i, old, val);
update(2 * id + 1, mid + 1, r, i, old, val);
st[id].erase(st[id].find(old));
st[id].insert(val);
}
int get(int id, int l, int r, int u, int v, int k) {
if (l > v || r < u) return inf;
if (l >= u && r <= v) {
auto it = st[id].lower_bound(k);
if (it == st[id].end()) return inf;
return *it;
}
int mid = l + r >> 1;
int get1 = get(2 * id, l, mid, u, v, k);
int get2 = get(2 * id + 1, mid + 1, r, u, v, k);
return min(get1, get2);
}
int main() {
cin >> n >> m;
for (int i = 1; i <= n; ++i) cin >> a[i];
build(1, 1, n);
while (m--){
int type, l, r, k;
cin >> type;
if (type == 1) {
cin >> l >> k;
update(1, 1, n, l, a[l], k);
a[l] = k;
}
else {
cin >> l >> r >> k;
int ans = get(1, 1, n, l, r, k);
cout << ((ans == inf) ? -1 : ans) << '\n';
}
}
}
Ngoài ra còn có cách cài đặt khác đơn giản hơn, ta có thể cài đặt hàm update
theo cách cập nhật "từ dưới lên trên" (không sử dụng đệ quy) như sau:
void update(int i, int val){
/* leaf[i] là chỉ số của nút lá tương ứng với
phần tử ở vị trí i trong dãy số */
int id = leaf[i];
int old = *st[id].begin();
while (id) {
st[id].erase(st[id].find(old));
st[id].insert(val);
id /= 2;
}
}
Tham khảo code chuẩn tại đây.
Độ phức tạp
Với thao tác xây dựng cây phân đoạn, ta thấy rằng, ở mỗi độ sâu của cây, ta sẽ phải duyệt hết toàn bộ phần tử trong dãy để insert vào multiset của các nút cha.
Ví dụ: Xét trường hợp tổng quát, với là lũy thừa của , ta có cây phân đoạn đầy đủ sau:
Mà chiều cao của cây là nên thao tác xây dựng cây phân đoạn có độ phức tạp là (độ phức tạp thời gian cho việc chèn, xóa và truy xuất thông tin trên multiset là ).
Với mỗi thao tác cập nhật hoặc lấy giá trị, ta mất độ phức tạp .
Nhìn chung, độ phức tạp của thuật toán là .
Đây là kĩ thuật được sử dụng trong Segment Tree để giảm độ phức tạp của Segment Tree với các truy vấn cập nhật đoạn.
Giả sử ta cần cập nhật đoạn . Dễ thấy rằng, việc cập nhật tất cả các nút trên Segment Tree sẽ mất độ phức tạp rất lớn là (do tổng số phần tử nằm trong đoạn có thể lên đến ). Do đó, với số lượng truy vấn cập nhật đoạn lớn, thao tác này sẽ không đủ tốt.
Vậy nên, trong quá trình cập nhật, ta chỉ thay đổi giá trị ở các nút gần gốc nhất sao cho tổng tất cả các phạm vi mà các nút đó quản lí đúng bằng đoạn .
Ví dụ: Cho mảng và ta cần cập nhật đoạn :
Cụ thể, ta xem xét bài toán sau:
Bạn được cho một mảng gồm số nguyên với . Nhiệm vụ của bạn là xử lí loại truy vấn:
Với mỗi truy vấn loại , hãy in ra câu trả lời trên một dòng.
(với là số lượng truy vấn).
Ban đầu, ta thực hiện thủ tục xây dựng cây phân đoạn như bình thường.
Truy vấn loại là thao tác lấy giá trị cơ bản trên Segment Tree đã được phân tích trước đó.
Với truy vấn loại , thao tác cập nhật đoạn . Giả sử ta gọi là giá trị lớn nhất trong đoạn mà nút quản lý. Trong lúc cập nhật, muốn hàm này thực hiện với độ phức tạp không quá , thì khi xét đến nút quản lý đoạn mà đoạn nằm hoàn toàn trong đoạn , thì ta không được đệ quy vào các nút con của nó nữa (nếu không độ phức tạp sẽ là do ta đi vào tất cả các nút nằm trong đoạn ).
Để giải quyết, ta dùng kĩ thuật Lazy Propagation như sau:
get
và update
ta thực hiện các thao tác sau:
st[id] += lazy[id]
- cập nhật giá trị nút theo .lazy[2 * id] += lazy[id]
lazy[2 * id + 1] += lazy[id]
lazy[id] = 0
- chú ý ta cần phải thực hiện thao tác này, nếu không mỗi phần tử của dãy sẽ bị tăng lên nhiều lần, do ta đẩy xuống nhiều lần.Cấu trúc dữ liệu:
inf = 1000000007
.maxN = 100007
.st[]
- Lưu thông tin của mỗi nút trên Segment Tree.lazy[]
- Lưu trữ các giá trị cần cập nhật của mỗi nút trên Segment Tree.#include <bits/stdc++.h>
using namespace std;
const int inf = 1e9 + 7;
const int maxN = 1e5 + 7;
int n, q;
int a[maxN];
long long st[4 * maxN], lazy[4 * maxN];
void build(int id, int l, int r) {
if (l == r) {
st[id] = a[l];
return;
}
int mid = l + r >> 1;
build(2 * id, l, mid);
build(2 * id + 1, mid + 1, r);
st[id] = max(st[2 * id], st[2 * id + 1]);
}
// Cập nhật nút đang xét và đẩy giá trị lazy xuống các nút con
void fix(int id, int l, int r) {
if (!lazy[id]) return;
st[id] += lazy[id];
// Nếu id không phải là nút lá thì đẩy giá trị xuống các nút con
if (l != r){
lazy[2 * id] += lazy[id];
lazy[2 * id + 1] += lazy[id];
}
lazy[id] = 0;
}
void update(int id, int l, int r, int u, int v, int val) {
fix(id, l, r);
if (l > v || r < u) return;
if (l >= u && r <= v) {
/* Khi cài đặt, ta LUÔN ĐẢM BẢO giá trị của nút được cập nhật ĐỒNG THỜI với
giá trị Lazy Propagation. Như vậy sẽ tránh sai sót. */
lazy[id] += val;
fix(id, l, r);
return;
}
int mid = l + r >> 1;
update(2 * id, l, mid, u, v, val);
update(2 * id + 1, mid + 1, r, u, v, val);
st[id] = max(st[2 * id], st[2 * id + 1]);
}
long long get(int id, int l, int r, int u, int v) {
fix(id, l, r);
if (l > v || r < u) return -inf;
if (l >= u && r <= v) return st[id];
int mid = l + r >> 1;
long long get1 = get(2 * id, l, mid, u, v);
long long get2 = get(2 * id + 1, mid + 1, r, u, v);
return max(get1, get2);
}
int main() {
cin >> n;
for (int i = 1; i <= n; ++i) cin >> a[i];
build(1, 1, n);
cin >> q;
while (q--){
int type, l, r, val;
cin >> type >> l >> r;
if (type == 1) {
cin >> val;
update(1, 1, n, l, r, val);
}
else cout << get(1, 1, n, l, r) << '\n';
}
}
Độ phức tạp
Tương tự như thao tác lấy giá trị, độ phức tạp của thao tác Lazy Propagation là .
Nhìn chung, độ phức tạp của cả bài toán là .
Codeforces - 558E A Simple Task
Cho một chuỗi kí tự độ dài (chỉ chứa các chữ cái tiếng Anh in thường) và truy vấn, mỗi truy vấn có dạng có nghĩa là hãy sắp xếp chuỗi con gồm các kí tự từ đến theo thứ tự không giảm nếu hoặc theo thứ tự không tăng nếu .
In ra chuỗi cuối cùng sau khi đã thực hiện tất cả các truy vấn.