BIT: ¿Cuál es la intuición detrás de un árbol indexado binario y cómo se pensó?


99

Un árbol indexado binario tiene muy poca o relativamente poca literatura en comparación con otras estructuras de datos. El único lugar donde se enseña es el tutorial de topcoder . Aunque el tutorial está completo en todas las explicaciones, ¿no puedo entender la intuición detrás de tal árbol? ¿Cómo se inventó? ¿Cuál es la prueba real de su corrección?


44
Un artículo en Wikipedia afirma que estos se llaman árboles Fenwick .
David Harkness

2
@ DavidHarkness- Peter Fenwick inventó la estructura de datos, por lo que a veces se les llama árboles Fenwick. En su artículo original (que se encuentra en citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.14.8917 ), se refirió a ellos como árboles indexados binarios. Los dos términos a menudo se usan indistintamente.
templatetypedef

1
La siguiente respuesta transmite una muy buena intuición "visual" de árboles indexados binarios cs.stackexchange.com/questions/42811/… .
Rabih Kodeih

1
Sé cómo te sientes, la primera vez que leí el artículo del topcoder, me pareció mágico.
Rockstar5645

Respuestas:


168

Intuitivamente, puede pensar en un árbol indexado binario como una representación comprimida de un árbol binario que es en sí misma una optimización de una representación de matriz estándar. Esta respuesta entra en una posible derivación.

Supongamos, por ejemplo, que desea almacenar frecuencias acumulativas para un total de 7 elementos diferentes. Puede comenzar escribiendo siete cubos en los que se distribuirán los números:

[   ] [   ] [   ] [   ] [   ] [   ] [   ]
  1     2     3     4     5     6     7

Ahora, supongamos que las frecuencias acumulativas se ven así:

[ 5 ] [ 6 ] [14 ] [25 ] [77 ] [105] [105]
  1     2     3     4     5     6     7

Con esta versión de la matriz, puede incrementar la frecuencia acumulativa de cualquier elemento aumentando el valor del número almacenado en ese punto, y luego incrementando las frecuencias de todo lo que viene después. Por ejemplo, para aumentar la frecuencia acumulada de 3 en 7, podríamos agregar 7 a cada elemento en la matriz en o después de la posición 3, como se muestra aquí:

[ 5 ] [ 6 ] [21 ] [32 ] [84 ] [112] [112]
  1     2     3     4     5     6     7

El problema con esto es que toma O (n) tiempo para hacer esto, lo cual es bastante lento si n es grande.

Una forma en que podemos pensar en mejorar esta operación sería cambiar lo que almacenamos en los cubos. En lugar de almacenar la frecuencia acumulativa hasta el punto dado, puede pensar simplemente en almacenar la cantidad que la frecuencia actual ha aumentado en relación con el segmento anterior. Por ejemplo, en nuestro caso, reescribiríamos los cubos anteriores de la siguiente manera:

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

Ahora, podemos incrementar la frecuencia dentro de un segmento en el tiempo O (1) simplemente agregando la cantidad apropiada a ese segmento. Sin embargo, el costo total de realizar una búsqueda ahora se convierte en O (n), ya que tenemos que volver a calcular el total en el depósito sumando los valores en todos los depósitos más pequeños.

La primera idea importante que necesitamos para llegar a un árbol indexado binario es la siguiente: en lugar de volver a calcular continuamente la suma de los elementos de la matriz que preceden a un elemento en particular, ¿qué pasaría si calculamos previamente la suma total de todos los elementos antes de puntos en la secuencia? Si pudiéramos hacer eso, entonces podríamos calcular la suma acumulativa en un punto simplemente sumando la combinación correcta de estas sumas calculadas previamente.

Una forma de hacer esto es cambiar la representación de ser una matriz de cubos a ser un árbol binario de nodos. Cada nodo se anotará con un valor que representa la suma acumulativa de todos los nodos a la izquierda de ese nodo dado. Por ejemplo, supongamos que construimos el siguiente árbol binario a partir de estos nodos:

             4
          /     \
         2       6
        / \     / \
       1   3   5   7

Ahora, podemos aumentar cada nodo almacenando la suma acumulativa de todos los valores, incluido ese nodo y su subárbol izquierdo. Por ejemplo, dados nuestros valores, almacenaríamos lo siguiente:

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]

