Técnicas de Programação - Soma Máxima em Intervalo

Aula por Arthur Lobo

Nessa aula, vamos falar sobre técnicas usadas para encontrar a maior soma de um intervalo contínuo em um vetor e suas aplicações.

Motivação inicial

Vamos imaginar o seguinte problema: Dado um vetor inicial de tamanho N, A_1, A_2 ... A_N, com números inteiros entre -100 e 100, responda qual é a maior soma de um intervalo contínuo. Formalmente, diga qual o maior valor de A_i+A_{i+1}+...+A_j, com 1 \le i \le j \le N.

Se quiser ler o enunciado inteiro e com mais detalhes, clique aqui.

Uma primeira ideia que podemos ter para resolver o problema é testar todos os intervalos (i,j) (começando em i e terminando em j) e ver qual deles tem a maior soma. Para isso podemos criar uma função soma(i,j), que retorna a soma de i até j fazendo um for no intervalo inteiro e somando os valores correspondentes Essa solução tem a complexidade O(N^3), pois testamos O(N^2) pares e a função soma(i,j) funciona em O(N).

Ainda podemos melhorar essa solução usando soma de prefixo: primeiro calculamos o vetor de somas de prefixo S, em que S_i guardará a soma A_1+A_2+...+A_i, e então podemos fazer com que a função soma(i,j) seja respondida em O(1), já que podemos apenas retornar S_j - S_{i-1}. Dessa maneira, podemos fazer essa ideia rodar em O(N^2), mas ainda podemos fazer melhor!

Algoritmo de Kadane

A ideia do algoritmo de Kadane é realizarmos uma solução gulosa. Vamos iterar do 1 até o N, quando estivermos em i, sempre manteremos 2 valores: a resposta máxima até aqui e o valor do intervalo de soma máximo terminando em i, guardaremos eles em resp e soma, respectivamente.

O algoritmo é o seguinte, digamos que acabamos de calcular a iteração de i-1, na iteração de i vamos seguir os seguintes passos:

  • Primeiro, verificamos se soma é menor que 0, caso seja, fazemos com que ela retorne a ser 0. A ideia é que sempre que o intervalo ficar negativo, vamos descartar ele inteiro.
  • Depois, adicionamos A_i em soma, fazendo soma = soma + A_i. Nesse momento, é garantido que soma guarda o valor do intervalo de soma máxima que termina em i (vamos provar isso logo em seguida).
  • Por último, iremos atualizar resp caso soma seja maior que resp fazendo resp = max(resp,soma)

A intuição por trás do algoritmo de Kadane é que sempre que a soma do intervalo fica menor que 0, nós resetamos ela, fazendo com que volte a ser 0. Isso faz com que, sempre que estamos em i, soma guardá o valor do intervalo de soma máxima que termina em i, portanto, pegamos o intervalo de soma máxima do vetor inteiro. A prova do porque podemos resetar (fazer o início e o final do intervalo ser i) fica como um exercício para o leitor, mas a ideia é imaginar o que aconteceria se fosse melhor mudar o início do intervalo para uma posição diferente de i.

Soma de Prefixo

Além da solução com o algoritmo de Kadane, esse problema também pode ser resolvido utilizando somas de prefixo. Além de ser mais intuitiva, a solução com soma de prefixo pode ser usada em diversas variações do problema. Agora vamos olhar ela mais de perto:

No início da aula, falamos sobre fazer um vetor de somas de prefixos S, em que S_i guarda A_1+A_2+...+A_i (em particular, S_0 = 0), e usar isso para descobrir a soma entre quaisquer de i até j (i \le j) fazendo a conta S_j-S_{i-1}. Portanto, queremos encontrar o valor máximo de S_j-S_{i-1}.

Assim como no algoritmo de Kadane, nosso objetivo vai ser descobrir qual a maior soma de intervalo terminando em cada j. Para isso, vamos fixar o j na nossa conta e olhar apenas para o i, Fazemos isso para todo j de 1 até N.

Queremos encontrar o maior valor de S_j-S_{i-1}, a partir do momento que fixamos um j, o valor do primeiro termo (S_j) é fixado também, então nosso objetivo virou apenas maximizar o segundo termo, ou seja, -S_{i-1}, e maximizar isso é o mesmo que minimizar S_{i-1}, já que está ele subtraindo. Como temos que 1 \le i \le j \Rightarrow 0 \le i-1 \le j-1, então queremos que S_{i-1} seja o menor valor entre S_0, S_1,...,S_{j-1}.

Para computarmos isso em cada j de 1 até N, podemos iterar nessa ordem, guardando sempre o valor do menor prefixo até j-1 na variável menor, ao mesmo tempo que vamos atualizando a resposta em cada iteração, guardando ela em resp:

Começamos com menor = 0 e resp = -infinito (com infinito sendo um número muito grande), e para cada j de 1 até N nosso algoritmo realizará os seguintes passos:

  • Primeiro, vemos se S_{j-1} < menor, e caso for, o atualizamos fazendo menor = min(menor,S_{j-1}).
  • Agora sabemos que o valor do intervalo de soma máxima que termina em j é S_j-menor, então comparamos a resposta atual com essa possível resposta fazendo resp = max(resp,S_j-menor).

A intuição por trás do algoritmo é que sempre queremos subtrair o menor valor possível de S_j, então a melhor opção é pegar o prefixo com a menor soma até j-1. Vale ressaltar que o problema inicial diz que temos que pegar pelo menos um valor do vetor, então se todos os elementos em A fossem negativos, nossa resposta seria negativa. Mas caso um problema fale que podemos escolher não pegar nenhum valor do vetor, então caso todos os elementos em A sejam negativos, a resposta será 0.

Problemas para praticar

Agora é a sua vez! Tente fazer esses problemas que usam os algoritmos ensinados na aula de hoje ou ideias semelhantes: