Chương 6·Bài 65

Balanced Binary Search Tree

Cây tìm kiếm nhị phân giữ dữ liệu có thứ tự; khi cân bằng, mọi thao tác chèn, xóa, tìm hạng, tìm phần tử thứ kk tốn O(log⁡n)O(\log n). Trong thi đấu, cây cân bằng tự cài thường là treap: mỗi nút có khóa và một độ ưu tiên ngẫu nhiên, cây vừa là cây tìm kiếm theo khóa vừa là đống theo độ ưu tiên, nên chiều cao kỳ vọng O(log⁡n)O(\log n). Hai thao tác tách (split) và ghép (merge) dựng được mọi thứ; bỏ khóa đi và dùng vị trí thì được treap ẩn khóa, một mảng cắt dán, đảo đoạn trong O(log⁡n)O(\log n).

Treap theo khóa: tách và ghép

Nút tt có key, độ ưu tiên ngẫu nhiên pr, hai con lc, rc và kích thước sz. Hai thao tác nền:

  • split(t, k, a, b): tách cây tt thành aa gồm các khóa ≤k\le k và bb gồm các khóa >k> k. Nếu gốc có khóa ≤k\le k thì gốc và cây con trái thuộc aa, chỉ cần tách tiếp cây con phải; ngược lại đối xứng.
  • merge(a, b) với mọi khóa của aa nhỏ hơn mọi khóa của bb: gốc mới là gốc có độ ưu tiên lớn hơn, đệ quy ghép phần còn lại vào con tương ứng.

Cả hai chỉ đi dọc một đường từ gốc, nên tốn đúng chiều cao, kỳ vọng O(log⁡n)O(\log n). Sau mỗi lần sửa con, cập nhật sz (hàm pull). Từ đó:

  • Chèn xx: tách theo xx, ghép aa, nút mới, bb.
  • Xóa một bản xx: tách ra phần bằng xx, bỏ gốc của phần đó bằng cách ghép hai con của nó.
  • Hạng của xx: kích thước phần <x< x cộng 1. Phần tử thứ kk: đi xuống, so kk với kích thước con trái.

Độ ưu tiên lấy từ mt19937; nếu dùng hàm băm cố định, đối thủ có thể dựng test làm cây suy biến.

Số nhỏ cạnh nút là độ ưu tiên. Đổi k để xem split đi đường nào; bước cuối ghép hai cây lại.

Hỏi nhanh

Treap có các khóa 1,3,5,7,91, 3, 5, 7, 9. Gọi split(t, 5, a, b) (phần aa gồm khóa ≤5\le 5). Cây bb chứa những khóa nào?

Ví dụĐa tập có thứ tự

Đề. n≤105n \le 10^5 thao tác trên một đa tập số nguyên: chèn, xóa một bản, hỏi hạng của xx, phần tử nhỏ thứ kk, tiền nhiệm và kế nhiệm nghiêm ngặt.

Lời giải. Mọi thao tác quy về tách theo khóa rồi ghép lại:

  • Chèn xx: tách thành phần ≤x\le x và >x> x, ghép nút mới vào giữa.
  • Xóa một bản xx: tách lấy riêng phần bằng xx, bỏ đúng gốc của phần đó, ghép lại.
  • Hạng của xx: số phần tử <x< x cộng 1, đọc từ sz sau khi tách tại x−1x - 1.
  • Phần tử thứ kk: đi xuống theo sz của con trái, không cần tách.
  • Tiền nhiệm là phần tử lớn nhất của phần <x< x; kế nhiệm là phần tử nhỏ nhất của phần >x> x.

Bước 1: tách theo khóa. Gốc có khóa ≤k\le k thì gốc cùng cả cây con trái thuộc a, chỉ còn tách tiếp cây con phải; phần ≤k\le k của cây con phải trở thành con phải mới của gốc. Ngược lại đối xứng. pull tính lại sz trên đường về.

C++
void split(int t, int k, int &a, int &b) {
    if (!t) {
        a = b = 0;
        return;
    }
    if (key[t] <= k) a = t, split(rc[t], k, rc[t], b), pull(a);
    else b = t, split(lc[t], k, a, lc[t]), pull(b);
}

