本文始发于我的公众号:TechFlow,原创不易,求个关注node
今天是机器学习的第16篇文章,咱们来继续上周KD-Tree的话题。web
若是有没有看过上篇文章或者是最新关注的小伙伴,能够点击一下下方的传送门:算法
【硬核】机器学习与数据结构的完美结合——KD-Treeubuntu
上周咱们实现了KD-Tree建树和查询的核心功能,而后咱们留了一个问题,若是咱们KD-Tree的数据集发生变化,应该怎么办呢?网络
最朴素的办法就是从新建树,可是显然咱们每次数据发生变更都把整棵树重建显然是不科学的,由于绝大多数数据是没有变化的,而且咱们从新建树的成本很高,若是变更稍微频繁一些会致使大量的开销,这明显是不合理的。数据结构
另外一个思路是借鉴平衡树,好比AVL或者是红黑树等树结构。在这些树结构当中,当咱们新增或者是删除节点致使树发生不平衡的状况时,平衡树会进行旋转操做在不改变二叉搜索树性质的前提下维护树的平衡。看起来这是一个比较好的方法,可是遗憾的是,这并不太可行。由于KD-Tree和二叉搜索树不一样,KD-Tree中的节点存储的元素都是高维的。每一棵子树的衡量的维度都不一样,这会使得旋转操做变得很是麻烦,甚至是不可行的。app
咱们来看下面这张图:机器学习
这是平衡树当中经典的左旋操做,它旋转先后都知足平衡树的性质,即左子树上全部元素小于根节点,小于右子树上全部元素。经过旋转操做,咱们能够变动树结构,可是不影响二叉搜索树的性质。编辑器
问题是KD-Tree当中咱们在不一样深度判断元素大小的维度不一样,咱们旋转以后节点的树深会发生变化,会致使判断标准发生变化。这样会致使旋转以后再也不知足KD-Tree的性质。函数
咱们用刚才的图举个例子:
咱们给每一个节点标上了数据,在树深为0的节点当中,划分维度是0,树深为1的节点划分维度是1。当咱们旋转以后,很明显能够发现KD-Tree的性质被打破了。
好比D节点的第0维是2,B节点是1,可是D却放在了B的左子树。再好比A节点的第1维是3,E节点的第1维是7,可是E一样放在了A的左子树。
这还只是二维的KD-Tree,若是维度更高,会致使状况更加复杂。
经过这个例子,咱们证实了平衡树旋转的方式不适合KD-Tree。
那么,除了平衡树旋转的方法以外,还有其余方法能够保持树平衡吗?
别说,还真有,这也是本篇文章的正主——替罪羊树。
替罪羊树其实也是平衡二叉树,可是它和普通的平衡二叉树不一样,它维护平衡的方式不是旋转,而是重建。
为何叫替罪羊树呢,替罪羊是圣经里的一个宗教术语,本来指的是将山羊献祭做为赎罪的仪式,后来才衍生出了代人受过,背锅侠的意思。替罪羊树的意思是一个节点的变化可能会致使某一个子树或者是整棵树被摧毁并重建,至关于整棵子树充当了某一个节点的”替罪羊“。
替罪羊树的里很是简单粗暴,不强制保证全部子树彻底平衡,容许必定程度的不平衡存在。当咱们插入或者删除使得某一棵子树的节点超过平衡底线的时候,咱们将整棵树拍平后重建。
好比下图红框当中表示一棵不平衡的子树:
很明显,它不平衡地十分严重,超过了咱们的底线。因而咱们将整棵子树拍平,拍平的意思是将子树当中全部的元素所有取出,而后重建该树。
拍平以后的结果是:
拍平以后重建该子树,获得:
咱们把重建的这棵子树插回到原树上,代替以前不平衡的部分,这样就保证了树的平衡。
整个原理应该很是简单,底层的细节也只有一个,就是咱们怎么衡量何时应该执行拍平重建的操做呢?
这一点在替罪羊树当中也很是简单粗暴,咱们维护每一棵子树中的节点数,而后经过一个参数alpha来控制。当它的某一棵子树的节点数的占比超过alpha的时候,咱们就认为不平衡性超过了限度,须要进行拍平和重建操做了。
通常alpha的取值在0.6-0.8之间。
在替罪羊树当中删除节点有不少种方法,可是大都大同小异,核心的思想是咱们删除节点并非真的删除,而是给节点打上标记,标记这个节点在查询的时候不会被考虑进去。
可是节点被打上标记而不是真的删除虽然实现起来简单,可是也有隐患,毕竟一个节点被删除了,咱们把它留在树上一段时间还能够接受,一直留着显然就有问题了。不只会占用空间,也会给计算增长负担。
针对这种状况,也有几种不一样的解决策略。一种策略是不用理会,等待某一次插入的时候发现树不平衡,进行拍平重构的时候将已经删除的节点移除。另外一种策略是咱们也删除设置一个参数,当某棵子树上被删除的元素的比例超过这个阈值的时候,咱们也一样进行子树的拍平重建。可是不论选择哪种,本质上来讲都是惰性操做。
所谓的惰性操做通常是经过标记代替本来复杂的运算,等待之后须要的时候执行。这个所谓须要的时候能够是之后查询到的时候,也能够是积累到必定阈值的时候。总之经过这样的设计,咱们能够简化删除操做,由于加上标记不会影响树结构,因此也不用担忧不平衡的问题。
对于KD-Tree的常规实现来讲,修改和新增是一回事,由于咱们会经过删除新增来代替修改。这么作的缘由也很简单,由于修改某一个节点的数据可能会影响整个树结构,尤为是KD-Tree中的数据是多维的,因此咱们是不能随意修改一个节点的。
实际上不仅是KD-Tree如此,不少平衡树都不支持修改,好比咱们以前介绍过的LSMT就不支持。固然不支持的缘由多种多样,本质上来讲都是由于性价比过低。
咱们再来看新增操做,二叉搜索树的纯新增操做实际上是很简单的,咱们只须要遍历树找到能够插入的位置便可。KD-Tree当中的新增也是如此,虽然KD-Tree当中是多个维度,可是查找节点的逻辑和以前相差并不大。咱们就顺着树结构遍历,找到须要插入的叶子节点便可。因为咱们使用替罪羊树的原理来维护树的平衡,因此咱们在插入的是时候也须要维护子树当中节点的数量,以及会不会出发拍平操做。
若是存在子树违反了平衡条件,咱们须要找到最上层的知足拍平条件的子树来进行拍平,不然的话底层的子树平衡了,可是上层的子树可能仍然须要拍平。注意这两个细节便可,其余的原理和普通的二叉树插入节点一致。
咱们来看下代码,寻找更多细节:
def _insert_data(self, node, data):
# 子树节点的数量+1
node.size += 1
axis = node.axis
new_axis = (axis + 1) % self.K
flat = False
# 当前节点的判断条件
# 小于等于则进入左子树,不然进入右子树
if data[axis] <= node.boundray:
# 若是子节点为空,说明已经到叶子节点,建立新节点
if node.lchild is None:
new_node = KDTree.Node(
data[new_axis], data, new_axis, node.depth + 1, 1, None, None)
new_node.father = node
node.lchild = new_node
else:
# 递归
self._insert_data(node.lchild, data)
# 回溯的时候判断是否引起树不平衡
if node.lchild.size >= self.alpha * node.size:
self.rebuildNode = node
else:
# 逻辑同上,找到叶子节点,回溯的时候判断是否不平衡
if node.rchild is None:
new_node = KDTree.Node(
data[new_axis], data, new_axis, node.depth + 1, 1, None, None)
new_node.father = node
node.rchild = new_node
else:
self._insert_data(node.rchild, data)
if node.rchild.size >= self.alpha * node.size:
self.rebuildNode = node
咱们再来看下拍平的逻辑,拍平其实就是拿到子树当中全部的节点。若是是二叉搜索树,咱们能够经过中序遍历保证元素的有序性,可是在KD-Tree当中,元素的维度太多,再加上存在被删除的节点,因此有序性没法保证,因此咱们能够忽略这点,拿到全部数据便可。
def flat_data(self, node, data):
if node is None:
return
# 跳过删除元素
if not node.deleted:
data.append(node.value)
self.flat_data(node.lchild, data)
self.flat_data(node.rchild, data)
拿到全部数据以后也简单,咱们只须要调用以前的建树函数,得到一棵新子树,而后将新子树插回到原树上对应的位置。
def rebuild(self):
data = []
# 拍平以rebuildNode节点为根的子树
node = self.rebuildNode
if node is None:
return
# 拿到全部数据
self.flat_data(node, data)
# 塞回到父节点当中去代替旧子树
father = node.father
if father is None:
# 若是父节点为空说明是整棵树重建了
self.root = self._build_model(data, node.depth)
self.set_father(self.root, None)
else:
# 判断是左孩子仍是右孩子
position = 'left' if node == father.lchild else 'right'
node = self._build_model(data, node.depth)
if position == 'left':
father.lchild = node
else:
father.rchild = node
self.set_father(node, father)
这样一来,咱们带增删改查功能的KD-Tree就实现好了。到这里,咱们还有一个问题没有解决,就是复杂度的问题。
这样作看起来可行,真的复杂度会下降吗?很遗憾,这个问题涉及到很是复杂的数学证实,我暂时尚未找到靠谱的证实过程,可是能够确定的是,虽然咱们每一次重建树都须要nlogn次计算,可是并非每一次插入和删除都会引起重建。若是假设发生大量操做的话,那么咱们拍平重建的计算会分摊到每一次查询上,分摊以后能够获得级别的插入和删除。实际上分摊的思路很是常见,像是红黑树也是利用了分摊操做。
到这里关于替罪羊树在KD-Tree的应用就结束了,虽然这是一个全新的数据结构,而且和比较困难的平衡树有关,但其实核心的思路并不困难,非但不困难,并且有些过于简单了,可是效果却又如此神奇,能解决一个如此棘手的问题,不得不说算法的魅力实在是无穷。
另外,网络上绝大多数关于KD-Tree的博客都只有建树和查询的部分,虽然实际场景当中,这也基本上足够了。可是我我的以为,学习的过程应该是饱和式的,不能仅仅停留在够用上。毕竟咱们努力保持学习的目的,并不仅是为了让这些知识派上用场,更是为了能够拥有更强的能力,成为一个更优秀的人。
最后,我把完整的代码放在ubuntu.paste当中,在公众号里回复'kd-tree2',我把完整代码发给你,和你一块儿学习。
若是你也这么以为,请顺手点个关注或者转发吧,大家的举手之劳对我来讲很重要。