BIT: Qual è l'intuizione dietro un albero indicizzato binario e come è stato pensato?


99

Un albero indicizzato binario ha una letteratura molto inferiore o relativamente nulla rispetto ad altre strutture di dati. L'unico posto dove viene insegnato è il tutorial di topcoder . Sebbene il tutorial sia completo in tutte le spiegazioni, non riesco a capire l'intuizione dietro un tale albero? Come è stato inventato? Qual è la prova effettiva della sua correttezza?


4
Un articolo su Wikipedia afferma che questi sono chiamati alberi Fenwick .
David Harkness,

2
@ DavidHarkness- Peter Fenwick ha inventato la struttura dei dati, quindi a volte vengono chiamati alberi Fenwick. Nel suo documento originale (trovato su citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.14.8917 ), si riferiva a loro come alberi indicizzati binari. I due termini sono spesso usati in modo intercambiabile.
templatetypedef

1
La seguente risposta trasmette un'intuizione "visiva" molto gradevole degli alberi indicizzati binari cs.stackexchange.com/questions/42811/… .
Rabih Kodeih,

1
So come ti senti, la prima volta che ho letto l'articolo topcoder, mi è sembrato magico.
Rockstar5645,

Risposte:


168

Intuitivamente, puoi pensare a un albero indicizzato binario come una rappresentazione compressa di un albero binario che è essa stessa un'ottimizzazione di una rappresentazione di array standard. Questa risposta arriva in una possibile derivazione.

Supponiamo, ad esempio, che si desideri memorizzare frequenze cumulative per un totale di 7 elementi diversi. Puoi iniziare scrivendo sette secchi in cui verranno distribuiti i numeri:

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

Supponiamo ora che le frequenze cumulative abbiano un aspetto simile al seguente:

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

Utilizzando questa versione dell'array, è possibile aumentare la frequenza cumulativa di qualsiasi elemento aumentando il valore del numero memorizzato in quel punto, quindi incrementando le frequenze di tutto ciò che verrà dopo. Ad esempio, per aumentare la frequenza cumulativa di 3 di 7, è possibile aggiungere 7 a ciascun elemento dell'array nella posizione 3 o successiva, come mostrato qui:

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

Il problema è che ci vuole O (n) tempo per farlo, il che è piuttosto lento se n è grande.

Un modo in cui possiamo pensare a migliorare questa operazione sarebbe quello di cambiare ciò che immagazziniamo nei secchi. Invece di memorizzare la frequenza cumulativa fino a un determinato punto, puoi invece pensare di memorizzare la quantità che la frequenza corrente è aumentata rispetto al bucket precedente. Ad esempio, nel nostro caso, riscriviamo i secchi sopra come segue:

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

Ora, possiamo aumentare la frequenza all'interno di un bucket nel tempo O (1) semplicemente aggiungendo la quantità appropriata a quel bucket. Tuttavia, il costo totale di una ricerca ora diventa O (n), poiché dobbiamo ricalcolare il totale nel bucket sommando i valori in tutti i bucket più piccoli.

La prima grande intuizione che dobbiamo ottenere da qui a un albero binario indicizzato è la seguente: piuttosto che ricalcolare continuamente la somma degli elementi dell'array che precedono un elemento particolare, e se dovessimo pre-calcolare la somma totale di tutti gli elementi prima di specifici punti nella sequenza? Se potessimo farlo, allora potremmo capire la somma cumulativa in un punto semplicemente riassumendo la giusta combinazione di queste somme pre-calcolate.

Un modo per farlo è quello di cambiare la rappresentazione da array di bucket a albero binario di nodi. Ogni nodo verrà annotato con un valore che rappresenta la somma cumulativa di tutti i nodi a sinistra di quel dato nodo. Ad esempio, supponiamo di costruire il seguente albero binario da questi nodi:

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

Ora, possiamo aumentare ogni nodo memorizzando la somma cumulativa di tutti i valori incluso quel nodo e la sua sottostruttura sinistra. Ad esempio, dati i nostri valori, memorizzeremmo quanto segue:

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]

