Estrutura de Dados 04 - Segment Tree

Aula por Leonardo Paes

Segment Tree, ou Árvore de Segmentos, é uma das estruturas de dados mais utilizadas na programação competitiva por conta da sua eficiência e versatilidade. Ela é, basicamente, uma árvore binária utilizada para armazenar intervalos. Uma árvore binária é uma árvore em que cada nó contém no máximo 2 filhos, chamados por filho da esquerda e filho da direita. Cada nó da Segment Tree representa apenas um intervalo.

Vamos pensar no seguinte problema para entendermos as Segment Trees:

Dado um vetor de n posições vet[0, 1, ..., n-1], nós devemos conseguir efetuar as seguintes operações:

  1. Mudar o valor de uma posição específica do vetor para x. Formalmente, precisamos que, para algum i, vet[i] = x, 0 \leq i \leq n-1.
  2. Encontrar a soma dos elementos de índice l até r, onde 0 \leq l \leq r \leq n-1. Formalmente, queremos: \sum_{i=l}^{r} vet[i]

Solução intuitiva 1:

Para atualizarmos a posição i do vetor basta fazermos vet[i]=x, sendo x o valor dado na operação. Para calcularmos a soma de um intervalo de l  até r, basta rodar um loop de l até r e ir incrementando a resposta com vet[i], pois l \leq i \leq r. Nesta solução a operação 1 tem complexidade O(1) e a operação 2 tem complexidade O(n).

Solução intuitiva 2:

Utilizaremos um vetor auxiliar pref que guarda as somas de prefixos de cada intervalo de 0 até i, 0 \leq i \leq n-1. Agora, a operação 2 pode ser feita com complexidade O(1), basta utilizarmos pref[r] - pref[l-1] como a soma do intervalo de l até r. Porém, para atualizarmos o vetor, precisaremos atualizar todas as n posições do vetor pref, custando O(n).

Conclusão:

A solução 1 é efetiva caso haja muitas operações de tipo 1 e muito poucas de tipo 2.

A solução 2 é efetiva caso haja muitas operações de tipo 2 e muito poucas de tipo 1.

E se no nosso problema houver um número aleatório de operações dos tipos 1 e 2, é possível respondermos ambas mais rapidamente? Com as Segment Trees, conseguimos efetuar ambas as operações em O(\log_{} N).

Representação da Segment Tree:

As folhas, ou seja, nós sem nenhum filho, representam os intervalos onde l = r, que são os próprios valores de vet dados pelo problema. Todos os outros nós representam a junção de algumas folhas. Essa junção pode variar de problema em problema. No nosso caso, ela é apenas a soma das folhas que estão abaixo de um nó.

Para guardarmos os valores de todos os nós da Segment Tree, utilizaremos um vetor tree. Para cada nó com índice i, seu filho da esquerda está na posição 2*i+1 e o seu filho da direita está na posição 2*i+2. Essa é uma maneira comumente  utilizada para representar árvores binárias no geral, não somente as Árvores de Segmentos.

Por exemplo, dado o vetor vet[6]= { 1, 2, 4, 3, 5, 10 }, a sua Segment Tree seria:

 

Website utilizado: http://visualgo.net

Embaixo de cada nó da árvore, temos dois números escritos dentro de colchetes, que são o l e o r do intervalo representado por eles. Como dito anteriormente, para l = r, o nó guarda o valor de vet[l] e qualquer outro nó guarda a soma de todas as folhas abaixo dele, ou seja, a soma do valor dos seus filhos. A raiz da Segment Tree, por exemplo, guarda o número 25, pois 25 = 7 + 18, a soma do valor do seu filho da esquerda com o da direita.

Como a Segment Tree se parece no nosso vetor tree?

O primeiro valor guardado é a soma de todos os valores de vet, ou seja, a soma de todos os elementos de vet no intervalo de 0 até n-1. A partir daí, como definido anteriormente, devemos ter que tree[2*i+1] guarde o valor de seu filho da esquerda e tree[2*i+2] guarde o valor de seu filho da direita. Aplicando a definição recursivamente, teremos:

tree[] = {25, 7, 18, 3, 4, 8, 10, 1, 2, \emptyset, \emptyset, 3, 5, \emptyset, \emptyset}.

