替罪羊树(Scapegoat Tree)
热身:线性时间建树
问题 给定有序排好的元素序列,现在要你在
最朴素的做法就是将元素逐一插入,由于许多平衡树的插入是
考虑到元素已经是排好序的,我们可以在这里做点优化。
众所周知,二叉搜索树是有序的,即它的中序遍历是有序的。那么在它的中序遍历中,假设一个节点所处的位置为
现在我们希望建一棵平衡二叉树,所以左右子树的大小应当相近。最优的情况为左右子树大小一致,此时该节点在中序遍历的最中间。
于是我们得到了一个较好的算法,就是选取中间的元素作为根节点,将序列切为两部分,递归的建立左右子树即可。对于每个元素需要花费
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | def build(s): return build(s, 1, len(s)) def build(s, left, right): if right < left: return None mid = (left + right) / 2 node = new Node() # 新建节点 node.left = build(s, left, mid - 1) # 递归建立左子树 node.right = build(s, mid + 1, right) # 递归建立右子树 node.update() # 对该节点做必要的更新 return node |
α权重平衡
上面的热身非常简单。下面要讲的替罪羊树其实也很简单。在介绍替罪羊树之前,我们先了解下它的一个基本理论:α权重平衡(α-weight-balanced)。下文所述的平衡均为α权重平衡。
我们认为一棵树
其中,
这个
当
而当
我们都知道,二叉搜索树极易退化,而AVL树又为了平衡导致常数相当大,并且代码又臭又长,几乎没有人写。
作为AVL树的改进,红黑树并不追求完美的平衡。通过放宽一些严格的平衡限制,保证了
较为常用的Treap和Splay树则对
BST \ 测试 | 1 | 2 | 3 | 4 | 5 | 平均 |
---|---|---|---|---|---|---|
Splay(5个不同数据) | 0.758 | 0.588 | 0.582 | 0.612 | 0.759 | 0.659 |
Treap(同一个数据) | 0.766 | 0.578 | 0.601 | 0.587 | 0.781 | 0.662 |
非旋转式Treap(同一个数据) | 0.914 | 0.860 | 0.613 | 0.678 | 0.803 | 0.773 |
以上的结果仅供参考。可见一般的BST都能够使
替罪羊树
替罪羊树是一种平衡二叉树,它有一个很大的特点,就是它可以人为设定一个
暴力重构
替罪羊树之所以能维持平衡,就是因为它把不平衡的树都暴力重构成一棵完全二叉树!
但是这样一来时间复杂度就会暴涨。因此替罪羊树应当设置一个合适的
首先考虑如何重构。在之前线性时间建树的基础上,我们先将树的中序遍历给复制出来,然后就可以直接在这个基础上重建。
查询
替罪羊树的查询和二叉搜索树一模一样,毕竟查找不会导致树的不平衡,那么它也没有必要进行重构。
插入
替罪羊树的插入操作和二叉搜索树是差不多的,只是最后要检查回溯时的树链上有哪棵子树违反了平衡。然后直接将其重构即可。
在重构时你可能会考虑到一条树链上可能有多个子树违反了平衡。对此,你只需要将最大的那棵子树重构即可。但是这样在实际代码中会略显复杂。如果你想偷懒,就只在回溯时找到了第一棵不平衡的树重构也是可以的。因为在替罪羊树中,同时出现几个不平衡的子树的情况也不是很多。在实际测试中,偷懒的写法在上千万的数据量下性能差距小于1s。
删除
替罪羊树的删除操作很好的体现了Lazy思想。它在删除节点时,并不是真正的删除,而是将其标记。此后所有的操作都将其无视,重构就直接丢弃,除非有插入操作将其恢复。当然我们不能无止尽地进行标记。如果被标记的节点数超过了整棵树大小的一半,我们就直接将整棵树重构,同时清除删除的节点。
实现细节及其时间复杂度
节点定义
除了存储键值外,我们还需要存储树的大小和删除标记。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | struct Node<KeyType, ValueType>: key: KeyType # 键 value: ValueType # 值 size: int # 树的大小 deleted: bool # 删除标记 left: Node # 左子树 right: Node # 右子树 def update(self): self.size = self.left.size + self.right.size if not self.deleted: # 如果自己不是被删除的节点 self.size += 1 # 空结点 struct NoneNode<*, *>: key = None value = None size = 0 deleted = True left = NodeNode() right = NodeNode() def update(self): pass |
选定α
替罪羊树的好处在于我们有了更好的调控能力。
1 | ALPHA = 0.75 |
重构
重构操作十分简单,只需要中序遍历一遍就可以重建树了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | # 中序遍历 def travel(h, s): if h is NoneNode: return travel(h.left, s) if h is not deleted: # 忽略删除的节点 s.append(h) travel(h.right, s) if h is deleted: del h def refact(h): assert h is not NoneNode s = [] travel(h, s) # 重建 return build(s) |
插入
插入操作与二叉搜索树一致,只是在最后要重新从顶向下检查一遍是否有不平衡的子树。如果有就重构。
1 2 3 4 5 6 7 8 9 10 11 12 | def check(h, key): if h is NoneNode: return NoneNode() elif max(h.left.size, h.right.size) > ALPHA * h.size: return refact(h) elif key < h.key: h.left = check(h.left, key) elif key > h.key: h.right = check(h.right, key) h.update() return h |
删除
当我们命中删除的节点时,直接将其作删除标记,然后退回。删除结束后,检查整棵树中被删除的节点数是否超过了总结点数的一半,如果超过就整体重构。
1 2 3 4 5 6 7 8 9 10 11 12 | # 命中时 h.deleted = True h.update() deleted_count += 1 # ... # 删除完后 if deleted_count > size / 2: tree = refact(tree) size -= deleted_count deleted_count = 0 |
时间复杂度
替罪羊树的所有操作均摊复杂度为
操作 | 时间复杂度(均摊) |
---|---|
查询 | |
插入 | |
删除 |
对于查询操作,因为替罪羊树是保持了
对于插入操作,我们假设一个刚好重构过的子树的大小为
即
因此每
因此最好情况下我们可以均摊到
同时我们可见,
对于删除操作,我们假设我们进行了
我们将其均摊到
最终的时间复杂度为
更多操作
替罪羊树作为一种“又懒又暴力”的平衡二叉树,代码简单易于实现,并且性能十分不错。当然我们会想拓展更多的操作。当然,由于替罪羊树只会定时重构,不依赖其它的操作,因此可以扩展许多操作。下面列举一些:
rank
取排名:记录了子树的大小就可$O(\log n)$ 求出了。- 第k大:记录了
size
可以$O(\log n)$ 求出。 split
拆分:非旋转式Treap的搞法即可,$O(\log n)$ 。merge
合并按照可合并堆的搞法即可,$O(\log n)$ 。slice
提取区间Splay乱搞,$O(\log n)$ 。- 各种区间操作,乱套各种树…
$O(\log n)$ 、$O(\log n)$ 、$O(\log n)$ … - …
- (当然有些时候何苦来乱搞)