1. 什么是二叉搜索树
二叉搜索树可以使用一个链表数据结构来表示, 每个结点就是一个对象. 除了 key k e y 和卫星数据(satellite data, 该对象附带的其它数据)之外, 每个结点还包含着属性 left l e f t , right r i g h t 和 p p , 它们分别指向结点的左孩子, 右孩子和双亲. 如果某个孩子结点或父结点不存在, 则该属性的值为NIL. 根结点是树中唯一父结点为NIL的结点.
1.1 二叉搜索树结构图
1.2 二叉搜索树性质
- 设是一个二叉搜索树中的一个结点. 如果
y
y
是左子树中的一个结点, 那么
y.key≤x.key
y
.
k
e
y
≤
x
.
k
e
y
.如果
y
y
是右子树中的一个结点, 那么
y.key≥x.key
y
.
k
e
y
≥
x
.
k
e
y
.
根据性质, 可以得到:
- 可以通过递归算法来按序输出二叉搜索树中的所有关键字(称为中序遍历)
- 如何中序遍历二叉树?
- 遍历一棵有n个结点的二叉搜索树需要耗费 O O ()的时间. (每个结点需要遍历两次, 左孩子和右孩子)
2. 查询二叉搜索树
2.1 查找
如在一棵二叉搜索树中查找一个具有给定关键字的结点(结点可以不存在). 输入一个根结点一个关键字 k k , 如果这个结点存在, 就返回一个关键字为的结点, 否则返回NIL
伪代码实现为:
//递归实现为: TREE-SEARCH(x, k) if x == NIL or k == x.key return x if k < x.key return TREE-SEARCH(x.left, k) else return TREE-SEARCH(x.right, k) //迭代实现为: ITERATIVE-TREE-SEARCH(x, k) while x ≠ NIL and k ≠ x.key if k < x.key x = x.left else x = x.right return x
为什么迭代方法的效率比递归高得多?
递归效率低是函数调用的开销导致的。在一个函数调用之前需要做许多工作,比如准备函数内局部变量使用的空间、搞定函数的参数等等,这些事情每次调用函数都需要做,因此会产生额外开销导致递归效率偏低,所以逻辑上开销一致时递归的额外开销会多一些当然了.
通过有意识的组织代码的写法可以把某些递归写成尾递归,尾递归可以进行特殊的优化所以效率会比普通的递归高一些,也不会因为递归太多导致栈溢出.
遍历树还不用递归的话,那么人肉写一个栈+深度优先遍历或者人肉队列+广度优先遍历,再辅以黑魔法给栈或者队列提速,应该会比递归快一些,加速幅度和语言和写法相关,但在大多数情况下我觉得是得不偿失的,花了很大精力很可能效率提升不明显.
—-来自知乎为什么说递归效率低?Yul8ulY的回答
尾调用(Tail Call)的优化:
- 什么是尾调用: 在计算机学里, 尾调用是指一个函数里的最后一个动作是返回一个函数的调用结果的情形, 即最后一步新调用的返回值直接被当前函数的返回结果.
- 递归函数的改写: 尾调用优化
2.2 最大关键字和最小关键字
通过树根沿着 left l e f t 孩子直到遇到一个NIL, 则当前结点为最小关键字结点
伪代码实现为:
TREE-MINIMUM(x) while x.left ≠ NIL x = x.left return x
同理, 可以得出最大关键字结点的求法为:
TREE-MAXIMUM(x) while x.right ≠ NIL x = x.right return x
2.3 后继和前驱
- 一个结点的后继是二叉搜索树中序遍历结果中该结点的后一个结点(也就是 key k e y 值比当前 key k e y 值大的第一个结点)
- 如果结点 x x 的右孩子非空并有一个后继, 那么 y y 就是的有左节点的最底层祖先, 并且它也是 x x 的一个祖先.
如下图, 关键字为13的结点的后继是关键词为15的结点.
伪代码实现为:
//寻找后继结点 TREE-SUCCESSOR(x) if x.right ≠ NIL return TREE-MINIMUM(x.right) y = x.p while y ≠ NIL and x == y.right x = y y = y.p return y //寻找前驱结点 TREE-PREDECESSOR(x) if x.left ≠ NIL return TREE-MAXIMUM(x.left) y = x.p while y ≠ NIL and x == y.left x = y y = y.p return y
总结
- 在一棵高度为的二叉搜索树上, 动态集合上的操作
SEARCH
,MINIMUM
,MAXIMUM
,SUCCESSOR
,PREDECESSOR
均能在 O O ()时间内执行完.
3. 插入和删除
3.1 插入
将一个新值 v v 插入到一棵二叉搜索树中, 需要调用过程
TREE-INSERT
. 该过程以结点 z z 作为插入, 其中 = v v , = NIL, z.right z . r i g h t = NIL. 这个过程要修改 T T 和的某些属性, 来把 z z 插入到树中的相应位置上.将关键字为13的数据项插入二叉搜索树的过程图解为:
伪代码实现为:
TREE-INSERT(T, z) y = NIL x = T.root while x ≠ NIL y = x if z.key < x.key x = x.left else x = x.right z.p = y if y == NIL T.root = z // tree T was empty elseif z.key < y.key y.left = z else y.right = z
3.2 删除
删除结点的整个策略分为3个基本情况:
- 如果 z z 没有孩子结点, 则简单删除它, 并修改父结点, 用NIL代替
- 如果 z z 只有一个孩子结点, 将的孩子提升到 z z 的位置, 并修改的父结点, 用 z z 的孩子代替. (如下图(a), (b)过程)
- 如果
z
z
有两个孩子结点, 需要寻找的后继
y
y
:
- 如果z的右孩子没有左孩子, 则的右孩子为后继 y y , 用代替 z z , 保持的右子树不变. (如下图中(c)过程)
- 如果z的右孩子( r r )有左孩子, 则寻找子树中最小的结点, 即为 y y , 用的右子树(如果它不是叶子结点)代替 y y 的父结点的左子树, 用代替 z z , 将的父结点设置为 y y , 将的父结点的右子树设置为 y y . (如下图中(d)过程)
为了在二叉搜索树中移动子树, 定义一个子过程
TRANSPLANT
, 它是用另一棵子树代替一棵子树.当
TRANSPLANT
用一棵以为子树的根来代替一棵以 u u 为根的子树时, 伪代码实现如下:TRANSPLANT(T, u, v) if u.p == NIL T.root = v elseif u == u.p.left u.p.left = v else u.p.right = v if v ≠ NIL v.p = u.p
注意:
- 以上
TRANSPLANT
并没有处理和 v.right v . r i g h t 的更新, 这些更新都有TRANSPLANT
的调用者来负责
二叉搜索树 T T 中删除结点的删除过程为:
TREE-DELETE(T, z) if z.left == NIL TRANSPLANT(T, z, z.right) elseif z.right == NIL TRANSPLANT(T, z, z.left) else y = TREE-MINIMUM(z.right) if y.p ≠ z TRANSPLANT(T, y, y.right) y.right = z.right y.right.p = y else TRANSPLANT(T, z, y) y.left = z.left y.left.p = y
4. 随机构造二叉搜索树
5. 二叉搜索树的变体