定义
一棵树是一些节点的集合。这个集合可以是空集,若非空,则一棵树由称作 根(root) 的节点 r 和 0 个或以上的非空子树组成。这些子树中每一棵的根都被来自根 r 的**一条有向边(edge)**连接。
父子节点,叶子节点定义略。。。
从节点 n<sub>1</sub> 到 n<sub>k</sub> 的 路径(path) 定义为节点 n<sub>1</sub>, n<sub>2</sub>, ..., n<sub>k</sub> 的一个序列,使得对于 1 ≤ i ≤ k,节点 n<sub>i+1</sub> 是 节点 n<sub>i</sub> 的父亲。这条路径的 长度(length) 定义为该路径上边的条数,即k - 1。
可见任意两节点 n<sub>i</sub> 和 n<sub>k</sub> 之间是否存在一条路径取决于 n<sub>i</sub> 是否是 n<sub>k</sub> 的祖先节点。且如果有,也仅有一条。(因为上面树的定义里,一个非根节点有且只有一条边)
而从根节点到任意节点均存在一条唯一的路径,这条路径的长度被称为该节点的 深度(depth)。一棵树的深度被定义为其最深的一个节点的深度。
按照之前算法分析的某条定理:如果一个算法可以使用常数时间将问题缩小为原问题的一部分,那么这个算法的复杂度为 O(logN)。树在正常情况下的查找上显然符合这个特征。
可以将树看成是序列的变形,它在序列之上允许了多子节点。
二叉树
二叉树即每个节点的子节点数均不超过 2 的情况。
实现
为了便于操作,实际的树通常会在定义之上再增加一些特性,如
- 在每个节点中记录其父节点,即反向边
- 删除时并不真正删除节点,而是将其计数 -1
下面是一个使用字典实现的二叉查找树,功能方面只实现了 __contains__
方法:
class Node(dict):
def __init__(self, value, parent=None, left=None, right=None):
self['value'] = value
self['count'] = 1
if parent:
self['parent'] = parent
if left:
self['left'] = left
if right:
self['right'] = right
class Tree(object):
def __init__(self, init_value):
self.root = Node(init_value)
def _find_near(self, value):
node = self.root
try:
while True:
if value == node['value']:
return node
elif value < node['value']:
node = node['left']
else:
node = node['right']
except KeyError:
return node
def add(self, value):
node = self._find_near(value)
if value == node['value']:
node['count'] += 1
elif value < node['value']:
node['left'] = Node(value)
else:
node['right'] = Node(value)
def rem(self, value):
node = self._find_near(value)
if node['value'] == value and node['count'] > 0:
node['count'] -= 1
def __contains__(self, value):
node = self._find_near(value)
return node['value'] == value and node['count'] > 0
平衡问题
容易看到,二叉树可能出现一个极端状态,即所有节点的子节点数均不超过 1 ,此时的树实际上就是链表。而其深度为 N-1.
因此使一棵树拥有尽可能低的深度,对于查找性能来说显得尤为重要,这个优化的过程便称为平衡,即取,使每个节点的左右子树的深度尽可能平衡之意。
平衡是个很麻烦的事情,它意味着每次更新操作后都有可能要变更树的结构。此过程称为旋转(ratation)。如 AVL 树,要求每个节点的左子树和右子树的高度最多差 1.
B 树
B-树的 B 并没有什么确定的意思,尤其不要理解为 Binary,因为一般它并不实现为二叉树,而是多路树。
阶为 M 的 B 树是这样一种树:
- 其根节点要么没有子节点,要么子节点数在 2 ~ M 之间
- 除根外,所有内部节点(非叶子节点)的子节点数在 M/2 ~ M 之间
- 所有的叶子节点都拥有相同深度
- 所有的数据都存储在叶子上
这个定义看起来不是很直观,可以这样理解:
M 阶 B树的数据部分是一个序列。除了这个序列外,还额外存在一个树型结构,用于索引这个序列。具体方法为:序列从前向后每 M 个元素便向上生出一个父节点,父节点的值等于这 M 个元素的最小值(或最大值,取决于序列的排序),然后这批父节点再每 M 个向上生出次级父节点,如此反复直到生出根来。
当然,实际 B树的生成过程是正好相反的。也相应的有更多需要考虑的问题。
B树每个节点的子节点数均小于等于 M。另外为了尽量降低树的深度,我们还规定内部节点的子节点数需大于等于 M/2 (ceil),当不满足此条件时,就要将子节点合并。
由此我们可以算出,M 阶 B树的深度最小可以为 Log<sub>M</sub>N,最大也不过是 Log<sub>M/2</sub>N。另外因为每个节点最多有 M 个子节点,所以一次节点内分支选择的复杂度为 LogM。故搜索的复杂度为 (M/2)Log<sub>M/2</sub>N,可化简为 O(LogN)
。增删操作可能需要 O(M) 的时间来调整节点数据,因此增删操作的复杂度为 O(MLog<sub>M</sub>N) ,可化简为 O((M/LogM)LogN)
。
M 的选择
由前面的增删操作复杂度公式前的常数部分 M/LogM
可得 M 的最佳值为 (2, 3, 4),当再高时,插入效率会变低。而搜索复杂度与 M 无关。
B树也是数据库常用的一种索引,因此 M 的选择更多会参考实际的应用场景。如存储在机械硬盘上的 SQL 数据库,硬盘寻址操作的开销比连续读要高得多,因此使内部节点所占空间尽量接近单扇区可用容量是最好的做法。如 512 字节的扇区容量,每个节点元素占 4 字节的话,M 就可以设置为 128。对于 SSD,或者更大扇区的磁盘,这个数字都可做相应调整。