Bước 2: ghép. Mọi khóa của a nhỏ hơn mọi khóa của b. Gốc nào ưu tiên lớn hơn làm gốc mới, phần còn lại ghép đệ quy vào phía tương ứng. Cây kết quả vẫn là cây tìm kiếm theo khóa và đống theo ưu tiên. Độ ưu tiên lấy từ mt19937 gieo bằng đồng hồ, nên không dựng được test làm cây suy biến.

C++
int merge(int a, int b) {
    if (!a || !b) return a ? a : b;
    if (pr[a] > pr[b]) {
        rc[a] = merge(rc[a], b), pull(a);
        return a;
    }
    lc[b] = merge(a, lc[b]), pull(b);
    return b;
}

Bước 3: xóa một bản. Sau hai lần tách, c chứa mọi bản của xx. merge(lc[c], rc[c]) bỏ đúng một nút là gốc của c; nếu xx không có thì c = 0 và không có gì đổi.

C++
split(root, x, a, b);
split(a, x - 1, a, c);
c = merge(lc[c], rc[c]);
root = merge(merge(a, c), b);

Bước 4: tiền nhiệm, kế nhiệm. kth(a, sz[a]) là phần tử lớn nhất của a, kth(b, 1) là phần tử nhỏ nhất của b. Đề bảo đảm phần đó không rỗng.

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

const int N = 1e5 + 5;
int tot, root, key[N], lc[N], rc[N], sz[N];
unsigned pr[N];
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());

int newNode(int k) {
    key[++tot] = k, pr[tot] = rng(), sz[tot] = 1;
    return tot;
}

void pull(int t) {
    sz[t] = 1 + sz[lc[t]] + sz[rc[t]];
}

void split(int t, int k, int &a, int &b) {
    if (!t) {
        a = b = 0;
        return;
    }
    if (key[t] <= k) a = t, split(rc[t], k, rc[t], b), pull(a);
    else b = t, split(lc[t], k, a, lc[t]), pull(b);
}

int merge(int a, int b) {
    if (!a || !b) return a ? a : b;
    if (pr[a] > pr[b]) {
        rc[a] = merge(rc[a], b), pull(a);
        return a;
    }
    lc[b] = merge(a, lc[b]), pull(b);
    return b;
}

int kth(int t, int k) {
    while (true) {
        if (k <= sz[lc[t]]) t = lc[t];
        else if (k == sz[lc[t]] + 1) return key[t];
        else k -= sz[lc[t]] + 1, t = rc[t];
    }
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;
    cin >> n;
    while (n--) {
        int op, x, a, b, c;
        cin >> op >> x;
        if (op == 1) {
            split(root, x, a, b);
            root = merge(merge(a, newNode(x)), b);
        } else if (op == 2) {
            split(root, x, a, b);
            split(a, x - 1, a, c);
            c = merge(lc[c], rc[c]);
            root = merge(merge(a, c), b);
        } else if (op == 3) {
            split(root, x - 1, a, b);
            cout << sz[a] + 1 << '\n';
            root = merge(a, b);
        } else if (op == 4) cout << kth(root, x) << '\n';
        else if (op == 5) {
            split(root, x - 1, a, b);
            cout << kth(a, sz[a]) << '\n';
            root = merge(a, b);
        } else {
            split(root, x, a, b);
            cout << kth(b, 1) << '\n';
            root = merge(a, b);
        }
    }
}
12
1 4
1 1
1 4
3 4
4 3
5 4
6 1
2 4
3 4
4 2
2 1
4 1

Chiều cao kỳ vọng O(log⁡n)O(\log n), nên mỗi thao tác O(log⁡n)O(\log n) kỳ vọng.

Treap ẩn khóa: thao tác theo vị trí

Bỏ khóa, coi thứ tự giữa các nút chính là thứ tự trong dãy: vị trí của một nút là số nút đứng trước nó, tính được từ sz. Tách theo kích thước split(t, k, a, b) cho aa là kk phần tử đầu: nếu con trái có ít hơn kk nút thì gốc thuộc aa, tách tiếp con phải với k−szlc−1k - sz_{lc} - 1.

