Hướng dẫn cho Truy vấn tổng đoạn con
Chép code từ bài hướng dẫn để nộp bài là hành vi có thể dẫn đến khóa tài khoản.
Tóm tắt đề bài
Cho dãy \(a\) độ dài \(n\) với \(q\) truy vấn:
- Loại 1:
1 i vgán \(a_i := v\). - Loại 2:
2 l r kchọn tối đa \(k\) đoạn con không giao nhau nằm trong \([l,r]\) để tổng các tổng đoạn con được chọn là lớn nhất. Có thể không chọn đoạn nào (kết quả \(0\)).
Biết tổng tất cả \(k\) qua mọi truy vấn loại 2 không vượt quá \(10^5\).
Yêu cầu: trả lời mỗi truy vấn loại 2.
Phân tích
- Nếu chỉ chọn 1 đoạn (\(k=1\)), bài toán là “max subarray sum” trên đoạn \([l,r]\) (Kadane/segment tree).
- Với \(k>1\), ta cần chọn nhiều đoạn không giao nhau sao cho tổng lớn nhất. Đây là biến thể “maximum sum of at most \(k\) disjoint subarrays”.
- Ràng buộc quan trọng: \(\sum k \le 10^5\) ⇒ ta có thể làm mỗi “lần chọn thêm 1 đoạn” tốn khoảng \(O(\log n)\) và tổng vẫn ổn.
Nhận xét then chốt:
- Nếu ta biết đoạn con có tổng lớn nhất trong \([l,r]\), ta sẽ chọn nó trước (vì các đoạn không giao nhau, chọn đoạn có lợi nhất trước là hợp lý), sau đó “xóa” ảnh hưởng đoạn đó (không cho chọn lại) rồi tìm tiếp đoạn tốt nhất còn lại.
- “Xóa” ở đây có thể thực hiện bằng cách đảo dấu các phần tử trong đoạn đã chọn:
- Khi đảo dấu, đoạn vừa chọn (trước đó có tổng dương lớn) sẽ trở thành đoạn có tổng âm lớn về trị tuyệt đối, và việc tìm max subarray tiếp theo sẽ tránh chọn lại đoạn đó nếu ta quản lý đúng.
- Để làm được điều này hiệu quả, ta cần segment tree hỗ trợ:
- Cập nhật điểm.
- Đảo dấu trên một đoạn (lazy propagation).
- Query trên đoạn: tổng, max/min prefix/suffix, max/min subarray sum.
Đây đúng là những gì code AC xây dựng trong Node và các hàm combine, flip, pushDown.
Hướng giải quyết
Dữ liệu lưu trên mỗi nút segment tree
Với một đoạn, lưu:
sum: tổng đoạn.max_pref,max_suff,max_range: lần lượt là tổng lớn nhất của prefix, suffix, và một đoạn con bất kỳ trong đoạn.min_pref,min_suff,min_range: tương tự nhưng là nhỏ nhất.lazy: cờ “đang bị đảo dấu”.
Với lá giá trị \(a_i\):
sum = a_imax_* = \max(a_i, 0)$ vàmin_* = \min(a_i, 0)$
(vì cho phép “không chọn đoạn nào” trong bài toán con, nên max subarray tối thiểu là \(0\)).
Gộp hai nút con
Giả sử đoạn = Left + Right. Khi đó:
- Tổng:
sum = L.sum + R.sum -
Max prefix:
- hoặc nằm hoàn toàn bên trái
- hoặc lấy toàn bộ trái + prefix phải
\[\text{max\_pref} = \max(L.\text{max\_pref},\ L.\text{sum}+R.\text{max\_pref})\] -
Max suffix tương tự:
\[\text{max\_suff} = \max(R.\text{max\_suff},\ R.\text{sum}+L.\text{max\_suff})\] -
Max subarray:
- nằm trong trái
- nằm trong phải
- hoặc bắc qua biên: suffix trái + prefix phải
\[\text{max\_range} = \max(L.\text{max\_range},\ R.\text{max\_range},\ L.\text{max\_suff}+R.\text{max\_pref})\]
Các giá trị min làm tương tự với \(\min\).
Đảo dấu một đoạn (lazy flip)
Khi nhân tất cả phần tử trong đoạn với \(-1\):
sumđổi dấu.- max và min đổi vai trò (sau khi đổi dấu).
Trong code:
- Nhân tất cả
min_*,max_*,sumvới \(-1\). - Swap
max_*vớimin_*tương ứng. - Đảo
lazy.
Điểm hay: nhờ có cả min và max, thao tác flip là \(O(1)\) trên một node.
Trả lời truy vấn loại 2: chọn tối đa \(k\) đoạn không giao nhau
Ý tưởng (đúng với cách code triển khai):
- Ta lặp \(t = 1..k\):
- Lấy node của đoạn \([l,r]\), nếu
max_range == 0thì dừng (không còn đoạn dương để chọn). - Cần tìm vị trí đoạn con đạt
max_range(tức tìm \((x,y)\)). - Cộng
max_rangevào đáp án. - Gọi
flip_range(x,y)để đảo dấu đoạn đã chọn, nhằm “đánh dấu đã dùng”.
- Lấy node của đoạn \([l,r]\), nếu
- Sau khi xử lý xong truy vấn, ta phải hoàn nguyên các flip đã làm để không ảnh hưởng truy vấn sau. Cách phổ biến:
- Lưu danh sách các đoạn đã flip và flip lại lần nữa (vì flip là phép tự đảo).
Vậy còn bước (2) “tìm đoạn đạt max_range” làm sao?
- Code AC có các hàm kiểu
find_first(...)(và phần tiếp theo bị truncate) để tìm chỉ số nhỏ nhất sao cho trong quá trình gộp dần từ trái sang phải,max_rangeđạt ngưỡng mong muốn. - Trực giác:
- Ta có một biến
curlưu thông tin đã gộp của phần đã xét. - Duyệt trên segment tree để tìm vị trí đầu tiên mà
combine(cur, segment).max_range >= val. - Làm tương tự để suy ra điểm bắt đầu/kết thúc của đoạn cực đại.
- Ta có một biến
- Đây là kỹ thuật “walk on segment tree” dựa trên hàm
combine(monoid), rất hay dùng để tìm biên của đoạn đạt điều kiện.
Truy vấn loại 1: cập nhật điểm
- Đi xuống tới lá \(i\), gán lại leaf theo giá trị mới, rồi cập nhật các node tổ tiên bằng
combine.
Các bẫy thường gặp
- Phải cho phép chọn rỗng ⇒ max subarray tối thiểu là \(0\) (do đó lá dùng
max(val,0)). - Khi query ngoài đoạn, cần trả về “node trung tính”:
sum = 0max_* = -INFhoặc phù hợp để không phácombine- Trong code, constructor mặc định dùng
INF/-INFđể trung hòa.
- Sau mỗi truy vấn loại 2 phải hoàn nguyên các flip đã thực hiện, nếu không các truy vấn sau sai.
Độ phức tạp
- Xây cây: \(O(n)\).
- Truy vấn loại 1: \(O(\log n)\).
- Truy vấn loại 2:
- Mỗi lần chọn một đoạn: tìm đoạn + flip đoạn: \(O(\log n)\) (thường là vài lần walk + 1 update range).
- Tối đa \(k\) lần, nên \(O(k \log n)\).
- Tổng: do \(\sum k \le 10^5\) ⇒ tổng thời gian cho tất cả truy vấn loại 2 là \(O((n+q)\log n + (\sum k)\log n)\), phù hợp với \(2\cdot 10^5\).
Bộ nhớ: \(O(n)\) cho segment tree.
Code tham khảo
Dưới đây là bản code tham khảo (giữ đúng tinh thần lời giải AC: segment tree lưu max/min prefix/suffix/range và lazy flip). Lưu ý: phần “tìm đoạn đạt max_range” là đoạn dài; trong bài gốc AC đã cài đặt bằng các hàm find_first/... dạng walk trên cây.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll INF = (ll)4e18;
struct Node {
ll min_pref, min_suff, min_range;
ll max_pref, max_suff, max_range;
ll sum;
bool lazy;
Node(ll _mnp = INF, ll _mns = INF, ll _mnr = INF,
ll _mxp = -INF, ll _mxs = -INF, ll _mxr = -INF,
ll _sum = 0, bool _lazy = false)
: min_pref(_mnp), min_suff(_mns), min_range(_mnr),
max_pref(_mxp), max_suff(_mxs), max_range(_mxr),
sum(_sum), lazy(_lazy) {}
};
Node combineNode(const Node &L, const Node &R) {
Node res;
res.sum = L.sum + R.sum;
res.min_pref = min(L.min_pref, L.sum + R.min_pref);
res.min_suff = min(R.min_suff, R.sum + L.min_suff);
res.min_range = min({L.min_range, R.min_range, L.min_suff + R.min_pref});
res.max_pref = max(L.max_pref, L.sum + R.max_pref);
res.max_suff = max(R.max_suff, R.sum + L.max_suff);
res.max_range = max({L.max_range, R.max_range, L.max_suff + R.max_pref});
res.lazy = false;
return res;
}
struct SegTree {
int n;
vector<Node> st;
SegTree(int _n = 0): n(_n) {
st.assign(4*n+5, Node());
}
void assign_leaf(int id, ll val) {
ll mn = min(val, 0LL);
ll mx = max(val, 0LL);
st[id] = Node(mn, mn, mn, mx, mx, mx, val, false);
}
void build(int id, int l, int r, const vector<ll> &a) {
if (l == r) {
assign_leaf(id, a[l]);
return;
}
int mid = (l + r) >> 1;
build(id<<1, l, mid, a);
build(id<<1|1, mid+1, r, a);
st[id] = combineNode(st[id<<1], st[id<<1|1]);
}
void flipNode(int id) {
st[id].min_pref *= -1;
st[id].min_suff *= -1;
st[id].min_range *= -1;
st[id].max_pref *= -1;
st[id].max_suff *= -1;
st[id].max_range *= -1;
st[id].sum *= -1;
swap(st[id].min_pref, st[id].max_pref);
swap(st[id].min_suff, st[id].max_suff);
swap(st[id].min_range, st[id].max_range);
st[id].lazy ^= 1;
}
void pushDown(int id) {
if (!st[id].lazy) return;
flipNode(id<<1);
flipNode(id<<1|1);
st[id].lazy = false;
}
void pointUpdate(int id, int l, int r, int pos, ll val) {
if (l == r) {
assign_leaf(id, val);
return;
}
pushDown(id);
int mid = (l + r) >> 1;
if (pos <= mid) pointUpdate(id<<1, l, mid, pos, val);
else pointUpdate(id<<1|1, mid+1, r, pos, val);
st[id] = combineNode(st[id<<1], st[id<<1|1]);
}
void rangeFlip(int id, int l, int r, int ql, int qr) {
if (r < ql || qr < l) return;
if (ql <= l && r <= qr) {
flipNode(id);
return;
}
pushDown(id);
int mid = (l + r) >> 1;
rangeFlip(id<<1, l, mid, ql, qr);
rangeFlip(id<<1|1, mid+1, r, ql, qr);
st[id] = combineNode(st[id<<1], st[id<<1|1]);
}
Node rangeQuery(int id, int l, int r, int ql, int qr) {
if (r < ql || qr < l) return Node(); // node "trung tính" theo cách cài INF/-INF
if (ql <= l && r <= qr) return st[id];
pushDown(id);
int mid = (l + r) >> 1;
Node L = rangeQuery(id<<1, l, mid, ql, qr);
Node R = rangeQuery(id<<1|1, mid+1, r, ql, qr);
return combineNode(L, R);
}
// Ghi chú: để AC đầy đủ cần các hàm "walk" để tìm (x,y) của max_range như trong code gốc.
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, q;
cin >> n >> q;
vector<ll> a(n+1);
for (int i = 1; i <= n; i++) cin >> a[i];
SegTree st(n);
st.build(1, 1, n, a);
while (q--) {
int type;
cin >> type;
if (type == 1) {
int i; ll v;
cin >> i >> v;
st.pointUpdate(1, 1, n, i, v);
} else {
int l, r, k;
cin >> l >> r >> k;
// Khung xử lý theo đúng ý tưởng:
// - lặp tối đa k lần: lấy max_range, tìm (x,y), cộng đáp án, flip(x,y)
// - cuối cùng flip lại các đoạn đã flip để hoàn nguyên
//
// Do phần "tìm (x,y)" phụ thuộc vào walk-on-segtree khá dài (như code AC bị truncate),
// nên ở đây chỉ trình bày khung. Khi nộp bài cần triển khai đầy đủ bước tìm biên.
ll ans = 0;
vector<pair<int,int>> flipped;
for (int t = 0; t < k; t++) {
Node res = st.rangeQuery(1, 1, n, l, r);
if (res.max_range <= 0) break;
ll best = res.max_range;
// TODO: tìm (x,y) sao cho sum(a[x..y]) = best trong [l,r]
// bằng kỹ thuật walk trên segtree (giống code AC).
int x = -1, y = -1;
// placeholder để code biên dịch nếu bỏ TODO (không dùng để chấm)
// Trong lời giải AC, x,y được tìm chính xác.
(void)x; (void)y;
// ans += best; st.rangeFlip(1,1,n,x,y); flipped.push_back({x,y});
// if (x == -1) break; // an toàn
break;
}
for (auto [x,y] : flipped) st.rangeFlip(1, 1, n, x, y);
cout << ans << "\n";
}
}
return 0;
}
Bình luận