Data questa struttura ad albero, è facile determinare la somma cumulativa fino a un certo punto. L'idea è la seguente: manteniamo un contatore, inizialmente 0, quindi eseguiamo una normale ricerca binaria fino a quando non troviamo il nodo in questione. Mentre lo facciamo, facciamo anche quanto segue: ogni volta che ci spostiamo a destra, aggiungiamo anche il valore corrente al contatore.

Ad esempio, supponiamo di voler cercare la somma per 3. Per fare ciò, facciamo quanto segue:

  • Inizia dalla radice (4). Il contatore è 0.
  • Vai a sinistra al nodo (2). Il contatore è 0.
  • Vai a destra al nodo (3). Il contatore è 0 + 6 = 6.
  • Trova nodo (3). Il contatore è 6 + 15 = 21.

Potresti anche immaginare di eseguire questo processo al contrario: partendo da un dato nodo, inizializzando il contatore sul valore di quel nodo, quindi risalendo l'albero fino alla radice. Ogni volta che segui un link figlio giusto verso l'alto, aggiungi il valore nel nodo in cui arrivi. Ad esempio, per trovare la frequenza per 3, potremmo fare quanto segue:

  • Inizia dal nodo (3). Il contatore è 15.
  • Andare verso l'alto al nodo (2). Il contatore è 15 + 6 = 21.
  • Andare verso l'alto al nodo (4). Il contatore è 21.

Per aumentare la frequenza di un nodo (e, implicitamente, le frequenze di tutti i nodi che lo seguono), dobbiamo aggiornare l'insieme di nodi nella struttura che includono quel nodo nella sua sottostruttura sinistra. Per fare ciò, facciamo quanto segue: incrementa la frequenza per quel nodo, quindi inizia a camminare fino alla radice dell'albero. Ogni volta che segui un link che ti porta come figlio di sinistra, aumenta la frequenza del nodo che incontri aggiungendo il valore corrente.

Ad esempio, per aumentare la frequenza del nodo 1 di cinque, faremo quanto segue:

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

A partire dal nodo 1, incrementare la sua frequenza di 5 per ottenere

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

Ora vai dal suo genitore:

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

Abbiamo seguito un collegamento figlio sinistro verso l'alto, quindi aumentiamo anche la frequenza di questo nodo:

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

Ora andiamo dal suo genitore:

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

Quello era un link figlio sinistro, quindi incrementiamo anche questo nodo:

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

E ora abbiamo finito!

Il passaggio finale è la conversione da questo a un albero indicizzato binario, ed è qui che possiamo fare alcune cose divertenti con i numeri binari. Riscriviamo ogni indice bucket in questo albero in binario:

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

Qui, possiamo fare un'osservazione molto, molto interessante. Prendi uno di questi numeri binari e trova l'ultimo 1 impostato nel numero, quindi rilascia quel bit, insieme a tutti i bit che lo seguono. Ora ti rimane quanto segue:

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

Ecco un'osservazione davvero molto interessante: se tratti 0 per significare "sinistra" e 1 per "destra", i bit rimanenti su ciascun numero spiegano esattamente come iniziare dalla radice e poi scendi a quel numero. Ad esempio, il nodo 5 ha un modello binario 101. L'ultimo 1 è il bit finale, quindi lo rilasciamo per ottenere 10. In effetti, se inizi dalla radice, vai a destra (1), quindi vai a sinistra (0), finisci fino al nodo 5!

Il motivo per cui ciò è significativo è che le nostre operazioni di ricerca e aggiornamento dipendono dal percorso di accesso dal nodo alla radice e dal fatto che stiamo seguendo collegamenti figlio sinistro o destro. Ad esempio, durante una ricerca, ci preoccupiamo solo dei collegamenti giusti che seguiamo. Durante un aggiornamento, ci preoccupiamo solo dei collegamenti a sinistra che seguiamo. Questo albero binario indicizzato fa tutto in modo super efficiente semplicemente usando i bit nell'indice.

