Java TreeMap e TreeSet

O HashMap e o HashSet são rápidos, mas embaralham os elementos. Quando você quer as coisas sempre em ordem (os alunos de A a Z, as pontuações da menor para a maior) e ainda perguntar "qual é a primeira?", "quem vem logo depois da Bia?", use o TreeMap e o TreeSet. Eles se reorganizam sozinhos a cada elemento que entra.

Você vai aprender
  • Como o TreeMap mantém as chaves ordenadas.
  • Os métodos de "navegação": firstKey, lastKey, headMap, tailMap, subMap.
  • Buscar o vizinho: ceilingKey, floorKey, higherKey, lowerKey e o útil floorEntry.
  • O TreeSet: first, last, headSet, ceiling, descendingSet.
  • Ordem natural × ordem definida por um Comparator (e a armadilha de comparar por tamanho).
  • Os erros: ClassCastException, NullPointerException e NoSuchElementException.

A ideia: um fichário em ordem alfabética

A secretaria da escola guarda uma ficha por aluno num fichário. Toda ficha nova é encaixada no lugar certo, em ordem alfabética. Assim, ninguém precisa reordenar nada depois, e é fácil responder "qual é a primeira ficha?" ou "qual vem logo depois de 'Bia'?".

Por dentro, essas coleções usam uma estrutura chamada árvore (tree, em inglês; mais precisamente, uma "árvore rubro-negra"), que mantém os elementos ordenados e permite achar qualquer um com poucos passos. Daí o nome: Tree + Map, Tree + Set. Elas são um pouco mais lentas que as versões Hash, mas a diferença só aparece com quantidades muito grandes.


TreeMap: um mapa com chaves ordenadas

O TreeMap é um Map como qualquer outro: put, get, remove, containsKey… tudo igual ao HashMap. A diferença é a ordem e os métodos extras de navegação:

import java.util.TreeMap;

public class Main {
    public static void main(String[] args) {
        TreeMap<String, Double> notas = new TreeMap<>();
        notas.put("Carla", 8.25);
        notas.put("Ana", 9.5);
        notas.put("Edu", 6.0);
        notas.put("Bruno", 7.0);
        notas.put("Duda", 10.0);
        System.out.println(notas);

        System.out.println("firstKey(): " + notas.firstKey());
        System.out.println("lastKey():  " + notas.lastKey());
        System.out.println("firstEntry(): " + notas.firstEntry());

        System.out.println("headMap(\"Carla\"): " + notas.headMap("Carla"));
        System.out.println("tailMap(\"Carla\"): " + notas.tailMap("Carla"));
        System.out.println("subMap(\"B\", \"D\"): " + notas.subMap("B", "D"));
    }
}
$ javac Main.java
$ java Main
{Ana=9.5, Bruno=7.0, Carla=8.25, Duda=10.0, Edu=6.0}      « em ordem, embora inseridos fora de ordem
firstKey(): Ana
lastKey():  Edu
firstEntry(): Ana=9.5
headMap("Carla"): {Ana=9.5, Bruno=7.0}                     « antes de Carla (sem ela)
tailMap("Carla"): {Carla=8.25, Duda=10.0, Edu=6.0}         « de Carla em diante (com ela)
subMap("B", "D"): {Bruno=7.0, Carla=8.25}                  « de "B" (incluso) até "D" (excluso)
TreeMap<String, Double> notas
Aqui a variável é TreeMap, e não Map, porque os métodos de navegação (firstKey, headMap…) só existem nos mapas ordenados. Se você só precisa da ordem ao imprimir, Map<…> notas = new TreeMap<>() basta.
firstKey() / lastKey()
A menor / a maior chave.
firstEntry() / lastEntry()
O par inteiro (chave=valor) da ponta.
headMap(k)
"A cabeça": os pares com chave menor que k. O k fica de fora.
tailMap(k)
"A cauda": os pares com chave maior ou igual a k.
subMap(de, ate)
Uma "fatia": de de (incluso) até ate (excluso). subMap("B", "D") pega todo nome que começa com B ou C.

A regra "começo incluso, fim excluso" é a mesma do substring (veja Métodos de String). Ela tem uma vantagem: headMap("Carla") e tailMap("Carla") dividem o mapa em duas partes sem repetir ninguém.

Buscar o vizinho: ceiling, floor, higher, lower

Às vezes a chave que você procura não está no mapa, e você quer a mais próxima. Pense num elevador: ceiling = teto (para cima), floor = chão (para baixo).

        System.out.println("ceilingKey(\"Bia\"):  " + notas.ceilingKey("Bia"));
        System.out.println("ceilingKey(\"Carla\"): " + notas.ceilingKey("Carla"));
        System.out.println("higherKey(\"Carla\"):  " + notas.higherKey("Carla"));
        System.out.println("floorKey(\"Bia\"):    " + notas.floorKey("Bia"));
        System.out.println("lowerKey(\"Ana\"):    " + notas.lowerKey("Ana"));
        System.out.println("ceilingKey(\"Zeca\"): " + notas.ceilingKey("Zeca"));

Com o mapa {Ana, Bruno, Carla, Edu}:

$ java Main
ceilingKey("Bia"):  Bruno           « a menor chave >= "Bia"
ceilingKey("Carla"): Carla          « Carla existe: ela mesma
higherKey("Carla"):  Edu            « estritamente maior: pula a própria Carla
floorKey("Bia"):    Ana             « a maior chave <= "Bia"
lowerKey("Ana"):    null            « não há ninguém antes da Ana
ceilingKey("Zeca"): null            « não há ninguém depois do Zeca
MétodoDevolve a chave…Imagem
ceilingKey(k)menor que seja >= k"o teto": k ou o próximo acima
higherKey(k)menor que seja > k"o próximo", nunca o próprio k
floorKey(k)maior que seja <= k"o chão": k ou o anterior
lowerKey(k)maior que seja < k"o anterior", nunca o próprio k

Todos devolvem null quando não existe vizinho naquela direção. E cada um tem uma versão …Entry (ceilingEntry, floorEntry…) que devolve o par inteiro.

Um uso real: faixas de nota

A escola da Ana converte notas em conceitos: de 9 para cima é A, de 7 a 9 é B, de 5 a 7 é C, abaixo de 5 é E. Com um TreeMap cujas chaves são o início de cada faixa, o floorEntry acha a faixa de qualquer nota, sem nenhum if:

import java.util.Map;
import java.util.TreeMap;

public class Main {
    public static void main(String[] args) {
        TreeMap<Double, String> conceitos = new TreeMap<>();
        conceitos.put(0.0, "E");
        conceitos.put(5.0, "C");
        conceitos.put(7.0, "B");
        conceitos.put(9.0, "A");

        double[] notas = {9.5, 6.5, 7.0, 3.0};
        for (double n : notas) {
            Map.Entry<Double, String> faixa = conceitos.floorEntry(n);
            System.out.println(n + " -> faixa a partir de " + faixa.getKey() + " = " + faixa.getValue());
        }
    }
}
$ javac Main.java
$ java Main
9.5 -> faixa a partir de 9.0 = A
6.5 -> faixa a partir de 5.0 = C       « o "chão" de 6.5 é 5.0
7.0 -> faixa a partir de 7.0 = B       « 7.0 existe: é ele mesmo
3.0 -> faixa a partir de 0.0 = E

Para mudar as faixas, você muda os dados do mapa, não o código. A mesma ideia serve para faixas de imposto, de frete por peso, de desconto por quantidade…

Missão: complete o programa para mostrar o conceito da nota 8.0 usando floorEntry (ou floorKey + get). A saída deve ser Conceito: B. Depois experimente outras notas.

import java.util.TreeMap;

public class Main {
    public static void main(String[] args) {
        TreeMap<Double, String> conceitos = new TreeMap<>();
        conceitos.put(0.0, "E");
        conceitos.put(5.0, "C");
        conceitos.put(7.0, "B");
        conceitos.put(9.0, "A");

        double nota = 8.0;
        System.out.println("Conceito: " + conceitos.get(nota));
    }
}
import java.util.TreeMap;

