平衡树
本章介绍平衡树的概念,详细讲解 AVL
树的平衡因子、四种平衡旋转操作及代码实现,并简介红黑树、Treap、SBT、B
树和 B +
树等其他平衡树。特别介绍了一种特殊的平衡树——伸展树的基本结构、核心操作、基本操作、序列操作、时间复杂度等内容。
平衡树概念与AVL
回顾二叉排序(查找)树
- 若其左子树非空,则左子树上所有结点的值均小于根结点的值
- 若其右子树非空,则右子树上所有结点的值均大于等于根结点的值
- 其左右子树本身又各是一棵二叉排序树
中序遍历二叉排序树后的结果有什么规律?
AVL树是最先发明的自平衡二叉查找树
二叉排序树的操作-删除
- 如果所删结点 \(now\)
左右子树有空树:用非空子树树根 \(nex\)
顶替被删结点
- 否则用左子树中序最末结点 \(nex\)
顶替被删节点 \(now\),并对 \(nex\) 再次执行删除操作
平衡树
二叉排序树在退化情况下查找与插入性能降低,接近线性而不再是 \(logn\)
\(ASL\)(Average Search
Length):平均搜索长度
在插入删除的过程中动态调整二叉树的姿态,尽可能保持任意子树的左右子树高度一致,即平衡树。
AVL
AVL树是最先发明的自平衡二叉查找树,基于平衡因子实现
- 平衡因子:该结点左子树与右子树的高度差
- AVL树任一结点的平衡因子只能取:-1、0 或 1
- 对于一棵有n个结点的AVL树,其高度和ASL都保持在\(O(log2n)\)
判断下面两个树是否是 AVL
重要操作
平衡旋转:
在插入新节点时,需要调整树的结构以保持平衡。主要有以下四种情况:
LL
:在左子树的左子树插入节点
- 平衡因子由 \(1\) 变为 \(2\)
- 需要进行顺时针旋转
RR
:在右子树的右子树插入节点
- 平衡因子由 \(-1\) 变为 \(-2\)
- 需要进行逆时针旋转
LR
:在左子树的右子树插入节点
- 平衡因子由 \(1\) 变为 \(2\)
- 需要先对左子树逆时针旋转,再对根顺时针旋转
RL
:在右子树的左子树插入节点
- 平衡因子由 \(-1\) 变为 \(-2\)
- 需要先对右子树顺时针旋转,再对根逆时针旋转
参考模板
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117
|
const int maxn = 1e5 + 10;
struct Node { int key; int data; int left; int right; int height; void Init(){data = key = left = right = height = -1;} void Init(int k_, int d_=0){Init(); key = k_; data = d_;} Node(){Init();} Node(int k_, int d_=0){Init(k_, d_);} };
Node tr[maxn]; int root, tp;
inline int UpdateHeight(int now) { int lh = tr[now].left == -1 ? 0 : tr[tr[now].left].height; int rh = tr[now].right == -1 ? 0 : tr[tr[now].right].height; return tr[now].height = (lh > rh ? lh : rh) + 1; }
inline int HeightDiff(int now) { int lh = tr[now].left == -1 ? 0 : tr[tr[now].left].height; int rh = tr[now].right == -1 ? 0 : tr[tr[now].right].height; return lh - rh; }
int LL(int an) { int bn = tr[an].left; int dn = tr[bn].right; tr[bn].right = an; tr[an].left = dn; UpdateHeight(an); UpdateHeight(bn); return bn; }
int RR(int an) { int bn = tr[an].right; int dn = tr[bn].left; tr[bn].left = an; tr[an].right = dn; UpdateHeight(an); UpdateHeight(bn); return bn; }
int LR(int an) { tr[an].left = RR(tr[an].left); return LL(an); }
int RL(int an) { tr[an].right = LL(tr[an].right); return RR(an); }
void Insert(int &now, int key, int data=0) { if(now == -1) { now = tp ++; tr[now].Init(key, data); } else if(key < tr[now].key) { Insert(tr[now].left, key, data); if(HeightDiff(now) == 2) now = key < tr[tr[now].left].key ? LL(now) : LR(now); } else if(key > tr[now].key) { Insert(tr[now].right, key, data); if(HeightDiff(now) == -2) now = key > tr[tr[now].right].key ? RR(now) : RL(now); } UpdateHeight(now); }
void Delete(int &now, int key) { if(now == -1) return; else if(key < tr[now].key) { Delete(tr[now].left, key); } else if(key > tr[now].key) { Delete(tr[now].right, key); } else { if(tr[now].left == -1) { now = tr[now].right; } else if(tr[now].right == -1) { now = tr[now].left; } else { int nd; for(nd = tr[now].left; ; nd = tr[nd].right) if(tr[nd].right == -1) break; tr[now].key = tr[nd].key; tr[now].data = tr[nd].data; Delete(tr[now].left, tr[nd].key); } } if(now == -1) return; UpdateHeight(now); if(HeightDiff(now) == 2) { now = HeightDiff(tr[now].left) >= 0 ? LL(now) : LR(now); } else if(HeightDiff(now) == -2) { now = HeightDiff(tr[now].right) <= 0 ? RR(now) : RL(now); } }
|
其它各种平衡树简介
红黑树
红黑树是一种广泛使用的平衡树,它通过节点的颜色来维持平衡:
- 每个节点要么是红色,要么是黑色
- 根节点是黑色
- 叶子节点(NIL)是黑色
- 如果一个节点是红色,则它的子节点必须是黑色
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点
相比AVL树,红黑树: - 牺牲了部分平衡性来换取更少的旋转操作 -
插入删除的维护成本更低 - 被广泛应用于标准库实现中(如C++ STL)
Treap
Treap = Tree + Heap,是一种随机化的平衡二叉树:
- 每个节点除了键值外还随机分配一个优先级
- 按照键值组成二叉搜索树
- 按照优先级组成最大堆
- 通过旋转操作来维护堆的性质
- 期望的高度是O(log n)
SBT (Size Balanced Tree)
SBT是由ICPC选手陈启峰发明的一种平衡树:
- 通过维护子树大小(size)而不是高度来保持平衡
- 平衡条件比较简单:较小子树的大小不小于较大子树的子树大小
- 实现相对简单,只需要维护size信息
- 各种操作的时间复杂度都是O(log n)
- 在某些情况下比红黑树更容易实现和维护
B树和B+树
这两种树主要用于数据库和文件系统:
- 允许每个节点包含多个键值
- 所有叶子节点都在同一层
- B+树的非叶节点只存储键值,数据都存在叶子节点
- 特别适合磁盘等外存储器的存储方式
- 广泛应用于数据库索引和文件系统中
用STL解决简单平衡树问题
一些只需要利用平衡树性质,但没有细化考查的问题,可以用STL的map
或set
完成。
std::map
由红黑树实现的数据有序,所有的“键”插入后都是有序的,比如可以用
lower_bound
找特定键的前驱
1 2 3 4 5 6 7 8 9
| std::map<int, int> myMap;
auto predecessorIt = myMap.lower_bound(targetKey); if (predecessorIt != myMap.begin()) { --predecessorIt; } else { }
|
std::set
是集合的概念,相对map
来说它只有“键”没有“值”,在不需要数据映射的时候用
set
更简洁,比如找一个键的前驱
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| #include <iostream> #include <set>
int main() { std::set<int> mySet = {1, 3, 5, 7, 9}; int target = 5; auto successorIt = mySet.upper_bound(target); if (successorIt != mySet.end()) { std::cout << "Successor: " << *successorIt << std::endl; } else { std::cout << "No successor found." << std::endl; } }
|
map
和set
都是平衡树实现,在简单场景中可以不用繁琐地自己去实现平衡树,在比赛中节约大量时间。
伸展树(Splay)
伸展树通过"伸展"操作将最近访问的节点移动到根节点,从而保证树的高度在均摊意义下为\(O(log n)\)。
传统平衡树的操作,伸展树基本都可以完成,但可能复杂度高一些,非特异需求不建议用伸展树。
除传统平衡树支持的操作外,伸展树更擅长执行以下操作:
- 区间操作:通过将区间两端点伸展到根节点附近,可以方便地进行区间修改和查询
- 最近访问优化:由于最近访问的节点会被移动到根部,对于有局部性的访问模式效率更高
- 动态统计:可以方便地维护子树信息,适合需要频繁修改和查询统计信息的场景
- 序列操作:通过中序遍历性质,可以方便地进行序列的分割、合并等操作
所以掌握一种传统平衡树(比如AVL或SBT)外,还应当掌握伸展树的使用。
基本结构
伸展树的基本结构包含以下信息:
1 2 3 4 5 6 7 8 9 10 11 12 13
| struct SplayNode { int ch[2]; int fa; int val; int cnt; int sz; int lz; void Init() { ch[0] = ch[1] = fa = val = cnt = sz = lz = 0; } SplayNode() { Init(); } };
|
每个节点需要维护以下信息:
ch[2]
:存储左右子节点的编号
fa
:存储父节点的编号
val
:节点的值
cnt
:该值出现的次数
sz
:以该节点为根的子树大小
lz
:用于区间操作的懒标记
核心操作
1. 旋转操作
旋转是Splay树的基础操作,用于将一个节点上移一层。旋转操作需要保证:
- 不破坏二叉查找树的性质
- 正确维护父子关系
- 更新节点信息
旋转分为两种情况:
- 当节点是其父节点的左儿子时,进行右旋
- 当节点是其父节点的右儿子时,进行左旋
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| void Rotate(int x) { int y = sn[x].fa, z = sn[y].fa; int chk = Ws(x); sn[y].ch[chk] = sn[x].ch[chk ^ 1]; if(sn[y].ch[chk]) sn[sn[y].ch[chk]].fa = y; sn[x].ch[chk ^ 1] = y; sn[y].fa = x; sn[x].fa = z; if(z) sn[z].ch[y == sn[z].ch[1]] = x; Maintain(y); Maintain(x); }
|
2. 伸展操作
伸展操作是Splay树的核心,它将一个节点通过一系列旋转移动到根节点。伸展操作分为三种情况:
- zig:当父节点是根节点时,直接旋转
- zig-zig:当节点和父节点都是左儿子或都是右儿子时,先旋转父节点,再旋转当前节点
- zig-zag:当节点和父节点一个是左儿子一个是右儿子时,连续旋转两次当前节点
1 2 3 4 5 6
| void Splay(int x, int target=0) { for(int y = sn[x].fa; (y = sn[x].fa) != target; Rotate(x)) if(sn[y].fa != target) Rotate(Ws(x) == Ws(y) ? y : x); if(!target) rt = x; }
|
基本操作
插入操作
插入操作的基本步骤:
- 如果树为空,直接创建新节点作为根节点
- 否则,按照二叉查找树的性质找到插入位置
- 如果找到相同值的节点,增加计数
- 否则创建新节点
- 最后进行伸展操作,将新节点移到根节点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| void Insert(int k) { if(!rt) { rt = Add(k); return; } int cur = rt, f = 0; while(true) { if(sn[cur].val == k) { sn[cur].cnt++; Maintain(cur); Maintain(f); Splay(cur); break; } f = cur; cur = sn[cur].ch[sn[cur].val < k]; if(!cur) { int nn = Add(k, f); Splay(nn); break; } } }
|
删除操作
删除操作的基本步骤:
- 首先找到要删除的节点并伸展到根
- 如果节点计数大于1,直接减少计数
- 如果节点没有子节点,直接删除
- 如果节点只有一个子节点,用子节点替代
- 如果节点有两个子节点,找到前驱节点替代,然后删除前驱节点
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| void Del(int k) { Rank(k); if(sn[rt].cnt > 1) { sn[rt].cnt--; Maintain(rt); return; } if(!sn[rt].ch[0] && !sn[rt].ch[1]) { Clear(rt); rt = 0; return; } for(int i = 0; i <= 1; i++) { if(sn[rt].ch[i]) continue; int cur = rt; rt = sn[rt].ch[i ^ 1]; sn[rt].fa = 0; Clear(cur); return; } int cur = rt, x = RtPre(); sn[sn[cur].ch[1]].fa = x; sn[x].ch[1] = sn[cur].ch[1]; Clear(cur); Maintain(rt); }
|
前驱后继查询
前驱后继查询的基本步骤:
- 前驱:找到左子树中的最大值
- 后继:找到右子树中的最小值
- 查询后将节点伸展到根
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| int RtPre(int cur=0) { if(!cur) cur = sn[rt].ch[0]; else cur = sn[cur].ch[0]; if(!cur) return cur; while(sn[cur].ch[1]) cur = sn[cur].ch[1]; Splay(cur); return cur; }
int RtNxt(int cur=0) { if(!cur) cur = sn[rt].ch[1]; else cur = sn[cur].ch[1]; if(!cur) return cur; while(sn[cur].ch[0]) cur = sn[cur].ch[0]; Splay(cur); return cur; }
|
序列操作
Splay树也可以用于维护序列信息。与线段树相比,Splay树虽然常数较大,但支持更复杂的序列操作,如区间翻转等。
将序列建成的Splay树具有以下性质:
- 中序遍历相当于原序列从左到右的遍历
- 每个节点代表序列中的一个元素
- 一棵子树代表序列中的一段区间
区间操作
以区间翻转为例,操作步骤如下:
- 将区间左端点前一个节点伸展到根
- 将区间右端点后一个节点伸展到根的右儿子
- 此时根的右儿子的左子树就是目标区间
- 对该子树进行翻转操作并打上懒标记
1 2 3 4 5 6 7 8 9 10
| void DFS(int cur, int k) { if(!cur) return; PushDown(cur); DFS(sn[cur].ch[0], k); DFS(sn[cur].ch[1], k); sn[sn[cur].fa].ch[Ws(cur)] = 0; Maintain(sn[cur].fa); tbi.push_back(sn[cur].val); cct.push_back(sn[cur].cnt); }
|
懒标记管理
对于区间操作,需要维护懒标记:
1 2 3 4 5 6 7 8 9 10 11 12
| void PushDown(int x) { if(!x || !sn[x].lz) return; UpdateLz(sn[x].ch[0], sn[x].lz); UpdateLz(sn[x].ch[1], sn[x].lz); sn[x].lz = 0; }
void UpdateLz(int x, LL lz) { if(!x) return; sn[x].lz += lz; sn[x].val -= lz; }
|
在访问节点时需要下传标记:
1 2 3 4 5 6 7 8
| void Splay(int x, int target=0) { std::vector<int> path; for(int f = x; f; f = sn[f].fa) path.push_back(f); for(int i = path.size() - 1; i >= 0; i--) PushDown(path[i]); for(int y; (y = sn[x].fa) != target; Rotate(x)) if(sn[y].fa != target) Rotate(Ws(x) == Ws(y) ? y : x); if(!target) rt = x; }
|
应用示例
以K小值查询为例,展示了Splay树处理区间操作的能力:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| void UpperBound(int k) { int lastLager = 0, cur = rt; while(cur) { PushDown(cur); if(k < sn[cur].val) { lastLager = cur; if(!sn[cur].ch[0]) break; cur = sn[cur].ch[0]; } else { if(!sn[cur].ch[1]) break; cur = sn[cur].ch[1]; } } if(sn[cur].val <= k) cur = lastLager; if(cur) Splay(cur); return cur; }
|
这个例子展示了如何:
- 维护区间信息
- 处理懒标记
- 进行区间查询和修改
通过这些操作,Splay树可以高效地处理各种序列操作问题。
时间复杂度
- 单次操作均摊复杂度:\(O(log
n)\)
- 空间复杂度:\(O(n)\)