先说明一下,这个玩意是学校数据结构课的作业。完全是赶着ddl写完的,所以后面我都有点不知道自己在写什么。gprof也不太会,瞎弄的。主要放这里做个归档,如果对大家有帮助就更好了。当然,有问题也欢迎大家批评指正。
平衡二叉搜索树性能分析
问题描述
在AVL、Splay和红黑树三种平衡二叉搜索树中至少实现两种,需要支持以下操作:元素插入、删除和查询。在题目中,所有数据均为非负整数。查询时给出一非负整数 x x x,需要求出树中满足 y ≤ x y\le x y≤x的最大整数 y y y;若树中不存在满足条件的 y y y,查询返回-1。
数据结构
选择AVL和Splay树进行实现。
二叉搜索树(公共部分)
AVL和Splay树都基于二叉搜索树并共享部分操作。二叉搜索树的节点可由以下结构描述:
template <typename T>
struct BSTNode {
T data;
BSTNode *parent;
union {
struct {
BSTNode *lc, *rc;
};
BSTNode *ch[2]; // 通用的子节点访问,`ch[0]`为左子,`ch[1]`为右子
};
};
类型T
在此题中为int
。每个节点除实际数据外,保存指向父亲和左右孩子的指针。具体实现中可以使用ch[0]
或ch[1]
访问相应的左右子节点,以节省重复的相似代码。一般而言,若某节点的父指针为空,则认为它是树的根节点。下文中以NodePtr
代指树节点对应的指针类型。
二叉树结构维护root
和hot
指针。root
指向根节点,hot
通常指向某次操作后目标节点的父节点。
查找
给定一个键值(key,或关键码),查找树中相应的节点。若该节点存在,返回指向该节点的指针(的引用);若不存在,则返回父节点中相应孩子指针的引用(此时该指针域的值为空,而它应当指向新节点)。从树的根节点开始,比较当前节点的数据值与待查找的值,并相应地前进到该节点的子节点,直到该子节点为空或查找成功。代码中需要注意树根为空或根节点即为目标节点的情况。另外,此查找函数具有副作用,会使得hot
指向目标节点的父节点,这一特性会在其它函数中起作用。
节点信息更新
每个树节点可能会维护额外的信息,并在该节点与其他节点的连接关系发生改变后需要有对应的信息更新操作。基本的Splay节点无需此操作,而AVL节点需要维护高度。对于AVL节点,更新操作可由以下代码描述:
void AVLNode::update()
{
this->height = 1 + max(get_height(lc), get_height(rc));
}
旋转
为了更好地描述旋转操作,定义节点o
与其父亲的相对关系rel(o)
如下:
int rel(NodePtr o)
{
return o == o->parent->rc;
}
若节点o
为其父亲的左孩子,rel(o)
为0;若为右孩子,rel(o)
为1。需要注意的是,调用此函数前应确保o
的父指针o->parent
非空,并且o
应当确实为o->parent
的左或右孩子。
然后引入辅助函数connect
,用于将一对节点连接为父子:
void connect(NodePtr o, NodePtr p, int r)
{
if (o) o->parent = p;
if (p) p->ch[r] = o;
}
其中的判断用于处理含有空指针的情况。函数调用后,o
成为p
的相对关系为r
子节点。
对于非根节点(父指针非空),定义对它的旋转操作为:将该节点旋转至其父亲的位置。旋转后,原父节点成为目标节点的子节点。代码如下所示:
void BST::rotate(NodePtr o)
{
int r = rel(o);
NodePtr p = o->parent, g = p->parent; // p为父亲,g为祖父
connect(o->ch[r ^ 1], p, r); // 将o的原孩子过继给p以填充o的空位
connect(o, g, g && rel(p)); // g为空时rel(p)不合法,需逻辑短路
connect(p, o, r ^ 1); // 旋转后p将成为o的!r孩子
p->update(); // 根据需求更新,此时p的高度较低,先p后o
o->update();
if (!g) root = o; // 如果g为空,认为原父亲p是树根,更新根节点
}
此函数的后三行需视具体情况修改。原祖父g
也可能需要调用update()
。
节点删除
设节点o
为待删除的节点(已通过查找定位到)。若o
的某一子树为空(单分支),则可直接将其替换为另一子树以达到删除的目的。若o
的左右子树均非空(双分支),那么可以找到o
的直接后继s
,s
必然无左孩子。然后将o
和s
互换,问题规约至删除单分支的s
。代码如下所示:
NodePtr BST::remove_at(NodePtr o)
{
int r;
if (!o->lc || !o->rc) { // 单分支,只有左子/右子(也可能左右子均为空)
r = o->rc ? 1 : 0;
if (root == o) // 被删节点o可能为根
root = o->ch[r];
} else { // 双分支
NodePtr s = successor(o); // o的直接后继s必定在以o为根的子树中
std::swap(o->data, s->data);
o = s; // 原o的后继被删去,树根root不会变化
r = 1; // r设为1,o的左子必然为空,用其右子替代
}
NodePtr p = o->parent;
connect(o->ch[r], p, p && rel(o)); // 短路逻辑
if (p) p->update(); // 视具体情况修改
delete o;
return p; // 返回被实际删除节点的父节点
}
AVL
适度平衡
为了实现整棵树的适度平衡,AVL树中引入平衡因子。对于某AVL树节点,其对应子树的平衡因子定义如下:
int AVLNode::balance_factor() const
{
return get_height(lc) - get_height(rc);
}
其中get_height
函数接收树节点指针并返回该子树的高度(空树 => -1)。
AVL树的平衡条件要求对于树中的任意节点,都满足其平衡因子的绝对值不大于1( ∣ b a l F a c ∣ ≤ 1 |balFac|\le1 ∣balFac∣≤1)。记 S ( h ) S(h) S(h)为高为 h h h的AVL树所含的最少节点数,那么有
S ( h ) = 1 + S ( h − 1 ) + S ( h − 2 ) S(h)=1+S(h-1)+S(h-2) S(h)=1+S(h−1)+S(h−2)
变形得
[ S ( h ) + 1 ] = [ S ( h − 1 ) + 1 ] + [ S ( h − 2 ) + 1 ] [S(h)+1]=[S(h-1)+1]+[S(h-2)+1] [S(h)+1]=[S(h−1)+1]+[S(h−2)+1]
这满足斐波那契数列的递推式。因为 S ( 0 ) = 1 S(0)=1 S(0)=1,所以
S ( h ) = f i b ( h + 3 ) − 1 S(h)=fib(h+3)-1 S(h)=fib(h+3)−1
其中 f i b ( n ) fib(n) fib(n)是斐波那契数列的第 n n n项( f i b ( 1 ) = f i b ( 2 ) = 1 fib(1)=fib(2)=1 fib(1)=fib(2)=1)。由于 f i b ( n ) fib(n) fib(n)随 n n n指数增长,故由 n n n个节点构成的AVL树的高度 h = O ( log n ) h=O(\log{n}) h=O(logn)。
在一棵高为 h h h的二叉树中查找节点的时间复杂度为 O ( h ) O(h) O(h)。对于含 n n n个节点的AVL树,查找的时间复杂度即为 O ( log n ) O(\log{n}) O(logn)。
失衡与复衡
在向AVL树插入或删除节点后,某一子树可能失衡。若出现失衡,必然存在节点g
,其对应子树的平衡因子绝对值为2;因此节点g
必然有儿子p
和孙子o
,它们对应的子树高度参与了g
子树的失衡。这也意味着子树o
和p
均为它们相应父亲的两个子树中较高的那个。
使用旋转可以解决局部g
子树的失衡。参考课件中的示意图,当三代节点o
、p
和g
的关系为zig-zig或zag-zag(为了便于理解,后文用LL或RR替代,LR和RL同理)时,对p
节点进行一次旋转就可以使得g
复衡。而当它们的关系为zig-zag或zag-zig时,先将o
旋转至p
的原来位置,然后继续将o
旋转至g
的原来位置,可以让新的以o
为根的子树复衡。
进行局部复衡的代码如下所示:
void AVL::rebalance(NodePtr o, NodePtr p)
{
int ro = rel(o), rp = rel(p);
if (ro == rp) // LL或RR,单旋父亲p
rotate(p);
else { // LR或RL,连续双旋o
rotate(o);
rotate(o);
// 虽然旋转的是同一个节点,但其位置会两次改变
}
}
调用这一函数前需保证相应的o
、p
和g
全部非空。单次旋转的时间复杂度为 O ( 1 ) O(1) O(1),所以局部复衡操作的时间复杂度也是 O ( 1 ) O(1) O(1)。
节点插入
AVL树的节点插入基于二叉搜索树的节点插入。首先查找插入的值对应的节点,若该节点存在,则无需进一步操作;否则在目标位置创建新节点,该节点的父亲可通过hot
指针访问。创建新节点后,更新其父亲的相关信息。
此时,AVL树的局部可能发生失衡。沿着新节点的父指针向上回溯,就能找到导致失衡的祖孙三代o
、p
和g
。参考课件上的示意图,当使用单旋或双旋进行局部复衡后,该子树的高度会复原(恢复到插入节点前的高度),所以树中的其它位置不会发生失衡,不必再继续向根回溯。
节点插入操作的代码如下所示:
bool AVL::insert(const T& v)
{
NodePtr &x = search(v);
if (x)
return false; // 没有真正进行插入
x = new AVLNode<T>(v, hot); // 注意:此构造函数并不将父亲(hot)的某个孩子指向新节点
if (hot) hot->update(); // 更新父节点(如果非空)
NodePtr o, p, g;
for (o = x, p = hot; p && (g = p->parent); o = p, p = g) {
// 祖父g不存在时不可能失衡
if (!g->balanced()) {
rebalance(o, p);
break;
}
g->update();
}
return true; // 插入成功
}
节点插入操作需要进行一次查找,并可能回溯至根,时间复杂度 O ( log n ) O(\log{n}) O(logn)。
节点删除
AVL树的节点删除基于二叉搜索树的节点删除。在移除节点后,树局部可能发生失衡。但修复局部失衡后,该子树的高度未必复原,可能小于删除节点前的高度;子树高度变化可能导致更高祖先随之失衡,失衡的传播要求回溯必须进行至根节点,并处理所有的失衡。在回溯的过程中,无法由回溯路径上的节点直接得到o
和p
,而需要通过判断高度得到失衡子树g
的较高儿子p
和较高孙子o
。(注:选择较高儿子时,若左右子高度相同,选择与父亲同侧的孩子)
节点删除操作的代码如下所示:
bool AVL::remove(const T& v)
{
NodePtr o = search(v);
if (!o)
return false; // 节点不存在,删除失败
NodePtr p, g;
// remove_at为二叉搜索树的节点删除
for (g = remove_at(o); g; g = g->parent) {
if (!g->balanced()) {
p = taller_child_of(g);
o = taller_child_of(p);
rebalance(o, p);
}
g->update();
}
return true;
}
节点删除操作需要进行一次查找,然后完整回溯至根节点,途中进行单次 O ( 1 ) O(1) O(1