public class Main {
    public static void main(String[] args) {
        TreeMap<Double, String> conceitos = new TreeMap<>();
        conceitos.put(0.0, "E");
        conceitos.put(5.0, "C");
        conceitos.put(7.0, "B");
        conceitos.put(9.0, "A");

        double nota = 8.0;
        System.out.println("Conceito: " + conceitos.floorEntry(nota).getValue());
    }
}

TreeSet: um conjunto sempre ordenado

O TreeSet é para o TreeMap o que o HashSet é para o HashMap: só as chaves. É um conjunto (sem repetidos) que mantém os elementos em ordem, com os mesmos métodos de navegação, sem o "Key" no nome:

import java.util.TreeSet;

public class Main {
    public static void main(String[] args) {
        TreeSet<Integer> pontos = new TreeSet<>();
        pontos.add(70);
        pontos.add(15);
        pontos.add(42);
        pontos.add(99);
        pontos.add(42);
        pontos.add(8);
        System.out.println(pontos);

        System.out.println("first(): " + pontos.first() + "  last(): " + pontos.last());
        System.out.println("headSet(42): " + pontos.headSet(42));
        System.out.println("tailSet(42): " + pontos.tailSet(42));
        System.out.println("ceiling(50): " + pontos.ceiling(50) + "  floor(50): " + pontos.floor(50));
        System.out.println("higher(99): " + pontos.higher(99));
        System.out.println("descendingSet(): " + pontos.descendingSet());
        System.out.println("pollFirst(): " + pontos.pollFirst() + " -> " + pontos);
    }
}
$ javac Main.java
$ java Main
[8, 15, 42, 70, 99]                       « ordenado e sem o 42 repetido
first(): 8  last(): 99
headSet(42): [8, 15]
tailSet(42): [42, 70, 99]
ceiling(50): 70  floor(50): 42
higher(99): null
descendingSet(): [99, 70, 42, 15, 8]      « a mesma coisa, de trás para a frente
pollFirst(): 8 -> [15, 42, 70, 99]        « tira e devolve o menor

descendingSet() não cria uma cópia: é uma "visão" ao contrário do mesmo conjunto. pollFirst()/pollLast() tiram o menor/o maior, o que transforma o TreeSet numa fila de prioridade simples.


Ordem natural × Comparator

Como o TreeSet sabe que 8 vem antes de 15 e "Ana" antes de "Bruno"? Pela ordem natural do tipo: números do menor para o maior; textos pelos códigos dos caracteres (maiúsculas antes das minúsculas; veja a aula de ArrayList). Os tipos que têm ordem natural implementam a interface Comparable.

Quando a ordem natural não serve, você passa ao construtor um Comparator (comparador): um objeto que diz como comparar dois elementos. Veja as mesmas frutas com quatro critérios:

import java.util.Comparator;
import java.util.TreeSet;

public class Main {
    public static void main(String[] args) {
        String[] frutas = {"uva", "Banana", "kiwi", "abacaxi", "pera", "Caju"};

        TreeSet<String> natural = new TreeSet<>();
        TreeSet<String> semCaixa = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
        TreeSet<String> inversa = new TreeSet<>(Comparator.reverseOrder());
        TreeSet<String> porTamanho = new TreeSet<>(Comparator.comparing(String::length));
        for (String f : frutas) {
            natural.add(f);
            semCaixa.add(f);
            inversa.add(f);
            porTamanho.add(f);
        }
        System.out.println("natural:     " + natural);
        System.out.println("sem caixa:   " + semCaixa);
        System.out.println("inversa:     " + inversa);
        System.out.println("por tamanho: " + porTamanho);
    }
}
new TreeSet<>()
Sem argumento: ordem natural.
String.CASE_INSENSITIVE_ORDER
Um comparador pronto que ignora maiúsculas/minúsculas.
Comparator.reverseOrder()
A ordem natural ao contrário.
Comparator.comparing(String::length)
"Compare pelo tamanho" (length). O :: é uma referência de método.
$ javac Main.java
$ java Main
natural:     [Banana, Caju, abacaxi, kiwi, pera, uva]      « maiúsculas primeiro
sem caixa:   [abacaxi, Banana, Caju, kiwi, pera, uva]      « como num dicionário
inversa:     [uva, pera, kiwi, abacaxi, Caju, Banana]
por tamanho: [uva, kiwi, Banana, abacaxi]                  « sumiram "pera" e "Caju"!
A armadilha: para o TreeSet, "comparar igual" é "ser igual"

Na última linha, pera e Caju sumiram. Elas têm 4 letras, como kiwi. Para o comparador por tamanho, as três são "iguais" (a comparação dá 0), e um conjunto não aceita repetidos: só a primeira que chegou (kiwi) ficou. Um TreeSet (e as chaves de um TreeMap) não usa equals: usa só o comparador. Para não perder ninguém, desempate: Comparator.comparing(String::length).thenComparing(Comparator.naturalOrder()). Como montar comparadores assim é o assunto da aula Comparable e Comparator.


Quando usar cada um

Você precisa…Use
de chave → valor, buscando só pela chaveHashMap
de chave → valor, listado em ordem de chaveTreeMap
de "a primeira chave depois de X", faixas, intervalosTreeMap (ceilingKey, floorEntry, subMap)
de um conjunto, só perguntando "tem?"HashSet
de um conjunto sempre ordenado, menor/maior, rankingTreeSet
de ordenar uma vez só, uma lista com repetidosArrayList + sort (veja ArrayList)

Quando dá errado

ClassCastException: … cannot be cast to class java.lang.Comparable

Este código tem um erro de propósito.

import java.util.TreeSet;

class Aluno {
    String nome;
    Aluno(String nome) { this.nome = nome; }
}

public class Main {
    public static void main(String[] args) {
        TreeSet<Aluno> turma = new TreeSet<>();
        turma.add(new Aluno("Ana"));
    }
}
$ javac Main.java
$ java Main
Exception in thread "main" java.lang.ClassCastException: class Aluno cannot be cast to class java.lang.Comparable (Aluno is in unnamed module of loader 'app'; java.lang.Comparable is in module java.base of loader 'bootstrap')
	at java.base/java.util.TreeMap.compare(TreeMap.java:1605)
	at java.base/java.util.TreeMap.addEntryToEmptyMap(TreeMap.java:810)
	at java.base/java.util.TreeMap.put(TreeMap.java:819)
	at java.base/java.util.TreeMap.put(TreeMap.java:568)
	at java.base/java.util.TreeSet.add(TreeSet.java:261)
	at Main.main(Main.java:11)

O que quer dizer: "a classe Aluno não pode ser convertida para Comparable". O TreeSet precisa comparar os elementos para ordená-los e, sem comparador, tenta usar a ordem natural, que Aluno não tem. O texto entre parênteses só diz onde cada classe foi carregada; pode ignorar. Repare também na pilha: o TreeSet usa um TreeMap por dentro. O erro aparece já no primeiro add, e só na hora de rodar: o compilador não reclama.

Como consertar: faça Aluno implementar Comparable<Aluno>, ou passe um comparador: new TreeSet<>(Comparator.comparing(a -> a.nome)). Os dois caminhos estão em Comparable e Comparator.

NullPointerException ao colocar uma chave null
$ java Main
Exception in thread "main" java.lang.NullPointerException
	at java.base/java.util.Objects.requireNonNull(Objects.java:220)
	at java.base/java.util.TreeMap.put(TreeMap.java:843)
	at java.base/java.util.TreeMap.put(TreeMap.java:568)
	at Main.main(Main.java:7)

O que quer dizer: a linha 7 fez m.put(null, 2). O HashMap aceita uma chave null; o TreeMap, não, porque não dá para comparar null com "Ana" (o mesmo vale para o TreeSet).

Como consertar: não use null como chave; confira o valor antes do put.

