BIT:二叉索引树背后的直觉是什么?它是如何想到的?


99

与其他数据结构相比,二进制索引树的文献很少或相对没有文献。唯一的授课地点是topcoder教程。尽管本教程的所有解释均已完成,但我无法理解这种树背后的直觉吗?它是如何发明的?正确性的实际证明是什么?


4
维基百科上的一篇文章声称这些称为芬威克树
David Harkness

2
@ DavidHarkness- Peter Fenwick发明了数据结构,因此有时将它们称为Fenwick树。在他的原始论文(位于citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.14.8917中)中,他将它们称为二进制索引树。这两个术语经常互换使用。
templatetypedef

1
以下答案传达了一个很好的二进制索引树cs.stackexchange.com/questions/42811/…的 “直观”直觉。
拉比·科迪

1
我知道您的感觉,当我第一次阅读topcoder文章时,感觉就像是魔术。
Rockstar5645

Answers:


168

直观地,您可以将二进制索引树视为二进制树的压缩表示形式,它本身是对标准数组表示形式的优化。这个答案有一个可能的推论。

例如,假设您要存储总共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的总和。为此,我们执行以下操作:

  • 从根(4)开始。计数器为0。
  • 向左转到节点(2)。计数器为0。
  • 向右转到节点(3)。计数器为0 + 6 = 6。
  • 查找节点(3)。计数器是6 + 15 = 21。

您可以想象也可以反向运行此过程:从给定节点开始,将计数器初始化为该节点的值,然后沿树走到根。每当您向上跟随一个正确的子链接时,请在您到达的节点上添加值。例如,要找到3的频率,我们可以执行以下操作:

  • 从节点(3)开始。计数器是15。
  • 向上转到节点(2)。计数器是15 + 6 = 21。
  • 向上转到节点(4)。计数器是21。

为了增加节点的频率(以及隐式地增加其后所有节点的频率),我们需要更新树中包含该节点在其左子树中的节点集。为此,我们执行以下操作:增加该节点的频率,然后开始向上走到树的根。每当您点击将您带入左边的链接时,都可以通过添加当前值来增加遇到的节点的频率。

例如,要将节点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。我们访问根的访问路径上涉及向上跟随右指针的节点是

  • 节点7:111
  • 节点6:110
  • 节点4:100

所有这些都是正确的链接。如果我们采用节点3的访问路径(即011),然后看一下正确的节点,则得到

  • 节点3:011
  • 节点2:010
  • (节点4:100,位于左侧链接之后)

这意味着我们可以非常非常有效地计算到一个节点的累积总和,如下所示:

  • 用二进制写出节点n。
  • 将计数器设置为0。
  • 当n≠0时重复以下步骤:
    • 在节点n处添加值。
    • 从n清除最右边的1位。

同样,让我们​​考虑如何执行更新步骤。为此,我们希望沿访问路径返回到根,更新所有我们沿左链接向上的节点。我们可以通过基本上执行上述算法来做到这一点,但是将所有1都切换为0,将0切换为1。

二进制索引树的最后一步是要注意,由于这种按位欺骗,我们甚至不需要再显式地存储树。我们可以将所有节点存储在长度为n的数组中,然后使用按位旋转技术来隐式导航树。实际上,这正是按位索引的树所做的工作-将节点存储在数组中,然后使用这些按位技巧来有效地模拟在此树中的向上行走。

希望这可以帮助!



您在第二段中失去了我。您是指7个不同元素的累积频率?
杰森·古玛

20
到目前为止,这是我在Internet上找到的所有资源中迄今为止阅读的关于该主题的最佳解释。做得好 !
Anmol Singh Jaggi 2015年

2
芬威克是如何变得如此聪明的?
Rockstar5645

1
这是一个很好的解释,但与其他任何解释以及芬威克自己的论文一样,也遇到同样的问题,没有提供证明!
DarthPaghius

3

我认为芬威克的原始论文要清晰得多。@templatetypedef的上述答案要求对完美的二叉树的索引进行一些“非常酷的观察”,这对我来说是令人困惑和神奇的。

Fenwick简单地说,询问树中每个节点的责任范围将根据其最后设置的位:

Fenwick树节点的职责

例如,由于6== 的最后一个设置位00110是“ 2位”,它将负责2个节点。对于12== 01100,它是一个“ 4位”,因此它将负责4个节点。

因此,在查询F(12)==时F(01100),我们将位一一剥离,即为F(9:12) + F(1:8)。这几乎不是严格的证明,但我认为将其简单地放在数字轴上而不是完美的二叉树上时,每个节点的职责是什么,为什么查询成本等于设置位。

如果仍然不清楚,则强烈建议使用该纸张。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.