Listas, Conjuntos e Mapas — o Collections Framework

Listas, Conjuntos e Mapas — o Collections Framework

O Collections Framework do Java: interfaces e implementações, ArrayList e a armadilha do remove, listas imutáveis, o iterador e a ConcurrentModificationException, HashSet, LinkedHashSet e TreeSet, mapas, filas e pilhas com ArrayDeque, o custo de cada operação e como escolher.
Java

• • 18 min de leitura

As estruturas de dados prontas da biblioteca padrão, o que cada uma custa e como escolher a certa para cada problema.

Em "Escreva uma Vez para Todo Tipo" você construiu uma Lista<E>: um array que cresce sozinho, escondido atrás de uma interface genérica. Foi o primeiro passo. A biblioteca padrão traz dezenas de estruturas desse tipo, prontas, testadas e otimizadas ao longo de mais de vinte e cinco anos: o Collections Framework. Nesta aula você vai conhecer as três famílias principais, listas, conjuntos e mapas, além das filas, e vai aprender o critério que mais importa na escolha entre elas: quanto custa cada operação.

Um mapa do framework

O framework separa, como recomendou "Contratos sem Implementação", os contratos das implementações:

Collection<E>                      Map<K, V>
├── List<E>                        ├── HashMap
│   ├── ArrayList                  ├── LinkedHashMap
│   └── LinkedList                 └── TreeMap
├── Set<E>
│   ├── HashSet
│   ├── LinkedHashSet
│   └── TreeSet
└── Queue<E> / Deque<E>
    ├── ArrayDeque
    └── PriorityQueue

Os nomes da esquerda de cada ramo, como List, Set e Map, são interfaces. Os da direita são classes. O nome de cada classe descreve a técnica usada por dentro: Array para um array que cresce, Hash para uma tabela de espalhamento baseada no hashCode de "Iguais ou o Mesmo?", Tree para uma árvore ordenada, e Linked para elementos encadeados uns aos outros.

A consequência prática: declare as variáveis pelo tipo da interface e escolha a implementação só no new:

List<String> tarefas = new ArrayList<>();

Todo o código que usa tarefas depende só do contrato List. Se amanhã outra implementação for mais adequada, só a linha do new muda. Pela mesma razão, métodos devem receber e devolver interfaces, como List<String>, e não classes concretas.

Listas: sequências com posição

Uma List guarda elementos numa ordem, acessíveis por posição, e aceita repetições. A implementação padrão é o ArrayList, a versão profissional da Lista que você construiu:

void main() {
    List<String> tarefas = new ArrayList<>();
    tarefas.add("estudar genéricos");
    tarefas.add("fazer exercícios");
    tarefas.add(0, "revisar a aula anterior");   // insere na posição 0
    tarefas.add("descansar");

    IO.println(tarefas);
    IO.println(tarefas.size() + " tarefas; a segunda é: " + tarefas.get(1));

    tarefas.set(3, "descansar um pouco");        // substitui
    tarefas.remove("fazer exercícios");          // remove pelo valor
    IO.println(tarefas.contains("estudar genéricos") + " " + tarefas.indexOf("descansar um pouco"));

    IO.println(tarefas.getFirst() + " | " + tarefas.getLast());
    IO.println(tarefas.reversed());

    for (String t : tarefas) {
        IO.println("- " + t);
    }
}
[revisar a aula anterior, estudar genéricos, fazer exercícios, descansar]
4 tarefas; a segunda é: estudar genéricos
true 2
revisar a aula anterior | descansar um pouco
[descansar um pouco, estudar genéricos, revisar a aula anterior]
- revisar a aula anterior
- estudar genéricos
- descansar um pouco

A maioria dos métodos se explica pelo nome. contains e indexOf usam equals, então funcionam com as classes de valor de "Iguais ou o Mesmo?". O for-each de "Muitos Valores sob um Nome" funciona com qualquer coleção. getFirst, getLast e reversed vêm das coleções sequenciadas, uma interface acrescentada no Java 21 a todas as coleções que têm uma ordem definida. Antes dela, pegar o último elemento de uma lista exigia lista.get(lista.size() - 1).

