承接之前的『树』,本文将目标特别锁定在『查找树』;这里整理出我遇到的各种形式的查找树,以后可能会不定期更新,以尽可能多的囊括所有种类的查找树;虽然标题为“搜索树”,但是我还是习惯叫“查找树”,以下也将沿袭着一传统
学习『查找树』心里面始终要有一个意识:对于查找树而言『平衡』很重要
没有任何限制的查找树,只要满足每个结点的左结点比该结点小而右结点比该结点大即可;普通查找树是后面介绍的所有种类查找树(AVL树、伸展树、红黑树……)的超集,普通的查找树,它既可能是平衡的,也可能是不平衡的(平衡与否直接决定了程序执行的效率)——这里给出最坏情况(当所有元素构成单调数列时搜索效率最低,直接退化成单链表)
也就是说,如果树中插入的是随机数据则执行效果很好,但如果插入的是有序或者逆序的数据,那么二叉查找树的执行速度就变得很慢(所以才需要平衡)
TreePtr BSTInit(ElementType E)
{
TreePtr T = malloc(sizeof(TreeNode));
if(T)
{
T->data = E;
T->left = T->right = NULL;
return T;
}
return NULL;
}
这里略作小结:如果要遍历一棵树我们可以采用三种方法(前、中、后),但是如果要遍历一棵查找树通常从树根开始查找(因为查找树的数据结点分布是高度有序的,所以这样效率最高也最简单)
TreeNode BinarySearch(ElementType E, TreePtr T)
{
while(T)
{
if(E == T->data)
{
return *T;
}
else if(E > T->data)
{
T = T->right;
}
else{
T = T->left;
}
}
return NULL;
}
查找树的查找时间通常和它的高度、宽度(扁平度)有关,一棵树越趋于平衡(高度、宽度维持在特定值)查找就越快(也有例外,有的查找树在搜索前事先确定好各个结点的位置——这样的树是静态的,也就不存在平衡一说——典型的例子如哈夫曼树)
一棵N结点查找树如果它是可变的(可以达到不同的平衡形态),那么理论上它的查找时间复杂度必然介于:O(logN)~O(N)之间
补充几点:
1)如果要按大小顺序查找,采用中序遍历(也很简单)
如图,查找顺序就是 1->3->5->10->10.5->11->12->13->15->16->17->18->20
2)对结点进行的每次操作(查找、插入、删除、包括修改)既可能让原来平衡的树不在平衡,也可能幸运的继续保持平衡;如果仍保持平衡,有的树(比如AVL树)就不做任何改变,而另外有些树(比如伸展树)不管当前平衡状态而追求的是平均角度看的平衡,在每次操作后都会采取相应改变以调整树的构型——这一点也体现了不同种类树的区别
附图:
忽略最下面的“26”号……如果我们把它放到右边“65”的下面作为其右结点,可以预见这棵树仍然是平衡的(我们说在『插入』操作后仍然保持平衡)——而后是否调整重构(怎么调整)就体现了不同种类的查找树的不同特点——从这一点看,我们说正是平衡的调整方案确定了不同的树啊
3)用队列结构对二叉树进行层序遍历(C++实现):
#include
void LevelTraversal(BSPtr *ptr)
{
queue rel;
rel.push(ptr);
while(!rel.isEmpty())
{
BSPtr *front = rel.front();
printf("%d\n", front->data);
rel.pop();
if(front->left != null)
rel.push(frontt->left);
if(front->right != null)
rel.push(front->right);
}
}
另外,学习查找树除了会写代码还要会看图
以上,是两棵不同的二叉查找树!虽然从物理模型角度看它们是等价的,但是在计算机科学中必须要区别对待——对每个结点,画一条经过它的水平线,对于与它相邻的结点(用轻绳连接),如果在这条水平线上面就说它是这个结点的父结点,否则在下面就称为该结点的孩子结点
哈夫曼让连接各个结点的路径带权,对于一组给定的数据,总存在唯一的让所有带权路径之和最小的树形结构,我们称这样的树为关于这一组给定数据的哈夫曼树;通常处理的数据数目庞大以至于不可能完全统计,通常使用概率代替频率作为描述特定数据集合的哈夫曼树的权值
可以看出,哈夫曼树并不满足一般查找树“左孩子比父结点大、右孩子比符结点小”的特点(如父结点46,他的两个孩子分别是21、25),从这一点看哈夫曼树不是严格的查找树——但是考虑到它可以查找数据的功能,姑且认为它也是一种特殊的查找树(静态的查找树,只能进行查找通常不做数据的修改、添加等操作)
typedef struct _HuffmanNode
{
ElementType data;
int weight;
struct _HuffmanNode *left;
struct _HuffmanNode *right;
}HuffmanNode, *HuffmanPtr;
查找的是数据,但是比较的是权值
为了使得到的哈夫曼树的结构尽量唯一,通常规定生成的哈夫曼树中每个结点的左子树根结点的权小于等于右子树根结点的权(下面的代码就是把小的作为左孩子)
/*
* 函数功能:找出给定数据集合中最小、次小的两个元素
* E:用于接受数据的数组
* n:数组元素个数
* p1:指向最小元素下标
* p2:指向次小元素下标
*/
static int FindMinNode(ElementType *E[], int n, int *p1, int *p2)
{
int index;
int fir_min = sec_min = 0xffff;
if(E == NULL) return -1;
for(index=0; indexindex++)
{
if(weight != 0)
{
if(E[index]->weight < fir_min)
{
sec_min = fir_min;
fir_min = E[index]->weight;
*p2 = *p1;
*p1 = index;
}
else if(E[index]->weight < sec_min)
{
sec_min = E[index]->weight;
*p2 = index;
}
}
}
return 0;
}
/* 函数功能:建立哈夫曼树
* E:给定数据数组
* n:给定数据个数
*/
HuffmanPtr CreateHuffmanTree(ElementType E[], int n)
{
int i, j;
HuffmanPtr b[], q;
b = malloc(n*sizeof(HuffmanNode));
for(i=0; isizeof(HuffmanNode));
b[i]->weight = E[i];
b[i]->left = b[i]->right = NULL;
}
for(i=0; i1; i++) //主体部分,循环n-1次建立Huffman Tree
{
for(j=0; j//让k1、k2分别指向第一和第二棵树
{
int k1=-1, k2;
if(b[j] != NULL && k1==-1)
{
k1 = j;
continue;
}
if(b[j] != NULL)
{
k2 = j;
break;
}
}
FindMinNode(E, n, &k1, &k2);
/*由最小权值、次小权值的两个结点建立一棵新树,q指向树根*/
q = malloc(sizeof(HuffmanNode));
q->weight = b[k1]->weight + b[k2]->weight;
q->left = b[k1];
q->right = b[k2];
b[k1] = q;
b[k2] = NULL;
}
free(b);
return q;
}
一种简单的高度平衡的自平衡查找树;它限制树中任何节点的两个子树的高度最大差别为1;在每次增加、删除元素后,要重新调整AVL树的结构以达到新的平衡(牵一发而动全身)
AVL树的四种失衡类型:1)LL型,2)LR型,3)RL型,4)RR型
AVL树的四种调整类型:1)右单旋,2)左右双旋,3)右左双旋,4)左单旋
几种旋转的英文叫法:右单旋 => Zig、左单旋 => Zag、左右双旋 => Zag-Zig、右左双旋 => Zig-Zag
对于每一个结点
0)高度自平衡
1)左右子树也都是AVL树
2)左右子树的高度之差不会超过1
typedef struct _AVLNode
{
ElementType data;
int height;
struct _AVLNode *left;
struct _AVLNode *right;
}AVLNode, *AVLPtr;
/*严格来说,这并不是结点的高度;但是我们约定用这样的二进制数表示它,好处就是简化了信息的复杂度,同样当height的差等于2时旋转——这一点看和一般意义上的height是等价的*/
static int Height(AVLPtr P)
{
if(P == NULL)
{
return -1;
}
else{
return P->height;
}
}
/*左单旋*/
static AVLPtr SingleRotateWithLeft(AVLPtr K2)
{
AVLPtr K1;
K1 = K2->left;
K2->left = K1->right;
K1->right = K2;
K2->height = Max(Height(K2->left), Height(K2->right)) + 1;
K1->height = Max(Height(K1->left), Height(K2->height)) + 1;
return K1;
}
/*右双旋*/
static AVLPtr DoubleRotateWithLeft(AVLPtr K3)
{
K3->left = SingleRotateWithRight(K3->left);
return SingleRotateWithLeft(K3);
}
/*插入函数主体部分,调用上面的函数*/
int AVLInsert(ElementType E, TreePtr T)
{
if(T == NULL)
{
T = malloc(sizeof(TreeNode));
if(T)
{
T->data = E;
T->left = T->right = NULL;
T->height = 0;
}
else{
return -1;
}
}
else if(E < T->data)
{
T->left = AVLInsert(E, T->left);
if(Height(T->left) - Height(T->right) == 2)
{
if(X < T->left->data)
{
T = SingleRotateWithLeft(T);
}
else{
T = DoubleRotateWithLeft(T);
}
}
}
else if(E > T->data)
{
T->right = AVLInsert(E, T->right);
if(Height(T->right) - Height(T->left) == 2)
{
if(X > T->left->data)
{
T = SingleRotateWithRight(T);
}
else{
T = DoubleRotateWithRight(T);
}
}
}
T->height = Max(Height(T->left), Height(T->right));
return T;
}
友情链接:树的旋转详解
树堆的每个结点都有一个在插入时随机赋予的优先级,其结构相当于以随机数据插入的二叉查找树,在平衡被打破时它也要通过旋转建立新的平衡
优先级越小的结点越靠近根结点;从这一点看,树堆和哈夫曼树倒有几分相似(树堆的优先级相当于哈夫曼树的权值,不过二者作用相反——哈夫曼树是权值越大的越靠近树根,而且树堆的每个结点的优先级也不满足等于两个子结点的相加关系->看下图13+14≠6)
树堆的性质总结起来就是,1. 最小堆原则:从树根往下,每个结点的优先级都必须小于其任意孩子结点的优先级(注意不一定是一个比它大一个比它小,那是二叉查找树存储数据的方法)2. 树堆既可以看成一棵二叉查找树,也可以看成是一个堆结构(Treap=Tree+Heap)
typedef struct _TreapNode
{
ElementType data;
int priority;
struct _TreapNode *left;
struct _TreapNode *right;
}TreapNode, *TreapPtr;
因为树堆既可以看成树,也可以看成堆,所以这里删除结点就有两种不同的思路(分别从「二叉树」和「堆」的角度)
/*使用二叉查找树的方式删除结点*/
if(结点是叶子结点)
{
直接删除之;
}
else if(结点有一个孩子)
{
删除该结点,并让它的唯一孩子代替它原来的位置;
}
else
{
删除该结点,并让其右子树中最小值(或左子树中最大值)代替它原来的位置;
}
/*使用堆的方式删除结点:把要删除的结点旋转到叶子上再直接删除*/
伸展树(Splay Tree)也叫分裂树,是一种自调整的平衡查找树;它能在O(log N)内完成插入、查找和删除操作;由丹尼尔·斯立特Daniel Sleator和罗伯特·恩卓·塔扬Robert Endre Tarjan在1985年发明;和AVL树不同,它的特点是伸展(本质上是和AVL一样都是二叉查找树,不过在失衡后的调整措施不同)
不会保证树一直是平衡的,但各种操作的平摊时间复杂度是O(logN),因而,从平摊复杂度上看,伸展树也是一种平衡二叉树(专业术语:『摊还』)
伸展树的自调整体现在:每一次对结点的查找、插入、删除操作,就把该结点移到根结点,如果需要删除在这之后进行伸展树不需要记录用于平衡树的冗余信息(结点的高度)
所谓伸展就是把指定结点旋转到树根,可以把伸展看成是特殊的旋转,而旋转是所有平衡查找树的特性(为什么要旋转到树根?因为查找树树的遍历都是从树根开始,这样可以让那些访问频率高的结点优先被访问到)说到优先级,我们说『伸展树的优先级是在人为干涉下动态变化的,而树堆的优先级和外界干涉无关是根据随机数算法随机分配的』;另外,伸展树的优先级(不严谨的说,优先级就是结点距离树根的路径长度)是概念上的,实际上并不存在
伸展树每次Splay前要考察的层数为三层(祖父、父亲、自己),提出Splay要『向上追溯两层而非一层』的是著名的计算机科学家Tarjan在1985年提出的
伸展树的伸展不是被动而是主动的,不论查找、删除、或者插入结点,不论当前状态是否已经平衡都要进行伸展;具体来说,当一个结点被查询时说明该结点被访问的可能性增大故进行伸展使该结点上移,同理可以分析删除、插入操作,总之伸展行为总是主动发生在每一次的访问(查、删、插)操作后
我们说『伸展树』是牺牲时间保护空间的典型代表 —— 不用额外的空间存储结点的高度信息,但是作为替代每次查找、删除、插入操作后都要进行一次伸展
伸展树的伸展类型根据被访问的结点位置分为三种情形:
1)被访问结点是树根的左或右孩子(最简单)
这时候只要进行一次单旋(是左孩子->右旋)
2)被访问结点不是根结点的孩子,且和它的父亲都是各自父亲的左或右结点
观察构型,应该右右双旋或左左双旋(这里是右右)
3)被访问结点不是根结点的孩子,且和它的父亲分别是各自父亲的左孩子和是右孩子
观察构型,应该左右双旋或右左双旋(这里是左右)
看不懂?没关系!且听我慢慢道来……
首先对于伸展树而言,它的目标是要『把当前操作结点旋转到树根』,这句话的理解很关键(复习二叉树相关概念),我们知道一个结点到树根的路径是唯一的(『路径』就是指『最短路径』),于是最简单的approach就是『向上回溯,遇到左孩子就右旋,遇到右孩子就左旋』,看上去很完美——BUT!
看图说话(其实也可以看本文的第一张图),what if 『构成树的所有元素可以构成一个单调数列』?
伸展操作(不同于『普通旋转』,『伸展操作』影响的范围或者说被操作结点的位置变化更大,因为人家的目标很明确就是树根)后你旋转得到的新树是这样的:对于单调数列,在『伸展操作』后,就像没有旋转一样,因为O(N)还是O(N)时间复杂度没变!
另外考虑这样的例子:
这种奇怪构型的查找树在进行『姑且认为是粗糙版的伸展操作』后,原来的k3结点却被推到了和k1同样深的位置——同样的,单旋转也没能降低这种情况下的时间复杂度
基于上述两个代表性例子——这些旋转的效果是将新结点一直推向树根,使得对该结点的进一步访问很容易(暂时的);不足之处是它把另外一个结点几乎推向和新结点以前同样的深度,而对那个结点的访问又将把另外的结点向深处推进……我们想『看来单旋转是不行了,不妨试试双旋转』
『双旋』也是可以转换成两步单旋的,我们假设被访问结点为x
在旋转之前要明确『目标是什么』:伸展树的目标是把访问结点移动到整棵树的树根
在明确了目标后,自然想到讨论结点和树根的关系
case1)x的父结点是树根
访问结点9后,只需要单旋一次(这里是左旋)把该结点搞成树根就完事了
这样最简单,想要双旋还搞不了
并且我们说单次旋转操作涉及到要判断的树结点层数不会太多(最多到『祖父』结点,这就是极限了)
case2)之字形
被访问结点X 介于父节点P 和 祖父节点G 之间,确切地说是 P≤X≤G(在『里面』)
这时候执行Zig-Zag或Zag-Zig(两次方向相反的单旋)
对于『RL』、『LR』的『之字形』,AVL树也要旋转两次,从结果上看AVL树的Rotate和这里伸展树的Splay是一样的,都是把X旋转到了树根位置(AVL是无意为之,而Splay则是故意为之)
case3)一字型
被访问结点X 不介于父节点P 和 祖父节点G 之间,确切地说是 X≤P≤G 或 G≤P≤X(在『外面』)
这之后执行Zig-Zig或Zag-Zag(两次方向相同的单旋,依次把最上面的树根翻下来)
可以看到,和AVL树的一字型Rotate相比,看下图如果是AVL树到第二步把G翻下来就完事了,而Splay则多了一个把P翻下来的过程(实际上是要把X翻上去——Splay的思想就是把操作结点翻到对应子树的树根位置)
总结:以后看到『伸展树』就要想到『伸展』,想到『把当前结点移动到树根位置』,还要想到我上面举的两个例子,要形成反射弧——
通过本章节你学到了什么,你的思维导图:
伸展树 => 伸展,就是把当前结点想办法移动到树根位置 => 尽量不要加深树的深度 => 使用双旋转(就像DNA的双螺旋一样)
另外,伸展树一律使用『双旋转』,除非特殊情况,即被访问结点的父亲是树根,这时候我们也想双旋转但是因为想搞搞不了只得使用单旋转
/*函数功能:实现伸展树的Splay操作
*函数说明:使用迭代,如果当前结点不是树根就根据情况旋转,当它的父亲是树根时跳出循环(因为时另一种情况),并根据case进行单旋转,其中封装了PushUp函数
*请参说明:T为树根结点,N时新插入结点
*/
void Splay(SplayPtr *T, SplayPtr *N)
{
while(T != N && T->left != N && T->right != N)
PushUp(T, N);
if(T->left == N) SingleRotateWithRight(T, N);
else if(T->right == N) SingleRotateWithLeft(T, N);
}
/*函数功能:实现Splay的主体功能,被封装在splay中
*请参说明:i和j是判断条件的标记,也可以用两个boolean类型代替
*/
void PushUp(SplayPtr T, SplayPtr N)
{
SplayPtr parent, grandparent;
int i, j;
parent = N->parent;
grandparent = N->parent->parent;
i = grandparent->left == parent ? 0 : 1;
j = parent->left == N ? 0 : 1;
if(i==0 && j==1)
zig-zig(T, N); //右右双旋
else if(i==0 && j==1)
zag-zig(T, N); //左右双旋
else if(i==0 && j==1)
zig-zag(T, N); //右左双旋
else
zag-zag(T, N); //左左双旋
}
注意下,每次Splay最多也只是把当前结点移动到它的祖父结点位置——之后如果要移动到整棵树的树根位置,还要继续Splay(如果此时父结点就是树根,那就直接单旋转完事)——反正记着,伸展树的Splay的最终目的是把操作结点移至整棵树的树根位置
首先想一想,伸展树为什么适合『区间操作』(不是说别的树不可以,只是说它的构型特点比较适合)?因为每次Splay(伸展树的核心操作)都要把相应的结点旋转的树根,这样旋转后该结点的位置就确定了,既然位置确定了又考虑到伸展树作为一棵查找树它所有结点分布应该满足有序的特点,所以对该新树根做一些遍历之类的操作就很简单
所以,与其说『伸展树的区间操作』,不如说『借用Splay函数的区间操作』
给里给出思路:要求区间[s, e]的极值,将结点s-1伸展成树根,再将结点e+1伸展为树根的右孩子,那么结点e+1的左子树就代表了区间[s, e],结点e+1的左孩子的极值域就是所求范围(以上,必须要保结点s-1和结点e+1存在)
名字不要和普通查找树弄混了(那是BST)
SBT,即Size Balanced Tree,节点大小平衡树,是一种自平衡二叉查找树,它是由中国广东中山纪念中学的陈启峰发明的;实践中,SBT是所有种类的平衡树中效率较高的一种;SBT的高度是O(logN),Maintain是O(1),所有主要操作都是O(logN);SBT的特点是,它需要专门去维护其大小,从而实现构建平衡二叉树的目的
SBT的自平衡是通过size域实现的
结点大小平衡树最主要的特色或者说核心操作就是『维护子树大小』,这里聪明的读者肯定一眼就看出来为了维护所谓的子树大小必然要有一个附加的『size』域用来存放每个结点的子树大小
SBT的高度是O(logN),Maintain是O(1),所有主要操作都是O(logn)——用陈老师的原话就是『目前为止速度最快的高级二叉搜索树』
SBT的实现结构高度
P.s. Treap、Splay、SBT,号称是『三大查找树』(并无考证,姑且信之吧)
SBT是一种通过Size域来保持平衡的查找树,它的性质总结起来一句话:对于SBT的每个结点,每棵子树的大小不小于其兄弟的子树大小
写成数学表达式就是:
Size[Right[T]] ≥ Size[Left[Left[T]]], Size[Right[Left[T]]]
Size[Left[T]] ≥ Size[Right[Right[T]]], Size[Left[Right[T]]]
做成PPT也好理解:
因为旋转总是发生在当前结点和其父结点之间,所以要取得这个结点的父亲的引用或者指针(当然,或者转换思路把要传给Rotate函数的参数写成上面的结点就不用加上parent域了,加上parent域是为了便于查找);另一种思路是用一个数组维护整个SBT
/*写法一*/
typedef struct _SBTNode
{
ElementType key;
int size;
struct _SBTNode *left;
struct _SBTNode *right;
}SBTNode;
/*写法二:用数组维护整棵树*/
typedef struct
{
int key, left, right, size;
}tree[MAXSIZE];
SBT的旋转函数最后是要封装到Maintain函数中去的,大体操作和普通的AVL旋转没有区别,不过SBT有SBT的特点:发现SBT每次旋转,包括判断是否要旋转(我是说『Maintain』,就是一系列旋转),波及到的结点都大于2层但是没有层数的上界限制,也就是说可以有3层、4层、5层……这一点要有一个印象,就是『SBT可以很深』
SBT的旋转和AVL的旋转没有区别,无非是多了个size域……
/*S表示旋转前在上面的结点,旋转后翻到下面*/
int RotateWithLeft(SBTNode *S)
{
SBTNode *tmp = S->right->left;
S->right->left = S;
S->right = tmp;
S->right.size = S.size;
/*S的size,因为S被翻下来后获得了它原来孩子的孩子,所以加一*/
S.size = S.left.size + S.right.size;
}
当我们删除、插入一个结点到SBT中,SBT的大小会发生改变(从而破坏SBT的两个重要性质),这时候就需要调用Maintain函数对SBT进行修复
1)Maintain(T)用于修复以T为根的SBT
2)调用Maintain(T)的前提是T的子树都已经是SBT了
看到注意事项2,你要想到两点:1)既然说『调用Maintain的前提是T的子树都是SBT』,那么我们说不看旋转单看Maintain,Maintain操作一定是自下而上进行的,即先Maintain下面的再Maintain上面的,2)虽然话是这么说没错,但是如果把Maintain和Rotate结合起来看的话(事实上,Maintain就是包含Rotate的),由于首先Rotate操作已经让发生问题的结点转到上面去了,我们想要『先Maintain下面的再Maintain上面的』,不过这时候是要接着Rotate操作后对翻下来的结点进行Maintain就好了
要细分的话,Maintain调用有四种情况:分别是1)Size[A]>Size[R],2)Size[B]>Size[R],3)Size[D]>Size[L],4)Size[C]>Size[L];不过二叉树好就好在它比较对称,后两种情况和前两种情况对应为镜像情形,这里以前两种要Maintain的情形分析
Maintain函数其实并不复杂,如果我们忽略那些多余的自子树结点(途中三角形部分),Maintain总是发生在一定范围内(从图中看好像是三四层的『感觉』)
case1:Size[A] > Size[R]
0x01)顺时针旋转根结点T(不是以T为转轴,而是拎着T)让T下,L上(R不动)
0x02)由于旋转后不确定T是否满足SBT的性质即不能确定是否仍然是SBT,我们对翻下来的结点T进行Maintain操作(熟练后就形成惯性思维了,知道要对翻下来的结点先进行维护),Maintain操作的基础也是Rotate——在Maintain过T后,我们知道又解决掉一层,接下来只要对Maintain发生的最高一层结点也就是L进行维护即可;在这之后,不要再想着是不是要继续上溯对L的父结点进行Maintain,那是完全没有必要的(回头看之前的红色部分),因为我们说『调用Maintain(T)的前提是T的子树都已经是SBT了』,这句话的理解我认为是理解Maintain函数的关键(每一次『操作』包括删除、插入的影响范围都是以操作位置为核心向周围散开的,如果没有特殊要求——比如Splay要求伸展比如要搞到树根位置这就是特殊要求——并不会影响范围不会太大)
case2:Size[B] > Size[R]
情形二和情形一相比,不同在于『新插入点在Maintain影响范围最高层的左右或右左位置,或者说插入点位于旋转点的内侧(情形一插入点位于旋转点的外侧)』
0x01)图中是『LR』类型,所以先旋转L结点划归为『LL』类型后(这不,又是case1了)再旋转T结点(两步合为一步)
0x02)旋转(Rotate)过后,就是维护(Maintain)了,这里L和T由于在同一层,都是B的孩子,所以谁先Maintain谁后Maintain是没关系的,不过在Maintain(L)& Maintain(T)过后,不要忘了现在处在『影响范围最高层』的B结点,也要Maintain——Maintain和Rotate一样,都有点『自下而上』的感觉(上溯,直到影响范围消失)
代码部分:
/*函数说明:维护函数,触发条件是size域不满足相关性质;其中前两个if对应图片的case1、case2,后两个就是前两种的镜像而已
*请参说明:T是旋转结点也是影响范围的最高层位置;flg:方便后面Insert调用,每次插入把平衡操作压入栈中(注意注释前后的语句变化,加上注释的版本方便后面函数的调用)
*备注:没有使用数组维护整个SBT树,因为如果使用数组的话,key域的类型就定死了,我们使用一个结构体left、right来封装各自的数据域;另外SBT的Maintain有四种情况这个记住
*/
void Maintain(int *T, bool flg)
{
if(flg)
{
if(T->left->left.size > T->right->size)
{
RotateWithRight(T);
//Maintain(T->left);
//Maintain(T);
}
else if(T->left->right->size > T->right->size)
{
RotateWithLeft(T->left);
RotateWithRight(T);
//Maintain(T->right);
//Maintain(T->left);
//Maintain(T);
}
}
else
{
else if(T->right->right->size > T->left->size) //对应case1镜像
{
RotateWithLeft(T);
//Maintain(T->left);
//Maintain(T);
}
else if(T->right->left->size > T->left->size) //对应case2镜像
{
RotateWithRight(T->right);
RotateWithLeft(T);
//Maintain(T->left);
//Maintain(T->right);
//Maintain(T);
}
}
Maintain(T->left, false); //#
Maintain(T->right, true); //#
Maintain(T, false); //#
Maintain(T, true); //#
}
略作小结:SBT的平衡是靠Size域维护的,当Size域不满足SBT性质时,要进行Maintain操作;而Maintain操作是包括Rotate操作的,在Rotate后,再要根据具体插入位置判断Maintain的结点次数——整个Maintain函数就是一个判断并递归调用而已
Insert函数封装Maintain函数,Maintain函数封装Rotate函数,Rotate函数封装size域的变化……
/*函数说明:实现SBT的结点插入
*参数T:为根树结点;参数newNode:为新结点;parent:用来保存T变为其孩子之前的值(确切地说是保存T变为NULL之前的值)
*备注:在实际使用当中,写法是灵活的——比如可以封装一个NewNode函数用来创建新结点,那么这里只要传入它的key域即可
*/
void Insert(SBTPtr *newNode, SBTPtr *T)
{
if(T == NULL)
{
T = newNode;
}
SBTPtr *parent = NULL;
while(T)
{
if(newNode->key > T->key)
{
parent = T;
T = T->right
}
else if(newNode->key < T->key)
{
parent = T;
T = T->left;
}
}
if(parent->key < newNode->key)
{
parent->right = newNode;
}
else
{
praent->left = newNode;
}
Maintain(T, key>=T->size);
}
SBT的Select函数返回以某结点为树根的子树包含第k小的值的结点指针
还是那句话,对于给定的查找树结点,左孩子的值>当前结点的值>右孩子的值
int Select(SBTPtr *T, int k)
{
int r = T->left->size+1;
if(r == k)
return T->key;
else
if(r < k)
return Select(T->right, k-r);
else
return Select(T->left, k);
}
SBT的Rank函数返回某结点的key值排名;Rank和Select互为补充
int Rank(SBTPtr *T, int key)
{
if(key < T->key)
return Rank(T->left, key);
else
if(key > T->key)
retrun rank(T->right, key) + T->left->size + 1;
else
retrun T->left->size + 1;
}
替罪羊树是一种基于重构的重量级(为什么说它是『重量级』?因为每次都要暴力重构啊,不懂往下看)查找树
替罪羊树的『平衡』是这样定义的:给出定值alpha(0.5≤alpha≤1),对于对树中的每个结点x,如果Size(left(x))≤α·size(x)和Size(right(x))≤α·size(x),则称这棵树满足替罪羊树的平衡;也就是说,结点的两棵子树包含结点数不超过整棵子树的α倍,那么就称这个节点x是α-大小平衡的(∂大小自取)——始终要谨记,不同的树对于『平衡』的定义是不同的,而只有AVL才是真正意义上的高度平衡
替罪羊树不是『旋转机制』,真的它不用旋转(像Splay那样好歹也是基于Rotate的,但是替罪羊树每次的『趋平衡操作』却是『取中点,定树根』)。看到这里,好像维护平衡不用旋转的也只有『替罪羊树』了……
如果在插入、删除结点后,查找树不再满足替罪羊树的平衡,就要『拍平重构』整棵树:先『打散』、『拍平』所有的结点,让后递归地,取中值结点为树根,并对左右子树做同样的操作
不难看出,『拍平』的时间复杂度(我们讲时间复杂度通常是指『最坏时间复杂度』)为O(N)
2-3树是一种多路查找树,它包含2-结点和3-结点——多路查找是为了方便磁盘读写的需要(后面有讲,一句就是把待读取信息尽量集中化)
2-结点:等同于一般查找树的结点,即有两个孩子(或者没有孩子),左孩子小右孩子大
3-结点:该结点有三个孩子(或者没有孩子),其中左孩子小于结点中较小元素,中间孩子介于两个孩子之间,右孩子大于结点中较大元素
由于3-结点的加入,2-3树的所有叶子结点在同一层上(矮矮胖胖的特点)
可以看到,所谓n-结点就是有n个孩子的结点(对应该结点就有n-1个值域)
因为比普通查找树,每个结点多了一个可能的值域(每个结点可以有0、1、2个不同的值域,),所以2-3树在插入新结点时,不仅可作为已有结点的孩子,也可以融合到已有的2-结点成为3-结点(其实想一想,我们可以认为普通的二叉查找树是所谓的1-2树,这样我们说知识就成体系了)
1)将新结点插入到2-结点
原2-结点扩充成为3-结点
2)将新结点插入到3-结点
case1)3-结点没有父结点(即整棵树只有它一个3-结点)
我们采用『先插入,后分解』的思想,把新结点插入到原来的3-结点使其变成一个4-结点(3个值域,4个孩子),然后把该4-结点分解并合成为一棵新的二叉查找树(即左孩子<当前结点<右孩子)
case2)3-结点有一个2-结点的父结点
同样先插入,再分解4-结点,将分解后的新树的父节点融入到2-节点的父节点中去
case3)3-结点又一个3-结点的父结点
还是先插入,再分解4-结点,接着往上融合,由于2-3树的最大结点值域数为2,而4-结点有3个值域,所以当前结点的父结点接着向上融合,直到父结点为2-结点为止;如果向上到根节点都是3-节点,将根节点扩充为4-节点,然后分解为新树,至此,整个树增加一层,仍然保持平衡
如果要构建一棵2-3树,他的理论基础就是『2-3树的插入』,即从零开始不断插入一些新的结点到树里面去;略
小结:2-3树因为加入了3-结点,所以一个结点最多可以有两个值域这不是说真的可以选择性的让某些结点有两个孩子而某些结点有三个孩子,那样的话就达不到矮矮胖胖的效果了——每一个结都要有达到最大值域数的趋势,在达到最大值域数之前,只要有新结点进来都要优先考虑融合——举例:一个2结点,当新结点插入后,融合成为3-结点,优先考虑融合
2-3树的一个著名变种就是『著名的红黑树』(在文章后面)
首先声明,B树和B-树(『-』是连字符)是同一种树的两种不同叫法
B树也是一种多路查找树,B 树中的每个结点根据实际情况可以包含大量的关键字信息和分支(当然不能超过磁盘块的大小,根据磁盘驱动的不同一般块的大小在1k~4k左右);这样树的深度降低了,就意味着查找一个元素只要很少结点从外存磁盘中读入内存,很快访问到要查找的数据,这样的特点决定了B树(以及它的变体B+树,后面有讲)很适合用在那些可能需要频繁磁盘读写的场景,比如磁盘文件系统、数据库索引查找(为啥?因为『多路』所以每次磁盘可以读取更多数据)
B树的每个节点比原来那些节点多了一个指示该节点元素个数的存储空间
最小度数为d、最大度数(称为B树的阶)为2*d-1、高度为h的B树(一般B树的阶都比较大):
1)最坏情况下(类似于普通二分查找):第一层有1个结点、第二层有2个结点、第三层有2d……第h(h≥3)层有2d^(h-2)个结点
2)最好情况下(尽可能发挥多路径的优势):第一层有1个结点、第二层有2个结点、第三层有(2d-1)^2……第h层(h≥3)有(2d-1)^(h-1个结点)
它是在1972年由Rudolf Bayer发明的,当时被称为平衡二叉B树(Symmetric Binary B-Trees),后来在1978年被 Leo J. Guibas 和 Robert Sedgewick 修改为如今的『红黑树』
红黑树背后的逻辑就是2-3树
红黑树的五条性质是理解红黑树的基础,必须记住(其实用熟了就是水到渠成的事情)
1)结点是红色或黑色
2)树根是黑色
3)每个叶子结点NIL也是黑色的
4)每个红色结点的两个孩子是黑色的(反之不一定成立)
5)从任一结点到每个叶子结点的所有路径包含相同数目的黑色结点
以上,第一条『颜色』可以用字符串写出,也可以用一个二元变量(比如boolean类型)来表示,这不是固定死的;第三条『叶子结点』是指『空的叶子NIL』;第四条也可以说成『从每个叶子到树根的所有路径上不能有两个连续的红色节点』;与其说这五条是『性质』,不如认为这是五条『作为红黑树必须要遵守的约定』
在满足前五条性质的基础上,可以导出红黑树一个最重要的性质:从树根到叶子的最长的可能路径不多于最短的可能路径的两倍长;建议自证。
这里略作解释:由性质四的推论『从每个叶子到树根的所有路径上不能有两个连续的红色节点』,考虑极端情况『最短路径都是黑色结点』,结合性质五让任意结点等于树根则『从树根到叶子交替出现黑色、红色、黑色……』,所以对于一般情况『从树根到叶子的最长路径不多于最短路径的两倍长』
注意性质五,『从任一结点到每个叶子结点的所有路径包含相同数目的黑色结点』,如果我们忽略红色结点,红黑树就和2-3树一样所有的叶子结点都在同一层了——为了做到这一点,我们认为树中所有红色结点不是真正的结点而把它们看成某种『链接』,用来链接相邻的两个黑色结点,既然有两个黑色结点就有两个值域,故可以把红黑树看成是一种2-3树的特殊实现
和所有查找树一样,『插入』、『删除』操作可能会导致树的失衡
红黑树因为加入了『颜色』这个属性,所以除了旋转还可以通过调整结点的颜色来调整平衡
既然失衡(准确说是『五条性质的一条或多条被打破』)发生在『插入』、『删除』操作后,下面就以具体实例进行讲解
插入红黑树的新结点必须是红色(硬性规定!),稍微解释下,考虑到性质五,如果插入结点的颜色不确定,就不知道在插入后还是否满足黑色结点数目相等,所以干脆就定死了,要求『新插入的结点必须是红色的』
在插入后,检查如果有违背五条性质任何一条,就通过变色或者旋转以满足五条性质(以重新达到平均意义上的『平衡』)
同样我们分类讨论结点插入,存在以下五种情况:
1)被插入结点是树根
违背性质二:把该结点变色为黑色即可
2)被插入结点的父结点是黑色
什么也不要做,这时候满足红黑树的五条性质
3)被插入结点的父结点是红色——这个比较复杂(复杂不怕,分类讨论之)
case1)叔叔结点也是红色
把父结点和叔结点变为黑色,把祖父结点变为红色,设置祖父结点为当前操作结点(用来判断是否违背五条性质),并递归上述操作
图片来自网络:
case2)叔叔结点是黑色或缺失,且插入结点是『RL』或『LR』类型
以当前结点的父结点作为新的当前结点,把插入结点绕着父结点旋转(左孩子右旋,右孩子左旋),并对新的当前结点递归操作
case3)叔叔结点是黑色或缺失,且插入结点是『RR』或『LL』类型
把父结点变成黑色,把祖父结点变成红色,接着以祖父结点为支点旋转(左孩子右旋,右孩子左旋,注意当前结点没变还是插入结点),并对当前结点递归操作
图从略
小结一下,红黑树的插入:首先按照二叉查找树正常插入,然后对当前结点递归判断是否满足红黑树的五条性质,如果不满足则先进行变色,后进行旋转(这个先后顺序已然成为惯例);另外,注意到红黑树结点总要判断父结点是否满足某些情况,所以可以考虑每个结点增加一个用来存储父结点的域(parent,以及叔叔结点的域uncle),这样查找起来也方便(牺牲空间保护时间);另另外,上述实现翻译成代码免不了有想用递归的冲动,但是不妨使用迭代这样效率更高
插入讲完了,删除类似:首先视作普通二叉查找树进行结点删除,然后先变色,后旋转(不过是多写几个if-else罢了!)
区间树也是一种二叉查找树,是在红黑树基础上进行扩展得到的支持以区间为元素的动态集合,其中每个节点的关键值是区间的左端点
使用区间树,区间内元素的查找和插入都可以在O(lgN)的时间内完成(这里的区间查找的并不是精确查找,而是查找和给定区间重叠的元素)
/*interval结构体声明*/
typedef struct interval
{
int high;
int low;
}Interval;
/*IntervalNode结构体声明*/
typedef struct _intervalNode
{
int key;
bool color;
struct _intervalNode *parent;
struct _intervalNode *left;
struct _intervalNode *right;
struct Interval interval;
int max;
}IntervalNode;
/*IntervalTree结构体声明*/
typedef struct _intervalTree
{
Interval *root;
}IntervalTree;
这里Interval(额外信息)被封装在IntervalNode(结点结构)中,而后者又被封装在IntervalTree(树结构)中,层层封装。
这样的做法思路清晰,便于理解,之前我们对AVL树的处理是没有写出树结构,而最后使用时在main函数中定义一个结点指针,它就相当于这里的root指针,后面所有的操作都是在它的基础上进行的,对于AVL我们也可以借鉴这里的做法,把一个root指针封装到声明的树结构中,然后要用时使用树结构就可以了
应用区间树的情形,必须符合区间加法,反例如求众数的相关问题就不能用线段树——所谓满足区间加法、区间减法的意思,如果知道[l, m]和[m+1, r]的信息,可以求出[l, r]的信息,就说[l, m]和[m, r]满足区间加法,如果知道了[1, x]和[1, y]的信息,可以求出[x+1, y]的信息,就说[1, x]和[1, y]满足区间减法(请在脑子里画一个数轴表示区间的图,理解起来很简单)
1)建树
2)查询
3)插入
4)删除
5)更新
区间树的一个特殊形式就是线段树(而且通常说线区间就是指线段树),下面重点讲解线段树
在讲之前,介绍一下我的代码风格:因为是区间所以我不喜欢用left、right来表示(那是表示链表这样的元素之间的关系),我会用low、high来表示相应区间的左边界、右边界,如果是查询的话,因为给定了范围我会用大些字母L、H替代low、high,这样看起来很清爽
线段树是特殊的区间树,它将一个区间划分成一些单元区间,每个单元区间对应线段树中的一个叶结点(线段树的结点表示线段区间)
线段树快速查找的理论基础是:当n≥3时,一个[1, n]的线段树可以将[1, n]的任意子区间[L, R]分解为不超过2*[ log2(n-1)]个子区间
在讨论线段树时,[L, R]是值从L到R这(R-L+1)个连续的离散点,而不是真的指一条线段
因为线段树是一棵二叉查找树,所以它的操作集中除了建树是O(N),其它的都是O(logN)
/*顺序存储(推荐)*/
typedef struct _segmentNode
{
int low;
int high;
int sum;
}SegmentTree[NODESIZE]; //NODESIZE = 4*n(n为需要区间长度,包括两端)
/*链式存储(和顺序存储比较)*/
typedef strut _segmentNode
{
struct _segmentNode *left;
struct _segmentNode *right;
int sum;
}SegmentNode;
怎么说呢,看个人习惯吧——不过使用顺序存储的貌似相对容易实现一点,特别是后面的update等操作的时候;另外都知道顺序存储的查找效率要高些;并且使用数组甚至可以不用存储每个结点的左右孩子(详见后文)
这里上一张基于数组的线段树图(来自网络)
(规律)可以看出, 对于线段树而言,一个结点下标为n,则它的左孩子下标为2*n,右孩子下标为2*n+1;又因为它是一步一步构造出来的这一点很清楚,叶子节点只会出现在最下层和次下层,所以一棵线段树一定是一棵完全二叉树(一类查找效率很高的树),在特殊情况下还会成为效率极高的满二叉树
另外如果使用链式存储,为了方便描述,我们通常会给原始数组(之后要把这里面的数据填进线段树中)建立一个数组,以后取用的使用也好找;对于每个结点由于下标唯一对应,我们还可以建立一个描述结点存储的额外信息的数组(如下面的sum数组)
define DATASIZE 100000
int Data[DATASIZE];
int sum[DATASIZE<<2];
int tag[DATASIZE<<2];
线段树的构造(不单指建立树根而是指建立整棵树)实际上是利用了二分法的思想
可以看出,线段树丛树根(从high、low的值看可以表示要讨论的整个区间)往下,每一次的分叉(划分区间),都进行如下操作:
1)由父结点[L, R],求出M=(L+R)/2,这里的除法是『整除』(向下取整,体现离散的点)
2)将父区间划分为两个子区间分别是[L, M]和[M+1, R]
3)对新产生的两个子区间递归进行步骤2和步骤3的操作
4)直到L==R,该结点是树叶结点
/*函数功能:自下而上更新当前结点的数据域
*请参说明:index就是当前结点的下标
*备注解释:该函数不直接出现,而是被封装到BuildSegmentTree中;这里偷个懒了,认为每个结点存储了它的左右孩子
*/
void PushUp(int index){sum[index] = sum[left] + sum[right]}
/*函数功能:从left到righ指定区间构建一棵线段树
*请参说明:low:当前区间左边界,high:当前区间右边界,index:当前结点编号
*备注解释:使用顺序存储,好处就是不用维护额外的指针域,这里直接传入原始类型;坏处就是每个结点可以保存的内容只能是int之类的整数类型,这一点要注意;另外看代码,这里我选择当前区间的和sum作为要保存的内容,在时机构造中可以灵活选择(必须是整数类型)
*/
void BuildSegmentTree(int index, int low, int high)
{
if(high == low) //比如[2,2],表示已尽达到树叶结点
{
sum[index] = Data[low];
return;
}
BuildSegmentTree(index<<1, left, (high+low)>>1);
BuildSegmentTree(index<<1|1, (high+low)>>1+1, right);
PushUp(index);
}
如果待查找区间是2的n次幂的话,那么构造出来的线段时就是一棵满二叉树
线段树用来对编号连续的一些点进行修改或者统计操作,修改和统计的复杂度都是O(logN)
从线段树的分叉可以看出,如果最终要查找的区间长度为n,至少需要为线段树结点分配(2n-1)*结点大小的内存(等号当前仅当该线段树成为一棵满二叉树时成立);鉴于此,在实际工程中往往声明结点个数为4*n(n为区间长度)
当有新的结点被插入到线段树中,或修改了原来的老数据,就要更新数据了
/*函数说明:点更新,让Data[X]+=C
*请参说明:X:目标结点下标,C:常数,low:当前区间左边界,high:当前区间右边界,index:当前结点下标
*/
void Update(int X, int C, int low, int high, int index)
{
if(low == high)
{
sum[index] += C;
return;
}
int m = (low+high)>>1;
if(X <= m) Update(X, C, low, m, index<<1)
else Update(X, C, m+1, high, index<<1|1)
PushUp(index);
}
因为上面写的线段树ADT中保存的数据域是sum,所以这里查询就是求指定区间的sum;在实际应用中注意灵活处理
/*函数功能:查询[L..H]的和
*请参说明:L:查询区间左边界,H:查询区间右边界,low:当前区间左边界,high:当前区间右边界,index:当前结点下标
*/
void Query(int L, int H, int low, in high, int index)
{
if(L <= low && H >= high) return sum[index];
int m = (low+high)>>1;
int ans = 0;
if(L <= m) ans += Query(L, H, low, m, index<<1);
if(H >= m) ans += Query(L, H, m+1, high, index<<1|1);
return ans;
}