Algoritmos e Estruturas de Dados
Lição n.º 22
Árvores binárias de busca
•
Árvores binárias de busca.
•
Árvores equilibradas
•
Outras classes de árvores.
Classe Table<K, V>, revisão
•
Eis uma classe abstrata
Table
típica, representando
tabelas sem chaves duplicadas:
public abstract class Table<K extends Comparable<K>, V>
{
public abstract V get(K key);
public abstract void put(K key, V value);
public abstract void delete(K key);
public abstract boolean has(K key);
public abstract boolean isEmpty();
public abstract int size();
public abstract Iterable<K> keys();
}
K
é o tipo das chaves;
V
é o tipo dos valores.
A função
get
é a função de busca; a função
put
acrescenta um par à tabela; a função
delete
remove da tabela o par com a chave dada; a função
has
indica se existe na tabela um par com a
chave dada; a função
isEmpty
indica se a tabela está vazia, isto é, se não tem elementos; a função
size
calcula o número de pares na tabela; a função
keys
retorna uma estrutura iterável com
Árvores de nós, revisão
•
Convencionalmente, em Java, as árvores binárias são
representadas por um conjunto de nós, cada um com
valor e com duas referências, uma para o nó que
representa a subárvore esquerda e a outra para a
referência que representa a subárvore esquerda.
•
As árvores de busca implementam as tabelas; por
isso, em cada nó guarda-se uma chave e o valor
respetivo.
•
Para cada árvore, recursivamente, a subárvore da
esquerda tem chaves menores do que a chave da
árvore e a árvore da direita tem chaves maiores do
que a chave da árvore.
Classe BinarySearchTree<K, V>
•
Eis a programação convencional, em Java:
public class BinarySearchTree<K extends Comparable<K>, V> extends Table<K, V>
{
private Node root;
private class
Node
{
private
final
K key;
private V value;
private Node left;
private Node right;
private int size; // size of subtree
public Node(K key, V value, int size)
{
this.key = key;
this.value = value;
this.left = null;
this.right = null;
this.size = size;
}
}
Note bem: a chave, uma vez fixada no
construtor, não pode ser mudada.
O nó mantém também o tamanho da
árvore representada pelo nó.
Procurando na árvore
•
Procura-se pela chave.
•
Se a chave for igual à chave do nó, o valor é o valor do nó.
•
Se não, se a chave for menor do que a chave do nó, procura-se na
subárvore esquerda.
•
Se não, procura-se na subárvore direita.
•
A função pública, que procura na árvore toda, usa uma função
privada que procura numa dada subárvore:
20140521 Algoritmos e Estruturas de Dados 6
public V get(K key)
{
return get(key, root);
}
private V get(K key, Node x)
{
V result = null;
if (x != null)
{
int cmp = key.compareTo(x.key);
if (cmp < 0)
result = get(key, x.left);
else if (cmp > 0)
result = get(key, x.right);
else
result = x.value;
}
return result;
Na verdade, a sequência de
comparações usada é “menor do
que a chave do nó”, “maior do que
a chave do nó” e, se não uma nem
outra, “igual à chave”.
Quando a chave não existe,
a função retorna
null
.
Função has
•
Programa-se simplesmente em termos da função
get
:
public boolean has(K key)
{
return get(key) != null;
}
Acrescentando à árvore
•
Acrescenta-se uma chave e um valor.
•
Se a árvore for vazia, passa a ter um nó.
•
Se não, se a chave for igual à chave do nó, o valor substitui o valor
do nó.
•
Se não, se a chave for menor do que a chave do nó, acrescenta à
subárvore da esquerda
•
Se não, acrescenta à subárvore direita.
•
Tal como no
get
, a função pública, que acrescenta à árvore, usa uma
função privada que acrescenta a uma dada subárvore e que retorna
uma referência para a árvore onde foi feito o acrescento.
•
Essa referência substitui a referência inicial.
•
No final, atualiza o tamanho da árvore representada pelo nó,
recorrendo ao tamanho de cada uma das subárvores.
•
Observe com atenção e aprenda a técnica.
Função put
public void put(K key, V value)
{
root = put(key, value, root);
}
private Node put(K key, V value, Node x)
{
Node result = x;
if (result == null)
result = new Node(key, value, 1);
else
{
int cmp = key.compareTo(x.key);
if (cmp < 0)
result.left = put(key, value, x.left);
else if (cmp > 0)
result.right = put(key, value, x.right);
else
result.value = value; // key exists, replace value
result.size = 1 + size(result.left) + size(result.right); // invariant
}
return result;
}
Estude bem esta função!
Repare que ela devolve uma referência
para o nó que representa a árvore à qual
o novo valor foi acrescentado. Se a
árvore era vazia, é uma referência para
um novo nó. Se não, é a referência para a
própria árvore após o valor ter sido
recursivamente acrescentado ao filho
esquerdo ou ao filho direito (consoante
o chave é menor ou maior que o chave
da raiz), ou após o valor da raiz ter sido
modificado, se a chave for igual à chave
da raiz.
Em todos os casos, o valor do tamanho é recalculado à saída,
usando os valores dos filhos, já calculados anteriormente.
•
Estas são simples:
Funções size, isEmpty
20140521 Algoritmos e Estruturas de Dados 10
public int size()
{
return size(root);
}
private int size(Node x)
{
int result = 0;
if (x != null)
result = x.size;
return result;
}
public boolean isEmpty()
{
return root == null;
}
A função pública
size
, que
dá o tamanho da árvore,
usa uma função privada que
dá o tamanho da árvore
representada pelo nó dado.
•
Para estudar pequenos exemplos, usaremos uma
função toString, análoga à da classe
Tree<T>
:
Função toString
public String toString()
{
String result = toString(root);
return result;
}
public String toString(Node x)
{
String s = x == null ? "" :
"<" + x.key + " " + x.value + ">" +
toString(x.left) + toString(x.right);
return "(" + s + ")";
}
Uma árvore é representada por uma
cadeia que começa por “(” e termina
por “)”. Se for vazia, não há mais nada;
se não, há a chave e o valor, seguidos
da cadeia da subárvore esquerda,
•
Eis uma função de teste, que aceita números e os vai
acrescentando à árvore de busca, mostrando-a a cada passo:
Testando a função put
20140521 Algoritmos e Estruturas de Dados 12
public static void testPut()
{
BinarySearchTree<Integer, Integer> t =
new BinarySearchTree<Integer, Integer>();
while (!StdIn.isEmpty())
{
int x = StdIn.readInt();
t.put(x, x*x);
StdOut.println(t);
StdOut.println("size: " + t.size());
}
}
Para simplificar fazemos o valor
ser o quadrado da chave.
$ java ... BinarySearchTree 4 (<4 16>()()) size: 1 12 (<4 16>()(<12 144>()())) size: 2 3 (<4 16>(<3 9>()())(<12 144>()())) size: 3 8 (<4 16>(<3 9>()())(<12 144>(<8 64>()())())) size: 4 9 (<4 16>(<3 9>()())(<12 144>(<8 64>()(<9 81>()()))()))
•
Vejamos um exemplo com uma árvore de estudantes, em que
chave é o número de aluno e o valor é o nome.
•
Carregamos a árvore por leitura a partir de um ficheiro cujo
nome é indicado na linha de comando:
BinarySearchTree<Integer, String>
public static void treeRead(In f, BinarySearchTree<Integer, String> t)
{
while (!f.isEmpty())
{
int x = f.readInt();
f.readChar(); // skip one space
String s = f.readLine();
t.put(x, s);
}
}
Eis um troço do ficheiro:
...
36975 GONÇALO JORGE FERNANDES RUAS 45171 GONÇALO MARTINHO MENDES BARRACOSA 21129 JOÃO DAVID BÔTO PANCINHA
42281 JOÃO FILIPE GONÇALVES CABEÇADAS MATOS 45400 JOÃO FILIPE SILVA NUNES COELHO
44610 JOÃO PAULO REIS ROSA 35540 JOÃO PEDRO MATIAS NUNES
25164 JORGE FERNANDO PACHECO MARTINS 44543 JOSÉ DANIEL RIBEIRO SALVADO 2901 JOSÉ GABRIEL FERNANDES CHAVECA 47712 JOSÉ MIGUEL FORJA PINTADO ALVES
Note bem: o ficheiro está
ordenado pelo nome, não pelo
•
Usamos a tabela de estudantes, previamente lida.
•
Observe:
Testando a função get
20140521 Algoritmos e Estruturas de Dados 14
public static void testGet(String[] args)
{
BinarySearchTree<Integer, String> students =
new BinarySearchTree<Integer, String>();
In f = new In(args[1]);
treeRead
(f, students);
StdOut.println("size: " + students.size());
while (!StdIn.isEmpty())
{
int x = StdIn.readInt();
String s = students.get(x);
StdOut.println(s);
}
}
$
java ... BinarySearchTree 2 ../work/inscritos_por_nome.txt
size: 51
47414
RAQUEL CATARINA CARRIÇO TOMÉ
34749
PEDRO MIGUEL GONÇALVES SILVA
32901
null
•
Não tem nada de especial. Fica aqui apenas para
completar o exercício:
Função main
public static void main(String[] args)
{
int choice = 1;
if (args.length >= 1)
choice = Integer.parseInt(args[0]);
switch (choice) {
case 1:
testPut();
break;
case 2:
testGet(args);
break;
default:
break;
}
}
Removendo da árvore
•
Remove-se indicando a chave.
•
Se a árvore for vazia, fica na mesma.
•
Se não, se a chave for igual à chave do nó, remove-se
esse nó.
•
Se não, se a chave for menor do que a chave do nó,
remove-se da subárvore esquerda
•
Se não, remove-se da subárvore direita.
•
Para remover a raiz, troca-se o valor com o valor do
nó seguinte e remove-se o nó seguinte.
•
No final, atualiza-se o tamanho da árvore
representada pelo nó, recorrendo ao tamanho de
cada uma das subárvores.
Função remove
public void delete(K k)
{
root = delete(k, root);
}
private Node delete(K k, Node x)
{
Node result = null;
if (x != null)
{
int cmp = k.compareTo(x.key);
if (cmp == 0)
result =
deleteRoot
(x);
else
{
if (cmp < 0)
x.left = delete(k, x.left);
else
x.right = delete(k, x.right);
x.size = 1 + size(x.left) + size(x.right);
result = x;
}
}
return result;
Se a chave é igual à chave do nó,
remove-se esremove-se nó, com a função
deleteRoot
.
O tamanho da árvore pendurada no nó é atualizado à saída.
Se não, remove-se à esquerda ou à
direita, consoante a chave é menor ou
maior que a chave do nó.
Se o nó for null, o que acontecerá
recursivamente quando a chave não
existir, o resultado é null também.
Função deleteRoot
20140521 Algoritmos e Estruturas de Dados 18
private Node deleteRoot(Node x)
{
assert x != null;
Node result;
if (x.right == null)
result = x.left;
else if (x.left == null)
result = x.right;
else
{
Node t = x;
x = first(x.right);
x.right = deleteFirst(t.right);
x.left = t.left;
x.size = 1 + size(x.left) + size(x.right);
result = x;
}
return result;
}
Se ambos os filhos não forem
null
, repare bem: o valor do
nó passa a ser o valor do nó seguinte, obtido através da
função
first
, aplicada à subárvore direita. Depois, remove-se
o “primeiro” nó da subárvore direita. Não mexe na
subárvore esquerda. O tamanho é recalculado.
Se o filho direito do nó for
null
, o resultado
de remover o nó é um apontador para o
filho esquerdo e vice-versa.
Funções first e deleteFirst
private Node first(Node x)
{
Node result = x;
while (result.left != null)
result = result.left;
return result;
}
private Node deleteFirst(Node x)
{
Node result = x.right;
if (x.left != null)
{
x.left = deleteFirst(x.left);
x.size = 1 + size(x.left) + size(x.right);
result = x;
}
return result;
}
O primeiro nó é o nó mais à esquerda.
Iterativamente “descemos” pelo nó da
esquerda, enquanto houver nó da esquerda.
Se o filho esquerdo for
null
, o primeiro é a
raiz e, portanto, remover o primeiro é
remover o nó da raiz, e neste caso, o
resultado é o filho direito.
Se o filho esquerdo não for
null
, então
remove-se recursivamente o primeiro do
filho esquerdo, etc.
•
Criamos uma árvore aleatória e depois vamos removendo
elementos, pela chave:
Testando a função delete
20140521 Algoritmos e Estruturas de Dados 20
public static BinarySearchTree<Integer, Integer> randomTree(int n) {
BinarySearchTree<Integer, Integer> result = new BinarySearchTree<Integer, Integer>(); int[] a = new int[n];
for (int i = 0; i < n; i++) a[i] = i; StdRandom.shuffle(a); for (int x : a) result.put(x, x*x); return result; }
public static void testDelete(String[] args) {
int n = 15;
if (args.length > 1)
n = Integer.parseInt(args[1]);
BinarySearchTree<Integer, Integer> t = randomTree(n); StdOut.println(t); StdOut.println("size: " + t.size()); while (!StdIn.isEmpty()) { int x = StdIn.readInt(); t.delete(x); StdOut.println(t); StdOut.println("size: " + t.size()); } }
•
Com uma árvore com 20 nós inicialmente:
Testando a função delete, exemplo
$ java ... BinarySearchTree 3 20 (<15 225>(<3 9>(<1 1>(<0 0>()())(<2 4>()()))(<5 25>(<4 16>()())(<14 196>(<8 64>(<7 49>(<6 36>()())()) (<11 121>(<10 100>(<9 81>()())())(<13 169>(<12 144>()())())))())))(<16 256>()(<18 324>(<17 289>()()) (<19 361>()())))) size: 20 9 (<15 225>(<3 9>(<1 1>(<0 0>()())(<2 4>()()))(<5 25>(<4 16>()())(<14 196>(<8 64>(<7 49>(<6 36>()())()) (<11 121>(<10 100>()())(<13 169>(<12 144>()())())))())))(<16 256>()(<18 324>(<17 289>()())(<19 361>() ())))) size: 19 16 (<15 225>(<3 9>(<1 1>(<0 0>()())(<2 4>()()))(<5 25>(<4 16>()())(<14 196>(<8 64>(<7 49>(<6 36>()())()) (<11 121>(<10 100>()())(<13 169>(<12 144>()())())))())))(<18 324>(<17 289>()())(<19 361>()()))) size: 18 0 (<15 225>(<3 9>(<1 1>()(<2 4>()()))(<5 25>(<4 16>()())(<14 196>(<8 64>(<7 49>(<6 36>()())())(<11 121>(<10 100>()())(<13 169>(<12 144>()())())))())))(<18 324>(<17 289>()())(<19 361>()()))) size: 17 19 (<15 225>(<3 9>(<1 1>()(<2 4>()()))(<5 25>(<4 16>()())(<14 196>(<8 64>(<7 49>(<6 36>()())())(<11 121>(<10 100>()())(<13 169>(<12 144>()())())))())))(<18 324>(<17 289>()())())) size: 16 15 (<17 289>(<3 9>(<1 1>()(<2 4>()()))(<5 25>(<4 16>()())(<14 196>(<8 64>(<7 49>(<6 36>()())())(<11 121>(<10 100>()())(<13 169>(<12 144>()())())))())))(<18 324>()())) size: 15
Outras funções
•
Podemos acrescentar as funções de ordem,
minimum
,
maximum
,
rank
e
select
, por analogia com
a classe
Tree<T>
. Por exemplo:
•
O iterador
keys
é análogo ao iterador
items
da
classe
Tree<T>
.
20140521 Algoritmos e Estruturas de Dados 22
public K minimum()
{
return minimum(root);
}
private K minimum(Node x)
{
return x.left == null ? x.key : minimum(x.left);
}
Árvores perfeitamente equilibradas
•
Uma árvore é perfeitamente equilibrada se para cada
nó o tamanho das suas subárvores diferir de 1 no
máximo. Exemplos:
65
80
32
71
81
44
12
24
99
31
16
4
52
5
61
91
32
2
14
11
59
41
12
33
42
54
7
1
Esta não é
perfeitamente
equilibrada.
Árvores equilibradas
•
Uma árvore é equilibrada se para cada nó a altura da
suas subárvores diferir de 1 no máximo.
•
Manter uma árvore equilibrada é menos complicado
do que mantê-la perfeitamente equilibrada, ao pôr e
ao remover.
20140521 Algoritmos e Estruturas de Dados 24