A armadilha do remove

List tem dois métodos remove: remove(int indice) e remove(Object elemento). Numa lista de inteiros, a sobrecarga de "Dividir para Resolver" cria uma armadilha:

List<Integer> numeros = new ArrayList<>(List.of(10, 20, 30, 2));
numeros.remove(2);                    // remove a POSIÇÃO 2 (o 30)
IO.println(numeros);                  // [10, 20, 2]
numeros.remove(Integer.valueOf(2));   // remove o VALOR 2
IO.println(numeros);                  // [10, 20]

Um int literal escolhe a versão por índice, porque ela não exige autoboxing. Para remover pelo valor, é preciso passar um Integer de forma explícita.

ArrayList ou LinkedList?

A biblioteca também tem o LinkedList, em que cada elemento guarda referências para o anterior e o próximo, como as listas encadeadas que os cursos de C constroem à mão. Na teoria, ele insere e remove no meio sem deslocar elementos. Na prática, para chegar ao meio ele precisa percorrer a lista nó por nó, e os nós espalhados pela memória são muito mais lentos de percorrer do que um array contíguo. Em quase todos os casos reais, o ArrayList é mais rápido. Até Joshua Bloch, que escreveu o LinkedList, disse publicamente que não o usa. Use ArrayList como padrão.

Listas imutáveis

Para listas que não devem mudar, existe List.of:

List<String> fixa = List.of("a", "b");
fixa.add("c");      // UnsupportedOperationException

A lista criada por List.of rejeita qualquer alteração, e também rejeita elementos null na criação. List.copyOf(outra) cria uma cópia imutável de uma lista existente, que não muda quando a original muda. É exatamente o que faltava ao record Turma de "Dados sem Cerimônia":

record Turma(String nome, List<String> alunos) {
    Turma {
        alunos = List.copyOf(alunos);   // cópia defensiva e imutável
    }
}
List<String> nomes = new ArrayList<>(List.of("Ana", "Bia"));
Turma t = new Turma("A", nomes);
nomes.set(0, "Zeca");
IO.println(t);                                                  // Turma[nome=A, alunos=[Ana, Bia]]
IO.println(t.equals(new Turma("A", List.of("Ana", "Bia"))));    // true

Os três problemas do exercício 2 daquela aula desaparecem. A turma não muda quando a lista original muda. t.alunos().add(...) lança exceção. E o equals e o toString gerados funcionam, porque listas comparam conteúdo e se imprimem legivelmente, ao contrário dos arrays.

Cuidado com um parente enganoso, o Arrays.asList(array). Ele devolve uma lista que é uma visão do array: alterar um elemento da lista altera o array, e vice-versa, e a lista tem tamanho fixo, então add lança UnsupportedOperationException. Prefira List.of para listas fixas e new ArrayList<>(List.of(...)) para listas que vão crescer, como nos exemplos desta aula.

Removendo durante o percurso

Um erro que todo programador Java comete uma vez:

List<String> nomes = new ArrayList<>(List.of("Ana", "Bruno", "Bia", "Caio"));
for (String n : nomes) {
    if (n.startsWith("B")) {
        nomes.remove(n);        // ConcurrentModificationException
    }
}

O for-each usa por baixo um iterador, um objeto que percorre a coleção. Quando a coleção é alterada por fora do iterador durante o percurso, ele detecta a mudança e lança ConcurrentModificationException, em vez de continuar com posições que não fazem mais sentido. O nome engana: não há nada de "concorrente", no sentido de threads, acontecendo. Para remover durante o percurso, use o próprio iterador:

Iterator<String> it = nomes.iterator();
while (it.hasNext()) {
    if (it.next().startsWith("B")) {
        it.remove();            // remove pelo iterador: seguro
    }
}
IO.println(nomes);              // [Ana, Caio]

Em "Comportamento como Valor" você vai conhecer uma forma de uma linha para isso, removeIf.

