Trilha 04 · Estruturas de dados

Arrays e listas ligadas

Toda estrutura de dados do resto desta trilha é construída sobre uma destas duas ideias: guardar tudo junto ou guardar espalhado e ligar com ponteiros. Essa única escolha de layout de memória decide quais operações serão baratas e quais serão caras — e a resposta é diferente do que a notação Big O sozinha sugere.

① Intuição

Uma rua de casas numeradas vs uma caça ao tesouro

Um array é uma rua onde as casas são numeradas em sequência e todas têm o mesmo tamanho. Você quer a casa 47? Não precisa perguntar a ninguém: multiplica e vai direto. Mas se quiser inserir uma casa nova entre a 10 e a 11, todo mundo da 11 em diante precisa se mudar uma casa para a frente.

Uma lista ligada é uma caça ao tesouro: cada pista diz onde está a próxima. Para inserir uma pista nova no meio, basta reescrever duas pistas — ninguém se muda. Mas para chegar à 47ª pista você tem que seguir as 46 anteriores, uma por uma.

O trade-off em uma frase: o array paga caro para mudar de tamanho no meio e é imbatível para acessar por posição. A lista ligada é o inverso: reorganizar é barato, localizar é caro. Nenhuma das duas é "melhor" — a pergunta certa é o que o seu programa faz com mais frequência.
② Visualização interativa

Veja o custo real de cada operação

Insira e remova nas duas estruturas e observe quantos elementos precisam se mover. Repare nos endereços de memória: no array eles são consecutivos; na lista ligada são aleatórios.

Um array vive em memória contígua: o endereço do elemento i é base + i × tamanho. Por isso o acesso é O(1) — mas inserir ou remover no meio obriga a deslocar todos os elementos seguintes.

12
[0]
3e8
45
[1]
3f0
7
[2]
3f8
83
[3]
400
índice · endereço de memória (hex)
0
③ Explicação técnica

Array: aritmética de endereços

# Um array é um bloco contíguo de memória.
# Endereço do elemento i = base + i * tamanho_do_elemento
#
#   base = 0x1000, cada int ocupa 8 bytes
#   lista[0] → 0x1000
#   lista[3] → 0x1000 + 3*8 = 0x1018
#
# A CPU calcula isso com uma multiplicação e uma soma: O(1).
# Não importa se o array tem 10 ou 10 bilhões de elementos.

lista = [12, 45, 7, 83]

lista[2]                # O(1)  — aritmética de endereço
lista.append(99)        # O(1)* — só escreve no fim
lista.insert(0, 99)     # O(n)  — desloca TODOS os elementos
lista.pop()             # O(1)  — remove do fim
lista.pop(0)            # O(n)  — desloca todos para a esquerda
99 in lista            # O(n)  — precisa comparar um a um

# * amortizado. Veja "array dinâmico" mais abaixo.

Array dinâmico: por que append é O(1) amortizado

A list do Python, o ArrayList do Java e o Vec do Rust não são arrays de tamanho fixo: são arrays dinâmicos. Eles guardam mais capacidade do que o necessário e dobram quando enchem.

# Por que append é O(1) AMORTIZADO e não O(1) puro?
#
# Um array tem tamanho fixo na memória. Quando enche, a linguagem:
#   1. aloca um array novo com o DOBRO da capacidade
#   2. copia todos os n elementos                    ← O(n)
#   3. libera o array antigo
#
# Capacidade: 4 → 8 → 16 → 32 → 64 ...
# Inserindo 64 elementos, as cópias custam 4+8+16+32 = 60 operações.
# Somando: ~2n operações para n inserções → 2 por inserção, em média.
#
# É por isso que dobrar (e não somar +1) é essencial:
# crescer de 1 em 1 daria O(n) por inserção — O(n²) no total.

import sys
lista = []
for i in range(20):
    lista.append(i)
    print(len(lista), sys.getsizeof(lista))  # veja os saltos de capacidade

Lista ligada: nós e ponteiros

# Uma lista ligada: nós espalhados pela memória, conectados por ponteiros.
class No:
    def __init__(self, valor):
        self.valor = valor
        self.proximo = None      # ponteiro para o próximo nó

class ListaLigada:
    def __init__(self):
        self.head = None

    def inserir_inicio(self, valor):   # O(1) — sem deslocar nada
        no = No(valor)
        no.proximo = self.head
        self.head = no

    def buscar(self, valor):           # O(n) — caminha nó a nó
        atual = self.head
        while atual:
            if atual.valor == valor:
                return atual
            atual = atual.proximo
        return None

    def remover_depois(self, no):      # O(1) — só religa um ponteiro
        if no.proximo:
            no.proximo = no.proximo.proximo

