Pular para o conteúdo
Felipe Túlio

Complexidade de algoritmos: da teoria à medição na prática

21 min de leituraProgramação

Complexidade de algoritmos é o estudo de quantos recursos (principalmente tempo e memória) um algoritmo consome em função do tamanho da entrada. Em vez de perguntar “quantos segundos este programa leva?”, perguntamos “como o custo cresce quando a entrada dobra, quadruplica ou fica mil vezes maior?”. Essa pergunta é mais útil porque a resposta não depende do computador, da linguagem ou do compilador (Cormen et al., 2022; Sedgewick; Wayne, 2011).

Ao longo deste artigo usaremos um único problema: o sistema de inscrições de um evento acadêmico recebe uma lista de números de matrícula e precisa saber se alguma matrícula aparece repetida. Resolveremos o problema de três maneiras, analisaremos cada uma na teoria e depois confrontaremos a teoria com contagens e medições reais em C++17. Em seguida, usaremos a mesma lente para revisitar as estruturas e algoritmos que você já estudou (ordenações, buscas, pilhas, filas, listas e árvores) e entender por que cada uma custa o que custa.

Ao final você deverá ser capaz de:

  • explicar por que contamos operações em vez de segundos;
  • classificar um trecho de código em O(1), O(log n), O(n), O(n log n) ou O(n²);
  • distinguir pior caso, melhor caso e caso médio;
  • verificar na prática se uma análise teórica se confirma;
  • usar a complexidade para explicar o desempenho de ordenações, buscas, pilhas, filas, listas e árvores, e escolher entre elas.

Fundamentos: o que medir e como

Tamanho da entrada e operação básica

Para comparar algoritmos precisamos de duas escolhas:

  1. Tamanho da entrada (n): no nosso problema, o número de matrículas na lista.
  2. Operação básica: a operação que mais pesa no custo e que se repete. Aqui, a comparação entre duas matrículas.

A complexidade de tempo é então uma função T(n) que conta quantas vezes a operação básica ocorre. Contar operações tem uma vantagem: o resultado é o mesmo em qualquer máquina. Já o tempo em segundos muda de computador para computador.

Notação assintótica

Como T(n) exata costuma ser trabalhosa, e detalhes como somas de constantes pouco importam quando n é grande, usamos a notação assintótica, que descreve a taxa de crescimento (Knuth, 1976; Cormen et al., 2022).

Definição (O, “limite superior”). Dizemos que T(n) = O(g(n)) se existem constantes positivas c e n₀ tais que

T(n) ≤ c · g(n) para todo n ≥ n₀.

Lê-se: “a partir de certo tamanho, T(n) nunca cresce mais rápido que um múltiplo de g(n)”.

Exemplo. Seja T(n) = 3n² + 5n + 2. Para n ≥ 1 temos 5n ≤ 5n² e 2 ≤ 2n², logo

3n² + 5n + 2 ≤ 3n² + 5n² + 2n² = 10n².

Tomando c = 10 e n₀ = 1, concluímos que T(n) = O(n²). Note como o termo dominante (n²) decide a classe e as constantes são “absorvidas”.

Há duas notações complementares:

NotaçãoSignificado informalIdeia
O(g(n))cresce no máximo como g(n)limite superior
Ω(g(n))cresce no mínimo como g(n)limite inferior
Θ(g(n))cresce exatamente como g(n), a menos de constanteslimite justo (O e Ω juntos)

No dia a dia é comum dizer “O(n²)” querendo dizer Θ(n²). Neste artigo, quando houver diferença, ela será explicitada.

As classes mais comuns

ClasseNomeExemplo típicoSe n dobra, o custo…
O(1)constanteacessar v[i]não muda
O(log n)logarítmicabusca bináriaaumenta em uma unidade fixa
O(n)linearachar o maior elementodobra
O(n log n)linearítmicaordenação eficientepouco mais que dobra
O(n²)quadráticadois laços aninhadosquadruplica
O(2ⁿ)exponencialtestar todos os subconjuntosé elevado ao quadrado

Essas classes estão em ordem crescente de custo. Entre O(n log n) e O(n²) há um abismo para n grande, como veremos.

Melhor, pior e caso médio

Para a mesma entrada de tamanho n, o custo pode variar conforme o conteúdo da entrada. Por exemplo, se a primeira matrícula for igual à segunda, uma busca por duplicata termina na hora. Por isso falamos em:

  • melhor caso: a entrada mais favorável;
  • pior caso: a mais desfavorável, que dá uma garantia de que o algoritmo nunca fará pior;
  • caso médio: o custo esperado sobre uma distribuição de entradas (exige hipóteses probabilísticas).

Por padrão, a análise de pior caso é a mais usada, porque é uma garantia. Em todos os experimentos abaixo forçamos o pior caso: todas as matrículas são distintas, de modo que nenhum algoritmo consegue parar antes do fim.

Análise teórica do nosso problema

Versão A: comparar todos os pares

A ideia mais direta é comparar cada matrícula com todas as seguintes.

bool temDuplicataQuadratica(const vector<int>& matriculas, long long& comparacoes) {
    int n = matriculas.size();
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            comparacoes++;
            if (matriculas[i] == matriculas[j]) {
                return true;
            }
        }
    }
    return false;
}

No pior caso, o laço interno executa (n − 1) vezes para i = 0, (n − 2) para i = 1, e assim por diante até 0. A soma é

(n − 1) + (n − 2) + … + 1 + 0 = n(n − 1)/2 = n²/2 − n/2.

Logo T(n) = Θ(n²). Por exemplo, para n = 1000 esperamos 1000 · 999 / 2 = 499.500 comparações. O melhor caso é O(1) (duplicata nas duas primeiras posições).

Versão B: ordenar e olhar os vizinhos

Se a lista estiver ordenada, matrículas iguais ficam lado a lado. Basta então ordenar e comparar cada elemento com o anterior.

bool temDuplicataOrdenando(vector<int> matriculas, long long& comparacoes) {
    // O terceiro argumento do sort conta cada comparação feita pela ordenação.
    sort(matriculas.begin(), matriculas.end(), [&comparacoes](int a, int b) {
        comparacoes++;
        return a < b;
    });
    for (size_t i = 1; i < matriculas.size(); i++) {
        comparacoes++;
        if (matriculas[i] == matriculas[i - 1]) {
            return true;
        }
    }
    return false;
}

O parâmetro é recebido por valor, ou seja, o sort trabalha numa cópia e não bagunça a lista original. O trecho [&comparacoes](int a, int b) { ... } é apenas uma pequena função sem nome que o sort chama para comparar dois elementos; nós a usamos só para contar quantas vezes isso acontece.

O padrão C++ exige que std::sort faça O(n log n) comparações (ISO/IEC, 2017). O laço final custa n − 1 comparações, que é menor, e portanto a soma é dominada pela ordenação: T(n) = O(n log n). O preço é a cópia da lista, isto é, memória extra O(n).

Um laço simples para comparar: o maior valor

Como referência de custo linear, considere achar a maior matrícula:

int maiorMatricula(const vector<int>& matriculas, long long& comparacoes) {
    int maior = matriculas[0];
    for (size_t i = 1; i < matriculas.size(); i++) {
        comparacoes++;
        if (matriculas[i] > maior) {
            maior = matriculas[i];
        }
    }
    return maior;
}

Há exatamente n − 1 comparações, sempre, e portanto Θ(n).

Regras práticas para analisar código

  1. Uma sequência de instruções simples custa O(1).
  2. Um laço que vai de 1 a n, com corpo O(1), custa O(n).
  3. Laços aninhados multiplicam: dois laços até n resultam em O(n²).
  4. Instruções em sequência somam, e vale o maior termo: O(n) + O(n²) = O(n²).
  5. Se a cada passo o problema cai pela metade, o número de passos é cerca de log₂ n.

A regra 5 aparece na busca binária, que veremos adiante.

Colocando a teoria à prova: contando operações

O programa abaixo é completo. Ele gera listas de matrículas distintas, embaralhadas com semente fixa para reprodutibilidade, e conta as comparações das três funções vistas acima para n = 1000, 2000, …, 16000.

#include <algorithm>
#include <iostream>
#include <numeric>
#include <random>
#include <vector>

using namespace std;

// Versão A: compara todos os pares (i, j). Força bruta.
bool temDuplicataQuadratica(const vector<int>& matriculas, long long& comparacoes) {
    int n = matriculas.size();
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            comparacoes++;
            if (matriculas[i] == matriculas[j]) {
                return true;
            }
        }
    }
    return false;
}

// Versão B: ordena uma cópia e olha apenas vizinhos.
bool temDuplicataOrdenando(vector<int> matriculas, long long& comparacoes) {
    // O terceiro argumento do sort conta cada comparação feita pela ordenação.
    sort(matriculas.begin(), matriculas.end(), [&comparacoes](int a, int b) {
        comparacoes++;
        return a < b;
    });
    for (size_t i = 1; i < matriculas.size(); i++) {
        comparacoes++;
        if (matriculas[i] == matriculas[i - 1]) {
            return true;
        }
    }
    return false;
}

// Um laço simples: encontra a maior matrícula. Custo linear.
int maiorMatricula(const vector<int>& matriculas, long long& comparacoes) {
    int maior = matriculas[0];
    for (size_t i = 1; i < matriculas.size(); i++) {
        comparacoes++;
        if (matriculas[i] > maior) {
            maior = matriculas[i];
        }
    }
    return maior;
}

int main() {
    mt19937 gerador(42);  // semente fixa: resultados reproduzíveis

    cout << "      n | maior (n-1) | duplicata A (n^2) | duplicata B (n log n)\n";
    for (int n = 1000; n <= 16000; n *= 2) {
        // Pior caso: matrículas todas distintas, então nenhuma versão para antes do fim.
        vector<int> matriculas(n);
        iota(matriculas.begin(), matriculas.end(), 1000);
        shuffle(matriculas.begin(), matriculas.end(), gerador);

        long long cMaior = 0, cA = 0, cB = 0;
        maiorMatricula(matriculas, cMaior);
        temDuplicataQuadratica(matriculas, cA);
        temDuplicataOrdenando(matriculas, cB);

        cout.width(7);  cout << n << " | ";
        cout.width(11); cout << cMaior << " | ";
        cout.width(17); cout << cA << " | ";
        cout.width(21); cout << cB << "\n";
    }
    return 0;
}

Compilação e execução (g++ 16.2.1, -std=c++17 -O2 -Wall -Wextra):

      n | maior (n-1) | duplicata A (n^2) | duplicata B (n log n)
   1000 |         999 |            499500 |                 13125
   2000 |        1999 |           1999000 |                 28027
   4000 |        3999 |           7998000 |                 64086
   8000 |        7999 |          31996000 |                131111
  16000 |       15999 |         127992000 |                284477

(O valor exato da coluna B pode variar entre implementações da biblioteca padrão; a tendência de crescimento é a que importa.)

