Treap 树
引入与简介
Treap 树是一种原理比较简单的弱平衡的二叉搜索树。其中 Treap 是一个合成词,把 Tree 和 Heap 各取 一半组合而成,Treap 是树和堆的结合,通常翻译成树堆。其支持插入节点、删除节点、求第 \(x\) 大的节点、求权值为 \(x\) 的节点的排名、求权值比 \(x\) 小的最大节点、求权值比 \(x\) 大的最小节点。
堆
堆是一棵树,其每个节点都有一个键值,且每个节点的键值都大于等于或小于等于其父亲的键值。每个节点的键值都大于等于其父亲键值的堆叫做小根堆,否则叫做大根堆。STL 中的 priority_queue 其实就是一个大根堆。
(小根)堆主要支持的操作有:插入一个数、查询最小值、删除最小值、合并两个堆、减小一个元素的值。
一些功能强大的堆(可并堆)还能(高效地)支持 merge 等操作。一些功能更强大的堆还支持可持久化,也就是对任意历史版本进行查询或者操作,产生新的版本。
二叉搜索树
二叉搜索树是一种二叉树的树形数据结构,其定义如下:
-
空树是二叉搜索树。
-
若二叉搜索树的左子树不为空,则其左子树上所有点的附加权值均小于其根节点的值。
-
若二叉搜索树的右子树不为空,则其右子树上所有点的附加权值均大于其根节点的值。
-
二叉搜索树的左右子树均为二叉搜索树。
二叉搜索树上的基本操作所花费的时间与这棵树的高度成正比。对于一个有 \(n\) 个结点的二叉搜索树中,这些操作的最优时间复杂度为 \(O(\log n)\),最坏为 \(O(n)\)。随机构造这样一棵二叉搜索树的期望高度为 \(O(\log n)\)。
旋转 Treap
旋转 Treap 维护平衡的方式为旋转,和 AVL 树[1]的旋转操作类似,分为左旋和右旋。即在满足二叉搜索树的条件下根据堆的优先级对 Treap 进行平衡操作。
Treap 的性质
Treap 树的重要性质:若每个节点的键值、优先级已经事先确定而且不同,那么建立的 BST 的形态是唯一的,与节点的插人顺序没有关系。可以把每个点的(键值,优先级)看作它在平面上的坐标 \((x,y)\),坐标确定了它的位置。可简单地概括为节点的键值 \(x\) 限定了它 在二叉树上的横向位置,优先级 \(y\) 限定了它的纵向位置。若优先级是随机产生的,那么在概率上就实现了二叉树的平衡。
Treap 树的唯一形态
键值:\(\{a, b, c, d, e, f, g\}\)。
优先级:\(\{6, 5, 2, 7, 3, 4, 1\}\)。
需要注意,所谓“Treap 树的形态唯一性”,是指已经提前确定所有节点的键值、优先级之后,建的树的形态是唯一的。但是在一般情况下,建 Treap 树是逐个点加入树上的,每个点的优先级是动态分配的,所以 Treap 树的最后形态并不能提前预知。不过,当处理完毕之后,这棵 Treap 树的新形态是确定唯一的。
给节点加上优先级是 Treap 树解决二叉树平衡的核心思想,合适的优先级能产生一个平衡的 BST。如何产生优先级?最简单的方法是对每个节点的优先级进行随机赋值,那么生成的 Treap 树的形态也是随机的。虽然不能保证生成的 Treap 树是完美的平衡,但是从概率期上看,它的插入、删除、查找的时间复杂度都为 \(O(\log n)\)。
如果预先知道所有节点的键值,那么建树很简单;先按键值排序,然后从键值最小的开始,从左到右逐个向树上加入节点,加入时按优先级(或者已知,或者随机生成)在纵向上调整形态。这就是笛卡儿树,它的建树复杂度为 \(O(n)\)。 更常见的情况是需要动态加入新的节点,并不能预先知道键值和优先级。做法是每读入一个新键值,为它分配一个随机的优先级,插人树中,插入时动态调整树的结构,使它仍然是一棵 Treap 树。此时建一棵 \(n\) 个节点的树,复杂度为 \(O(n\log n)\)。
Treap 的实现
下面用旋转法实现几个基本操作:插入节点、删除节点、排名、第 \(k\) 大、前驱和后继。
旋转
旋转操作是 Treap 的一个非常重要的操作,主要用来在保持 Treap 树性质的同时,调整不同节点的层数,以达到维护堆性质的作用。旋转操作的左旋和右旋可能不是特别容易区分,以下是两个较为明显的特点:
旋转操作的含义:
- 在不影响搜索树性质的前提下,把和旋转方向相反的子树变成根节点(如左旋,就是把右子树变成根节点)
- 不影响性质,并且在旋转过后,跟旋转方向相同的子节点变成了原来的根节点(如左旋,旋转完之后的左子节点是旋转前的根节点)
右旋
节点 \(p\) 右旋时,会携带自己的右子树,向右旋转到 \(q\) 的右子树位置,\(q\) 的右子树被抛弃, 而 \(p\) 右旋后左子树正好空闲,将 \(q\) 的右子树放在 \(p\) 的左子树位置,旋转后的树根为 \(q\)。

