基本概念

替罪羊树,又称 \(Scapegoat\ Tree\) ,是一种 平衡树 。这种平衡树的单次时间复杂度是 \(O(logn)\) 。若替罪羊树单位时间内最多同时存在 \(m\) 个结点,空间复杂度由于其特殊的 非指针内存回收 机制也可以达到 \(O(m)\) 。

替罪羊树的思想较为暴力,但是代码是除 \(fhq\ treap\) 外较为简洁的平衡树,相较于 \(Treap, Splay\) 等平衡树更容易快速实现。所以,如果 懒癌发作 想在较短的时间内实现平衡树的功能,同时题目对时间没有特别严苛的要求,这是就可以考虑使用替罪羊树。

算法思想

替罪羊树的主要思想就是 重构 。具体来说,如果替罪羊树中的某棵子树失去了平衡,那么我们直接暴力地将这棵子树排列成一个 线性序列 ,然后再通过这个序列把该子树重建成一棵 完全平衡 的二叉搜索树。这样,通过次数较小的调整,我们可以令大部分的子树都实现完全平衡,从而使整棵树尽量平衡。

最暴力的情况下,在每次操作后都会尝试重建一棵子树,但是这样的修改时间复杂度达到了惊人的 \(O(n)\) 。因此,我们需要判断一棵子树是否失衡,之后再决定是否需要重构。在这里引入一个新的概念 平衡因子。平衡因子根据需要的不同,取值范围大多在 \([0.5, 1]\) 之间,通常选用中间值 \(0.7\) 。根据平衡因子,我们判断:

令平衡因子为 \(\alpha\) ,假设需要判断结点 \(x\) 的子树是否失衡。设 \(x\) 的左右子树大小分别为 \(n, m\) ,当前结点子树大小为 \(k\) ,若 \(\frac{\max(n, m)}{k} > \alpha\) ,我们就重构当前子树。否则,认为这棵子树是平衡的,不进行任何操作。判断和控制重构可以在一个函数内实现。

接下来,我们一个个分析一下替罪羊树的操作。

操作详解

判断结点可用

如果当前结点没有左右子树,并且自身个数为 \(0\) ,说明替罪羊树中这一部分已经被彻底删除,只是还没有被重构函数过滤。此时我们不可以使用这些结点,因此,我们需要根据上述条件判断这些结点是否可用。

bool exist(int x)
{
return !(tree[x].l == 0 && tree[x].r == 0 && tree[x].cnt == 0);
}


建立序列

把结点 \(x\) 的子树重组成一个线性序列,每一个递归子结构都满足根结点是区间中点,左子树在序列左半部分,右子树在序列右半部分。最后返回根结点在序列中的下标。

还是直接模拟。如果当前结点左子树不为空,先将左子树建成一个线性序列。接着判断根结点是否为空,若不空,记录根结点的下标为当前序列的长度,再在序列末尾加入根结点。最后判断右子树,与左子树同理。

请注意,如果存在个数为 \(0\) 的结点,在这个函数就会被清理掉。所以,替罪羊树中一般是不存在不合法结点的。因此,替罪羊树即使删除次数较多,时间复杂度也不会太坏。而且,并不是每次操作都能引起序列失衡的。所以,就算是暴力,替罪羊树也通常比普通的二叉搜索树跑得更快。

int flatten(int x)
{
if (exist(tree[x].l))
flatten(tree[x].l);
int id = tp.size();
if (tree[x].cnt)
{
tp.push_back(x);
tn.push_back(tree[x].cnt);
tv.push_back(tree[x].val);
}
if (exist(tree[x].r))
flatten(tree[x].r);
return id;
}


重建子树

假设目前已经得到了重建的线性序列,要将其建成一个完全平衡的二叉树,并接回原来的替罪羊树。

假如当前结点存在左右子树,也就是当前待建树的区间不是单点,那么判断是否可以建立左右子树,再分别建树即可。注意此时因为之前的结点信息没有清空,所以不可以直接访问左右子树的大小。如果存在左右子树,那么将它们的信息更新以后才可以用变量记录它们使用。最后,记得更新当前结点的信息。

void rebuild(int x, int l, int r)
{
int mid = (l + r) / 2;
int sizel = 0, sizer = 0;
if (l < mid)
{
tree[x].l = tp[(l + mid - 1) / 2];
rebuild(tree[x].l, l, mid - 1);
sizel = tree[tree[x].l].size;
}
else
tree[x].l = 0;
if (r > mid)
{
tree[x].r = tp[(mid + 1 + r) / 2];
rebuild(tree[x].r, mid + 1, r);
sizer = tree[tree[x].r].size;
}
else
tree[x].r = 0;
tree[x].cnt = tn[mid];
tree[x].val = tv[mid];
tree[x].size = sizel + sizer + tree[x].cnt;
}


维护平衡

这个函数既要判断子树是否失衡,又要在失衡时重建子树。

显然,我们首先要根据平衡因子判断是否失衡。如果失衡,我们首先先进行初始化,其中 ​​tp​​ 数组表示子树中结点下标组成的线性序列,​​tn​​ 表示子树中结点个数组成的线性序列,​​tv​​ 表示子树中结点权值组成的线性序列。

接着,我们将子树重构成线性序列,然后根据序列重构子树即可。值得注意的易错点 是,因为我们重构子树是从序列的中点开始的,如果根结点的下标不在中点(左右子树可能为空),我们必须先将它交换到中点,才能进行建树。

void restr(int x)
{
double val = max(tree[tree[x].l].size, tree[tree[x].r].size) * 1.0 / tree[x].size;
if (val > alpha)
{
tp.clear();
tn.clear();
tv.clear();
int id = flatten(x);
swap(tp[id], tp[(tp.size() - 1) / 2]);
rebuild(x, 0, tp.size() - 1);
}
}


其他函数

替罪羊树的其余函数与普通的二叉搜索树相差不大,如果理解的平衡树的思想,不难通过代码理解这些内容。由于 笔者实在太懒 篇幅限制,此处不再对其他的操作进行赘述。如果对平衡树的思想不是非常理解,建议先阅读笔者的 ​​Splay​​ ​​FHQ_Treap​​ ​​Treap​​ 这几篇博文。

最后,感谢在 ​​洛谷​​ 提供优质博客链接的同学们。同时放上笔者推荐的 ​​替罪羊树学习笔记​​ 。在此处感谢这篇文章的作者,为本文提供了优质的参考资料。

参考代码

​例题链接​

#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;

const int maxn = 1e5 + 5;
const double alpha = 0.7;

struct node
{
int l, r;
int cnt, val, size;
} tree[maxn];

int n, m, cnt = 1;
vector<int> tp, tn, tv;

bool exist(int x)
{
return !(tree[x].l == 0 && tree[x].r == 0 && tree[x].cnt == 0);
}

int flatten(int x)
{
if (exist(tree[x].l))
flatten(tree[x].l);
int id = tp.size();
if (tree[x].cnt)
{
tp.push_back(x);
tn.push_back(tree[x].cnt);
tv.push_back(tree[x].val);
}
if (exist(tree[x].r))
flatten(tree[x].r);
return id;
}

void rebuild(int x, int l, int r)
{
int mid = (l + r) / 2;
int sizel = 0, sizer = 0;
if (l < mid)
{
tree[x].l = tp[(l + mid - 1) / 2];
rebuild(tree[x].l, l, mid - 1);
sizel = tree[tree[x].l].size;
}
else
tree[x].l = 0;
if (r > mid)
{
tree[x].r = tp[(mid + 1 + r) / 2];
rebuild(tree[x].r, mid + 1, r);
sizer = tree[tree[x].r].size;
}
else
tree[x].r = 0;
tree[x].cnt = tn[mid];
tree[x].val = tv[mid];
tree[x].size = sizel + sizer + tree[x].cnt;
}

void restr(int x)
{
double val = max(tree[tree[x].l].size, tree[tree[x].r].size) * 1.0 / tree[x].size;
if (val > alpha)
{
tp.clear();
tn.clear();
tv.clear();
int id = flatten(x);
swap(tp[id], tp[(tp.size() - 1) / 2]);
rebuild(x, 0, tp.size() - 1);
}
}

void insert(int x, int val)
{
if (!exist(x))
{
tree[x].cnt = 1;
tree[x].val = val;
}
else if (val < tree[x].val)
{
if (!exist(tree[x].l))
tree[x].l = ++cnt;
insert(tree[x].l, val);
}
else if (val > tree[x].val)
{
if (!exist(tree[x].r))
tree[x].r = ++cnt;
insert(tree[x].r, val);
}
else
tree[x].cnt++;
tree[x].size++;
restr(x);
}

void del(int x, int val)
{
tree[x].size--;
if (val < tree[x].val)
del(tree[x].l, val);
else if (val > tree[x].val)
del(tree[x].r, val);
else
tree[x].cnt--;
restr(x);
}

int rank_pre(int x, int val)
{
if (val < tree[x].val)
return (exist(tree[x].l) ? rank_pre(tree[x].l, val) : 0);
else if (val > tree[x].val)
return tree[tree[x].l].size + tree[x].cnt + (exist(tree[x].r) ? rank_pre(tree[x].r, val) : 0);
else
return tree[tree[x].l].size;
}

int rank_nxt(int x, int val)
{
if (val > tree[x].val)
return ((exist(tree[x].r) ? rank_nxt(tree[x].r, val) : 0));
else if (val < tree[x].val)
return tree[tree[x].r].size + tree[x].cnt + (exist(tree[x].l) ? rank_nxt(tree[x].l, val) : 0);
else
return tree[tree[x].r].size;
}

int rank(int val)
{
return rank_pre(1, val) + 1;
}

int find(int x, int rk)
{
if (rk <= tree[tree[x].l].size)
return find(tree[x].l, rk);
else if (rk > tree[tree[x].l].size + tree[x].cnt)
return find(tree[x].r, rk - tree[tree[x].l].size - tree[x].cnt);
else
return tree[x].val;
}

int pre(int x)
{
int rk = rank_pre(1, x);
return find(1, rk);
}

int nxt(int x)
{
int rk = rank_nxt(1, x);
return find(1, tree[1].size - rk + 1);
}

int main()
{
// freopen("P3369_2.in", "r", stdin);
// freopen("P3369_2.out", "w", stdout);
int opt, x;
scanf("%d", &n);
for (int i = 1; i <= n; i++)
{
scanf("%d%d", &opt, &x);
if (opt == 1)
insert(1, x);
else if (opt == 2)
del(1, x);
else if (opt == 3)
printf("%d\n", rank(x));
else if (opt == 4)
printf("%d\n", find(1, x));
else if (opt == 5)
printf("%d\n", pre(x));
else
printf("%d\n", nxt(x));
}
return 0;
}