学习笔记 ---- 平衡树

平衡树的含义

平衡树是一种特殊的 二叉查找树(Binary Search Tree, BST),其重要目的是在保持二叉查找树的基本特性的基础上,通过额外的约束条件确保 树的高度尽可能均衡。从而保证在插入,删除,修改和查找等操作保持较高的性能。

平衡树线段树 对比:
优点:

  1. 文艺平衡树 能够支持在序列中 插入 一个元素或一段区间。序列线段树 无法做到。
  2. 文艺平衡树 可以支持 区间翻转,线段树无法做到。

缺点:

  1. 码量大,细节比较多。
  2. 仅仅维护 集合,不维护 下标 的平衡树一般可以用 值域线段树 代替。

二叉搜索树 是学习平衡树的基础,因此我们先来学习二叉搜索树。

二叉搜索树

二叉搜索树是 以某一关键字排序 的二叉树。满足左儿子都比根小,右儿子都比根大,同时左右子树都是二叉搜索树。二叉搜索树的中序遍历就是集合内的元素按照关键字排序后的结果。下面以 数值 为关键字的二叉搜索树为例说明。

如图,这就是一棵二叉搜索树的形态:
在这里插入图片描述
二叉搜索树支持以下操作:

插入节点

假设我们要插入一个数 \(x\),就不断比较它和根的大小关系。(假设没有相同的元素)

  • 比根小,递归左子树。

  • 比根大,递归右子树。

  • 递归到一个空结点,插入。
    在这里插入图片描述
    删除节点

  • 这个点是叶子,直接删除即可。

  • 这个点只有一个儿子,删去这个点,让它儿子的父亲指向它的父亲。

  • 有两个儿子。那么首先递归到右子树,然后一直往左递归找到叶子,交换叶子和这个节点后把叶子删掉。比如要删 \(7\)
    在这里插入图片描述
    找到 \(8\) 之后交换 \(7, 8\) 再把 \(7\) 删掉。
    在这里插入图片描述
    这样做是对的原因是:找到的叶子相当于是删除节点的 后继。交换这两个节点后再把它删去不会影响别的节点的 中序遍历顺序

查询 \(x\) 的排名

不断比较 \(x\) 与当前根的大小关系直到递归到空结点。

  • \(x\) 大于当前根,排名加上左子树的元素个数 \(+1\),往右子树递归。
  • 否则往左子树递归。

查询第 \(x\)

不断比较 \(x\) 与当前根的左子树中元素个数的关系。

  • \(x\) 等于左子树大小 \(+1\),答案就是当前根。
  • \(x\) 小于等于左子树大小,往左子树递归。
  • \(x\) 大于左子树大小 \(+1\),往右子树递归。

查询 \(x\) 的前驱 / 查询 \(x\) 的后继
与上面方法类似。
或者可以调用上面两个函数完成。

二叉搜索树支持的操作很多,但缺点是复杂度不稳定
当搜索树退化成一条 时,单次操作的复杂度就达到了 \(O(n)\) 量级。
在这里插入图片描述
而不同的平衡树就是通过不同的方法去 平衡树高 以保证复杂度。

下面介绍三种常见平衡树。

\(treap\)

首先我们需要知道: \(treap = tree + heap\)
其中 \(tree\) 代表二叉搜索树, \(heap\) 代表堆。相当于是二者的结合。

\(treap\) 无法支持序列操作,只能够维护集合。
\(treap\) 支持的操作:插入一个数,删掉一个数,查询集合第 \(k\) 大,查询一个数 \(x\) 在集合中的排名,求数 \(x\) 的 前驱 和 后继。

\(treap\) 的核心思想是 利用二叉搜索树的性质完成各种操作,利用堆的性质平衡树高以优化复杂度

首先需要学习 \(treap\) 的核心操作: \(rotate\) 函数

把右儿子向上翻转称作 左旋,左儿子向上翻转称作 右旋
口诀:左旋拎右左连右,右旋拎左右连左

\(rotate\) 可以用来 在不改变二叉搜索树排序性质的基础上,将一个点旋转到它父亲的位置
比如下图:
在这里插入图片描述
要把 \(6\) 转到 \(4\) 的位置:
在这里插入图片描述
不难发现这样操作对于二叉搜索树的中序遍历是没有影响的,因此不会改变二叉搜索树的性质。

然后 \(treap\) 的具体做法就是每次插入一个节点时 给这个节点随机一个权重 \(w\)。我们刚插入时这个点一定是叶子,然后每次跟它父亲的权重比较,如果它更大就把它旋转到父亲的位置。相当于同时维护了一个 大根堆

别的操作不用改变。这样树高可被优化到 \(\log n\),单次操作复杂度为 \(O(\log n)\)
代码就不放了,因为也不太常用。
可以借鉴这篇博客

\(Splay\)(延伸树)

\(Splay\) 是一种比较特殊的平衡树,它不需要向别的平衡树一样将树高维持在 \(log\),并且在操作过程中树高可能达到 \(O(n)\) 量级。但是通过 均摊 可将单次复杂度均摊到 \(\log\) 级别,并且相对于其它平衡树而言较快。

发明者是非常 \(nb\)\(Tarjan\) 神仙。

优化思想

\(splay\) 的含义为 延伸\(Splay\) 主要是通过每次 将最后经过的节点延伸到根 来均摊复杂度。而延伸过程需要用到上文提到的 \(rotate\) 函数

\(Splay\) 的定义

struct Splay {
	int root, tot; // root -> 当前搜索树的根, tot -> 用来新建节点 
	struct Node {
		int son[2]; // 左右儿子
		int fa, sz; // 父亲,子树大小
		int v; // 当前节点的权值 
		... // 一些别的参量。文艺平衡树中多有定义
	}spl[N];
	... // 若干函数 
}Spl;

核心操作

\({\LARGE connect}\)
\(connect\) 操作是用来给两个节点建立父子关系。