Lendo os números

  • Coluna “maior”: sempre n − 1, exatamente como previsto. Dobrar n dobra o custo.
  • Coluna A: para n = 1000, 499.500, exatamente o valor n(n − 1)/2 calculado na teoria. Quando n dobra de 1000 para 2000, o custo vai de 499.500 para 1.999.000, um fator de aproximadamente 4. De 8000 para 16000, de 31.996.000 para 127.992.000, de novo fator 4. É a assinatura do crescimento quadrático.
  • Coluna B: dobrar n multiplica o custo por cerca de 2,05 a 2,3 (por exemplo, 131.111 para 284.477), ligeiramente acima de 2. É a assinatura do n log n: como log n também cresce devagar, o custo “pouco mais que dobra”.

Observe a distância entre as colunas em n = 16000: 127,9 milhões contra 284 mil de comparações, uma razão de cerca de 450 vezes, e ela só aumenta com n.

Teoria e prática: o logaritmo em ação

Para ver O(log n) de perto, voltemos a uma operação que a secretaria do evento faz o tempo todo: procurar uma matrícula numa lista já ordenada. Comparamos a busca sequencial (O(n)) com a binária (O(log n)). A binária compara o alvo com o elemento do meio e descarta metade da lista a cada passo.

Se T(n) é o custo para n elementos, então T(n) = T(n/2) + 1, pois fazemos uma comparação e recaímos num problema com metade do tamanho. Dividindo n por 2 repetidamente, chegamos a 1 após cerca de log₂ n passos, e portanto T(n) = O(log n) (Cormen et al., 2022).

#include <iostream>
#include <numeric>
#include <vector>

using namespace std;

// Percorre a lista do início ao fim. No pior caso, n comparações.
int buscaSequencial(const vector<int>& v, int alvo, int& comparacoes) {
    for (int i = 0; i < (int)v.size(); i++) {
        comparacoes++;
        if (v[i] == alvo) {
            return i;
        }
    }
    return -1;
}

// Exige lista ordenada. A cada passo descarta metade: cerca de log2(n) comparações.
int buscaBinaria(const vector<int>& v, int alvo, int& comparacoes) {
    int inicio = 0, fim = (int)v.size() - 1;
    while (inicio <= fim) {
        int meio = inicio + (fim - inicio) / 2;
        comparacoes++;
        if (v[meio] == alvo) {
            return meio;
        } else if (v[meio] < alvo) {
            inicio = meio + 1;
        } else {
            fim = meio - 1;
        }
    }
    return -1;
}

int main() {
    cout << "        n | sequencial (pior caso) | binária (pior caso)\n";
    for (int n = 1000; n <= 1000000; n *= 10) {
        vector<int> v(n);
        iota(v.begin(), v.end(), 0);  // 0, 1, ..., n-1 já ordenado

        int cSeq = 0, cBin = 0;
        buscaSequencial(v, -1, cSeq);  // -1 não existe: força o pior caso
        buscaBinaria(v, -1, cBin);

        cout.width(9);  cout << n << " | ";
        cout.width(22); cout << cSeq << " | ";
        cout.width(19); cout << cBin << "\n";
    }
    return 0;
}

Saída obtida:

        n | sequencial (pior caso) | binária (pior caso)
     1000 |                   1000 |                   9
    10000 |                  10000 |                  13
   100000 |                 100000 |                  16
  1000000 |                1000000 |                  19

Multiplicar n por 10 multiplica por 10 a busca sequencial, mas soma apenas cerca de 3,3 (log₂ 10) à binária. Para um milhão de matrículas, são 19 comparações contra 1.000.000. Repare também na condição: a busca binária só funciona se a lista estiver ordenada, e essa preparação tem custo (O(n log n) para ordenar). Se for buscar uma única vez, ordenar antes não compensa; se for buscar muitas vezes, compensa muito.

Medindo o tempo real

Contar operações é limpo, mas o que o usuário sente é o tempo. Vamos medir com std::chrono, agora incluindo uma terceira versão, que usa um conjunto de dispersão (unordered_set): percorre a lista e, para cada matrícula, pergunta se ela já foi vista. Cada consulta e inserção custa O(1) em média, de modo que a versão tem custo esperado O(n) (Cormen et al., 2022). Note o “em média”: no pior caso teórico, as operações de um conjunto de dispersão podem degradar, e é por isso que a classificação como O(n) vale para o caso médio, não como garantia.

#include <algorithm>
#include <chrono>
#include <iostream>
#include <numeric>
#include <random>
#include <unordered_set>
#include <vector>

using namespace std;
using namespace std::chrono;

bool temDuplicataQuadratica(const vector<int>& matriculas) {
    int n = matriculas.size();
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            if (matriculas[i] == matriculas[j]) {
                return true;
            }
        }
    }
    return false;
}

bool temDuplicataOrdenando(vector<int> matriculas) {
    sort(matriculas.begin(), matriculas.end());
    for (size_t i = 1; i < matriculas.size(); i++) {
        if (matriculas[i] == matriculas[i - 1]) {
            return true;
        }
    }
    return false;
}

// Versão C: conjunto de hash. Custo esperado linear (caso médio).
bool temDuplicataComConjunto(const vector<int>& matriculas) {
    unordered_set<int> vistas;
    for (int m : matriculas) {
        if (vistas.count(m) > 0) {
            return true;
        }
        vistas.insert(m);
    }
    return false;
}

