Treap 树

引入与简介

Treap 树是一种原理比较简单的弱平衡二叉搜索树。其中 Treap 是一个合成词,把 Tree 和 Heap 各取 一半组合而成,Treap 是树和堆的结合,通常翻译成树堆。其支持插入节点、删除节点、求第 \(x\) 大的节点、求权值为 \(x\) 的节点的排名、求权值比 \(x\) 小的最大节点、求权值比 \(x\) 大的最小节点。

堆是一棵树,其每个节点都有一个键值,且每个节点的键值都大于等于或小于等于其父亲的键值。每个节点的键值都大于等于其父亲键值的堆叫做小根堆,否则叫做大根堆。STL 中的 priority_queue 其实就是一个大根堆。

(小根)堆主要支持的操作有:插入一个数、查询最小值、删除最小值、合并两个堆、减小一个元素的值。

一些功能强大的堆(可并堆)还能(高效地)支持 merge 等操作。一些功能更强大的堆还支持可持久化,也就是对任意历史版本进行查询或者操作,产生新的版本。

二叉搜索树

二叉搜索树是一种二叉树的树形数据结构,其定义如下:

  1. 空树是二叉搜索树。

  2. 若二叉搜索树的左子树不为空,则其左子树上所有点的附加权值均小于其根节点的值。

  3. 若二叉搜索树的右子树不为空,则其右子树上所有点的附加权值均大于其根节点的值。

  4. 二叉搜索树的左右子树均为二叉搜索树。

二叉搜索树上的基本操作所花费的时间与这棵树的高度成正比。对于一个有 \(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 树的过程有两步:

  1. \(k\) 按键值大小插入一个空的叶子节点。

  2. \(k\) 随机分配一个优先级,如果 \(k\) 的优先级违反了堆的性质,即它的优先级比父结点高,那么进行调整,让 \(k\) 往上走,替代父结点,最后得到一个新的 Treap 树。

新节点的插入与调整

  1. 图 (2) 插入 \(d\) 点,按朴素的插入方法插入到底部;
  2. 图 (3) \(d\) 的优先级比父结点 \(c\) 高,左旋,上升;
  3. 图 (4) \(d\) 的优先级比新的父结点 \(b\) 高,继续左旋上升;
  4. 图 (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)\)

  1. 分裂:void Split(int u ,int x,int &L,int &R)。其中 &L&R 是引用传递,函数返回 \(L\)\(R\) 的值。把一棵以 \(u\) 为根的 Treap 树按键值分裂,返回分别以 \(L\)\(R\) 为根的两棵树,其中左树 \(L\) 上所有节点的键值都小于或等于 \(x\),右树 \(R\) 上所有节点的键值都大于 \(x\)
  2. 合并: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\) 合并,得到一棵新树。

接下来举例说明分裂和合并的具体实现:

  1. 原 Treap 树包含节点 \(\{1,2,4,7,8\}\),优先级分别为 \(\{4,19,7,13,9\}\),准备加入新节点 \(x=5\)
  2. 把 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

  1. AVL树,全称为平衡二叉搜索树,是一种自平衡的二叉搜索树。其主要特点是平衡因子,主要作用是提高查找、插入和删除操作的效率↩︎

posted @ 2026-04-01 16:50  绪风ﺴﻬৡ  阅读(3)  评论(0)    收藏  举报
当前时间: