Correção, invariantes e complexidade
Provar o que um algoritmo calcula e contar tempo e memória em função da entrada.
Em [6,9,9,2], a nota máxima é 9, mas há duas posições possíveis. Se o pedido for a primeira ocorrência, a resposta é 1, com índices a partir de zero. Um algoritmo tem de fixar estes empates antes de podermos provar que está correto. Ordenar todas as notas para descobrir um único máximo faz trabalho que um percurso evita.
Percorrer o vetor guardando a maior nota vista é um algoritmo: uma sequência finita de passos para transformar a entrada no resultado pedido. Além de justificar esse resultado, precisamos de contar tempo e memória.
Contrato e invariante
O contrato separa a pré-condição, que o chamador garante, da pós-condição, que o algoritmo garante. Para procurar o máximo, o vetor tem pelo menos um elemento. A resposta é um valor presente no vetor e nenhum elemento é maior. Se também pedires a posição, tens de decidir o que acontece num empate.
Considera este pseudocódigo, que devolve a primeira posição do máximo num vetor não vazio:
melhor = 0
para i = 1, ..., n-1:
se v[i] > v[melhor]: melhor = i
devolver melhor
- Inicialização: antes da primeira iteração,
i=1. O prefixo só contémv[0].melhor=0satisfaz a propriedade. - Manutenção: se
v[i]for maior, a posiçãoipassa a ser o novo máximo. Se for menor ou igual, manter a posição antiga conserva o máximo e o primeiro empate. Depois de aumentari, a propriedade vale para o prefixo seguinte. - Conclusão: ao sair,
i=n. O prefixo é o vetor inteiro. A propriedade implica a pós-condição. - Terminação: a quantidade inteira não negativa
n-idiminui em cada passo. O ciclo não pode continuar indefinidamente.
As três primeiras etapas dão correção parcial: se terminar, o resultado é correto. Com a terminação, tens correção total. Uma propriedade como «o máximo ainda existe» é demasiado fraca para justificar a posição devolvida. Usar >= conserva o máximo, mas passa a escolher a última ocorrência.
Contar operações
Escolhe uma operação representativa, como comparações de chaves, e conta-a em função do tamanho n. O máximo anterior faz exatamente n-1 comparações. Não precisa de ordenar, porque isso acrescentaria trabalho sem melhorar uma única consulta. A tabela seguinte distingue os dois casos que mais se confundem: ciclos seguidos somam, e ciclos encaixados com interior independente multiplicam; o caso triangular soma 1+…+n.
| Fragmento de ciclo | Número de execuções do corpo | Ordem apertada |
|---|---|---|
i=0..n-1 | ||
dois ciclos consecutivos de tamanho n | ||
dois ciclos encaixados de tamanho n | ||
i=1..n, j=1..i | ||
i=1; i<n; i*=2 | , para | |
| ciclo linear seguido de ciclo quadrático |
No primeiro programa, o segundo ciclo começa depois de terminar o primeiro. No segundo, o ciclo interior executa n vezes em cada iteração exterior, independentemente de i. Por isso somamos custos no primeiro e multiplicamos contagens no segundo; quando o interior depende do exterior, conta por soma, como no caso triangular.
long long s = 0;
for (int i = 0; i < n; ++i) s += a[i];
for (int i = 0; i < n; ++i) s += b[i]; // total 2n passos
long long s = 0;
for (int i = 0; i < n; ++i)
for (int j = 0; j < n; ++j) s += 1; // total n*n passos
Com n=0, ambos fazem zero iterações. Com n=1000, o primeiro faz cerca de 2000 adições e o segundo cerca de 1 000 000. Chamar «independentes» aos dois casos esconde esta diferença: o que importa é se um ciclo está dentro do outro. E não classifiques todos os ciclos encaixados como quadráticos: se o exterior duplica i e o interior executa i vezes, a soma é , ou seja, . Um índice que apenas avança ao longo de todo o programa pode justificar uma soma linear, mesmo dentro de outro ciclo.
O modelo costuma tratar operações sobre inteiros de dimensão fixa e acesso a uma posição de array como constantes. Comparar duas strings com comprimento máximo L pode custar . Copiar um vetor de n posições custa , mesmo que a chamada pareça uma única instrução. Juntar strings com += repetido recopia o resultado parcial em cada passo, por isso n junções podem custar no total: conta a operação repetida, não só o comprimento final.
Limites assintóticos
Para funções não negativas a partir de certa dimensão:
- se existem com para todo .
- se existe um limite inferior análogo.
- se os dois limites valem.
Para , com , temos . Logo . Também pertence a , mas esse limite informa menos. E não significa «pior caso»: podes dar um limite superior para o melhor caso, o caso médio ou o pior caso, desde que indiques qual analisaste.
Se pedirem a relação mais apertada entre duas funções, tenta primeiro . Por exemplo, : para , basta escolher constantes 6 e 11, pois . O facto de a função exceder não refuta ; o limite permite uma constante multiplicativa.
Já , mas não em . O quociente tende a zero. Para qualquer constante positiva c que proposes como limite inferior, esse quociente acaba por ficar abaixo de c. Portanto não existe um limite inferior quadrático que valha para todas as dimensões suficientemente grandes. Um limite superior isolado não prova uma ordem apertada.
A notação indica uma soma dos termos nos limites escritos. Por exemplo, . é o expoente que dá n como potência de 2; conta aproximadamente quantas divisões por dois reduzem n até um. é o produto .
A ordem de crescimento habitual é
A base do logaritmo muda apenas uma constante. Uma medição de 0,4 s com dimensão n sugere cerca de 1,6 s ao duplicar n num algoritmo quadrático, se a parcela quadrática dominar e as condições forem semelhantes. É uma estimativa, não uma garantia sobre outra máquina ou outra distribuição de dados.
O vídeo de Rodrigo Dias complementa o estudo de invariantes e análise assintótica.
Recorrências e espaço
Uma função recursiva chama-se a si própria para resolver um problema menor. O caso base resolve diretamente um problema pequeno, sem nova chamada. Para provares terminação, verifica que cada chamada reduz uma dimensão inteira não negativa até ao caso base. A hipótese indutiva assume a correção dos subproblemas menores; tens de explicar como os seus resultados resolvem o problema atual. Cada chamada suspensa conserva parâmetros e variáveis na pilha de chamadas.
Uma recorrência exprime o custo de uma chamada pelo trabalho local e pelo custo das chamadas menores. Assume custo constante no caso base; divisões em metades de dimensão ímpar usam arredondamentos, que não alteram as ordens seguintes.
Uma chamada que resolve duas metades e faz trabalho linear satisfaz . Em cada nível da árvore de chamadas, o trabalho total é e há níveis. O total é .
Calcular o máximo de duas metades e comparar os dois resultados faz trabalho constante por chamada: . Os níveis têm 1, 2, 4, … chamadas e a soma até às n folhas é , ou seja, . Ter duas chamadas recursivas não implica custo , porque o custo da junção é decisivo.
Uma chamada que só segue uma metade com trabalho constante satisfaz . Uma chamada que reduz o tamanho em um e percorre o restante satisfaz .
Conta também o espaço auxiliar, sem a entrada e a saída quando essa convenção é explícita. Uma recursão com profundidade h ocupa na pilha de chamadas. Duas chamadas consecutivas não exigem o espaço das duas subárvores ao mesmo tempo. Um vetor auxiliar reutilizado de tamanho n mais uma pilha de profundidade custa .
Pior caso, média e amortização
O pior caso maximiza o custo entre entradas da mesma dimensão. O caso médio exige uma distribuição de probabilidades: não basta dizer «normalmente». Com chaves distintas, um quicksort com pivô uniforme tem custo esperado devido à escolha aleatória do pivô, embora ainda exista uma execução quadrática. A partição também importa: Lomuto com chaves todas iguais continua quadrático, mesmo com pivô aleatório.
A análise amortizada limita uma sequência de operações, sem assumir entradas aleatórias. Num vetor que duplica a capacidade quando fica cheio, as cópias ao inserir n elementos somam . Uma inserção isolada pode custar , mas o custo amortizado por inserção é . Realocar pode invalidar referências e iteradores, mesmo com custo amortizado baixo.
Pré-processar consultas
Para responderes à soma de um intervalo [l,r), constrói prefixos: e . Por indução, é a soma das primeiras i posições. Assim,
Construir custa , cada consulta custa , e q consultas custam no total. Somar cada intervalo de novo pode custar . Se o vetor mudar, os prefixos deixam de corresponder aos dados.
- Entrada, posições 0 a 3O intervalo [1,3) contém -2 e 7.
4-271 - Prefixos, fronteiras 0 a 4p[3] - p[1] = 9 - 4 = 5.
042910
#include <iostream>
#include <vector>
int main() {
int n, q;
if (!(std::cin >> n >> q) || n < 0 || n > 10000 || q < 0) return 1;
std::vector<long long> p(n + 1, 0);
for (int i = 0; i < n; ++i) {
int x;
if (!(std::cin >> x) || x < -1000000 || x > 1000000) return 1;
p[i + 1] = p[i] + x;
}
for (int i = 0; i < q; ++i) {
int l, r;
if (!(std::cin >> l >> r) || l < 0 || r < l || r > n) return 1;
std::cout << p[r] - p[l] << '\n';
}
}Executa o código para ver a saída e os gráficos.
Na entrada inicial, v=[4,-2,7,1] dá p=[0,4,2,9,10]. A consulta [1,3) retira o prefixo de soma 4 ao prefixo de soma 9: ficam -2+7=5. [2,2) dá zero e [0,4) dá 10. O programa aceita o intervalo vazio e limita os valores para que a soma caiba em long long. Testa também n=0, q=1 e a consulta 0 0.
Avaliar um polinómio com Horner
Para , começa em a[d] e repete r=r*x+a[i] com i decrescente. Para , com x=2, começa em 4, passa a 4×2-3=5 e termina em 5×2+2=12. Depois de incorporar o coeficiente a[i], o invariante é . Multiplicar por x e somar o coeficiente seguinte preserva essa forma. Faz d multiplicações e d somas, em vez de recalcular cada potência.
Janela deslizante com contagem
Na ficha atual 02, o problema [AED008] Saving Pipefish pede o número de janelas de tamanho K com pelo menos metade das posições com valor maior ou igual a T (2026/27). Recontar cada janela do zero custa . Mantém antes uma contagem da janela atual.
O invariante é: antes de avaliar a janela que começa em i, dentro conta quantas das K posições dessa janela satisfazem profundidade[j] >= T. Para passar de i para i+1, retira a posição i e acrescenta a posição i+K. Cada posição entra e sai uma vez, logo o total é após a primeira janela.
Com N=7, K=3, T=150 e [142,155,147,165,150,112,73], as contagens são 1,2,2,2,1 e há 3 janelas válidas. Com K=1, cada janela tem uma posição e a resposta conta os elementos >=T. Com K=N, há uma única janela. Verifica sempre 1<=K<=N antes de aceder a a[i+K].
int validas(const std::vector<int>& a, int k, int t) {
int n = (int)a.size(), dentro = 0;
for (int i = 0; i < k; ++i) if (a[i] >= t) ++dentro;
int r = (dentro * 2 >= k);
for (int i = 1; i + k <= n; ++i) {
if (a[i - 1] >= t) --dentro;
if (a[i + k - 1] >= t) ++dentro;
if (dentro * 2 >= k) ++r;
}
return r;
}
Multiplicar por dois evita divisão inteira no teste «pelo menos metade», incluindo K ímpar.
Máximo de uma janela fixa
A maior soma de exatamente k elementos consecutivos é outro contrato. Com [4,-1,3,-2,5,-3] e k=3, as somas são 6,0,6,0; a primeira janela máxima começa em 0. Kadane permite comprimentos diferentes, pelo que não resolve este pedido sem alteração.
Assume 1<=k<=n e somas representáveis. Soma primeiro [0,k), iniciando c=s nessa soma e p=0. Começa j=1; enquanto j<=n-k, faz:
c = c - x[j - 1] + x[j + k - 1];
if (c > s) { s = c; p = j; }
++j;
No teste do ciclo, c é a soma da janela que começa em j-1; s é a maior soma entre as janelas com inícios em [0,j), e p é o primeiro desses inícios que atinge s. Dizer só que s é um máximo não explica por que a atualização de c calcula a janela seguinte.
Inicialmente, j=1 e só a janela de início 0 foi processada. Na manutenção, subtrair x[j-1] retira a posição antiga e somar x[j+k-1] acrescenta a nova. O teste estrito conserva o primeiro empate, e incrementar j volta a pôr c na janela de início j-1. Na saída, j=n-k+1, por isso foram consideradas todas as n-k+1 janelas. Essa quantidade menos j diminui até zero e prova terminação.
Recalcular cada janela custa . Deslizar custa , com espaço auxiliar . Com k fixo, as duas versões são lineares; com k=\lfloor n/2\rfloor, recalcular passa a quadrático e deslizar continua linear. Com k=n, existe uma única janela: até a versão que recalcula é linear. A hipótese k<=n/2, usada nalguns enunciados, permite simplificar o primeiro custo para ; sem ela, mantém n-k+1.
Melhor soma contígua
Considera a maior soma de um intervalo não vazio. Enumerar todos os pares de extremos e somar cada intervalo custa . Atualizar a soma enquanto avanças o extremo direito reduz para ; prefixos também avaliam cada par em tempo constante, mas deixam um número quadrático de pares.
Em [-3,5,-2,4,-8,6], os valores de atual são -3,5,3,7,-1,6, e a melhor soma é 7, do intervalo [5,-2,4]. Num vetor só de negativos, a resposta é o maior elemento, não zero. Se o intervalo vazio fosse permitido, zero passaria a ser uma resposta possível; seria outro contrato.
#include <algorithm>
#include <iostream>
#include <optional>
#include <vector>
std::optional<long long> melhorSoma(const std::vector<int>& a) {
if (a.empty()) return std::nullopt;
long long atual = a[0], melhor = a[0];
for (std::size_t i = 1; i < a.size(); ++i) {
atual = std::max<long long>(a[i], atual + a[i]);
melhor = std::max(melhor, atual);
}
return melhor;
}
int main() {
for (const std::vector<int>& a : {std::vector<int>{-3, 5, -2, 4, -8, 6},
std::vector<int>{-6, -2, -8},
std::vector<int>{}}) {
const auto r = melhorSoma(a);
if (r) std::cout << *r << '\n';
else std::cout << "sem intervalo\n";
}
}Executa o código para ver a saída e os gráficos.
O programa usa optional para representar a ausência de intervalo numa entrada vazia. Assume até 10 000 valores com módulo até um milhão para que as somas caibam em long long. Tempo e espaço auxiliar . A saída negativa no segundo caso distingue este contrato de uma implementação que começa o melhor em zero.
Exercícios
As justificações e os programas em C++ são de autoavaliação: compara-os com os critérios da solução. Nas questões numéricas, a verificação automática confirma apenas o valor.
Contar um ciclo triangular
Um ciclo exterior usa i=1..12; o interior usa j=1..i-1 e faz uma comparação
por iteração. Quantas comparações são feitas? Generaliza para dimensão n.
Compara com outro ciclo exterior i=1; i<n; i*=2, cujo interior
executa exatamente i vezes. Conta para n=12 e justifica a ordem
assintótica de ambos. Assume inteiros suficientemente largos para os índices.
Pista
Escreve os comprimentos dos primeiros ciclos interiores.
Mais uma pista
A soma é 0+1+…+11.
Solução
São 12×11/2=66 comparações, ou seja, para dimensão n, n(n-1)/2 é Θ(n²). O
ciclo com i=1 não executa o corpo interior. No segundo programa, com n=12,
o índice exterior vale 1, 2, 4 e 8: o trabalho soma 1+2+4+8=15.
Em geral, a soma das potências de dois menores que n é menor que 2n
e a última é pelo menos n/2, para n>1. Por isso é Θ(n), apesar
de também haver dois ciclos encaixados.
Confere a tua resposta:
- Contei 66 comparações no ciclo triangular e generalizei para n(n-1)/2.
- Contei 15 execuções com n=12 no ciclo por potências de dois.
- Justifiquei Θ(n²) no primeiro e Θ(n) no segundo.
Erros frequentes
Não uses n² como contagem exata; não incluas j=i.
Provar uma política de empate
O máximo do vetor [6,9,9,2] é procurado atualizando a posição só quando o novo valor é estritamente maior. Dá a posição com índices a partir de zero e prova que é a primeira ocorrência.
Este programa usa uma comparação que viola a política da primeira ocorrência. Corrige-a e confirma que cada resultado obtido coincide com o esperado. No vazio, a posição devolvida é o tamanho, sem aceder ao vetor.
#include <iostream>
#include <vector>
std::size_t primeiraPosicao(const std::vector<int>& a) {
if (a.empty()) return a.size();
std::size_t melhor = 0;
for (std::size_t i = 1; i < a.size(); ++i)
if (a[i] >= a[melhor]) melhor = i;
return melhor;
}
int main() {
const std::vector<std::vector<int>> casos{{6,9,9,2},{4,4,4},{-2},{1,3,5},{}};
const std::vector<std::size_t> esperado{1,0,0,2,0};
for (std::size_t i = 0; i < casos.size(); ++i)
std::cout << "obtido=" << primeiraPosicao(casos[i])
<< "; esperado=" << esperado[i] << '\n';
}Executa o código para ver a saída e os gráficos.
Pista
A propriedade tem de referir o prefixo já examinado e a escolha entre iguais.
Mais uma pista
Antes da iteração i, a resposta é a primeira posição do máximo em [0,i).
Solução
A posição é 1. No programa, troca >= por >. No vazio, a verificação inicial devolve size() como ausência; o contrato do máximo aplica-se aos casos não vazios. Inicialmente o prefixo tem só a posição 0. Uma chave maior
substitui o máximo; uma chave igual não troca a posição mais antiga, porque a política é a primeira ocorrência. Ao
terminar, o prefixo é todo o vetor. n-i diminui até zero, provando
terminação.
Confere a tua resposta:
- Indiquei a posição 1 e corrigi >= para > no programa.
- Mostrei inicialização, manutenção e conclusão com a política de empate.
- Justifiquei terminação e o contrato não vazio.
Erros frequentes
Provar só que o valor é máximo não justifica a primeira posição; usar >= mudaria a resposta para 2.
Distinguir o intervalo vazio
Qual é a maior soma de um intervalo contíguo não vazio de [-2,4,-6,3,5,-1]?
Traça atual e melhor para justificar a resposta. Repete para
[-6,-2,-8] e explica o que mudaria se fosse permitido um intervalo vazio.
Pista
Em cada posição, compara começar aí com prolongar o melhor intervalo que acabava antes.
Mais uma pista
Os valores de atual começam por -2, 4 e -2; o valor 3 inicia um intervalo novo.
Solução
Os valores de atual são -2,4,-2,3,8,7. Os de melhor são -2,4,4,4,8,8. A
soma máxima é 8, do intervalo [3,5]. Há apenas trabalho constante por
posição, por isso o custo é O(n). Numa entrada só de negativos, a
inicialização no primeiro elemento conserva o contrato não vazio. Para
[-6,-2,-8], atual vale -6, -2 e -8; melhor vale -6, -2 e -2. A
resposta é -2, do elemento central. Se o vazio fosse permitido, a soma
zero venceria qualquer intervalo negativo; no primeiro vetor, a resposta
continuaria a ser 8.
Confere a tua resposta:
- Tracei atual e melhor até à soma 8 de [3,5].
- Obtive -2 no vetor [-6,-2,-8] sob o contrato não vazio.
- Expliquei por que permitir o vazio altera a segunda resposta para zero.
Erros frequentes
Não escolhas valores separados: 4+3+5 ignora o -6 entre eles. Inicializar a resposta em zero devolveria um intervalo vazio quando todos os valores fossem negativos.
Contar janelas sem recomeçar
Com [150,149,151,148,147,153], K=4 e
T=150, quantas janelas têm pelo menos metade das posições
com valor maior ou igual a T? Mostra as três contagens, identificando em cada avanço
o valor que sai e o que entra.
Pista
Mais uma pista
Ao avançar, retira a posição que sai e junta a que entra.
Solução
As contagens são 2, 1 e 2. A primeira janela inclui 150 e 151; 150
conta porque o teste é >=T. No primeiro avanço, sai 150 e entra 147: a
contagem desce para 1. No segundo, sai 149 e entra 153: sobe para 2.
Com K=4, pelo menos metade significa pelo menos dois valores, logo
há duas janelas válidas. Inicializar custa O(K) e cada avanço custa
O(1), dando O(N) no total. Dados próprios, adaptados do contrato AED008.
Erros frequentes
Recontar cada janela do zero custa O(NK); com
K=N há uma só janela.
Provar um limite com constantes
Uma pessoa afirma que 8n²+3 não pertence a O(n²) porque excede n²
para todo n>=1. Refuta ou confirma pela definição e dá a relação mais
apertada. Para n log₂ n e n², justifica também por que a relação mais
apertada é diferente. Considera dimensões inteiras positivas.
Pista
Solução
Para n>=1, 8n²<=8n²+3<=11n². Portanto c1=8, c2=11 e n0=1
provam Θ(n²), incluindo O(n²). A afirmação confundiu a referência n²
com o limite c n². Já n log₂ n<=n² para n>=1, dando O(n²).
O quociente log₂ n/n tende a zero: qualquer constante positiva candidata
a limite inferior acaba por falhar. Logo não há Ω(n²) e não há Θ(n²).
Confere a tua resposta:
- Dei constantes positivas e um n0 para provar Θ(n²).
- Expliquei por que exceder n² não refuta O(n²).
- Mostrei que n log₂ n não tem limite inferior quadrático.
Provar o primeiro máximo de comprimento fixo
Em [3,-2,3,3,-4,5], procura a primeira janela de exatamente três
elementos com soma máxima. Usa c para a soma atual, s para a melhor
soma e p para o início. Inicializa com a janela de início 0 e j=1;
enquanto j<=n-k, atualiza c=c-x[j-1]+x[j+k-1], compara com s e
incrementa j. Traça, prova e compara com recalcular cada janela.
Generaliza para 1<=k<=n, assumindo somas representáveis.
Pista
Solução
As somas são 4,4,2,4, dando s=4,p=0. O teste tem de ser estrito
c>s; >= acabaria no início 3. Antes de cada teste, c soma a janela
de início j-1; s é o máximo das janelas com inícios em [0,j), e
p é o primeiro desses inícios que o atinge. Em j=1, só foi processada
a primeira janela. Subtrair a posição que sai e juntar a que entra calcula
a janela de início j; comparar e depois incrementar preserva as três
propriedades. A saída tem j=n-k+1, logo inclui a última janela.
n-k+1-j diminui e prova terminação. Recalcular custa
Θ(k(n-k+1)); deslizar custa Θ(n). Com k fixo, ambas são lineares;
com k=floor(n/2), recalcular é quadrático; com k=n, há uma única soma
de n valores e ambas são lineares. Kadane, que permite comprimentos
variáveis, daria outra resposta: neste vetor, a melhor soma não vazia é 8.
Confere a tua resposta:
- Obtive somas 4,4,2,4 e início 0 para k=3.
- Relacionei c, s e p com as janelas antes de cada teste.
- Provei inicialização, manutenção, saída j=n-k+1 e terminação.
- Distingui o custo com k fixo, k=floor(n/2) e k=n.