void connect(int p, int fa, int k) { // p 是 fa 的 k 儿子 (k = 0 代表左, k = 1 代表 右) 
	spl[p].fa = fa;
	spl[fa].son[k] = p;
}

\({\LARGE ident}\)
\(ident\) 操作是用来识别节点 \(p\) 是它父亲的左儿子还是右儿子。

int ident(int p, int fa) {
	return spl[fa].son[1] == p;
}

\({\LARGE newnode}\)
\(newnode\) 的作用是新建一个节点。

void newnode(int &p, int val, int fa) { // 建立新节点  
	p = ++ tot;
	spl[p].fa = fa;
	spl[p].val = val;
	spl[p].sz = 1;
}

\({\LARGE update}\)
向线段树一样, \(update\) 是为了更新子树信息。代码下方的省略号表示文艺平衡树中会有更多别的信息修改。

void update(int p) {
	spl[p].sz = spl[spl[p].son[0]].sz + spl[spl[p].son[1]].sz + 1;
	...
}

\({\LARGE rotate}\)

\(rotate\) 操作综合了 左旋(\(zag\))右旋(\(zig\))

左旋(\(zag\)) 是指将节点 \(p\)右儿子 旋转到 \(p\) 的位置。

右旋(\(zig\)) 是指将节点 \(p\)左儿子 旋转到 \(p\) 的位置。

这两种操作实际上只有一个异或 \(1\) 的改变。

\(rotate\) 操作则是将任意一个节点旋转到它的父亲位置,并且不改变二叉搜索树的性质。

口诀:左旋拎右左连右,右旋拎左右连左

解释就是:
\(p\) 就拎起儿子,将右儿子的儿子连在 \(p\)儿子位置。将 \(p\) 连在右儿子的左儿子位置,然后将右儿子连在原来 \(p\) 在它父亲的儿子位置上。右旋同理。使用上面的 \(ident\)\(connect\) 函数,可以比较短的写出代码:

void rotate(int p) {
	int fa = spl[p].fa, ffa = spl[fa].fa, k = ident(p, fa);
	connect(spl[p].son[k ^ 1], fa, k); // 从下到上的连接顺序,最好不要弄错。
	connect(fa, p, k ^ 1);
	connect(p, ffa, ident(fa, ffa));
	update(fa); update(p);
}

\({\LARGE splaying}\)
\(splaying\) 操作的作用是 将一个点延伸到某个位置。它也是 \(splay\) 能够不将树高维持在 \(\log\) 复杂度却依然正确的核心。
基础的用法是 每次操作后将最后经过的点旋转到根,这样复杂度可以用 势能法 分析是均摊 \(\log\) 的(我不会)。
但是这里的延伸有一些规则,在满足规则的条件下 \(splay\) 的复杂度才是正确的。在讲解规则前,我们先来了解 同构调整异构调整 的概念:

  • 同构调整
    考虑点 \(p\),和它的父亲 \(fa\),以及它的爷爷 \(ffa\) 的位置关系,如果形态上构成一条链,那么把 \(p\) 延伸到 \(ffa\) 的位置成为同构调整。如下图两种情况
    在这里插入图片描述
    在这里插入图片描述
    同构调整 可以先 \(rotate(fa)\),再 \(rotate(p)\)\(p\) 旋转到 \(ffa\) 的位置。
    注意:虽然先 \(rotate(p)\),再 \(rotate(p)\) 也可以将 \(p\) 旋转到 \(ffa\),但是这样 会导致复杂度不正确。因此必须按照上面的顺序旋转。
  • 异构调整
    如果 \(p, fa, ffa\) 的位置形成 \(<\)\(>\) 的形态,那么将 \(p\) 延伸到 \(ffa\) 的位置称为异构调整。
    在这里插入图片描述
    在这里插入图片描述
    异构调整 只能先 \(rotate(p)\),再 \(rotate(p)\)\(p\) 延伸到 \(ffa\) 的位置。

由于旋转了两次,我们成上述两种操作为 双旋。只对 \(p\) 旋转称为 单旋

那么 \(splaying\) 的规则是:如果当前点 \(p\) 与目标位置的距离大于等于 \(2\),那么需要判断是 同构调整 还是 异构调整 来进行相应 双旋。否则仅对 \(p\) 进行一次 单旋

我们来看看具体实现:

void splaying(int p, int top) { // 将 p 旋转成 top 的儿子 
	if(!top) root = p; // 根的父亲是 0,所以如果top是0相当于把p旋转到根
	while(spl[p].fa != top) { // 一直往上旋转 
		int fa = spl[p].fa, ffa = spl[fa].fa;
		if(ffa != top) { // 双旋	
			if(ident(p, fa) == ident(fa, ffa)) rotate(fa); // 同构
			else rotate(p); // 异构
		}
		rotate(p); // 最后都需要旋转 p,因此这样写可以简化代码
	}
}

\({\LARGE ins}\)
\(ins\) 操作的作用是插入一个点。根据二叉搜索树的性质找到插入点然后新建一个点即可。

void ins(int &p, int val, int fa) { // 插入一个节点   最后会splaying 不用update 
	if(!p) {
		newnode(p, val, fa); 
		splaying(p, 0); // 将 p 伸展到根的位置 
		return ;
	}
	if(val <= spl[p].val) ins(spl[p].son[0], val, p); // 插左边 
	else ins(spl[p].son[1], val, p); // 插右边 
}

\({\LARGE delnode}\)
\(delnode\) 的作用是删掉一个节点 \(p\)。 首先需要将 \(p\) 旋转到根。如果此时 \(p\) 没有右儿子,那么令左儿子为根即可。否则就从右儿子开始一直往左递归到叶子 \(q\)。那么根据二叉搜索树的性质可得到 \(q\)\(p\) 的后继。将 \(q\) 延伸到 \(p\) 的儿子位置,此时 \(q\) 一定是没有左儿子的(如果有 \(q\) 就不是 \(p\) 的后继),将 \(p\) 的左儿子连到 \(q\) 的左儿子位置,将根设置成 \(q\) 即可。

void delnode(int p) {
	splaying(p, 0);
	if(!spl[p].son[1]) {
		root = spl[p].son[0];
		spl[spl[p].son[0]].fa = 0;
	}
	else {
		int x = spl[p].son[1];
		spread(x);
		while(spl[x].son[0]) {
			x = spl[x].son[0];
			spread(x);
		}
		splaying(x, p);
		connect(spl[p].son[0], x, 0);
		root = x; spl[x].fa = 0;
	}
}

其余查找操作都可以根据二叉搜索树的性质查找。但是 一定要将最后经过的点旋转到根

例题:
普通平衡树(数据加强版)
CODE:

// Splay 模板 
#include<bits/stdc++.h>
using namespace std;
const int N = 2e6 + 10;
struct Splay {
	int root, tot; // 根 节点数 
	struct Node { // 节点 
		int son[2]; // 左右儿子 
		int fa, sz, val; // 父亲, 子树大小, 这个节点的权值 
	}spl[N];
	void connect(int x, int fa, int k) { // x 是 fa 的 k 儿子 
		spl[fa].son[k] = x;
		spl[x].fa = fa;
	}
	int ident(int x, int fa) { // 检验 x 是 fa 的 左/右儿子 
		return spl[fa].son[1] == x;
	}
	void update(int p) {
		spl[p].sz = spl[spl[p].son[0]].sz + spl[spl[p].son[1]].sz + 1;
	}
	void rotate(int p) { // 将 p 旋转到它父亲的位置 
		int fa = spl[p].fa, ffa = spl[fa].fa, k = ident(p, fa);
		connect(spl[p].son[k ^ 1], fa, k);
		connect(fa, p, k ^ 1);
		connect(p, ffa, ident(fa, ffa));
		update(fa); update(p); 
	}
	void splaying(int p, int top) { // 将 p 旋转成 top 的儿子 
		if(!top) root = p;
		while(spl[p].fa != top) { // 一直往上旋转 
			int fa = spl[p].fa, ffa = spl[fa].fa;
			if(ffa != top) { // 双旋	
				if(ident(p, fa) == ident(fa, ffa)) rotate(fa);
				else rotate(p);
			}
			rotate(p);
		}
	}
	void newnode(int &p, int val, int fa) { // 建立新节点 
		p = ++ tot;
		spl[p].fa = fa;
		spl[p].val = val;
		spl[p].sz = 1;
	}
	void ins(int &p, int val, int fa) { // 插入一个节点   最后会splaying 不用update 
		if(!p) {
		    newnode(p, val, fa); 
		    splaying(p, 0); // 将 p 伸展到根的位置 
		    return ;
		}
		if(val <= spl[p].val) ins(spl[p].son[0], val, p); // 插左边 
		else ins(spl[p].son[1], val, p); // 插右边 
	}
	void delnode(int p) { // 删掉 p 节点  首先将节点旋转到根,然后找后继 
	    splaying(p, 0);
		if(!spl[p].son[1]) { // 没有后继 
			root = spl[p].son[0];
			spl[root].fa = 0;
			return ;
		}
		else { // 存在后继 
			int x = spl[p].son[1];
			while(spl[x].son[0]) x = spl[x].son[0]; // 找左儿子 
			splaying(x, p); // 这时候 x 一定是没有左儿子的 
			root = x; spl[x].fa = 0; // 变成根 
			connect(spl[p].son[0], x, 0);
			update(x);
		}
	}
	void del(int p, int val) { // 删除权值val   首先先找到这个节点,然后把它旋转到根,然后再删掉 
		if(spl[p].val == val) {
			delnode(p);
			return ;
		}
		if(val < spl[p].val) del(spl[p].son[0], val);
		else del(spl[p].son[1], val);
	}
	int rank(int val) { // 查 val 的排名  
		int p = root, pre = 0, rk = 1; // pre 表示上一次经过的节点 
		while(p) {
			pre = p;
			if(val <= spl[p].val) p = spl[p].son[0];
			else rk += spl[spl[p].son[0]].sz + 1, p = spl[p].son[1];
		}
		if(pre) splaying(pre, 0);
		return rk;
	}
	int Kth(int k) { // 查询第k小 
		int p = root, pre = 0, res = 0;
		while(p) {
			pre = p;
			if(spl[spl[p].son[0]].sz + 1 == k) {pre = p; res = spl[p].val; break;}
			else if(spl[spl[p].son[0]].sz >= k) p = spl[p].son[0];
			else k -= (spl[spl[p].son[0]].sz + 1), p = spl[p].son[1];
		}
		if(pre) splaying(pre, 0);
		return res;
	}
}Spl;
int n, m, lstans, res;
int main() {
	scanf("%d%d", &n, &m);
	for(int i = 1; i <= n; i ++ ) {
		int x; scanf("%d", &x);
		Spl.ins(Spl.root, x, 0);
	}
	for(int i = 1; i <= m; i ++ ) {
		int opt, x; scanf("%d%d", &opt, &x);
		x ^= lstans;
		if(opt == 1) Spl.ins(Spl.root, x, 0);
		if(opt == 2) Spl.del(Spl.root, x);
		if(opt == 3) lstans = Spl.rank(x), res ^= lstans;
		if(opt == 4) lstans = Spl.Kth(x), res ^= lstans;
		if(opt == 5) lstans = (Spl.Kth(Spl.rank(x) - 1)), res ^= lstans; // 前驱 
		if(opt == 6) lstans = (Spl.Kth(Spl.rank(x + 1))), res ^= lstans; // 后继 
	}
	cout << res << endl;
	return 0;
}

文艺平衡树(序列操作)

普通平衡树是用来维护集合的。
所谓文艺平衡树,就是使用 平衡树维护序列
我们序列每个数以 下标 为关键值建立平衡树。那么这个平衡树的中序遍历就是原序列。
一棵子树可以代表一段区间,每个节点可以维护一些额外信息表示它的子树对应区间的信息。
文艺平衡树可以支持很多序列操作,它可以支持一般的线段树操作,并且还能够支持 区间翻转在序列某个位置插入元素 等线段树不能完成的操作。
下面我们来通过一道例题学习一下:

【模板】文艺平衡树
题意:
给你一个长度为 \(n\) 的有序序列 \(a_i\),有 \(m\) 次操作,每次操作给出 \(l, r\) 表示把 \([l, r]\) 区间翻转。求 \(m\) 次操作后的序列。

数据规模:
\(1 \leq n, m \leq 100000\)

分析:
首先按照下标为关键值建立平衡树,同时记录每个节点存储的真实值。

对于一次操作 \((l, r)\),找到 \(l - 1\) 所在的节点 \(p\) 并将它延伸到根,然后找到 \(r + 1\) 对应的节点 \(q\) 并将它延伸到 \(p\) 的儿子位置。由于当 \(l = 1\)\(r = n\) 时会找到空节点,因此我们开始时就插入 \(0\)\(n + 1\) 这两个 哨兵节点 来处理边界。

由于延伸操作不改变二叉树的中序遍历,那么此时 \(q\) 的左儿子对应的子树就是 \([l, r]\) 区间。记这个左儿子为 \(son_{q, 0}\)

对于区间翻转操作,相当于就是 交换 \(son_{q, 0}\) 子树中的所有节点的左右儿子。因为这样就可以将遍历顺序倒过来。
所以我们类比 线段树区间修改 操作,将 \(son_{q, 0}\) 的左右儿子交换,并打上标记。标记的含义表示当前节点的信息已经修改过了,但是还没有往下传,如果经过一个节点就把标记下传。

需要注意的是我们 不能让向上延伸的点所要经过的路径上存在没有下传的标记,否则会因为父子关系的改变导致标记传给本来不应该传递的点。因此每次 \(splaying\) 一个点之前,都应该先使用 \(Find\) 函数从上至下找到它,在找的过程中顺便把经过的所有点的标记下传。

最后找到每个位置上的点并输出它的真实值即可。
时间复杂度 \(O(m \times \log_2n)\)

\(Find\) 函数:

int Find(int x) { // 找到 排名为 x 的节点 
	int p = root;
	while(p) {
		spread(p);
		if(spl[spl[p].son[0]].sz + 1 == x) return p;
		else if(spl[spl[p].son[0]].sz >= x) p = spl[p].son[0];
		else x -= spl[spl[p].son[0]].sz + 1, p = spl[p].son[1];
	}
}

CODE:

// 文艺平衡树
// 支持序列翻转, 这个是平衡树做不到的 
#include<bits/stdc++.h>
using namespace std;
const int N = 1e5 + 10;
int n, m, a[N];
struct Splay {
	int root, tot;
	struct Node {
		int son[2], fa;
		int sz, val, rev;
	}spl[N];
	void update(int p) {
		spl[p].sz = spl[spl[p].son[0]].sz + spl[spl[p].son[1]].sz + 1;
	}
	void connect(int p, int fa, int k) { // 建立父子关系 
		spl[p].fa = fa; 
		spl[fa].son[k] = p;
	}
	int ident(int p, int fa) {
		return spl[fa].son[1] == p;
	}
	void rotate(int p) {
		int fa = spl[p].fa, ffa = spl[fa].fa, k = ident(p, fa);
		connect(spl[p].son[k ^ 1], fa, k);
		connect(fa, p, k ^ 1);
		connect(p, ffa, ident(fa, ffa));
		update(fa); update(p);
	}
	void splaying(int p, int top) {
		if(!top) root = p;
		while(spl[p].fa != top) {
			int fa = spl[p].fa, ffa = spl[fa].fa;
			if(ffa != top) { // 双旋 
				if(ident(fa, ffa) == ident(p, fa)) rotate(fa);
				else rotate(p);
			}
			rotate(p);
		}
	}
	void spread(int p) { // 传递翻转标记 
		if(spl[p].rev) {
			swap(spl[p].son[0], spl[p].son[1]);
			spl[spl[p].son[0]].rev ^= 1;
			spl[spl[p].son[1]].rev ^= 1;
			spl[p].rev = 0;
		}
	}
	int Find(int x) { // 找到 排名为 x 的节点 
		int p = root;
		while(p) {
			spread(p);
			if(spl[spl[p].son[0]].sz + 1 == x) return p;
			else if(spl[spl[p].son[0]].sz >= x) p = spl[p].son[0];
			else x -= spl[spl[p].son[0]].sz + 1, p = spl[p].son[1];
		}
	}
	int build(int l, int r) {
		if(l > r) return 0;
		int p = ++ tot;
		if(l == 0 && r == n + 1) spl[p].fa = 0;
		if(l == r) {
			spl[tot].sz = 1;
			spl[tot].val = a[l];
			return p;
		}
		int mid = (l + r >> 1);
		spl[p].val = a[mid];
		spl[p].son[0] = build(l, mid - 1);
		if(spl[p].son[0]) connect(spl[p].son[0], p, 0);
		spl[p].son[1] = build(mid + 1, r);
		if(spl[p].son[1]) connect(spl[p].son[1], p, 1);
		update(p);
		return p;
	}
	void Rev(int l, int r) { // 翻转 [l, r] 区间 
		int p = root;
		l = Find(l), r = Find(r + 2); // 找到l - 1, r + 1 所在编号,其实是为了传懒标记 
		splaying(l, 0); splaying(r, l);
		spl[spl[r].son[0]].rev ^= 1;
	}
	int rank(int k) {
		int p = root, pre = 0, res = 0;
		while(p) {
			spread(p); pre = p;
			if(spl[spl[p].son[0]].sz + 1 == k) {res = spl[p].val; break;}
			else if(spl[spl[p].son[0]].sz >= k) p = spl[p].son[0];
			else k -= spl[spl[p].son[0]].sz + 1, p = spl[p].son[1];
		}
		if(pre) splaying(pre, 0);
		return res;
	}
}Spl;
int main() {
	scanf("%d%d", &n, &m);
	for(int i = 0; i <= n + 1; i ++ ) a[i] = i;
	Spl.root = Spl.build(0, n + 1);
	for(int i = 1; i <= m; i ++ ) {
		int l, r; scanf("%d%d", &l, &r);
		Spl.Rev(l, r);
	}
	for(int i = 2; i <= n + 1; i ++ ) {
		printf("%d ", Spl.rank(i));
	}
	return 0;
}

文艺平衡树的功能非常强大,可以用它来完成一些线段树操作。比如区间加,求最大子段和等。

操作的关键在于 \(spread\) 函数(向下传递懒标记)和 \(update\) 函数。(向上修改子树信息)
\(update\) 函数一般会在 树的形态发生改变 时用到。所以 \(rotate\) 函数和 \(delnode\) 函数一般有 \(update\)。另外区间修改后也需要向上 \(update\) 两次。

对于一次区间修改(单点改也可以看作区间修改),我们一般的写法是:

  1. 使用 \(Find\) 函数找到两个节点 \(p, q\),同时将沿路所有标记下传
  2. \(splaying(p, 0)\)\(splaying(q, p)\)
  3. 修改 \(son_{q, 0}\) 的信息。
  4. \(update(q)\)\(update(p)\)

对于区间查询或者删除某个点,都需要先使用 \(Find\) 函数传递标记。

练习题

平衡树维护序列

P2596 [ZJOI2006] 书架
需要支持在序列上单点插入,单点删除,查询某个位置上的值,查询某个值所在的位置。
以下标为关键字建立 \(Splay\),同时维护 每个值所在的节点编号。前三种操作正常,第四种操作在这个节点往上跳的过程中计算。

P4036 [JSOI2008] 火星人
对每个询问二分答案。转化成你需要支持在序列上插入字符,修改字符,查询区间 \(hash\) 值。
\(Splay\) 维护 文艺平衡树,平衡树的每个节点维护它的子树对应区间的 \(hash\) 值。

P2042 [NOI2005] 维护数列
史题。所有操作平衡树都可以直接维护。但是有两个点需要注意:

  1. 删掉的节点编号可以放到一个数组里,下次新建直接使用减少空间复杂度。
  2. 对于往一个位置后插入若干数的操作,可以先把这些数建成一棵小的平衡树再插入,不要每次插入一个,复杂度会变劣。

P3215 [JSOI2011] 括号序列
挺好的题。
相当于给你一个 \(0/1\) 序列,你需要支持 区间赋值,区间翻转,区间异或 \(1\),区间查询最少将多少 \(0/1\) 反转使区间满足括号匹配的性质
建立一棵文艺平衡树。前三种操作的维护是简单的。
对于查询,一个很对的策略是将所有数依次入栈,能匹配就直接弹栈。最后肯定剩下形如 \(1111...000..\)。设 \(x\) 表示 \(1\) 的数量, \(y\) 表示 \(0\) 的数量。那么答案就是 \(\left \lfloor \frac{x}{2} \right \rfloor + \left \lfloor \frac{y}{2} \right \rfloor + (x\mod 2) + (y \mod 2)\)
平衡树维护这些信息即可。

平衡树维护数集

CF702F T-Shirts
感觉是很厉害的题。
考虑按照 \(q_i\) 从大到小扫每个物品,设物品的价值为 \(c_i\)。那么每次相当于把所有 \(v_i\) 大于等于 \(c_i\) 的人的 \(v_i\) 减去 \(c_i\),答案加 \(1\)
将所有 \(v_i\) 放进平衡树中维护。但是不可能每次将所有 \(v_i\) 大于等于 \(c_i\) 的数删掉再插进去。
考虑将所有数分成 \([0, c_i)\)\([c_i, 2c_i)\)\([2c_i, INF)\) 三组。
对于第一组,没有影响。
对于第二组,暴力将节点删除,将值更新后插入。
对于第三组,它们再平衡树上的位置不会变化,整体打上一个减法标记。

对于第二组,这些数每次至少减少一半。因此一个数最多被取出 \(\log V\) 次。因此总复杂度是 \(O(n \log V \log n)\) 的。

这个题还可以用 平衡树的有交合并 做。但是这个算法只能用 \(FHQ-treap\) 维护,具体思路看下面。

