Complexidade de Algoritmos
1. Introdução: Por que analisar algoritmos?
No desenvolvimento de software de alto desempenho e competições: - Escrever código correto é apenas metade da batalha. - Garantir que execute dentro dos limites de tempo e memória é a outra metade.
Exemplo: Processamento de transações bancárias. - 10 transações em 1 seg \(\to\) OK. - \(1 \times 10^5\) transações em \(10^5\) seg (27 horas) \(\to\) Inviável.
Essa relação entre Tamanho da Entrada (\(N\)) vs Custo de Execução é a Análise de Complexidade.
O Modelo RAM
Para analisar algoritmos independentemente do hardware, usamos o modelo Random Access Machine (RAM): - Operações simples (+, -, *, if, call) levam 1 unidade de tempo. - Loops são compostos por várias operações simples. - Acesso à memória é constante \(O(1)\).
2. Notação Assintótica (Big O)
A notação Big O (\(O\)) descreve o limite superior do tempo de execução quando \(N \to \infty\). - Foco no Pior Caso. - Ignoramos constantes (ex: \(2N\) vira \(N\)). - Se \(N\) dobra, como o tempo cresce?
Se algoritmo é \(O(N^2)\), o tempo cresce quadraticamente.
Classes de Complexidade Comuns
| Notação | Nome | Descrição | Exemplo Típico |
|---|---|---|---|
| \(O(1)\) | Constante | O tempo não muda com \(N\). | Acessar arr[i], operações aritméticas. |
| \(O(\log N)\) | Logarítmica | O problema é dividido em pedaços menores. | Busca Binária, operações em TreeSet. |
| \(O(N)\) | Linear | Percorre a entrada uma vez. | Loop simples, Busca Linear. |
| \(O(N \log N)\) | Linearítmica | Combinação de linear e logarítmico. | Merge Sort, Heap Sort, Arrays.sort(). |
| \(O(N^2)\) | Quadrática | Loop aninhado (todos com todos). | Bubble Sort, Selection Sort. |
| \(O(N^3)\) | Cúbica | Três loops aninhados. | Multiplicação de Matrizes ingênua. |
| \(O(2^N)\) | Exponencial | Tenta todas as subcombinações. | Caixeiro Viajante (Força Bruta). |
| \(O(N!)\) | Fatorial | Tenta todas as permutações. | Gerar anagramas. |
3. Exemplos Práticos em Java
3.1. Complexidade Linear \(O(N)\)
O exemplo mais clássico é a Busca Linear. Precisamos verificar cada elemento até encontrar o alvo ou chegar ao fim.
public int buscaLinear(int[] vetor, int alvo) {
// N é o tamanho do vetor
for (int i = 0; i < vetor.length; i++) { // Executa N vezes no pior caso
if (vetor[i] == alvo) {
return i; // O(1)
}
}
return -1;
}Análise: No pior caso (elemento não existe ou está no final), o loop roda \(N\) vezes. Logo, \(O(N)\).
3.2. Complexidade Quadrática \(O(N^2)\)
Geralmente ocorre quando temos loops aninhados dependentes de \(N\). Um exemplo é verificar se há duplicatas comparando todos com todos.
public boolean temDuplicata(int[] vetor) {
int n = vetor.length;
for (int i = 0; i < n; i++) { // Loop Externo: N vezes
for (int j = i + 1; j < n; j++) { // Loop Interno: ~N vezes
if (vetor[i] == vetor[j]) {
return true;
}
}
}
return false;
}Análise: O loop interno roda \((N-1) + (N-2) + \dots + 1\), que é a soma aritmética \(\frac{N(N-1)}{2}\). Ignorando constantes e termos menores, temos \(\frac{N^2}{2} \approx O(N^2)\).
3.3. Complexidade Logarítmica \(O(\log N)\)
A Busca Binária é o exemplo fundamental. A cada passo, cortamos o espaço de busca pela metade. Isso só funciona se o vetor estiver ordenado.
public int buscaBinaria(int[] vetor, int alvo) {
int inicio = 0;
int fim = vetor.length - 1;
while (inicio <= fim) {
int meio = inicio + (fim - inicio) / 2; // Evita overflow de (inicio+fim)
if (vetor[meio] == alvo) return meio;
if (vetor[meio] < alvo) {
inicio = meio + 1; // Descarta metade esquerda
} else {
fim = meio - 1; // Descarta metade direita
}
}
return -1;
}Análise: Quantas vezes podemos dividir \(N\) por 2 até chegar a 1? A resposta é \(\log_2 N\). Para \(N = 1.000.000\), o loop executa apenas cerca de 20 iterações!
4. Analisando Recursão
Para algoritmos recursivos, nem sempre é óbvio olhar os loops. Usamos frequentemente o Teorema Mestre para dividir e conquistar.
Uma recorrência comum é: \(T(N) = aT(N/b) + f(N)\). - \(a\): número de chamadas recursivas. - \(N/b\): tamanho de cada subproblema. - \(f(N)\): custo para dividir/combinar.
Exemplo: Merge Sort O Merge Sort divide o vetor em 2 metades (\(a=2, b=2\)) e depois intercala as metades em tempo linear \(O(N)\). \[ T(N) = 2T(N/2) + O(N) \] Pelo Teorema Mestre, isso resulta em \(O(N \log N)\).
Códigos Recursivos “Ingênuos”: O cálculo de Fibonacci recursivo sem memorização:
public int fib(int n) {
if (n <= 1) return n;
return fib(n-1) + fib(n-2);
}Isso gera uma árvore de recursão onde cada nó gera 2 filhos. A altura da árvore é \(N\). Logo, \(O(2^N)\). Extremamente ineficiente para \(N > 40\).
5. Dicas para Maratonas e Juízes Online
Ao submeter um problema no Beecrowd, Codeforces ou LeetCode, você verá um “Time Limit” (geralmente 1 ou 2 segundos). Um computador moderno processa aproximadamente \(10^8\) operações simples por segundo.
Podemos usar essa regra de bolso para estimar se nossa solução vai passar (Time Limit Exceeded - TLE) ou não:
| Tamanho da Entrada (\(N\)) | Complexidade Esperada | Algoritmos Sugeridos |
|---|---|---|
| \(N \leq 10\) | \(O(N!)\) | Permutação Completa |
| \(N \leq 20\) | \(O(2^N)\) | Backtracking, Bitmask DP |
| \(N \leq 500\) | \(O(N^3)\) | Floyd-Warshall, Multiplicação de Matrizes |
| \(N \leq 2.000\) | \(O(N^2)\) | Bubble Sort, DP 2D, Dijkstra (denso) |
| \(N \leq 10^5\) ou \(10^6\) | \(O(N \log N)\) ou \(O(N)\) | Sort padrão, Busca Binária, Hash Map, Árvores |
| \(N \leq 10^9\) | \(O(\sqrt{N})\) | Fatoração de Primos, Mo’s Algorithm |
| \(N \leq 10^{18}\) | \(O(\log N)\) ou \(O(1)\) | Exponenciação Rápida, Algoritmos Matemáticos |
Exemplo de Análise Prévia
Problema: Dado um vetor de \(N=10^5\) números, encontre dois números que somam \(K\).
- Abordagem Força Bruta: Testar todos os pares.
- Complexidade: \(O(N^2)\).
- Operações: \((10^5)^2 = 10^{10}\).
- Veredito: \(10^{10} > 10^8\). Muito lento. TLE.
- Abordagem Otimizada: Usar um HashSet ou ordenar.
- Usando Sort + Two Pointers: \(O(N \log N)\).
- Operações: \(10^5 \times 17 \approx 1.7 \times 10^6\).
- Veredito: \(1.7 \times 10^6 < 10^8\). Passa com folga (Accepted).
6. Complexidade de Espaço
Não esqueça da memória! “Memory Limit Exceeded” (MLE) também é um erro comum. - \(O(1)\): Usa algumas variáveis auxiliares (int, float). - \(O(N)\): Usa um vetor auxiliar do tamanho da entrada. - \(O(N^2)\): Cria uma matriz \(N \times N\) (Cuidado! Para \(N=10.000\), uma matriz de inteiros gasta 400MB, estourando limites comuns de 256MB).
Na prática, sempre tente reduzir a complexidade de tempo primeiro, mas fique atento se você não está alocando memória demais (ex: recursão muito profunda estoura a Stack, tabelas de DP gigantes estouram a Heap).