Il trucco chiave è la seguente proprietà di questo perfetto albero binario:

Dato il nodo n, il nodo successivo sul percorso di accesso fino alla radice in cui andiamo a destra viene dato prendendo la rappresentazione binaria di n e rimuovendo l'ultimo 1.

Ad esempio, dai un'occhiata al percorso di accesso per il nodo 7, che è 111. I nodi sul percorso di accesso alla radice che prendiamo che coinvolgono seguendo un puntatore destro verso l'alto sono

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

Tutti questi sono collegamenti giusti. Se prendiamo il percorso di accesso per il nodo 3, che è 011, e osserviamo i nodi in cui andiamo a destra, otteniamo

  • Nodo 3: 011
  • Nodo 2: 010
  • (Nodo 4: 100, che segue un collegamento a sinistra)

Ciò significa che possiamo calcolare in modo molto, molto efficiente la somma cumulativa fino a un nodo come segue:

  • Scrivi il nodo n in binario.
  • Impostare il contatore su 0.
  • Ripeti quanto segue mentre n ≠ 0:
    • Aggiungi il valore nel nodo n.
    • Cancella il bit più a destra da n.

Allo stesso modo, pensiamo a come faremmo un passaggio di aggiornamento. Per fare ciò, vorremmo seguire il percorso di accesso fino alla radice, aggiornando tutti i nodi in cui abbiamo seguito un collegamento sinistro verso l'alto. Possiamo farlo essenzialmente facendo l'algoritmo sopra, ma passando da 1 a 0 e da 0 a 1.

Il passaggio finale nell'albero indicizzato binario è notare che a causa di questo inganno bit per bit, non è nemmeno necessario che l'albero venga archiviato più esplicitamente. Possiamo semplicemente memorizzare tutti i nodi in una matrice di lunghezza n, quindi utilizzare le tecniche di rotazione bit per bit per navigare implicitamente l'albero. In effetti, questo è esattamente ciò che fa l'albero indicizzato bit a bit: memorizza i nodi in un array, quindi utilizza questi trucchi bit a bit per simulare in modo efficiente il cammino verso l'alto in questo albero.

Spero che sia di aiuto!



Mi hai perso nel secondo paragrafo. Cosa intendi con frequenze cumulative di 7 diversi elementi?
Jason Goemaat,

20
Questa è di gran lunga la migliore spiegazione che ho letto sull'argomento finora, tra tutte le fonti che ho trovato su Internet. Molto bene !
Anmol Singh Jaggi,

2
In che modo Fenwick è diventato così intelligente?
Rockstar5645,

1
Questa è un'ottima spiegazione, ma soffre dello stesso problema di ogni altra spiegazione, così come il documento di Fenwick, non fornisce una prova!
DarthPaghius,

3

Penso che il documento originale di Fenwick sia molto più chiaro. La risposta sopra di @templatetypedef richiede alcune "osservazioni molto interessanti" sull'indicizzazione di un albero binario perfetto, che sono confuse e magiche per me.

Fenwick ha semplicemente detto che l'intervallo di responsabilità di ogni nodo nella struttura dell'interrogazione sarebbe secondo l'ultimo bit impostato:

Responsabilità dei nodi dell'albero di Fenwick

Ad esempio, poiché l'ultimo bit impostato di 6== 00110è un "2 bit", sarà responsabile di un intervallo di 2 nodi. Per 12==01100 , è un "4 bit", quindi sarà responsabile di un intervallo di 4 nodi.

Quindi quando interrogiamo F(12)== F(01100), eliminiamo i bit uno per uno, ottenendo F(9:12) + F(1:8). Questa non è quasi una prova rigorosa, ma penso che sia più ovvio se messo così semplicemente sull'asse dei numeri e non su un albero binario perfetto, quali sono le responsabilità di ciascun nodo e perché il costo della query è uguale al numero di impostare bit.

Se ciò non è ancora chiaro, la carta è molto consigliata.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.