Chương 1·Bài 1.8

Segment Tree

Cây phân đoạn (segment tree) là cấu trúc dữ liệu chuyên trị bài toán vừa cập nhật vừa truy vấn trên đoạn [l,r][l, r] bất kỳ, mỗi thao tác trong O(log⁡n)O(\log n). Bài toán quen thuộc nhất của nó: cho dãy nn số và qq thao tác xen kẽ nhau, mỗi thao tác hoặc gán lại một phần tử, hoặc hỏi số nhỏ nhất trong một đoạn; trả lời mọi câu hỏi trong O((n+q)log⁡n)O((n + q) \log n).

Ý tưởng của cây phân đoạn là chia đôi dãy liên tiếp thành những đoạn nhỏ dần, tính sẵn đáp án cho từng đoạn, rồi ghép vài đoạn lại thành đáp án cho đoạn được hỏi. Mỗi phần tử chỉ nằm trong khoảng log⁡2n\log_2 n đoạn như vậy, nên khi một phần tử thay đổi, ta chỉ cần tính lại chừng ấy đoạn. Thông tin tính sẵn có thể là số nhỏ nhất, tổng, ước chung lớn nhất, hay phức tạp hơn; và với kỹ thuật thẻ lười, ta còn cộng hay gán được cả một đoạn trong O(log⁡n)O(\log n).

IHỏi min đoạn trên dãy bị sửa

Bài toán: cho dãy a1,a2,…,ana_1, a_2, \dots, a_n và qq thao tác. Mỗi thao tác thuộc một trong hai loại: gán aka_k bằng một giá trị mới, hoặc hỏi số nhỏ nhất trong đoạn [l,r][l, r]. Từ đây ta viết tắt số nhỏ nhất là min.

Cách dễ nghĩ ra nhất là gán trực tiếp, còn mỗi câu hỏi thì duyệt cả đoạn:

C++
if (t == 1) a[k] = x;
else {
    ll res = 1e18;
    for (ll i = l; i <= r; i++) res = min(res, a[i]);
    cout << res << '\n';
}

Độ phức tạp: gán O(1)O(1), mỗi câu hỏi O(n)O(n); với n=q=2⋅105n = q = 2 \cdot 10^5 cần khoảng 4⋅10104 \cdot 10^{10} phép tính.

Tổng tiền tố (bài 0.3) không giúp được ở đây vì hai lý do: phép min không có phép ngược để gỡ phần đầu ra, và mỗi lần gán một phần tử làm sai mọi tiền tố đứng sau nó. Ta có thể thấy được điểm lãng phí của cách duyệt: các câu hỏi khác nhau thường chồng lên nhau, nhưng câu hỏi nào cũng duyệt lại từ đầu. Ta muốn tính sẵn min của một số đoạn, đủ ít để khi một phần tử đổi chỉ phải sửa vài đoạn, nhưng cũng đủ nhiều để ghép được thành mọi đoạn được hỏi.

Cách chọn các đoạn là chia đôi. Đoạn [l,r][l, r] với l<rl < r được tách tại mid=⌊l+r2⌋mid = \lfloor \frac{l + r}{2} \rfloor thành hai nửa [l,mid][l, mid] và [mid+1,r][mid + 1, r]; mỗi nửa lại được tách tiếp như vậy, cho tới khi đoạn chỉ còn một phần tử. Các đoạn này xếp thành một cây:

  • mỗi đoạn là một nút của cây, và ta nói nút đó quản lý đoạn đó;
  • nút quản lý cả dãy [1,n][1, n] là gốc;
  • hai nửa của một đoạn là hai nút con của nút quản lý đoạn đó, gọi là con trái và con phải;
  • nút quản lý đúng một phần tử là lá.

Mỗi nút lưu min của đoạn nó quản lý. Cây như vậy được gọi là cây phân đoạn. Hình dưới là cây của dãy 5,2,7,3,8,1,6,45, 2, 7, 3, 8, 1, 6, 4. Nút cam nằm trọn trong đoạn hỏi, nút xanh cắt ngang biên của đoạn hỏi, nút mờ nằm ngoài đoạn hỏi. Bạn có thể đổi đoạn hỏi, hoặc gán một phần tử rồi bấm chạy:

Truy vấn đoạn và cập nhật điểm.

Hỏi min đoạn [2,7][2, 7]. Ta lấy các nút lớn nhất nằm trọn trong đoạn hỏi: [2,2][2, 2], [3,4][3, 4], [5,6][5, 6] và [7,7][7, 7]. Bốn đoạn này rời nhau và ghép lại đúng bằng [2,7][2, 7], nên đáp án là min⁡(2,3,1,6)=1\min(2, 3, 1, 6) = 1.

Gán một phần tử. Gán a6=9a_6 = 9 chỉ làm thay đổi các nút có đoạn chứa vị trí 66. Đó là các nút trên đường đi từ lá [6,6][6, 6] lên gốc: [6,6][6, 6], [5,6][5, 6], [5,8][5, 8], [1,8][1, 8]. Ta tính lại các nút này từ dưới lên, mỗi nút bằng min của hai con; mọi nút khác giữ nguyên.

Để cài đặt, ta đánh số các nút: gốc là nút 11, hai con của nút ii là nút 2i2i và nút 2i+12i + 1. Min của nút ii lưu ở ô thứ ii của một mảng. Chương trình gồm ba hàm; cả ba đều đi từ gốc xuống, và nhận vào số hiệu của nút cùng đoạn mà nút đó quản lý:

  • Dựng cây: ở lá, min là chính phần tử đó; ở nút khác, dựng hai con trước rồi lấy min của hai con.
  • Gán một phần tử: đi xuống đúng con chứa vị trí cần gán, gán ở lá, rồi trên đường quay lên tính lại min của mỗi nút từ hai con.
  • Hỏi đoạn: có ba trường hợp. Nút nằm ngoài đoạn hỏi thì trả về một số rất lớn, 101810^{18}, để không ảnh hưởng tới phép min. Nút nằm trọn trong đoạn hỏi thì trả về min đã lưu và dừng. Còn lại thì hỏi cả hai con rồi lấy min.

Với n=2⋅105n = 2 \cdot 10^5, cây chỉ có khoảng 1919 tầng, nên gán một phần tử chỉ ghé khoảng 1919 nút. Với câu hỏi, chỉ những nút cắt ngang biên của đoạn hỏi mới đi tiếp xuống con, mà mỗi tầng có không quá hai nút như vậy; vì thế một câu hỏi ghé không quá bốn nút mỗi tầng (xem ô Chứng minh).

Chứng minh

Số tầng. Mỗi lần chia đôi, độ dài đoạn giảm còn không quá một nửa (làm tròn lên), nên cây có không quá ⌈log⁡2n⌉+1\lceil \log_2 n \rceil + 1 tầng; với n=2⋅105n = 2 \cdot 10^5 là 1919 tầng.

Mỗi tầng có không quá hai nút cắt. Gọi một nút là nút cắt nếu đoạn của nó có cả phần tử nằm trong lẫn phần tử nằm ngoài đoạn hỏi. Đoạn của nút là một đoạn liên tiếp, nên nút cắt phải chứa biên trái hoặc biên phải của đoạn hỏi. Các nút cùng một tầng quản lý các đoạn rời nhau, nên mỗi biên nằm trong không quá một nút của tầng đó. Vậy mỗi tầng có không quá hai nút cắt.

