Entendendo Big O Notation

Recentemente eu estava praticando alguns exercícios no site LeetCode e um detalhe nas instruções chamou minha atenção: "A solução deve ter complexidade de tempo O(1), O(n), O(log n) para ser considerada correta".

No dia-a-dia nos preocupamos apenas em resolver um problema mas raramente pensamos na complexidade e performance do algoritmo.

Um loop que fazemos num array leva milisegundos para ser executado, isso é quase imperceptível. Mas conforme criamos grandes aplicações, com grande quantidade de dados, isso pode se tornar um gargalo de performance.

Pra isso usamos a Big O Notation para medir a performance de um algoritmo.

Big O Graph

Big O Graph

A Notação Big O é, essencialmente, a ferramenta que usamos para medir a performance de um algoritmo. Ela não conta o tempo em segundos, mas sim o crescimento do número de operações à medida que o volume de dados de entrada (n) aumenta.

Em suma: ela responde à pergunta: "O quanto meu código vai ficar lento se a minha base de usuários ou de dados crescer 1000x?".

O melhor é manter nosso código com a complexidade O(1) (Busca por index), O(log n) (Busca binária) ou O(n) (Busca linear), nessa ordem. Vamos entender cada uma delas.

1. Notação O(1) - Complexidade Constante

Não importa se o seu array tem 1 item ou 1 milhão de itens, o tempo de execução será rigorosamente o mesmo, O(1).

Um exemplo clássico é acessar um elemento diretamente pelo seu índice em um array (O(1)) ou buscar uma chave em um objeto (O(1)).

function buscarPrimeiroElemento(arr) {
  return arr[0]; // Sempre faz apenas 1 operação
}

const frutas = ['maçã', 'banana', 'laranja', 'morango'];
console.log(buscarPrimeiroElemento(fritas)); // 'maçã'

2. Notação O(log n) - Complexidade Logarítmica

A complexidade logarítmica parece assustadora pelo nome, mas a lógica é linda: a cada passo que o algoritmo dá, ele divide o problema pela metade. O exemplo perfeito aqui é a Busca Binária (Binary Search).

Imagine que você está procurando uma palavra em um dicionário físico; você não lê página por página, você abre no meio e descarta a metade que não interessa. O mesmo vale para a busca binária.

Nota importante: para a busca binária funcionar, a lista obrigatoriamente precisa estar ordenada.

function buscaBinaria(arr, alvo) {
  let inicio = 0;
  let fim = arr.length - 1;

  while (inicio <= fim) {
    let meio = Math.floor((inicio + fim) / 2);

    if (arr[meio] === alvo) return meio; // Achou!

    if (arr[meio] < alvo) {
      inicio = meio + 1; // Descarta a metade esquerda
    } else {
      fim = meio - 1; // Descarta a metade direita
    }
  }
  return -1; // Não encontrou
}

const numerosOrdenados = [10, 20, 30, 40, 50, 60, 70, 80];
console.log(buscaBinaria(numerosOrdenados, 60)); // Índice 5

A cada loop ele chega mais perto do alvo, o que faz o algoritmo percorrer menos itens. Por isso a complexidade é O(log n).

3. Notação O(n) - Complexidade Linear

Aqui, o tempo de execução cresce na mesma proporção que os dados de entrada. Se você tem 10 itens, faz 10 operações. Se tem 10.000 itens, faz 10.000 operações.

O caso mais comum do nosso dia a dia é o bom e velho loop (for, forEach, map, find) varrendo um array para buscar ou transformar dados.

function contemItem(arr, alvo) {
  return arr.find(item => item === alvo);
}

const usuarios = ['Sérgio', 'Ana', 'Beatriz'];
console.log(contemItem(usuarios, 'Ana')); // Executa proporcionalmente ao tamanho do array

4. Notação O(n log n) - Complexidade Linear-Logarítmica

Essa complexidade geralmente aparece em algoritmos de ordenação eficientes, como o Merge Sort ou Quick Sort. O JavaScript usa essa abordagem por baixo dos panos no método nativo Array.prototype.sort().

A lógica consiste em dividir o array consecutivamente pela metade (log n) e, depois, passar juntando e ordenando os elementos (n).

