与其他数据结构相比,二进制索引树的文献很少或相对没有文献。唯一的授课地点是topcoder教程。尽管本教程的所有解释均已完成,但我无法理解这种树背后的直觉吗?它是如何发明的?正确性的实际证明是什么?
与其他数据结构相比,二进制索引树的文献很少或相对没有文献。唯一的授课地点是topcoder教程。尽管本教程的所有解释均已完成,但我无法理解这种树背后的直觉吗?它是如何发明的?正确性的实际证明是什么?
Answers:
直观地,您可以将二进制索引树视为二进制树的压缩表示形式,它本身是对标准数组表示形式的优化。这个答案有一个可能的推论。
例如,假设您要存储总共7个不同元素的累积频率。您可以首先写出将要分配数字的七个存储桶:
[ ] [ ] [ ] [ ] [ ] [ ] [ ]
1 2 3 4 5 6 7
现在,让我们假设累积频率如下所示:
[ 5 ] [ 6 ] [14 ] [25 ] [77 ] [105] [105]
1 2 3 4 5 6 7
使用此版本的数组,您可以通过增加该点处存储的数字的值来增加任何元素的累积频率,然后增加之后所有内容的频率。例如,要将3的累积频率增加7,我们可以在位置3或之后的数组中的每个元素上加上7,如下所示:
[ 5 ] [ 6 ] [21 ] [32 ] [84 ] [112] [112]
1 2 3 4 5 6 7
这样做的问题是,这需要O(n)时间来完成,如果n大,这将非常慢。
我们可以考虑改进此操作的一种方法是更改存储在存储桶中的内容。您可以考虑只存储当前频率相对于先前存储桶增加的数量,而不是存储到给定点的累积频率。例如,在本例中,我们将按以下方式重写上述存储桶:
Before:
[ 5 ] [ 6 ] [21 ] [32 ] [84 ] [112] [112]
1 2 3 4 5 6 7
After:
[ +5] [ +1] [+15] [+11] [+52] [+28] [ +0]
1 2 3 4 5 6 7
现在,我们可以通过在时间段O(1)中向存储桶中添加适当的量来增加其频率。但是,执行查找的总成本现在变为O(n),因为我们必须通过对所有较小存储桶中的值求和来重新计算存储桶中的总数。
我们需要从这里获得的二进制索引树的第一个主要见解如下:与其持续不断地重新计算特定元素之前的数组元素之和,如果我们要预先计算所有特定元素之前的所有元素的总和,该怎么办?点顺序?如果我们能够做到这一点,那么我们可以通过将这些预先计算的总和正确组合起来得出一个点的累计总和。
一种实现方法是将表示形式从存储桶数组更改为节点的二叉树。每个节点都将用一个值表示,该值表示该给定节点左侧所有节点的累计和。例如,假设我们从这些节点构造以下二进制树:
4
/ \
2 6
/ \ / \
1 3 5 7
现在,我们可以通过存储所有值(包括该节点及其左子树)的累积总和来扩充每个节点。例如,给定我们的值,我们将存储以下内容:
Before:
[ +5] [ +1] [+15] [+11] [+52] [+28] [ +0]
1 2 3 4 5 6 7
After:
4
[+32]
/ \
2 6
[ +6] [+80]
/ \ / \
1 3 5 7
[ +5] [+15] [+52] [ +0]
有了这种树形结构,很容易确定一个点的累积总和。想法如下:我们维护一个计数器,最初为0,然后进行常规的二进制搜索,直到找到有问题的节点。这样做时,我们还要注意以下几点:在每次向右移动时,我们还将当前值添加到计数器中。
例如,假设我们要查找3的总和。为此,我们执行以下操作:
您可以想象也可以反向运行此过程:从给定节点开始,将计数器初始化为该节点的值,然后沿树走到根。每当您向上跟随一个正确的子链接时,请在您到达的节点上添加值。例如,要找到3的频率,我们可以执行以下操作:
为了增加节点的频率(以及隐式地增加其后所有节点的频率),我们需要更新树中包含该节点在其左子树中的节点集。为此,我们执行以下操作:增加该节点的频率,然后开始向上走到树的根。每当您点击将您带入左边的链接时,都可以通过添加当前值来增加遇到的节点的频率。
例如,要将节点1的频率增加5,我们将执行以下操作:
4
[+32]
/ \
2 6
[ +6] [+80]
/ \ / \
> 1 3 5 7
[ +5] [+15] [+52] [ +0]
从节点1开始,将其频率增加5以得到
4
[+32]
/ \
2 6
[ +6] [+80]
/ \ / \
> 1 3 5 7
[+10] [+15] [+52] [ +0]
现在,转到其父级:
4
[+32]
/ \
> 2 6
[ +6] [+80]
/ \ / \
1 3 5 7
[+10] [+15] [+52] [ +0]
我们沿着左子链接向上移动,因此我们也增加了该节点的频率:
4
[+32]
/ \
> 2 6
[+11] [+80]
/ \ / \
1 3 5 7
[+10] [+15] [+52] [ +0]
我们现在转到其父项:
> 4
[+32]
/ \
2 6
[+11] [+80]
/ \ / \
1 3 5 7
[+10] [+15] [+52] [ +0]
那是一个左子链接,所以我们也增加这个节点:
4
[+37]
/ \
2 6
[+11] [+80]
/ \ / \
1 3 5 7
[+10] [+15] [+52] [ +0]
现在我们完成了!
最后一步是将其从树转换为二进制索引树,在这里我们可以使用二进制数做一些有趣的事情。让我们用二进制重写此树中的每个存储区索引:
100
[+37]
/ \
010 110
[+11] [+80]
/ \ / \
001 011 101 111
[+10] [+15] [+52] [ +0]
在这里,我们可以做一个非常非常酷的观察。取这些二进制数字中的任何一个,找到数字中设置的最后一个1,然后将该位及其后的所有位都删除。现在,您剩下以下内容:
(empty)
[+37]
/ \
0 1
[+11] [+80]
/ \ / \
00 01 10 11
[+10] [+15] [+52] [ +0]
这是一个非常非常酷的观察结果:如果将0表示“左”,将1表示“右”,则每个数字上的其余位将准确说明如何从根开始,然后向下移动到该数字。例如,节点5具有二进制模式101。最后1是最后一位,因此我们将其丢弃以得到10。实际上,如果您从根开始,请向右(1),然后向左(0),然后结束在节点5上!
之所以如此重要,是因为我们的查找和更新操作取决于从节点备份到根节点的访问路径,以及我们是否遵循左或右子链接。例如,在查找过程中,我们只关心我们遵循的正确链接。在更新过程中,我们只关心关注的左侧链接。仅使用索引中的位,该二进制索引树就可以高效地完成所有这些工作。
关键技巧是此完美的二叉树的以下属性:
给定节点n,通过取n的二进制表示并删除最后一个1,可以得到访问路径上备份到根的下一个节点。
例如,看一下节点7的访问路径,即111。我们访问根的访问路径上涉及向上跟随右指针的节点是
所有这些都是正确的链接。如果我们采用节点3的访问路径(即011),然后看一下正确的节点,则得到
这意味着我们可以非常非常有效地计算到一个节点的累积总和,如下所示:
同样,让我们考虑如何执行更新步骤。为此,我们希望沿访问路径返回到根,更新所有我们沿左链接向上的节点。我们可以通过基本上执行上述算法来做到这一点,但是将所有1都切换为0,将0切换为1。
二进制索引树的最后一步是要注意,由于这种按位欺骗,我们甚至不需要再显式地存储树。我们可以将所有节点存储在长度为n的数组中,然后使用按位旋转技术来隐式导航树。实际上,这正是按位索引的树所做的工作-将节点存储在数组中,然后使用这些按位技巧来有效地模拟在此树中的向上行走。
希望这可以帮助!
我认为芬威克的原始论文要清晰得多。@templatetypedef的上述答案要求对完美的二叉树的索引进行一些“非常酷的观察”,这对我来说是令人困惑和神奇的。
Fenwick简单地说,询问树中每个节点的责任范围将根据其最后设置的位:
例如,由于6
== 的最后一个设置位00110
是“ 2位”,它将负责2个节点。对于12
== 01100
,它是一个“ 4位”,因此它将负责4个节点。
因此,在查询F(12)
==时F(01100)
,我们将位一一剥离,即为F(9:12) + F(1:8)
。这几乎不是严格的证明,但我认为将其简单地放在数字轴上而不是完美的二叉树上时,每个节点的职责是什么,为什么查询成本等于设置位。
如果仍然不清楚,则强烈建议使用该纸张。