Khi đó dãy hỗ trợ: tách đoạn [l,r][l, r] bằng hai lần tách (lấy l−1l - 1 phần tử đầu, rồi r−l+1r - l + 1 phần tử tiếp), xử lý đoạn giữa, ghép lại theo thứ tự tùy ý. Cắt đoạn rồi dán xuống cuối chỉ là ghép lại theo thứ tự trái, phải, giữa. Dựng cây ban đầu bằng cách ghép từng phần tử vào cuối, O(nlog⁡n)O(n \log n); in dãy bằng duyệt trung thứ tự.

Treap ẩn khóa: duyệt giữa cho đúng dãy, split theo số phần tử bên trái chứ không theo giá trị.

Ví dụCắt rồi ghép xâu

Đề. Xâu dài n≤2⋅105n \le 2 \cdot 10^5, m≤2⋅105m \le 2 \cdot 10^5 thao tác: cắt đoạn [a,b][a, b] và dán xuống cuối. In xâu cuối cùng.

Lời giải. Treap ẩn khóa: không có khóa, thứ tự duyệt giữa của cây chính là thứ tự ký tự trong xâu. Cắt đoạn [a,b][a, b] là hai lần tách theo số phần tử: a−1a - 1 ký tự đầu, rồi b−a+1b - a + 1 ký tự kế. Dán xuống cuối là ghép lại theo thứ tự trái, phải, giữa.

Bước 1: tách theo kích thước. Con trái có ít hơn kk nút thì gốc thuộc a, và trong cây con phải chỉ còn cần k−szlc−1k - sz_{lc} - 1 nút nữa. Không có khóa nào được so sánh. merge giống hệt treap theo khóa, với “mọi phần tử của a đứng trước b” thay cho “khóa nhỏ hơn”.

C++
void split(int t, int k, int &a, int &b) {
    if (!t) {
        a = b = 0;
        return;
    }
    if (sz[lc[t]] < k) a = t, split(rc[t], k - sz[lc[t]] - 1, rc[t], b), pull(a);
    else b = t, split(lc[t], k, a, lc[t]), pull(b);
}

Bước 2: cắt và dán.

C++
split(root, l - 1, a, b);
split(b, r - l + 1, b, c);
root = merge(merge(a, c), b);

Bước 3: đọc lại xâu. Dựng xâu ban đầu bằng cách ghép từng ký tự vào cuối. print duyệt giữa; đệ quy sâu bằng chiều cao, kỳ vọng O(log⁡n)O(\log n), nên không lo tràn ngăn xếp.

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

const int N = 2e5 + 5;
int tot, root, lc[N], rc[N], sz[N];
unsigned pr[N];
char ch[N];
string res;
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());

int newNode(char c) {
    ch[++tot] = c, pr[tot] = rng(), sz[tot] = 1;
    return tot;
}

void pull(int t) {
    sz[t] = 1 + sz[lc[t]] + sz[rc[t]];
}

void split(int t, int k, int &a, int &b) {
    if (!t) {
        a = b = 0;
        return;
    }
    if (sz[lc[t]] < k) a = t, split(rc[t], k - sz[lc[t]] - 1, rc[t], b), pull(a);
    else b = t, split(lc[t], k, a, lc[t]), pull(b);
}

int merge(int a, int b) {
    if (!a || !b) return a ? a : b;
    if (pr[a] > pr[b]) {
        rc[a] = merge(rc[a], b), pull(a);
        return a;
    }
    lc[b] = merge(a, lc[b]), pull(b);
    return b;
}

void print(int t) {
    if (!t) return;
    print(lc[t]);
    res += ch[t];
    print(rc[t]);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n, m;
    string s;
    cin >> n >> m >> s;
    for (char c : s) root = merge(root, newNode(c));
    while (m--) {
        int l, r, a, b, c;
        cin >> l >> r;
        split(root, l - 1, a, b);
        split(b, r - l + 1, b, c);
        root = merge(merge(a, c), b);
    }
    print(root);
    cout << res << '\n';
}
7 3
ABCDEFG
2 5
3 4
1 2

Mỗi thao tác hai lần tách, hai lần ghép: O(log⁡n)O(\log n) kỳ vọng.

Nhãn lười: đảo đoạn và thông tin đoạn

