Estrutura de Dados¶
Dado, tipo de dado (TD) e tipo abstrato de dado (TAD)¶
Antes de entrar em cada estrutura específica, vale fixar um vocabulário que aparece tanto em entrevista quanto na documentação de qualquer linguagem:
Definição: Dado
Um valor bruto, sem significado por si só — 10, "b", 2.6, true. Um dado só
vira informação quando ganha contexto (2.6 sozinho não diz nada; "a taxa de
juros é de 2.6% ao mês" diz).
Definição: Tipo de dado (TD)
A categoria em que um dado se enquadra — numérico, lógico, literal (texto/caractere).
São os tipos primitivos de uma linguagem (em Java: int, double, boolean,
char, ...; ver Java). Um TD é sempre
amarrado à linguagem de programação que o disponibiliza.
Definição: Tipo abstrato de dado (TAD)
Uma estrutura construída a partir de tipos primitivos, que representa no mundo computacional (abstrato) algo que existe no mundo real (concreto) — uma fila de banco, uma pilha de pratos, uma lista de tarefas. Ao contrário de um TD, um TAD não é amarrado a nenhuma linguagem específica: o conceito de pilha é o mesmo em Java, Python ou C, só a sintaxe muda. Todo TAD é implementado internamente usando TDs (e outros TADs), mas quem usa um TAD não precisa saber desses detalhes internos — só das operações que ele expõe (esse é o sentido de "abstrato" aqui: abstrai os detalhes de implementação, sem perder a capacidade de representar o problema real).
Os TADs mais comuns costumam ser agrupados em quatro famílias, que servem de mapa para o resto desta página:
- Lineares — elementos numa sequência, um após o outro: Vetor/Matriz, Pilha, Fila, Lista.
- Hierárquicos/relacionais — elementos conectados por relações mais ricas que "um após o outro": Árvore, Grafo.
- Hash — acesso por chave em vez de posição: Mapa/Dicionário (
Map, ver Collections Framework abaixo). - Conjuntos — sem duplicados, sem ordem garantida:
Set.
Ponteiro¶
Definição: Ponteiro
Uma forma de acessar um dado indiretamente pelo seu endereço de memória, em vez de pelo seu valor direto. Linguagens como C dão acesso explícito a esse endereço; linguagens mais modernas (Java, C#, Python) escondem esse mecanismo atrás de referências — a variável de um objeto guarda, por trás dos panos, um endereço de memória, mas a linguagem não deixa o programador manipular esse endereço diretamente (ver Referência, não valor). O ganho de esconder o ponteiro é segurança (menos classe de erro por acesso indevido a memória); o custo é uma camada a mais de abstração entre o código e o que a máquina realmente faz.
Memória: heap e stack¶
Dois tipos de memória do computador importam para quem programa: a memória principal (RAM — acesso rápido, dados voláteis, perdidos ao desligar) e a memória secundária (disco — acesso mais lento, dados duráveis). É dentro da RAM que vivem duas regiões que todo software usa constantemente: heap e stack.
Definição: Heap
Região de memória onde vivem os objetos criados dinamicamente (em Java, tudo que é
criado com new) e as variáveis de escopo global. Múltiplos programas (e, dentro de
um mesmo programa, múltiplas stacks) compartilham o mesmo heap, cada um com sua
própria área dentro dele.
Definição: Stack (pilha de execução)
Região de memória que guarda o controle de chamadas de função/método: cada chamada empilha um novo quadro com suas variáveis locais, o endereço de retorno e os parâmetros recebidos — removido assim que aquela função/método termina. Diferente do heap, o espaço que a stack ocupa é prioritariamente alocado de forma estática (decidido de antemão), enquanto o heap é alocado dinamicamente (sob demanda, conforme o programa roda). Ver a aplicação concreta desse conceito na JVM em Referência, não valor (variável de objeto na stack guardando uma referência para o objeto no heap).
Array¶
Um array é a estrutura mais básica para guardar uma coleção de valores do mesmo tipo, em posições sequenciais e de tamanho fixo — definido no momento da criação e que não muda depois.
Produto[] produtos = new Produto[10]; // array de 10 posições, todas nulas no início
produtos[0] = produto; // acesso por índice, começando em 0
Pontos que valem a pena ter na ponta da língua:
- Índices vão de
0atétamanho - 1. Um array de 10 posições vai deprodutos[0]aprodutos[9]— tentar acessarprodutos[10]estoura o índice. - Tamanho fixo é a maior limitação prática: se o array já está cheio, não existe
"adicionar mais uma posição" — seria preciso criar um array novo, maior, e copiar tudo.
É essa limitação que leva a maioria dos códigos Java a preferir as classes de
Collections (
List,Map, ...) no dia a dia, que crescem dinamicamente por trás dos panos. - Um array é, ele mesmo, um objeto — tem um atributo
length(não é método, não leva()) com o tamanho total.
for (Produto produto : produtos) { // enhanced-for (Java 5): sem índice, sem length
if (produto != null) {
System.out.println(produto.getValor());
}
}
O enhanced-for (for (Tipo item : colecao)) evita ter que controlar índice e condição
manualmente — e, com isso, evita também a classe de bug mais comum em arrays: estourar o
índice por engano (ArrayIndexOutOfBoundsException, ver
Boas Práticas).
Alguns detalhes de sintaxe e comportamento que valem a pena conhecer:
- Os colchetes podem vir logo após o tipo ou logo após o nome da variável — as duas
formas são equivalentes:
int[] x;eint x[];. - O atalho de literal (
{1, 2, 5, 7, 5}) só funciona na mesma linha da declaração — se a declaração e a atribuição estiverem em linhas separadas, é obrigatório onew: - Criar um array com tamanho negativo compila normalmente — o erro só aparece em
tempo de execução, como
NegativeArraySizeException. - Um array de tipos não primitivos (de referências) começa com todas as posições
null— acessar um membro de uma posição não preenchida lançaNullPointerException, nãoArrayIndexOutOfBoundsException(o índice existe, o objeto naquela posição que não existe ainda).
Definição: Por que acesso por índice é O(1)
Um array ocupa um bloco contíguo de memória — todas as posições uma logo depois
da outra. Isso permite calcular o endereço exato de qualquer posição direto, sem
percorrer as anteriores: endereço_base + tamanho_do_tipo × índice. Para um int
(4 bytes) começando no endereço 1000, o elemento de índice 3 está em
1000 + 4 × 3 = 1012 — o acesso é O(1) (tempo constante, não depende do tamanho
do array) justamente porque é uma conta, não uma busca elemento a elemento (isso é o
que muda numa lista ligada, ver Lista encadeada mais abaixo, onde
o acesso por posição é O(n)).
Definição: Casting de arrays
Arrays de tipos primitivos não aceitam casting entre si, mesmo entre tipos
primitivos compatíveis (int[] não vira long[] via casting). Já arrays de
referências seguem o polimorfismo do tipo que guardam: um String[] pode ser
atribuído a uma variável Object[] sem casting (toda classe herda de Object), mas
o caminho contrário exige casting explícito — e, se o array não for realmente
daquele tipo em tempo de execução, lança ClassCastException:
Array multidimensional¶
Java não tem "array 2D" como um conceito à parte — um array multidimensional é, na prática, um array de arrays (e um array de arrays de arrays, para 3 dimensões, e assim por diante):
int[][] table = new int[10][15]; // 10 arrays, cada um com 15 posições
table[0][1] = 5; // linha 0, coluna 1
É possível inicializar só a primeira dimensão e deixar as demais para depois:
int[][] cube = new int[10][][]; // 10 posições, cada uma ainda null
cube[0] = new int[5][]; // preenchendo a primeira só quando precisar
Ou inicializar diretamente com valores conhecidos, aninhando chaves:
Definição: Array não retangular (jagged array)
Como um array multidimensional é só um array de arrays, cada "linha" pode ter um tamanho diferente das outras — não existe obrigação de ser "quadrado" ou retangular:
Pilha (Stack)¶
Uma pilha organiza elementos de forma que só é possível inserir e remover por um único ponto, o topo — como uma pilha de pratos: o último prato colocado é o primeiro a ser retirado.
Definição: LIFO
Last in, first out — "o último a entrar é o primeiro a sair". É o comportamento de uma pilha, e o jeito mais comum de se referir a ela (o oposto, FIFO, descreve a fila — ver abaixo).
As operações centrais de uma pilha:
| Operação | Faz |
|---|---|
push(x) |
empilha x no topo |
pop() |
desempilha e devolve o elemento do topo |
top() / peek() |
devolve o elemento do topo, sem removê-lo |
size() |
quantidade de elementos |
isEmpty() |
true se a pilha está vazia |
Deque<String> pilha = new ArrayDeque<>(); // Java não tem uma classe "Stack" moderna dedicada — Deque é a recomendada
pilha.push("A");
pilha.push("B");
pilha.peek(); // "B", sem remover
pilha.pop(); // "B", remove
pilha.pop(); // "A", remove
Definição: por que Deque, e não a classe Stack
Java tem uma classe java.util.Stack histórica (desde o Java 1.0), mas a própria
documentação oficial recomenda evitá-la — ela estende Vector (uma lista antiga,
sincronizada, mais lenta) por razões históricas, não porque faça sentido uma pilha
"ser" uma lista. A alternativa recomendada é usar uma Deque (double-ended queue,
fila de duas pontas) só pelo lado de pilha, com push/pop/peek — a implementação
mais comum é ArrayDeque.
Onde uma pilha aparece na prática¶
- Pilha de chamadas de método (call stack) — cada método chamado empilha um quadro; quando retorna, é desempilhado. Ver Memória: heap e stack.
- Desfazer (
ctrl+z) — cada ação editável é empilhada; desfazer é umpop. - Balanceamento de expressões — verificar se
(,[,{fecham corretamente numa expressão comoA + (B * [C - D]) - {E / F}: a cada abertura, empilha o símbolo; a cada fechamento, desempilha e confere se é o par esperado. Sobrou algo na pilha no final, ou tentou desempilhar uma pilha vazia? A expressão está malformada. - Recursão — toda chamada recursiva usa a pilha de chamadas por baixo dos panos (ver
Recursividade mais abaixo); é por isso que recursão sem caso de
parada estoura
StackOverflowError.
Definição: Notação infixada, prefixada e pós-fixada
Três formas equivalentes de escrever uma expressão aritmética, segundo a posição do
operador em relação aos operandos: infixada é a usual (1 + 2, operador entre
os operandos); prefixada vem antes (+ 1 2); pós-fixada (também chamada
notação polonesa reversa) vem depois (1 2 +). Calculadoras e compiladores usam
pilhas para converter entre essas notações ou para avaliar uma expressão pós-fixada
diretamente (empilhando operandos, e a cada operador desempilhando os dois últimos
para aplicar a conta e empilhar o resultado).
Fila (Queue)¶
Uma fila organiza elementos de forma que a inserção e a remoção acontecem em pontos opostos — como uma fila de banco: quem chega primeiro, entra no fim da fila; quem sai primeiro, é quem está na frente.
Definição: FIFO
First in, first out — "o primeiro a entrar é o primeiro a sair". É o comportamento de uma fila comum.
| Operação | Faz |
|---|---|
enqueue(x) / offer(x) |
insere x no fim da fila |
dequeue() / poll() |
remove e devolve o elemento da frente |
peek() |
devolve o elemento da frente, sem removê-lo |
Queue<String> fila = new LinkedList<>(); // LinkedList implementa a interface Queue
fila.offer("A");
fila.offer("B");
fila.peek(); // "A" — o primeiro a entrar
fila.poll(); // "A", remove
Onde uma fila aparece na prática: fila de processamento (tarefas executadas na ordem em que chegaram), buffers de rede (pacotes chegando mais rápido do que são processados), percorrer uma árvore/grafo em largura (ver Árvore e Grafo abaixo).
Definição: variações de fila
Fila de prioridade (priority queue) — a ordem de saída não é por chegada, e sim por uma prioridade definida (o próximo a sair é sempre o de maior/menor prioridade, não necessariamente o mais antigo). Deque (double-ended queue) — permite inserir e remover nas duas pontas, servindo tanto de pilha quanto de fila conforme o lado usado.
Lista encadeada¶
Diferente de pilha e fila, uma lista não impõe nenhum comportamento de acesso — não existe "topo" nem "frente", os elementos só estão ligados linearmente, um apontando para o próximo. Cada elemento (chamado de nó) guarda dois pedaços de informação: o valor e uma referência para o próximo nó.
Definição: Simplesmente x duplamente encadeada
Numa lista simplesmente encadeada, cada nó só conhece o próximo — navegar só
é possível num sentido, do início para o fim. Numa lista duplamente encadeada,
cada nó guarda referência para o próximo e para o anterior — navega nos dois
sentidos, e permite começar a busca pela ponta mais próxima do índice desejado (é
assim que LinkedList do Java funciona por trás dos panos).
Definição: Lista circular
Variação em que o último nó, em vez de apontar para null, aponta de volta para o
primeiro — não existe mais um "fim" absoluto, útil para percorrer indefinidamente em
ciclo (ex.: alternar entre jogadores num jogo de tabuleiro).
O que muda, na prática, entre um array/ArrayList e uma lista encadeada é onde cada
operação é rápida:
| Operação | Array / ArrayList |
Lista encadeada |
|---|---|---|
Acessar por índice (get(i)) |
O(1) — é uma conta de endereço (ver definição mais acima nesta página) | O(n) — precisa navegar nó a nó a partir de uma ponta |
| Inserir/remover no início | O(n) — precisa deslocar todos os elementos seguintes | O(1) — só reaponta algumas referências |
| Inserir/remover no fim | O(1) amortizado (ArrayList), O(n) se precisar redimensionar |
O(1) se a lista guarda referência para o último nó |
| Inserir/remover no meio | O(n) (desloca o restante) | O(n) para achar a posição, O(1) para religar os ponteiros depois de achada |
Definição: Por que escolher um sobre o outro
Se o uso predominante é acessar por posição/percorrer (ex.: iterar e ler), um
array/ArrayList é mais rápido — acesso contíguo em memória também é mais amigável
ao cache do processador. Se o uso predominante é inserir/remover nas pontas
(ex.: implementar uma fila ou uma pilha), uma lista encadeada evita o custo de
deslocar elementos. Ver ArrayList x LinkedList abaixo, na implementação
concreta em Java.
Collections Framework¶
Um array resolve pouco: tamanho fixo, sem método de busca/remoção pronto, sem padronização entre diferentes estruturas. O Collections Framework (desde o Java 1.2) resolve isso com uma família de interfaces e implementações prontas para guardar e manipular grupos de objetos — a diferença entre as estruturas está em como os dados são organizados e em quais operações são rápidas em cada uma.
graph TD
C["«interface» Collection"] --> L["«interface» List"]
C --> S["«interface» Set"]
L --> AL[ArrayList]
L --> LL[LinkedList]
S --> HS[HashSet]
S --> TS[TreeSet]
Regra geral de bom senso: programe voltado para a interface (List, Set, Map),
não para a implementação concreta (ArrayList, HashSet, HashMap). Declarar variáveis,
atributos e retornos de método como a interface deixa o código livre para trocar a
implementação depois (ex.: de ArrayList para LinkedList) sem quebrar nada que usa
esse código — só o new muda.
Definição: Generics
Recurso (desde o Java 5) que permite parametrizar o tipo que uma estrutura vai
guardar — List<Produto> só aceita Produto. Sem generics, uma lista trabalha com
Object genérico, obrigando casting manual (e o risco de ClassCastException) toda
vez que um valor é recuperado. Desde o Java 7, o diamond operator evita repetir o
tipo genérico dos dois lados: List<Produto> produtos = new ArrayList<>();.
List¶
Uma List guarda elementos em ordem de inserção, permite duplicados, e acessa por
índice — é o substituto direto e mais flexível de um array.
ArrayList— implementação mais comum; mantém um array internamente (encapsulado), mas cresce dinamicamente. Mais rápida para percorrer/ler.LinkedList— lista duplamente encadeada (cada elemento aponta para o próximo e para o anterior); mais rápida para inserir/remover elementos nas pontas, mas O(n) para acessar por índice no meio da lista — ver a comparação completa de complexidade acima.
List<Produto> produtos = new ArrayList<>();
produtos.add(produto);
produtos.remove(produto); // remove pelo valor
produtos.contains(produto); // busca
Principais métodos de List/ArrayList, além de add/remove/contains:
| Método | Faz |
|---|---|
get(indice) |
elemento numa posição |
set(indice, valor) |
substitui o elemento numa posição, devolve o antigo |
add(indice, valor) |
insere numa posição específica (sobrecarga de add(valor)) |
remove(indice) |
remove pela posição — cuidado: remove(int) remove por índice, remove(Object) remove pelo valor |
size() |
quantidade de elementos |
indexOf(valor) / lastIndexOf(valor) |
posição da primeira/última ocorrência (-1 se não achar) |
toArray() |
converte para um array |
addAll(outraColecao) |
adiciona todos os elementos de outra coleção |
Definição: remove(int) x remove(Object)
Em uma ArrayList<Integer>, list.remove(1) é ambíguo à primeira vista: remove o
elemento na posição 1, ou remove o valor 1? A regra do Java: se o argumento
é um int primitivo, chama a sobrecarga por índice; para remover pelo valor 1
(um Integer), é preciso fazer autoboxing explícito: list.remove(Integer.valueOf(1)).
Com ArrayList<String> essa ambiguidade não existe (String nunca é confundida com
índice).
toArray() sempre devolve um array novo: se você passar um array como argumento
(toArray(new String[0])), ele é usado como está se for grande o suficiente, ou um outro
é criado do mesmo tipo, caso contrário.
Definição: Cuidado ao sobrescrever equals para comparar em coleções
contains, remove e indexOf de uma coleção usam equals para decidir se um
elemento "é aquele" — a implementação padrão (herdada de Object) compara só
referência. Sobrescrever equals recebendo um tipo específico em vez de Object (ver
Orientação a Objetos) faz um overload, não um
override — a coleção continua chamando o equals(Object) original, e o
comportamento de busca não muda como esperado.
Iterator¶
A interface Iterator permite percorrer qualquer coleção de forma padronizada, sem
depender de índice (útil especialmente para coleções que não têm, como Set):
Iterator<String> iterator = strings.iterator();
while (iterator.hasNext()) {
String current = iterator.next(); // avança e devolve o elemento
System.out.println(current);
}
hasNext()—truese ainda há elemento a percorrer.next()— devolve o elemento atual e avança para o próximo.remove()— remove da coleção o último elemento devolvido pornext().
O enhanced-for usa Iterator por baixo dos panos — por isso funciona igual para
qualquer Collection (List, Set, ...), não só para List.
Set¶
Um Set é uma coleção que não permite duplicados e, diferente de List, não
garante ordem de inserção — modela o conceito matemático de conjunto.
HashSet— implementação mais comum; usa uma tabela de dispersão baseada emhashCode/equals(ver Java) para decidir rapidamente se um elemento já existe — é isso que tornacontainsnumHashSetO(1) em média, muito mais rápido que numListgrande (que precisa comparar item a item, O(n)). Não garante nenhuma ordem de iteração.LinkedHashSet— comoHashSet, mas mantém a ordem de inserção ao iterar (custo extra de manter uma lista encadeada por baixo, junto com a tabela de dispersão).TreeSet— mantém os elementos ordenados (usa uma árvore binária de busca por baixo dos panos), ao custo de inserção/busca O(log n) em vez de O(1).
Set<String> cupons = new HashSet<>();
cupons.add("CUP74");
cupons.add("CUP74"); // ignorado — já existe
cupons.size(); // 1
Map¶
Um Map associa chave → valor (um dicionário) — útil sempre que a busca precisa
ser feita por uma chave única, em vez de por posição.
Map<String, Double> cupons = new HashMap<>();
cupons.put("CUP74", 10.0);
Double desconto = cupons.get("CUP74"); // 10.0
cupons.containsKey("CUP74"); // true
Assim como Set, HashMap (a implementação mais comum) depende de hashCode/equals
da chave — via tabela de dispersão — para localizar
valores rapidamente. LinkedHashMap e TreeMap seguem o mesmo padrão de LinkedHashSet
e TreeSet: ordem de inserção preservada, ou chaves ordenadas, respectivamente.
Definição: HashMap x Hashtable
Hashtable é a classe original de Java para mapa chave-valor (desde a versão 1.0) —
hoje considerada legada. HashMap (desde o Java 1.2, parte do Collections
Framework) é a substituta recomendada: mesma ideia, porém sem a sincronização
desnecessária que torna Hashtable mais lenta na maioria dos usos (single-thread).
Ordenando com Comparable¶
Collections.sort(lista) ordena qualquer List — mas o método sort precisa saber
como comparar os elementos entre si. Isso é feito implementando a interface
Comparable<T> e seu método compareTo:
public abstract class Livro implements Produto, Comparable<Produto> {
@Override
public int compareTo(Produto outro) {
if (this.getValor() < outro.getValor()) return -1;
if (this.getValor() > outro.getValor()) return 1;
return 0;
}
}
Definição: Contrato de compareTo
Retorna um número negativo se o objeto atual (this) deve vir antes do
argumento na ordenação, positivo se deve vir depois, e 0 se são equivalentes
para fins de ordenação. Uma forma mais compacta e comum de escrever a mesma lógica
para números: return (int) (this.getValor() - outro.getValor()); (ou
Integer.compare(a, b) para tipos inteiros).
Árvore¶
Os TADs vistos até aqui (vetor, pilha, fila, lista) são todos lineares — um elemento após o outro. Uma árvore é o primeiro TAD hierárquico: os elementos têm uma relação de subordinação entre si, como um organograma de empresa ou uma árvore genealógica.
Definição: Árvore
Estrutura de dados onde os elementos (nós) possuem um vínculo do tipo pai e filho, criando uma relação hierárquica. Cada nó pode ter vários filhos, mas um único pai (exceto a raiz, que não tem pai nenhum).
Vocabulário essencial¶
- Raiz (root) — o nó inicial, único sem pai.
- Nó (node) — qualquer elemento da árvore.
- Nó interno — tem pelo menos um filho.
- Nó folha (leaf) — não tem filho nenhum (fim de um "ramo").
- Ancestral / descendente — nós que vêm antes/depois de um nó, no caminho até a raiz.
- Profundidade (depth) — a distância, em nós, da raiz até a folha mais distante.
- Subárvore — qualquer nó, junto com todos os seus descendentes, forma uma árvore menor dentro da árvore maior — é por isso que algoritmos em árvore costumam ser recursivos (ver Recursividade): resolver para uma subárvore é o mesmo problema que resolver para a árvore inteira, só que menor.
Árvore binária e árvore binária de busca¶
Definição: Árvore binária
Árvore em que todo nó tem, no máximo, 2 filhos — comumente chamados de filho à esquerda e filho à direita.
Definição: Árvore binária de busca (BST)
Uma árvore binária ordenada: para todo nó, os valores da subárvore à esquerda são menores que o nó, e os valores da subárvore à direita são maiores. Essa regra vale recursivamente em cada subárvore, não só no nível raiz — é o que torna a busca por um valor rápida: a cada nó visitado, dá para descartar metade dos nós restantes (esquerda ou direita), igual a uma busca binária num array ordenado.
Percorrendo uma árvore: pré-ordem, em-ordem, pós-ordem¶
Três formas clássicas de visitar todos os nós de uma árvore binária, todas recursivas, diferindo só em quando a raiz é visitada em relação às suas subárvores:
| Percurso | Ordem | Uso típico |
|---|---|---|
| Pré-ordem (pre-order) | raiz → esquerda → direita | copiar/serializar a árvore (a raiz vem primeiro, então dá para reconstruir a estrutura lendo em sequência) |
| Em-ordem (in-order) | esquerda → raiz → direita | numa BST, visita os valores em ordem crescente — é a forma de "extrair" uma lista ordenada de uma BST |
| Pós-ordem (post-order) | esquerda → direita → raiz | apagar a árvore com segurança (apaga os filhos antes do pai) |
AVL: mantendo a árvore balanceada¶
Uma BST comum pode ficar desbalanceada — no pior caso (inserir valores já ordenados), ela degenera numa lista encadeada, e a busca deixa de ser rápida (O(log n)) e vira O(n).
Definição: Árvore AVL
Uma árvore binária de busca que se rebalanceia sozinha: a cada inserção ou remoção, se a diferença de profundidade entre a subárvore esquerda e a direita de algum nó passar de 1, a árvore aplica uma rotação (reorganização local dos nós) para restaurar o balanceamento. O resultado é que buscas, inserções e remoções continuam O(log n) mesmo no pior caso — a árvore nunca degenera numa lista.
Definição: AVL x árvore rubro-negra — qual usar
As duas são BSTs autobalanceadas, mas com um trade-off diferente: a AVL fica "mais balanceada" (mais rígida), o que deixa a busca mais rápida, mas como qualquer inserção/remoção tende a disparar mais rotações, ela é mais cara para escrever. A árvore rubro-negra aceita ficar "quase balanceada", fazendo menos rotações (escrita mais rápida), ao custo de buscas um pouco mais lentas. Regra prática: dados que mudam pouco e são lidos muito → AVL; dados com muita inserção/remoção → rubro-negra.
Vale notar também a escolha entre árvore binária e árvore N-ária (nó pode ter mais de 2 filhos): binárias tendem a ficar mais profundas com muitos elementos (mais chamadas recursivas, mais uso de pilha de execução); N-árias ficam mais "largas" e menos profundas, ao custo de mais memória por nó (espaço para mais ponteiros de filho).
Existem dezenas de variações especializadas além da BST/AVL — árvores B/B+ (usadas internamente por índices de banco de dados), árvore heap (ver Heap abaixo), árvore Trie (usada para autocompletar e dicionários) — cada uma otimizada para um problema específico; não existe uma única "melhor árvore".
Grafo¶
Uma árvore é hierárquica (todo nó tem só um pai); um grafo relaxa até essa regra — qualquer elemento pode se relacionar livremente com qualquer outro. É o TAD mais genérico e mais complexo, mas também o mais comum na prática: uma rede social, o mapa de rotas do Google Maps e a malha de conexões de uma rede de computadores são todos grafos.
Definição: Grafo
Estrutura de dados onde os elementos (vértices) podem estar livremente relacionados entre si, através de arestas. Diferente de árvore, não existe conceito de raiz nem de direção obrigatória "de cima para baixo".
Vocabulário essencial¶
- Vértice (nó) — o elemento do grafo (equivalente ao nó de uma árvore).
- Aresta — a conexão entre dois vértices; pode ter peso (um custo/distância associado) e pode ser um laço (loop — uma aresta que liga um vértice a ele mesmo).
- Vértices adjacentes — vértices ligados diretamente por uma aresta.
- Grau de um vértice — a quantidade de arestas que ele possui.
- Caminho — a sequência de arestas percorridas para ir de um vértice a outro.
Tipos de grafo¶
| Tipo | Significa |
|---|---|
| Direcionado (dígrafo) | as arestas têm sentido — (A, B) é diferente de (B, A) |
| Não direcionado | as arestas não têm sentido — ir de A para B é o mesmo que de B para A |
| Ponderado | as arestas têm peso (custo, distância, ...) |
| Cíclico / acíclico | existe (ou não existe) algum caminho que sai de um vértice e volta a ele mesmo |
| Conexo | existe caminho de qualquer vértice até qualquer outro |
| Completo | todo vértice está conectado a todos os outros |
Definição: Árvore é um tipo particular de grafo
Uma árvore é, tecnicamente, um grafo simples, acíclico e conexo — a hierarquia (raiz, pai/filho) é uma restrição adicional que o grafo genérico não impõe.
Representações¶
- Matriz de adjacência — uma tabela
V × V(V= quantidade de vértices); a posição[A][B]marca1se existe aresta deAparaB,0caso contrário. Simples de implementar e de verificar "existe aresta entre X e Y" (O(1)), mas desperdiça memória quando o grafo tem poucas arestas em relação à quantidade de vértices (grafo esparso). - Lista de adjacência — cada vértice guarda uma lista só dos vértices com quem tem aresta. Mais econômica em memória para grafos esparsos (a grande maioria dos casos reais), ao custo de "existe aresta entre X e Y" não ser mais O(1) (precisa percorrer a lista daquele vértice).
Percorrendo um grafo: busca em profundidade x em largura¶
As mesmas duas estratégias de busca se aplicam tanto a árvores quanto a grafos:
Definição: Busca em profundidade (DFS — depth-first search)
A partir de um vértice inicial, avança o mais fundo possível por um caminho antes de retroceder e tentar outro. Implementação típica usa pilha (explícita, ou a pilha de execução via recursão). Mais simples de implementar, mas pode explorar caminhos bem mais longos que o necessário antes de achar o destino.
Definição: Busca em largura (BFS — breadth-first search)
A partir de um vértice inicial, visita todos os vizinhos diretos antes de avançar para os vizinhos dos vizinhos — expande "em camadas". Implementação típica usa fila. Mais custosa de implementar, mas encontra o caminho mais curto (em número de arestas) primeiro, quando o grafo não é ponderado.
Em ambos os casos, cada vértice visitado é marcado como processado, para não entrar em loop (visitar o mesmo vértice repetidamente através de um ciclo).
Problemas e algoritmos clássicos¶
- Caminho mínimo — menor custo (soma de pesos) para ir de um vértice a outro; é o problema que o Google Maps resolve a cada rota traçada. O algoritmo clássico é o de Dijkstra: a partir do vértice de origem, sempre avança pela aresta de menor peso ainda não avaliada, descartando os caminhos já demonstrados como piores.
- Caixeiro-viajante (travelling salesman) — encontrar a rota mais barata que visita um conjunto de vértices exatamente uma vez e retorna à origem (aplicação direta em logística de entregas). É um problema NP-difícil — não existe algoritmo eficiente conhecido para resolvê-lo exatamente em grafos grandes; soluções práticas usam heurísticas como o vizinho mais próximo (sempre segue para o vértice não visitado mais barato), que é rápido mas não garante a melhor resposta possível ("processamento guloso" — bom o suficiente na maioria dos casos, sem garantia de ótimo).
- Coloração de grafos — colorir vértices (ou regiões de um mapa) de forma que vértices adjacentes nunca tenham a mesma cor, usando o menor número de cores possível (o teorema das quatro cores prova que 4 cores sempre bastam para qualquer mapa planar).
Onde um grafo aparece na prática¶
Redes sociais (pessoas = vértices, conexões = arestas), mapas e rotas, topologia de redes de computadores, e máquinas de estado (cada estado é um vértice, cada transição possível é uma aresta direcionada — útil para modelar fluxos como o de um pagamento passando por "verificação → autorizado/não autorizado → entregue").
Tabela de dispersão (Hashtable)¶
Vetor/lista/árvore resolvem bem o acesso rápido em cenários diferentes, mas todos têm um custo em algum caso: array tem tamanho fixo, lista é O(n) para achar um elemento no meio, árvore ordenada pode ficar profunda demais. A tabela de dispersão ataca isso de outro ângulo: em vez de guardar o elemento numa posição relacionada à ordem de inserção, ela calcula a posição a partir do próprio valor (ou de uma chave associada a ele).
Definição: Tabela de dispersão (hashtable) e função hash
Estrutura que usa uma função hash — que recebe um valor (ou chave) e devolve um
número — para calcular diretamente onde guardar/buscar aquele elemento, sem
precisar navegar pelos outros. O resultado é acesso, inserção e remoção em média
O(1), independente de quantos elementos já existem na estrutura. É a base de
implementação de HashSet, HashMap e Set/Map em praticamente toda linguagem.
Definição: Colisão de hash
Quando a função hash calcula o mesmo endereço para dois valores diferentes.
Colisões são inevitáveis (a quantidade de valores possíveis é sempre maior que a
quantidade de endereços disponíveis) — o que muda entre implementações é a
estratégia de tratamento (ex.: guardar uma lista de elementos em cada endereço,
em vez de um só). Isso não é um detalhe de implementação irrelevante: é o motivo pelo
qual hashCode/equals bem implementados importam tanto para o desempenho de um
HashSet/HashMap (ver Orientação a Objetos) —
um hashCode que gera colisão para muitos valores diferentes degrada o O(1) esperado
de volta para O(n).
Heap¶
Heap (também chamada binary heap) é uma árvore binária com uma regra extra de ordenação, focada em um único objetivo: achar rapidamente o maior (ou o menor) elemento do conjunto.
Definição: Propriedade heap
Toda árvore binária que respeita: max-heap — todo nó pai é maior ou igual aos seus filhos; ou min-heap — todo nó pai é menor ou igual aos seus filhos. Diferente de uma BST, não existe regra de esquerda-menor/direita-maior — só importa a relação vertical (pai x filho).
Como o maior (numa max-heap) ou o menor (numa min-heap) elemento está sempre na raiz, consultá-lo é O(1). Remover a raiz exige reorganizar a árvore (movendo outro nó para o lugar dela e "afundando-o" até restaurar a propriedade heap) — é assim que uma heap implementa uma fila de prioridade de forma eficiente: o próximo elemento a sair (maior ou menor prioridade) já está sempre no topo, pronto para ser consultado.
Recursividade¶
Recursividade não é um TAD, mas é a técnica que torna praticável trabalhar com árvores e grafos — ambos têm uma natureza que se repete em escala menor (uma subárvore é uma árvore; navegar a partir de um vizinho é o mesmo problema de navegar a partir do vértice original), o que se encaixa naturalmente numa função que chama a si mesma.
Definição: Recursividade
Quando uma função (ou método) chama a si mesma, repetidamente, até atingir uma condição de parada. Toda solução recursiva tem três partes obrigatórias:
- Teste de parada (caso base) — a condição que encerra a recursão. Sem essa parte, a função chama a si mesma indefinidamente.
- Operação a ser realizada — o trabalho feito nesta chamada específica.
- Chamada recursiva — a função chamando a si mesma para uma versão menor ou mais simples do problema original.
int fatorial(int n) {
if (n <= 1) return 1; // 1. caso base
return n * fatorial(n - 1); // 2. e 3. operação + chamada recursiva
}
Definição: Por que recursão sem caso de parada estoura a pilha
Cada chamada recursiva empilha um novo quadro na pilha de
execução — e só é desempilhado quando aquela chamada
específica retorna. Sem um caso de parada alcançável, as chamadas se empilham
indefinidamente até a memória reservada para a pilha se esgotar, lançando
StackOverflowError (Java) ou equivalente.
Qualquer solução recursiva também pode ser escrita de forma iterativa (com
for/while) — a escolha entre as duas é sobre clareza, não sobre capacidade: alguns
problemas (percorrer uma árvore, calcular Fibonacci, calcular um fatorial, dividir um
problema em subproblemas menores — divide and conquer) ficam bem mais simples de ler
e escrever de forma recursiva, ao custo de usar mais memória (uma chamada de pilha por
nível) do que a versão equivalente com loop.