前言
下面的分析是我基于算法导论的二叉搜索树章节来的,本文也可以看做是算法导论二叉搜索树的读书笔记,我会添加自己的理解,尽量把二叉搜索树用自己的话给整明白。本文的语言我用了我熟悉的Java语言。
文中的左孩子和左子树的意思是一样的,相对应的右孩子和右子树的意思是一样的,个人习惯说左子树,右子树了。
本人的二叉树在有些地方可能没有涉及二叉搜索树能否存相同值的问题。
什么是二叉搜索树
首先它是一个二叉树,其次听名字我们也知道,这个特殊的二叉树在搜索方面应该有优势。二叉搜索树可以用一个链表数据结构来表示,每一个结点都是一个对象,对象的属性分别为left(左孩子),right(右节点),p(双亲结点,也可以叫父节点,我觉得不叫父节点的原因可能和反对性别歧视有关),key(存储的值)。如果孩子结点和父节点不存在,则相应属性值为NIL。根节点是唯一一个父指针为NIL的结点。
//这里我用了静态内部类
static class Node{
public Node left;
public Node right;
public Node p;
public int key;
}
这里key我用了int型,其实我们也可以像Java类库一样设计为只要实现了Comparator的类,能比大小就可以。但是这里我们为了着重说明二叉搜索树,其他地方的设计就选择了越简单越好。
如上图所示,二叉搜索树具有以下特性:
假设x是二叉搜索树的一个结点:
如果y是x的左孩子,那么y.key<=x.key,且以y为祖先所有后代的key都小于x的key。
如果y是x的右孩子,那么y.key>=x.key,且以y为祖先所有后代的key都大于x的key。
以上图举例:根为7的二叉搜索树,它的左孩子4,5,6都不大于7;而它的右孩子8,9都不小于7;
我们采用中序遍历的遍历方法,可以按照key的值从小到大的遍历二叉搜索树。
//这里用了递归的方式来做了tree的中序遍历
public void inorder_tree_walk(Node x){
if (x!=null){
inorder_tree_walk(x.left);
System.out.println(x.key);
inorder_tree_walk(x.right);
}
}
中序遍历一个结点数为n的二叉搜索树的时间复杂度为O(n)。
查询指定key的结点
public Node tree_search(Node head,int k){
while(head!=null||head.key!=k){
if(head.key>k)
head=head.left;
else
head=head.right;
}
return head;
}
这里,我们指定key为k,在根节点为head的树里查找k对应的结点。
如果根有子树,且k的不是根的key,我们就进入循环,否则就直接返回根节点。
进入循环后,判断当前结点的key是否大于k,如果大于就去当前结点的左孩子找,否则就去当前结点的右孩子找。
查询指定值的结点的时间复杂度为O(h),h为树的高度。
查找最小值
根据二叉搜索树的性质,左孩子总比根结点和右孩子的值要小,所以要找一棵二叉搜索树的最小值,就一直“取左”就可以了。查找最小值的时间复杂度为O(h),h为树的高度。
public Node tree_minmum(Node head){
while(head.left!=null){
head=head.left;
}
return head;
}
查找最大值
与查找最小值相似但是相反的道理,根据二叉搜索树的性质,右孩子总比根结点和左孩子的值要大,所以要找一棵二叉搜索树的最大值,就一直“取右”就可以了。查找最大值的时间复杂度为O(h),h为树的高度。
public Node tree_maxmum(Node head){
while(head.right!=null){
head=head.right;
}
return head;
}
查找当前结点的后继结点
这里说的后继结点可以简单的理解为,树中比当前结点的值大的最小的结点。
例如,我们拿4来举例,比4大,但最接近4的是6,所以6是4的后继结点.
根据我们的目的,寻找指定结点的后继结点我们分为下面三种情况:
1.指定结点有右孩子,那么它右孩子的最小值就是它的后继结点。例如上图的6的后继结点就是6结点的右孩子的最小值7.
2.指定结点没有右孩子,但是指点结点是它父结点的左孩子,那么它的后继结点就是它的父结点。例如图中2结点的后继结点就是它的父结点3.
3.指定结点没有右孩子且指定结点是它父结点的右孩子,那么就一直向“祖辈”上面找,直到查到当前结点为根节点说明指定结点就是二叉树的最大值,它没有后继或者查到当前结点是它父结点的左孩子。那么当前结点的父结点就是指定结点的后继。例如:结点13,是它父结点7的右孩子,根据二叉搜索树的性质右孩子肯定比父结点大,那么我们继续向“祖辈”上找,发现结点7也是6的右孩子,再继续“向上”找,发现结点6是结点15的左孩子,则结点15就是13的后继。
第3点我开始理解的时候琢磨了好一会,其实还是根据二叉搜索树的性质来的:根结点的值比起后代左子树都大,比其后代右子树的都小。
下面的程序解决了:如果后继存在,就返回其后继结点,如果传入的x本身就是这颗二叉搜索树的最大值,那么它就没有后继结点,就返回null。时间复杂度为O(h),h为树的高度。
public Node tree_successor(Node x){
Node y;
if(x.right!=null){
return tree_minmum(x.right);
}
y=x.p;
while(y!=null&&x==y.right){
x=y;
y=y.p;
}
return y;
}
查找当前结点的前驱结点
前驱结点是指:比指定结点小的最大值。
我们还用这个图来说明,图中比9小最接近9的是7,所以结点7就是9的前驱结点。
和后继结点对应的,我们把寻找指定结点的前驱结点也分为3中情况:
1.如果指定结点有左子树,那么左子树的最大值就是它的前驱结点。例如根结点15的前驱结点就是其左子树的最大值,也就是结点13.
2.如果指定结点没有左子树,但是它是它父结点的右子树,那么它的父结点就是它的前驱结点,例如结点4是结点3的右子树,那么3就是4的前驱结点。
3.如果指定结点是其父结点的左子树,那么我们就一直“向上”查找,直到查到根节点,说明指定结点就是二叉树的最小值,它没有前驱结点,或者查到当前结点为其父结点的右子树,那么当前结点的父结点就是指定结点的前驱。例如,结点9为结点13的左子树,那么继续向上,结点13是其父结点7的右子树,那么7就是9的前驱。
下面的程序解决了:如果前驱存在,就返回其前驱结点,如果传入的x本身就是这颗二叉搜索树的最小值,那么它就没有前驱结点,就返回null。时间复杂度为O(h),h为树的高度
public Node tree_predecessor(Node x){
Node y;
if(x.left!=null){
return tree_maxmum(x.left);
}
y=x.p;
while(y!=null&&x==y.left){
x=y;
y=y.p;
}
return y;
}
二叉搜索树的插入
插入一个新的结点在二叉搜索树中。先来看图
要将值为13的结点,插入二叉树中,正确的结果是作为15的左子树,图中用虚线连接,我们直接结合着代码分析。解释的话直接用注释的方式写在代码里了。时间复杂度为O(h),h为树的高度。
public void tree_insert(Node head, Node z){
Node y=null;
Node x=head;
while(x!=null){
//如果树不为空,就找到z在树中应该在的位置
y=x;//最后y是z对应的父结点
if(x.key>z.key){
x=x.left;
}
else{
x=x.right;
}
}
z.p=y;
if(y==null){
head=z;//树为空的情况
}
else if(y.key>z.key){
y.left=z;
}
else{
y.right=z;
}
}
插入的情况还是相对简单的,接下来删除的情况可要比插入要复杂的多。
二叉搜索树的删除
从二叉搜索树T中删除指定结点z大致分为3钟情况:
1.z结点为叶子结点,也就是z没有子树,那么只需要把z简单的删除。
2.z结点只有一个孩子结点,那么将这个孩子结点替换z的位置,并修改z的父结点的指针,使其指向z的唯一的孩子。
3.z结点有两个孩子,那么找z的后继结点y(一定在z的右子树),并让y占据树中z的位置,z的右子树部分就是y的右子树,z的左子树作为y的左子树,这种情况有点麻烦,因为还与y是否是z的直接右子树相关。下面我们会分析。
根据以上三种情况,我们可以再详细用图演示:
1.为叶子结点
直接删除就可以。
2.只有一个孩子
直接用孩子顶替就可以。
3.第三种情况又分为两种
3.1 z的后继结点y就是z的右子树
这种情况,直接用y替换z。
3.2 z的后继结点y不是z的右子树
如上图所示,z的后继结点y不是z的直接右子树,在移动的时候要注意:
首先用x来替代y的位置,并将y设置为r的父结点
最后把l设置为y的左子树。
这里我们先定义一个中间函数,它的作用是用另一个子树替换一棵子树,并成为其父结点的孩子结点。下面的函数就是用子树v来替换子树u。
public void transplant(Node head,Node u,Node v){
if(u.p==null){
head=v;
}
else if(u==u.p.left){
u.p.left=v;
}else{
u.p.right=v;
}
}
接下来我们就来看真正删除函数,解析就在代码注释中,时间复杂度为O(h),h为树的高度。
public void tree_delete(Node head,Node z){
Node y;
//if和else if包括了前两种情况,其实当left为null是right也可以为null,就是第一种情况,使用transplant方法其实就是把null结点作为z父结点的孩子,和直接删除z的效果一样
if(z.left==null){
transplant(head, z, z.right);
}
else if(z.right==null){
transplant(head, z, z.left);
}
//这里是第三种情况
else{
//先找出z的后继结点
y=tree_minmum(z.right);
if(y!=z.right){
//如果y不是z的右子树,就先用y的右子树来代替y的位置,(因为y是z的后继结点,y肯定是没有左子树的),这里right为null也没关系,就是说y没有右子树,transplant方法的作用相当于把y的父结点相对应的孩子设置为null。
transplant(head, y, y.right);
//把z的右子树给y.
y.right=z.right;
y.right.p=y;
}
//让z的父结点指向y
transplant(head, z, y);
//最后再把z的左子树给y
y.left=z.left;
y.left.p=y;
}
}
总结:总来说二叉搜索树的各项操作的时间复杂度为O(h),但是考虑一种情况,一棵二叉搜索树有n个结点,每个结点只有左子树或者只有右子树。止痒整棵树其实是一个链表,树的高度就n-1,时间复杂度就相对大了,经证明可得
h>=|lgn|,红黑树是二叉搜索树的变体,它可以保证它的树高位O(lgn)。来对二叉搜索树进行平滑折中。