Đảo đoạn [l,r][l, r]: tách ra cây giữa, bật cờ rev ở gốc của nó, ghép lại. Cờ được đẩy xuống khi cần đi vào nút: đổi chỗ hai con và lật cờ của chúng. Mọi hàm đi xuống cây (tách, ghép, duyệt) đều gọi push trước khi đọc con.

Muốn hỏi thông tin của đoạn (tổng, cực đại), mỗi nút giữ thêm giá trị tổng hợp của cả cây con, cập nhật trong pull. Phép đảo không đổi tổng; với thông tin phụ thuộc chiều như tổng tiền tố lớn nhất thì khi đảo cũng phải đổi chỗ phần tiền tố và hậu tố.

Hỏi nhanh

Vì sao phải gọi push trong split trước khi so sánh szlcsz_{lc} với kk?

Ví dụĐảo các đoạn con

Đề. Xâu dài n≤2⋅105n \le 2 \cdot 10^5, m≤2⋅105m \le 2 \cdot 10^5 lần đảo đoạn [a,b][a, b]. In xâu cuối cùng.

Lời giải. Treap ẩn khóa như bài trước. Đảo đoạn: tách đoạn ra thành một cây riêng, bật cờ rev ở gốc, ghép lại. Cờ nghĩa là “cả cây con này đang bị đảo nhưng chưa sửa”; chỉ khi cần đi vào nút mới đẩy xuống. rev[b] ^= 1 dùng XOR vì đảo hai lần thì về như cũ.

Bước 1: đẩy cờ. Đảo một cây con là đổi chỗ hai con ở gốc rồi đảo đệ quy từng con; phần đệ quy được hoãn bằng cách lật cờ của hai con.

C++
void push(int t) {
    if (!rev[t]) return;
    swap(lc[t], rc[t]);
    if (lc[t]) rev[lc[t]] ^= 1;
    if (rc[t]) rev[rc[t]] ^= 1;
    rev[t] = 0;
}

Bước 2: mọi hàm đọc con phải đẩy cờ trước. split gọi push trước khi so sz[lc[t]] với kk: nếu nút còn cờ thì con trái hiện tại thật ra là con phải, đếm theo nó sẽ tách sai đoạn. merge gọi push cho đúng nút sắp bị đổi con (a khi đi vào con phải của nó, b khi đi vào con trái). print gọi push trước khi duyệt con.

C++
if (pr[a] > pr[b]) {
    push(a);
    rc[a] = merge(rc[a], b), pull(a);
    return a;
}
push(b);
lc[b] = merge(a, lc[b]), pull(b);
return b;

Bước 3: một lần đảo.

C++
split(root, l - 1, a, b);
split(b, r - l + 1, b, c);
rev[b] ^= 1;
root = merge(merge(a, b), c);
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 2e5 + 5;
int tot, root, lc[N], rc[N], sz[N];
unsigned pr[N];
bool rev[N];
char ch[N];
string res;
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());

int newNode(char c) {
    ch[++tot] = c, pr[tot] = rng(), sz[tot] = 1;
    return tot;
}

void pull(int t) {
    sz[t] = 1 + sz[lc[t]] + sz[rc[t]];
}

void push(int t) {
    if (!rev[t]) return;
    swap(lc[t], rc[t]);
    if (lc[t]) rev[lc[t]] ^= 1;
    if (rc[t]) rev[rc[t]] ^= 1;
    rev[t] = 0;
}

void split(int t, int k, int &a, int &b) {
    if (!t) {
        a = b = 0;
        return;
    }
    push(t);
    if (sz[lc[t]] < k) a = t, split(rc[t], k - sz[lc[t]] - 1, rc[t], b), pull(a);
    else b = t, split(lc[t], k, a, lc[t]), pull(b);
}

int merge(int a, int b) {
    if (!a || !b) return a ? a : b;
    if (pr[a] > pr[b]) {
        push(a);
        rc[a] = merge(rc[a], b), pull(a);
        return a;
    }
    push(b);
    lc[b] = merge(a, lc[b]), pull(b);
    return b;
}

void print(int t) {
    if (!t) return;
    push(t);
    print(lc[t]);
    res += ch[t];
    print(rc[t]);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n, m;
    string s;
    cin >> n >> m >> s;
    for (char c : s) root = merge(root, newNode(c));
    while (m--) {
        int l, r, a, b, c;
        cin >> l >> r;
        split(root, l - 1, a, b);
        split(b, r - l + 1, b, c);
        rev[b] ^= 1;
        root = merge(merge(a, b), c);
    }
    print(root);
    cout << res << '\n';
}
6 3
ABCDEF
2 5
1 3
4 6

Mỗi lần đảo O(log⁡n)O(\log n) kỳ vọng.

Ví dụĐảo đoạn và tính tổng

Đề. Dãy n≤2⋅105n \le 2 \cdot 10^5 số tới 10910^9, m≤105m \le 10^5 thao tác: đảo đoạn, hoặc in tổng đoạn.

Lời giải. Như bài trước, mỗi nút giữ thêm giá trị val và tổng cây con sum, tính lại trong pull. Đảo đoạn không làm đổi tổng của đoạn, nên cờ đảo không cần sửa sum. Hỏi tổng đoạn là tách đoạn ra thành một cây riêng rồi đọc sum ở gốc của nó.

Bước 1: thêm tổng vào pull. Nút 0 có sum = 0 nên con rỗng không làm sai. Tổng tới 2⋅105⋅1092 \cdot 10^5 \cdot 10^9 nên sum là long long.

C++
void pull(int t) {
    sz[t] = 1 + sz[lc[t]] + sz[rc[t]];
    sum[t] = val[t] + sum[lc[t]] + sum[rc[t]];
}

push giữ nguyên như bài trước. Nếu đề hỏi thông tin phụ thuộc chiều, như tổng tiền tố lớn nhất, thì push còn phải đổi chỗ phần tiền tố và hậu tố.

Bước 2: hai loại thao tác chung một khuôn. Cả hai đều tách đoạn ra thành b; loại 1 bật cờ, loại 2 in sum[b]; rồi ghép lại đúng thứ tự a, b, c.

C++
split(root, l - 1, a, b);
split(b, r - l + 1, b, c);
if (t == 1) rev[b] ^= 1;
else cout << sum[b] << '\n';
root = merge(merge(a, b), c);
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 2e5 + 5;
int tot, root, lc[N], rc[N], sz[N];
unsigned pr[N];
bool rev[N];
ll val[N], sum[N];
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());

int newNode(ll v) {
    tot++;
    val[tot] = sum[tot] = v, pr[tot] = rng(), sz[tot] = 1;
    return tot;
}

void pull(int t) {
    sz[t] = 1 + sz[lc[t]] + sz[rc[t]];
    sum[t] = val[t] + sum[lc[t]] + sum[rc[t]];
}

void push(int t) {
    if (!rev[t]) return;
    swap(lc[t], rc[t]);
    if (lc[t]) rev[lc[t]] ^= 1;
    if (rc[t]) rev[rc[t]] ^= 1;
    rev[t] = 0;
}

void split(int t, int k, int &a, int &b) {
    if (!t) {
        a = b = 0;
        return;
    }
    push(t);
    if (sz[lc[t]] < k) a = t, split(rc[t], k - sz[lc[t]] - 1, rc[t], b), pull(a);
    else b = t, split(lc[t], k, a, lc[t]), pull(b);
}

int merge(int a, int b) {
    if (!a || !b) return a ? a : b;
    if (pr[a] > pr[b]) {
        push(a);
        rc[a] = merge(rc[a], b), pull(a);
        return a;
    }
    push(b);
    lc[b] = merge(a, lc[b]), pull(b);
    return b;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n, m;
    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        ll x;
        cin >> x;
        root = merge(root, newNode(x));
    }
    while (m--) {
        int t, l, r, a, b, c;
        cin >> t >> l >> r;
        split(root, l - 1, a, b);
        split(b, r - l + 1, b, c);
        if (t == 1) rev[b] ^= 1;
        else cout << sum[b] << '\n';
        root = merge(merge(a, b), c);
    }
}
6 5
2 1 4 0 3 5
2 1 3
1 2 5
2 2 4
1 1 6
2 4 6

Mỗi thao tác O(log⁡n)O(\log n) kỳ vọng.

Khi thư viện là đủ