Conjuntos: sem repetição

Um Set não aceita elementos repetidos, segundo o equals. As três implementações diferem na ordem em que os elementos aparecem:

String[] visitas = {"Recife", "Olinda", "Recife", "Caruaru", "Olinda", "Arcoverde"};

Set<String> hash = new HashSet<>();
Set<String> linked = new LinkedHashSet<>();
Set<String> tree = new TreeSet<>();
for (String cidade : visitas) {
    hash.add(cidade);
    linked.add(cidade);
    tree.add(cidade);
}
HashSet:       [Arcoverde, Recife, Caruaru, Olinda]
LinkedHashSet: [Recife, Olinda, Caruaru, Arcoverde]
TreeSet:       [Arcoverde, Caruaru, Olinda, Recife]

As repetições desapareceram nos três. O HashSet não garante ordem nenhuma: a ordem depende dos hashCode e pode mudar entre versões do Java, então nunca dependa dela. O LinkedHashSet mantém a ordem de inserção. O TreeSet mantém os elementos ordenados, e é assunto da próxima aula.

O add devolve um boolean que diz se o elemento era novo: hash.add("Recife") devolve false. Isso permite detectar duplicatas sem um teste separado. As operações de conjunto da matemática também estão lá:

Set<Integer> a = new TreeSet<>(List.of(1, 2, 3, 4));
Set<Integer> b = Set.of(3, 4, 5);

Set<Integer> uniao = new TreeSet<>(a);    uniao.addAll(b);      // [1, 2, 3, 4, 5]
Set<Integer> inter = new TreeSet<>(a);    inter.retainAll(b);   // [3, 4]
Set<Integer> dif = new TreeSet<>(a);      dif.removeAll(b);     // [1, 2]

Os conjuntos baseados em hash dependem totalmente do contrato de "Iguais ou o Mesmo?": uma classe com equals e sem hashCode quebra o HashSet, e um elemento que muda depois de inserido se perde dentro dele.

Mapas: chaves e valores

Um Map<K, V> associa chaves a valores, como um dicionário associa palavras a definições. Cada chave aparece uma vez só. Contar as palavras de um texto é o exemplo clássico:

String texto = "o rato roeu a roupa do rei de roma e a rainha roeu o resto";
Map<String, Integer> contagem = new TreeMap<>();
for (String palavra : texto.split(" ")) {
    int atual = contagem.getOrDefault(palavra, 0);
    contagem.put(palavra, atual + 1);
}
IO.println(contagem);
{a=2, de=1, do=1, e=1, o=2, rainha=1, rato=1, rei=1, resto=1, roeu=2, roma=1, roupa=1}

split(" ") divide o texto num array de palavras. getOrDefault devolve o valor da chave ou um padrão, se ela não existir. put grava o valor, substituindo o anterior se a chave já existia. Com TreeMap, as chaves ficam em ordem alfabética.

As operações principais:

contagem.get("roeu");            // 2
contagem.get("gato");            // null: a chave não existe
contagem.containsKey("rei");     // true

for (Map.Entry<String, Integer> entrada : contagem.entrySet()) {
    if (entrada.getValue() > 1) {
        IO.println(entrada.getKey() + " aparece " + entrada.getValue() + " vezes");
    }
}
a aparece 2 vezes
o aparece 2 vezes
roeu aparece 2 vezes

O entrySet() devolve os pares chave-valor, e é a forma eficiente de percorrer um mapa quando você precisa dos dois. Também existem keySet(), só com as chaves, e values(), só com os valores.

Atenção a um detalhe: get devolve null quando a chave não existe. Guardar o resultado num int desembrulha o null e causa a NullPointerException de "Escreva uma Vez para Todo Tipo": int n = mapa.get("ausente"); quebra. Use getOrDefault ou teste com containsKey. E o put devolve o valor anterior da chave, ou null se ela era nova.

As implementações seguem o padrão dos conjuntos: HashMap sem ordem, LinkedHashMap na ordem de inserção e TreeMap ordenado pelas chaves. Como os mapas com ordem são sequenciados, eles oferecem firstEntry() e lastEntry(), disponíveis quando a variável é declarada como SequencedMap:

SequencedMap<String, String> capitais = new LinkedHashMap<>();
capitais.put("PE", "Recife");
capitais.put("BA", "Salvador");
capitais.put("CE", "Fortaleza");
IO.println(capitais.firstEntry() + " " + capitais.lastEntry());   // PE=Recife CE=Fortaleza

A regra das chaves é a mesma dos elementos de conjunto: use chaves imutáveis, com equals e hashCode corretos. String, números, enums e records são as chaves ideais.

Filas e pilhas

Uma fila (queue) atende na ordem de chegada: o primeiro a entrar é o primeiro a sair. Uma pilha (stack) funciona ao contrário, como uma pilha de pratos: o último a entrar é o primeiro a sair. A interface Deque (double-ended queue, fila de duas pontas) serve para as duas, e a implementação padrão é o ArrayDeque:

Deque<String> pilha = new ArrayDeque<>();
pilha.push("página 1");
pilha.push("página 2");
pilha.push("página 3");
IO.println("voltar para: " + pilha.pop() + ", depois " + pilha.peek());
// voltar para: página 3, depois página 2

Queue<String> fila = new ArrayDeque<>();
fila.offer("cliente A");
fila.offer("cliente B");
fila.offer("cliente C");
IO.println("atendendo: " + fila.poll() + "; restam " + fila);
// atendendo: cliente A; restam [cliente B, cliente C]

O botão "voltar" de um navegador é uma pilha. Uma fila de atendimento ou de impressão é uma fila. A biblioteca ainda tem uma classe antiga chamada Stack, dos tempos do Java 1.0, que não deve ser usada em código novo: ArrayDeque a substitui com vantagens. Uma fila em que o próximo a sair é o mais prioritário, e não o mais antigo, é a PriorityQueue, que aparece na próxima aula.

O que cada operação custa

A diferença entre as coleções não está no que elas fazem, e sim em quanto custa fazer. Um teste: procurar 10 mil valores numa lista e num conjunto, cada um com 100 mil números.

List<Integer> lista = new ArrayList<>();
Set<Integer> conjunto = new HashSet<>();
for (int i = 0; i < 100_000; i++) {
    lista.add(i);
    conjunto.add(i);
}
// 10.000 buscas com contains em cada um...

Na máquina em que este artigo foi testado (JDK 27), a lista levou cerca de 420 milissegundos, e o conjunto, menos de 2 milissegundos. Uma diferença de mais de duzentas vezes, para a mesma operação. O contains da lista compara elemento por elemento, como a busca linear de "Muitos Valores sob um Nome". O do HashSet calcula o hashCode e vai direto à posição certa.

Programadores descrevem esses custos com a notação O grande, que diz como o tempo de uma operação cresce com o número de elementos, n:

  • O(1), tempo constante: o custo não depende do tamanho. Acessar uma posição de ArrayList, buscar num HashSet ou HashMap.
  • O(log n), tempo logarítmico: dobrar o tamanho acrescenta um passo. Buscar num TreeSet ou TreeMap. Com um milhão de elementos, são cerca de 20 passos.
  • O(n), tempo linear: dobrar o tamanho dobra o tempo. contains numa lista, inserir no início de um ArrayList.
Operação ArrayList LinkedList HashSet / HashMap TreeSet / TreeMap
Acessar por posição O(1) O(n) — —
Adicionar no fim O(1)* O(1) O(1) O(log n)
Inserir/remover no início O(n) O(1) — —
Buscar (contains, get) O(n) O(n) O(1) O(log n)
Mantém ordem inserção inserção não ordenada

\* Em média: de vez em quando o array interno precisa dobrar de tamanho, como na sua Lista, mas o custo das cópias, distribuído entre todas as inserções, é constante.

Como escolher

Um roteiro que resolve quase todos os casos:

  • Precisa de uma sequência, com posição e repetições? ArrayList.
  • Precisa saber rapidamente se algo já existe, sem repetições? HashSet. Se a ordem de inserção importa, LinkedHashSet. Se precisa de ordem de valor, TreeSet.
  • Precisa encontrar um valor a partir de uma chave? HashMap, ou LinkedHashMap e TreeMap pelos mesmos critérios de ordem.
  • Precisa processar na ordem de chegada, ou na ordem inversa? ArrayDeque.
  • A coleção não vai mudar? List.of, Set.of, Map.of.

O erro mais comum não é escolher entre implementações parecidas: é usar uma lista onde o problema pede um conjunto ou um mapa, e pagar O(n) em cada busca. Um sistema que verifica se um e-mail já está cadastrado percorrendo uma lista funciona perfeitamente com 100 usuários e trava com 100 mil.

Resta uma pergunta que esta aula deixou de lado: como o TreeSet e o TreeMap sabem em que ordem colocar os elementos? Com textos e números, eles usam a ordem natural. Mas e com um Aluno? Por nome, por nota ou por idade? E se a ordem natural dos textos, com maiúsculas antes de minúsculas e acentos no fim, não for a que uma pessoa espera?

Fontes e leituras recomendadas

Exercícios

Exercício 1

Sem rodar, diga o conteúdo final da lista:

List<Integer> v = new ArrayList<>(List.of(5, 1, 4, 1, 3));
v.remove(1);
v.remove(Integer.valueOf(1));
v.add(0, 9);
v.set(v.size() - 1, 7);
Ver resposta

✓ Resposta: [9, 5, 4, 7]. Passo a passo: remove(1) recebe um int e remove a posição 1, o primeiro 1, e a lista fica [5, 4, 1, 3]. remove(Integer.valueOf(1)) remove o valor 1, a primeira ocorrência, e a lista fica [5, 4, 3]. add(0, 9) insere o 9 no início: [9, 5, 4, 3]. set(v.size() - 1, 7) substitui o último elemento: [9, 5, 4, 7]. A primeira linha é a armadilha da sobrecarga: com List<Integer>, um literal int sempre escolhe a versão por índice.

Exercício 2

Escolha a coleção mais adequada para cada caso e justifique: (a) os e-mails já cadastrados num sistema, para recusar duplicatas; (b) o histórico de comandos digitados num terminal, para navegar com a seta para cima; (c) os produtos de uma loja, a serem encontrados pelo código de barras; (d) as músicas de uma playlist, na ordem escolhida pelo usuário, podendo repetir; (e) os países com medalhas numa olimpíada, exibidos em ordem alfabética, cada um com o número de medalhas.

Ver resposta

✓ Resposta: (a) HashSet<String>. A pergunta é "já existe?", que o conjunto responde em O(1), e o add devolvendo false já sinaliza a duplicata. (b) ArrayDeque<String> usado como pilha. O comando mais recente é o primeiro a ser recuperado. Uma ArrayList também serviria, navegando por índice a partir do fim. (c) HashMap<String, Produto>, com o código de barras como chave. Encontrar pela chave é O(1). Uma lista exigiria percorrer todos os produtos a cada busca. (d) ArrayList<Musica>. A ordem é a do usuário, e não uma ordem de valor, e repetições são permitidas, o que exclui os conjuntos. (e) TreeMap<String, Integer>. É um mapa, porque associa país a medalhas, e ordenado, porque a exibição é alfabética. O TreeMap mantém a ordem sem que seja preciso ordenar a cada exibição.

Exercício 3

Escreva um programa que leia uma frase e mostre quantas vezes cada letra aparece, em ordem alfabética, ignorando espaços e maiúsculas. Por exemplo, "Ana ama" deve mostrar a=4, m=1, n=1.

Ver resposta

✓ Resposta:

void main() {
    String frase = IO.readln("Frase: ").toLowerCase();
    Map<Character, Integer> frequencia = new TreeMap<>();
    for (int i = 0; i < frase.length(); i++) {
        char c = frase.charAt(i);
        if (c == ' ') {
            continue;
        }
        frequencia.put(c, frequencia.getOrDefault(c, 0) + 1);
    }
    IO.println(frequencia);
}