Mỗi tầng được ghé không quá bốn nút. Chỉ nút cắt mới gọi xuống hai con; nút nằm trọn hay nằm ngoài đoạn hỏi đều dừng ngay. Hai nút cắt của một tầng sinh ra không quá bốn nút được ghé ở tầng dưới. Vậy một câu hỏi ghé không quá 4⋅19=764 \cdot 19 = 76 nút khi n=2⋅105n = 2 \cdot 10^5.

Còn một chi tiết: mảng lưu cây cần bao nhiêu ô? Cây có đúng 2n−12n - 1 nút, nhưng số hiệu của các nút không liền nhau.

Hỏi nhanh

Đánh số các nút như trên: gốc là 11, hai con của nút ii là 2i2i và 2i+12i + 1, đoạn [l,r][l, r] tách tại mid=⌊l+r2⌋mid = \lfloor \frac{l + r}{2} \rfloor. Với n=6n = 6, cây có 2n−1=112n - 1 = 11 nút.

Số hiệu lớn nhất của một nút trong cây này là bao nhiêu?

Vì vậy mảng 2n2n ô có thể không đủ chỗ. Mảng 4n4n ô thì luôn đủ (xem ô Chứng minh).

Chứng minh

Gọi h=⌈log⁡2n⌉h = \lceil \log_2 n \rceil. Cây có không quá h+1h + 1 tầng, và nút ở tầng thứ dd (gốc ở tầng 00) có số hiệu nhỏ hơn 2d+12^{d+1}. Vậy mọi số hiệu đều nhỏ hơn 2h+12^{h+1}. Vì 2h−1<n2^{h-1} < n nên 2h+1<4n2^{h+1} < 4n.

Ví dụGiá Trị Thấp Nhất

Đề. Cho dãy n≤2⋅105n \le 2 \cdot 10^5 số và q≤2⋅105q \le 2 \cdot 10^5 yêu cầu, mỗi yêu cầu có một trong hai dạng: 1 k u gán ô thứ kk bằng uu; 2 l r in số nhỏ nhất trong các ô từ ll đến rr.

Lời giải. Đây đúng là bài toán của mục này. Trong code, mảng st lưu min của các nút; ba hàm build, update, get lần lượt là dựng cây, gán một phần tử và hỏi đoạn; mỗi hàm nhận số hiệu nút id cùng đoạn [l,r][l, r] mà nút đó quản lý. Với ví dụ của đề, cả dãy 7,2,9,2,67, 2, 9, 2, 6 có min là 22; sau khi gán a2=8a_2 = 8, đoạn [1,3][1, 3] có min là 77.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, q, a[N], st[4 * N];

void build(ll id, ll l, ll r) {
    if (l == r) {
        st[id] = a[l];
        return;
    }
    ll mid = (l + r) >> 1;
    build(id * 2, l, mid);
    build(id * 2 + 1, mid + 1, r);
    st[id] = min(st[id * 2], st[id * 2 + 1]);
}

void update(ll id, ll l, ll r, ll pos, ll val) {
    if (l == r) {
        st[id] = val;
        return;
    }
    ll mid = (l + r) >> 1;
    if (pos <= mid) update(id * 2, l, mid, pos, val);
    else update(id * 2 + 1, mid + 1, r, pos, val);
    st[id] = min(st[id * 2], st[id * 2 + 1]);
}

ll get(ll id, ll l, ll r, ll u, ll v) {
    if (v < l || r < u) return 1e18;
    if (u <= l && r <= v) return st[id];
    ll mid = (l + r) >> 1;
    return min(get(id * 2, l, mid, u, v), get(id * 2 + 1, mid + 1, r, u, v));
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> q;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    build(1, 1, n);
    while (q--) {
        ll t, x, y;
        cin >> t >> x >> y;
        if (t == 1) update(1, 1, n, x, y);
        else cout << get(1, 1, n, x, y) << '\n';
    }
}
5 6
7 2 9 2 6
2 1 5
1 2 8
2 1 3
1 4 10
2 3 5
2 4 4

Độ phức tạp: dựng cây O(n)O(n), mỗi yêu cầu O(log⁡n)O(\log n); với n=q=2⋅105n = q = 2 \cdot 10^5 cần khoảng 1,5⋅1071{,}5 \cdot 10^7 lần ghé nút.

IILưu thông tin khác ở mỗi nút

Nhìn lại chương trình ở trên: phép min chỉ xuất hiện ở ba chỗ, là cách tính một nút từ hai con, giá trị của lá, và giá trị 101810^{18} trả về cho nút nằm ngoài đoạn hỏi. Mọi phần còn lại chỉ nói về hình dạng của cây. Thay phép min bằng phép cộng và thay 101810^{18} bằng 00, ta có cây cho tổng đoạn; thay bằng phép lấy ước chung lớn nhất và 00, ta có cây cho ước chung lớn nhất; tương tự cho max, XOR, hay đếm số phần tử thỏa một điều kiện.

Tổng quát, mỗi nút lưu một thông tin về đoạn nó quản lý, và cần có hai điều:

  • Thông tin của một đoạn tính được từ thông tin của hai nửa, bằng một hàm mà ta gọi là phép gộp.
  • Có một giá trị dành cho “đoạn rỗng”, gộp với nó thì không thay đổi gì, gọi là phần tử trung hòa. Hàm hỏi đoạn trả về phần tử trung hòa cho nút nằm ngoài đoạn hỏi.

Khi có đủ hai điều này, ba hàm của cây giữ nguyên, chỉ cần thay phép min bằng phép gộp.

Không phải thông tin nào cũng gộp được. Điều kiện thứ nhất đòi thông tin của nút cha phải tính được chỉ từ thông tin đã lưu ở hai nút con. Khi một đại lượng không gộp được một mình, cách thường dùng là lưu kèm vài đại lượng phụ, như ví dụ cuối mục này.

Hỏi nhanh

Mỗi đoạn chỉ được lưu đúng một con số, và con số của nút cha phải tính được chỉ từ con số của hai nút con. Trung vị của một đoạn là phần tử đứng giữa sau khi sắp xếp đoạn; nếu đoạn có số phần tử chẵn thì lấy phần tử nhỏ hơn trong hai phần tử đứng giữa.

Ba trong bốn con số dưới đây gộp được như vậy. Con số nào là ngoại lệ?

Cần chú ý thêm về thứ tự: phép gộp không nhất thiết phải giao hoán, tức là gộp nửa trái với nửa phải có thể cho kết quả khác gộp nửa phải với nửa trái. Vì vậy ta luôn gộp theo đúng thứ tự vị trí: nửa trái trước, nửa phải sau. Với min, tổng hay ước chung lớn nhất, thứ tự không ảnh hưởng; nhưng với ví dụ ngay dưới đây, đảo thứ tự sẽ cho kết quả sai.

Ví dụTổng Đoạn Liên Tiếp

Đề. Cho dãy n≤2⋅105n \le 2 \cdot 10^5 số với ∣ai∣≤109|a_i| \le 10^9, và m≤2⋅105m \le 2 \cdot 10^5 lần gán ak=xa_k = x. Sau mỗi lần gán, in tổng lớn nhất của một đoạn con liên tiếp của cả dãy; được chọn đoạn rỗng, có tổng 00.

