trilha

Fundamentos de Programação/01 - Fundamentos Duros/Semana 02 - Estruturas de Dados I8 min

Notação Big-O

Perguntas-guia
  • O que Big-O mede — tempo, operações, ou crescimento?
  • Por que O(2n) e O(n) são a mesma coisa, e quando a constante importa na prática?
  • Qual a diferença entre pior caso, caso médio e amortizado?
  • Você consegue estimar a complexidade de uma função sua sem contar linha por linha?

Conceito

Big-O não mede tempo e não conta operações. Mede como o custo cresce quando a entrada cresce — é uma cota superior assintótica sobre a taxa de crescimento.

Por que assintótico: constantes e termos menores dependem de hardware, compilador, cache e clima. O que sobrevive a todas essas variações é a forma da curva. Big-O isola o que é do algoritmo do que é da máquina.

O(2n) e O(n) são a mesma classe porque a definição descarta constante multiplicativa — de propósito. A constante volta a importar em dois casos: quando n é pequeno (quase sempre, na prática) e quando a constante é absurda (existem algoritmos de multiplicação de matrizes com expoente melhor e constante que os torna inúteis para qualquer n real).

As três medidas, e a diferença que importa:

Medida O que é Vale como garantia?
Pior caso cota para qualquer entrada sim — é o que você promete
Caso médio média sobre uma distribuição suposta de entradas não — a suposição costuma ser falsa
Amortizado média sobre uma sequência de operações sim — é garantia, não aposta

O termo "amortizado" vem da contabilidade: amortizar é distribuir um custo concentrado ao longo de um período. Você compra uma máquina de R$ 12.000 que dura 12 meses e lança R$ 1.000/mês — o gasto foi real e concentrado, mas diluído no período é constante.

append é idêntico: copiar 8 elementos no append #8 foi real e caro, e só aconteceu porque os appends #4 a #7 foram de graça. Diluído entre eles, o custo é constante.

A confusão entre "médio" e "amortizado" é a mais comum, e a diferença é a natureza da promessa:

  • caso médio é uma aposta na distribuição da entrada — um adversário pode escolher a entrada que quebra a suposição
  • amortizado não supõe nada — não existe sequência de entrada que faça N appends custarem mais que O(N), porque a conta já somou todas as operações caras

O par de comparação: m[k] num mapa é O(1) esperado (caso médio), e é exatamente por isso que Go randomiza a semente do hash — para impedir que alguém escolha chaves colidentes e destrua a suposição (7. Tratamento de Colisão). append é O(1) amortizado, e nenhuma entrada quebra isso.

E "constante" não quer dizer "sempre o mesmo número", quer dizer "não cresce com n":

n appends cópias por append
10 1,20
1.000 1,87
100.000 4,02
10.000.000 4,92

n cresceu um milhão de vezes; o custo por operação convergiu para ~5. É isso.

O que Big-O esconde: memória, localidade de cache, I/O, e a constante. Um O(n log n) que percorre memória sequencialmente frequentemente vence um O(n) que salta aleatoriamente — porque um cache miss custa duas ordens de magnitude mais que uma comparação.

O é cota superior, Ω inferior, Θ as duas. Dizer "busca linear é O(n²)" é tecnicamente verdadeiro e completamente inútil — por isso na prática todo mundo diz O querendo dizer Θ.

Em Go

Go te dá medição empírica de primeira classe, o que é o antídoto certo para a teoria — go test -bench . -benchmem:

func BenchmarkBuscaLinear(b *testing.B) {
	for i := 0; i < b.N; i++ { buscaLinear(dados, alvo) }
}

b.N é ajustado pelo runtime até a medição estabilizar. -benchmem acrescenta allocs/op, que frequentemente explica mais que a complexidade.

Complexidade das operações da stdlib que vale ter memorizada:

Operação Custo
m[k], m[k]=v, delete(m,k) O(1) esperado
append(s, v) O(1) amortizado
s[i] O(1)
sort.Slice, slices.Sort O(n log n) — pdqsort desde Go 1.19
slices.Contains O(n)
slices.BinarySearch O(log n), exige ordenado
copy(dst, src) O(n), mas memmove — constante mínima

A constante que faz append ser amortizado O(1) em Go: até 256 elementos a capacidade dobra; acima disso o incremento é cap/4 + 192 elementos, depois arredondado para a classe de tamanho do alocador (Go 1.18+). Esse +192 importa: o fator só converge para 1,25× em capacidades grandes.

cap antes fator observado
300 2,03×
1.000 1,54×
10.000 1,33×
1.000.000 1,250×

Por que geométrico dá O(1) amortizado

A contradição aparente: um append pode custar O(n) (realocar e copiar tudo). Então como ele é O(1)?

Porque o caro fica raro na mesma velocidade em que fica caro. Com fator 2, as realocações ocorrem em cap = 1, 2, 4, 8, … e cada uma copia o cap anterior:

1 + 2 + 4 + 8 + ... + n/2  ≈  n

Série geométrica: o custo dobra, a frequência cai pela metade, e os dois se cancelam. Você paga ~1 cópia por elemento — distribuída de forma desigual, limitada no total.

Medido, contando cada elemento copiado em 100.000 appends:

Política Realocações Cópias totais Cópias/append
dobra (2×) 18 131.071 1,31
1,5× 30 276.521 2,77
1,25× 52 482.072 4,82
1,01× 852 10.111.229 101,11
cap+1 (aditivo) 100.000 4.999.950.000 49.999,50
Go real 26 402.076 4,02

