Introduzione ai Segment Tree

Introduzione ai Segment Tree

Visti i numerosi thread comparsi recentemente nel forum riguardo problemi con i segment tree, ho provato a scrivere questa breve introduzione a una delle strutture dati più “famose” nell’ambito della programmazione competitiva. Se ci fossero errori, impercisioni o punti spiegati male, fatemelo sapere, grazie.

Prerequisiti

  • conoscenza di base sugli alberi
  • utilizzo di funzioni ricorsive

A cosa serve un segment tree

Partiamo dal considerare il seguente problema: “ci è dato un array A di N interi (N\le10^6) e dobbiamo eseguire Q operazioni (Q\le10^6) che possono essere una query sulla somma del range [l,r[ o modificare l’i-esimo elemento dell’array”

Approccio 1

Query O(N) e update O(1)

La prima idea che viene in mente è implementare le richieste in maniera naive, modificando gli elementi nell’array e sommando quelli gli interi di un range con un ciclo for. Ci si accorge presto che questa soluzione risulta troppo lenta. Il caso pessimo (se tutte le operazioni fossero query di somma sull’intero l’array) verrebbero eseguite circa 10^{12} operazioni; ben oltre quello che ci serve per non fare TLE.

Approccio 2

Query O(1) e update O(N)

Un approccio più sofisticato ma comunque inefficiente è precalcolare le somme prefisse dell’array in modo da poter rispondere velocemente alle query di somma. Tuttavia modificare un elemento comporta la modifica di tutte le somme prefisse dalla posizione dell’elemento in poi. Il caso pessimo (solo update sul primo elemento) ha la stessa complessità del precedente approccio, di conseguenza anche questo si rivela poco efficace.

Approccio 3

Query O(\log_{2}N) e update O(\log_{2}N)

L’approccio migliore per questo tipo di problema è utilizzare il segment tree. Si tratta di una struttura dati molto utilizzata quando si tratta di eseguire query e update su un array. Le operazioni possono essere eseguite sia su un solo elemento (point update/point query) che su range di elementi contigui (range update/rangequery). Ciascuna di queste operazioni, siano esse su range o singoli elementi, avranno complessità O(\log_{2}N).
Il problema descritto sopra richiede point update e range sum.

I segment tree

Idea: Posso evitare di iterare su tutti gli elementi?

Un’idea che può venire in mente è di salvarsi la somma di alcuni dei range per non doverli ricalcolare da capo ogni volta. Se questa cosa viene fatta in maniera corretta può portare a un significativo miglioramento delle performance. Per quanto riguarda gli update, vorremmo anche fare in modo di non dover modificare troppi “range precalcolati”. Spesso la cosa migliore per massimizzare l’efficienza è trovare un compromesso tra query e update.

Come è fatto un segment tree?

Il modo migliore di capire come è fatto un segment tree è vederne una rappresentazione schematica:

Gli elementi in giallo sono quelli dell’array originale e ogni altro nodo contiene la somma dei due nodi sottostanti.

Qualche osservazione

  • Se la dimensione dell’array iniziale è N, ci serviranno N+N/2+N/4+N/2^k+...+1 \approx 2N nodi.
  • La profondità di un path dalla radice a ciascuna delle foglie è O(\log_{2}N).
  • Ciascun nodo contiene la somma delle foglie del suo sottoalbero, che corrisponde a un sottoarray di lunghezza 2^k dell’array originale.

Vediamo nel dettaglio come avvengono le varie operazioni.

Update

Se vogliamo modificare l’i-esimo elemento ci accorgiamo che i nodi del segment tree da aggiornare sono solo quelli del path dalla radice all’i-esima foglia. E siccome la profondità dell’albero è \log_{2}N, la complessità dell’update sarà per l’appunto O(\log_{2}N).

I nodi evidenziati in rosa sono quelli interessati dall’update, in questo caso è stato cambiato il quinto elemento, che è diventato 5, e il valore dei nodi tra la foglia e la radice (compresi) è stato modificato di conseguenza.

Query

Per quanto riguarda la somma di un range, il nostro obiettivo è non dover iterare su tutti gli elementi del range, ma sfruttare i range di cui conosciamo la somma per “comporre” il range interessato. È facilmente dimostrabile (ma non necessario agli scopi di questa introduzione) che il numero di nodi che compone un range è al più 2\log_{2}N.

Nell’immagine il range della query è composto dagli ultimi 6 elementi nel riquadro verde ma notiamo che bastano i due nodi evidenziati in rosso per ottere la somma richiesta.

Implementazione

L’albero

I segment tree sono di fatto alberi binari completi e profondi \log_{2}N. Di conseguenza è possibile memorizzarli all’interno di un singolo array lungo \approx 2N. Tuttavia, siccome l’array non è sempre lungo una potenza di due, possiamo utilizzare un array lungo 4N ed essere sicuri che vada bene per qualsiasi N.

In questa implementazione il segment tree è 1-based, cioè la radice si trova in posizione 1 dell’array. Prendendo in considerazione il nodo i-esimo, il suo figlio sinistro sarà in posizione 2i e il destro in posizione 2i+1. Il padre del nodo i sarà in posizione \lfloor i/2 \rfloor.

Per semplicità implementativa, è bene scrivere una classe (o struct) segtree per mantenere ordine e facilitare l’eventuale debugging.

typedef long long ll; // sempre meglio usare i long long

struct segtree {

    int N; // simensione dell'array originale
    vector<ll> st; // array in cui memorizziamo l'albero

};

build

Complessità: O(N)

Il costruttore della classe riceve l’array su cui vorremo effettuare le operazioni e ridimensiona l’array del segment tree a 4N.

segtree (vector<ll> &A) {

    N = A.size();
    st.resize (4*N); // 4N va sempre bene

    build(1,0,N,A);

} 

Successivamente chiama la funzione ricorsiva build che costruisce ricorsivamente il segment tree.

ll build (int i, int left, int right, vector<ll> &A) {

    // funzione ricorsiva che costruisce un range del segment tree e restituisce la somma del range 

    if (right - left == 1) { // caso base

        // il nodo si riferisce a un solo elemento
        return st[i] = A[left];

    }

    // divido a metà il range e costruisco ricorsivamente

    int mid = (left + right) / 2; 
    st[i] = build(2*i, left, mid, A) + 
            build(2*i+1, mid, right, A);

    // restituisco la somma di questo range
    return st[i];
}  

La costruzione ricorsiva prende come parametri l’indice i del nodo del segment tree, gli estremi left (incluso) e right (escluso) del range [left,right[ che stiamo costruendo. Il range si riferisce all’array di interi su cui facciamo le operazioni. Ciò significa che il nodo i conterrà la somma del range [left,right[ di A. L’ultimo parametro è infatti l’array A, che è importante passare per riferimento, evitando di generarne una copia ad ogni chiamata.

Caso base: il range è lungo 1, cioè contine la somma di un solo elemento di A (cioè A[left]), possiamo quindi assegnare il valore e restituirlo.

Caso generale: dividiamo a metà il range e costruiamo ricorsivamente le due metà. Il valore del nodo sarà quindi la somma dei due range in cui l’abbiamo diviso (i valori restituiti dalle chiamate ricorsive).

Per dividere un range a metà troviamo il punto medio mid facendo la media (approssimata per difetto) di left e right. La posizione mid sarà il primo elemento della metà a destra. La metà sinistra ricorre sul range [left,mid[ mentre quella destra su [mid,right[.

update

Complessità: O(log_{2}N)

Anche l’update avviene con una funzione ricorsiva. Un dettaglio che può semplificare il successivo utilizzo del segment tree è scrivere una procedura che viene chiamata dall’esterno con due soli parametri (posizione e nuovo valore) la quale chiama una procedura omonima interna con tutti i parametri necessari.

void update (int i, int left, int right, int pos, ll val) {

    // funzione ricorsiva che modifica il valore di un elemento e tutti i nodi interessati

    if (right - left == 1) {
        // caso base
        st[i] = val;
        return;
    }

    // ricorro sulla metà in cui si trova l'elemento da modificare 

    int mid = (left + right) / 2;

    if (pos < mid) { // sta nella metà sinistra
        update (2*i, left, mid, pos, val);
    } else { // sta a destra
        update (2*i+1, mid, right, pos, val);
    }

    // aggiorno il valore del nodo come somma dei figli 
    st[i] = st[2*i] + st[2*i+1];
}

void update (int pos, ll val) {
    update (1, 0, N, pos, val);
}

I parametri sono gli stessi della funzione build ma al posto dell’array abbiamo pos che indica l’indice dell’elemento che vogliamo modificare e val, cioè il nuovo valore.

Il caso base è sempre il range lungo 1: se il nodo si riferisce a un solo elemento (quello che vogliamo modificare), cambiamo il suo valore.

Nel caso generale dividiamo sempre a metà il range ma al posto di ricorrere su entrambi i figli, ricorriamo sul sinistro se l’elemento da modificare sta nella prima metà. Sul destro altrimenti.

Dopo la chiamata ricorsiva aggiorniamo il valore del nodo facendo la somma dei valori dei figli (uno dei quali sarà stato modificato dall’update).

query

Complessità: O(log_{2}N)

Le query su un range hanno una struttura leggermente diversa ed è meno intuitivo il motivo per cui la complessità rimane logaritmica. Accenno una possibile dimostrazione (non formale) che può benissimo essere ignorata.

Molto vagamente

Supponiamo che la funzione ricorra sulle due metà e che nessuna delle due sia un caso base. A questo punto entrambi i range avranno sempre uno dei due estremi inclusi nel range della query e di conseguenza da quel momento in poi ad ogni chiamata una delle due metà arriva a un caso base. Se mid non è incluso nel range della query allora una delle due metà avrà intersezione nulla, contrariamente uno dei due range è totalmente incluso. Possiamo concludere che può accadere solo una volta che entrambi i range su cui ricorre non siano casi base e di conseguenza la complessità è O(\approx 4\log_{2}N) che equivale a O(log_{2}N).

ll query (int i, int left, int right, int query_left, int query_right) {
    
    // funzione ricorsiva che restituisce la somma del range [query_left,query_right[

    if (query_left >= right || query_right <= left) {
        // caso base 1
        // il range del nodo non ha intersezioni con il range della query
        return 0;
    }
    if (left >= query_left && right <= query_right) {
        // caso base 2
        // il range del nodo è totalmente incluso nel range della query
        return st[i];
    }

    // ricorro sulle due metà

    int mid = (left + right) / 2;

    return query (2*i, left, mid, query_left, query_right)+
            query (2*i+1, mid, right, query_left, query_right);
}

ll query (int query_left, int query_right) {
    return query (1, 0, N, query_left, query_right);
}

La funzione query a differenza delle precedenti ha due casi base:

  • il range del nodo non ha intersezioni con quello della query, quindi la somma dell’intersezione è 0
  • il range del nodo interseca totalmente quello della query, quindi ritorno la somma del range

Nel caso generale ricorro sulle due metà senza modificare query_left e query_right dato che il range della query rimane sempre lo stesso.

Implementazione completa

Implementazione in C++
struct segtree {
    int N; 
    vector<ll> st;

    ll build (int i, int left, int right, vector<ll> &A) {
        if (right - left == 1) { 
            return st[i] = A[left];
        }
        int mid = (left + right) / 2; 
        st[i] = build(2*i, left, mid, A) +
                build(2*i+1, mid, right, A);
        return st[i];
    } 

    segtree (vector<ll> &A) {
        N = A.size();
        st.resize (4*N);
        build(1,0,N,A);
    }

    void update (int i, int left, int right, int pos, ll val) {
        if (right - left == 1) {
            st[i] = val;
            return;
        }
        int mid = (left + right) / 2;
        if (pos < mid) { 
            update (2*i, left, mid, pos, val);
        } else {
            update (2*i+1, mid, right, pos, val);
        }
        st[i] = st[2*i] + st[2*i+1];
    }

    void update (int pos, ll val) {
        update (1, 0, N, pos, val);
    }

    ll query (int i, int left, int right, int query_left, int query_right) {
        if (query_left >= right || query_right <= left) {
            return 0;
        }
        if (left >= query_left && right <= query_right) {
            return st[i];
        }
        int mid = (left + right) / 2;
        return query (2*i, left, mid, query_left, query_right)+
               query (2*i+1, mid, right, query_left, query_right);
    }

    ll query (int query_left, int query_right) {
        return query (1, 0, N, query_left, query_right);
    }
};

Quando si può usare un segment tree

I segment tree sono quasi sempre una buona scelta quando si tratta di fare update e query su range, ma non vanno bene per tutte le operazioni. In particolare funzionano con le operazioni dette associative come il minimo/massimo, lo XOR, il prodotto, il GCD etc.

Se per esempio ci interessasse il massimo in un range al posto della somma, al posto di unire i due figli di un nodo facendone la somma, si prende il massimo dei due.

st[i] = max(st[2*i], st[2*i+1]);

I segment tree permettono anche di eseguire operazioni più complesse come trovare il subarray di somma massima in un range, trovare il k-esimo elemento (salvando le frequenze) o trovare il primo elemento \ge k in range.

Versioni più elaborate permettono di eseguire query su versioni precedenti della struttura (persistenti) o di memorizzare valori in range più ampi (segment tree sparsi)… gli utilizzi sono innumerevoli.

Il tipo di segment tree spiegato in questa introduzione permette di eseguire point update/range query oppure point query/range update ma con una tecnica chiamata Lazy Propagation è possibile supportare range query/range update.

Per mettere in pratica

Implementativi

Altro

Stay tuned, lazy propagation coming soon

18 Mi Piace