Lời giải. Nếu mỗi nút chỉ lưu tổng lớn nhất của một đoạn con, ta chưa gộp được: đoạn con tốt nhất của đoạn ghép có thể vắt qua chỗ nối hai nửa, và khi đó nó gồm một đoạn ở cuối nửa trái nối với một đoạn ở đầu nửa phải. Vì vậy mỗi nút lưu bốn số:

  • sum\mathrm{sum}: tổng cả đoạn;
  • pre\mathrm{pre}: tổng lớn nhất của một đoạn ở đầu (tiền tố), được chọn đoạn rỗng;
  • suf\mathrm{suf}: tổng lớn nhất của một đoạn ở cuối (hậu tố), được chọn đoạn rỗng;
  • best\mathrm{best}: tổng lớn nhất của một đoạn con, được chọn đoạn rỗng.

Gộp nửa trái AA với nửa phải BB:

pre=max⁡(A.pre, A.sum+B.pre),suf=max⁡(B.suf, B.sum+A.suf),\mathrm{pre} = \max(A.\mathrm{pre},\ A.\mathrm{sum} + B.\mathrm{pre}), \qquad \mathrm{suf} = \max(B.\mathrm{suf},\ B.\mathrm{sum} + A.\mathrm{suf}), best=max⁡(A.best, B.best, A.suf+B.pre).\mathrm{best} = \max(A.\mathrm{best},\ B.\mathrm{best},\ A.\mathrm{suf} + B.\mathrm{pre}).

Lá có giá trị xx thì có tổng xx, còn ba số kia bằng max⁡(0,x)\max(0, x): hoặc lấy chính phần tử đó, hoặc lấy đoạn rỗng. Bài chỉ hỏi cả dãy, nên sau mỗi lần gán, đáp án là best\mathrm{best} của gốc, không cần hàm hỏi đoạn.

Trong hình, mỗi nút giữ bốn số; bạn có thể đổi hai nửa để xem cách gộp:

Gộp hai nút bốn số.
Chứng minh

Một tiền tố của đoạn ghép hoặc nằm gọn trong nửa trái, hoặc gồm cả nửa trái nối với một tiền tố của nửa phải; lấy giá trị lớn hơn của hai trường hợp, ta được công thức của pre\mathrm{pre}. Hậu tố làm đối xứng.

Một đoạn con của đoạn ghép hoặc nằm gọn trong nửa trái, hoặc nằm gọn trong nửa phải, hoặc vắt qua chỗ nối. Trường hợp cuối gồm một hậu tố của nửa trái và một tiền tố của nửa phải, nên tổng lớn nhất của nó là A.suf+B.preA.\mathrm{suf} + B.\mathrm{pre}.

Phép gộp này không giao hoán: gộp 2,−12, -1 với 4,−54, -5 theo đúng thứ tự cho best=5\mathrm{best} = 5 (đoạn 2,−1,42, -1, 4), còn đổi chỗ thì ra 44. Tổng có thể tới 2⋅10142 \cdot 10^{14}, nên các số dùng kiểu long long.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

struct Node {
    ll sum, pre, suf, best;
};

const ll N = 2e5 + 5;
ll n, m, a[N];
Node st[4 * N];

Node leaf(ll x) {
    return {x, max(0LL, x), max(0LL, x), max(0LL, x)};
}

Node combine(Node a, Node b) {
    return {a.sum + b.sum, max(a.pre, a.sum + b.pre), max(b.suf, b.sum + a.suf),
            max({a.best, b.best, a.suf + b.pre})};
}

void build(ll id, ll l, ll r) {
    if (l == r) {
        st[id] = leaf(a[l]);
        return;
    }
    ll mid = (l + r) >> 1;
    build(id * 2, l, mid);
    build(id * 2 + 1, mid + 1, r);
    st[id] = combine(st[id * 2], st[id * 2 + 1]);
}

void update(ll id, ll l, ll r, ll pos, ll val) {
    if (l == r) {
        st[id] = leaf(val);
        return;
    }
    ll mid = (l + r) >> 1;
    if (pos <= mid) update(id * 2, l, mid, pos, val);
    else update(id * 2 + 1, mid + 1, r, pos, val);
    st[id] = combine(st[id * 2], st[id * 2 + 1]);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    build(1, 1, n);
    while (m--) {
        ll k, x;
        cin >> k >> x;
        update(1, 1, n, k, x);
        cout << st[1].best << '\n';
    }
}
5 4
-3 2 -1 4 -5
1 -2
5 3
2 -6
4 -8

Độ phức tạp: dựng cây O(n)O(n), mỗi lần gán O(log⁡n)O(\log n); với n=m=2⋅105n = m = 2 \cdot 10^5 cần khoảng 4⋅1064 \cdot 10^6 lần gộp nút.

IIITìm vị trí bằng cách đi xuống cây

Có những bài không hỏi giá trị trên một đoạn mà hỏi một vị trí. Bài toán: cho dãy bit b1,b2,…,bnb_1, b_2, \dots, b_n, mỗi bib_i là 00 hoặc 11, và các thao tác xen kẽ nhau: đảo một bit, hoặc hỏi bit 11 thứ kk tính từ trái nằm ở vị trí nào.

Ta có thể dùng cây lưu tổng, tức là số bit 11 của mỗi đoạn, rồi chặt nhị phân (bài 0.6) trên vị trí: tìm vị trí pp nhỏ nhất mà đoạn [1,p][1, p] có ít nhất kk bit 11. Mỗi bước chặt cần một câu hỏi tốn O(log⁡n)O(\log n), nên một lần tìm tốn O(log⁡2n)O(\log^2 n). Cây phân đoạn cho phép làm nhanh hơn, bằng cách đi thẳng từ gốc xuống một lá.

Mỗi nút lưu số bit 11 trong đoạn của nó. Ta đi từ gốc xuống và luôn giữ điều sau: đáp án là bit 11 thứ kk nằm trong đoạn của nút đang đứng. Tại một nút, nếu con trái có ít nhất kk bit 11 thì đáp án nằm trong con trái; ta đi sang trái và giữ nguyên kk. Ngược lại, đáp án nằm trong con phải; ta bỏ qua mọi bit 11 của con trái, nên trừ số bit 11 của con trái khỏi kk rồi đi sang phải. Mỗi bước xuống một tầng, và lá cuối cùng chính là đáp án. Trước khi đi, cần kiểm tra gốc có ít nhất kk bit 11; nếu không thì không có đáp án.

Trong hình, mỗi nút ghi số bit 11 trong đoạn của nó; đường cam là đường đi xuống:

Đi xuống tìm bit 1 thứ k.
C++
ll kth(ll id, ll l, ll r, ll k) {
    if (l == r) return l;
    ll mid = (l + r) >> 1;
    if (st[id * 2] >= k) return kth(id * 2, l, mid, k);
    return kth(id * 2 + 1, mid + 1, r, k - st[id * 2]);
}

Độ phức tạp: O(log⁡n)O(\log n), với n=2⋅105n = 2 \cdot 10^5 cần khoảng 1919 bước.

Lỗi hay gặp là quên trừ số bit 11 của con trái khi đi sang phải: khi đó kk vẫn tính cả những bit 11 đã bỏ qua, và đường đi lạc sang nhánh sai.

Cùng cách đi xuống đó dùng được cho câu hỏi “vị trí đầu tiên có ai≥xa_i \ge x” trên cây lưu max: nếu max của con trái ít nhất xx thì đi sang trái, ngược lại đi sang phải. Ví dụ dưới đây làm đúng như vậy.