A última linha do meio é a que prova a tese: aditivo copia 1+2+...+n = n²/2, ou seja n/2 por append — O(n) amortizado, 5 bilhões de cópias em vez de 130 mil.

O número de cópias por append fica entre 1/(k-1) e k/(k-1). Confira: k=1,01 prevê ~100, mediu 101,11.

E o fator é um botão entre cópia e memória desperdiçada. Logo após crescer para cap = k·n com n elementos, o desperdício é (k-1)/k:

Fator Cópias/append Memória parada
~1 a 2 até 50%
1,25× ~4 a 5 até 20%

É por isso que Go muda de política em 256: 50% de desperdício em 100 elementos são 400 bytes — irrelevante, então vale dobrar e copiar menos. 50% em 10 milhões são 40 MB de RAM parada. Go troca cópia por memória exatamente onde a memória passa a importar.

O +192 é um piso. Sem ele, em cap=257 o incremento seria 64 elementos e você realocaria quase de imediato. É também o que explica o fator ~2× observado perto de cap 300: ali o +192 é 64% do próprio cap. Ele só se dilui, e o fator só converge para 1,25×, quando cap ≫ 192.

Somando o arredondamento para classe de tamanho do alocador, o Go real (26 realocações) sai melhor que 1,25× puro (52) — a capacidade extra do arredondamento vem de graça.

A consequência prática: toda essa maquinaria existe para quando você não sabe o tamanho final. Quando sabe, make([]T, 0, n) elimina a conta inteira — zero realocações, zero cópias. Não é economizar 20%: é remover o problema.

Respostas às perguntas-guia

1. O que Big-O mede — tempo, operações, ou crescimento?

Conceito: crescimento. É uma afirmação sobre a função que relaciona tamanho da entrada e custo, não sobre um número.

Em Go: para o número você usa go test -bench; Big-O te diz se vale a pena medir.

2. Por que O(2n) e O(n) são a mesma coisa, e quando a constante importa na prática?

Conceito: a definição descarta constantes porque elas são propriedade da máquina, não do algoritmo. Importa quando n é pequeno ou a constante é gigante.

Em Go, a constante aparece principalmente como alocação: um algoritmo O(n) que aloca por elemento perde de um O(n log n) que não aloca. allocs/op no benchmark é onde isso fica visível.

3. Qual a diferença entre pior caso, caso médio e amortizado?

Conceito: pior caso é garantia por operação; amortizado é garantia por sequência; caso médio é uma aposta na distribuição da entrada.

Em Go: append e o map são amortizados. append individual pode realocar e copiar tudo; a sequência de N appends custa O(N).

4. Você consegue estimar a complexidade de uma função sua sem contar linha por linha?

Conceito: o atalho é olhar estrutura de repetição e o que ela percorre: laços aninhados sobre a mesma coleção → n²; divisão do problema pela metade → log n; laço que percorre uma vez → n; laço com trabalho log n dentro → n log n.

Em Go, a armadilha é que a chamada esconde o laço: slices.Contains dentro de um for te dá O(n²) sem nenhum laço aninhado visível. É o bug de performance mais comum — e a correção quase sempre é trocar por map.

Trade-offs

Do conceito:

  • Análise assintótica é portátil entre máquinas e inútil para escolher entre dois O(n). Aí só medição resolve.
  • Otimizar a classe de complexidade é a única otimização que escala; otimizar a constante é a única que ajuda quando n já é pequeno.
  • Pior caso é honesto e pessimista; caso médio é otimista e frágil.

Em Go: benchmark é fácil e por isso a tentação é medir sem pensar. Medir um caso de tamanho errado te dá a resposta certa para a pergunta errada — varie o n.

Exemplo prático

O cruzamento entre O(n) e O(1), que é o argumento inteiro da semana:

package main

import (
	"fmt"
	"slices"
	"time"
)

func main() {
	for _, n := range []int{100, 10_000, 1_000_000} {
		dados := make([]int, n)
		for i := range dados {
			dados[i] = i
		}
		indice := make(map[int]bool, n)
		for _, v := range dados {
			indice[v] = true
		}
		alvo := n - 1 // pior caso da busca linear

		t := time.Now()
		for i := 0; i < 1000; i++ {
			_ = slices.Contains(dados, alvo) // O(n)
		}
		linear := time.Since(t)

		t = time.Now()
		for i := 0; i < 1000; i++ {
			_ = indice[alvo] // O(1)
		}
		hash := time.Since(t)

		fmt.Printf("n=%-9d linear=%-12v hash=%-10v razao=%.0fx\n",
			n, linear, hash, float64(linear)/float64(hash))
	}
}

Saída real desta máquina:

n=100       linear=74.973µs     hash=14.166µs   razao=5x
n=10000     linear=6.328854ms   hash=17.172µs   razao=369x
n=1000000   linear=578.082725ms hash=16.123µs   razao=35855x

Repare nas duas colunas. O tempo do hash é praticamente constante — é o O(1). O da busca linear acompanha n — é o O(n). E a razão cresce sem limite: é isso que a notação está dizendo. Com n=100 o tempo absoluto é irrelevante nos dois casos; a mesma linha de código passa de 75µs para meio segundo sem ninguém ter mudado nada além do tamanho dos dados.

Relacionado


Parte de Semana 02 - Estruturas de Dados I · 00 - MOC Fundamentos de Programação

Buscar

Busca por título, seção e texto das notas