Dada esta estructura de árbol, es fácil determinar la suma acumulativa hasta cierto punto. La idea es la siguiente: mantenemos un contador, inicialmente 0, luego hacemos una búsqueda binaria normal hasta encontrar el nodo en cuestión. Al hacerlo, también hacemos lo siguiente: cada vez que nos movemos hacia la derecha, también agregamos el valor actual al contador.

Por ejemplo, supongamos que queremos buscar la suma de 3. Para hacerlo, hacemos lo siguiente:

  • Comience en la raíz (4). El contador es 0.
  • Ir a la izquierda al nodo (2). El contador es 0.
  • Ir a la derecha al nodo (3). El contador es 0 + 6 = 6.
  • Encuentra el nodo (3). El contador es 6 + 15 = 21.

Podría imaginarse también ejecutando este proceso en reversa: comenzando en un nodo dado, inicialice el contador al valor de ese nodo, luego suba el árbol hasta la raíz. Cada vez que siga un enlace secundario derecho hacia arriba, agregue el valor en el nodo al que llegue. Por ejemplo, para encontrar la frecuencia de 3, podríamos hacer lo siguiente:

  • Comience en el nodo (3). El contador es 15.
  • Ir hacia arriba al nodo (2). El contador es 15 + 6 = 21.
  • Ir hacia arriba al nodo (4). El contador es 21.

Para incrementar la frecuencia de un nodo (e, implícitamente, las frecuencias de todos los nodos que vienen después de él), necesitamos actualizar el conjunto de nodos en el árbol que incluye ese nodo en su subárbol izquierdo. Para hacer esto, hacemos lo siguiente: incrementar la frecuencia para ese nodo, luego comenzar a caminar hacia la raíz del árbol. Cada vez que siga un enlace que lo lleve como hijo izquierdo, incremente la frecuencia del nodo que encuentre agregando el valor actual.

Por ejemplo, para incrementar la frecuencia del nodo 1 en cinco, haríamos lo siguiente:

                 4
               [+32]
              /     \
           2           6
         [ +6]       [+80]
         /   \       /   \
      > 1     3     5     7
      [ +5] [+15] [+52] [ +0]

Comenzando en el nodo 1, incremente su frecuencia en 5 para obtener

                 4
               [+32]
              /     \
           2           6
         [ +6]       [+80]
         /   \       /   \
      > 1     3     5     7
      [+10] [+15] [+52] [ +0]

Ahora, ve a su padre:

                 4
               [+32]
              /     \
         > 2           6
         [ +6]       [+80]
         /   \       /   \
        1     3     5     7
      [+10] [+15] [+52] [ +0]

Seguimos un enlace hijo izquierdo hacia arriba, por lo que también incrementamos la frecuencia de este nodo:

                 4
               [+32]
              /     \
         > 2           6
         [+11]       [+80]
         /   \       /   \
        1     3     5     7
      [+10] [+15] [+52] [ +0]

Ahora vamos a su padre:

               > 4
               [+32]
              /     \
           2           6
         [+11]       [+80]
         /   \       /   \
        1     3     5     7
      [+10] [+15] [+52] [ +0]

Ese era un enlace secundario izquierdo, por lo que también incrementamos este nodo:

                 4
               [+37]
              /     \
           2           6
         [+11]       [+80]
         /   \       /   \
        1     3     5     7
      [+10] [+15] [+52] [ +0]

¡Y ahora hemos terminado!

El paso final es convertir de esto a un árbol indexado binario, y aquí es donde podemos hacer algunas cosas divertidas con números binarios. Reescribamos cada índice de cubo en este árbol en binario:

                100
               [+37]
              /     \
          010         110
         [+11]       [+80]
         /   \       /   \
       001   011   101   111
      [+10] [+15] [+52] [ +0]

Aquí, podemos hacer una observación muy, muy buena. Tome cualquiera de estos números binarios y encuentre el último 1 que se estableció en el número, luego suelte ese bit, junto con todos los bits que vienen después. Ahora te queda lo siguiente:

              (empty)
               [+37]
              /     \
           0           1
         [+11]       [+80]
         /   \       /   \
        00   01     10   11
      [+10] [+15] [+52] [ +0]

Aquí hay una observación realmente genial: si trata 0 para significar "izquierda" y 1 para significar "derecha", los bits restantes en cada número explican exactamente cómo comenzar en la raíz y luego caminar hacia ese número. Por ejemplo, el nodo 5 tiene un patrón binario 101. El último 1 es el bit final, por lo que soltamos eso para obtener 10. De hecho, si comienza en la raíz, vaya a la derecha (1), luego vaya a la izquierda (0), termine arriba en el nodo 5!

La razón de que esto sea significativo es que nuestras operaciones de búsqueda y actualización dependen de la ruta de acceso desde el nodo hasta la raíz y si estamos siguiendo enlaces secundarios izquierdos o derechos. Por ejemplo, durante una búsqueda, solo nos preocupamos por los enlaces correctos que seguimos. Durante una actualización, solo nos preocupamos por los enlaces izquierdos que seguimos. Este árbol indexado binario hace todo esto de manera súper eficiente simplemente usando los bits en el índice.

El truco clave es la siguiente propiedad de este árbol binario perfecto:

Dado el nodo n, el siguiente nodo en la ruta de acceso de regreso a la raíz en la que vamos a la derecha se obtiene tomando la representación binaria de n y eliminando el último 1.

Por ejemplo, eche un vistazo a la ruta de acceso para el nodo 7, que es 111. Los nodos en la ruta de acceso a la raíz que tomamos que implican seguir un puntero derecho hacia arriba es

  • Nodo 7: 111
  • Nodo 6: 110
  • Nodo 4: 100

Todos estos son enlaces correctos. Si tomamos la ruta de acceso para el nodo 3, que es 011, y miramos los nodos donde vamos a la derecha, obtenemos

  • Nodo 3: 011
  • Nodo 2: 010
  • (Nodo 4: 100, que sigue un enlace a la izquierda)

Esto significa que podemos calcular muy, muy eficientemente la suma acumulativa hasta un nodo de la siguiente manera:

  • Escriba el nodo n en binario.
  • Establecer el contador a 0.
  • Repita lo siguiente mientras n ≠ 0:
    • Agregue el valor en el nodo n.
    • Despeje el 1 bit más a la derecha de n.

Del mismo modo, pensemos cómo haríamos un paso de actualización. Para hacer esto, nos gustaría seguir la ruta de acceso hasta la raíz, actualizando todos los nodos donde seguimos un enlace izquierdo hacia arriba. Podemos hacer esto esencialmente haciendo el algoritmo anterior, pero cambiando todos los 1 a 0 y 0 a 1.

El paso final en el árbol indexado binario es notar que debido a este truco bit a bit, ya ni siquiera necesitamos tener el árbol almacenado explícitamente. Simplemente podemos almacenar todos los nodos en una matriz de longitud n, luego usar las técnicas de giro bit a bit para navegar el árbol implícitamente. De hecho, eso es exactamente lo que hace el árbol indexado a nivel de bits: almacena los nodos en una matriz, luego usa estos trucos a nivel de bits para simular eficientemente caminar hacia arriba en este árbol.

¡Espero que esto ayude!



Me perdiste en el segundo párrafo. ¿Qué quieres decir con frecuencias acumuladas de 7 elementos diferentes?
Jason Goemaat

20
Esta es, con mucho, la mejor explicación que he leído sobre el tema hasta ahora, entre todas las fuentes que he encontrado en Internet. Bien hecho !
Anmol Singh Jaggi

2
¿Cómo llegó Fenwick a ser tan inteligente?
Rockstar5645

1
Esta es una gran explicación, pero tiene el mismo problema que cualquier otra explicación, así como el propio documento de Fenwick, ¡no proporciona una prueba!
DarthPaghius

3

Creo que el artículo original de Fenwick es mucho más claro. La respuesta anterior de @templatetypedef requiere algunas "observaciones muy interesantes" sobre la indexación de un árbol binario perfecto, que son confusas y mágicas para mí.

Fenwick simplemente dijo que el rango de responsabilidad de cada nodo en el árbol de interrogación estaría de acuerdo con su último bit establecido:

Responsabilidades de los nodos del árbol Fenwick

Por ejemplo, como el último bit establecido de 6== 00110es un "2 bits", será responsable de un rango de 2 nodos. Para 12== 01100, es un "4 bits", por lo que será responsable de un rango de 4 nodos.

Entonces, al consultar F(12)== F(01100), despojamos los bits uno por uno, obteniendo F(9:12) + F(1:8). Esto no es una prueba rigurosa, pero creo que es más obvio cuando se pone de manera simple en el eje de números y no en un árbol binario perfecto, cuáles son las responsabilidades de cada nodo y por qué el costo de la consulta es igual al número de establecer bits.

Si esto aún no está claro, el papel es muy recomendable.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.