Sử dụng kiến thức này cũng có thể giải bài: tìm phần tử nhỏ thứ kk của một tập số đang được thêm và xóa. Cho chỉ số của cây là giá trị, mỗi lá đếm số lần giá trị đó có trong tập; phần tử nhỏ thứ kk chính là vị trí của “bit 11 thứ kk” theo cách đi xuống ở trên.

Ví dụPhân Bổ Phòng

Đề. Có n≤2⋅105n \le 2 \cdot 10^5 khách sạn, khách sạn thứ ii còn hih_i phòng trống. Lần lượt m≤2⋅105m \le 2 \cdot 10^5 đoàn khách đến, đoàn thứ jj cần rjr_j phòng và được xếp vào khách sạn có chỉ số nhỏ nhất còn ít nhất rjr_j phòng trống; khách sạn đó bớt đi rjr_j phòng. Nếu không có khách sạn nào đủ chỗ, in 00.

Lời giải. Cây lưu max số phòng trống của mỗi đoạn khách sạn. Nếu max ở gốc nhỏ hơn rjr_j, không khách sạn nào đủ chỗ, ta in 00. Ngược lại, ta đi xuống từ gốc: khách sạn đầu tiên đủ chỗ nằm bên trái khi và chỉ khi bên trái có ít nhất một khách sạn đủ chỗ, tức là max của con trái ít nhất rjr_j. Tới lá là đúng khách sạn cần tìm. Hàm walk trong code vừa tìm vừa sửa trong một lượt: tới lá thì trừ đi rjr_j phòng, và trên đường quay lên tính lại max của mỗi nút. Với ví dụ của đề, đoàn thứ hai cần 33 phòng được xếp vào khách sạn 33, vì lúc đó khách sạn 11 chỉ còn 11 phòng.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, m, h[N], st[4 * N];

void build(ll id, ll l, ll r) {
    if (l == r) {
        st[id] = h[l];
        return;
    }
    ll mid = (l + r) >> 1;
    build(id * 2, l, mid);
    build(id * 2 + 1, mid + 1, r);
    st[id] = max(st[id * 2], st[id * 2 + 1]);
}

ll walk(ll id, ll l, ll r, ll x) {
    if (l == r) {
        st[id] -= x;
        return l;
    }
    ll mid = (l + r) >> 1, res;
    if (st[id * 2] >= x) res = walk(id * 2, l, mid, x);
    else res = walk(id * 2 + 1, mid + 1, r, x);
    st[id] = max(st[id * 2], st[id * 2 + 1]);
    return res;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    for (ll i = 1; i <= n; i++) cin >> h[i];
    build(1, 1, n);
    while (m--) {
        ll x;
        cin >> x;
        cout << (st[1] < x ? 0 : walk(1, 1, n, x)) << ' ';
    }
    cout << '\n';
}
4 5
3 1 4 2
2 3 1 4 2

Độ phức tạp: dựng cây O(n)O(n), mỗi đoàn đi một đường từ gốc xuống lá tốn O(log⁡n)O(\log n); với n=m=2⋅105n = m = 2 \cdot 10^5 cần khoảng 4⋅1064 \cdot 10^6 lần ghé nút.

IVCộng cả đoạn: thẻ lười

Bài toán: cho dãy a1,a2,…,ana_1, a_2, \dots, a_n và các thao tác xen kẽ nhau: cộng xx vào mọi phần tử của đoạn [l,r][l, r], hoặc hỏi tổng của đoạn [l,r][l, r].

Nếu cộng xx bằng cách gán lại từng vị trí trong đoạn, một thao tác có thể tốn O(nlog⁡n)O(n \log n). Ta có thể thấy được điểm lãng phí: câu hỏi tổng đoạn chỉ đọc các nút lớn nhất nằm trọn trong đoạn hỏi, chứ không cần biết từng lá. Vì vậy thao tác cộng cũng chỉ nên sửa đúng những nút nằm trọn trong đoạn cần cộng, giống cách câu hỏi tìm các nút đó.

Với một nút nằm trọn trong đoạn cần cộng, tổng mới của nút tính được ngay: tổng cũ cộng xx nhân với độ dài đoạn. Còn các nút bên dưới thì ta chưa sửa vội, mà chỉ ghi lại ở nút đó: “mọi phần tử trong đoạn này cần cộng thêm xx, nhưng hai nút con chưa được cộng”. Ghi chú này được gọi là thẻ lười (lazy tag). Chỉ khi một thao tác sau cần đi xuống các con của nút này, ta mới đẩy thẻ xuống: sửa tổng của hai con, chuyển thẻ thành thẻ của hai con, rồi xóa thẻ ở nút. Kỹ thuật này được gọi là cập nhật lười (lazy propagation).

Hai quy tắc dưới đây dùng cho cả thao tác cộng lẫn câu hỏi:

  • Gặp nút nằm trọn trong đoạn: với thao tác cộng, sửa tổng của nút và cộng xx vào thẻ của nút; rồi dừng.
  • Gặp nút cắt ngang biên: đẩy thẻ của nút xuống hai con trước khi đi xuống. Với thao tác cộng, khi quay lên thì tính lại tổng của nút từ hai con.

Trong hình, nhãn +k+k màu xanh lá là thẻ chưa được đẩy xuống. Bạn có thể cộng thêm vào một đoạn, hoặc hỏi tổng một đoạn nhỏ để xem thẻ được đẩy:

Cập nhật đoạn bằng thẻ lười.

Ta chạy tay trên dãy 5,2,7,3,8,1,6,45, 2, 7, 3, 8, 1, 6, 4 với thao tác cộng 33 vào đoạn [2,7][2, 7]. Hai nút [3,4][3, 4] và [5,6][5, 6] nằm trọn trong đoạn nên nhận thẻ +3+3, tổng thành 1616 và 1515. Hai lá [2,2][2, 2] và [7,7][7, 7] cũng nằm trọn trong đoạn, giá trị thành 55 và 99. Các nút cắt ngang biên được tính lại từ hai con, và gốc thành 54=36+6⋅354 = 36 + 6 \cdot 3. Bốn lá từ [3,3][3, 3] tới [6,6][6, 6] chưa hề bị sửa. Nếu sau đó hỏi tổng đoạn [3,3][3, 3], nút [3,4][3, 4] cắt ngang biên nên thẻ +3+3 được đẩy xuống, và lá [3,3][3, 3] trả về 7+3=107 + 3 = 10.

Trong code, mảng st lưu tổng của các nút, mảng lz lưu thẻ. Hàm apply đặt thẻ xx lên một nút có độ dài lenlen: tổng tăng x⋅lenx \cdot len, thẻ tăng xx. Hàm down đẩy thẻ của một nút xuống hai con rồi xóa thẻ đó.

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, q, a[N], st[4 * N], lz[4 * N];

void build(ll id, ll l, ll r) {
    if (l == r) {
        st[id] = a[l];
        return;
    }
    ll mid = (l + r) >> 1;
    build(id * 2, l, mid);
    build(id * 2 + 1, mid + 1, r);
    st[id] = st[id * 2] + st[id * 2 + 1];
}

void apply(ll id, ll len, ll x) {
    st[id] += x * len;
    lz[id] += x;
}

void down(ll id, ll l, ll r) {
    if (!lz[id]) return;
    ll mid = (l + r) >> 1;
    apply(id * 2, mid - l + 1, lz[id]);
    apply(id * 2 + 1, r - mid, lz[id]);
    lz[id] = 0;
}

void update(ll id, ll l, ll r, ll u, ll v, ll x) {
    if (v < l || r < u) return;
    if (u <= l && r <= v) {
        apply(id, r - l + 1, x);
        return;
    }
    down(id, l, r);
    ll mid = (l + r) >> 1;
    update(id * 2, l, mid, u, v, x);
    update(id * 2 + 1, mid + 1, r, u, v, x);
    st[id] = st[id * 2] + st[id * 2 + 1];
}

ll get(ll id, ll l, ll r, ll u, ll v) {
    if (v < l || r < u) return 0;
    if (u <= l && r <= v) return st[id];
    down(id, l, r);
    ll mid = (l + r) >> 1;
    return get(id * 2, l, mid, u, v) + get(id * 2 + 1, mid + 1, r, u, v);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> q;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    build(1, 1, n);
    while (q--) {
        ll t, l, r;
        cin >> t >> l >> r;
        if (t == 1) {
            ll x;
            cin >> x;
            update(1, 1, n, l, r, x);
        } else cout << get(1, 1, n, l, r) << '\n';
    }
}
8 5
5 2 7 3 8 1 6 4
2 1 8
1 2 7 3
2 1 8
2 3 4
2 5 8

Độ phức tạp: dựng cây O(n)O(n), mỗi thao tác O(log⁡n)O(\log n); với n=q=2⋅105n = q = 2 \cdot 10^5 cần khoảng 1,5⋅1071{,}5 \cdot 10^7 lần ghé nút.

Chứng minh

Khi một hàm đi tới một nút, mọi nút tổ tiên trên đường đi đều đã được đẩy thẻ xuống, vì hàm chỉ đi từ một nút xuống con sau khi gọi down ở nút đó. Vậy mọi phần cộng của các thao tác trước đều đã được tính vào tổng của nút này, và tổng lưu ở nút là tổng đúng. Ngoài ra, khi đặt thẻ lên một nút, tổng của chính nút đó được sửa ngay, nên một câu hỏi dừng ở nút đó cũng đọc được tổng đúng.

Các nút được ghé giống hệt câu hỏi ở mục đầu, và mỗi lần đẩy thẻ chỉ tốn O(1)O(1), nên mỗi thao tác vẫn tốn O(log⁡n)O(\log n).

Hỏi nhanh

Một bạn cài thẻ lười như trên, nhưng quên gọi down trong hàm hỏi đoạn. Bạn có thể bấm Chạy để xem chương trình in gì với dãy 1,2,31, 2, 3, thao tác cộng 55 vào đoạn [1,2][1, 2], rồi hỏi tổng đoạn [1,1][1, 1]:

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, q, a[N], st[4 * N], lz[4 * N];

void build(ll id, ll l, ll r) {
    if (l == r) {
        st[id] = a[l];
        return;
    }
    ll mid = (l + r) >> 1;
    build(id * 2, l, mid);
    build(id * 2 + 1, mid + 1, r);
    st[id] = st[id * 2] + st[id * 2 + 1];
}

void apply(ll id, ll len, ll x) {
    st[id] += x * len;
    lz[id] += x;
}

void down(ll id, ll l, ll r) {
    if (!lz[id]) return;
    ll mid = (l + r) >> 1;
    apply(id * 2, mid - l + 1, lz[id]);
    apply(id * 2 + 1, r - mid, lz[id]);
    lz[id] = 0;
}

void update(ll id, ll l, ll r, ll u, ll v, ll x) {
    if (v < l || r < u) return;
    if (u <= l && r <= v) {
        apply(id, r - l + 1, x);
        return;
    }
    down(id, l, r);
    ll mid = (l + r) >> 1;
    update(id * 2, l, mid, u, v, x);
    update(id * 2 + 1, mid + 1, r, u, v, x);
    st[id] = st[id * 2] + st[id * 2 + 1];
}

ll get(ll id, ll l, ll r, ll u, ll v) {
    if (v < l || r < u) return 0;
    if (u <= l && r <= v) return st[id];
    ll mid = (l + r) >> 1;
    return get(id * 2, l, mid, u, v) + get(id * 2 + 1, mid + 1, r, u, v);
}

signed main() {
    cin >> n >> q;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    build(1, 1, n);
    while (q--) {
        ll t, l, r;
        cin >> t >> l >> r;
        if (t == 1) {
            ll x;
            cin >> x;
            update(1, 1, n, l, r, x);
        } else cout << get(1, 1, n, l, r) << '\n';
    }
}
3 2
1 2 3
1 1 2 5
2 1 1

Đáp án đúng là 1+5=61 + 5 = 6, nhưng chương trình in ra 11. Vì sao?

VCộng và gán cùng lúc: hợp thẻ

Khi có từ hai loại cập nhật đoạn trở lên, thứ tự của chúng bắt đầu quan trọng. “Cộng 55 rồi gán 22” cho mọi phần tử bằng 22, còn “gán 22 rồi cộng 55” cho mọi phần tử bằng 77. Thẻ của một nút phải nhớ đúng tác dụng của cả chuỗi thao tác chưa được đẩy xuống, theo đúng thứ tự thời gian.

Cách gọn nhất là coi thẻ là một hàm bậc nhất f(x)=bx+cf(x) = b x + c, áp lên từng phần tử của đoạn. Cộng yy là thẻ (b,c)=(1,y)(b, c) = (1, y); gán yy là thẻ (0,y)(0, y); nhân yy là thẻ (y,0)(y, 0); thẻ “không làm gì” là (1,0)(1, 0). Ta cần hai phép tính:

  • Áp thẻ lên một nút có độ dài lenlen và tổng ss: tổng của các giá trị bxi+cb x_i + c trên lenlen phần tử là bs+c⋅lenb s + c \cdot len.
  • Hợp hai thẻ. Thẻ g=(bg,cg)g = (b_g, c_g) đến sau thẻ f=(bf,cf)f = (b_f, c_f) cho g(f(x))=bg(bfx+cf)+cgg(f(x)) = b_g (b_f x + c_f) + c_g, tức là thẻ (bgbf, bgcf+cg)(b_g b_f,\ b_g c_f + c_g).

Kiểm tra lại với ví dụ trên: “cộng 55 rồi gán 22” là thẻ (0,2)(0, 2) hợp sau thẻ (1,5)(1, 5), cho (0⋅1, 0⋅5+2)=(0,2)(0 \cdot 1,\ 0 \cdot 5 + 2) = (0, 2); “gán 22 rồi cộng 55” là thẻ (1,5)(1, 5) hợp sau thẻ (0,2)(0, 2), cho (1⋅0, 1⋅2+5)=(0,7)(1 \cdot 0,\ 1 \cdot 2 + 5) = (0, 7). Cả hai đều khớp với phép tính tay.

Khi đẩy thẻ từ cha xuống con, thẻ của cha luôn đến sau thẻ đang có ở con (xem ô Chứng minh), nên thẻ mới của con là thẻ của cha hợp sau thẻ của con.

Chứng minh

Một thẻ chỉ được đặt lên một nút khi thao tác đi tới nút đó. Muốn đi tới nút đó, mọi tổ tiên của nó đã phải đẩy thẻ xuống, nên lúc đó các tổ tiên không còn thẻ. Vì vậy mọi thẻ đang nằm ở tổ tiên đều được đặt sau thẻ đang nằm ở con, tức là đến sau theo thời gian.