// Exemplo conceitual usando o método nativo de ordenação do JS
function ordenarLista(arr) {
  return arr.sort((a, b) => a - b); // O motor do V8 otimiza isso para O(n log n)
}

const numerosBagunçados = [5, 1, 4, 2, 8];
console.log(ordenarLista(numerosBagunçados)); // [1, 2, 4, 5, 8]

5. Notação O(n²) - Complexidade Quadrática

A complexidade quadrática acontece quando o tempo de execução cresce ao quadrado do número de elementos.

O gatilho clássico para um código virar O(n²) é ter um loop dentro de outro loop (nested loops). Para cada item do array de fora, você percorre o array de dentro inteiro novamente. Se a sua lista crescer um pouco, a sua aplicação vai engasgar.

function encontrarDuplicados(arr) {
  let duplicados = [];
  for (let i = 0; i < arr.length; i++) {
    for (let j = i + 1; j < arr.length; j++) {
      if (arr[i] === arr[j] && !duplicados.includes(arr[i])) {
        duplicados.push(arr[i]);
      }
    }
  }
  return duplicados;
}

console.log(encontrarDuplicados([1, 2, 3, 2, 4, 1])); // [1, 2]

6. Notação O(2^n) - Complexidade Exponencial

No crescimento exponencial, a cada novo elemento adicionado na entrada, o esforço do algoritmo dobra. Um exemplo muito conhecido disso é a implementação recursiva ingênua para calcular a sequência de Fibonacci. Como a função chama a si mesma duas vezes para cada nível, ela gera uma árvore de chamadas gigantesca e redundante.

function fibonacciRecursivo(n) {
  if (n <= 1) return n;

  // Duas chamadas recursivas para cada execução. Evite isso para números grandes!
  return fibonacciRecursivo(n - 1) + fibonacciRecursivo(n - 2);
}

console.log(fibonacciRecursivo(6)); // 8

Combinando complexidades

As vezes podemos ver uma função que combina duas complexidades, como O(n) + O(1).

function minhaFuncao(arr) {
  arr[0]; // O(1)
  return arr.map(...); // O(n)
}

Nesse caso a complexidade predominante é O(n), pois ele leva mais tempo para executar o map do que o arr[0].

Lembre-se: a complexidade de maior curva sempre será a predominante.

Soma de complexidades

Podemos ter uma função com N loops, por exemplo O(n) + O(n) + O(n). Você pode pensar que a complexidade é O(3n), mas nesse caso ignoramos a constante e consideramos apenas O(n).

function minhaFuncao(arr) {
  // O(n)
  for (let i = 0; i < arr.length; i++) {
    console.log(arr[i]);
  }

  // O(n)
  for (let i = 0; i < arr.length; i++) {
    console.log(arr[i]);
  }

  // O(n)
  for (let i = 0; i < arr.length; i++) {
    console.log(arr[i]);
  }
}

Complexidade de Tempo X Espaço

Além de medir o tempo de execução, também usamos Big O Notation para analisar a complexidade de espaço, que representa a quantidade de memória adicional que um algoritmo precisa em relação ao tamanho da entrada.

function soma(arr) {
  let total = 0;

  for (const num of arr) {
    total += num;
  }

  return total;
}

Embora o tamanho do array de entrada possa crescer, o algoritmo continua utilizando apenas uma quantidade fixa de memória adicional. Por isso, sua complexidade de espaço é O(1). Já sua complexidade de tempo é O(n), pois cada elemento do array precisa ser visitado uma vez.

Conclusão

Saber Big O Notation é essencial para criar algoritmos eficientes e escaláveis em grandes aplicações e encontrar gargalos de performance que podem ser melhorados.

Muitas empresas grandes perguntam sobre Big O Notation nas entrevistas técnicas e isso pode ser um diferencial para você.

Nos próximos posts, vamos começar a destrinchar padrões de algoritmos e estruturas de dados como Two Pointers, Sliding Window e HashMaps.

Sabendo o Big O de cada um deles, você vai entender perfeitamente o motivo de escolher uma abordagem em detrimento de outra.