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 posições , nós devemos conseguir efetuar as seguintes operações:
- Mudar o valor de uma posição específica do vetor para . Formalmente, precisamos que, para algum , .
- Encontrar a soma dos elementos de índice até , onde . Formalmente, queremos:
Solução intuitiva 1:
Para atualizarmos a posição do vetor basta fazermos , sendo o valor dado na operação. Para calcularmos a soma de um intervalo de até , basta rodar um loop de até e ir incrementando a resposta com , pois . Nesta solução a operação tem complexidade e a operação tem complexidade .
Solução intuitiva 2:
Utilizaremos um vetor auxiliar que guarda as somas de prefixos de cada intervalo de até , . Agora, a operação pode ser feita com complexidade , basta utilizarmos como a soma do intervalo de até . Porém, para atualizarmos o vetor, precisaremos atualizar todas as posições do vetor , custando .
Conclusão:
A solução 1 é efetiva caso haja muitas operações de tipo e muito poucas de tipo .
A solução 2 é efetiva caso haja muitas operações de tipo e muito poucas de tipo .
E se no nosso problema houver um número aleatório de operações dos tipos e , é possível respondermos ambas mais rapidamente? Com as Segment Trees, conseguimos efetuar ambas as operações em .
Representação da Segment Tree:
As folhas, ou seja, nós sem nenhum filho, representam os intervalos onde , que são os próprios valores de 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 . Para cada nó com índice , seu filho da esquerda está na posição e o seu filho da direita está na posição . Essa é uma maneira comumente utilizada para representar árvores binárias no geral, não somente as Árvores de Segmentos.
Por exemplo, dado o vetor { }, 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 e o do intervalo representado por eles. Como dito anteriormente, para , o nó guarda o valor de 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 , pois , a soma do valor do seu filho da esquerda com o da direita.
Como a Segment Tree se parece no nosso vetor ?
O primeiro valor guardado é a soma de todos os valores de , ou seja, a soma de todos os elementos de no intervalo de até . A partir daí, como definido anteriormente, devemos ter que guarde o valor de seu filho da esquerda e guarde o valor de seu filho da direita. Aplicando a definição recursivamente, teremos:
{}.
Note que o símbolo representa que tal nó não existe na nossa árvore!
Construção da Segment Tree:
Utilizaremos o vetor 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 . Começaremos com o vetor inteiro, ou seja, com a construção do nó que representa o vetor inteiro, com e . Para criarmos todas as folhas iremos, recursivamente, dividir o nosso intervalo atual em duas metades, até que . Quando , basta fazermos: 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: .
Como a Segment Tree é uma árvore binária, se considerarmos que seu vetor de origem tenha posições, o número de posições que o vetor que a representa terá, no máximo, posições, onde x é a menor potência de maior ou igual a . Por exemplo, se , o tamanho do vetor que representa a sua Segment Tree terá .
Código de exemplo da função Build, que constrói a Segment Tree:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 de e um valor , 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 até . Caso contrário, o nó atual não é uma folha. Então, se 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 o índice que divide o nó atual em duas partes. Se , faremos o "update" no filho da esquerda, pois ele abrange o intervalo de até e se encontra nele. Caso contrário, atualizaremos o filho da direita, já que ele abrange o intervalo de até e se encontra nele. Após termos atualizado o filho correto, basta atualizarmos o nó atual. Para fazer isso é só dizer que: .
Código de exemplo da função Update, que atualiza a Segment Tree:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 até , queremos saber a soma de todas os elementos do vetor com posições de até . Como fizemos no Build e no Update, iremos calcular a soma recursivamente. Se o nó atual esta totalmente fora do intervalo de até , então retornaremos , pois esse nó não nos interessa e não alterará a nossa soma. Se o nó atual esta totalmente dentro do intervalo de até , então retornaremos o seu valor: , pois esse intervalo faz parte da resposta. Caso contrário, só nos restaram nós que cobrem parcialmente o intervalo de até . 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 até .
Código de exemplo da função Query, que consulta a Segment Tree:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 é . Existem no total nós, e o valor de cada nó é calculado apenas uma vez na construção da árvore.
A Complexidade de tempo para consulta é . Para consultar uma soma, processamos no máximo quatro nós em cada nível e o número de níveis é .
A complexidade de tempo da atualização também é . Para atualizar o valor de uma folha, nós processamos um nó em cada nível e o número de níveis é
.
Código de exemplo da Segment Tree, sem comentários:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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); | |
} |