最近开始学习王争老师的《数据结构与算法之美》,通过总结再加上自己的思考的形式记录这门课程,文章主要作为学习历程的记录。
树是一种非线性表结构。树这种数据结构比线性表的数据结构要复杂得多。
树中的每个元素叫做“节点”,用来连线相邻节点之间的关系,叫做“父子关系”。如上图,A节点是B节点的父节点,B节点是A节点的子节点。B、C、D这三个节点的父节点是同一个节点。因此,它们互称兄弟节点。把没有父节点的节点叫做根节点,也就是图中的节点E。将没有子节点的节点叫做叶子节点或叶节点,其中G、H、I、J、K、L均为叶子节点。
关于树,还有三个比较相似的概念:高度、深度和层,定义为:
节点的高度 = 节点到叶子节点的最长路径(边数)
节点的深度 = 根节点到这个节点所经历的边的个数
节点的层数 = 节点的深度+1
树的高度 = 根节点的高度
以下图为例
树的结构多种多样,最常用的还是二叉树。二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,左子节点和右子节点。
其中,叶子节点均在最底层,除了叶子节点之外,每个节点都有左右两个子节点。这种二叉树叫做满二叉树。如下图中②:
叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫做完全二叉树,如上图③。
要理解完全二叉树定义的由来,需要了解如何表示(或存储)一棵二叉树?
想要存储一棵二叉树,有两种方法:一种是基于指针或引用的二叉树链式存储法,一种是基于数组的顺序存储法。首先看一下比较简单,直观的链式存储法,每个结点有三个字段,其中一个存储数据,只要拎着根节点,就可以通过左右子节点的指针,把整棵树串起来。
接下来,再看基于数组的顺序存储法。我们把根结点存储在下标为i的位置,那左子节点存储在下标 2 ∗ i = 2 2*i=2 2∗i=2的位置,右子节点存储在 2 ∗ i + 1 = 3 2*i+1=3 2∗i+1=3的位置。以此类推,B节点的左子节点存储在 2 ∗ i = 2 ∗ 2 = 4 2*i=2*2=4 2∗i=2∗2=4的位置,右子节点存储在 2 ∗ i + 1 = 2 ∗ 2 + 1 = 5 2*i+1=2*2+1=5 2∗i+1=2∗2+1=5的位置。
这个例子是一棵完全二叉树,仅仅浪费了一个下标为0的存储位置。如果是非完全二叉树,会浪费比较多的数组存储空间。如下图:
因此,如果一棵树是完全二叉树,那用数组存储无疑是最省内存的。
二叉树的遍历
二叉树的遍历方法分为三种:前序遍历,中序遍历和后序遍历。
前序遍历是指:对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
中序遍历是指:对于树中的任意节点来说,先打印它的左子树,然后再打印本身,最后打印它的右子树。
后序遍历是指:对于树中的任意节点来说,先打印它的左子树,然后再打印它的本身,最后打印节点本身。
因为每个节点最多会被访问两次,因此遍历操作的时间复杂度跟节点的个数n成正比,也就是说,二叉树遍历的时间复杂度为O(n)。
采用递归方式实现遍历
#先序遍历
def preOrder(root):
if root is not None:
print(root.data,end='')
BiTree.preOrder(root.lchild)
BiTree.preOrder(root.rchild)
#中序遍历
def inOrder(root):
if root is not None:
BiTree.inOrder(root.lchild)
print(root.data,end='')
BiTree.inOrder(root.rchild)
#后序遍历
def postOrder(root):
if root is not None:
BiTree.postOrder(root.lchild)
BiTree.postOrder(root.rchild)
print(root.data,end='')
采用非递归算法实现递归
#先序遍历
def preOrder2(root):
p = root
s = LinkStack()
s.push(p)
while not s.isEmpty():
p = s.pop()
print(p.data,end='')
while p is not None:
if p.lchild is not None:
print(p.lchild.data,end='')
if p.rchild is not None:
s.push(p.rchild)
p = p.lchild
#中序遍历
def inOrder2(root):
p = root
s = LinkStack()
s.push(p)
while not s.isEmpty():
while p.lchild is not None:
p = p.lchild
s.push(p)
p = s.pop()
print(p.data,end='')
if p.rchild is not None:
s.push(p.rchild)
#后序遍历
def postOrder2(root):
p = root
t = None
flag = True
s = LinkStack()
if p is not None:
s.push(p)
while p.child is None:
p = p.lchild
s.push(p)
while not s.isEmpty() and flag:
if p.rchild == t or p.rchild is None:
print(p.data,end='')
flag = True
t = p
s.pop()
else:
s.push(p.rchild)
flag = False
二叉查找值
二叉查找树最大特点为支持动态集合的快速插入、删除和查找操作。二叉查找树也叫二叉搜索树,查找、插入、删除都依赖二叉查找树的特殊结构。二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。
1、二叉查找树的查找操作
先取根节点,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。
2、二叉查找树的插入操作
新插入的数据一般都在叶子节点上,所以我们只需要从根节点开始,依次比较要插入的数据和节点的大小。如果插入的数据比节点的数据大,并且节点的右子树为空,则将新数据插到右子节点的位置;如果不为空,就递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且左子树为空,就将新数据插入到左子节点的位置。如果不为空,就再递归遍历左子树,查找插入位置。
3、二叉查找树的删除操作
二叉查找树的删除操作比较复杂,针对要删除节点的子节点个数不同,需要分三种情况来处理:
第一种情况是,如果要删除的节点没有子节点,只需要将父节点中,指向要删除节点的指针置为NULL,比如图中的删除节点55。
第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或右子节点),我们只需要更新父节点,指向要删除节点的指针,让它指向要删除节点的子节点就可以了,比如图中的删除节点13。
第三种情况是,如果要删除的节点有两个子节点,就比较复杂了。我们需要找到这个节点的右子树的最小节点,把它替换到要删除的节点上,然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子节点,那就不是最小节点了)。所以,可以应用上面的两条规则来删除这个最小节点,比如删除节点18。
实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法就是将要删掉的节点标记为“已删除”,但是并不真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但删除操作就变得简单了,而且这种处理方法也没有增加。
4、二叉查找树的其他操作
二叉查找树还支持快速查找最大节点和最小节点、前驱节点和后驱节点。除了支持上述操作,还有一个重要特性就是中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度为O(n)。
支持重复数据的二叉查找树
之前说的二叉查找树的操作,针对的都是不存在键值相同,当存储两个对象键值相同,则有两种处理方法。
第一种方法:二叉查找树中每一个节点不仅会存储一个数据,因此我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。
第二种方法:每个节点仍然只存储一个数据。在查找、插入数据的过程中,如果碰到一个节点的值,与要插入数据的值相同,就将这个要插入的数据放在这个节点的右子树,也就是说,把这个新插入的数据当做大于这个节点的值来处理。
当要查找数据时,遇到值相同的节点,并不停止查找操作,而是继续再查找,直到遇到叶子节点才停止。
对于删除操作,也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。
二叉查找树的时间复杂度分析
先分析一个最理想情况,二叉查找树是一棵完全二叉树,这样时间复杂度其实都跟树的高度成正比,也就是O(height)。这时,问题就转化为如何求一棵包含n个节点的完全二叉树的高度?
对于完全二叉树,n满足这样一种关系(最大层数为L)
n > = 1 + 2 + 4 + 8 + . . . + 2 L − 2 + 1 n >= 1+2+4+8+...+2^{L-2}+1 n>=1+2+4+8+...+2L−2+1
n < = 1 + 2 + 4 + 8 + . . . + 2 L − 2 + 2 L − 1 n <= 1+2+4+8+...+2^{L-2}+2^{L-1} n<=1+2+4+8+...+2L−2+2L−1
因此L的范围是[ l o g 2 ( n + 1 ) log_2{(n+1)} log2(n+1), l o g 2 n + 1 log_2{n}+1 log2n+1],完全二叉树的层数小于等于 l o g 2 n + 1 log_2{n}+1 log2n+1,也就是说,完全二叉树的高度小于等于 l o g 2 n log_2{n} log2n,因此时间复杂度为O( l o g n logn logn)。
不过,二叉查找树在频繁地动态更新过程,可能会出现树的高度远大于 l o g 2 n log_2{n} log2n的情况。极端情况下,二叉树会退化成链表,时间复杂度为O(n)。因此,需要设计一种平衡二叉查找树。
于 l o g 2 n log_2{n} log2n,因此时间复杂度为O( l o g n logn logn)。
不过,二叉查找树在频繁地动态更新过程,可能会出现树的高度远大于 l o g 2 n log_2{n} log2n的情况。极端情况下,二叉树会退化成链表,时间复杂度为O(n)。因此,需要设计一种平衡二叉查找树。
参考资料:王争《数据结构与算法之美》