// Mede em milissegundos o tempo de uma chamada à função recebida.
template <typename Funcao>
double medirMs(Funcao f, const vector<int>& dados) {
    auto inicio = steady_clock::now();
    bool resultado = f(dados);
    auto fim = steady_clock::now();
    if (resultado) {
        cout << "(inesperado: duplicata encontrada)\n";
    }
    return duration<double, milli>(fim - inicio).count();
}

int main() {
    mt19937 gerador(42);

    cout << "      n |   A: n^2 (ms) | B: n log n (ms) | C: hash (ms)\n";
    for (int n = 2000; n <= 64000; n *= 2) {
        vector<int> matriculas(n);
        iota(matriculas.begin(), matriculas.end(), 1000);
        shuffle(matriculas.begin(), matriculas.end(), gerador);

        double tA = medirMs(temDuplicataQuadratica, matriculas);
        double tB = medirMs(temDuplicataOrdenando, matriculas);
        double tC = medirMs(temDuplicataComConjunto, matriculas);

        cout.width(7);  cout << n << " | ";
        cout.width(13); cout << tA << " | ";
        cout.width(15); cout << tB << " | ";
        cout.width(12); cout << tC << "\n";
    }
    return 0;
}

Resultado (AMD Ryzen 7 5700U, Linux, g++ 16.2.1, -std=c++17 -O2; um único experimento por tamanho):

      n |   A: n^2 (ms) | B: n log n (ms) | C: hash (ms)
   2000 |      0.989366 |        0.141987 |     0.246608
   4000 |       4.01061 |        0.282437 |     0.411852
   8000 |       16.0392 |        0.596371 |     0.857996
  16000 |        62.088 |         1.27411 |      1.75727
  32000 |       249.012 |         2.78512 |      3.81373
  64000 |       998.445 |         5.82362 |      8.11286

Seus tempos serão diferentes: dependem da máquina, da carga do sistema e do compilador. O que deve se repetir é a forma do crescimento:

  • A: cada duplicação de n multiplica o tempo por cerca de 4 (0,99 → 4,0 → 16,0 → 62,1 → 249,0 → 998,4 ms). Quadrático, como a teoria previu. Com 64.000 matrículas, já é perto de 1 segundo, e para 640.000 o tempo estimado seria cerca de 100 segundos.
  • B: o tempo pouco mais que dobra a cada duplicação (0,14 → 0,28 → 0,60 → 1,27 → 2,79 → 5,82 ms).
  • C: cresce quase linearmente (0,25 → 0,41 → 0,86 → 1,76 → 3,81 → 8,11 ms), mas, nestes tamanhos, é mais lenta que a B.

O que a prática ensina sobre a teoria

O último ponto merece atenção: a versão C tem melhor classe assintótica (O(n) em média contra O(n log n)), e mesmo assim perdeu a corrida neste experimento. Isso não contradiz a teoria. A notação assintótica ignora constantes e descreve o comportamento quando n tende ao infinito. O conjunto de dispersão aloca memória para cada elemento e acessa posições dispersas da memória, o que tem um custo constante grande, enquanto o sort trabalha sobre um vetor contíguo. Em tamanhos muito maiores a vantagem assintótica tende a aparecer, mas isso depende do hardware e deve ser medido, não presumido.

A complexidade por trás das estruturas e algoritmos clássicos

Tudo o que vimos até aqui se aplica diretamente às estruturas de dados que você já estudou. Cada estrutura é, na prática, uma escolha de quais operações serão baratas e quais serão caras. A análise de complexidade é a ferramenta que torna essa escolha explícita (Cormen et al., 2022; Sedgewick; Wayne, 2011).

Tabela de referência

As tabelas abaixo resumem os custos usuais. Elas são o ponto de partida; nas subseções seguintes verificamos várias delas em código. Em todas, n é o número de elementos e os custos são de tempo, exceto onde indicado.

Algoritmos de ordenação

AlgoritmoMelhor casoCaso médioPior casoMemória extraObservação
SeleçãoΘ(n²)Θ(n²)Θ(n²)O(1)faz o mesmo trabalho mesmo se a lista já estiver ordenada
InserçãoΘ(n)Θ(n²)Θ(n²)O(1)ótima para listas quase ordenadas ou pequenas
Bolha (com parada antecipada)Θ(n)Θ(n²)Θ(n²)O(1)didática; raramente usada na prática
Intercalação (merge sort)Θ(n log n)Θ(n log n)Θ(n log n)O(n)custo previsível, usa vetor auxiliar
QuicksortΘ(n log n)Θ(n log n)Θ(n²)O(log n)pior caso raro com boa escolha do pivô
HeapsortΘ(n log n)Θ(n log n)Θ(n log n)O(1)ordena sobre o próprio vetor
std::sort (C++)——O(n log n)—o padrão da linguagem exige O(n log n) comparações

Algoritmos de busca

AlgoritmoMelhor casoCaso médioPior casoPré-requisito
SequencialO(1)O(n)O(n)nenhum
Binária (em vetor)O(1)O(log n)O(log n)vetor ordenado
Tabela de dispersão (hash)O(1)O(1)O(n)boa função de dispersão
Árvore binária de buscaO(1)O(log n)O(n)depende do formato da árvore
Árvore balanceada (AVL, rubro-negra)O(1)O(log n)O(log n)rebalanceamento a cada inserção

Estruturas lineares e árvores: custo de cada operação

