一、二叉搜索树
**二叉搜索树(BST binary search tree)**是一种比较特殊的二叉树,表现为任意节点的值都比左孩子的值要大,而且小于等于右孩子的值,采用中序遍历BST(Binary Search Tree)就可以的到排序好的元素集合,而且插入删除的时间消耗也比较合理,但是有一个缺点就是内存开销有点大。
二叉搜索树的性质
1,任意节点x,其左子树中的key不大于x.key,其右子树中的key不小于x.key。
2,不同的二叉搜索树可以代表同一组值的集合。
3,二叉搜索树的基本操作和树的高度成正比,所以如果是一棵完全二叉树的话最坏运行时间为Θ(lgn),但是若是一个n个节点连接成的线性树,那么最坏运行时间是Θ(n)。
4,根节点是唯一一个parent指针指向NIL节点的节点。
5,每一个节点至少包括key、left、right与parent四个属性,构建二叉搜索树时,必须存在针对key的比较算法。
二、二叉搜索树的实现
1. 定义
树的节点:
class TreeNode:
def __init__(self,key,val,left=None,right=None,parent=None):
self.key = key
self.payload = val
self.leftChild = left
self.rightChild = right
self.parent = parent
class BinarySearchTree:
def __init__(self):
self.root = None
self.size = 0
2.插入
现在我们有了 BinarySearchTree shell 和 TreeNode,现在是时候编写 put 方法,这将允许我们构建二叉搜索树。 put 方法是BinarySearchTree 类的一个方法。此方法将检查树是否已具有根。如果没有根,那么 put 将创建一个新的 TreeNode 并将其做为树的根。如果根节点已经就位,则 put 调用私有递归辅助函数 _put 根据以下算法搜索树:
- 从树的根开始,搜索二叉树,将新键与当前节点中的键进行比较。如果新键小于当前节点,则搜索左子树。如果新键大于当前节点,则搜索右子树。
- 当没有左(或右)孩子要搜索时,我们在树中找到应该建立新节点的位置。
- 要向树中添加节点,请创建一个新的 TreeNode对象,并将对象插入到上一步发现的节点。
插入时,总是作为叶节点插入到树中
给定序列去依次构造二叉查找树,则二叉搜索树唯一;如果是用这个序列所有的关键字去构造可能的二叉树(排列任意),则一般不唯一。
def put(self,key,val):
if self.root:
self._put(key,val,self.root)
else:
self.root = TreeNode(key,val)
self.size = self.size + 1
def _put(self,key,val,currentNode):
if key < currentNode.key:
if currentNode.hasLeftChild():
self._put(key,val,currentNode.leftChild)
else:
currentNode.leftChild = TreeNode(key,val,parent=currentNode)
else:
if currentNode.hasRightChild():
self._put(key,val,currentNode.rightChild)
else:
currentNode.rightChild = TreeNode(key,val,parent=currentNode)
上面展示了在树中插入一个新节点的 Python 代码。_put 函数按照上述步骤递归编写。请注意,当一个新的子节点插入到树中时,currentNode 将作为父节点传递给新的树节点。
我们实现插入的一个重要问题是重复的键不能正确处理。当我们的树被实现时,重复键将在具有原始键的节点的右子树中创建具有相同键值的新节点。这样做的结果是,具有新键的节点将永远不会在搜索期间被找到。处理插入重复键的更好方法是将新键相关联的值替换旧值。
3.查找
一旦树被构造,下一个任务是实现对给定键的值的检索。get 方法比 put 方法更容易,因为它只是递归地搜索树,直到它到达不匹配的叶节点或找到匹配的键。当找到匹配的键时,返回存储在节点的有效载荷中的值。
当二叉查找树不为空时:
- 首先将给定值与根结点的关键字比较,若相等,则查找成功
- 若小于根结点的关键字值,递归查左子树
- 若大于根结点的关键字值,递归查右子树
- 若子树为空,查找不成功
def get(self,key):
if self.root:
res = self._get(key,self.root)
if res:
return res.payload
else:
return None
else:
return None
def _get(self,key,currentNode):
if not currentNode:
return None
elif currentNode.key == key:
return currentNode
elif key < currentNode.key:
return self._get(key,currentNode.leftChild)
else:
return self._get(key,currentNode.rightChild)
def __getitem__(self,key):
return self.get(key)
4.删除
第一个任务是通过搜索树来找到要删除的节点。 如果树具有多个节点,我们使用 _get 方法搜索以找到需要删除的 TreeNode。 如果树只有一个节点,这意味着我们删除树的根,但是我们仍然必须检查以确保根的键匹配要删除的键。 在任一情况下,如果未找到键,del 操作符将引发错误。
def delete(self,key):
if self.size > 1:
nodeToRemove = self._get(key,self.root)
if nodeToRemove:
self.remove(nodeToRemove)
self.size = self.size-1
else:
raise KeyError('Error, key not in tree')
elif self.size == 1 and self.root.key == key:
self.root = None
self.size = self.size - 1
else:
raise KeyError('Error, key not in tree')
def __delitem__(self,key):
self.delete(key)
一旦我们找到了我们要删除的键的节点,我们必须考虑三种情况:
- 要删除的节点没有子节点。
- 要删除的节点只有一个子节点。
- 要删除的节点有两个子节点。
第一种情况:
如果当前节点没有子节点,我们需要做的是删除节点并删除对父节点中该节点的引用。
第二种情况:
如果一个节点只有一个孩子,那么我们可以简单地促进孩子取代其父。此案例的代码展示在下一个列表中。当你看这个代码,你会看到有六种情况要考虑。由于这些情况相对于左孩子或右孩子对称,我们将仅讨论当前节点具有左孩子的情况。决策如下:
- 如果当前节点是左子节点,则我们只需要更新左子节点的父引用以指向当前节点的父节点,然后更新父节点的左子节点引用以指向当前节点的左子节点。
- 如果当前节点是右子节点,则我们只需要更新左子节点的父引用以指向当前节点的父节点,然后更新父节点的右子节点引用以指向当前节点的左子节点。
- 如果当前节点没有父级,则它是根。在这种情况下,我们将通过在根上调用replaceNodeData 方法来替换 key,payload,leftChild 和 rightChild 数据。
第三种情况:
最难处理的情况。 如果一个节点有两个孩子,那么我们不太可能简单地提升其中一个节点来占据节点的位置。 然而,我们可以在树中搜索可用于替换被调度删除的节点的节点。 我们需要的是一个节点,它将保留现有的左和右子树的二叉搜索树关系。 执行此操作的节点是树中具有次最大键的节点。 我们将这个节点称为后继节点,我们将看一种方法来很快找到后继节点。 继承节点保证没有多于一个孩子,所以我们知道使用已经实现的两种情况删除它。 一旦删除了后继,我们只需将它放在树中,代替要删除的节点。
找到后继的代码如下所示,是 TreeNode 类的一个方法。此代码利用二叉搜索树的相同属性,采用中序遍历从最小到最大打印树中的节点。在寻找接班人时,有三种情况需要考虑: - 如果节点有右子节点,则后继节点是右子树中的最小的键。
- 如果节点没有右子节点并且是父节点的左子节点,则父节点是后继节点。
- 如果节点是其父节点的右子节点,并且它本身没有右子节点,则此节点的后继节点是其父节点的后继节点,不包括此节点。
def findSuccessor(self):
succ = None
if self.hasRightChild():
succ = self.rightChild.findMin()
else:
if self.parent:
if self.isLeftChild():
succ = self.parent
else:
self.parent.rightChild = None
succ = self.parent.findSuccessor()
self.parent.rightChild = self
return succ
def findMin(self):
current = self
while current.hasLeftChild():
current = current.leftChild
return current
def spliceOut(self):
if self.isLeaf():
if self.isLeftChild():
self.parent.leftChild = None
else:
self.parent.rightChild = None
elif self.hasAnyChildren():
if self.hasLeftChild():
if self.isLeftChild():
self.parent.leftChild = self.leftChild
else:
self.parent.rightChild = self.leftChild
self.leftChild.parent = self.parent
else:
if self.isLeftChild():
self.parent.leftChild = self.rightChild
else:
self.parent.rightChild = self.rightChild
self.rightChild.parent = self.parent
三、平衡二叉搜索树(AVL树)
1. 概念
二叉搜索树的深度越小,那么搜索所需要的运算时间越小。一个深度为log(n)的二叉搜索树,搜索算法的时间复杂度也是log(n)。然而,我们在二叉搜索树中已经实现的插入和删除操作并不能让保持log(n)的深度。如果我们按照8,7,6,5,4,3,2,1的顺序插入节点,那么就是一个深度为n的二叉树。那么,搜索算法的时间复杂度为n。
在上一节中我们讨论了建立一个二叉搜索树。我们知道,当树变得不平衡时get和put操作会使二叉搜索树的性能降低到O(n)。
如何减少树的深度呢?
一种想法是先填满一层,再去填充下一层,这样就是一个完全二叉树(complete binary tree)。这样的二叉树实现插入算法会比较复杂。另一种比较容易实现的树状数据结构——AVL树,其搜索算法复杂度为log(n)。
在这一节中我们将看到一种特殊的二叉搜索树,它可以自动进行调整,以确保树随时都保持平衡。这种树被称为AVL树,命名源于其发明者:G.M. Adelson-Velskii 和 E.M. Landis。
AVL树实现 Map 抽象数据类型就像一个常规的二叉搜索树,唯一的区别是树的执行方式。为了实现我们的 AVL树,我们需要跟踪树中每个节点的平衡因子。我们通过查看每个节点的左右子树的高度来做到这一点。更正式地,我们将节点的平衡因子定义为左子树的高度和右子树的高度之间的差。
b
a
l
a
n
c
e
F
a
c
t
o
r
=
h
e
i
g
h
t
(
l
e
f
t
S
u
b
T
r
e
e
)
−
h
e
i
g
h
t
(
r
i
g
h
t
S
u
b
T
r
e
e
)
balanceFactor = height(leftSubTree) - height(rightSubTree)
balanceFactor=height(leftSubTree)−height(rightSubTree)
使用上面给出的平衡因子的定义,我们说如果平衡因子大于零,则子树是左重的。如果平衡因子小于零,则子树是右重的。如果平衡因子是零,那么树是完美的平衡。为了实现AVL树,并且获得具有平衡树的好处,如果平衡因子是 -1,0 或 1,我们将定义树平衡。一旦树中的节点的平衡因子是在这个范围之外,我们将需要一个程序来使树恢复平衡。
高度为h的树的节点数(Nh)为:
Nh=1+Nh−1+Nh−2这个公式和斐波那契序列非常相似。我们可以利用这个公式通过树中的节点的数目推导出一个AVL树的高度。随着斐波那契序列的数字越来越大,Fi / Fi−1 越来越接近于黄金比例 Φ
在任何时候,我们的AVL树的高度等于树中节点数目的对数的常数(1.44)倍。 这是搜索我们的AVL树的好消息,因为它将搜索限制为 O(logN)。
2.实现
当新增一个节点时,会破坏二叉树的平衡,因此需要通过旋转来修正。
单旋转
执行一个正确的右旋转,我们需要做到以下几点:
- 使左节点成为子树的根。
- 移动旧根到新根的右节点。
- 如果新根原来有右节点,那么让其成为新根右节点的左节点。
注:由于新根是旧根的左节点,移动后的旧根的左节点一定为空。这时可以直接给移动后的旧根添加左节点。
同理,执行左旋转我们需要做到以下几点:
- 使右节点(B)成为子树的根。
- 移动旧的根节点(A)到新根的左节点。
- 如果新根(B)原来有左节点,那么让原来B的左节点成为新根左节点(A)的右节点。
双旋转
如图,如果子节点是4不是2,尝试单旋转,会发现无法解决问题。
要解决这个问题,我们必须使用以下规则:
- 如果子树需要左旋转使之平衡,首先检查右节点的平衡因子。如果右节点左重则右节点右旋转,然后原节点左旋转。
- 如果子树需要右旋转使之平衡,首先检查左节点的平衡因子。如果左节点右重则左节点左旋转,然后原节点右旋转。
此时解决方式为双旋转。
双旋转实际上是进行两次单旋转: 4为根节点的子树先进行一次向左的单旋转,然后将5为根节点的子树进行了一次向右的单旋转。这样恢复了树的ACL性质。
对于AVL树,可以证明,在新增一个节点时,总可以通过一次旋转恢复AVL树的性质。
旋转规则
当我们插入一个新的节点时,在哪里旋转?是用单旋转还是双旋转?
我们按照如下基本步骤进行:
- 按照二叉搜索树的方式增加节点,新增节点称为一个叶节点。
- 从新增节点开始,回溯到第一个失衡节点(5)。
(如果回溯到根节点,还没有失衡节点,就说明该树已经符合AVL性质。) - 找到断的边(5->3),并确定断弦的方向(5的左侧)
- 以断边下端(3)为根节点,确定两个子树中的哪一个深度大(左子树还是右子树)。
(这两棵子树的深度不可能相等,而且深度大的子树包含有新增节点。) - 如果第2和第3步中的方向一致(都为左或者都为右),需要单旋转以失衡节点为根节点的子树。
- 否则,双旋转以失衡节点为根节点的子树。
四、红黑树
红黑树是一种常用的平衡二叉搜索树,它是复杂的,但它的操作有着良好的最坏情况运行时间,即操作的复杂度都不会恶化,并且在实践中是高效的: 它可以在O(logn)时间内做单次的查找,插入和删除,这里的n是树中元素的数目。
红黑树是一种很有意思的平衡检索树。它的统计性能要好于平衡二叉树( AVL-树),因此,红黑树在很多地方都有应用。在C++ STL中,很多部分(目前包括set, multiset, map, multimap)应用了红黑树的变体(SGI STL中的红黑树有一些变化,这些修改提供了更好的性能,以及对set操作的支持)。
红黑树的主要规则:
红-黑树的主要规则如下:
- 节点是红色或黑色。
- 根是黑色。
- 所有叶子都是黑色(叶子是NIL节点)。
- 每个红色节点必须有两个黑色的子节点。(从每个叶子到根的所有路径上不能有两个连续的红色节点。)
- 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。
在红-黑树中插入的节点都是红色的,这不是偶然的,因为插入一个红色节点比插入一个黑色节点违背红-黑规则的可能性更小。原因是:插入黑色节点总会改变黑色高度(违背规则4),但是插入红色节点只有一半的机会会违背规则3。另外违背规则3比违背规则4要更容易修正。
当插入一个新的节点时,可能会破坏这种平衡性,红-黑树主要通过三种方式对平衡进行修正,改变节点颜色、左旋和右旋。(具体规则省略)
因为每一个红黑树也是一个特化的二叉查找树,因此红黑树上的只读操作与普通二叉查找树上的只读操作相同。然而,在红黑树上进行插入操作和删除操作会导致不再符合红黑树的性质。恢复红黑树的性质需要少量(O(logn))的颜色变更(实际是非常快速的)和不超过三次树旋转(对于插入操作是两次)。虽然插入和删除很复杂,但操作时间仍可以保持为O(logn) 次。
一棵有 n 个内部节点的红黑树的高度至多为 2lg(n+1)。
证明略,参考最后一个链接。
参考:
https://facert.gitbooks.io/python-data-structure-cn/6.树和树的算法/6.13.查找树实现/
python实现二叉查找树(代码简单)
纸上谈兵: AVL树
[Data Structure] 数据结构中各种树
红黑树相关定理及其证明