Aula escrita por Leonardo Paes e Luca Dantas
Problemas envolvendo Segment Trees são muito recorrentes na OBI, principalmente na modalidade programação nível 2. Por exemplo, nas últimas 3 edições da olimpíada, OBI 2020, 2019 e 2018, houve problemas solucionáveis com Segment Trees.
Problemas: Candidatas, Grand-Prix e Baldes.
Apesar de existirem diversos problemas cuja única dificuldade é saber implementar uma Segment Tree, problemas mais avançados requerem soluções que exijem mais esforço e criatividade. Nesses problemas, será necessário construir uma Segment Tree com um merge (ou combinação) específico, ao invés das usuais Segs de soma e máximo, por exemplo.
Mas o que é esse merge? É a função que recebe ambos os filhos de um nó da Seg como parâmetro e retorna como resultado os valores que o nó pai terá. Por exemplo: se precisarmos fazer uma seg de soma, a função seria semelhante a esta:
Problema de exemplo
Tendo em vista que problemas mais difíceis exigem merges mais complexos, vamos detalhar um passo-a-passo para resolvê-los por meio da explicação da solução do seguinte problema:
Problema: Dado um vetor de inteiros , temos consultas a responder. Elas podem ser de dois tipos:
- Mudar o valor do -ésimo elemento de para . Em outras palavras, recebe .
- Dado um intervalo , encontrar o valor do subarray de soma máxima tal que .
Restrições:
Link de possível judge para submissão do problema de exemplo (note que é necessário se registrar no EDU do CodeForces para acessar o link).
Um exemplo da consulta de tipo 2:
- Seja [] e a consulta do tipo , com intervalo [. O valor que deverá ser imprimido é , soma do intervalo contíguo , contido dentro do intervalo .
Para resolvermos problemas que envolvem merges complexos, é interessante o uso de uma Struct ou Class, para facilitar a escrita do código. Geralmente, criamos um Struct , que terá todas as variáveis necessárias para mantermos os valores desejados em cada nó da Seg. Por padrão, utilizaremos a variável , abreviação da palavra inglesa answer, que significa "resposta" em português. Se preferir, pode chamá-la de . Note que estamos comentando os nomes que geralmente usamos para as variáveis.
Solução do problema
Segue abaixo um passo-a-passo de como resolver um problema de Segment Tree com merge complexo:
- Passo 1: Devemos estabelecer o valor de uma folha da Seg. Geralmente este passo é bem direto.
- Passo 2: Em seguida, temos que considerar a seguinte situação: Somente com o que está armazenado no filho da esquerda e da direita de qualquer nó que não é folha, deve ser possível conseguir a resposta para o próprio nó também. Ou seja, para deduzir o que armazenar nos nós, devemos pensar no que é necessário ter em ambos os filhos de cada nó para chegar na sua própria resposta.
- Passo 3: Após saber quais variáveis são necessários e suficientes, devemos entender como combiná-las corretamente.
Vamos aplicar o passo-a-passo no problema de exemplo:
Passo 1: Para o nó folha . Note que o intervalo vazio sempre é uma possibilidade, por isso o valor .
Passo 2: Com a variável do filho da esquerda, sabemos a soma máxima dentre os subintervalos contíguos que começam e terminam no intervalo representado pelo nó do filho da esquerda. O mesmo vale para o filho da direita. Portanto, só nos resta calcular a soma máxima dentre os subintervalos contíguos que começam no filho da esquerda e terminam no filho da direita. Como esse subintervalo de soma máxima se parecerá? Ele certamente será formado por um sufixo do filho da esquerda concatenado a um prefixo do filho da direita. Não somente isso, será a concatenação do sufixo de soma máxima do filho da esquerda com o prefixo de soma máxima do filho da direita. Note que, como precisaremos dessas duas variáveis, precisamos mantê-las durante todos os nós. Portanto, precisaremos de uma variável que guarda a soma do nó também! Logo, conseguimos determinar as variáveis suficientes para manter em cada nó da Seg!
Passo 3: Como pode-se concluir a partir do passo 2, a resposta para um nó que não é folha é:
,
onde , e guardam a resposta de um nó, o valor do sufixo e do prefixo de soma máxima, respectivamente. Além disso, o prefixo de soma máxima do nó atual é:
,
onde representa a soma total dos valores no intervalo de um nó. De modo análogo, o sufixo de soma máxima é
.
Segue abaixo um código com essa Seg para melhor entedimento (note que no código a função merge é chamada de join):
OBI 2020, Fase 3, P2: Candidatas
Agora vamos analisar um exemplo que caiu na OBI em 2020 e que utiliza essa técnica, o problema Candidatas.
Resumidamente, o problema nos dá um vetor e realizará dois tipos de operações:
- Mudar o valor de uma posição do vetor;
- Saber o número de subintervalos desse vetor com máximo divisor comum maior do que 1 dentro de um intervalo dado.
Como já estamos trabalhando técnicas de árvore em segmentos, pode-se inferir que esse problema têm uma solução envolvendo isso. Porém, podemos perceber que essa é uma boa técnica a ser utilizada neste problema pelo tipo de operações que nos são pedidas: mudar o valor em um posição e perguntar uma informação sobre um intervalo.
A base da solução para esse problema é bem parecida com a ideia geral apresentada na aula: queremos dividir as respostas para cada segmento pequeno e conseguir ter informação suficiente dentro deles para unir dois segmentos e calcular eficientemente assim a resposta de um segmento maior, assim como fazemos em problemas de árvore de segmentos normalmente. Portanto, vamos focar nossos esforços em responder o problema para um intervalo e em descobrir como calcular a resposta de um intervalo maior baseado na união de dois intervalos.
O caso base neste problema é um intervalo formado por um único elemento, e nesse caso a única resposta é formada pelo subintervalo formado somente por ele.
Para todos os conjunto maiores, as respostas têm 3 possibilidades:
- A subsequência válida está completamente contida no filho da esquerda;
- A subsequência válida está completamente contida no filho da direita;
- A subsequência válida inicia no filho da esquerda e termina no filho da direita.
Para os dois primeiros casos basta adicionarmos na resposta do nó atual a soma das respostas dos dois filhos. Para o terceiro caso, já chegamos em um problema um pouco mais complicado.
Primeira ideia
Uma observação que podemos ter é que, nesta situação, a resposta vai ser formada por um prefixo dos elementos do filho da direita e um sufixo dos elementos do filho da esquerda. Uma ideia então seria salvarmos todos os sufixos e prefixos de cada segmento e, ao combiná-los, compararmos todos os prefixos da direita com os sufixos da esquerda um a um em e contarmos em quantos deles o MDC é maior que 1.
Vale lembrar que a operação de MDC é comutativa e associativa, então se quisermos calcular o MDC de um segmento e tivermos o MDC de dois subintervalos que juntos cobrem o segmento inteiro, o MDC do segmento inteiro vai ser simplesmente o MDC dos valores desses dois subsegmentos.
Otimizando o merge
Para otimizar a solução acima, vamos precisar fazer algumas observações sobre as propriedades do MDC.
Perceba que se analisarmos os prefixos (também vale para os sufixos pois eles são simétricos), a estrutura que encontraremos será uma sequência de valores decrescentes por uma propriedade dos MDCs. Podemos observá-la vendo que se tivermos um conjunto de elementos tal que e adicionarmos um elemento ao conjunto, teremos que . Por exemplo, cofira a imagem abaixo:
Outra observação que podemos fazer é que toda vez que o MDC é alterado, a decomposição em primos desse MDC "perde" um elemento. No caso acima, por exemplo, tínhamos: . Isso acontece pois o MDC é essencialmente o "and" dos fatores primos de todos os elementos do conjunto, ou seja, para a potência de primo estar presente na fatoração do MDC do conjunto, todos os elementos do conjunto têm que ter essa potência de primo em sua fatoração.
Portanto, o número de MDC's diferentes para o prefixo é limitado por , já que o maior valor de um elemento desse conjunto só pode ter no máximo 30 divisores primos e a cada vez que o valor do MDC muda um desses primos é perdido.
Juntando essas observações, podemos encontrar uma forma muito mais eficiente de salvar os valores de prefixos e sufixos. Já que somente existem valores diferentes para o MDC, basta salvarmos cada intervalo de prefixos com o mesmo valor de MDC, salvando qual é esse valor e qual o tamanho desse conjunto. Isso pode ser armazenado facilmente em um vector<pair<int,int>>, indicando qual o valor e quantos itens existem com esse MDC.
Por exemplo, para a ilustração acima teríamos: .
Como calcular este vetor dos MDC's do prefixo e sufixo?
Para calcular o vetor de prefixo (o sufixo é simétrico, então a explicação será a mesma) do nó atual, iremos juntar duas partes. A primeira é simplesmente pegar o prefixo do filho da esquerda, pois essa parte se manterá inalterada. Para considerarmos os prefixos que terminam no filho da direita, vamos simplesmente adicionar os mesmos prefixos já salvos no filho da direita tirando o MDC do valor já presente nele com o MDC do filho da esquerda inteiro.
Como um exemplo, se o vetor de prefixo do filho da direita for e o MDC do intervalo da esquerda inteiro era , os valores que iremos adicionar serão . Porém, tome cuidado, pois se somente adicionarmos assim, poderíamos deixar segmentos com o mesmo valor separado. Imagine que no caso mostrado agora o MDC do nó da esquerda é . Nesse caso, se só seguirmos cegamente o algoritmo de adicionar os valores tirando o MDC com o nó da esquerda completo, adicionaríamos , o que quebra a propriedade de que mantemos os conjuntos de mesmo valor em um mesmo elemento do vetor, fazendo com que o vetor inteiro possa ter mais que elementos. Para remediar esse problema, basta toda vez que formos adicionar um elemento, checarmos se o novo MDC que estamos adicionando é igual ao último elemento do vetor atualmente; caso seja, somente somamos os tamanhos do intervalo ao invés de adicionar outro elemento ao vetor.
Um outro detalhe que utilizamos para tornar a implementação mais simples é salvar apenas os prefixos cujo MDC é maior do que 1.
Segue uma implementação dessa ideia para melhor compreensão:
Como podemos calcular a resposta com esses valores de prefixo e sufixo?
A primeira solução (e menos eficiente) é simplesmente repetir o que fizemos na primeira ideia e compararmos os pares um a um em . Nesse caso, como o tamanho é , o merge possuirá complexidade e a solução completa , o que não é suficiente para a solução final.
A observação final é que se temos um prefixo e um sufixo do outro nó e , o para qualquer . De maneira semelhante, se exise um tal que , para qualquer . Note que a análise é a mesma se trocarmos a posição de prefixo e sufixo aqui pois eles são simétricos.
Em outras palavras, existe um ponto em que todos os intervalos iniciados em e que terminam até são maiores que , e portanto são válidos para a resposta. E todos os intervalos que terminam depois de não são válidos para a resposta. Logo, podemos fazer uma busca mais eficiente para achar quantos valores iniciando em são válidos.
Essa busca mais eficiente pode ser uma busca binária, levando a complexidade à , o que é uma melhoria. Porém, a implementação se torna complicada considerando como fizemos o cálculo dos MDC's de prefixos e sufixos acima. Além disso, esta solução ainda pode não ser rápida o suficiente. Portanto, implementaremos a técnica de Two Pointers.
Para perceber que Two Pointers funciona nesse caso, basta perceber que os pontos ótimos dos prefixos nunca aumentarão se colocarmos mais elementos ao conjunto. Considerando que o ponto ótimo do prefixo é , temos que para todo . Ou seja, os pontos ótimos são decrescentes, portanto aplicar Two Pointers é válido.
Segue uma implementação desse cálculo da resposta para mais detalhes (os detalhes de como implementar estão comentados no código):
Segue o código agora da solução completa: