1 | 对应本书第六章。 |
笔记
作者介绍了两个计算机中的树结构:文件系统(File System)和HTML页面。
用结点和引用实现的二叉树
1 | from __future__ import print_function |
1 | r = BinaryTree('a') |
1 | a |
解析树
Parse Tree的构建规则(这里是数学表达式的解析树):
- 如果当前token是
'('
,增加一个结点作为左子结点,下降到左子结点。 - 如果当前token属于
['+','-','/','*']
,将当前结点的值设为这个操作符,增加一个结点作为右子结点,下降到右子结点。 - 如果当前token是一个数字,则将当前结点的值设为这个数字,回到父结点。
- 如果当前token是
')'
,回到父结点。
构建的解析树如下:
构建解析树的代码:
1 | from pythonds.basic.stack import Stack |
1 | 3 |
计算表达式的值:
1 | import operator |
1 | 23 |
树的遍历
1.前序遍历(preorder),即根左右。
1 | def preorder(tree): |
2.中序遍历(inorder),即左根右。
1 | def |
3.后序遍历(postorder),即左右根。
1 | def postorder(tree): |
4.层次遍历,即:根/子子/孙孙孙孙
原书没有提及层次遍历,这里做一点补充。
1 | def level_order(tree): |
1 | # 对比前序遍历和层次遍历的区别 |
1 | a |
1 | level_order(r) |
1 | a |
二叉堆
首先引入priority queue
的概念,你可以把它想象成操作系统中优先级队列,它的特征是:每一次出队的是优先级最高的元素。
如果你使用list实现它,每一次出队前需要重新排序,那么时间复杂度可能是$O(nlogn)$。或者你可以说,只需要第一次排个序就可以,后面每入队一个元素的时候,都加到合适的位置里去,这样插入一个元素的时间复杂度是$O(n)$。
计算机科学家觉得还是不够,于是发明了二叉堆
,使得优先级队列
的时间复杂度是$O(logn)$。
二叉堆从外观上来看是一棵完全二叉树,但它有一个性质,即父结点比子结点小(小顶堆)。
接下来看看如何实现一个二叉堆:
需要实现的方法:
实现的时候,首先牢记二叉堆的两个属性:
- The StructureProperty,即二叉堆在结构上是一棵完全二叉树。原因是完全二叉树可以直接存在list中。第0个元素置空,第$i$个元素的左子结点和右子结点分别是$i×2$和$i×2+1$,第i个元素的父结点是$i/2$。
- Heap Order Property,即父结点比子结点小。
主要关注两个过程,注意过程中要维护上面的两个属性:
- 插入元素时的percolate up,即结点比父结点大的,和父结点互换。
- 删除元素时的percolatedown,即删除顶结点以后,先将最后一个结点补入第一位,然后再不断地将这个结点跟其最小的子结点比,大于这个子结点则互换。
- 这里如果忘了,最好回去看原文,有很生动的图,一看就懂。
1 | class BinHeap: |
二叉查找树
二叉查找树(bst)
即这样的二叉树:
所有的父结点的值都比左子结点的大,但比右子结点的小。
值得注意的是bst的删除操作,分三种情况:
1.该目标结点没有子节点;
这种情况直接删除目标结点即可。
2.该目标结点只有一个子节点;
这种情况需要先将目标结点删除,再把目标结点的子结点连到目标结点的父结点上
3.该目标结点有两个子节点。
这种情况最复杂,删除目标结点后,需要寻找successor,来替代当前结点的位置。
successor必须满足比目标结点所有左子树要大,比所有右子树要小,又分三种情况:
- 目标结点有右子结点,则successor是
右子树的最小结点
(这个结点是比目标结点大的最小结点)。上面图示就是这种情况。 - 目标结点没有右子结点,且是父结点的左子结点,这说明父结点小于目标结点的所有左子树,则successor直接选用目标结点的父结点。
- 目标结点没有右子结点,且是父结点的右子结点,这说明父结点大于目标结点的所有左子树,则递归地再去寻找父结点的successor。
1 | class TreeNode: |
1 | yellow |
平衡二叉查找树
当一般的二叉查找树出现倾斜的情况(skewed tree):
时间复杂度右重新变成了$O(n)$,于是引入平衡二叉查找树(Balanced Binary Search Trees, AVL tree),解决倾斜问题(AVL的命名源自其作者的名字)。
平衡因子(balance factor):$balanceFactor=height(leftSubTree)−height(rightSubTree)$,即左子树的高度-右子树的高度。
如果一棵二叉查找树可以被称为平衡,则其所有子树的平衡因子只能是:-1,0,1三者之一。
先来看看AVL的性能:
来找到最左倾的AVL的结点个数和高度:
很容易可以发现一个规律,即$N_h=1+N_{h−1}+N_{h−2}$。其中,$h$是树的高度,$N_h$是高度$h$的情况下,最左倾的AVL的结点个数。这个形态和斐波那契数列是一致的。斐波那契数列有一个性质就是,对于足够大的数,后一个比前一个的比值接近于黄金分割:$Φ=\frac{1+\sqrt5}{2}$。
可以将递推公式转换成:$h=1.44logN_h$,也就是说,AVL的查找操作的复杂度,依然是:$O(logN)$。
现在考虑如何维护平衡,对插入元素的父结点递归地更新平衡因子,直到:1.已经更新到root;2.当前父结点的平衡因子是0。
1 | def |
获得新的平衡因子以后,需要对不平衡的结点做旋转(操作)。
左旋:
1 | def rotateLeft(self,rotRoot): |
最后两行的更新平衡参数需要补充解释:
$newBal(B)=h_A−h_C$,
$oldBal(B)=h_A−h_D$,
对于旋转前:$h_D=1+max(h_C,h_E)$,而经过旋转后$h_C$和$h_E$是不变的。
于是有:$oldBal(B)=h_A−(1+max(h_C,h_E))$
再将两条表达式相减,有:
$$
newBal(B) - oldBal(B) = h_A - h_C - (h_A - (1 + max(h_C,h_E))) \
newBal(B) -
oldBal(B) = h_A - h_C - h_A + (1 + max(h_C,h_E)) \
newBal(B) - oldBal(B) = h_A
- h_A + 1 + max(h_C,h_E) - h_C \
newBal(B) - oldBal(B) = 1 + max(h_C,h_E) -
h_C \
newBal(B) = oldBal(B) + 1 + max(h_C - h_C ,h_E - h_C) \
newBal(B) =
oldBal(B) + 1 + max(0 , -oldBal(D)) \
newBal(B) = oldBal(B) + 1 - min(0 ,
oldBal(D)) \
$$
即
1 | rotRoot.balanceFactor = rotRoot.balanceFactor + 1 - |
类似的方法可以推出:
1 | newRoot.balanceFactor = |
还有一个问题,如果是这样的右倾:
直接进行左旋,结果是:
结果变成了左倾。
因此在做旋转之前,比如左旋,先检查左子树是否左倾,如果有要先右旋左子树:
1 | def rebalance(self,node): |