Câu hỏi nhanh dành cho tài khoản Pro

Mở khóa bằng tài khoản Pro

Cần cẩn thận: không phải loại thẻ nào cũng dùng được với mọi thông tin lưu ở nút. Thẻ phải áp được lên một nút chỉ từ thông tin đã lưu ở nút đó và độ dài đoạn. Chẳng hạn, cộng cc vào mọi phần tử thì tổng mới tính được ngay; nhưng tổng lớn nhất của một đoạn con thì không, vì đoạn con tốt nhất có thể thay đổi tùy theo cc.

Sử dụng kiến thức này cũng có thể giải bài: nhân cả đoạn với một số, cộng một số vào cả đoạn, và hỏi tổng đoạn theo modulo. Thẻ vẫn là cặp (b,c)(b, c) như trên; chỉ cần lấy modulo sau mọi phép nhân và phép cộng.

Ví dụCộng, Gán Và Lấy Tổng

Đề. Cho mảng N≤2⋅105N \le 2 \cdot 10^5 số và Q≤2⋅105Q \le 2 \cdot 10^5 thao tác trên đoạn [L,R][L, R]: 1 L R X cộng XX vào mọi phần tử; 2 L R X gán mọi phần tử bằng XX; 3 L R in tổng. Mọi AiA_i và XX nằm trong khoảng từ 11 tới 10610^6.

Lời giải. Thẻ là cặp (b,c)(b, c) như trên, nghĩa là “mỗi phần tử xx của đoạn thành bx+cb x + c”, ban đầu là (1,0)(1, 0). Cộng XX là thẻ (1,X)(1, X), gán XX là thẻ (0,X)(0, X). Ta không cần xét riêng nút đang có thẻ gán hay thẻ cộng, vì mọi trường hợp đã nằm trong công thức hợp thẻ. Trong code, hai mảng mul và ad lưu hai thành phần bb và cc của thẻ. Cần chú ý: hàm dựng cây phải đặt mul bằng 11 cho mọi nút, vì mảng toàn cục ban đầu toàn số 00, mà thẻ (0,0)(0, 0) lại có nghĩa là “gán bằng 00”.

Một phần tử có thể tới khoảng 2⋅10112 \cdot 10^{11} (được cộng 10610^6 tới 2⋅1052 \cdot 10^5 lần), tổng cả mảng tới khoảng 4⋅10164 \cdot 10^{16}, vẫn vừa kiểu long long. Với ví dụ của đề, sau khi cộng 33 vào đoạn [2,4][2, 4] rồi gán 22 cho đoạn [3,5][3, 5], mảng là 1,5,2,2,21, 5, 2, 2, 2, có tổng 1212.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, q, a[N], st[4 * N], mul[4 * N], ad[4 * N];

void build(ll id, ll l, ll r) {
    mul[id] = 1;
    if (l == r) {
        st[id] = a[l];
        return;
    }
    ll mid = (l + r) >> 1;
    build(id * 2, l, mid);
    build(id * 2 + 1, mid + 1, r);
    st[id] = st[id * 2] + st[id * 2 + 1];
}

void apply(ll id, ll len, ll b, ll c) {
    st[id] = b * st[id] + c * len;
    mul[id] = b * mul[id];
    ad[id] = b * ad[id] + c;
}

void down(ll id, ll l, ll r) {
    if (mul[id] == 1 && ad[id] == 0) return;
    ll mid = (l + r) >> 1;
    apply(id * 2, mid - l + 1, mul[id], ad[id]);
    apply(id * 2 + 1, r - mid, mul[id], ad[id]);
    mul[id] = 1;
    ad[id] = 0;
}

void update(ll id, ll l, ll r, ll u, ll v, ll b, ll c) {
    if (v < l || r < u) return;
    if (u <= l && r <= v) {
        apply(id, r - l + 1, b, c);
        return;
    }
    down(id, l, r);
    ll mid = (l + r) >> 1;
    update(id * 2, l, mid, u, v, b, c);
    update(id * 2 + 1, mid + 1, r, u, v, b, c);
    st[id] = st[id * 2] + st[id * 2 + 1];
}

ll get(ll id, ll l, ll r, ll u, ll v) {
    if (v < l || r < u) return 0;
    if (u <= l && r <= v) return st[id];
    down(id, l, r);
    ll mid = (l + r) >> 1;
    return get(id * 2, l, mid, u, v) + get(id * 2 + 1, mid + 1, r, u, v);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> q;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    build(1, 1, n);
    while (q--) {
        ll t, l, r;
        cin >> t >> l >> r;
        if (t == 3) {
            cout << get(1, 1, n, l, r) << '\n';
            continue;
        }
        ll x;
        cin >> x;
        if (t == 1) update(1, 1, n, l, r, 1, x);
        else update(1, 1, n, l, r, 0, x);
    }
}
5 7
1 2 3 4 5
3 1 5
1 2 4 3
3 2 5
2 3 5 2
3 1 5
1 1 3 4
3 2 4

Độ phức tạp: dựng cây O(N)O(N), mỗi thao tác O(log⁡N)O(\log N); với N=Q=2⋅105N = Q = 2 \cdot 10^5 cần khoảng 1,5⋅1071{,}5 \cdot 10^7 lần ghé nút.

VICài đặt không đệ quy, nhanh hơn

Khi chỉ cần gán một phần tử và hỏi đoạn, không cần thẻ lười, ta có một cách cài đặt ngắn hơn, chạy nhanh hơn và chỉ dùng 2n2n ô. Cách này đánh số vị trí từ 00 và dùng đoạn nửa mở [l,r)[l, r), tức là đoạn gồm các vị trí l,l+1,…,r−1l, l + 1, \dots, r - 1.

Lá của vị trí ii đặt ở ô n+in + i, và cha của nút vv là nút ⌊v/2⌋\lfloor v / 2 \rfloor. Dựng cây là một vòng lặp lùi từ n−1n - 1 về 11; gán một phần tử là đi từ lá lên gốc, tính lại từng nút trên đường. Để hỏi đoạn [l,r)[l, r), ta đưa hai biên lên dần từng tầng:

  • nếu ll là số lẻ thì nút ll là con phải, và cha của nó chứa cả nút nằm bên trái ll, ngoài đoạn hỏi; vì vậy ta lấy riêng nút ll rồi dịch ll sang phải một ô;
  • nếu rr là số lẻ thì nút r−1r - 1 là con trái, và cha của nó chứa cả nút rr, ngoài đoạn hỏi; vì vậy ta lấy riêng nút r−1r - 1.

Sau đó cả hai biên cùng lên tầng trên, và ta dừng khi hai biên gặp nhau.

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, q, st[2 * N];

void update(ll pos, ll val) {
    for (st[pos += n] = val; pos > 1; pos >>= 1) st[pos >> 1] = min(st[pos], st[pos ^ 1]);
}

ll get(ll l, ll r) {
    ll res = 1e18;
    for (l += n, r += n; l < r; l >>= 1, r >>= 1) {
        if (l & 1) res = min(res, st[l++]);
        if (r & 1) res = min(res, st[--r]);
    }
    return res;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> q;
    for (ll i = 0; i < n; i++) cin >> st[n + i];
    for (ll i = n - 1; i >= 1; i--) st[i] = min(st[i * 2], st[i * 2 + 1]);
    while (q--) {
        ll t, x, y;
        cin >> t >> x >> y;
        if (t == 1) update(x, y);
        else cout << get(x, y) << '\n';
    }
}
6 6
4 9 2 7 5 6
2 0 6
2 1 4
1 2 8
2 1 4
2 3 6
1 0 1

Độ phức tạp: dựng cây O(n)O(n), mỗi thao tác O(log⁡n)O(\log n); với n=q=105n = q = 10^5 cần khoảng 3⋅1063 \cdot 10^6 phép tính.

Chương trình này đúng với mọi nn, kể cả khi nn không phải lũy thừa của 22 như ví dụ có n=6n = 6, miễn là phép gộp giao hoán (min, max, tổng, ước chung lớn nhất). Với phép gộp không giao hoán, cần đệm số lá lên một lũy thừa của 22 bằng phần tử trung hòa, rồi gộp riêng phần bên trái, phần bên phải và ghép hai phần lại ở cuối.

So với bản đệ quy, bản không đệ quy chạy nhanh hơn rõ rệt:

Một triệu gán và một triệu hỏi min đoạn trên 500 nghìn phần tử.đệ quy, mảng 4n ô857 ms · gấp 3,8 lầnkhông đệ quy, mảng 2n ô225 ms
Một triệu gán và một triệu hỏi min đoạn trên 500 nghìn phần tử.

Bản không đệ quy nhanh hơn bản đệ quy khoảng 3,83{,}8 lần, vì không tốn lời gọi hàm và mảng chỉ bằng một nửa. Vì vậy khi không cần thẻ lười và giới hạn thời gian chặt, nên cân nhắc dùng bản không đệ quy.

Bài nâng cao

Xem 2 bài nâng cao

Ví dụThay Bằng Số Ước

Đề. Cho dãy n≤3⋅105n \le 3 \cdot 10^5 số nguyên dương ai≤106a_i \le 10^6 và m≤3⋅105m \le 3 \cdot 10^5 thao tác: 1 l r thay mỗi aia_i trong đoạn [l,r][l, r] bằng d(ai)d(a_i), số ước dương của aia_i; 2 l r in tổng của đoạn [l,r][l, r].

Lời giải. Thao tác loại 11 không dùng được thẻ lười: biết tổng của một đoạn không cho biết tổng số ước của các phần tử trong đoạn. Tuy nhiên, số ước giảm rất nhanh. Với x≥3x \ge 3 thì d(x)<xd(x) < x, và với x≤106x \le 10^6, chỉ sau nhiều nhất 66 lần thay, xx đã thành 11 hoặc 22. Hai số này không đổi nữa, vì d(1)=1d(1) = 1 và d(2)=2d(2) = 2.

Vì vậy ta cứ thay từng phần tử ở lá, nhưng bỏ qua những đoạn mà mọi phần tử đều đã là 11 hoặc 22. Mỗi nút lưu tổng và max của đoạn; khi thay, gặp nút có max không quá 22 thì dừng ngay, không đi xuống. Mỗi phần tử chỉ thực sự bị thay không quá 66 lần, nên tổng số nút được ghé vẫn nhỏ (xem ô Chứng minh).

Số ước của mọi số tới C=106C = 10^6 được tính trước bằng một vòng lặp kiểu sàng: với mỗi ii, cộng 11 vào số ước của mọi bội của ii. Vòng lặp này tốn khoảng Cln⁡C≈1,4⋅107C \ln C \approx 1{,}4 \cdot 10^7 phép tính.

Chứng minh

d(x)<xd(x) < x khi x≥3x \ge 3. Các ước của xx ghép được thành từng cặp ee và x/ex / e với e≤xe \le \sqrt{x}, nên d(x)≤2xd(x) \le 2\sqrt{x}, nhỏ hơn xx khi x>4x > 4; còn d(3)=2d(3) = 2 và d(4)=3d(4) = 3. Thử hết các số tới 10610^6 thì số lần thay nhiều nhất trước khi thành 11 hoặc 22 là 66, chẳng hạn 5040→60→12→6→4→3→25040 \to 60 \to 12 \to 6 \to 4 \to 3 \to 2.

Số nút được ghé. Xét một thao tác loại 11 trên đoạn [l,r][l, r]. Các nút được ghé gồm các nút cắt ngang biên, không quá hai nút mỗi tầng như ở mục đầu, cùng con của chúng; và các nút nằm trọn trong đoạn mà có max lớn hơn 22, cùng con của chúng. Mỗi nút nằm trọn có max lớn hơn 22 chứa ít nhất một phần tử sẽ bị thay thật sự trong thao tác này, nên ta tính nó vào lần thay của phần tử đó; mỗi lần thay chỉ được tính cho các tổ tiên của lá đó, không quá log⁡2n+1\log_2 n + 1 nút. Cả dãy có không quá 6n6n lần thay thật sự, nên tổng số nút được ghé qua mọi thao tác là O((m+6n)log⁡n)O((m + 6n) \log n).

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 3e5 + 5, C = 1e6 + 5;
ll n, m, a[N], d[C], st[4 * N], mx[4 * N];

void build(ll id, ll l, ll r) {
    if (l == r) {
        st[id] = mx[id] = a[l];
        return;
    }
    ll mid = (l + r) >> 1;
    build(id * 2, l, mid);
    build(id * 2 + 1, mid + 1, r);
    st[id] = st[id * 2] + st[id * 2 + 1];
    mx[id] = max(mx[id * 2], mx[id * 2 + 1]);
}

void update(ll id, ll l, ll r, ll u, ll v) {
    if (v < l || r < u || mx[id] <= 2) return;
    if (l == r) {
        st[id] = mx[id] = d[st[id]];
        return;
    }
    ll mid = (l + r) >> 1;
    update(id * 2, l, mid, u, v);
    update(id * 2 + 1, mid + 1, r, u, v);
    st[id] = st[id * 2] + st[id * 2 + 1];
    mx[id] = max(mx[id * 2], mx[id * 2 + 1]);
}

ll get(ll id, ll l, ll r, ll u, ll v) {
    if (v < l || r < u) return 0;
    if (u <= l && r <= v) return st[id];
    ll mid = (l + r) >> 1;
    return get(id * 2, l, mid, u, v) + get(id * 2 + 1, mid + 1, r, u, v);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    for (ll i = 1; i < C; i++)
        for (ll j = i; j < C; j += i) d[j]++;
    cin >> n >> m;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    build(1, 1, n);
    while (m--) {
        ll t, l, r;
        cin >> t >> l >> r;
        if (t == 1) update(1, 1, n, l, r);
        else cout << get(1, 1, n, l, r) << '\n';
    }
}
5 6
1 2 6 10 12
2 1 5
1 3 5
2 1 5
1 1 5
2 2 4
2 1 1

Độ phức tạp: sàng số ước O(Clog⁡C)O(C \log C), dựng cây O(n)O(n), các thao tác tổng cộng O((m+6n)log⁡n)O((m + 6n) \log n); với n=m=3⋅105n = m = 3 \cdot 10^5 cần khoảng 1,4⋅1071{,}4 \cdot 10^7 phép tính cho sàng và không quá khoảng 6⋅1076 \cdot 10^7 lần ghé nút.

Ví dụDãy Cột Chênh Đủ Xa

