Resumo teórico
Imagine que você tem uma caixa com 10 brinquedos e quer achar o seu carrinho vermelho. Você olha os brinquedos um por um. Agora imagine que a caixa tem 10.000 brinquedos. Demora muito mais, não é? A análise de algoritmos é a ciência de prever quanto mais tempo um programa leva quando a quantidade de dados cresce, antes mesmo de rodá-lo.
Um algoritmouma lista de passos claros que resolve um problema, como uma receita pode ser rápido com 10 itens e dolorosamente lento com um milhão. Mapas no celular, busca de vídeos, lojas online e jogos dependem de programadores que escolhem os passos que crescem devagar. A ferramenta que eles usam para falar disso se chama Big O.
Neste resumo você vai aprender: como descrever o crescimento com Big O e suas primas, como contar os passos de laços, como resolver as equações dos algoritmos recursivos, como provar que um algoritmo está correto, por que alguns custos são melhores do que parecem (análise amortizada), por que a ordenação tem um limite de velocidade, quanta memória um algoritmo precisa, quão rápidas são as estruturas de dados comuns e do que trata a famosa pergunta P versus NP. No final, você estará pronto para o quiz.
O panorama: o que medimos?
Quando você pergunta 'quão rápido é este programa?', a resposta em segundos não ajuda muito. Um computador rápido e um lento dão segundos diferentes para o mesmo programa. Então contamos algo que não muda: o número de passos básicos (uma comparação, uma soma, a leitura de uma célula de memória) em função do tamanho da entrada, que chamamos de n.
Dois tipos de custo
| Custo | Pergunta que responde | Exemplo |
|---|---|---|
| Tempo | Quantos passos básicos são necessários? | Olhar cada item de uma lista: cerca de n passos |
| Espaço | Quanta memória extra é necessária? | Fazer uma cópia da lista: cerca de n células extras |
Palavras que você vai encontrar
- Tamanho da entrada (n): quantos dados o algoritmo recebe, por exemplo o número de itens de uma lista.
- [[Assintótico|o que acontece quando n fica muito, muito grande]]: olhamos o quadro geral para entradas enormes e ignoramos pequenos detalhes.
- Ordem de crescimento: o 'formato' do crescimento, como 'linha reta' ou 'curva que dispara'.
- Pior, melhor e caso médio: o custo para a entrada mais azarada, a mais sortuda e uma entrada típica.
- Limite inferior: um limite que diz 'nenhum algoritmo consegue fazer melhor do que isto'.
Crescimento de funções e sua ordenação
Algoritmos diferentes crescem de maneiras diferentes. Alguns mal percebem uma entrada maior. Outros explodem. Para compará-los usamos uma pequena família de 'velocidades', da mais suave à mais selvagem.
| Nome | Escrita | Imagem do dia a dia |
|---|---|---|
| Constante | 1 | Abrir a primeira página de um livro: não importa a espessura do livro |
| Logarítmica | log n | Achar uma palavra no dicionário abrindo no meio e dividindo a busca ao meio várias vezes |
| Raiz quadrada | √n | Crescimento lento, mas mais rápido que log n |
| Linear | n | Ler cada página uma vez |
| Linearítmica | n log n | Ordenar um baralho de um jeito esperto |
| Quadrática | n² | Cada aluno cumprimentando todos os outros alunos |
| Cúbica | n³ | Três laços aninhados |
| Exponencial | 2ⁿ | Cada novo item dobra o trabalho |
| Fatorial | n! | Tentar todas as ordens possíveis de n itens |
A ordem é: 1 < log n < √n < n < n log n < n² < n³ < 2ⁿ < n! < nⁿ. Um 'logaritmolog2 n responde: quantas vezes posso cortar n ao meio até sobrar só 1?' cresce muito devagar: log₂ 1.024 é só 10, e log₂ 1.000.000 é apenas cerca de 20.
- 1
- 1 passos
- log n
- 4 passos
- n
- 16 passos
- n log n
- 64 passos
- n²
- 256 passos
- 2ⁿ
- 65,536 passos
O que acontece quando a entrada dobra?
| Crescimento | Se n dobra, o trabalho... |
|---|---|
| log n | cresce apenas 1 passo extra |
| n | dobra (x2) |
| n² | fica 4 vezes maior (x4) |
| n³ | fica 8 vezes maior (x8) |
| 2ⁿ | vira o quadrado: nem de longe é administrável |
Dois fatos úteis para comparar. Primeiro, o crescimento polinomial (uma potência de n, como n² ou n^1,5) sempre perde para o exponencial (2ⁿ) quando n é grande. Segundo, uma potência de log n, até (log n)¹⁰, sempre perde para uma pequena potência de n como √n quando n é grande. Assim, n log n é menor que n^1,5, porque log n cresce mais devagar que √n. E n / log n é menor que n, mas maior que √n.
Um computador mais rápido ajuda menos do que você pensa. Se um algoritmo faz n² operações e hoje resolve n = 1.000 em um segundo, uma máquina 100 vezes mais rápida resolve só n = 10.000 nesse segundo (porque 10.000² = 100 × 1.000²). Esse mesmo ganho em um algoritmo 2ⁿ soma apenas cerca de 7 ao n.
↑ Voltar ao sumárioNotação assintótica: O, Ω, Θ, o, ω
Suponha que um algoritmo faça exatamente 3n² + 10n + 5 passos. Para um n enorme, o 10n e o 5 são minúsculos perto de 3n², e o 3 depende só do computador. Então dizemos que o algoritmo é 'da ordem de n²'. As notações abaixo dão uma maneira precisa de dizer isso.
| Notação | Lê-se | Como comparar números | Significado |
|---|---|---|---|
| f = O(g) | O grande | f ≤ g | f cresce no máximo tão rápido quanto g (limite superior) |
| f = Ω(g) | ômega grande | f ≥ g | f cresce pelo menos tão rápido quanto g (limite inferior) |
| f = Θ(g) | teta | f = g | f cresce na mesma taxa que g (limite justo) |
| f = o(g) | o pequeno | f < g | f cresce estritamente mais devagar que g |
| f = ω(g) | ômega pequeno | f > g | f cresce estritamente mais rápido que g |
A definição formal (para funções não negativas)
f(n) = O(g(n)) significa: existem constantes c > 0 e n0 tais que f(n) ≤ c · g(n) para todo n ≥ n0. Em palavras: a partir de certo ponto, f nunca passa de um múltiplo constante de g. Ω é o mesmo com ≥. Θ exige os dois: f = Θ(g) exatamente quando f = O(g) e f = Ω(g).
Exemplo: para mostrar que 100n + 50 = O(n), tome c = 150 e n0 = 1. Para n ≥ 1 temos 100n + 50 ≤ 100n + 50n = 150n. Outro par válido é c = 101 e n0 = 50. Qualquer par que funcione prova a afirmação.
Com limites fica ainda mais fácil. Olhe a razão f(n)/g(n) quando n tende ao infinito. Se tende a 0, então f = o(g). Se tende ao infinito, então f = ω(g). Se tende a uma constante positiva (por exemplo 7), então f = Θ(g).
razão f(n)/g(n) quando n -> infinito
0 -> f = o(g) (f é menor)
constante > 0 -> f = Θ(g) (mesma taxa)
infinito -> f = ω(g) (f é maior)Fatos úteis
- n = O(n²) é verdadeiro. O é apenas um limite superior, não precisa ser justo. É como dizer 'tenho menos de 100 anos'.
- Ω sozinho não diz nada sobre limite superior. 'Isto leva pelo menos n passos' pode ser n ou 1.000.000 n.
- 2^(n+1) = Θ(2ⁿ), porque 2^(n+1) = 2 · 2ⁿ é só uma constante maior. Mas 2^(2n) = 4ⁿ = ω(2ⁿ), porque a razão 2ⁿ tende ao infinito.
- A base do logaritmo não importa dentro de O. log₂ n e log₁₀ n diferem por um fator constante, então escrevemos só O(log n).
- Θ é simétrico: se f = Θ(g), então g = Θ(f). O não é: n = O(n²), mas n² não é O(n).
- log₂(n!) = Θ(n log n). Essa soma de logaritmos é a ordem de crescimento justa do logaritmo de um fatorial.
- Alguns pares de funções não são comparáveis. Se f(n) = n e g(n) vale 1 para n par mas n² para n ímpar, então f não é O(g) nem Ω(g).
Contagem de operações em laços
Para achar o Big O de um programa, contamos quantas vezes sua linha principal executa em função de n. Algumas regras resolvem quase todos os casos.
- Um depois do outro: soma. Dois laços em sequência, cada um com n passos, custam n + n = 2n, que é Θ(n).
- Um dentro do outro: multiplica. Um laço de n passos contendo outro laço de n passos custa n × n = n².
- Descarte constantes e termos menores. n² + n é Θ(n²).
- Um if / else: pegue o pior ramo quando você não sabe qual será usado.
Laços simples e laços aninhados
// PT: Um laço que roda n vezes faz n passos: Θ(n).
for (let i = 0; i < n; i++) work();
// PT: Cada par (i, j) com i < j é visitado uma vez: n(n-1)/2 vezes, logo Θ(n²).
// Para n = 10 são 10 * 9 / 2 = 45 vezes.
for (let i = 0; i < n; i++)
for (let j = i + 1; j < n; j++) work();Contar pares é como contar apertos de mão numa sala com n pessoas: cada par se cumprimenta uma vez, então há n(n-1)/2 apertos. Contar trios i < j < k funciona igual: n(n-1)(n-2)/6 trios. Para n = 6 são 20. Isso é Θ(n³).
Laços que dobram ou andam em passos variáveis
// PT: i dobra: 1, 2, 4, 8... só cerca de log2(n) iterações: Θ(log n).
for (let i = 1; i < n; i *= 2) work();
// PT: O externo dobra i, o interno roda i vezes: 1 + 2 + 4 + ... + n é cerca de 2n: Θ(n).
for (let i = 1; i < n; i *= 2)
for (let j = 0; j < i; j++) work();
// PT: O interno pula de i em i: n/1 + n/2 + n/3 + ... é cerca de n log n: Θ(n log n).
for (let i = 1; i <= n; i++)
for (let j = 0; j < n; j += i) work();O exemplo do meio é uma soma geométrica: cada termo é o dobro do anterior, então a soma toda é cerca do dobro do último termo. O último exemplo é uma soma harmônica: n/1 + n/2 + ... + n/n, que é n vezes cerca de ln n.
O custo depende do valor, não só do tamanho
Um teste de primalidade simples tenta os divisores d = 2, 3, 4... enquanto d × d ≤ n. No pior caso faz cerca de √n divisões, onde n é o valor testado. Parece suave, mas um número com 100 dígitos tem valor em torno de 10¹⁰⁰, então √n é 10⁵⁰: muito além do possível. Medido pelo número de dígitos, o custo é exponencial.
↑ Voltar ao sumárioMelhor caso, pior caso e caso médio
O mesmo algoritmo pode se comportar de modo muito diferente conforme os dados que recebe. Procurando um nome numa lista de n nomes: se for o primeiro, você teve sorte; se for o último (ou não existir), teve azar.
| Caso | O que mede | Busca linear em n itens |
|---|---|---|
| Melhor caso | A entrada mais barata de tamanho n | 1 comparação (o item é o primeiro) |
| Pior caso | A entrada mais cara de tamanho n: uma garantia | n comparações (último item, ou ausente) |
| Caso médio | O custo esperado sobre um conjunto de entradas | (n + 1) / 2 comparações se o item está presente e todas as posições são igualmente prováveis |
O pior caso é o mais usado, porque é uma promessa: 'nunca será mais lento que isto'. O caso médio precisa de uma hipótese extra que o pior caso não precisa: uma distribuição de probabilidade das entradas (por exemplo, 'todas as posições são igualmente prováveis'). Sem essa hipótese, 'média' não significa nada.
Ordenação por inserção
A ordenação por inserção coloca cada carta nova no lugar certo entre as cartas que você já segura. Numa lista já ordenada, cada carta precisa de uma só comparação: melhor caso Θ(n). Numa lista invertida, cada carta passa por todas as anteriores: pior caso Θ(n²).
Quicksort e seu pivô
O quicksort escolhe um pivô, põe os itens menores de um lado e os maiores do outro, e repete em cada lado. Se o pivô é sempre o primeiro elemento, uma lista já ordenada (ou invertida) é o pior caso: o pivô é o menor item, um lado fica vazio e o outro tem n - 1 itens. O trabalho é n + (n-1) + (n-2) + ... = Θ(n²).
Com um pivô aleatório, nenhuma entrada específica é ruim. O azar agora vem da escolha aleatória, não dos dados. O tempo esperado é Θ(n log n) para toda entrada. Pivôs ruins ainda podem acontecer, mas são raros.
lista ordenada, pivô = primeiro elemento:
[1 2 3 4 5] pivô 1 -> esq: [] dir: [2 3 4 5]
[2 3 4 5] pivô 2 -> esq: [] dir: [3 4 5]
[3 4 5] pivô 3 -> esq: [] dir: [4 5]
...
n níveis, cada um com cerca de n de trabalho -> Θ(n²)| Algoritmo | Melhor | Médio | Pior |
|---|---|---|---|
| Ordenação por inserção | Θ(n) | Θ(n²) | Θ(n²) |
| Quicksort (primeiro pivô) | Θ(n log n) | Θ(n log n) | Θ(n²) |
| Merge sort | Θ(n log n) | Θ(n log n) | Θ(n log n) |
| Busca linear | Θ(1) | Θ(n) | Θ(n) |
Recorrências e o teorema mestre
Alguns algoritmos resolvem um problema resolvendo cópias menores do mesmo problema. Isso se chama recursãouma função que chama a si mesma com uma entrada menor. Para medi-los escrevemos uma equação que diz: 'o custo de tamanho n é o custo das partes menores mais o trabalho extra'. Essa equação é uma recorrência.
Escrevendo as recorrências que você precisa
| Algoritmo | Recorrência | Solução |
|---|---|---|
| Busca binária (olha o meio, fica com uma metade) | T(n) = T(n/2) + Θ(1) | Θ(log n) |
| Merge sort (ordena duas metades, intercala em tempo linear) | T(n) = 2T(n/2) + Θ(n) | Θ(n log n) |
| Percorre e chama a recursão com n - 1 itens | T(n) = T(n-1) + n | Θ(n²) |
| Torres de Hanói | T(n) = 2T(n-1) + 1 | Θ(2ⁿ) |
Para T(n) = T(n-1) + n com T(1) = 1, os valores são T(2) = 3, T(3) = 6, T(4) = 10, T(5) = 15. É a soma 1 + 2 + ... + n = n(n+1)/2. Em Hanói, para mover n discos você move n-1 discos, move o maior disco e move os n-1 discos de novo, então o trabalho dobra a cada disco extra: T(n) = 2ⁿ - 1.
O teorema mestre
Muitas recorrências de divisão e conquista têm a forma T(n) = a · T(n/b) + f(n). Aqui a é o número de chamadas recursivas, n/b é o tamanho de cada chamada e f(n) é o trabalho extra fora das chamadas (dividir e juntar). Compare f(n) com o número n^(log_b a), que é quanto trabalho as 'folhas' da recursão fazem.
| Caso | Se... | Então T(n) é... | Quem vence? |
|---|---|---|---|
| 1 | f(n) é polinomialmente menor que n^(log_b a) | Θ(n^(log_b a)) | As folhas |
| 2 | f(n) = Θ(n^(log_b a)) | Θ(n^(log_b a) · log n) | Empate: todo nível custa o mesmo |
| 3 | f(n) é polinomialmente maior que n^(log_b a) (e vale uma condição de regularidade) | Θ(f(n)) | A raiz |
'Polinomialmente' quer dizer por um fator de pelo menos n^ε para algum ε > 0. Exemplos: T(n) = 2T(n/2) + n: aqui n^(log₂ 2) = n e f = n, empate, então Θ(n log n). T(n) = 4T(n/2) + n: n^(log₂ 4) = n² vence n, então Θ(n²). T(n) = T(n/2) + n: n^(log₂ 1) = 1, f = n é maior, então Θ(n). Strassen: T(n) = 7T(n/2) + n²: n^(log₂ 7) é cerca de n^2,81, que vence n², então Θ(n^(log₂ 7)).
T(n) = 2T(n/2) + n
profundidade 0: [ n ] -> n
profundidade 1: [n/2] [n/2] -> n
profundidade 2: [n/4][n/4][n/4][n/4] -> n
...
profundidade i: 2^i nós de tamanho n/2^i -> n
níveis: log2(n) total = n * log2(n)A árvore de recursão mostra o porquê. Na profundidade i há 2^i nós, cada um custando n / 2^i, então cada nível soma n. Com cerca de log n níveis, o total é n log n.
Quando o teorema mestre não se aplica
- T(n) = T(n-1) + n e T(n) = 2T(n-1) + 1: o subproblema é n - 1, não n dividido por algo. Use a árvore de recursão, ou expanda e some.
- T(n) = 2T(n/2) + n log n: f(n) é maior que n, mas não polinomialmente maior, então cai entre os três casos básicos. Uma versão estendida dá Θ(n log² n).
- T(n) = T(n/3) + T(2n/3) + n: as duas chamadas têm tamanhos diferentes. A árvore de recursão tem cerca de log n níveis, cada um custando no máximo n, e a resposta é Θ(n log n).
Provando por substituição
Outro método é chutar a resposta e provar por indução. Cuidado: você precisa provar exatamente a afirmação que chutou. Se você supõe T(n/2) ≤ c · n/2 para T(n) = 2T(n/2) + n, obtém T(n) ≤ cn + n. Isso não é ≤ cn, então a prova de T(n) = O(n) falha (e de fato T(n) é Θ(n log n)). Concluir 'cn + n é O(n)' é um erro clássico, porque a constante c precisa continuar a mesma.
↑ Voltar ao sumárioInvariantes de laço e prova de corretude
Ser rápido não adianta se a resposta está errada. Um invariante de laço é uma frase sobre as variáveis que é verdadeira toda vez que o laço começa uma nova volta. É um jeito de provar que um algoritmo faz a coisa certa.
Os três passos
| Passo | O que você mostra | Como a indução |
|---|---|---|
| Inicialização | O invariante é verdadeiro antes da primeira volta | O caso base |
| Manutenção | Se é verdadeiro antes de uma volta, continua verdadeiro depois dela | O passo indutivo |
| Término | Quando o laço para, o invariante mais a condição de parada dão o resultado que queremos | Usar o resultado no fim (a indução sozinha não tem 'fim') |
// PT: Invariante: quando o teste i < n roda, s é a soma de a[0..i-1].
// Início: i = 0, s = 0 (soma vazia). Manutenção: somar a[i] dá a soma de a[0..i].
// Fim: i = n, então s é a soma do vetor inteiro.
let s = 0;
for (let i = 0; i < a.length; i++) s += a[i];A ordenação por inserção tem um invariante famoso: no início da volta que insere a[j], a parte a[0..j-1] contém os mesmos elementos do começo, em ordem. Quando j chega a n, o vetor inteiro está ordenado.
Um bom invariante é forte o bastante
Para um laço que calcula o máximo com m = a[0], a frase 'm é igual a algum elemento do vetor' é sempre verdadeira, mas é inútil: no fim ela não diz que m é o maior. O invariante certo é 'm é o maior de a[0..i-1]'. No término, i = n e então m é o maior do vetor inteiro.
Correto não é o mesmo que terminar
Um invariante prova que se o laço termina, a resposta está certa. Você também precisa provar que o laço termina. Na busca binária, se lo = mid + 1 for trocado por lo = mid, o invariante 'se x está no vetor, está em a[lo..hi]' continua verdadeiro, mas quando hi = lo + 1 o valor de mid não muda e o laço roda para sempre. Para provar o término, mostre uma quantidade que diminui a cada volta e não pode passar de um limite (aqui, hi - lo).
↑ Voltar ao sumárioAnálise amortizada
Algumas operações são baratas quase sempre e caras de vez em quando. Se olhássemos só a pior operação isolada, seríamos pessimistas demais. A análise amortizada dá o custo médio de uma operação ao longo de uma sequência inteira de operações no pior caso. Não há probabilidade: é uma garantia sobre a sequência.
O vetor dinâmico que dobra
Um vetor dinâmicouma lista que cresce: quando fica cheia, ganha uma casa maior e copia tudo para lá tem uma capacidade. Acrescentar a um vetor cheio é caro porque todos os elementos são copiados para um vetor novo com o dobro da capacidade. Começando com capacidade 1, após 16 inserções as cópias foram 1 + 2 + 4 + 8 = 15 no total (a 17ª inserção copiaria mais 16). A soma das cópias é sempre menor que 2n, então cada inserção custa O(1) amortizado.
capacidade: 1 2 4 8 16
inserção #: 1 2 3 4,5 ... 8,9 ....... 16,17
cópias: - 1 2 4 8 (cresce quando cheio)
total de cópias após 16 inserções = 1 + 2 + 4 + 8 = 15Se, em vez disso, crescermos por uma quantia fixa (digamos +100 posições), as cópias são n + (n-100) + (n-200) + ... que é cerca de n²/200 no total. Isso é Θ(n²) para n inserções, então cada inserção custa O(n) amortizado. Dobrar (crescer por um fator) é o que torna O(1).
O contador binário
Um contador binário inverte bits quando incrementa. O bit mais baixo inverte toda vez, o seguinte a cada 2 incrementos, o seguinte a cada 4... Para um contador de 5 bits incrementado 16 vezes, as inversões são 16 + 8 + 4 + 2 + 1 = 31, menos de 2 por incremento. Então um incremento custa O(1) amortizado, mesmo que um incremento possa inverter todos os bits.
Métodos
- Método agregado: some o custo das n operações e divida por n (o que fizemos acima).
- Método contábil (do banqueiro): cobre um preço fixo de cada operação. As operações baratas são cobradas a mais do que custam, e o extra vai para um banco como crédito. As operações caras são pagas com o crédito. O banco nunca pode ficar negativo.
- Método do potencial: uma versão mais formal da mesma ideia, com uma 'energia potencial' guardada na estrutura de dados.
O limite inferior da ordenação por comparação
Até agora procuramos algoritmos mais rápidos. Agora perguntamos: existe um limite de velocidade que nenhum algoritmo de ordenação consegue vencer? Para algoritmos que ordenam comparando dois itens por vez (como merge sort, quicksort e heap sort), a resposta é sim: Ω(n log n).
A árvore de decisão
Desenhe cada comparação que o algoritmo faz como um ramo: 'a < b?' vai para a esquerda se sim e para a direita se não. Cada caminho da raiz até uma folha é uma execução do algoritmo, e a folha diz qual ordem foi encontrada. Para n itens distintos há n! ordens possíveis, e cada uma precisa aparecer como folha. Então a árvore tem pelo menos n! folhas.
ordenando 3 itens a, b, c (3! = 6 ordens = 6 folhas)
a < b ?
/ \
b < c ? a < c ?
/ \ / \
abc a<c? bac b<c?
/ \ / \
acb cab bca cbaUma árvore binária de altura h tem no máximo 2^h folhas. Então 2^h ≥ n!, e a altura satisfaz h ≥ log₂(n!). A altura é o número de comparações no pior caso. E log₂(n!) = Θ(n log n). Portanto qualquer ordenação por comparação precisa de Ω(n log n) comparações no pior caso.
- n = 2
- 1 comparações
- n = 3
- 3 comparações
- n = 4
- 5 comparações
- n = 5
- 7 comparações
- n = 6
- 10 comparações
Para 4 itens: 4! = 24 ordens, e 2⁴ = 16 < 24 ≤ 32 = 2⁵, então são necessárias pelo menos 5 comparações. Para 5 itens: 5! = 120 > 2⁶ = 64, então ninguém ordena 5 itens com 6 comparações no pior caso: tal afirmação não pode ser verdadeira.
Como algumas ordenações podem ser mais rápidas?
O limite vale só para ordenações que descobrem os dados comparando. Ordenações que usam os próprios valores escapam dele:
| Algoritmo | Tempo | Ideia |
|---|---|---|
| Counting sort | Θ(n + k) | Para inteiros de 0 a k - 1: conte quantas vezes cada valor aparece |
| Radix sort | Θ(d · (n + k)) | d dígitos, cada um com k valores possíveis: ordene dígito por dígito com um counting sort estável |
Eles não contradizem o limite porque nunca perguntam 'a < b?'. Usam o valor como índice numa tabela. O preço: o intervalo k precisa ser pequeno, e só funcionam para coisas como inteiros ou chaves curtas.
↑ Voltar ao sumárioComplexidade de espaço
Tempo não é a única coisa que custa. Um algoritmo também usa memória. A complexidade de espaço mede a memória que um algoritmo precisa quando a entrada cresce. Normalmente contamos o espaço auxiliar: a memória extra usada além da própria entrada.
| Algoritmo | Espaço auxiliar | Por quê |
|---|---|---|
| Inverter um vetor no próprio lugar | O(1) | Algumas variáveis, não importa o tamanho do vetor |
| Merge sort tradicional | O(n) | Precisa de um segundo vetor para intercalar |
| Quicksort (no próprio lugar) | O(log n) em média, O(n) no pior caso | O espaço vem da pilha de recursão |
| Soma recursiva de 1 a n | O(n) | n chamadas esperando na pilha |
| Fibonacci recursivo fib(n-1) + fib(n-2) | O(n) | Chamadas exponenciais, mas só uma cadeia por vez na pilha |
Espaço auxiliar O(1) significa um número fixo de variáveis extras, que não cresce com n. Cuidado: 'no próprio lugar' com recursão ainda usa espaço, porque cada chamada que não terminou fica guardada na pilha de chamadas. Se uma função chama a si mesma n vezes em cadeia, a pilha guarda n chamadas: O(n).
// PT: Cada chamada espera a próxima, então n quadros se empilham: espaço O(n).
// (Sem otimização de chamada em cauda, que a maioria das linguagens não garante.)
function sum(n: number): number {
if (n === 0) return 0;
return n + sum(n - 1);
}O quicksort particiona no próprio lugar, mas recursa nos dois lados. Se o pivô for sempre o pior, a cadeia de chamadas tem profundidade n, então a pilha é O(n). O Fibonacci com duas chamadas faz um número exponencial de chamadas no total, mas em um único momento só um caminho a partir da raiz está aberto, com no máximo n chamadas de profundidade, então a pilha é O(n).
Há também uma ligação com o tempo. Se cada passo toca um número constante de células de memória, um algoritmo não pode usar mais células do que tem passos. Então S(n) = O(T(n)): o espaço nunca é maior que o tempo. O contrário é falso: um algoritmo pode levar muito tempo usando quase nenhuma memória.
↑ Voltar ao sumárioComplexidade das operações comuns de estruturas de dados
Escolher uma estrutura de dados é escolher quais operações são baratas. Aqui estão os custos que você deve conhecer, com n elementos.
| Estrutura | Acesso / busca | Inserção | Remoção | Notas |
|---|---|---|---|---|
| Vetor | Ler posição i: O(1). Buscar por valor: O(n) | No fim: O(1) amortizado. No início: O(n) | O(n) (deslocar os outros) | Rápido para ler, lento para abrir espaço |
| Lista encadeada simples | O(n) | No início (com ponteiro para a cabeça): O(1) | O(1) se você tem o nó anterior | Rápida para inserir na frente |
| Vetor ordenado | Busca binária: O(log n) | O(n) para manter a ordem | O(n) | Ótimo para ler, ruim para alterar |
| Tabela hash (boa função hash, fator de carga controlado) | O(1) em média | O(1) em média | O(1) em média | Pior caso O(n) se todas as chaves colidem numa lista |
| Árvore binária de busca, sem balanceamento | O(h): O(n) se degenerar | O(h) | O(h) | h é a altura |
| Árvore de busca balanceada (AVL, rubro-negra) | O(log n) no pior caso | O(log n) | O(log n) | Também lista as chaves em ordem em O(n) |
| Min-heap binário | Ler o menor: O(1) | O(log n) | Remover o menor: O(log n) | Construir um heap com n itens: O(n) |
Vamos ver por que algumas dessas valem.
Vetores e listas encadeadas
Um vetor guarda os itens lado a lado, então a posição i é achada por uma conta simples: O(1). Mas inserir no começo empurra cada item uma casa para a direita: O(n). Uma lista encadeada é uma corrente de caixinhas, cada uma apontando para a próxima, então inserir no começo só muda um ponteiro: O(1). Para chegar à i-ésima caixinha é preciso andar pela corrente: O(n).
Tabelas hash
Uma tabela hashuma tabela em que uma função transforma cada chave no número de uma gaveta, e você abre direto a gaveta certa com uma boa função hash e um fator de carga controlado (itens por gaveta) acha uma chave em O(1) em média. Com encadeamento (cada gaveta guarda uma lista), se todas as chaves caírem na mesma gaveta, a busca percorre uma lista de n chaves: O(n) no pior caso.
Árvores e heaps
Uma árvore binária de busca guarda as chaves menores à esquerda e as maiores à direita. Se você inserir 1, 2, 3, ..., n em ordem numa árvore simples sem balanceamento, toda chave vai para a direita e a árvore vira uma lista: buscar n custa O(n). Uma árvore balanceada (AVL, rubro-negra) se conserta sozinha e mantém a altura em O(log n), então dá busca, inserção e remoção em O(log n) no pior caso, e um percurso em ordem lista as chaves ordenadas em O(n). Uma tabela hash não lista chaves em ordem, então só a árvore balanceada atende a todos esses requisitos.
Um min-heap binário mantém o menor item na raiz: lê-lo é O(1), removê-lo é O(log n) (move o último item para a raiz e deixa ele afundar). Construir um heap a partir de um vetor qualquer com sift-down do último nó interno até a raiz custa apenas O(n), não O(n log n), porque a maioria dos nós está perto do fundo e afunda muito pouco.
Uma fila sobre um vetor
Se uma fila é um vetor, enfileirar escreve no fim (O(1) amortizado), mas desenfileirar remove o índice 0 e desloca todos os outros: O(n). Para n enfileiramentos e n desenfileiramentos o total é O(n²). Uma lista encadeada, ou um vetor com dois índices que andam (um buffer circular), leva cada operação a O(1).
- Tabela hash (média)
- 1 passos
- Árvore balanceada / vetor ordenado
- 10 passos
- Árvore sem balanceamento após inserções ordenadas
- 1,024 passos
- Lista encadeada
- 1,024 passos
P, NP e intratabilidade
Até aqui todo algoritmo que vimos é 'rápido o bastante'. Mas alguns problemas parecem não ter nenhum algoritmo rápido. Para falar deles, cientistas da computação separam os problemas em classes.
Polinomial e não polinomial
Um algoritmo roda em tempo polinomial se seu custo é limitado por n elevado a uma potência fixa: n, n log n, n², n³⁰⁰... Algoritmos como 2ⁿ ou n! não são polinomiais (são exponenciais ou piores). Até n^(log n) não é polinomial. Algoritmos polinomiais são chamados tratáveis (administráveis). Os outros são intratáveis para entradas grandes: nem um computador um milhão de vezes mais rápido salva o 2ⁿ.
As classes
| Classe | Significado | Imagem do dia a dia | Exemplo |
|---|---|---|---|
| P | Problemas que podem ser resolvidos em tempo polinomial | Resolver um labirinto | Ordenação, caminho mínimo |
| NP | Problemas cuja resposta proposta pode ser verificada em tempo polinomial | Alguém mostra um sudoku resolvido: conferir é rápido | SAT, o problema do caixeiro viajante |
| NP-completo | Os problemas mais difíceis de NP: todo problema de NP pode ser transformado neles | A 'fase final' de NP | SAT, caixeiro viajante (versão de decisão) |
| NP-difícil | Pelo menos tão difícil quanto todo problema de NP (pode nem estar em NP) | Difícil, e talvez nem verificável | Versões de otimização de problemas NP-completos |
+--------------------------+
| NP |
| +----------+ |
| | P | NP- |
| | | completo |
| +----------+ |
+--------------------------+
P está dentro de NP. P = NP? Ninguém sabe.Fatos que você precisa
- P ⊆ NP: se você consegue resolver um problema rápido, também consegue verificar uma resposta rápido.
- P = NP é uma questão em aberto. Ninguém provou, e ninguém provou o contrário. A maioria dos especialistas acredita que P ≠ NP, mas isso é uma crença, não um teorema.
- Um problema de decisão tem resposta sim/não. A versão de decisão do caixeiro viajante pergunta: 'existe um roteiro que visita as n cidades uma vez e volta ao início com custo no máximo K?'. Ela está em NP porque, dado um roteiro, você verifica em tempo polinomial que ele visita cada cidade uma vez e que o custo é no máximo K.
- Se alguém achar um algoritmo polinomial para um problema NP-completo (como o SAT, a satisfatibilidade de fórmulas lógicas), então todo problema de NP tem um, e P = NP.
- Para provar que um novo problema X é NP-difícil, pegue um problema A já conhecido como NP-completo e construa uma redução em tempo polinomial de A para X: um jeito rápido de transformar qualquer instância de A numa instância de X com a mesma resposta. Se X fosse fácil, A seria fácil.
Note o sentido da redução: você reduz o problema difícil conhecido ao novo. Fazer ao contrário (do novo para o conhecido) só mostraria que o novo problema não é mais difícil que A, o que não prova nada sobre a dificuldade dele.
↑ Voltar ao sumárioJuntando tudo
Todas as ferramentas desta área respondem a uma pergunta: como o custo de um algoritmo cresce com o tamanho da entrada? Veja como as peças se conectam.
| Pergunta | Ferramenta | Seção |
|---|---|---|
| Como descrevo o crescimento? | O, Ω, Θ, o, ω | Notação assintótica |
| Quais crescimentos são maiores? | 1 < log n < n < n log n < n² < 2ⁿ < n! | Crescimento de funções |
| Qual é o custo de um laço? | Soma na sequência, multiplica no aninhamento | Contagem de operações |
| E se a entrada for sortuda ou azarada? | Melhor, pior e caso médio | Casos |
| Qual é o custo de um algoritmo recursivo? | Recorrência, árvore de recursão, teorema mestre | Recorrências |
| O algoritmo está correto? | Invariante de laço: inicialização, manutenção, término | Invariantes de laço |
| Uma operação cara ocasional é um problema? | Análise amortizada | Análise amortizada |
| Algum algoritmo pode fazer melhor? | Limite inferior Ω(n log n) da ordenação por comparação | Limite inferior |
| Quanta memória? | Espaço auxiliar, incluindo a pilha de chamadas | Complexidade de espaço |
| Qual estrutura devo escolher? | Custos de cada operação | Estruturas de dados |
| O problema tem solução rápida? | P, NP, NP-completo, reduções | P e NP |
Erros comuns
- Achar que Big O dá o tempo em segundos. Ele dá o formato do crescimento.
- Confundir melhor, pior e caso médio com as notações O, Ω e Θ.
- Dizer Θ quando você só provou O.
- Esquecer de descartar constantes e termos menores, ou descartar um termo que importa (por exemplo dentro de um expoente).
- Ignorar a memória usada pela recursão.
- Usar o teorema mestre quando a recorrência não tem a forma a · T(n/b) + f(n).
- Acreditar que um computador mais rápido muda a ordem de crescimento.
- Ler NP como 'não polinomial'.