EstruturaInserirRemoverAcessar / buscarComentário
Pilha (em vector)O(1)*O(1)topo: O(1)só se mexe no fim; *push_back é O(1) amortizado
Fila ingênua (em vector)O(1)*O(n)frente: O(1)remover do início desloca todos os elementos
Fila com índice de inícioO(1)*O(1)frente: O(1)não desloca nada; desperdiça espaço dos removidos
Lista como vectorinício: O(n); fim: O(1)*início: O(n)posição: O(1); valor: O(n)memória contígua
Lista encadeadainício: O(1)início: O(1)posição: O(n); valor: O(n)cada nó aponta para o próximo
Árvore binária de buscaO(h)O(h)O(h)h = altura: de O(log n) a O(n)
Árvore balanceadaO(log n)O(log n)O(log n)h é garantidamente O(log n)
Heap binárioO(log n)min/máx: O(log n)min/máx: O(1)base de filas de prioridade

A leitura da tabela é sempre a mesma: não existe estrutura “a melhor”. A lista encadeada insere no início em O(1), mas perde em acesso por posição; o vector faz o oposto. O restante da seção mostra isso em números.

Ordenação: o melhor, o médio e o pior caso aparecem de verdade

A ordenação por inserção é uma boa vitrine dos três casos: em lista já ordenada, cada elemento é comparado uma única vez (n − 1 comparações no total); em lista inversa, cada elemento precisa passar por todos os anteriores (n(n − 1)/2); em lista embaralhada, em média metade disso, ou seja, cerca de n²/4. Comparamos com a ordenação por intercalação, que divide a lista ao meio, ordena cada metade e intercala. Como há cerca de log₂ n níveis de divisão e cada nível custa no máximo n comparações para intercalar, o total é O(n log n) em qualquer caso (Cormen et al., 2022).

#include <algorithm>
#include <iostream>
#include <numeric>
#include <random>
#include <vector>

using namespace std;

// Ordenação por inserção: pega cada elemento e o "empurra" para a posição certa.
long long ordenacaoInsercao(vector<int> v) {
    long long comparacoes = 0;
    for (size_t i = 1; i < v.size(); i++) {
        int chave = v[i];
        int j = (int)i - 1;
        while (j >= 0) {
            comparacoes++;
            if (v[j] <= chave) {
                break;
            }
            v[j + 1] = v[j];
            j--;
        }
        v[j + 1] = chave;
    }
    return comparacoes;
}

// Intercala as duas metades já ordenadas v[inicio..meio) e v[meio..fim).
void intercalar(vector<int>& v, vector<int>& aux, int inicio, int meio, int fim,
                long long& comparacoes) {
    int i = inicio, j = meio, k = inicio;
    while (i < meio && j < fim) {
        comparacoes++;
        if (v[i] <= v[j]) {
            aux[k++] = v[i++];
        } else {
            aux[k++] = v[j++];
        }
    }
    while (i < meio) aux[k++] = v[i++];
    while (j < fim) aux[k++] = v[j++];
    for (k = inicio; k < fim; k++) v[k] = aux[k];
}

// Ordenação por intercalação: divide ao meio, ordena cada metade e intercala.
void ordenacaoIntercalacao(vector<int>& v, vector<int>& aux, int inicio, int fim,
                           long long& comparacoes) {
    if (fim - inicio < 2) {
        return;
    }
    int meio = inicio + (fim - inicio) / 2;
    ordenacaoIntercalacao(v, aux, inicio, meio, comparacoes);
    ordenacaoIntercalacao(v, aux, meio, fim, comparacoes);
    intercalar(v, aux, inicio, meio, fim, comparacoes);
}

int main() {
    mt19937 gerador(42);

    cout << "      n | insercao: melhor | insercao: medio | insercao: pior | intercalacao\n";
    for (int n = 1000; n <= 8000; n *= 2) {
        vector<int> crescente(n);
        iota(crescente.begin(), crescente.end(), 0);

        vector<int> decrescente(crescente.rbegin(), crescente.rend());

        vector<int> aleatoria = crescente;
        shuffle(aleatoria.begin(), aleatoria.end(), gerador);

        long long cMelhor = ordenacaoInsercao(crescente);
        long long cMedio = ordenacaoInsercao(aleatoria);
        long long cPior = ordenacaoInsercao(decrescente);

        vector<int> copia = aleatoria;
        vector<int> aux(n);
        long long cInter = 0;
        ordenacaoIntercalacao(copia, aux, 0, n, cInter);

        cout.width(7);  cout << n << " | ";
        cout.width(16); cout << cMelhor << " | ";
        cout.width(15); cout << cMedio << " | ";
        cout.width(14); cout << cPior << " | ";
        cout.width(12); cout << cInter << "\n";
    }
    return 0;
}

Saída obtida:

      n | insercao: melhor | insercao: medio | insercao: pior | intercalacao
   1000 |              999 |          246352 |         499500 |         8713
   2000 |             1999 |          997538 |        1999000 |        19408
   4000 |             3999 |         3978974 |        7998000 |        42809
   8000 |             7999 |        16107630 |       31996000 |        93663

Conferindo com a teoria:

  • Melhor caso: n − 1 exatamente (999, 1999, …). Custo linear.
  • Pior caso: n(n − 1)/2 exatamente (499.500 para n = 1000). Custo quadrático.
  • Caso médio: 246.352 para n = 1000, bem próximo de n²/4 = 250.000. O crescimento também é quadrático: dobrar n multiplica o custo por cerca de 4.
  • Intercalação: 8.713 comparações para n = 1000, abaixo de n log₂ n ≈ 9.966, e dobrar n pouco mais que dobra o custo. Para n = 8000, a inserção em média faz 16,1 milhões de comparações contra 93,6 mil da intercalação, algo como 170 vezes mais.

