Noções de complexidade (Big O)
Esta aula introduz o conceito de complexidade de algoritmos e a notação Big O, essencial para escrever código C eficiente. Você aprenderá a identificar a ordem de crescimento de algoritmos comuns, entenderá por que isso é crítico em C (onde você tem controle fino sobre memória e desempenho) e praticará a análise de trechos de código.
Bem-vindo à aula 72! Hoje vamos mergulhar em um dos tópicos mais importantes para quem quer dominar a linguagem C: a análise de complexidade de algoritmos, também conhecida como notação Big O. Se você já se perguntou por que alguns programas rodam em milissegundos enquanto outros travam com entradas grandes, a resposta está na eficiência algorítmica. Nesta aula, você vai entender o que é Big O, como identificar a complexidade de trechos de código em C e por que isso é crucial quando você está lidando com recursos limitados, como em sistemas embarcados ou processamento de alto desempenho.
Dominar Big O não é apenas uma habilidade acadêmica — é uma ferramenta prática que influencia decisões de design desde o início de um projeto. Em C, onde você gerencia memória manualmente e tem acesso direto ao hardware, a escolha do algoritmo certo pode significar a diferença entre um sistema responsivo e um que sofre com gargalos. Vamos construir essa base juntos, com exemplos concretos e análises que você poderá aplicar imediatamente.
O que é
A notação Big O descreve o comportamento assintótico de um algoritmo, ou seja, como o tempo de execução (ou o uso de memória) cresce à medida que o tamanho da entrada (n) aumenta. Ela ignora constantes multiplicativas e termos de menor ordem, focando no termo que domina o crescimento. Por exemplo, se um algoritmo executa 3n^2 + 5n + 10 operações, dizemos que ele é O(n^2), pois o termo quadrático domina para valores grandes de n.
Formalmente, dizemos que f(n) = O(g(n)) se existem constantes positivas c e n0 tais que 0 ≤ f(n) ≤ c * g(n) para todo n ≥ n0. Isso nos dá uma cota superior para o crescimento. Na prática, usamos Big O para comparar algoritmos e prever seu desempenho em entradas grandes.
Vamos ver um exemplo simples em C. Considere uma função que soma os elementos de um vetor:
int soma(int v[], int n) {
int total = 0;
for (int i = 0; i < n; i++) {
total += v[i];
}
return total;
}
O loop executa n iterações, e cada iteração faz um número constante de operações. Portanto, a complexidade é O(n). Se dobrarmos o tamanho do vetor, o tempo de execução aproximadamente dobra.
Exemplos comuns
Existem algumas complexidades que aparecem com frequência em algoritmos. Vamos analisá-las com exemplos em C.
- O(1) — Constante: O tempo não depende do tamanho da entrada. Exemplo: acessar um elemento de um array por índice.
- O(log n) — Logarítmica: O tempo cresce logaritmicamente. Exemplo: busca binária em um array ordenado.
- O(n) — Linear: O tempo cresce proporcionalmente à entrada. Exemplo: percorrer um array.
- O(n log n) — Linearítmica: Comum em algoritmos de ordenação eficientes, como Merge Sort.
- O(n^2) — Quadrática: Típica de loops aninhados. Exemplo: ordenação por seleção.
- O(2^n) — Exponencial: Crescimento explosivo, comum em recursão sem otimização, como calcular Fibonacci ingênuo.
Vejamos um exemplo de O(log n) com busca binária:
int busca_binaria(int v[], int n, int alvo) {
int esq = 0, dir = n - 1;
while (esq <= dir) {
int meio = (esq + dir) / 2;
if (v[meio] == alvo) return meio;
if (v[meio] < alvo) esq = meio + 1;
else dir = meio - 1;
}
return -1;
}
Em cada iteração, o intervalo de busca é dividido pela metade, então o número de iterações é log2(n), resultando em O(log n).
Um exemplo clássico de O(n^2) é a ordenação por bolha:
void bolha(int v[], int n) {
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (v[j] > v[j+1]) {
int temp = v[j];
v[j] = v[j+1];
v[j+1] = temp;
}
}
}
}
Os dois loops aninhados executam aproximadamente n^2/2 comparações, então a complexidade é O(n^2).
Por que importa em C
Em C, a eficiência é frequentemente a prioridade máxima. Diferente de linguagens como Python, onde você pode ignorar a complexidade por um tempo, em C você está próximo do hardware e qualquer ineficiência se torna crítica. Por exemplo, em sistemas embarcados com clock limitado, um algoritmo O(n^2) pode inviabilizar um projeto se n for grande.
Além disso, C é usado em áreas como processamento de sinais, gráficos em tempo real, sistemas operacionais e simulações científicas, onde o desempenho é essencial. Dominar Big O permite que você escolha a melhor estrutura de dados e algoritmo desde o início, evitando retrabalho.
Outro ponto: em C, você tem controle sobre a memória, e a complexidade de espaço também importa. Por exemplo, um algoritmo recursivo pode ter complexidade O(n) de espaço devido à pilha de chamadas, enquanto uma versão iterativa usa O(1). Saber analisar isso ajuda a evitar estouro de pilha em sistemas com memória limitada.
Vamos comparar duas implementações de Fibonacci: a recursiva ingênua e a iterativa. A recursiva tem complexidade O(2^n) de tempo, enquanto a iterativa é O(n).
// Recursiva ingênua (O(2^n))
int fib_rec(int n) {
if (n <= 1) return n;
return fib_rec(n-1) + fib_rec(n-2);
}
// Iterativa (O(n))
int fib_iter(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; i++) {
int temp = a + b;
a = b;
b = temp;
}
return b;
}
Para n = 40, a versão recursiva pode levar segundos, enquanto a iterativa é instantânea. Isso mostra na prática como a complexidade afeta o desempenho.
Análise prática
Analisar a complexidade de um trecho de código envolve identificar os loops e recursões, e contar quantas vezes as operações principais são executadas. Vamos praticar com alguns exemplos.
Exemplo 1: Loop simples com incremento constante.
for (int i = 0; i < n; i++) {
printf("%d\n", i);
}
Executa n vezes, então O(n).
Exemplo 2: Loop com passo variável.
for (int i = 1; i < n; i *= 2) {
printf("%d\n", i);
}
O valor de i dobra a cada iteração, então o número de iterações é log2(n). Complexidade O(log n).
Exemplo 3: Dois loops aninhados independentes.
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
printf("(%d,%d)\n", i, j);
}
}
O loop interno executa n vezes para cada i, totalizando n * n iterações. Complexidade O(n^2).
Exemplo 4: Loops com dependência.
for (int i = 0; i < n; i++) {
for (int j = i+1; j < n; j++) {
printf("(%d,%d)\n", i, j);
}
}
O número de iterações é a soma de 1 a n-1, que é n(n-1)/2, que é O(n^2).
Exemplo 5: Recursão com duas chamadas.
void f(int n) {
if (n <= 1) return;
f(n-1);
f(n-1);
}
A cada chamada, o problema é reduzido em 1, mas são feitas duas chamadas recursivas. Isso gera uma árvore binária de profundidade n, totalizando 2^n chamadas. Complexidade O(2^n).
Para medir na prática, você pode usar a função clock() da biblioteca time.h para cronometrar a execução. Veja um exemplo:
#include <stdio.h>
#include <time.h>
int main() {
clock_t inicio = clock();
// chama a função que quer testar
int resultado = soma(vetor, 1000000);
clock_t fim = clock();
double tempo = (double)(fim - inicio) / CLOCKS_PER_SEC;
printf("Tempo: %f segundos\n", tempo);
return 0;
}
Lembre-se de compilar com otimizações (-O2 ou -O3) para ter uma medição realista.
Boas práticas e observações finais
Ao analisar algoritmos, alguns cuidados são importantes:
- Considere o pior caso, o caso médio e o melhor caso. Big O geralmente se refere ao pior caso.
- Constantes e fatores menores podem importar para entradas pequenas, mas para entradas grandes, a ordem de crescimento domina.
- Em C, também preste atenção à complexidade de espaço, especialmente ao usar recursão ou alocar memória dinamicamente.
- Use ferramentas de profiling (como
gprof) para identificar gargalos reais, mas a análise teórica ajuda a evitar problemas antes mesmo de codificar. - Sempre que possível, prefira algoritmos com complexidade menor, mas considere também a implementação prática e a legibilidade.
Com essas noções, você está pronto para analisar e otimizar código C de forma sistemática.
Referências
- Grande-O (Wikipedia)
- Análise Assintótica (GeeksforGeeks)
- Algoritmos (Khan Academy)
- Big-O Cheat Sheet
- Eficiência Algorítmica (CProgramming)
- Algoritmos (IME-USP)
Exercícios
- Determine a complexidade Big O da seguinte função em C:
int funcao(int n) { int cont = 0; for (int i = 0; i < n; i++) { for (int j = 0; j < 10; j++) { cont++; } } return cont; }✓ Resposta: O loop externo executanvezes, e o interno executa 10 vezes (constante). Portanto, o total de iterações é10n, que éO(n). - Qual é a complexidade da seguinte busca em um array não ordenado?
int buscar(int v[], int n, int alvo) { for (int i = 0; i < n; i++) { if (v[i] == alvo) return i; } return -1; }✓ Resposta: No pior caso, o elemento não está presente e o loop percorre todos osnelementos, resultando emO(n). - Analise a complexidade da seguinte função recursiva:
int f(int n) { if (n <= 1) return 1; return f(n-1) + f(n-1); }✓ Resposta: Cada chamada gera duas novas chamadas, formando uma árvore binária de profundidaden. O número total de chamadas é2^n - 1, então a complexidade éO(2^n). - Qual a complexidade do seguinte trecho que processa uma matriz?
for (int i = 0; i < linhas; i++) { for (int j = 0; j < colunas; j++) { matriz[i][j] = 0; } }✓ Resposta: Senfor o número total de elementos (linhas × colunas), a complexidade éO(linhas * colunas). Se considerarmoslinhas ≈ colunas ≈ n, éO(n^2). Mas se definirmosncomo o total de células, éO(n). A resposta mais comum éO(linhas * colunas). - Dado o seguinte algoritmo de ordenação por inserção, qual é a complexidade no pior caso?
void insertion_sort(int v[], int n) { for (int i = 1; i < n; i++) { int chave = v[i]; int j = i - 1; while (j >= 0 && v[j] > chave) { v[j+1] = v[j]; j--; } v[j+1] = chave; } }✓ Resposta: No pior caso (vetor em ordem decrescente), o loop while executaivezes para cadai, totalizando1+2+...+n-1 = O(n^2). Portanto, a complexidade éO(n^2).