Note que o símbolo \emptyset representa que tal nó não existe na nossa árvore!

Construção da Segment Tree:

Utilizaremos o vetor vet para a construção de sua respectiva Segment Tree.

Inicialmente sabemos calcular o valor de todas as folhas pois elas só utilizam dos valores de vet. Começaremos com o vetor vet inteiro, ou seja, com a construção do nó que representa o vetor inteiro, com l=0 e r=n-1. Para criarmos todas as folhas iremos, recursivamente, dividir o nosso intervalo atual em duas metades, até que l=r. Quando l=r, basta fazermos: tree[node]=vet[l] e retornarmos a função. Enquanto estivermos no processo de desempilhamento, ou seja, na "volta" da recursão, criaremos os outros nós da árvore. Para criarmos eles, basta dizermos que o valor deles é a soma dos valores de seus respectivos filhos, pois eles já foram criados previamente: tree[node] = tree[2*node+1] + tree[2*node+2].

Como a Segment Tree é uma árvore binária, se considerarmos que seu vetor de origem tenha n posições, o número de posições que o vetor que a representa terá, no máximo, 2*x - 1 posições, onde x é a menor potência de 2 maior ou igual a n. Por exemplo, se n=6, o tamanho do vetor que representa a sua Segment Tree terá 2*8-1 = 15.

Código de exemplo da função Build, que constrói a Segment Tree:


// Os parametros da função build são:
// O índice do nó atual, o l e o r do intervalo representado pelo nó.
void build(int node, int l, int r){
// Se o nó atual é uma folha:
// O valor desse nó é simplesmente vet[l], ou vet[r], e então retorno a função.
if(l==r){
tree[node]=vet[l];
return;
}
// Caso contrário, declararemos mid como o índice que divide o nó atual em duas metades.
int mid = (l+r)/2;
// Chamaremos a função build para as duas metades, que são:
// O filho da esquerda: Com nó 2*node+1 e com intervalo de l até mid.
// O filho da direita: Com nó 2*node+2 e com intervalo mid+1 até r.
build(2*node+1,l,mid);
build(2*node+2,mid+1,r);
// Após termos calculado a resposta dos dois filhos do nó atual
// saberemos a resposta dele, que é a soma dos valores dos seus filhos.
// Lembrando que 2*node+1 é o nó do filho da esquerda e 2*node+2 o da direita.
tree[node] = tree[2*node+1] + tree[2*node+2];
}

Atualizando a Segment Tree:

Dado um índice idx de vet e um valor x, queremos atualizar a nossa Seg, abreviação de Segment Tree. Vamos fazer isso recursivamente. Se o nó atual é uma folha, então só iremos atualizá-lo se ele representa o intervalo de idx até idx. Caso contrário, o nó atual não é uma folha. Então, se idx se encontra dentro do intervalo do nó atual, iremos chamar a função update ou para o seu filho da direita ou para o seu filho da esquerda. Seja mid o índice que divide o nó atual em duas partes. Se idx \leq mid, faremos o "update" no filho da esquerda, pois ele abrange o intervalo de l até mid e idx se encontra nele. Caso contrário, atualizaremos o filho da direita, já que ele abrange o intervalo de mid+1 até r e idx se encontra nele. Após termos atualizado o filho correto, basta atualizarmos o nó atual. Para fazer isso é só dizer que: tree[node] = tree[2*node+1] + tree[2*node+2].

Código de exemplo da função Update, que atualiza a Segment Tree:


// Os parametros da função Update são:
// O nó atual, tl e tr, o intervalo que o nó atual abrange, idx e x, o índice que queremos atualizar e o seu valor.
void update(int node, int tl, int tr, int idx, int x){
// Se o nó atual é uma folha, então ele representa o intervalo de idx até idx.
// Então, atualizaremos o seu valor e retornaremos a função.
if(tl==tr){
tree[node]=x;
vet[l]=x;
return;
}
// Caso contrário, declararemos mid como o índice que divide o nó atual em duas metades.
int mid = (tl+tr)/2;
// Se idx se encontra no intervalo de tl até mid, atualizaremos o filho da esquerda
// do nó atual, pos ele abrange o intervalo de tl até mid.
// Senão, então ele se encontra de mid+1 até r, então:
// Basta atualizarmos o filho da direita do nó atual.
if(tl<=idx and idx<=mid) update(2*node+1,tl,mid,idx,v);
else update(2*node+2,mid+1,tr,idx,v);
// Após termos atualizado um dos dois filhos, atualizaremos o valor do nó atual.
tree[node] = tree[2*node+1] + tree[2*node+2];
}

Consultando a Segment Tree:

Dado um intervalo de l até r, queremos saber a soma de todas os elementos do vetor vet com posições de l até r. Como fizemos no Build e no Update, iremos calcular a soma recursivamente. Se o nó atual esta totalmente fora do intervalo de l até r, então retornaremos 0, pois esse nó não nos interessa e 0 não alterará a nossa soma. Se o nó atual esta totalmente dentro do intervalo de l até r, então retornaremos o seu valor: tree[node], pois esse intervalo faz parte da resposta. Caso contrário, só nos restaram nós que cobrem parcialmente o intervalo de l até r. Para resolvermos esses casos, basta chamarmos a função para os filhos do nó atual e retornarmos a soma delas como resposta. É importante notar que, recursivamente, a nossa resposta final será a soma de um conjunto de nós da Segment Tree que inteiramente fazem parte do intervalo de l até r.

Código de exemplo da função Query, que consulta a Segment Tree:


// Os parametros da função Query são:
// O nó atual, tl e tr, o intervalo que o nó atual abrange, l e r, o intervalo que queremos calcular.
int query(int node, int tl, int tr, int l, int r){
// Se o intervalo que o nó atual abrange estiver totalmente fora do intervalo de l até r, retornaremos 0.
// Então, ou o início do intervalo do nó atual é maior que o fim dp intervalo que queremos calcular, ou
// o fim do intervalo do nó atual é menor que o início do intervalo que queremos calcular.
if(r<tl or l>tr) return 0;
// Se o intervalo que o nó atual representa estiver totalmente dentro do intervalo de l até r, então
// retornaremos o valor do nó atual.
if(l<=tl and r>=tr) return tree[node];
// Caso contrário, declararemos mid como o índice que divide o nó atual em duas metades.
int mid = (tl+tr)>>1;
// Retornamos como resposta a soma da resposta dos filhos do nó atual.
return query(2*node+1, tl, mid, l, r)+query(2*node+2, mid+1, tr, l, r);
}

Complexidade de tempo das funções:

A Complexidade de tempo para construção da árvore é O(n). Existem no total 2*n-1 nós, e o valor de cada nó é calculado apenas uma vez na construção da árvore.

A Complexidade de tempo para consulta é O(\log_{} n). Para consultar uma soma, processamos no máximo quatro nós em cada nível e o número de níveis é \log_{} n.

A complexidade de tempo da atualização também é O(\log_{} n). Para atualizar o valor de uma folha, nós processamos um nó em cada nível e o número de níveis é

\log_{} n.

Código de exemplo da Segment Tree, sem comentários:


// Segment Tree
// Build: O(n)
// Update: O(log n)
// Query: O(log n)
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e5+10;
int vet[maxn], tree[4*maxn];
void build(int node, int l, int r){
if(l==r){
tree[node]=vet[l];
return;
}
int mid = (l+r)/2;
build(2*node+1,l,mid);
build(2*node+2,mid+1,r);
tree[node] = tree[2*node+1] + tree[2*node+2];
}
void update(int node, int tl, int tr, int idx, int v){
if(tl==tr){
tree[node]=v;
return;
}
int mid = (tl+tr)/2;
if(tl<=idx and idx<=mid) update(2*node+1,tl,mid,idx,v);
else update(2*node+2,mid+1,tr,idx,v);
tree[node] = tree[2*node+1] + tree[2*node+2];
}
int query(int node, int tl, int tr, int l, int r){
if(r<tl or l>tr) return 0;
if(l<=tl and r>=tr) return tree[node];
int mid = (tl+tr)/2;
return query(2*node+1, tl, mid, l, r)+query(2*node+2, mid+1, tr, l, r);
}