A lição vai além da ordenação: o mesmo algoritmo pode ser excelente ou péssimo conforme os dados. Por isso, antes de escolher, pergunte como é a entrada típica (quase ordenada? aleatória?). A intercalação, em compensação, paga com memória: o vetor auxiliar aux torna sua memória extra O(n).

Pilhas, filas e listas: o custo está em onde se mexe

Voltemos à secretaria do evento. Os inscritos entram numa fila de atendimento, os atendimentos desfeitos são guardados numa pilha (o último a ser feito é o primeiro a ser desfeito) e a lista de presença é uma lista. O programa a seguir conta os “passos” (uma inserção, uma remoção ou um elemento deslocado conta um passo) de operações sobre n elementos.

#include <iostream>
#include <vector>

using namespace std;

int main() {
    cout << "      n | pilha (2n) | fila ingenua | fila c/ indice | insere inicio: vector | insere inicio: lista\n";
    for (int n = 1000; n <= 8000; n *= 2) {
        // ---- Pilha: empilhar e desempilhar só mexem no fim do vector ----
        long long passosPilha = 0;
        vector<int> pilha;
        for (int i = 0; i < n; i++) { pilha.push_back(i); passosPilha++; }
        while (!pilha.empty())      { pilha.pop_back();   passosPilha++; }

        // ---- Fila ingênua: desenfileirar remove o primeiro e desloca todos os outros ----
        long long passosFilaIngenua = 0;
        vector<int> fila;
        for (int i = 0; i < n; i++) { fila.push_back(i); passosFilaIngenua++; }
        while (!fila.empty()) {
            passosFilaIngenua += (long long)fila.size() - 1;  // elementos deslocados
            fila.erase(fila.begin());
            passosFilaIngenua++;
        }

        // ---- Fila com índice: só avançamos o índice do início, sem deslocar nada ----
        long long passosFilaIndice = 0;
        vector<int> fila2;
        size_t inicio = 0;
        for (int i = 0; i < n; i++) { fila2.push_back(i); passosFilaIndice++; }
        while (inicio < fila2.size()) { inicio++; passosFilaIndice++; }

        // ---- Lista como vector: inserir no início desloca todos os elementos ----
        long long passosVector = 0;
        vector<int> lista;
        for (int i = 0; i < n; i++) {
            passosVector += (long long)lista.size() + 1;  // deslocamentos + a inserção
            lista.insert(lista.begin(), i);
        }

        // ---- Lista encadeada (por índices): inserir no início é só trocar a cabeça ----
        long long passosEncadeada = 0;
        vector<int> valor, proximo;
        int cabeca = -1;
        for (int i = 0; i < n; i++) {
            valor.push_back(i);
            proximo.push_back(cabeca);   // o novo nó aponta para o antigo primeiro
            cabeca = (int)valor.size() - 1;
            passosEncadeada++;
        }

        cout.width(7);  cout << n << " | ";
        cout.width(10); cout << passosPilha << " | ";
        cout.width(12); cout << passosFilaIngenua << " | ";
        cout.width(14); cout << passosFilaIndice << " | ";
        cout.width(21); cout << passosVector << " | ";
        cout.width(20); cout << passosEncadeada << "\n";
    }

    // ---- Acesso ao k-ésimo elemento: vector vs lista encadeada ----
    cout << "\n      n | acesso ao ultimo: vector | acesso ao ultimo: lista\n";
    for (int n = 1000; n <= 8000; n *= 2) {
        vector<int> valor(n), proximo(n);
        for (int i = 0; i < n; i++) {
            valor[i] = i;
            proximo[i] = (i + 1 < n) ? i + 1 : -1;
        }
        long long passosVector = 1;  // valor[n-1]
        long long passosLista = 0;
        int atual = 0;               // cabeça = nó 0
        while (proximo[atual] != -1) { atual = proximo[atual]; passosLista++; }
        cout.width(7);  cout << n << " | ";
        cout.width(24); cout << passosVector << " | ";
        cout.width(23); cout << passosLista << "\n";
    }
    return 0;
}

Saída obtida:

      n | pilha (2n) | fila ingenua | fila c/ indice | insere inicio: vector | insere inicio: lista
   1000 |       2000 |       501500 |           2000 |                500500 |                 1000
   2000 |       4000 |      2003000 |           4000 |               2001000 |                 2000
   4000 |       8000 |      8006000 |           8000 |               8002000 |                 4000
   8000 |      16000 |     32012000 |          16000 |              32004000 |                 8000

      n | acesso ao ultimo: vector | acesso ao ultimo: lista
   1000 |                        1 |                     999
   2000 |                        1 |                    1999
   4000 |                        1 |                    3999
   8000 |                        1 |                    7999

O que os números mostram:

  • Pilha: 2n passos para n empilhamentos e n desempilhamentos. Cada operação é O(1), pois só toca no fim do vector.
  • Fila ingênua (desenfileirar com erase(begin())): cerca de n²/2 passos, o mesmo crescimento quadrático do pior caso da inserção. Cada remoção desloca todos os outros elementos para a esquerda.
  • Fila com índice de início: de novo 2n passos. Basta avançar um índice em vez de deslocar. A troca é que o espaço dos elementos já removidos continua ocupado até o fim. Filas circulares e a std::queue resolvem isso, mas a ideia de custo é a mesma: remover da frente em O(1).
  • Lista como vector vs. lista encadeada: inserir n elementos no início custa cerca de n²/2 deslocamentos no vector (32 milhões para n = 8000) e apenas n passos na lista encadeada, que só precisa mudar a “cabeça”. Em contrapartida, chegar ao último elemento custa 1 passo no vector e n − 1 passos na lista encadeada. Essa é a troca fundamental entre as duas estruturas.

Na lista encadeada do código, cada “nó” é uma posição nos vetores valor e proximo, e proximo[i] guarda o índice do nó seguinte (−1 quando não há). Isso reproduz a ideia de ponteiros sem precisar de alocação dinâmica.

Um detalhe sobre push_back: quando o vector fica cheio, ele aloca um espaço maior e copia tudo, um custo O(n) naquela chamada. Como isso acontece cada vez menos vezes à medida que o vetor cresce, o custo médio por inserção no fim é constante. Dizemos que push_back é O(1) amortizado; a demonstração formal fica para estudos posteriores (Cormen et al., 2022).

Árvores: a altura decide tudo

Em uma árvore binária de busca, cada comparação com a chave de um nó descarta uma subárvore. Buscar, inserir e remover custam O(h), em que h é a altura da árvore (o número de níveis do caminho mais longo). Se a árvore for “equilibrada”, h ≈ log₂ n e tudo é logarítmico. Se ela degenerar numa corrente de nós, h = n e voltamos à busca sequencial (Cormen et al., 2022). O programa abaixo guarda a árvore em vetores (o nó i tem chave[i], esquerda[i] e direita[i]) e insere as mesmas chaves de duas maneiras: em ordem crescente e em ordem embaralhada.

#include <algorithm>
#include <iostream>
#include <numeric>
#include <random>
#include <vector>

using namespace std;

// Árvore binária de busca guardada em vetores: o nó i tem chave[i],
// filho esquerdo esquerda[i] e filho direito direita[i] (-1 = não existe).
vector<int> chave, esquerda, direita;

// Insere e devolve quantas comparações foram feitas para achar o lugar.
int inserir(int valor) {
    int comparacoes = 0;
    chave.push_back(valor);
    esquerda.push_back(-1);
    direita.push_back(-1);
    int novo = (int)chave.size() - 1;
    if (novo == 0) {
        return 0;  // primeira chave vira a raiz
    }
    int atual = 0;
    while (true) {
        comparacoes++;
        if (valor < chave[atual]) {
            if (esquerda[atual] == -1) { esquerda[atual] = novo; break; }
            atual = esquerda[atual];
        } else {
            if (direita[atual] == -1) { direita[atual] = novo; break; }
            atual = direita[atual];
        }
    }
    return comparacoes;
}

// Altura da árvore a partir do nó dado: número de níveis do caminho mais longo.
int altura(int no) {
    if (no == -1) {
        return 0;
    }
    return 1 + max(altura(esquerda[no]), altura(direita[no]));
}

// Quantas comparações a busca por um valor faz (-1 em filho = não achou).
int buscar(int valor) {
    int comparacoes = 0;
    int atual = 0;
    while (atual != -1) {
        comparacoes++;
        if (valor == chave[atual]) {
            break;
        }
        atual = (valor < chave[atual]) ? esquerda[atual] : direita[atual];
    }
    return comparacoes;
}

void limpar() {
    chave.clear();
    esquerda.clear();
    direita.clear();
}

int main() {
    mt19937 gerador(42);

    cout << "      n | ordem crescente: altura / busca do ultimo | ordem aleatoria: altura / busca do ultimo\n";
    for (int n = 500; n <= 4000; n *= 2) {
        vector<int> valores(n);
        iota(valores.begin(), valores.end(), 1);

        // Degenerada: chaves chegam em ordem crescente, a árvore vira uma "lista".
        limpar();
        for (int v : valores) inserir(v);
        int altCrescente = altura(0);
        int buscaCrescente = buscar(n);

        // Aleatória: a mesma coleção, mas inserida em ordem embaralhada.
        shuffle(valores.begin(), valores.end(), gerador);
        limpar();
        for (int v : valores) inserir(v);
        int altAleatoria = altura(0);
        int buscaAleatoria = buscar(valores.back());

        cout.width(7);  cout << n << " | ";
        cout.width(22); cout << altCrescente;
        cout << " /";
        cout.width(5); cout << buscaCrescente << "           | ";
        cout.width(22); cout << altAleatoria;
        cout << " /";
        cout.width(5); cout << buscaAleatoria << "\n";
    }
    return 0;
}

Saída obtida:

      n | ordem crescente: altura / busca do ultimo | ordem aleatoria: altura / busca do ultimo
    500 |                    500 /  500           |                     21 /   10
   1000 |                   1000 / 1000           |                     20 /   15
   2000 |                   2000 / 2000           |                     25 /   17
   4000 |                   4000 / 4000           |                     29 /   16

As mesmas 4000 chaves produziram árvores completamente diferentes:

  • Inserção em ordem crescente: cada chave nova é maior que todas e vai para a direita da anterior. A altura é n, e a busca pelo último elemento faz 4000 comparações, pior caso O(n).
  • Inserção em ordem aleatória: a altura ficou entre 20 e 29, na ordem de grandeza de log₂ n (≈ 12 para n = 4000), e as buscas fizeram em torno de 10 a 17 comparações.

Isso explica por que existem as árvores balanceadas (AVL, rubro-negras): elas reorganizam a árvore a cada inserção para manter h = O(log n) mesmo com entradas ordenadas, transformando a garantia de “caso médio” em garantia de pior caso. Em C++, std::set e std::map oferecem busca, inserção e remoção em O(log n), e costumam ser implementados com árvores desse tipo.

Escolhendo com base na complexidade