\(FHQ \ treap\)(无旋 \(treap\)

简介

\(FHQ-treap\) 是一种特殊的 \(treap\)
其维护树高的方式和 \(treap\) 一样,都是根据随机值维护堆的形态。
但与 \(treap\) 的旋转调整结构不同, \(FHQ-treap\) 不需要旋转。其核心操作为 分裂合并
它的操作形式使它很容易支持维护序列,可持久化。

核心操作

定义

int root, tot;
int rt1, rt2, rt3;
struct Node {
	int son[2];
	int sz, v;
	LL w; // v 为权值, w 为随机值
	... // 一些别的子树信息 
}t[N];

其中 \(rt_1,rt_2, rt_3\) 是用来承接分裂时得到的子树根。

分裂

称用来排序的比较值为 关键值。下面以数值作为关键值为例,即 平衡树维护数集 模型。

\(split(p, lim, \& x, \& y)\) 的作用是将以 \(p\) 为根的子树按照关键值 \(lim\) 分裂成两棵子树,满足一棵子树中关键值小于等于 \(lim\),另一棵关键值都大于 \(lim\),并且得到两棵子树根的编号。

称分裂得到的两棵子树中,所有点的关键值都小于等于 \(lim\) 的子树为 左树,都大于 \(v\) 的为 右树
根据平衡树二叉搜索树 左小右大 的特性,可以得到以下分裂过程:

  • 比较根节点的关键值 与 给定关键值的大小关系:
    1.若根节点的关键值 小于等于 给定关键值,那么将根与左子树都划分到左树中。向右子树递归,继续分裂子树。
    2.若根节点的关键值 大于 给定关键值,那么将根与右子树都划分到右树中。向左子树递归,继续分裂子树。

  • 递归到叶子,结束。

由于树高为 \(\log_2 n\),因此单次分裂的时间复杂度为 \(O(\log_2 n)\)
代码:

void split(int p, LL lim, int &x, int &y) {
	if(!p) {x = y = 0; return ;}
	if(t[p].v <= lim) x = p, split(t[p].son[1], lim, t[p].son[1], y);
	else y = p, split(t[p].son[0], lim, x, t[p].son[0]);
	update(p);
}

合并

\(Merge(x, y)\) 的作用是将 \(x,y\) 的子树合并为一棵,并且返回合并后树根的编号。
需要特别注意:合并的两棵树必须满足 \(x\) 中点的权值都小于等于 \(y\) 中的权值,即没有交叉。如果存在交叉,则需要平衡树的有交并。这个科技下面会讲到。

由于 \(x\) 子树中任一点权值都小于等于 \(y\) 中任一点权值,因此只需要比较随机值来维护堆的形态即可。
\(x\) 对应的子树为左树, \(y\) 对应的子树为右树。
过程:

  • 若左树根的随机值小于右树根,就将左树根定为当前根,将右树与左树的右子树接着合并。
  • 若右树根的随机值小于左树根,就将右树根定为当前根,将左树与右树的左子树接着合并。
  • 如果有有一棵树为空,就返回另一棵树的根。

由于树高为 \(\log_2 n\),因此单次合并的复杂度为 \(O(\log_2 n)\)
代码:

int Merge(int p, int q) {
	if(!p || !q) return p ^ q;
	if(t[p].w < t[q].w) {
		t[p].son[1] = Merge(t[p].son[1], q);
		update(p);
		return p;
	}
	else {
		t[q].son[0] = Merge(p, t[q].son[0]);
		update(q);
		return q;
	}
}

基本操作

\({\LARGE update}\)
\(update(p)\) 用来向上更新子树信息。

void update(int p) {
	t[p].sz = t[t[p].son[0]].sz + t[t[p].son[1]].sz + 1;
	... // 一些别的传递信息
}

\({\LARGE newnode}\)
\(newnode(v)\) 是用来新建一个权值为 \(w\) 的节点,并返回节点编号。

int newnode(int w) {
	tot ++;
	t[tot] = (Node) {{0, 0}, 1, v, rnd()};
	return tot;
}

\({\LARGE ins}\)
\(ins(v)\) 是用来插入一个值为 \(v\) 的数。
先按照 \(v\) 将原来树分裂,然后将 \(v\) 合并到左树,再将左右树合并。
复杂度 \(O(\log_2 n)\)

void ins(int v) {
	split(root, v, rt1, rt2);
	root = Merge(Merge(rt1, newnode(v)), rt2);
}

\({\LARGE del}\)
\(del(v)\) 的作用是删掉一个值为 \(v\) 的数。
先按照 \(v\) 将原来的树分成左右子树,然后将左树按照 \(v-1\) 分裂成两颗子树。将此时新生成的右树的根节点删掉,然后将三棵树合并即可。
复杂度 \(O(\log_2 n)\)

void del(int v) {
	split(root, v, rt1, rt2); 
	split(rt1, v - 1, rt1, rt3);
	rt3 = Merge(t[rt3].son[0], t[rt3].son[1]);
	root = Merge(Merge(rt1, rt3), rt2);
}

\({\LARGE rank}\)
\(rank(v)\) 的作用是查询 \(v\) 在数集中的排名。
按照 \(v-1\) 分裂, \(v\) 的排名就是左树大小 \(+1\)
复杂度 \(O(\log_2 n)\)

int rank(int v) {
	split(root, v - 1, rt1, rt2);
	int ans = t[rt1].sz + 1;
	root = Merge(rt1, rt2);
	return ans;
}

\({\LARGE Kth}\)
\(Kth(x)\) 的作用是找到序列中排名为 \(x\) 的数。
根据平衡树二叉搜索树的性质,直接从上到下找比较子树大小和剩余数量即可。
复杂度 \(O(\log_2 n)\)

int Kth(int p, int x) { // 查询第 x 个 
	while(p) {
		if(t[t[p].son[0]].sz >= x) p = t[p].son[0];
		else if(t[t[p].son[0]].sz + 1 == x) return t[p].v;
		else x -= t[t[p].son[0]].sz + 1, p = t[p].son[1];
	}
}

一些别的操作可以由上述操作组合完成。
关键都是在于使用 \(split\)\(Merge\) 函数。

文艺平衡树(序列操作)

同维护数集的方式一样,只需要将关键值变为下标即可。
能够支持线段树的所有操作:区间加,区间求和,求最大子段和... 并且也能支持 单点插入区间翻转 等。
对于一个区间修改或区间查询,假设区间为 \([l, r]\)
那么做法是先按照 \(r\) 分裂成 \([1, r]\)\([r + 1, n]\),然后按照 \(l - 1\)\([1, r]\) 分裂成 \([1, l - 1]\)\([l, r]\)。查询或打标记 \([l, r]\) 对应子树即可。
需要注意的是:
分裂合并 时需要向下传递标记,回溯的时候也需要向上传递信息。
单点查询与修改只需要根据二叉搜索树的性质找到对应节点即可。

文艺平衡树不需要插入哨兵节点

平衡树的有交并

一个很 \(NB\) 的科技。
首先先来思考一个问题:对于\(FHQ-treap\) 的合并操作,我们必须要求左树和右树不交(文艺平衡树肯定不交),这是为什么呢?
因为这样每次确定当前根,根的左右子树至少有一个已经定下来了,我们只需要接着去确定根的另一棵子树。由于树高为 \(\log_2n\),因此我们只会确定 \(\log_2 n\) 次根,复杂度为 \(O(\log_2n)\)

那如果左右树有交呢?发现我们未必能确定根的一棵子树的形态,还需要分别往左右子树确定。
但是平衡树的有交并告诉我们:直接暴力的往左右子树合并,复杂度就是均摊 \(O(\log V \log n)\)

怎么做呢?其实很简单:
设当前合并的子树为 \(p, q\)。注意 \(p, q\) 中的权值可能有交。\(p, q\) 的权值分别为 \(v_p, v_q\)

  • 比较 \(p, q\) 的随机值大小。
    1.若 \(p\) 的随机值更大,那么将 \(p\) 作为当前根。然后将 \(q\) 的子树按照 \(v_p\) 分裂为左右树 \(l_q, r_q\)。将 \(l_q\)\(ls_p\) 合并作为根的左子树, \(r_q\)\(rs_p\) 合并作为根的右子树。
    2.若 \(q\) 的随机值更大,那么将 \(q\) 作为当前根。然后将 \(p\) 的子树按照 \(v_q\) 分裂为左右树 \(l_p, r_p\)。将 \(l_p\)\(ls_q\) 合并作为根的左子树, \(r_p\)\(rs_q\) 合并作为根的右子树。
  • 如果 \(p, q\) 有一个为空,则返回另一个。

注意
注意到对于当前要分裂的树而言,设另一棵树根的值为 \(v\),那么实际上按照 \(v\) 还是 \(v - 1\) 分裂都能满足二叉搜索树的性质。但是我们每次都把 \(v\) 分到左树中,这样有可能会导致树高增大很多(我也不懂为啥)。因此每次分裂我们随机一个值 \(w \in \{0, 1\}\),然后按照 \(v - w\) 分裂。这样就不会 \(T\) 了。

代码:

int Merge(int p, int q) { // 有交合并
	if(!p || !q) return p ^ q;
	spread(p); spread(q);
	if(t[p].w < t[q].w) {
		int r1, r2;
		int w = rnd() % 2;
		split(q, t[p].v - w, r1, r2); // 分裂 
		t[p].son[0] = Merge(t[p].son[0], r1);
		t[p].son[1] = Merge(t[p].son[1], r2);
		return p;
	}
	else {
		int r1, r2;
		int w = rnd() % 2;
		split(p, t[q].v - w, r1, r2);
		t[q].son[0] = Merge(r1, t[q].son[0]);
		t[q].son[1] = Merge(r2, t[q].son[1]);
		return q;
}

练习题

P10284 [USACO24OPEN] Splitting Haybales P

分析:
考虑对 \(r\) 扫描线,同时维护所有 \(l\) 的答案。
扫到一个 \(l\),我们将 \(B\) 的草量将去 \(E\) 的草量插入数集中。
那么每扫到一个 \(r\),要做的就是将数集里小于等于 \(0\) 的数 \(+a_{r}\),将大于 \(0\) 的数 \(-a_r\)
平衡树的分裂可以做到将数集分成两部分,修改只需要打标记,最后有交并维护平衡树的形态即可。
复杂度 \(O(n \log_2V \log_2n)\)
CODE:

// fhq_treap
// 平衡树的有交合并 
// 支持单点插入, 有交合并 
#include<bits/stdc++.h>
#define pb emplace_back
using namespace std;
typedef long long LL;
const int N = 2e5 + 10;
mt19937_64 rnd(time(0));
int idq[N];
LL ans[N];
struct fhq_treap {
	int root, tot;
	int rt1, rt2, rt3;
	struct Node {
		int son[2];
		int fa;
		LL v, w, tag; // tag 表示加减标记 
	}t[N];
	void init(int p, LL c) {
		t[p].v += c;
		t[p].tag += c;
	}
	void spread(int p) {
		if(t[p].tag) {
			if(t[p].son[0]) init(t[p].son[0], t[p].tag);
			if(t[p].son[1]) init(t[p].son[1], t[p].tag);
			t[p].tag = 0;
		}
	}
	void split(int p, LL lim, int &x, int &y) {
		if(!p) {x = y = 0; return ;}
		spread(p);
		if(t[p].v <= lim) x = p, split(t[p].son[1], lim, t[p].son[1], y);
		else y = p, split(t[p].son[0], lim, x, t[p].son[0]);
		if(t[p].son[0]) t[t[p].son[0]].fa = p;
		if(t[p].son[1]) t[t[p].son[1]].fa = p;
	}
	int Merge(int p, int q) { // 有交合并
		if(!p || !q) return p ^ q;
		spread(p); spread(q);
		if(t[p].w < t[q].w) {
			int r1, r2;
			int w = rnd() % 2;
			split(q, t[p].v - w, r1, r2); // 分裂 
			t[p].son[0] = Merge(t[p].son[0], r1);
			t[p].son[1] = Merge(t[p].son[1], r2);
			if(t[p].son[0]) t[t[p].son[0]].fa = p;
			if(t[p].son[1]) t[t[p].son[1]].fa = p;
			t[p].fa = 0;
			return p;
		}
		else {
			int r1, r2;
			int w = rnd() % 2;
			split(p, t[q].v - w, r1, r2);
			t[q].son[0] = Merge(r1, t[q].son[0]);
			t[q].son[1] = Merge(r2, t[q].son[1]);
			if(t[q].son[0]) t[t[q].son[0]].fa = q;
			if(t[q].son[1]) t[t[q].son[1]].fa = q;
			t[q].fa = 0;
			return q;
		}
	}
	int newnode(LL v, int id) {
		tot ++;
		t[tot] = (Node) {{0, 0}, 0, v, rnd(), 0};
		idq[id] = tot;
		return tot;
	}
	void ins(LL v, int o) { // 插入一个权值 v 
		split(root, v, rt1, rt2);
		root = Merge(Merge(rt1, newnode(v, o)), rt2);
	}
	void change(LL v) { // 一个操作 v 
		split(root, 0, rt1, rt2);
		if(rt1) init(rt1, v);
		if(rt2) init(rt2, -v);
		root = Merge(rt1, rt2);
	}
	void dfs(int x) {
		if(!x) return ;
		dfs(t[x].fa);
		spread(x);
	}
	LL query(int id) {
		dfs(idq[id]);
		return t[idq[id]].v;
	}
	void print() {
		for(int i = 1; i <= tot; i ++ ) {
			printf("t[%d].v = %lld t[%d].ls = %d  t[%d].rs = %d  t[%d].fa = %d\n", i, t[i].v, i, t[i].son[0], i, t[i].son[1], i, t[i].fa);
		}
	}
} trp;
int n, q;
LL a[N], sv[N];
vector< int > add[N], del[N];
int main() {
	scanf("%d", &n);
	for(int i = 1; i <= n; i ++ ) {
		scanf("%lld", &a[i]);
	}
	scanf("%d", &q);
	for(int i = 1; i <= q; i ++ ) {
		int l, r;
		scanf("%d%d%lld", &l, &r, &sv[i]);
		add[l].pb(i); del[r].pb(i);
	}
	for(int i = 1; i <= n; i ++ ) {
		for(auto v : add[i]) {
			trp.ins(sv[v], v);
		}
		trp.change(a[i]);
		for(auto v : del[i]) ans[v] = trp.query(v);
	}
	for(int i = 1; i <= q; i ++ ) printf("%lld\n", ans[i]);
	return 0;
}

P2611 [ZJOI2012] 小蓝的好友

分析:
首先答案等于矩形的总数量减去不存在一个关键点的矩形数。
然后按照行从上往下扫,维护每一列向上延伸没有关键点的高度 \(h_i\)
计算以当前行为底不包含一个关键点的矩形数量是简单的:根据 \(h_i\) 建出笛卡尔树,然后树形 \(dp\) 即可。
由于关键点分布随机,因此 \(h_i\) 的大小也是随机的。因此这棵笛卡尔树可以看作是一棵树高为 \(\log_2n\) 的文艺平衡树。
那么相当于需要支持单点删除,单点插入。计算答案在 \(update\) 函数中算即可。

CODE:

// fhq_treap 
// 需要支持单点删除
#include<bits/stdc++.h>
using namespace std;
const int N = 2e5 + 10;
typedef long long LL;
mt19937_64 rnd(time(0));
int R, C, n;
LL res;
struct fhq_treap {
	int root, tot;
	int rt1, rt2, rt3;
	struct Node {
		int son[2];
		int sz, w;
		LL tag, ans;
	}t[N];
	void update(int p) {
		t[p].sz = t[t[p].son[0]].sz + t[t[p].son[1]].sz + 1;
		t[p].ans = t[t[p].son[0]].ans + t[t[p].son[1]].ans + 1LL * t[p].w * (t[t[p].son[0]].sz + 1LL) * (t[t[p].son[1]].sz + 1);
	}
	void add(int p, int c) { 
		t[p].tag += c;
		t[p].w += c;
		t[p].ans += 1LL * c * t[p].sz * (t[p].sz + 1) / 2LL;
	}
	void split(int p, int k, int &x, int &y) {
		if(!p) {x = y = 0; return ;}
		if(t[t[p].son[0]].sz + 1 <= k) x = p, split(t[p].son[1], k - (t[t[p].son[0]].sz + 1), t[p].son[1], y);
		else y = p, split(t[p].son[0], k, x, t[p].son[0]);
		update(p);
	}
	int Merge(int p, int q) {
		if(!p || !q) return p ^ q;
		if(t[p].w < t[q].w) {
			t[p].son[1] = Merge(t[p].son[1], q);
			update(p);
			return p;
		}
		else {
			t[q].son[0] = Merge(p, t[q].son[0]);
			update(q);
			return q;
		}
	}
	int newnode(int w) {
		tot ++;
		t[tot] = (Node) {{0, 0}, 1, w, 0, 0};
		return tot;
	}
	void del(int x) { // 删掉一个下标 
		split(root, x, rt1, rt2);
		split(rt1, x - 1, rt1, rt3);
		root = Merge(rt1, rt2);
	}
	void ins(int x, int w) { // 插入一个值  前面有 x - 1 个 
		split(root, x, rt1, rt2);
		root = Merge(Merge(rt1, newnode(w)), rt2);
	}
	void Add() {
		add(root, 1);
	}
} trp;
struct node {
	int x, y;
}d[N];
bool cmp(node a, node b) {
	return ((a.x < b.x) || (a.x == b.x && a.y < b.y));
}
inline LL f(int x) {
	return 1LL * x * (x + 1);
}
int main() {
	scanf("%d%d%d", &R, &C, &n);
	for(int i = 1; i <= n; i ++ ) 
		scanf("%d%d", &d[i].x, &d[i].y);
	sort(d + 1, d + n + 1, cmp);
	int p = 1;
	for(int i = 1; i <= C; i ++ ) trp.ins(i - 1, 0);
	for(int i = 1; i <= R; i ++ ) {
		trp.Add();
		while(p <= n && d[p].x == i) {
			trp.del(d[p].y);
			trp.ins(d[p].y - 1, 0);
			p ++;
		}
		res += trp.t[trp.root].ans;
	}
	LL ans = 0;
	ans = f(R) / 2LL * f(C) / 2LL;
	printf("%lld\n", ans - res);
	return 0;
}
posted @ 2026-09-10 20:24  Czl2007  阅读(6)  评论(0)    收藏  举报