Com "Ana ama", a saída é {a=4, m=1, n=1}. As chaves são Character, e o char de charAt é embrulhado automaticamente. O TreeMap mantém as letras em ordem, e getOrDefault evita o teste separado para a primeira ocorrência de cada letra. Um detalhe: com letras acentuadas, o TreeMap as colocaria depois do "z", porque a ordem natural de Character é a do código Unicode. Ordenar respeitando o alfabeto português é assunto da próxima aula.

Exercício 4

O trecho abaixo lança ConcurrentModificationException. Explique por quê e corrija-o de modo que remova todos os números negativos.

List<Integer> valores = new ArrayList<>(List.of(3, -1, 4, -5, 9));
for (Integer v : valores) {
    if (v < 0) {
        valores.remove(v);
    }
}
Ver resposta

✓ Resposta: O for-each percorre a lista com um iterador. Quando valores.remove(v) altera a lista por fora dele, o iterador percebe, na próxima volta, que a lista mudou sem o seu conhecimento, e lança a exceção para não continuar com posições que já não correspondem aos elementos. A correção usa o próprio iterador para remover:

Iterator<Integer> it = valores.iterator();
while (it.hasNext()) {
    if (it.next() < 0) {
        it.remove();
    }
}

O resultado é [3, 4, 9]. Repare que valores.remove(v), com v sendo um Integer, chamaria a versão que remove por valor, o que é o desejado aqui. O problema não era a versão do remove, e sim o momento. Outra solução seria percorrer a lista por índice, de trás para frente, removendo com remove(i): de trás para frente, as remoções não deslocam as posições que ainda serão visitadas.

Exercício 5

Uma agenda guarda vários telefones por pessoa. Declare a estrutura adequada e escreva um método adicionarTelefone(String nome, String telefone) e um método que devolva os telefones de uma pessoa, ou uma lista vazia se ela não estiver na agenda.

Ver resposta

✓ Resposta:

Map<String, List<String>> agenda = new TreeMap<>();

void adicionarTelefone(String nome, String telefone) {
    List<String> telefones = agenda.get(nome);
    if (telefones == null) {
        telefones = new ArrayList<>();
        agenda.put(nome, telefones);
    }
    telefones.add(telefone);
}

List<String> telefonesDe(String nome) {
    return List.copyOf(agenda.getOrDefault(nome, List.of()));
}

A estrutura é um mapa cujos valores são listas, um padrão muito comum. Em adicionarTelefone, se a pessoa ainda não tem lista, cria-se uma e ela é guardada no mapa antes de receber o telefone. Como o mapa guarda a referência à lista, o add feito depois altera a lista que está no mapa. telefonesDe devolve uma cópia imutável, pela lição de "O que Fica Dentro, o que Fica Fora": quem chama não consegue alterar a agenda por fora. Para quem não está na agenda, devolve uma lista vazia, em vez de null, e quem chama pode percorrer o resultado sem testar nada. Em "Comportamento como Valor", o método computeIfAbsent vai reduzir as cinco primeiras linhas de adicionarTelefone a uma só.

Comentários

Mais em Java

Extensão — Dados que Sobrevivem: SQL Essencial e JDBC com SQLite
Extensão — Dados que Sobrevivem: SQL Essencial e JDBC com SQLite

As trilhas deixam de viver só na memória: tabelas com regras que o banco…

Extensão — Páginas Dinâmicas: Thymeleaf e Formulários
Extensão — Páginas Dinâmicas: Thymeleaf e Formulários

Nesta aula as Trilhas da Ilha ganham páginas HTML geradas no servidor com…

Lições de Quem Veio Antes — Effective Java, Código Limpo e Padrões
Lições de Quem Veio Antes — Effective Java, Código Limpo e Padrões

O que três livros clássicos ensinam sobre escrever Java bem: como criar…