Com esses resultados, decisões de projeto ficam argumentáveis:

  • Precisa de muitas buscas em dados que quase não mudam? Ordene uma vez (O(n log n)) e use busca binária (O(log n) por consulta).
  • Precisa inserir e remover o tempo todo, sempre pelas pontas? Pilha ou fila bem implementadas (O(1)).
  • Precisa de acesso por posição? vector. Precisa inserir no meio ou no início com frequência? Considere lista encadeada, mas lembre-se do custo de chegar até a posição.
  • Os dados chegam em ordem previsível e você usa árvore de busca? Prefira uma árvore balanceada.

Limitações da análise

  • Constantes importam para n pequeno. Um algoritmo O(n²) simples pode ser mais rápido que um O(n log n) sofisticado quando n é pequeno. Por isso implementações reais de ordenação costumam usar métodos simples em trechos pequenos.
  • Pior caso nem sempre é o caso típico. Um algoritmo pode ter pior caso ruim e comportamento médio excelente, e vice-versa.
  • Memória também é recurso. A versão B usa O(n) de memória extra e a C também; a A usa O(1). Há uma troca (trade-off) entre tempo e espaço.
  • O modelo é uma abstração. Ao contar “comparações”, ignoramos cache, alocação de memória e outros custos de hardware, que o experimento com chrono revelou na versão C.
  • Medição de tempo é ruidosa. Os tempos acima vêm de uma única execução por tamanho. Medições rigorosas repetem o experimento várias vezes e usam mediana ou média.
  • A tabela é um resumo. Ela apresenta custos usuais; implementações concretas (e o hardware) podem alterar constantes e detalhes, como o espaço desperdiçado pela fila com índice ou a localidade de memória da lista encadeada, que costuma ser pior que a do vector.
  • Este artigo não cobre a demonstração formal da análise amortizada, notações o e ω, nem a resolução geral de recorrências (como o Teorema Mestre), assuntos para um estudo posterior.

Síntese

Resumindo o percurso com o nosso problema das matrículas duplicadas (a síntese das estruturas clássicas está na tabela de referência da seção anterior):

VersãoIdeiaTempo (pior caso)Memória extra
Acomparar todos os paresΘ(n²)O(1)
Bordenar e olhar vizinhosO(n log n)O(n)
Cconjunto de dispersãoO(n) em médiaO(n)

A teoria permitiu prever o comportamento (n(n−1)/2 comparações; fator 4 quando n dobra) e a prática o confirmou, ao mesmo tempo em que mostrou o limite da teoria: a classe assintótica diz como o custo cresce, não quem é mais rápido em um n específico. Boa engenharia combina as duas coisas: raciocinar sobre a complexidade para descartar soluções que não escalam e medir para decidir entre as que sobrevivem.

As estruturas de dados clássicas seguem a mesma lógica. Pilhas e filas bem implementadas dão operações O(1); vector e lista encadeada trocam acesso por posição por inserção no início; árvores de busca são O(log n) quando equilibradas e O(n) quando degeneram; e algoritmos de ordenação como inserção e intercalação diferem por um fator que, para n grande, chega a centenas de vezes. Conhecer o custo de cada operação é o que permite escolher a estrutura certa para cada problema.

Referências

INTERNATIONAL ORGANIZATION FOR STANDARDIZATION; INTERNATIONAL ELECTROTECHNICAL COMMISSION. ISO/IEC 14882:2017: Programming languages — C++. 5. ed. Genebra: ISO/IEC, 2017.

CORMEN, Thomas H.; LEISERSON, Charles E.; RIVEST, Ronald L.; STEIN, Clifford. Introduction to algorithms. 4. ed. Cambridge, MA: MIT Press, 2022.

KNUTH, Donald E. Big omicron and big omega and big theta. ACM SIGACT News, New York, v. 8, n. 2, p. 18-24, 1976.

SEDGEWICK, Robert; WAYNE, Kevin. Algorithms. 4. ed. Upper Saddle River: Addison-Wesley, 2011.

Atividade de fixação

  1. Análise. Classifique cada trecho em O(1), O(n), O(n²) ou outra classe, justificando:
    • (a) um laço for (int i = 0; i < n; i++) que soma v[i];
    • (b) dois laços aninhados, cada um de 0 a n, com corpo constante;
    • (c) um laço while (n > 1) { n = n / 2; }.
  2. Demonstração. Mostre, encontrando c e n₀, que 4n² + 10n = O(n²).
  3. Experimento. Altere contagem.cpp para que a lista tenha uma matrícula duplicada logo nas duas primeiras posições. Quais contagens mudam, e por quê? Isso é melhor caso ou pior caso?
  4. Estruturas. Usando a tabela de referência, indique a estrutura mais adequada e justifique em termos de complexidade: (a) histórico de “desfazer” de um editor de texto; (b) fila de impressão; (c) cadastro com milhões de matrículas consultado muito mais vezes do que alterado.
  5. Experimento com árvores. Em arvore.cpp, insira as chaves em ordem decrescente. Qual será a altura? Depois, insira primeiro a chave do meio, depois as metades de forma recursiva, e observe o que acontece com a altura.
  6. Ordenação. Em ordenacao.cpp, crie uma lista “quase ordenada” (ordenada, com apenas 10 pares de elementos trocados) e conte as comparações da inserção. Qual caso ela se aproxima? Isso muda sua escolha de algoritmo?
  7. Reflexão. Na medição de tempo, a versão C (O(n) em média) foi mais lenta que a B (O(n log n)). Em que situação você apostaria que a C passaria a ganhar? Como testaria sua hipótese?