void Zig(int &o) {
int k = t[o].ls;
t[o].ls = t[k].rs;
t[k].rs = o;
o = k;
}
左旋
节点 \(p\) 左旋时,携带自己的左子树,向左旋转到 \(q\) 的左子树位置,\(q\) 的左子树被抛弃,此时 \(p\) 左旋后右子树正好空闲,将 \(q\) 的左子树放在 \(p\) 的右子树 位置,旋转后的树根为 \(q\)。

void Zag(int &o) { // 左旋
int k = t[o].rs;
t[o].rs = t[k].ls;
t[k].ls = o;
o = k;
}
插入节点
插入节点类似于普通二叉搜索树的插入,但是需要在插入的过程中通过旋转来维护优先级的堆性质。
把新节点 \(k\) 插入 Treap 树的过程有两步:
-
把 \(k\) 按键值大小插入一个空的叶子节点。
-
为 \(k\) 随机分配一个优先级,如果 \(k\) 的优先级违反了堆的性质,即它的优先级比父结点高,那么进行调整,让 \(k\) 往上走,替代父结点,最后得到一个新的 Treap 树。
新节点的插入与调整
- 图 (2) 插入 \(d\) 点,按朴素的插入方法插入到底部;
- 图 (3) \(d\) 的优先级比父结点 \(c\) 高,左旋,上升;
- 图 (4) \(d\) 的优先级比新的父结点 \(b\) 高,继续左旋上升;
- 图 (5) 再次左旋上升,完成了新的 Treap 树。
void Insert(int &u, int x) {
if (u == 0) {
addNode(x);
u = cnt;
return;
}
++t[u].sz;
if (x < t[u].key) Insert(t[u].ls, x);
else Insert(t[u].rs, x);
if (t[u].rs != 0 && t[u].pri > t[t[u].rs].pri) Rotate(u, 1);
if (t[u].ls != 0 && t[u].pri > t[t[u].ls].pri) Rotate(u, 2);
Update(u);
}
删除节点
主要就是分类讨论,不同的情况有不同的处理方法,删完了树的大小会有变化,要注意更新。并且如果要删的节点有左子树和右子树,就要考虑删除之后让谁来当父节点。分下面两种情况:
- 待删除的结点x是叶子结点:直接删除。
- 待删除的结点x有子结点:找到优先级最大的子结点,把 \(x\) 向相反的方向旋转,也就是把 \(x\) 向树的下层调整,直到 \(x\) 被旋转到叶子结点,然后直接删除。
void Delete(int &u, int x) {
--t[u].sz;
if (x == t[u].key) {
if (t[u].ls == 0 && t[u].rs == 0) u = 0;
else if (t[u].ls == 0 || t[u].rs == 0) u = t[u].ls + t[u].rs;
else if (t[t[u].ls].pri < t[t[u].rs].pri) {
Rotate(u, 2);
Delete(t[u].rs, x);
} else {
Rotate(u, 1);
Delete(t[u].ls, x);
}
return;
}
if (t[u].key >= x) Delete(t[u].ls, x);
else Delete(t[u].rs, x);
Update(u);
}
排名
求数字 \(x\) 的排名。从根节点开始递归查找,递归到节点 \(u\) 时:若 \(u\) 的键值大于或等于 \(r\),\(x\) 在 \(u\) 的左子树上,继续递归 \(u\) 的左子树;若 \(u\) 的键值小于 \(x\),\(x\) 在 \(x\) 的右子树上,递归 \(u\) 的右子树,并加上 \(w\) 的左子树的 \(size\) 值。
int Rank(int u, int x) {
if (u == 0) return 0;
if (x > t[u].key) return t[t[u].ls].sz + Rank(t[u].rs, x) + 1;
return Rank(t[u].ls, x);
}
第 k 大
根据节点的 \(size\) 值不断递归整棵树,求得第 \(k\) 大数。
int Kth(int u, int k) {
if (k == t[t[u].ls].sz + 1) return t[u].key;
if (k <= t[t[u].ls].sz + 1) return Kth(t[u].ls, k);
if (k > t[t[u].ls].sz + 1) return Kth(t[u].rs, k - t[t[u].ls].sz - 1);
}
前驱和后继
前驱是求比 \(x\) 小的数,后继是求比 \(x\) 大的数,计算过程与排名的过程类似。
前驱
int prequery(int u, int x) {
if (u == 0) return 0;
if (t[u].key >= x) return prequery(t[u].ls, x);
int tmp = prequery(t[u].rs, x);
if (tmp == 0) return t[u].key;
return tmp;
}
后继
int nxtquery(int u, int x) {
if (u == 0) return 0;
if (t[u].key <= x) return nxtquery(t[u].rs, x);
int tmp = nxtquery(t[u].ls, x);
if (tmp == 0) return t[u].key;
return tmp;
}
模板
#include <bits/stdc++.h>
#define ll long long
using namespace std;
constexpr int N = 2e6 + 5;
mt19937 rnd(time(0));
int n, cnt, rt;
struct Node {
int key, pri, sz;
int ls, rs;
} t[N];
void addNode(int x) {
t[++cnt].key = x;
t[cnt].sz = 1;
t[cnt].ls = t[cnt].rs = 0;
t[cnt].pri = rnd();
}
void Update(int p) {
t[p].sz = t[t[p].ls].sz + t[t[p].rs].sz + 1;
}
void Rotate(int &o, int op) { // op = 1 左旋,op = 2 右旋
int k = 0;
if (op == 1) {
k = t[o].rs;
t[o].rs = t[k].ls;
t[k].ls = o;
} else if (op == 2) {
k = t[o].ls;
t[o].ls = t[k].rs;
t[k].rs = o;
}
t[k].sz = t[o].sz;
Update(o);
o = k;
}
void Insert(int &u, int x) {
if (u == 0) {
addNode(x);
u = cnt;
return;
}
++t[u].sz;
if (x < t[u].key) Insert(t[u].ls, x);
else Insert(t[u].rs, x);
if (t[u].rs != 0 && t[u].pri > t[t[u].rs].pri) Rotate(u, 1);
if (t[u].ls != 0 && t[u].pri > t[t[u].ls].pri) Rotate(u, 2);
Update(u);
}
void Delete(int &u, int x) {
--t[u].sz;
if (x == t[u].key) {
if (t[u].ls == 0 && t[u].rs == 0) u = 0;
else if (t[u].ls == 0 || t[u].rs == 0) u = t[u].ls + t[u].rs;
else if (t[t[u].ls].pri < t[t[u].rs].pri) {
Rotate(u, 2);
Delete(t[u].rs, x);
} else {
Rotate(u, 1);
Delete(t[u].ls, x);
}
return;
}
if (t[u].key >= x) Delete(t[u].ls, x);
else Delete(t[u].rs, x);
Update(u);
}
int Rank(int u, int x) {
if (u == 0) return 0;
if (x > t[u].key) return t[t[u].ls].sz + Rank(t[u].rs, x) + 1;
return Rank(t[u].ls, x);
}
int Kth(int u, int k) {
if (k == t[t[u].ls].sz + 1) return t[u].key;
if (k < t[t[u].ls].sz + 1) return Kth(t[u].ls, k);
if (k > t[t[u].ls].sz + 1) return Kth(t[u].rs, k - t[t[u].ls].sz - 1);
}
int prequery(int u, int x) {
if (u == 0) return 0;
if (t[u].key >= x) return prequery(t[u].ls, x);
int tmp = prequery(t[u].rs, x);
if (tmp == 0) return t[u].key;
return tmp;
}
int nxtquery(int u, int x) {
if (u == 0) return 0;
if (t[u].key <= x) return nxtquery(t[u].rs, x);
int tmp = nxtquery(t[u].ls, x);
if (tmp == 0) return t[u].key;
return tmp;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
int op, x;
for (int w = 1; w <= n; w++) {
cin >> op >> x;
if (op == 1) Insert(rt, x);
else if (op == 2) Delete(rt, x);
else if (op == 3) cout << Rank(rt, x) + 1 << '\n';
else if (op == 4) cout << Kth(rt, x) << '\n';
else if (op == 5) cout << prequery(rt, x) << '\n';
else if (op == 6) cout << nxtquery(rt, x) << '\n';
}
return 0;
}
例题
例 1:P5076 普通二叉树
你需要实现一个数据结构,维护一个数的集合(初始为空),支持以下 \(5\) 种操作:查询数 \(x\) 的排名(小于 \(x\) 的个数 \(+1\),\(x\) 可能不在集合中);查询排名为 \(x\) 的数;求 \(x\) 的前驱(小于 \(x\) 的最大数,不存在输出 \(-2147483647\));求 \(x\) 的后继(大于 \(x\) 的最小数,不存在输出 \(2147483647\));插入一个数 \(x\)(插入前 \(x\) 不在集合中)。操作次数 \(q \le 10^4\),所有数的绝对值 \(\le 10^9\),保证操作 \(1\)、\(3\)、\(4\) 时集合非空。
思路
第一道题是一道非常简单的练手题。显然这是一道简单的不能再简单的平衡树板子题。Treap 板子扔上去然后稍微修改一下前驱和后继的部分就可以了。
这要是打不出来就可以重开了。
参考代码
#include <bits/stdc++.h> #define ll long long #define fast_running ios::sync_with_stdio(false), cin.tie(nullptr) using namespace std; constexpr int N = 2e6 + 5; mt19937 rnd(time(nullptr)); int Q, cnt, rt; struct Node { int key, pri, sz; int ls, rs; } t[N]; void addNode(const int x) { t[++cnt].key = x; t[cnt].ls = t[cnt].rs = 0; t[cnt].sz = 1; t[cnt].pri = rnd(); } void Update(const int p) { t[p].sz = t[t[p].ls].sz + t[t[p].rs].sz + 1; } void Rotate(int &u, const int op) { int v = 0; if (op == 1) { v = t[u].rs; t[u].rs = t[v].ls; t[v].ls = u; } else if (op == 2) { v = t[u].ls; t[u].ls = t[v].rs; t[v].rs = u; } t[v].sz = t[u].sz; Update(u); u = v; } void Insert(int &u, const int x) { if (u == 0) { addNode(x); u = cnt; return; } ++t[u].sz; if (x < t[u].key) Insert(t[u].ls, x); else Insert(t[u].rs, x); if (t[u].rs != 0 && t[u].pri > t[t[u].rs].pri) Rotate(u, 1); if (t[u].ls != 0 && t[u].pri > t[t[u].ls].pri) Rotate(u, 2); Update(u); } int Rank(const int u, const int x) { if (u == 0) return 0; if (x > t[u].key) return t[t[u].ls].sz + Rank(t[u].rs, x) + 1; return Rank(t[u].ls, x); } int Kth(const int u, const int k) { if (k == t[t[u].ls].sz + 1) return t[u].key; if (k < t[t[u].ls].sz + 1) return Kth(t[u].ls, k); if (k > t[t[u].ls].sz + 1) return Kth(t[u].rs, k - t[t[u].ls].sz - 1); return 0; } int prequery(const int u, const int x, const int ans) { if (u == 0) return 0; if (t[u].key >= x) { if (t[u].ls == 0) return ans; return prequery(t[u].ls, x, ans); } if (t[u].rs == 0) return t[u].key; const int tmp = prequery(t[u].rs, x, t[u].key); if (tmp == 0) return t[u].key; return tmp; } int nxtquery(const int u, const int x, const int ans) { if (u == 0) return 0; if (t[u].key <= x) { if (t[u].rs == 0) return ans; return nxtquery(t[u].rs, x, ans); } if (t[u].ls == 0) return t[u].key; const int tmp = nxtquery(t[u].ls, x, t[u].key); if (tmp == 0) return t[u].key; return tmp; } signed main() { fast_running; cin >> Q; int op, x; while (Q--) { cin >> op >> x; if (op == 1) { cout << Rank(rt, x) + 1 << '\n'; } else if (op == 2) { cout << Kth(rt, x) << '\n'; } else if (op == 3) { cout << prequery(rt, x, -2147483647) << '\n'; } else if (op == 4) { cout << nxtquery(rt, x, 2147483647) << '\n'; } else if (op == 5) { Insert(rt, x); } } return 0; }
例 2:P3850 书架
初始给定包含 \(n\) 个字符串的序列,再向这个序列中插入 \(m\) 个字符串,最后在查询 \(q\) 次,每次查询第 \(x\) 个字符串。
数据范围 \(n\le 200,m\le 10^5,q\le 10^4\)。
思路
也是很显然的一道题。本题一共有 \(2\) 个操作,插入和查询。插入操作需要先找到第 \(x\) 个的位置,再在这个位置后插入一个新节点。查询就是很普通的查询,这里就不过多说了。其实就只是把键值改为字符串。
参考代码
#include <bits/stdc++.h> #define ll long long #define fast_running ios::sync_with_stdio(false), cin.tie(nullptr) using namespace std; constexpr int N = 2e5 + 5; mt19937 rnd(time(nullptr)); int n, m, Q, cnt, rt; struct Treap { string key; int pri, sz, ls, rs; } t[N]; void addNode(const string& x) { t[++cnt].key = x; t[cnt].ls = t[cnt].rs = 0; t[cnt].sz = 1; t[cnt].pri = rnd(); } void Update(const int p) { t[p].sz = t[t[p].ls].sz + t[t[p].rs].sz + 1; } void Rotate(int &u, const int op) { int v = 0; if (op == 1) { v = t[u].rs; t[u].rs = t[v].ls; t[v].ls = u; } else if (op == 2) { v = t[u].ls; t[u].ls = t[v].rs; t[v].rs = u; } t[v].sz = t[u].sz; Update(u); u = v; } void Insert(int &u, const string& s, const int k) { if (u == 0) { addNode(s); u = cnt; return; } ++t[u].sz; if (t[t[u].ls].sz > k - 1) Insert(t[u].ls, s, k); else Insert(t[u].rs, s, k - t[t[u].ls].sz - 1); if (t[u].rs != 0 && t[u].pri > t[t[u].rs].pri) Rotate(u, 1); if (t[u].ls != 0 && t[u].pri > t[t[u].ls].pri) Rotate(u, 2); Update(u); } string Kth(const int u, const int k) { if (k == t[t[u].ls].sz + 1) return t[u].key; if (k < t[t[u].ls].sz + 1) return Kth(t[u].ls, k); if (k > t[t[u].ls].sz + 1) return Kth(t[u].rs, k - t[t[u].ls].sz - 1); return ""; } signed main() { fast_running; cin >> n; string s; int x; for (int i = 1; i <= n; i++) { cin >> s; Insert(rt, s, i); } cin >> m; for (int i = 1; i <= m; i++) { cin >> s >> x; Insert(rt, s, x); } cin >> Q; while (Q--) { cin >> x; cout << Kth(rt, x + 1) << '\n'; } return 0; }
例 3:P2596 书架
你要维护 \(n\) 个元素,\(n\) 个元素的编号构成 \(1\) 到 \(n\) 的排列,有以下操作:
Top:将编号为 \(x\) 的元素放在最前。Bottom:将编号为 \(x\) 的元素放在最后。Insert:将编号为 \(x\) 的元素拿出,并重新插入到 \(t\) 个位置之前或之后。Ask:查询编号为 \(x\) 的元素的排名−1。Query:查询排名为 \(x\) 的元素的编号。
思路
将每一本书的编号和一个权值联系起来,通过权值的大小,在 Treap 中不同的位置,达到插入到不同位置的需求。
就比如说,初始状态权值是 \([1,n]\)。第一本书是 \(1\),第二本书是 \(2\),第三本书是 \(3\),直到第 \(n\) 本书是 \(n\)。如果想要把一本书插入到顶端,就把他的权值改成 \(0\),如果想把它放到底下,就改成 \(n+1\)。每次修改完位置以后,权值的范围会有所改变,改变范围,好以此类推。
总之就是用 \(a[i]\) 数组记录编号为 \(i\) 的书的权值,通过维护数组和 Treap 来实现本题。
参考代码
#include <bits/stdc++.h> #define ll long long #define fast_running ios::sync_with_stdio(false), cin.tie(nullptr) using namespace std; constexpr int N = 2e6 + 5; mt19937 rnd(time(nullptr)); int n, m, cnt, rt; int a[N]; struct Treap { int key, pri, sz, id; int ls, rs; } t[N]; void addNode(const int x, const int id) { t[++cnt].key = x; t[cnt].ls = t[cnt].rs = 0; t[cnt].id = id; t[cnt].sz = 1; t[cnt].pri = rnd(); } void Update(const int p) { t[p].sz = t[t[p].ls].sz + t[t[p].rs].sz + 1; } void Rotate(int &u, const int op) { int v = 0; if (op == 1) { v = t[u].rs; t[u].rs = t[v].ls; t[v].ls = u; } else if (op == 2) { v = t[u].ls; t[u].ls = t[v].rs; t[v].rs = u; } t[v].sz = t[u].sz; Update(u); u = v; } void Insert(int &u, const int k, const int id) { if (u == 0) { addNode(k, id); u = cnt; return; } if (k < t[u].key) { Insert(t[u].ls, k, id); if (t[t[u].ls].pri > t[u].pri) Rotate(u, 2); } else { Insert(t[u].rs, k, id); if (t[t[u].rs].pri > t[u].pri) Rotate(u, 1); } Update(u); } void Delete(int &u, const int k) { if (u == 0) return; if (k == t[u].key) { if (t[u].ls == 0 && t[u].rs == 0) u = 0; else if (t[u].ls == 0 || t[u].rs == 0) u = t[u].ls + t[u].rs; else { if (t[t[u].ls].pri > t[t[u].rs].pri) { Rotate(u, 2); Delete(t[u].rs, k); } else { Rotate(u, 1); Delete(t[u].ls, k); } } } else if (k < t[u].key) Delete(t[u].ls, k); else Delete(t[u].rs, k); if (u) Update(u); } int Rank(const int u, const int x) { if (u == 0) return 0; if (x == t[u].key) return t[t[u].ls].sz + 1; if (x < t[u].key) return Rank(t[u].ls, x); return t[t[u].ls].sz + 1 + Rank(t[u].rs, x); } int Kth(const int u, const int k) { if (u == 0) return 0; if (k == t[t[u].ls].sz + 1) return t[u].id; if (k <= t[t[u].ls].sz) return Kth(t[u].ls, k); return Kth(t[u].rs, k - t[t[u].ls].sz - 1); } signed main() { fast_running; cin >> n >> m; int x, y, l = 1, r = n; for (int i = 1; i <= n; i++) { cin >> x; a[x] = i; Insert(rt, i, x); } string op; while (m--) { cin >> op; if (op == "Top") { cin >> x; Delete(rt, a[x]); l -= 1; a[x] = l; Insert(rt, a[x], x); } else if (op == "Bottom") { cin >> x; Delete(rt, a[x]); r += 1; a[x] = r; Insert(rt, a[x], x); } else if (op == "Insert") { cin >> x >> y; if (y == 0) continue; int tmp = Kth(rt, Rank(rt, a[x]) + y); int ka = a[tmp]; int kb = a[x]; Delete(rt, kb); Delete(rt, ka); a[x] = ka; a[tmp] = kb; Insert(rt, a[x], x); Insert(rt, a[tmp], tmp); } else if (op == "Ask") { cin >> x; cout << Rank(rt, a[x]) - 1 << '\n'; } else if (op == "Query") { cin >> x; cout << Kth(rt, x) << '\n'; } } return 0; }
FHQ Treap
FHQ 是近几年开始流行的新技术,不仅比旋转法的编码更简单,而且还能用于区间翻转、移动、持久化等场合。
不管是旋转法还是FHQ,它们所维护的都是 Treap 树,Treap 树的最后形态由键值和优先级决定。旋转法和 FHQ 的区别是维护的方法不同,但结果是一样的。
FHQ Treap 的发明者是范浩强(FHQ),是著名的OI选手。FHQ Treap 的高明之处是所有的操作都只用到了分裂和合并这两个基本操作,这两个操作的复杂度都为 \(O(\log n)\)。
- 分裂:
void Split(int u ,int x,int &L,int &R)。其中&L和&R是引用传递,函数返回 \(L\) 和 \(R\) 的值。把一棵以 \(u\) 为根的 Treap 树按键值分裂,返回分别以 \(L\)、\(R\) 为根的两棵树,其中左树 \(L\) 上所有节点的键值都小于或等于 \(x\),右树 \(R\) 上所有节点的键值都大于 \(x\)。 - 合并:
int Merge(int L,int R)。把树 \(L\) 和树 \(R\) 按优先级合并,合并的隐含前提是 \(L\) 上所有节点的键值都小于 \(R\) 上节点的键值。合并后返回新树的根,显然,新树的根是 \(L\) 和 \(R\) 中优先级最大的那个。
因为分裂和合并的最多操作次数就是从根到叶子节点,而 Treap 树的高度的期望值为 \(O(\log n)\),所以算法的复杂度也为 \(O(\log n)\)。
基本操作
通过分裂与合并这两种操作,在很多情况下可以比旋转 Treap 更方便的实现别的操作。下面逐一介绍这两种操作。
插入节点
插人一个新节点的步骤:按新节点 \(x\) 的键值把树分裂为 \(L\) 和 \(R\) 两棵;合并 \(L\) 和 \(x\);继续与 \(R\) 合并,得到一棵新树。

接下来举例说明分裂和合并的具体实现:
- 原 Treap 树包含节点 \(\{1,2,4,7,8\}\),优先级分别为 \(\{4,19,7,13,9\}\),准备加入新节点 \(x=5\)。
- 把 Treap 树按节点值 \(x=5\) 分裂成两棵,小于或等于 \(5\) 的节点在左边的树上,大于 \(5\) 的节点在右边的树上。分裂后的两棵树应该仍是 Treap 树。

分裂如何实现? 下面的代码展示了分裂操作的完成方法。执行完毕后,返回两棵树的根 \(L\) 和 \(R\),后续操作通过 \(L\) 和 \(R\) 访问这两棵树。分裂只用到了节点的键值 \(key\),没有用到节点的优先级,因为分裂的过程不会破坏优先级。代码最关键的是第 \(8\) 行和第 \(11\) 行,通过递归继续分裂,并在回溯时改变 \(rs\) 和 \(ls\)。例如上图中节点 \(4\) 在分裂前是节点 \(7\) 的左子树,分裂后变为节点 \(2\) 的右子树。
void Split(int u, int x, int &L, int &R) {
if (u == 0) {
L = R = 0;
return;
}
if (t[u].key <= x) {
L = u;
Split(t[u].rs, x, t[u].rs, R);
} else {
R = u;
Split(t[u].ls, x, L, t[u].ls);
}
}
注意,此处的分裂是按键值进行分裂,称为“权值分裂”。分裂后,左树上所有节点的键值 \(key\) 都小于右树。
有时需要按排名顺序 \(r\) 进行分裂,称为“排名分裂”,就是把整棵树的中序遍历的前 \(x\) 个节点放在 \(L\) 上,其他节点放在 \(R\) 上。可以把按排名进行计算的树称为“区间树”,把按权值进行计算的树称为“权值树”。
如前图所示,首先把左树与节点 \(5\) 合并,然后继续与右树合并。
下面是合并的代码,合并树 \(L\) 和 \(R\)。因为有 \(L\) 上所有节点键值 \(key\) 都小于 \(R\) 的节点的隐含条件,所以合并时只需要考虑节点的优先级 \(pri\)。合并后的新树,左边是原 \(L\) 的节点, 右边是原 \(R\) 的节点。代码中最重要的是第 \(4\) 行和第 \(7\) 行。
int Merge(int L, int R) { //合并以L和R为根的两棵树,返回一棵树的根
if (L == R) return L + R; //到达叶子,如 L==0 就是返回L+R=R
if (t[L].pri > t[R].pri) { //左树L优先级大于右树R,则L节点是父节点
t[L].rs = Merge(t[L].rs, R); //合并R和工的右儿子,并更新工的右儿子
return L; //合并后的根是L
} else { //合并后R是父节点
t[R].ls = Merge(L, t[R].ls); //合并L和R的左儿子,并更新R的左儿子
return R; //合并后的根是R
}
}
删除节点
删除一个节点 \(x\),先通过分裂剥离出 \(x\),然后合并。删除步骤:把树按 \(x\) 分裂为根小于或等于 \(x\) 的树 \(A\) 和根大于 \(x\) 的树 \(B\);再把 \(A\) 分裂为根小于 \(x\) 的树 \(C\) 和根等于 \(x\) 的树 \(D\);合并 \(D\) 的左右儿子得树 \(E\),也就是删除了 \(x\);最后合并 \(C\)、\(E\)、\(B\)。
注意,上述是“权值分裂”的删除,而“排名分裂”的删除操作略有不同。
排名
求数字 \(x\) 的排名。代码可以和旋转法的代码一样,这里给出一种新方法。在每个节点 上,用 \(size\) 记录以它为根的子树的数量。求数字 \(x\) 的排名,把树按 \(x-1\) 分裂成 \(A\) 和 \(B\),\(A\) 中包含了所有小于 \(x\) 的数,那么 \(x\) 的排名等于\(A\) 的 \(size\) 加 \(1\)。排名之后合并 \(A\) 和 \(B\) 恢复成 原来的树。
求第 k 大数
代码与旋转法一样,不需要分裂和合并操作。
前驱
求比 \(x\) 小的数。把树按 \(x-1\) 分裂成 \(A\) 和 \(B\),在 \(A\) 中找最大的数(利用求第 \(k\) 大数操作)。找到后,合并 \(A\) 和 \(B\) 恢复成原来的树。
后继
求比 \(x\) 大的数。把树按 \(x\) 分裂成 \(A\) 和 \(B\),在 \(B\) 中找最小的数(利用求第 \(k\) 大数操作)。 找到后,合并 \(A\) 和 \(B\) 恢复成原来的树。
模板
#include <bits/stdc++.h>
#define ll long long
using namespace std;
constexpr int N = 1e6 + 5;
mt19937 rnd(time(0));
int n, cnt, rt;
struct Node {
int key, pri, sz;
int ls, rs;
} t[N];
void addNode(int x) {
t[++cnt].key = x;
t[cnt].ls = t[cnt].rs = 0;
t[cnt].sz = 1;
t[cnt].pri = rnd();
}
void Update(int u) {
t[u].sz = t[t[u].ls].sz + t[t[u].rs].sz + 1;
}
void Split(int u, int x, int &l, int &r) {
if (u == 0) {
l = r = 0;
return;
}
if (t[u].key <= x) {
l = u;
Split(t[u].rs, x, t[u].rs, r);
} else {
r = u;
Split(t[u].ls, x, l, t[u].ls);
}
Update(u);
}
int Merge(int l, int r) {
if (l == 0 || r == 0) return l + r;
if (t[l].pri > t[r].pri) {
t[l].rs = Merge(t[l].rs, r);
Update(l);
return l;
} else {
t[r].ls = Merge(l, t[r].ls);
Update(r);
return r;
}
}
void Insert(int x) {
int l, r;
Split(rt, x, l, r);
addNode(x);
rt = Merge(Merge(l, cnt), r);
}
void Delete(int x) {
int l, r, p;
Split(rt, x, l, r);
Split(l, x - 1, l, p);
p = Merge(t[p].ls, t[p].rs);
rt = Merge(Merge(l, p), r);
}
void Rank(int x) {
int l, r;
Split(rt, x - 1, l, r);
cout << t[l].sz + 1 << '\n';
rt = Merge(l, r);
}
int Kth(int u, int k) {
if (k == t[t[u].ls].sz + 1) return u;
if (k < t[t[u].ls].sz + 1) return Kth(t[u].ls, k);
if (k > t[t[u].ls].sz + 1) return Kth(t[u].rs, k - t[t[u].ls].sz - 1);
}
void Prequery(int x) {
int l, r;
Split(rt, x - 1, l, r);
cout << t[Kth(l, t[l].sz)].key << '\n';
rt = Merge(l, r);
}
void Nxtquery(int x) {
int l, r;
Split(rt, x, l, r);
cout << t[Kth(r, 1)].key << '\n';
rt = Merge(l, r);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
int op, x;
for (int i = 1; i <= n; i++) {
cin >> op >> x;
if (op == 1) Insert(x);
else if (op == 2) Delete(x);
else if (op == 3) Rank(x);
else if (op == 4) cout << t[Kth(rt, x)].key << '\n';
else if (op == 5) Prequery(x);
else if (op == 6) Nxtquery(x);
}
return 0;
}
例题
例 1:P1533 可怜的狗狗
给你一列数,以及 \(m\) 个询问,每次求 \(l,r\) 区间内的第 \(k\) 小数。
思路
这道题非常显然的树形数据结构,做法很多,主席树、整体二分都可以。因为这里这里讲的是 Treap,所以这里使用离线+Treap 的方法来做。
首先肯定是将询问排序,至于如何排,因为题目中说了“他喂的每个区间 \((i,j)\) 不互相包含“,这样一来只要一左端点为第一关键字,右端点为第二关键字从小到大就行了。
具体来讲,我们定义左右指针 \(l,r\) 表示当前左右端点,由于排序后询问区间的左右端点单调递增,就有了以下操作:
while(r<que[i].r) Insert(root,w[++r]); while(l<que[i].l) Remove(root,w[l++]); ans[que[i].id]=getval(root,que[i].k+1);即当左右指针小于询问的左右端点时,不断右移,完成后查询并记录即可。
参考代码
#include<bits/stdc++.h> using namespace std; constexpr int N = 3e5 + 5; mt19937 rnd(time(0)); int n, m, cnt, rt; int ans[N], w[N]; struct Ask { int l, r, k, id; bool operator < (const Ask &o) const { if (l == o.l) return r < o.r; return l < o.l; } } a[50005]; struct Node { int key, pri, sz; int ls, rs; } t[N]; void Update(int p) { t[p].sz = t[t[p].ls].sz + t[t[p].rs].sz + 1; } int addNode(int x) { t[++cnt].key = x; t[cnt].ls = t[cnt].rs = 0; t[cnt].sz = 1; t[cnt].pri = rnd(); return cnt; } void split(int p, int k, int &x, int &y) { if (!p) { x = y = 0; return; } if (t[p].key <= k) { x = p; split(t[p].rs, k, t[p].rs, y); } else { y = p; split(t[p].ls, k, x, t[p].ls); } Update(p); } int merge(int x, int y) { if (!x || !y) return x + y; if (t[x].pri > t[y].pri) { t[x].rs = merge(t[x].rs, y); Update(x); return x; } else { t[y].ls = merge(x, t[y].ls); Update(y); return y; } } void Insert(int x) { int l, r; split(rt, x, l, r); rt = merge(merge(l, addNode(x)), r); } void Delete(int x) { int l, mid, r; split(rt, x - 1, l, r); split(r, x, mid, r); mid = merge(t[mid].ls, t[mid].rs); rt = merge(merge(l, mid), r); } int Kth(int p, int k) { if (k == t[t[p].ls].sz + 1) return t[p].key; if (k <= t[t[p].ls].sz) return Kth(t[p].ls, k); return Kth(t[p].rs, k - t[t[p].ls].sz - 1); } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> n >> m; for (int i = 1; i <= n; i++) cin >> w[i]; for (int i = 1; i <= m; i++) { cin >> a[i].l >> a[i].r >> a[i].k; a[i].id = i; } sort(a + 1, a + m + 1); rt = 0; for (int i = 1, l = 1, r = 0; i <= m; ++i) { while (r < a[i].r) Insert(w[++r]); while (l < a[i].l) Delete(w[l++]); ans[a[i].id] = Kth(rt, a[i].k); } for (int i = 1; i <= m; ++i) cout << ans[i] << '\n'; return 0; }
End
课后作业
- 洛谷:P13900, P13908, P13968, P13981, P14003, P14180, P14379, P14494, P14761
AVL树,全称为平衡二叉搜索树,是一种自平衡的二叉搜索树。其主要特点是平衡因子,主要作用是提高查找、插入和删除操作的效率。 ↩︎



浙公网安备 33010602011771号