Big O Notation: Explicação completa.
Introdução
Tempo e espaço são relativos. O mesmo algoritmo pode demorar 2s em um servidor e em outro 500ms. Isso pode acontecer a performance de um algoritmo depende muito do hardware que está sendo rodado.
Com isso posto, como podemos "medir" um algoritmo já que o tempo é relativo ao hardware em alguma medida?
Para resolver isso tempos o famoso: Big O Notation. Na pergunta eu coloquei a palavra medir entre aspas, pois na verdade o Big O não mede nada. Na prática o que fazemos é classificar, é dizer como esse algoritmo se comporta em relação ao tempo e espaço.
Deixa eu exemplificar para ficar claro. Em um cenário na qual precisamos ordenar um array na cada de dezenas, na prática, qualquer algoritmo é suficiente. Mas no cenário que estamos lidando com centenas ou milhares de itens a converda muda. Nesse cenário, o algoritmo que você escolher fazer total diferença. Se você usar um algoritmo como o Bubble Sort, que para ordenar ele faz o que chamamos de "Força Bruta", onde ele percorre e compara repetidamente todos os itens. Esse tipo classificado como O(n²). Já o algoritmo Merge Sort é mais eficiênte pois tem a classificação O(n log n), pois ele tem a filosófia de "dividir para conquistar". E na hierarquia do Big O, um algoritmo com a notação O(n log n) é melhor que o O(n²). E em ambiente com "dados reais", essa é a diferença de um sistema travar e outro não.
Antes de mostrar como classificar um algoritmo, preciso deixar algo claro. No exemplo anterior eu falei sobre escolher o "Melhor" algoritmo. Mas quando estamos analisando com Big O, não estamos olhando necessariamente a sua implementação. Nós queremos saber como ele se comporta no pior cenário. Ou seja, procuramos analisar como esse algoritmo se comporta se os dados começarem a crescer. Lógico que com mais dados o algoritmo provavelmente vai demorar mais, tirando o cenário de algoritmos O(1), mas o que queremos saber é como esse crescimento de tempo acontece. Isso é o que chamamos de Complexidade Temporal.
Pode suar um pouco repetitivo, mas é necessário.
Agora vamos entender como classificar um algoritmo.
Classificando um algoritmo
O primeiro passo é fixar o que chamamos de tamanho da entrada. Em geral usamos a letra n: pode ser o número de elementos de um array, o tamanho de uma string, a quantidade de nós de um grafo, e assim por diante. A ideia do Big O não é dizer "quantos milissegundos" o código vai levar; é dizer como o custo cresce quando n fica grande.
Na prática, quase sempre estamos interessados no pior caso (ou em um caso representativo), porque é ele que nos protege quando os dados são adversários: entradas grandes, já ordenadas, já bagunçadas, etc. Existem também análises de melhor caso e caso médio, mas o pior caso é o ponto de partida mais útil para comparar algoritmos de forma conservadora.
Tempo e espaço
Você pode classificar o algoritmo em relação ao tempo (quantas operações “relevantes” o fluxo faz em função de n) e em relação ao espaço (memória extra além da própria entrada — variáveis, pilhas de recursão, estruturas auxiliares). As duas dimensões importam: um algoritmo pode ser rápido em tempo O(n) mas usar O(n) de memória extra, ou ser O(n log n) no tempo e O(1) extra no espaço, dependendo do problema.
Diferença entre as classificações
O(1) — Tempo Constante
O que significa: não importa se a entrada tem 1 elemento ou 1 bilhão, o algoritmo sempre executa o mesmo número de operações. O custo é fixo.
Por que acontece: porque o código acessa diretamente o que precisa, sem precisar percorrer ou comparar nada. O caminho até o resultado tem comprimento fixo, independentemente de n.
Exemplos típicos:
- Acessar um índice de array:
arr[42]— o endereço de memória é calculado diretamente. - Inserir ou remover o primeiro elemento de uma linked list com ponteiro para o head.
- Verificar se um mapa/hash table contém uma chave (caso médio).
- Empilhar (
push) ou desempilhar (pop) de uma stack.
function primeiroDobrado(arr) {
return arr[0] * 2; // sempre uma operação, não importa o tamanho do array
}
Visualização do crescimento:
| n | operações |
|---|---|
| 1 | 1 |
| 100 | 1 |
| 1.000 | 1 |
| 1.000.000 | 1 |
Resumo: ideal. Se você consegue resolver um problema em O(1), não existe nada melhor.
O(log n) — Tempo Logarítmico
O que é logarítmico?: É o inverso da exponencial. Enquanto a exponencial aumenta a cada interação, igual juros compostos que a cada mês a porentagem do juros é sobre a dívida atual. Em pouco tempo a divida dobra e continua crescendo desenfreadamente. Já o logarítmico é o inverso disso, ele diminui a cada interação.
O que significa: a cada passo, o algoritmo descarta uma fração do problema, geralmente a metade. Isso faz o número de operações crescer de forma extremamente lenta mesmo quando n explode.
Por que acontece: o mecanismo central é a divisão repetida. Se você divide n ao meio sucessivamente, em quantos passos chega a 1? Exatamente log₂ n passos. Com n = 1.000.000, são apenas ~20 passos. Com n = 1.000.000.000, são ~30 passos.
Exemplos típicos:
- Busca Binária em array ordenado.
- Operações em Árvore Binária de Busca balanceada (inserção, remoção, busca).
- Operações em Heap (subir/descer um elemento).
- Algoritmo de exponenciação rápida.
// Busca binária — a cada iteração o espaço de busca cai pela metade
function buscaBinaria(arr, alvo) {
let esq = 0;
let dir = arr.length - 1;
while (esq <= dir) {
const meio = Math.floor((esq + dir) / 2);
if (arr[meio] === alvo) {
return meio;
} else if (arr[meio] < alvo) {
esq = meio + 1; // descarta metade esquerda
} else {
dir = meio - 1; // descarta metade direita
}
}
return -1;
}
Visualização do crescimento:
| n | operações (~log₂ n) |
|---|---|
| 8 | 3 |
| 1.000 | ~10 |
| 1.000.000 | ~20 |
| 1.000.000.000 | ~30 |
Resumo: extremamente eficiente. Qualquer algoritmo que "corta" o problema ao meio em cada passo provavelmente é O(log n).
O(n) — Tempo Linear
O que significa: o número de operações cresce de forma diretamente proporcional ao tamanho da entrada. Dobrou n, dobrou o trabalho.
Por que acontece: porque o algoritmo precisa visitar cada elemento pelo menos uma vez. Não há como escapar: se você precisa olhar para todos, o custo mínimo é proporcional à quantidade.
Exemplos típicos:
- Percorrer um array para encontrar o maior/menor elemento.
- Somar todos os elementos de uma lista.
- Busca linear (quando o array não está ordenado).
- Copiar um array.
- Verificar se uma string é palíndromo.
function maiorElemento(arr) {
let maior = arr[0];
for (const v of arr) { // percorre todos os n elementos
if (v > maior) {
maior = v;
}
}
return maior;
}
Visualização do crescimento:
| n | operações |
|---|---|
| 1 | 1 |
| 100 | 100 |
| 1.000 | 1.000 |
| 1.000.000 | 1.000.000 |
Resumo: é o "custo mínimo" para a maioria dos problemas que exigem processar toda a entrada. É considerado eficiente para volumes grandes.
O(n log n) — Tempo Linearítmico
O que significa: um pouco pior que linear, mas muito melhor que quadrático. É o resultado de fazer uma operação O(log n) para cada um dos n elementos — ou, equivalentemente, dividir o problema em log n níveis com trabalho total O(n) em cada nível.
Por que acontece: é a assinatura de algoritmos que usam a estratégia de dividir para conquistar (divide and conquer). O problema é dividido ao meio recursivamente (gera log n níveis de divisão), e em cada nível é feito trabalho linear para combinar as partes (merge, por exemplo).
Exemplos típicos:
- Merge Sort — divide o array ao meio, ordena cada metade recursivamente e une os resultados.
- Heap Sort — constrói uma heap (O(n)) e extrai o máximo n vezes (O(log n) por extração).
- Quick Sort — no caso médio, as partições ficam balanceadas, resultando em O(n log n).
- FFT (Transformada Rápida de Fourier).
function mergeSort(arr) {
if (arr.length <= 1) return arr;
const meio = Math.floor(arr.length / 2);
const esq = mergeSort(arr.slice(0, meio)); // divide ao meio → log n níveis
const dir = mergeSort(arr.slice(meio));
return merge(esq, dir); // une em O(n) — trabalho linear por nível
}
function merge(esq, dir) {
const resultado = [];
let i = 0, j = 0;
while (i < esq.length && j < dir.length) {
if (esq[i] <= dir[j]) {
resultado.push(esq[i++]);
} else {
resultado.push(dir[j++]);
}
}
return resultado.concat(esq.slice(i)).concat(dir.slice(j));
}
Visualização do crescimento:
| n | operações (~n log₂ n) |
|---|---|
| 8 | ~24 |
| 1.000 | ~10.000 |
| 1.000.000 | ~20.000.000 |
Resumo: é a classe dos melhores algoritmos de ordenação por comparação. Matematicamente provado que não é possível ordenar por comparação em menos que O(n log n) no caso geral.
O(n²) — Tempo Quadrático
O que significa: para cada elemento, o algoritmo faz trabalho proporcional ao tamanho total da entrada. Ou seja, dois laços aninhados, cada um indo até n.
Por que acontece: geralmente porque o algoritmo compara ou processa cada par de elementos. O número de pares possíveis em n elementos é n(n-1)/2, que é O(n²).
Exemplos típicos:
- Bubble Sort, Insertion Sort, Selection Sort — comparam pares repetidamente.
- Verificar se há duplicatas em um array (abordagem ingênua com dois laços).
- Multiplicação de matrizes (abordagem ingênua é O(n³), mas o produto de um vetor por uma matriz é O(n²)).
- Encontrar todos os pares com soma igual a um alvo (abordagem força bruta).
// Bubble Sort — dois laços aninhados: O(n²)
function bubbleSort(arr) {
const n = arr.length;
for (let i = 0; i < n; i++) { // laço externo: n vezes
for (let j = 0; j < n - i - 1; j++) { // laço interno: até n vezes
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}
}
}
}
Visualização do crescimento:
| n | operações (~n²) |
|---|---|
| 10 | 100 |
| 100 | 10.000 |
| 1.000 | 1.000.000 |
| 10.000 | 100.000.000 |
Resumo: aceitável para entradas pequenas (até alguns milhares), mas começa a travar com volumes maiores. Sempre que ver dois laços aninhados, suspeite de O(n²).
O(n³) — Tempo Cúbico
O que significa: três laços aninhados, cada um proporcional a n. O trabalho cresce ao cubo.
Por que acontece: quando o problema envolve triplas de elementos ou a combinação de dois subproblemas quadráticos. Cada elemento do outer loop dispara um bloco O(n²) dentro.
Exemplos típicos:
- Multiplicação de matrizes (algoritmo clássico): para calcular cada célula da matriz resultado, você percorre uma linha inteira e uma coluna inteira.
- Floyd-Warshall para menor caminho entre todos os pares de vértices em um grafo de n nós.
- Verificar se há uma tripla (i, j, k) tal que
arr[i] + arr[j] + arr[k] = 0(abordagem força bruta).
// Multiplicação de matrizes n×n — O(n³)
function multiplicarMatrizes(A, B) {
const n = A.length;
const C = Array.from({ length: n }, () => new Array(n).fill(0));
for (let i = 0; i < n; i++) { // laço 1
for (let j = 0; j < n; j++) { // laço 2
for (let k = 0; k < n; k++) { // laço 3
C[i][j] += A[i][k] * B[k][j];
}
}
}
return C;
}
Visualização do crescimento:
| n | operações (~n³) |
|---|---|
| 10 | 1.000 |
| 100 | 1.000.000 |
| 500 | 125.000.000 |
| 1.000 | 1.000.000.000 |
Resumo: já é considerado lento para n acima de alguns centenas. Quando vê três laços aninhados, é sinal de que provavelmente há uma abordagem melhor. Para multiplicação de matrizes, existem algoritmos mais eficientes como o Algoritmo de Strassen, que chega a O(n^2.81).
O(2ⁿ) — Tempo Exponencial
O que significa: a cada novo elemento adicionado à entrada, o número de operações dobra. O crescimento é catastrófico — com n = 50 já estamos falando de mais de 1 quadrilhão de operações.
Por que acontece: acontece em algoritmos que exploram todas as combinações possíveis de um conjunto, ou em recursões que se ramificam em 2 em cada nível sem reaproveitar resultados já calculados. Cada decisão binária (incluir ou não incluir um elemento) dobra o espaço de busca.
Exemplos típicos:
- Fibonacci recursivo ingênuo — cada chamada gera duas novas chamadas, criando uma árvore de chamadas que cresce exponencialmente.
- Problema da Mochila (força bruta) — para cada item, você decide incluir ou não.
- Subconjuntos de um conjunto — um conjunto de n elementos tem 2ⁿ subconjuntos.
- Alguns problemas de backtracking sem poda eficiente.
// Fibonacci recursivo — O(2ⁿ)
// fib(5) chama fib(4) e fib(3)
// fib(4) chama fib(3) e fib(2) ← fib(3) calculado duas vezes!
// ... e isso se repete exponencialmente
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2); // duas chamadas recursivas por nível
}
// A versão com memoization reduz para O(n):
function fibMemo(n, memo = {}) {
if (n <= 1) return n;
if (n in memo) return memo[n]; // reusa o que já foi calculado
memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
return memo[n];
}
Visualização do crescimento:
| n | operações (~2n) |
|---|---|
| 10 | 1.024 |
| 20 | ~1 milhão |
| 30 | ~1 bilhão |
| 50 | ~1 quadrilhão |
| 64 | ~18 quintilhões |
Resumo: inviável para qualquer n moderado. A solução quase sempre envolve memoization, programação dinâmica ou reformulação do problema para evitar recalcular subproblemas.
O(n!) — Tempo Fatorial
O que significa: o número de operações cresce de acordo com o fatorial de n. É o pior crescimento assintótico que existe na prática. 10! = 3.628.800. 20! ≈ 2,4 × 10¹⁸. 30! já ultrapassa o número de átomos no universo observável.
Por que acontece: quando o algoritmo precisa explorar todas as permutações possíveis de uma entrada. O número de maneiras de ordenar n elementos é exatamente n! — para cada posição, escolhemos dentre os elementos restantes, e o produto de todas as escolhas é n × (n-1) × (n-2) × … × 1.
Exemplos típicos:
- Problema do Caixeiro Viajante (TSP) por força bruta — encontrar o caminho mais curto que visita n cidades exatamente uma vez: é necessário testar todas as n! rotas possíveis.
- Geração de todas as permutações de uma sequência.
- Quebra de senha por permutação — testar todas as combinações de caracteres em ordem.
// Gera todas as permutações de um array — O(n!)
function permutacoes(arr, inicio = 0, resultado = []) {
if (inicio === arr.length) {
resultado.push([...arr]); // copia o estado atual
return resultado;
}
for (let i = inicio; i < arr.length; i++) {
[arr[inicio], arr[i]] = [arr[i], arr[inicio]]; // troca
permutacoes(arr, inicio + 1, resultado); // recursão
[arr[inicio], arr[i]] = [arr[i], arr[inicio]]; // desfaz troca
}
return resultado;
}
// permutacoes([1, 2, 3]) gera: [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]
// 3! = 6 permutações
Visualização do crescimento:
| n | n! |
|---|---|
| 5 | 120 |
| 10 | 3.628.800 |
| 15 | 1.307.674.368.000 |
| 20 | ~2,4 × 1018 |
| 25 | ~1,5 × 1025 |
Resumo: completamente impraticável além de entradas triviais (cerca de n ≤ 12). Problemas que exigem O(n!) por força bruta são geralmente NP-difíceis, e a solução real usa heurísticas, aproximações ou programação dinâmica para torná-los tratáveis.
Como enxergar a ordem na prática
Alguns atalhos mentais ajudam muito:
- Um único laço que percorre toda a entrada costuma indicar tempo linear, O(n).
- Dois laços aninhados, cada um indo até n, costumam levar a algo da ordem de n × n, ou seja quadrático, O(n²) — típico de comparações repetidas entre todos os pares.
- Dividir o problema ao meio repetidamente (e fazer trabalho constante ou linear em cada etapa) costuma aparecer como logarítmico ou linearítmico, O(log n) ou O(n log n).
- Recursão que resolve o mesmo subproblema muitas vezes sem reaproveitar resultado pode explodir para exponencial, da ordem de O(2ⁿ) ou pior, dependendo da árvore de chamadas.
Constantes multiplicativas (por exemplo, “faço 3n operações”) e termos de ordem menor (algo como n² + 100n) não mudam a classe no Big O quando n cresce: o termo que domina o crescimento é o que fica na notação — nesse exemplo, O(n²).
Com isso em mente, a hierarquia usual — do melhor crescimento assintótico para o pior — aparece na tabela da próxima seção, com nomes e exemplos típicos.
Resumo da hierarquia do Big O
| Notação | Nome | Descrição |
|---|---|---|
O(1) |
Constante | O tempo de execução não muda, independentemente do tamanho de n. Ex: Acessar um índice de um array. |
O(log n) |
Logarítmica | O tempo cresce de forma muito lenta. Comum em algoritmos que dividem o problema ao meio. Ex: Busca Binária. |
O(n) |
Linear | O tempo cresce proporcionalmente ao tamanho da entrada. Ex: Percorrer uma lista simples. |
O(n log n) |
Linearítmica | Um pouco pior que a linear, mas ainda muito eficiente para grandes volumes. Ex: Merge Sort e Quick Sort. |
O(n2) |
Quadrática | O tempo cresce ao quadrado da entrada. Comum em loops aninhados simples. Ex: Bubble Sort. |
O(n3) |
Cúbica | Três loops aninhados. Geralmente sinaliza que o algoritmo precisa de otimização. |
O(2n) |
Exponencial | O tempo dobra a cada novo elemento. Frequentemente visto em recursões sem otimização. Ex: Fibonacci recursivo. |
O(n!) |
Fatorial | O pior cenário possível. O crescimento é astronômico. Ex: Problema do Caixeiro Viajante via força bruta. |
Conclusão
Resuma os pontos principais do artigo. Reforce os conceitos mais importantes e faça uma conexão com o contexto maior (série de artigos, aplicação prática, etc.).