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 (dinâmica):
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 chamadas filhas: - Nível 0: 1 chamada - Nível 1: 2 chamadas - Nível 2: 4 chamadas … Nível \(N\): \(2^N\) chamadas.
O total de operações soma a progressão geométrica \(1 + 2 + 4 + \dots + 2^{N-1} = 2^N - 1\). A complexidade, portanto, é \(O(2^N)\). Isso se torna 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 |
Por que \(\leq 500\) aceita \(O(N^3)\)?
Se a entrada \(N = 500\), então \(500^3 = 125.000.000 \approx 1.25 \times 10^8\). Esse valor fica exatamente ali próximo ao limiar tolerado de \(10^8\) operações em 1 segundo. Ou seja, qualquer algoritmo cúbico de constante alta falharia para \(N \geq 500\) tomando TLE.
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).
Limites práticos para Ordenações Comuns
E quando os algoritmos de ordenação estouram o tempo limite de \(10^8\) operações?
- Bubble Sort (\(O(N^2)\)):
- Para \(N = 10.000\) (\(10^4\)): \((10^4)^2 = 10^8\) operações. (Passa raspando).
- Para \(N = 100.000\) (\(10^5\)): \((10^5)^2 = 10^{10}\) operações. (TLE - Demoraria cerca de 100 segundos).
- Merge/Quick Sort (\(O(N \log_2 N)\)):
- Para \(N = 100.000\) (\(10^5\)): \(10^5 \times 17 \approx 1.7 \times 10^6\) operações. (Passa com folga - Gasta \(\approx 0.01s\)).
- Para \(N = 1.000.000\) (\(10^6\)): \(10^6 \times 20 \approx 2 \times 10^7\) operações. (Passa com folga - Gasta \(\approx 0.2s\)).
6. Complexidade de Espaço
Não esqueça da memória! O veredito “Memory Limit Exceeded” (MLE) também é um erro comum em problemas competitivos. - \(O(1)\): Usa algumas variáveis auxiliares (int, double). - \(O(N)\): Usa um vetor auxiliar do tamanho exato da entrada. - \(O(N^2)\): Cria uma matriz \(N \times N\).
Qual o limite prático para alocação no Java? O tipo int ocupa 4 bytes em Java. Portanto: - 1 MB é capaz de armazenar cerca de \(\approx 250.000\) inteiros. - 512 MB serviria idealmente para alocar até \(128\) milhões de inteiros (\(\approx 1.28 \times 10^8\)).
Atenção: A JVM usa muito overhead arquitetural, objetos como
Integergastam bem mais memória e o Garbage Collector precisa de margem. Na prática, alocarint[100_000_000]pode acionar rapidamente o limite dependendo do juiz da competição. Uma matriz \(10.000 \times 10.000\) suga rapidamente 400 MB, estourando o teto típico de 256MB de muitos juízes.
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 gigantes de DP estouram a Heap).
7. Gargalos de Entrada e Saída (I/O)
Mesmo que sua complexidade de tempo seja excelente (\(O(N)\)), seu código pode tomar TLE simplesmente pela lentidão na leitura/escrita de dados massivos na tela.
O Problema no Java: - A classe Scanner é muito lenta porque faz parsing robusto usando Expressões Regulares por baixo dos panos. - System.out.println descarrega o buffer na tela a cada chamada, o que é custoso e trava a thread da JVM.
Quando me preocupar (Regra de Ouro dos \(10^5\)): - Se o problema exige ler/imprimir \(10^5\) ou mais números/linhas. - Substitua Scanner por BufferedReader e StringTokenizer (combinação também chamada de “Fast I/O”). - Substitua chamadas a println() em loops grandes por concatenações de StringBuilder ou utilize o fluxo otimizado de um PrintWriter.
8. Análise Amortizada
Às vezes, uma operação pode ser lenta no pior caso, mas, quando avaliada ao longo de MUITAS execuções, o custo médio (amortizado) se dilui e fica excelente.
O Clássico ArrayList.add()
Quando você insere num ArrayList em Java, a complexidade geralmente é \(O(1)\).
Porém, quando o array interno enche, o Java precisa: 1. Criar um novo array interno maior (geralmente +50% em Java). 2. Copiar todos os \(N\) elementos para o novo vetor (custo pesado e imediato de \(O(N)\)).
Logo, no pior caso o add() é \(O(N)\). No entanto, como o aumento de tamanho acontece de fato exponencialmente espaçado, o custo “caro” das cópias se dilui ao longo das muitas inserções \(O(1)\) acumuladas que antecederam aquilo.
Conclusão: Dizemos formalmente que a inserção sistemática no
ArrayListtem um custo de desempenho excelente de \(O(1)\) amortizado.