Đề. Có n≤105n \le 10^5 cột xếp từ trái sang phải, cột thứ ii cao aia_i. Chọn một dãy cột i1<i2<⋯<iki_1 < i_2 < \dots < i_k sao cho hai cột liên tiếp được chọn chênh nhau ít nhất DD, tức là ∣aij−aij+1∣≥D|a_{i_j} - a_{i_{j+1}}| \ge D. In độ dài lớn nhất của dãy và một dãy chỉ số đạt độ dài đó (0≤D,ai≤10150 \le D, a_i \le 10^{15}).

Lời giải. Đây là một bài quy hoạch động (bài 1.3). Gọi fif_i là độ dài lớn nhất của một dãy hợp lệ kết thúc tại cột ii. Cột đứng ngay trước ii trong dãy là một cột j<ij < i có aj≤ai−Da_j \le a_i - D hoặc aj≥ai+Da_j \ge a_i + D, nên

fi=1+max⁡{fj:j<i, aj≤ai−D hoặc aj≥ai+D},f_i = 1 + \max \{ f_j : j < i,\ a_j \le a_i - D \text{ hoặc } a_j \ge a_i + D \},

với quy ước max của tập rỗng bằng 00. Tính thẳng công thức này tốn O(n2)O(n^2), khoảng 5⋅1095 \cdot 10^9 phép tính.

Ta có thể thấy rằng điều kiện của jj chỉ phụ thuộc vào giá trị aja_j: nó phải nằm trong một khoảng ở dưới hoặc một khoảng ở trên. Vì vậy ta dựng cây phân đoạn lưu max, có chỉ số là giá trị độ cao: sau khi tính xong fjf_j, ta ghi fjf_j vào lá ứng với độ cao aja_j, giữ số lớn hơn nếu lá đó đã có giá trị. Khi tính fif_i, các cột đã được ghi đều đứng trước ii, nên chỉ cần hỏi max trên hai khoảng độ cao.

Độ cao tới 101510^{15}, không dùng trực tiếp làm chỉ số được. Ta nén giá trị: sắp xếp các độ cao khác nhau tăng dần, rồi thay mỗi độ cao bằng thứ tự của nó trong dãy đã sắp xếp. Hai khoảng độ cao khi đó thành hai đoạn chỉ số, tìm bằng chặt nhị phân (bài 0.6): các độ cao không quá ai−Da_i - D là một đoạn ở đầu, các độ cao ít nhất ai+Da_i + D là một đoạn ở cuối.

Để in dãy, mỗi lá lưu cặp (độ dài, chỉ số cột), và ta nhớ cột đứng trước của mỗi cột. Đi ngược từ cột có độ dài lớn nhất theo các cột đứng trước, ta được dãy cần in theo thứ tự ngược lại.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, d, k, a[N], b[N], f[N], par[N];
pair<ll, ll> st[4 * N];

void update(ll id, ll l, ll r, ll pos, pair<ll, ll> val) {
    if (l == r) {
        st[id] = max(st[id], val);
        return;
    }
    ll mid = (l + r) >> 1;
    if (pos <= mid) update(id * 2, l, mid, pos, val);
    else update(id * 2 + 1, mid + 1, r, pos, val);
    st[id] = max(st[id * 2], st[id * 2 + 1]);
}

pair<ll, ll> get(ll id, ll l, ll r, ll u, ll v) {
    if (v < l || r < u) return make_pair(0LL, 0LL);
    if (u <= l && r <= v) return st[id];
    ll mid = (l + r) >> 1;
    return max(get(id * 2, l, mid, u, v), get(id * 2 + 1, mid + 1, r, u, v));
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> d;
    for (ll i = 1; i <= n; i++) {
        cin >> a[i];
        b[i] = a[i];
    }
    sort(b + 1, b + n + 1);
    k = unique(b + 1, b + n + 1) - b - 1;
    ll best = 0;
    for (ll i = 1; i <= n; i++) {
        ll p = upper_bound(b + 1, b + k + 1, a[i] - d) - b - 1;
        ll q = lower_bound(b + 1, b + k + 1, a[i] + d) - b;
        pair<ll, ll> t = max(get(1, 1, k, 1, p), get(1, 1, k, q, k));
        f[i] = t.first + 1;
        par[i] = t.second;
        update(1, 1, k, lower_bound(b + 1, b + k + 1, a[i]) - b, make_pair(f[i], i));
        if (f[i] > f[best]) best = i;
    }
    vector<ll> res;
    for (ll i = best; i; i = par[i]) res.push_back(i);
    reverse(res.begin(), res.end());
    cout << res.size() << '\n';
    for (ll i = 0; i < (ll)res.size(); i++) cout << res[i] << (i + 1 == (ll)res.size() ? '\n' : ' ');
}
6 4
2 8 4 12 7 15

Độ phức tạp: O(nlog⁡n)O(n \log n) cho bước sắp xếp, các lần chặt nhị phân, và hai câu hỏi cùng một lần ghi cho mỗi cột; với n=105n = 10^5 cần khoảng 10710^7 lần ghé nút.

Khi đi thi

  • Dãy vừa bị sửa vừa bị hỏi theo đoạn, mà phép gộp không có phép ngược (min, max, ước chung lớn nhất, đoạn con tốt nhất) hoặc có cập nhật cả đoạn, thì nghĩ tới cây phân đoạn. Chỉ cần tổng đoạn với gán một phần tử thì cây Fenwick (bài 1.7) ngắn hơn; dãy không đổi thì cân nhắc tổng tiền tố.
  • Trước khi gõ code, nên viết ra giấy: nút lưu gì, phép gộp (đúng thứ tự trái phải), phần tử trung hòa. Với cập nhật đoạn thì thêm: thẻ là gì, hợp thẻ thế nào, áp thẻ lên nút ra sao (nhớ nhân với độ dài đoạn).
  • Cấp mảng 4n4n ô và dùng kiểu long long. Lỗi hay gặp: quên đẩy thẻ trong hàm hỏi đoạn, quên tính lại nút sau khi cập nhật hai con, và với cây min, phần tử trung hòa chưa đủ lớn.

Tóm tắt

  • Cây phân đoạn chia đôi dãy liên tiếp: mỗi nút quản lý một đoạn và lưu thông tin của đoạn đó. Cây có 2n−12n - 1 nút, lưu trong mảng 4n4n ô, dựng trong O(n)O(n).
  • Gán một phần tử thì tính lại một đường từ lá lên gốc; hỏi đoạn thì ghép các nút lớn nhất nằm trọn trong đoạn hỏi. Mỗi tầng có không quá hai nút cắt, nên mỗi thao tác tốn O(log⁡n)O(\log n).
  • Thông tin ở nút phải gộp được từ hai nửa bằng một phép gộp có phần tử trung hòa; phép gộp không cần giao hoán, nhưng phải gộp đúng thứ tự trái phải.
  • Đi xuống cây tìm vị trí (bit 11 thứ kk, phần tử đầu tiên ít nhất xx) tốn O(log⁡n)O(\log n), không cần chặt nhị phân bên ngoài.
  • Cập nhật đoạn dùng thẻ lười: nút nằm trọn nhận thẻ và sửa tổng của mình ngay; thẻ chỉ được đẩy xuống khi cần đi qua nút cắt, trong cả thao tác cập nhật lẫn câu hỏi. Nhiều loại cập nhật thì coi thẻ là một hàm và hợp theo thứ tự thời gian.

Bài tập

Bài tập ngoài

Đi xuống cây

IIISegment TreeAtCoder

Đọc thêm