Nhiều bài “cây cân bằng” chỉ cần chèn, xóa, tìm phần tử lớn nhất, nhỏ nhất, hay phần tử gần xx nhất: set, multiset với lower_bound, upper_bound là cây đỏ đen có sẵn. Xóa một bản trong multiset bằng erase(find(x)), vì erase(x) xóa mọi bản. Cái thư viện chuẩn không có là hạng và phần tử thứ kk; khi chỉ cần phần tử ở vị trí cố định (như trung vị), giữ hai multiset: nửa nhỏ và nửa lớn, cân bằng kích thước sau mỗi thao tác.

Ví dụTrung vị của cửa sổ

Đề. Mảng n≤2⋅105n \le 2 \cdot 10^5, cửa sổ dài kk. In trung vị (phần tử thứ ⌈k/2⌉\lceil k/2 \rceil) của mọi cửa sổ.

Lời giải. Giữ hai multiset: lo chứa ⌈k/2⌉\lceil k/2 \rceil phần tử nhỏ nhất của cửa sổ, hi chứa phần còn lại, và mọi phần tử của lo không lớn hơn mọi phần tử của hi. Khi đó trung vị là phần tử lớn nhất của lo.

Bước 1: thêm phần tử mới. Không lớn hơn max của lo thì vào lo, ngược lại vào hi; điều kiện thứ tự được giữ.

C++
if (lo.empty() || a[i] <= *lo.rbegin()) lo.insert(a[i]);
else hi.insert(a[i]);

Bước 2: xóa phần tử rời cửa sổ. Có trong lo và không lớn hơn max của lo thì xóa ở lo, ngược lại ở hi. Giá trị trùng nằm ở hai bên thì xóa bản nào cũng được, vì chúng như nhau. erase(find(x)) chỉ xóa một bản; erase(x) sẽ xóa mọi bản.

C++
int x = a[i - k];
if (lo.find(x) != lo.end() && x <= *lo.rbegin()) lo.erase(lo.find(x));
else hi.erase(hi.find(x));

Bước 3: cân bằng. lo thừa thì chuyển max của nó sang hi; thiếu thì lấy min của hi sang. Phần tử chuyển đi luôn ở biên, nên điều kiện thứ tự vẫn đúng. Trong k−1k - 1 bước đầu cửa sổ chưa đủ, balance vẫn chạy nhưng không in.

C++
void balance() {
    while ((int)lo.size() > (k + 1) / 2) {
        hi.insert(*lo.rbegin());
        lo.erase(prev(lo.end()));
    }
    while ((int)lo.size() < (k + 1) / 2 && !hi.empty()) {
        lo.insert(*hi.begin());
        hi.erase(hi.begin());
    }
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 2e5 + 5;
int n, k, a[N];
multiset<int> lo, hi;

void balance() {
    while ((int)lo.size() > (k + 1) / 2) {
        hi.insert(*lo.rbegin());
        lo.erase(prev(lo.end()));
    }
    while ((int)lo.size() < (k + 1) / 2 && !hi.empty()) {
        lo.insert(*hi.begin());
        hi.erase(hi.begin());
    }
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> k;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1; i <= n; i++) {
        if (lo.empty() || a[i] <= *lo.rbegin()) lo.insert(a[i]);
        else hi.insert(a[i]);
        if (i > k) {
            int x = a[i - k];
            if (lo.find(x) != lo.end() && x <= *lo.rbegin()) lo.erase(lo.find(x));
            else hi.erase(hi.find(x));
        }
        balance();
        if (i >= k) cout << *lo.rbegin() << ' ';
    }
    cout << '\n';
}
7 4
8 1 5 2 9 3 6

O(nlog⁡k)O(n \log k).

Khi đi thi

  • Cần hạng, phần tử thứ kk trên tập động, hoặc khóa không nén được ngoại tuyến: treap theo khóa; nén được ngoại tuyến thì Fenwick trên giá trị thường gọn hơn.
  • Dãy bị cắt, dán, chèn giữa, đảo đoạn: treap ẩn khóa, tách hai lần lấy đoạn giữa, nhãn lười đẩy xuống trong mọi hàm đi xuống.
  • Chỉ cần lớn nhất, nhỏ nhất, gần nhất: set, multiset; nhớ erase(find(x)) để xóa một bản.

Tóm tắt

  • Treap: cây tìm kiếm theo khóa, đống theo độ ưu tiên ngẫu nhiên, chiều cao kỳ vọng O(log⁡n)O(\log n).
  • Tách và ghép là nền cho chèn, xóa, hạng, phần tử thứ kk, tiền nhiệm, kế nhiệm.
  • Treap ẩn khóa tách theo kích thước, biến dãy thành cây; đoạn [l,r][l, r] là cây giữa sau hai lần tách.
  • Thông tin đoạn giữ trong pull, phép lười đẩy trong push.

Bài tập

1
Thống kê mức lươngcanuc80k6501Nén mọi mức lương kể cả trong truy vấn rồi đếm bằng Fenwick; hoặc treap theo khóa đếm khóa trong đoạn.
LQDOJ
2
Những vết cắt trên thanh gỗcanuc80k6502Giữ tập các vết cắt trong set; mảnh chứa xx nằm giữa hai vết kề tìm bằng lower_bound.
LQDOJ
3
Xóa phần tử theo vị trícanuc80k6504Xóa theo vị trí: treap ẩn khóa, hoặc Fenwick đánh dấu phần tử còn và tìm phần tử thứ pp.
LQDOJ
4
Vòng loại Josephuscanuc80k6508Vị trí bị loại tiếp theo là (vị trí hiện tại cộng kk) theo mô-đun số người còn lại; tìm người thứ đó bằng Fenwick hay treap.
LQDOJ
5
Quầy lương có ngưỡngcanuc80k6509Giữ một độ lệch chung cho mọi lương; sa thải là tách bỏ phần nhỏ hơn ngưỡng; hỏi lớn thứ kk bằng kích thước cây con.
LQDOJ
6
Tập thứ tự độngcanuc80k6510Treap theo khóa cho tập không trùng: phần tử thứ kk và số phần tử nhỏ hơn xx từ kích thước.
LQDOJ
7
Lấy phần tử theo thứ hạngcanuc80k6512Lấy phần tử thứ xx rồi xóa nó: treap, hoặc Fenwick trên giá trị đã nén ngoại tuyến.
LQDOJ
8
Phân phối vé hòa nhạccanuc80k6515multiset các vé; khách nhận vé prev(upper_bound(x)) nếu có, rồi xóa đúng một bản.
LQDOJ
9
Các cột đèn giao thôngcanuc80k6516set vị trí đèn và multiset độ dài các đoạn; mỗi đèn mới tách một đoạn thành hai.
LQDOJ
10
Cắt tấm kínhcanuc80k6517Hai set vết cắt ngang, dọc và hai multiset độ dài; diện tích lớn nhất là tích hai độ dài lớn nhất.
LQDOJ
11
Khôi phục hoán vịcanuc80k6520Lấy giá trị chưa dùng thứ si+1s_i + 1: Fenwick trên các giá trị và tìm kiếm nhị phân trên cây.
LQDOJ
12
Tổng k thành tích cao nhấtcanuc80k6526Hai multiset: KK phần tử lớn nhất cùng tổng của chúng, và phần còn lại; mỗi cập nhật đổi một phần tử rồi cân bằng.
LQDOJ
13
Kệ sách độngcanuc80k6534Chèn sách theo vị trí: treap ẩn khóa; hoặc xử lý ngược thứ tự chèn với Fenwick tìm vị trí trống.
LQDOJ
14
Dãy động và tổng đoạn lớn nhấtcanuc80k6538Treap ẩn khóa giữ tổng, tiền tố, hậu tố và đoạn con lớn nhất của cây con; chèn, xóa, thay bằng tách và ghép.
LQDOJ
15
Bảo trì dãy sốcanuc80k6539Treap ẩn khóa với hai nhãn lười gán và đảo; đảo thì đổi chỗ tiền tố và hậu tố lớn nhất.
LQDOJ

Đọc thêm

  • Treap (Cartesian tree)cp-algorithmsTách, ghép, treap ẩn khóa và nhãn lười.
  • TreapsUSACO GuideCài đặt và bài luyện.
  • TreapOI WikiCác biến thể và chứng minh chiều cao kỳ vọng.