NoSuchElementException no firstKey de um mapa vazio
$ java Main
Exception in thread "main" java.util.NoSuchElementException
	at java.base/java.util.TreeMap.key(TreeMap.java:1638)
	at java.base/java.util.TreeMap.firstKey(TreeMap.java:303)
	at Main.main(Main.java:6)

O que quer dizer: um TreeMap vazio não tem primeira chave. O mesmo acontece com lastKey, e com first/last num TreeSet vazio.

Como consertar: confira isEmpty() antes, ou use firstEntry() / pollFirst(), que devolvem null em vez de lançar exceção.

Elementos "sumiram" do TreeSet

O que aconteceu: o comparador considerou dois elementos iguais (devolveu 0), e o conjunto guardou só um. Veja a armadilha acima.

Como consertar: desempate com thenComparing, ou use uma lista com sort se repetidos são permitidos.


Referência

TreeMapTreeSetO que fazSe não existir
firstKey() / lastKey()first() / last()Menor / maiorNoSuchElementException
firstEntry() / lastEntry()—Par da pontanull
pollFirstEntry() / pollLastEntry()pollFirst() / pollLast()Tira e devolve a pontanull
headMap(k)headSet(x)Menores que kvazio
tailMap(k)tailSet(x)Maiores ou iguais a kvazio
subMap(de, ate)subSet(de, ate)Fatia [de, ate)vazio
ceilingKey(k) / floorKey(k)ceiling(x) / floor(x)>= / <=null
higherKey(k) / lowerKey(k)higher(x) / lower(x)> / <null
floorEntry(k), ceilingEntry(k)—O par vizinhonull
descendingMap()descendingSet()Visão ao contrário—
new TreeMap<>(comparador)new TreeSet<>(comparador)Ordem personalizada—

Exercícios

1. A menor chave

Complete para pegar a primeira chave (a menor) do TreeMap chamado agenda:

String primeira = agenda.;

2. O próximo, ou ele mesmo

Complete para achar, no TreeSet<Integer> pontos, o menor valor que seja maior ou igual a 50:

Integer x = pontos.;

3. O TreeSet tem [10, 20, 30]. O que headSet(20) devolve?

4. Um TreeSet<String> com comparador por tamanho recebe "sol", "mar" e "lua". Quantos elementos ele terá?

5. O que acontece ao colocar objetos de uma classe sem Comparable num TreeSet sem comparador?

Missão final: rode e veja que banana e uva vão para o fim, por serem minúsculas. Passe um comparador ao TreeSet para ordenar ignorando maiúsculas/minúsculas. A saída deve ser [Abacaxi, banana, Caju, uva] e Primeira: Abacaxi.

import java.util.TreeSet;

public class Main {
    public static void main(String[] args) {
        TreeSet<String> frutas = new TreeSet<>();
        frutas.add("banana");
        frutas.add("Abacaxi");
        frutas.add("uva");
        frutas.add("Caju");
        System.out.println(frutas);
        System.out.println("Primeira: " + frutas.first());
    }
}
import java.util.TreeSet;

public class Main {
    public static void main(String[] args) {
        TreeSet<String> frutas = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
        frutas.add("banana");
        frutas.add("Abacaxi");
        frutas.add("uva");
        frutas.add("Caju");
        System.out.println(frutas);
        System.out.println("Primeira: " + frutas.first());
    }
}

Resumo

  • TreeMap = mapa com chaves sempre ordenadas; TreeSet = conjunto sempre ordenado.
  • Pontas: firstKey/lastKey (mapa), first/last (conjunto).
  • Fatias: headMap/headSet (antes, sem incluir), tailMap/tailSet (dali em diante), subMap/subSet.
  • Vizinhos: ceiling (>=), higher (>), floor (<=), lower (<); null se não existir. floorEntry resolve faixas.
  • Sem argumento, usam a ordem natural (Comparable); com um Comparator, a ordem que você quiser.
  • Elementos que o comparador acha "iguais" viram um só. Não aceitam null. Classe sem ordem natural: ClassCastException.

Próxima aula: Iterator, o "cursor" que percorre qualquer coleção, e como remover elementos durante o percurso.