# Não existe "lista[500]". Para chegar no 500º nó,
# é preciso seguir 500 ponteiros — um de cada vez.
Variantes que valem conhecer: a lista duplamente ligada guarda também um ponteiro para o nó anterior — isso permite remover um nó em O(1) tendo só ele em mãos (sem precisar do anterior) e percorrer nos dois sentidos, ao custo de mais um ponteiro por nó. A lista circular faz o último nó apontar de volta para o primeiro, útil para round-robin de tarefas. O collections.deque do Python é, por dentro, uma lista duplamente ligada de blocos de array — um híbrido que tenta pegar o melhor dos dois mundos.

O fator que o Big O esconde: cache

# O detalhe que os livros de complexidade não contam:
# a CPU não lê 1 byte da RAM — ela lê uma LINHA DE CACHE (64 bytes).
#
# Array: os elementos são vizinhos na memória.
#   Ler lista[0] já traz lista[1..7] para o cache L1 de graça.
#   Percorrer 1 milhão de ints = ~125 mil buscas na RAM.
#
# Lista ligada: cada nó está num lugar aleatório.
#   Cada "atual = atual.proximo" pode ser um cache miss.
#   Percorrer 1 milhão de nós = ~1 milhão de buscas na RAM.
#
# Um cache miss custa ~100x mais que um acerto no L1.
# Na prática, percorrer um array pode ser 10-50x mais rápido que
# percorrer uma lista ligada — mesmo os dois sendo O(n).
#
# Por isso listas ligadas puras são raras em código moderno:
# o Python usa array dinâmico para list, o Java prefere ArrayList
# a LinkedList, e o C++ recomenda vector como padrão.
④ Projeto para programar

Construindo do zero

Mini projeto: implemente seu próprio array dinâmico. Comece com um array de capacidade 2 (em Python, use uma list de tamanho fixo preenchida com None, ou o módulo array). Implemente append, get(i) e __len__, dobrando a capacidade quando encher. Imprima o número total de cópias após 1000 inserções e compare com a previsão teórica de ~2n.

Projeto principal: implemente uma lista duplamente ligada completa com inserir_inicio, inserir_fim, remover(no) e iteração nos dois sentidos. Depois use-a para escrever um histórico de navegador: botões "voltar" e "avançar" que andam pela lista sem recriar nada. Compare com fazer o mesmo usando duas pilhas — qual fica mais simples?

Desafio extra: detecte se uma lista ligada tem um ciclo (o último nó aponta de volta para algum nó anterior) usando apenas O(1) de memória extra. A resposta é o algoritmo da lebre e da tartaruga de Floyd: dois ponteiros, um andando 1 nó por vez e outro 2. Se houver ciclo, eles se encontram. Prove por que isso sempre funciona.

⑤ Exercícios rápidos

Teste sua intuição

Inserir no início custa O(n) num array e O(1) numa lista ligada. Por quê?
Percorrer um array e percorrer uma lista ligada são ambos O(n). Por que, na prática, o array costuma ser muito mais rápido?
Qual é a complexidade de lista.append(x) num array dinâmico?
Você precisa de uma estrutura para uma playlist onde o usuário arrasta músicas para reordenar constantemente, mas quase nunca pula direto para "a música nº 300". O que escolher?
⑥ Aplicações no mundo real

Onde você encontra isso

📜

Buffer de texto de um editor

Editores como o VS Code não guardam o texto num único array gigante — mover um caractere no início de um arquivo de 10 MB deslocaria 10 milhões de bytes a cada tecla. A solução clássica é o gap buffer (array com um "buraco" onde está o cursor) ou a piece table (uma lista de trechos que apontam para o arquivo original e para um buffer de adições). Ambos existem exatamente para fugir do O(n) de inserção no meio de um array.

🧠

Listas de páginas livres do sistema operacional

O kernel do Linux mantém as páginas de memória livres em listas duplamente ligadas — e os ponteiros ficam dentro das próprias páginas livres, então a estrutura não custa memória extra. Como as operações são "pegue qualquer página" e "devolva esta página", o acesso por índice nunca é necessário e o O(1) de inserção/remoção é tudo o que importa.

Motores de jogos e o padrão "array of structs"

Jogos que rodam a 60 fps processam milhares de entidades por quadro. Motores modernos usam ECS (Entity Component System), que guarda os componentes em arrays contíguos justamente para maximizar acertos de cache. É o mesmo Big O que uma lista de objetos espalhados, mas pode ser uma ordem de grandeza mais rápido — a diferença entre 60 fps e 6 fps.

← Trilha: Estruturas de dados Próxima: Pilhas, filas e deques →