Está en la página 1de 198

Módulo 3

Estruturas de Dados

Lição 1
Conceitos Básicos e Notações

Versão 1.0 - Mai/2007


JEDITM

Autor Necessidades para os Exercícios


Joyce Avestro Sistemas Operacionais Suportados
NetBeans IDE 5.5 para os seguintes sistemas operacionais:
• Microsoft Windows XP Profissional SP2 ou superior
• Mac OS X 10.4.5 ou superior
Equipe • Red Hat Fedora Core 3
Joyce Avestro • Solaris™ 10 Operating System (SPARC® e x86/x64 Platform Edition)
Florence Balagtas NetBeans Enterprise Pack, poderá ser executado nas seguintes plataformas:
• Microsoft Windows 2000 Profissional SP4
Rommel Feria • Solaris™ 8 OS (SPARC e x86/x64 Platform Edition) e Solaris 9 OS (SPARC e
Reginald Hutcherson x86/x64 Platform Edition)
Rebecca Ong • Várias outras distribuições Linux
John Paul Petines
Configuração Mínima de Hardware
Sang Shin
Nota: IDE NetBeans com resolução de tela em 1024x768 pixel
Raghavan Srinivas
Sistema Operacional Processador Memória HD Livre
Matthew Thompson
Microsoft Windows 500 MHz Intel Pentium III 512 MB 850 MB
workstation ou equivalente
Linux 500 MHz Intel Pentium III 512 MB 450 MB
workstation ou equivalente
Solaris OS (SPARC) UltraSPARC II 450 MHz 512 MB 450 MB
Solaris OS (x86/x64 AMD Opteron 100 Série 1.8 GHz 512 MB 450 MB
Platform Edition)
Mac OS X PowerPC G4 512 MB 450 MB

Configuração Recomendada de Hardware

Sistema Operacional Processador Memória HD Livre


Microsoft Windows 1.4 GHz Intel Pentium III 1 GB 1 GB
workstation ou equivalente
Linux 1.4 GHz Intel Pentium III 1 GB 850 MB
workstation ou equivalente
Solaris OS (SPARC) UltraSPARC IIIi 1 GHz 1 GB 850 MB
Solaris OS (x86/x64 AMD Opteron 100 Series 1.8 GHz 1 GB 850 MB
Platform Edition)
Mac OS X PowerPC G5 1 GB 850 MB

Requerimentos de Software
NetBeans Enterprise Pack 5.5 executando sobre Java 2 Platform Standard Edition
Development Kit 5.0 ou superior (JDK 5.0, versão 1.5.0_01 ou superior), contemplando a
Java Runtime Environment, ferramentas de desenvolvimento para compilar, depurar, e
executar aplicações escritas em linguagem Java. Sun Java System Application Server
Platform Edition 9.
• Para Solaris, Windows, e Linux, os arquivos da JDK podem ser obtidos para sua
plataforma em http://java.sun.com/j2se/1.5.0/download.html
• Para Mac OS X, Java 2 Plataform Standard Edition (J2SE) 5.0 Release 4, pode ser
obtida diretamente da Apple's Developer Connection, no endereço:
http://developer.apple.com/java (é necessário registrar o download da JDK).

Para mais informações: http://www.netbeans.org/community/releases/55/relnotes.html

Estruturas de Dados 2
JEDITM

Colaboradores que auxiliaram no processo de tradução e revisão


Alexandre Mori Jacqueline Susann Barbosa Mauro Regis de Sousa Lima
Alexis da Rocha Silva João Paulo Cirino Silva de Novais Namor de Sá e Silva
Aline Sabbatini da Silva Alves João Vianney Barrozo Costa Nolyanne Peixoto Brasil Vieira
Allan Wojcik da Silva José Augusto Martins Nieviadonski Paulo Afonso Corrêa
André Luiz Moreira José Ricardo Carneiro Paulo Oliveira Sampaio Reis
Anna Carolina Ferreira da Rocha Kleberth Bezerra G. dos Santos Pedro Antonio Pereira Miranda
Antonio Jose R. Alves Ramos Kefreen Ryenz Batista Lacerda Renato Alves Félix
Aurélio Soares Neto Leonardo Leopoldo do Nascimento Renê César Pereira
Bárbara Angélica de Jesus Barbosa Lucas Vinícius Bibiano Thomé Reyderson Magela dos Reis
Bruno da Silva Bonfim Luciana Rocha de Oliveira Ricardo Ulrich Bomfim
Bruno dos Santos Miranda Luís Carlos André Robson de Oliveira Cunha
Bruno Ferreira Rodrigues Luiz Fernandes de Oliveira Junior Rodrigo Fernandes Suguiura
Carlos Alexandre de Sene Luiz Victor de Andrade Lima Rodrigo Vaez
Carlos Eduardo Veras Neves Marco Aurélio Martins Bessa Ronie Dotzlaw
Cleber Ferreira de Sousa Marcos Vinicius de Toledo Rosely Moreira de Jesus
Everaldo de Souza Santos Marcus Borges de S. Ramos de Pádua Seire Pareja
Fabrício Ribeiro Brigagão Maria Carolina Ferreira da Silva Silvio Sznifer
Fernando Antonio Mota Trinta Massimiliano Giroldi Tiago Gimenez Ribeiro
Frederico Dubiel Mauricio da Silva Marinho Vanderlei Carvalho Rodrigues Pinto
Givailson de Souza Neves Mauro Cardoso Mortoni Vanessa dos Santos Almeida

Auxiliadores especiais

Revisão Geral do texto para os seguintes Países:


• Brasil – Tiago Flach
• Guiné Bissau – Alfredo Cá, Bunene Sisse e Buon Olossato Quebi – ONG Asas de Socorro

Coordenação do DFJUG

• Daniel deOliveira – JUGLeader responsável pelos acordos de parcerias


• Luci Campos - Idealizadora do DFJUG responsável pelo apoio social
• Fernando Anselmo - Coordenador responsável pelo processo de tradução e revisão,
disponibilização dos materiais e inserção de novos módulos
• Rodrigo Nunes - Coordenador responsável pela parte multimídia
• Sérgio Gomes Veloso - Coordenador responsável pelo ambiente JEDITM (Moodle)

Agradecimento Especial
John Paul Petines – Criador da Iniciativa JEDITM
Rommel Feria – Criador da Iniciativa JEDITM

Estruturas de Dados 3
JEDITM

1. Objetivos
Na criação de solução para os processos de resolução de problemas, existe a necessidade de
representação dos dados em nível mais alto a partir de informações básicas e estruturas disponíveis
em nível de máquina. Existe também a necessidade de se sintetizar o algoritmo a partir das
operações básicas disponíveis em nível de máquina para manipular as representações em alto nível.
Estas duas características são muito importantes para obtenção do resultado desejado. Estruturas
de dados (Data Structures) são necessárias para a representação dos dados, enquanto que os
algoritmos precisam operar no dado para obter a saída correta.

Nesta lição iremos discutir os conceitos básicos por detrás do Processo Resolução de Problemas
(Problem Solving), tipos de dados, tipos de dados abstratos, algoritmos e suas propriedades,
métodos de endereçamento, funções matemáticas e complexidade dos algoritmos.

Ao final desta lição, o estudante será capaz de:

• Explicar os processos de resolução de problemas


• Definir tipos de dados (data type), tipos de dados abstratos (abstract data type) e
estrutura de dados (data structure)
• Identificar as propriedades de um algoritmo
• Diferenciar os dois métodos de endereçamento – endereçamento computado e
endereçamento por link
• Utilizar as funções matemáticas básicas para analisar algoritmos
• Mensurar a complexidade dos algoritmos expressando a eficiência em termos de
complexidade de tempo e notação Big-O

Estruturas de Dados 4
JEDITM

2. Processo de resolução de problemas


Programação é um processo de resolução de problemas. Por exemplo, o problema é identificado, o
dado a ser manipulado e trabalhado é distinguido e o resultado esperado é determinado. Isso é
implementado em uma máquina chamada computador e as informações fornecidas para ela são
utilizadas para solucionar um dado problema. O processo de resolução de problemas pode ser visto
em termos de Domínio de Problema (Domain Problem), máquina e solução.

Domínio do problema inclui a entrada (input), ou os dados brutos, em um processo, e a saída


(output), ou os dados processados. Por exemplo, na classificação de um conjunto de números
aleatórios, o dado bruto é um conjunto de números na ordem original, aleatórios, e o dado
processado são os números em ordem classificada, crescente, por exemplo.

O domínio de máquina (machine domain) consiste em meios de armazenamento (storage


medium) e unidades processadas. Os meios de armazenamento – bits, bytes, etc – consistem na
combinação de bits em seqüências que são endereçáveis como unidade. As unidades processadas
nos permitem melhorar o desempenho de operações básicas que incluem a aritmética, a comparação
e assim por diante.

O domínio de solução (solution domain), em outras palavras, liga os domínios de problema e de


máquina. É no domínio de solução que as estruturas de dados de alto nível e os algoritmos são
afetados.

Estruturas de Dados 5
JEDITM

3. Tipo de Dado, Tipo de Dado Abstrato e Estrutura de


Dados
Tipo de dado (data type) refere-se à classificação do dado que um atributo pode assumir,
armazenar ou receber em uma linguagem de programação e para a qual as operações são
automaticamente fornecidas. Em Java, os dados primitivos são:

Palavra-chave Descrição
byte Inteiro do tamanho de um byte
short Inteiro curto
int Inteiro
long Inteiro longo
float Ponto flutuante de precisão simples
double Ponto flutuante de precisão dupla
char Um único caractere
boolean Valor booleano (verdadeiro ou falso)
Tabela 1: Dados primitivos

Tipo de dado abstrato (Abstract Data Type – ADT) é um modelo matemático contendo uma
coleção de operadores. Ele especifica um tipo de dado armazenado, o que a operação faz, mas não
como é feito. Um ADT pode ser expresso por uma interface que contém apenas uma lista de
métodos. Por exemplo, esta é uma interface para a stack ADT:

public interface Stack{


public int size(); // retorna o tamanho da stack
public boolean isEmpty(); // verifica se está vazia
public Object top() throws StackException;
public Object pop() throws StackException;
public void push(Object item) throws StackException;
}

Estrutura de dados é a implementação de um TDA em termos de tipos de dados ou outras


estruturas de dados. Uma estrutura de dados é modelada através de classes. Classes especificam
como as operações são executadas. Para implementar um TDA como uma estrutura de dados, uma
interface é implementada através de uma classe.

Abstração e representação ajudam-nos a entender os princípios por detrás dos grandes sistemas de
software. Encapsulamento de informação pode ser utilizada junto com abstração para particionar um
sistema grande em subsistemas menores com interfaces simples que são mais fáceis de entender e
utilizar.

Estruturas de Dados 6
JEDITM

4. Algoritmo
Algoritmo é um conjunto finito de instruções que, se seguidas corretamente, completam uma
determinada tarefa. Possui cinco importantes propriedades: finito, definido, entrada, saída e efetivo.
Finito quer dizer que um algoritmo sempre terá um fim após um número finito de passos. Definido
é a garantia de que todos os passos do algoritmo foram precisamente definidos. Por exemplo:
“dividir por um número x” não é suficiente. O número x deve ser precisamente definido, ou seja, x
deve ser inteiro e positivo. Entrada é o domínio do algoritmo que pode ser nenhum (zero) ou vários.
Saída é o conjunto de um ou mais resultados que também é chamado de alcance do algoritmo.
Efetivo é a garantia de que todas as operações do algoritmo são suficientemente simples de
maneira que também possam, a princípio, ser executadas, em um tempo exato e finito, por uma
pessoa utilizando papel e caneta.

Considere o seguinte exemplo:

public class Minimum {


public static void main(String[] args) {
int a[] = { 23, 45, 71, 12, 87, 66, 20, 33, 15, 69 };
int min = a[0];
for (int i = 1; i < a.length; i++) {
if (a[i] < min) min = a[i];
}
System.out.println("The minimun value is: " + min);
}
}

A classe acima obtém o valor mínimo de um array de inteiros. Não há entrada de dados por parte do
usuário uma vez que o array já está pronto dentro da classe. Para cada propriedade de entrada e
saída cada passo da classe é precisamente definido; neste ponto esta poderá ser definida. A
declaração do laço for e suas respectivas saídas terão um número finito de execução. Logo, a
propriedade finito é satisfeita. E quando executado, a classe retornará o valor mínimo entre os
valores do array, e por isso é dito efetivo.

Todas as propriedades devem ser garantidas na construção de um algoritmo.

Estruturas de Dados 7
JEDITM

5. Métodos de Endereçamento
Na criação de uma estrutura de dados é importante definir como acessar estes dados. Isto é
determinado pelo método de acesso a dados. Existem dois tipos de métodos de endereçamento –
método calculado e método de endereçamento – computado e por link.

5.1. Método de Endereçamento Computado

O método de endereçamento computado é utilizado para acessar os elementos de uma estrutura em


um espaço pré-alocado. É essencialmente estático. Um array, por exemplo:

int x[] = new int[10];

Um item de dado pode ser acessado diretamente pelo índice de onde o dado está armazenado.

5.2. Método de Endereçamento por Link

Este método de endereçamento fornece um mecanismo de manipulação dinâmica de estruturas,


onde o tamanho e a forma não são conhecidos de antemão, ou que são alterados durante a
execução. O importante para este método é o conceito de node contido nestes dois campos: INFO e
LINK.

Figura 1: Estrutura de node


Em Java:

public class Node {


public Object info;
public Node link;

public Node(Object o) {
info = o;
}
public Node(Object o, Node n) {
info = o;
link = n;
}
}

5.2.1. Alocação de ligação: O Pool de Memória

O pool de memória é a fonte dos nodes, onde são construídas as estruturas de ligação. Também são
conhecidas como lista de espaços disponíveis (ou nodes) ou simplesmente lista disponível:

Estruturas de Dados 8
JEDITM

Figura 2: Lista Disponível

A seguir uma classe Java chamada AvailList:

public class AvailList {


private Node head;

public AvailList() {
head = null;
}

public AvailList(Node n){


head = n;
}
}

Criando uma lista disponível através de uma simples declaração:

AvailList avail = new AvailList();

5.2.2. Dois Procedimentos Básicos


Os dois procedimentos básicos que manipulam a lista disponível são getNode e setNode, que obtém
e retornam um node, respectivamente.

O método seguinte na classe AvailList obtém um node da lista disponível:

public Node getNode() {


return head;
}

Figura 3: Obtém um node

enquanto o método a seguir na classe Avail retorna um node para a lista disponível:

public void setNode(Node n) {


n.link = head.link; // Adiciona o novo node no início da lista disponível
head.link = n;
}

Estruturas de Dados 9
JEDITM

Figura 4: Retorna um node

Os dois métodos poderiam ser usados por estruturas de dados que usam alocação de ligação para
pegar os nodes e retorná-los para o pool de memória. E como teste final teremos a seguinte classe:

public class TestNodes {


public static void main(String [] args) {
Node n1 = new Node("1");
Node n2 = new Node("2", n1);
Node n3 = new Node("3", n2);
Node n4 = new Node("4", n3);
AvailList avail = new AvailList(n4);

System.out.println(avail.getNode().info);
System.out.println(avail.getNode().link.info);
System.out.println(avail.getNode().link.link.info);
System.out.println(avail.getNode().link.link.link.info);
}
}

Estruturas de Dados 10
JEDITM

6. Funções Matemáticas
Funções matemáticas são úteis na criação e na análise de algoritmos. Nesta seção, algumas das
funções mais básicas e mais comumente usadas e suas propriedades serão mostradas.

• Floor de x – o maior inteiro menor que ou igual a x, onde x é um número real qualquer.

Notação:  x 

ex.  3.14  = 3  1/2  = 0  -1/2  = - 1

• Ceil de x – é o menor inteiro maior que ou igual a x, onde x é um número real qualquer.

Notação :  x 

ex.  3.14  = 4  1/2  = 1  -1/2  = 0

• Módulo - Dados quaisquer dois números reais x e y, x mod y é definido como

x mod y =x se y = 0
=x-y* x/y  se y <> 0

ex. 10 mod 3 = 1 24 mod 8 = 0 -5 mod 7 = 2

6.1. Identidades

O que segue são identidades relacionadas às funções matemáticas definidas acima:

• x=x se e somente se x é um inteiro


• x>x se e somente se x não é um inteiro
• - x  = -  x 
•  x  +  y  <=  x + y 
• x =  x  + x mod 1
• z ( x mod y ) = zx mod zy

Estruturas de Dados 11
JEDITM

7. Complexidade de Algoritmos
Diversos algoritmos podem ser criados para resolver um único problema. Estes algoritmos podem
variar no modo de obter, processar e dar saída nos dados. Com isso, eles podem ter diferença
significativa em termos de performance e utilização de memória. É importante saber como analisar
os algoritmos, e saber como medir a eficiência dos algoritmos ajuda bastante no processo de análise.

7.1. Eficiência de Algoritmos

A eficiência dos algoritmos é medida através de dois critérios: utilização de espaço e eficiência de
tempo. Utilização de espaço é a quantidade de memória requerida para armazenar dados enquanto
eficiência de tempo é a quantidade de tempo gasta para processar os dados.
Antes de podermos medir a eficiência de tempo de um algoritmo, temos que obter o tempo de
execução. O tempo de execução é a quantidade de tempo gasto para se executar as instruções de
um dado algoritmo. Ele depende do computador (hardware) sendo usado. Para exibir o tempo de
execução, usamos a seguinte notação:
T(n), onde T é a função e n o tamanho da entrada.
Existem vários fatores que afetam o tempo de execução. Eles são:
• Tamanho da entrada
• Tipo da instrução
• Velocidade da máquina
• Qualidade do código-fonte da implementação do algoritmo
• Qualidade do código de máquina gerado pelo compilador

7.2. A Notação Big-O

Embora T(n) forneça a quantidade real de tempo na execução de um algoritmo, é mais fácil
classificar as complexidades de algoritmos utilizando uma notação mais abrangente, a notação Big-O
(ou simplesmente O). T(n) cresce a uma taxa proporcional a n e dessa forma T(n) é dita como
tendo “ordem de magnitude n” denotada pela notação O:
T(n) = O(n)
Esta notação é usada para descrever a complexidade de tempo ou espaço de um algoritmo. Ela
fornece uma medida aproximada do tempo de computação de um algoritmo para um grande número
de dados de entrada. Formalmente, a notação O é definida como:
g(n) = O (f(n)) se existem duas constantes c e n0 tais que
| g(n) | <= c * | f(n) | para todo n >= n0
A seguir temos exemplos de tempos de computação sobre análise de algoritmos:

Big-O Descrição Algoritmo


O(1) Constante
O(log2n) Logarítmica Busca Binária
O(n) Linear Busca Seqüencial
O(n log2n) Heapsort
O(n2) Quadrática Inserção Ordenada
O(n )
3
Cúbica Algoritmo de Floyd

Estruturas de Dados 12
JEDITM

Big-O Descrição Algoritmo


O( 2 )
n
Exponencial
Tabela 2: Tempos de computação sobre análise de algoritmos

Para tornar clara a diferença, vamos efetuar a comparação baseada no tempo de execução onde
n=100000 e a unidade de tempo = 1 mseg:

F(n) Tempo de Execução


log2n 19.93 microssegundos
n 1.00 segundos
n log2n 19.93 segundos
n2
11.57 dias
n3
317.10 séculos
2n Eternidade
Tabela 3: Tempo de execução

7.3. Operações sobre a Notação O

1. Regra para Adição


Suponha que T1(n) = O( f(n) ) e T2(n) = O( g(n) ).
Então, t(n) = T1(n) + T2(n) = O( max( f(n), g(n) ) ).

Prova: Por definição da notação O,


T1(n) ≤ c1 f(n) para n ≥ n1 e
T2(n) ≤ c2 g(n) para n ≥ n2.

Seja n0 = max(n2, n2). Então


T1(n) + T2(n) ≤ c1 f(n) + c2 g(n) n ≥ n0.
≤ (c1 + c2) max(f(n),g(n)) n ≥ n0.
≤ c max ( f(n), g(n) ) n ≥ n0.

Sendo assim, T(n) = T1(n) + T2(n) = O( max( f(n), g(n) ) ).

Por exemplo, 1. T(n) = 3n3 + 5n2 = O(n3)


2. T(n) = 2n + n4 + nlog2n = O(2n)

2. Regra para Multiplicação


Suponha que T1(n) = O( f(n) ) e T2(n) = O( g(n) ).
Então, T(n) = T1(n) * T2(n) = O( f(n) * g(n) ).

Por exemplo, considere o algoritmo abaixo:

for (int i=1; i < n-1; i++)


for (int i=1; i <= n; i++)
// as iterações são executadas O(1) vezes

Já que as iterações no laço mais interno são executadas:

Estruturas de Dados 13
JEDITM

n + n-1 + n-2 + ... + 2 + 1 vezes,

então

n(n+1)/2 = n2/2 + n/2


= O(n2)

Exemplo: Considere o trecho de código abaixo:

for (i=1; i <= n, i++)


for (j=1; j <= n, j++)
// iterações que são executados O(1) vezes

Já que as iterações no laço mais interno serão executadas n + n-1 + n-2 + ... + 2 + 1 vezes,
então o tempo de execução será:

n(n+1)/2 = n2/2 + n/2


= O(n2)

7.4. Análise de Algoritmos

Exemplo 1: Revisitação Mínima

public class Minimum {


public static void main(String [] args) {
int a[] = {23, 45, 71, 12, 87, 66, 20, 33, 15, 69};
int min = a[0];
for (int i = 0; i < a.length; i++) {
if (a[i] < min)
min = a[i];
}
System.out.println("Minimun value is: " + min);
}
}

No algoritmo, as declarações de a e min terão tempos constantes. O tempo constante da sentença if


no loop for serão executadas n vezes, onde n é o número de elementos do array a. A última linha
também será executada em tempo constante.

Linha #Vezes executada


4 1
5 1
6 n+1
7 n
9 1
Tabela 4: Quantidade de execuções por linha

Usando a regra para adição, temos:

T(n) = 2n +4 = O(n)

Estruturas de Dados 14
JEDITM

Já que g(n) <= c f(n) para n >= n0, então

2n + 4 <= cn
2n + 4 <= c
---------
n

2 + 4/n <= c

Assim c = 4 e n0 = 3.
Seguem abaixo as regras gerais para se determinar o tempo de execução de um algoritmo:
• Laços FOR

• Tempo de execução da declaração dentro do laço FOR vezes o número de iterações.

• Laços FOR ANINHADOS

• A Análise é feita a partir do laço mais interior para fora. O tempo total de execução de
uma declaração dentro de um grupo de laço FOR é o tempo de execução da declaração,
multiplicado pelo produto dos tamanhos de todos os laços FOR.

• DECLARAÇÕES CONSECUTIVAS

• A declaração com o maior tempo de execução.

• Condicional IF/ELSE

• Quanto maior o tempo de execução do teste, maior será o tempo de execução do bloco
condicional.

Estruturas de Dados 15
JEDITM

8. Exercícios
a) Funções Piso, Teto e Módulo. Compute para os valores resultantes:

a)  -5.3 
b)  6.14 
c) 8 mod 7
d) 3 mod –4
e) –5 mod 2
f) 10 mod 11
g)  (15 mod –9) + 4.3 

b) Qual é a complexidade de tempo do algoritmo com os seguintes tempos de execução?

a) 3n5 + 2n3 + 3n +1
b) n3/2+n2/5+n+1
c) n5+n2+n
d) n3 + lg n + 34

c) Imagine que tenhamos duas partes em um algoritmo, sendo que a primeira parte toma
T(n1)=n3+n+1, tempo para executar e a segunda parte toma T(n2) = n5+n2+n. Qual é a
complexidade do algoritmo, se a parte 1 e a parte 2 forem executadas uma de cada vez?

d) Ordene as seguintes complexidades de tempo em ordem ascendente.

0(n log2 n) 0(n2) 0(n) 0(log2 n) 0(n2 log2 n)


0(1) 0(n3) 0(nn) 0(2n) 0(log2 log2 n)

e) Qual é o tempo de execução e a complexidade de tempo do algoritmo abaixo?

void warshall(int A[][], int C[][], int n){


for (int i=1; i<=n; i++)
for (int j=1; j<=n; j++)
A[i][j] = C[i][j];
for (int i=1; i<=n; i++)
for (int j=1; j<=n; j++)
for (int k=1; k<=n; k++)
if (A[i][j] == 0)
A[i][j] = A[i][k] & A[k][j];
}

Estruturas de Dados 16
Módulo 3
Estruturas de Dados

Lição 2
Stack

Versão 1.0 - Mai/2007


JEDITM

1. Objetivos
Uma stack (pilha) é uma ordem linear de elementos obedecendo a regra: “o último que entrar é o
primeiro a sair”, é conhecida como listas LIFO (Last In First Out).

É semelhante a um conjunto de caixas em um armazém, onde só a caixa de topo pode ser retirada e
não há acesso às outras caixas. Quando adicionamos uma caixa, é sempre é colocada no topo.

Stack é utilizada em reconhecimentos de padrões, listas e árvores transversais, avaliação de


expressões, soluções de recursividade e muito mais. As duas operações básicas para manipulação de
dados em uma stack são “push” e “pop”, ou seja, inserção e retirada de elementos do topo da stack
respectivamente.

Ao final desta lição, o estudante será capaz de:

• Explicar os conceitos básicos e operações em uma stack ADT


• Implementar uma stack ADT usando representação seqüencial e de ligação
• Discutir aplicações de stack: Os problemas de reconhecimento de padrões e conversões do
tipo infix para postfix
• Explicar como múltiplas stacks podem ser armazenadas utilizando array de uma dimensão
• Realocação de memória durante um transbordamento (estouro) de um array com múltiplas
stacks utilizando algoritmos unit-shift policy e Garwick's

Estruturas de Dados 4
JEDITM

2. Operações em Stack
Como já mencionado, Interfaces (Application Programming Interface ou API) são usadas para
implementar ADT em Java. Esta é a interface Java para stack:
public interface Stack {
public int size(); // retorna o tamanho da stack
public boolean isEmpty(); // verifica se está vazia
public Object top() throws StackException;
public Object pop() throws StackException;
public void push(Object item) throws StackException;
}

StackException é uma extensão de RuntimeException:


class StackException extends RuntimeException{
public StackException(String err){
super(err);
}
}

As stacks possuem duas implementações possíveis - array unidimensional seqüencialmente alocado


ou uma lista linear encadeada. Entretanto, a implementação que será usada é uma interface Stack.

A seguir as operações de uma stack:

• Verificar o tamanho
• Verificar se está vazia
• Pegar o elemento do topo sem excluí-lo
• Inserir um novo elemento (push)
• Apagar um elemento do topo (pop)

Figura 1. Operação PUSH

Figura 2. Operação POP

Estruturas de Dados 5
JEDITM

3. Representação seqüencial
A alocação seqüencial de uma stack faz uso de arrays, conseqüentemente o tamanho é estático. A
stack está vazia se o topo=-1 e cheia se o topo=n-1. Tentar apagar um elemento de uma stack vazia
causa um underflow enquanto a inserção de um elemento em uma stack cheia causa um overflow.
A figura a seguir mostra um exemplo de uma stack ADT:

Figura 3. Retirar e inserir

A seguir, a implementação de uma stack usando a representação seqüencial:


public class ArrayStack implements Stack {
// Array usado para implementar a stack
Object S[];

// Inicializa a stack em vazio


int top = -1;

// Inicializa a stack para o padrão 0


public ArrayStack() {
this(0);
}

// Inicializa a stack para ser o comprimento recebido


public ArrayStack(int c) {
S = new Object[c];
}

// Implementação do método size


public int size() {
return (top+1);
}

// Implementação do método isEmpty


public boolean isEmpty() {
return (top < 0);
}

// Implementação do método top


public Object top() {
if (isEmpty()) throw new StackException("Stack empty.");
return S[top];
}

// Implementação do método pop


public Object pop() {
Object item;
if (isEmpty()) throw new StackException("Stack underflow.");
item = S[top];

Estruturas de Dados 6
JEDITM

S[top--] = null;
return item;
}

// Implementação do método push


public void push(Object item) {
if (size() == s.length)
throw new StackException("Stack overflow.");
S[++top]=item;
}
}

Podemos testar esta representação com a seguinte classe:


public class TestArrayStack {
public static void main(String [] args) {
ArrayStack stack = new ArrayStack(4);
stack.push("1");
stack.push("2");
stack.push("3");
stack.push("4");
System.out.println(stack.pop());
System.out.println(stack.pop());
System.out.println(stack.pop());
System.out.println(stack.pop());
}
}

Como resultado, lembre-se que a stack armazena “Empilhando” os dados, e retirá-os do último ao
primeiro elemento armazenado.

Estruturas de Dados 7
JEDITM

4. Representação Encadeada
Uma lista acoplada de nodes poderia ser utilizada para implementar uma stack. Na representação
acoplada, um node com a estrutura definida a seguir será usada:
class Node {
private Object info;
private Node link;
public Node(Object o, Node n) {
info = o;
link = n;
}
}

A figura seguinte mostra uma stack representada como uma lista linear encadeada:

Figura 4. Representação Encadeada

O código Java a seguir implementa a stack utilizando representação encadeada:


public class LinkedStack implements Stack {
private Node top;

// O número de elementos na stack


private int numElements = 0;

// Implementaçao do método size


public int size() {
return (numElements);
}

// Implementaçao do método isEmpty


public boolean isEmpty() {
return (top == null);
}

// Implementaçao do método top


public Object top() {
if (isEmpty()) throw new
StackException("Stack empty.");
return top.info;
}

// Implementaçao do método pop


public Object pop() {
Node temp;
if (isEmpty())
throw new StackException("Stack underflow.");
temp = top;

Estruturas de Dados 8
JEDITM

top = top.link;
return temp.info;
}

// Implementaçao do método push


public void push(Object item) {
Node newNode = new Node(item);
newNode.link = top;
top = newNode;
}
}

Podemos testar esta representação com a seguinte classe:


public class TestArrayStack {
public static void main(String [] args) {
LinkedStack stack = new LinkedStack();
stack.push("1");
stack.push("2");
stack.push("3");
stack.push("4");
System.out.println(stack.pop());
System.out.println(stack.pop());
System.out.println(stack.pop());
System.out.println(stack.pop());
}
}

Estruturas de Dados 9
JEDITM

5. Aplicação Exemplo: Problema do Reconhecimento de Padrão


Dado o conjunto L = { wcwR | w ⊂ { a, b }+ }, onde wR é o reverso de w. L define uma linguagem
que contém um conjunto infinito de strings palíndromos. w não pode ser uma string vazia. Exemplos
são aca, abacaba, bacab, bcb e aacaa.

O algoritmo seguinte pode ser usado para resolver o problema:

1. Pegue o próximo caractere a ou b da string de entrada e insira na stack; repita até o símbolo
c ser encontrado.
2. Pegue o próximo caractere a ou b da string de entrada, abra a stack e compare. Se os dois
símbolos batem, continue, de outra maneira, pare – a string não está em L.

A seguir estão os estados adicionais nos quais a string de entrada pode ser encontrada para não
estar em L:

1. O fim da string foi atingida mas o c não foi encontrado.


2. O fim da string foi atingido mas a stack não está vazia.
3. A stack está vazia mas o fim da string não foi atingido ainda.

Os exemplos a seguir ilustram como o algoritmo trabalha:

Entrada Ação Stack


abbabcbabba ------ (bottom) --> (top)
abbabcbabba Entre a a
bbabcbabba Entre b ab
babcbabba Entre b abb
abcbabba Entre a abba
bcbabba Entre b abbab
cbabba Descarte c abbab
babba Abra, compare b e b --> ok abba
abba Abra, compare a e a --> ok abb
bba Abra, compare b e b --> ok ab
ba Abra, compare b e b --> ok a
a Abra, compare a e a --> ok -
- Sucesso
Tabela 1: Execução do algoritmo

Entrada Ação Stack


abacbab ------ (bottom) --> (top)
abacbab Entre a a
bacbab Entre b ab
acbab Entre a aba
cbab Descarte c aba

Estruturas de Dados 10
JEDITM

bab Abra, compare a e b ba


--> não batem, na string
Tabela 2: Execução do algoritmo

No primeiro exemplo, a string será aceita enquanto que no segundo não.

A seguir temos uma classe Java utilizada para implementar o padrão recognizer:
public class PatternRecognizer{
ArrayStack S = new ArrayStack(100);

public static void main(String[] args) {


PatternRecognizer pr = new PatternRecognizer();
if (args.length < 1)
System.out.println("Usage: PatternRecognizer <input string>");
else {
boolean inL = pr.recognize(args[0]);
if (inL)
System.out.println(args[0] + " is in the language.");
else
System.out.println(args[0] + " is not in the language.");
}
}

boolean recognize(String input) {


int i=0; // Indicador de caractere corrente

// Enquanto c não é encontrado, entre com um caractere na stack


while ((i < input.length()) && (input.charAt(i) != 'c')) {
S.push(input.substring(i, i+1));
i++;
}

// O fim da string foi atingido mas o c não foi encontrado


if (i == input.length()) return false;

// Descarte o c, mova para o próximo caractere


i++;

// O ultimo character é c
if (i == input.length()) return false;

while (!S.isEmpty()) {
// Se o character de entrada e o outro no topo da stack não baterem
if (!(input.substring(i,i+1)).equals(S.pop()))
return false;
i++;
}

// A stack está vazia mas o fim da string não foi alcançada ainda
if (i < input.length()) return false;

// O fim da string foi alcançado mas a stack não está vazia


else if ((i == input.length()) && (!S.isEmpty())) return false;

else return true;

Estruturas de Dados 11
JEDITM

}
}

5.1. Aplicação: Infix to Postfix


Uma expressão está na forma infix se toda sub-expressão a ser avaliada está no formato
operando-operador-operando. Por outro lado, a forma postfix é aquela em que a sub-expressão
a ser avaliada está na forma operando-operando-operador. Estamos acostumados a avaliar
expressões infix, porém é mais apropriado para os computadores avaliarem expressões na forma
postfix.

Existem algumas propriedades que precisamos tomar nota neste problema:

• O grau de um operador é o número de operandos que ele tem.


• O rank de um operando é 1. O rank de um operando é 1 menos seu grau. O rank de uma
seqüência arbitrária de operandos e operadores é as somas dos ranks dos operandos e
operadores individuais.
• Se z = x | y é uma string, então x é o topo de z. E x é um próprio topo se y não é uma
string nula.

Teorema: uma expressão postfix é bem moldada se o rank de todos os topos são maiores ou iguais
a 1 e o rank da expressão é 1.

A tabela a seguir mostra a ordem de precedência dos operadores:

Operador Prioridade Propriedade Exemplo


^ 3 Associação a direita a^b^c = a^(b^c)
*/ 2 Associação a esquerda a*b*c = (a*b)*c
+- 1 Associação a esquerda a+b+c = (a+b)+c
Tabela 3: Ordem de precedência

Exemplos:

Expressão Infix Expressão Postfix


a*b+c/d ab*cd/-

a^b^c-d abc^^d-

a*(b+(c+d)/e)-f a b c d + e /+* f -

a*b/c+f*(g+d)/(f–h)^i ab*c/fgd+*fh–i^/+

Tabela 4: Expressão Infix e Postfix

Regras para conversão de infix para postfix:

1. A ordem dos operandos nas duas formas são as mesmas se os parênteses estiverem ou não
presentes na expressão infix.
2. Se a expressão infix não contém parênteses, então a ordem dos operadores na expressão
postfix está de acordo com sua prioridade.
3. Se a expressão infix contém sub expressões em parênteses, a regra 2 se aplica do mesmo

Estruturas de Dados 12
JEDITM

modo para as sub-expressões.

E a seguir estão os números prioritários:

• Icp (x de ·) - número da prioridade quando o simbólico (token) x é um símbolo de entrada


(prioridade de entrada)

• Isp (x de ·) - número de prioridade quando o simbólico (token) x está na stack (prioridade


de entrada na stack)

Token, x icp(x) isp(x) Rank


Operando 0 - +1
+- 1 2 -1
*/ 3 4 -1
^ 6 5 -1
( 7 0 -
Tabela 5: Números prioritários

Agora o algoritmo:

1. Pega o próximo símbolo (token) x.


2. Se x é operando então sai x
3. Se x é (, então insere x na stack.
4. Se x é ), então retira elementos da stack até que ( seja encontrado, mais uma vez apagar o
(, Se topo = 0, o algoritmo termina.
5. Se x é um operador, então, enquanto icp(x) < isp(stack(top)), remove os elementos da
stack; caso contrário; se icp(x) > isp(stack(top)), então insere x na stack.
6. Retorna ao passo 1.

Como no exemplo, vamos fazer a conversão de a + ( b * c + d ) - f / g ^ h usando a forma


postfix:

Símbolo de Stack saída Observações


entrada
a a sai a
+ + a entra +
( +( a entra (
b +( ab sai b
* +(* ab icp(*) > isp(()
c +(* abc sai c
+ +(+ abc* icp(+) < isp(*), sai *
icp(+) > isp((), insere +
d +(+ abc*d sai d
) + abc*d+ sai +, sai (
- - abc*d++ icp(-) < isp(+), pop +, push -

Estruturas de Dados 13
JEDITM

f - abc*d++f sai f
/ -/ abc*d++f icp(/)>isp(-), push /
g -/ abc*d++fg sai g
^ -/^ abc*d++fg icp(^)>isp(/), push ^
h -/^ abc*d++fgh sai h
- abc*d++fgh^/- retira ^, retira /, retira -
Tabela 6: Forma postfix

Estruturas de Dados 14
JEDITM

6. Tópicos avançados de stacks

6.1. Múltiplas stacks usando um array unidimensional


Duas ou mais stack podem coexistir em um array S comum de tamanho n. Nesta abordagem temos
uma melhora na utilização da memória.

Se duas stack compartilham determinado array S, elas crescem e diminuem dentro do array S,
sendo que os finais estão frente a frente separados por um intervalo definido. A figura a seguir
mostra o comportamento de duas stack quando compartilham um mesmo array S:

Figura 5. Duas stack coexistindo em um mesmo array

Na inicialização, a stack 1 está com o topo marcado como sendo -1, desta forma, top1=-1 e para a
stack 2 será top2 = n.

6.2. Três ou mais stacks no mesmo array S:


Se três ou mais stack compartilham um mesmo array, elas precisarão de um indicador do endereço
para os vários topos e bases destas, os apontadores de bases definem o inicio das stack m dentro do
array S com tamanho igual a n, a notação disto será determinada por B[i]:

B[i] = n/m * i - 1 0≤i<m

B[m] = n-1

Os pontos B[i] determinam o espaço de uma stack. Para inicializar a stack, os topos serão marcados
como sendo a ponto base da outra stack formando células,

T[i] = B[i] , 0≤i≤m

Por exemplo:

Figura 6. Três stacks no mesmo array

Estruturas de Dados 15
JEDITM

6.3. Três possíveis estados


O diagrama abaixo mostra as três possibilidades possíveis: stack vazia, cheia, cheia mas não lotada.
Stack i está cheia se T[i] = B[i+1]. Não está cheia se T[i] < B[i+1] e está cheia se T[i] = B[i].

Figura 7. Três estados das stack (vazia, não vazia mas não cheia, cheia)

a parte do código Java a seguir mostra a implementação das operações de push e pop (inserir e
remover) para a classe Mstack:
class MStack {
int m = 3; // número de stacks, por padrão 3
int n = 300; // tamanho do array S
Object[] S;
int B[], T[], oldT[];

//int B[] = {-1,90,210,310,400,499};


//int T[] = {79,210,270,345,423};
//int oldT[] = {85,195,254,360,415};

// Construtor padrão
public MStack() {
S = new Object[n];
B = new int[m+1];
T = new int[m];
oldT = new int[m];
}

// Construtor com número de stacks e tamanho


public MStack(int numStacks, int s) {
m = numStacks;
n = s;
S = new Object[n];
B = new int[m+1];
T = new int[m];
oldT = new int[m];
}

// Returna o tamanho da stack i


public int size(int i) {
return (T[i]-B[i]);
}

// Checa se a stack está vazia


public boolean isEmpty(int i) {
return ((T[i]-B[i])==0);
}

// Returna o topo da stack i


public Object top(int i) throws StackException {

Estruturas de Dados 16
JEDITM

if (isEmpty(i)) throw new StackException("Stack empty.");


return S[T[i]];
}

// inserindo elementos na stack i


public void push(int i, Object item) {
if (T[i]==B[i+1]) MStackFull(i);
S[++T[i]]=item;
}

// retirando elementos na stack i


public Object pop(int i) throws StackException {
Object item;
if (isEmpty(i))
throw new StackException("Stack underflow.");
item = S[T[i]];
S[T[i]--] = null;
return item;
}
}

O método MStackFull captura uma possível condição de overflow.


public void MStackFull(int i) {
// garwicks(i);
// unitShift(i);
}

Veremos os métodos descritos a seguir.

6.4. Realocação de memória no caso de estouro (Stack Overflow)


Quando as stack coexistem em um mesmo array é possível que uma determinada stack fique cheia
enquanto a stack adjacente ainda esteja vazia ou “não cheia”. Neste cenário será preciso realocar
memória para que seja possível disponibilizar mais espaço para a stack cheia. Para fazer isso
procuramos a stack que possui endereços vazios.

Para fazer isto, procuramos nas stacks sobre a stack i (endereço-primário) pela mais próxima stack
com células disponíveis, chamamos stack k, e então trocaremos a stack i+1 acima da stack k uma
célula, até a célula esteja disponível para a stack i. Se todas as stacks sobre a stack i estão cheias,
então procuramos as stacks abaixo até a mais próxima stack com espaço livre, chamamos de stack
k, e então trocamos as células uma unidade para baixo. Isto é conhecido como o método de unit-
shift. Se k possui um valor inicial igual a -1, então as seguintes implementações de código para o
método unitShift que será chamado pelo método MStackFull:
/* capturando estouro de stack (stack overflow) usando o policiamento unidade
de troca(Unit-Shift) e retorna true se obteve sucesso, caso contrário false */

public void unitShift(int i) throws StackException {


int k=-1; // Pontos da mais proxima stack com espaço livre

// Procura a stack acima (endereço)


for (int j=i+1; j<m; j++)
if (T[j] < B[j+1]) {
k = j;
break;
}

Estruturas de Dados 17
JEDITM

// Troca os itens da stack k para fazer frente a stack i


if (k > i) {
for (int j=T[k]; j>T[i]; j--)
S[j+1] = S[j];

// Ajusta os pontos de topo e base


for (int j=i+1; j<=k; j++) {
T[j]++;
B[j]++;
}
} else if (k > 0) { // Procura a stack abaixo se nenhum for achado
for (int j=i-1; j>=0; j--)
if (T[j] < B[j+1]) {
k = j+1;
break;
}
for (int j=B[k]; j<=T[i]; j++)
S[j-1] = S[j];

// Ajusta os ponteiros da base e to topo


for (int j=i; j>k; j--) {
T[j]--;
B[j]--;
}
} else // Não obteve sucesso, todas as stack estão cheias
throw new StackException("Stack overflow.");
}

6.5. Realocação de Memória usando o algoritmo de Garwick


O algoritmo de Garwick é uma maneira mais eficaz de se realocar os espaços quando a stack se
torna cheia. Ele realoca a memória em dois passos: primeiro, um tamanho fixo de espaço é dividido
entre todas as stack; e, segundo, o espaço restante é distribuído nas stack de acordo com a
necessidade. A seguir vemos o algoritmo:
1. Elimina todas as células que não estão sendo usadas de todas as stack e considera esse
espaço não-usado como sendo um espaço válido para a realocação.
2. Realoca de 1 a 10% do espaço livre válido igualmente entre as stack.
3. Realoca o espaço restante disponível nas stack em proporção com o recente crescimento,
onde o recente crescimento será medido como sendo a diferença entre T[j] – oldT[j], onde
oldT[j] é o valor de T[j] ao final da ultima realocação. Uma diferença negativa (positiva)
significa que a stack j realmente foi diminuída (aumentada) em tamanho desde a última
realocação.

6.6. Implementação de Knuth para o Algoritmo de Garwick


A implementação de Knuth organiza os espaços para que sejam distribuídos igualmente entre as
stacks em 10%. Os 90% restantes serão particionados de acordo com o crescimento recente. O
tamanho da stack (crescimento cumulativo) também é usado como medida de necessidade para a
distribuição dos 90%. Quanto maior a stack, maior a quantidade de espaço que será alocada.

A seguir vemos o algoritmo:

1. Reunindo estatísticas sobre o uso da stack:

Estruturas de Dados 18
JEDITM

Tamanho da stack = T[j] - B[j]

Nota: +1 se a stack estiver sobrecarregada

differences = T[j] – oldT[j] if T[j] – oldT[j] >0

else 0 [a diferença negativa será substituída por 0]

Nota: +1 se a stack estiver sobrecarregada

freecells = tamanho total – (soma dos tamanhos)

incr = (soma das diferenças)

Nota: contador recebe +1 para a célula que sobrecarregou a stack.

2. Calculo da alocação de fatores

α = 10% * freecells / m

β = 90%* freecells / incr

onde:

- m= número de stack

- α é o número de células que cada stack possui dos 10% do espaço válido alocado

- β é o número de células que a stack terá por unidade incrementada do uso da stack dos
90% do espaço livre restantes.

3. Calculando o novo endereço

σ - espaço livre teoricamente alocado para as stack 0, 1, 2, ..., j - 1

τ - espaço livre teoricamente alocada para as stack 0, 1, 2, ..., j

Número real total de células livres alocadas na stack j =  τ  -  σ 

inicialmente, (new)B[0] = -1 e σ = 0

for j = 1 to m-1:

τ = σ + α + diff[j-1]*β

B[j] = B[j-1] + size[j-1] +  τ  -  σ 

σ=τ

4. Trocando as stack para suas novas coordenadas.

5. Atribuindo oldT = T

Considere o seguinte exemplo. 5 stack coexistindo num vector de 500 posições. O estado das stacks
é mostrado na figura abaixo:

Estruturas de Dados 19
JEDITM

Figura 8. Estado das stack antes da re-alocação

1. Reunindo estatísticas sobre o uso da stack

Tamanho das stack = T[j] - B[j]

Nota: +1 se a stack estiver sobrecarregada

Diferenças = T[j] – OLDT[j] if T[j] – OLDT[j] >0

else 0 [a diferença negativa é substituída por 0]

Nota: +1 se a stack estiver sobrecarregada

freecells = tamanho total – (soma dos tamanhos)

incr = (soma das diferenças)

fator Valor
Tamanho das stack Tamanho = (80, 120+1, 60, 35, 23)
Diferenças Diferença = (0, 15+1, 16, 0, 8)
freecells 500 - (80 + 120+1 + 60 + 35 + 23) = 181
incr 0 + 15+1 + 16 + 0 + 8 = 40
Tabela 7: Fator e valor

2. Calculando a alocação dos fatores

α = 10% * freecells / m = 0.10 * 181 / 5 = 3.62

β = 90%* freecells / incr = 0.90 * 181 / 40 = 4.0725

3. Calcula o novo endereço

B[0] = -1 and σ = 0

for j = 1 to m:

τ = σ + α + diff(j-1)*β

B[j] = B[j-1] + size[j-1] +  τ  -  σ 

σ=τ

j = 1: τ = 0 + 3.62 + (0*4.0725) = 3.62

B[1] = B[0] + size[0] + τ  -  σ 

= -1 + 80 + 3.62 – 0 = 82

Estruturas de Dados 20
JEDITM

σ = 3.62

j = 2: τ = 3.62 + 3.62 + (16*4.0725) = 72.4

B[2] = B[1] + size[1] + τ  -  σ 

= 82 + 121 + 72.4 –  3.62 = 272

σ = 72.4

j = 3: τ = 72.4 + 3.62 + (16*4.0725) = 141.18

B[3] = B[2] + size[2] + τ  -  σ 

= 272 + 60 + 141.18 – 72.4 = 401

σ = 141.18

j = 4: τ = 141.18 + 3.62 + (0*4.0725) = 144.8

B[4] = B[3] + size[3] + τ  -  σ 

= 401 + 35 + 144.8 – 141.18 = 439

σ = 144.8

Para checar, NEWB(5) deverá ser igual a 499:

j = 5: τ = 144.8 + 3.62 + (8*4.0725) = 181

B[5] = B[4] + size[4] + τ  -  σ 

= 439 + 23 + 181 – 144.8 = 499 [OK]

4. Troca as stack para suas novas coordenadas.

B = (-1, 82, 272, 401, 439, 499)

T[i] = B[i] + size [i] ==> T = (0+80, 83+121, 273+60, 402+35, 440+23)

T = (80, 204, 333, 437, 463)

oldT = T = (80, 204, 333, 437, 463)

Figura 9. Estado das stacks após uma realocação

Existem algumas técnicas para melhorar a utilização das stack. Primeiro, saiba qual stack é a maior,
faça isso primeiro. Segundo, o algoritmo pode emitir um comando de parada quando o espaço livre
se tornar menor que um valor mínimo especificado que não 0, quer dizer um minfree (mínimo livre),
onde o usuário possa especificar esse valor mínimo.

Além de Stacks, o algoritmo pode ser capacitado para realocar espaço para mais dados em outros

Estruturas de Dados 21
JEDITM

tipos de estruturas (por exemplo queue, tabelas seqüenciais ou a combinação destas).

O método a seguir implementa um algoritmo Garwick's que será chamado pelo método MStackFull:
// Método Garwick's
public void garwicks(int i) throws StackException {
int diff[] = new int[m];
int size[] = new int[m];
int totalSize = 0;
double freecells, incr = 0;
double alpha, beta, sigma=0, tau=0;

// calculo de fatores de distribuição


for (int j=0; j<m; j++) {
size[j] = T[j]-B[j];
if ((T[j]-oldT[j]) > 0)
diff[j] = T[j]-oldT[j];
else
diff[j] = 0;
totalSize += size[j];
incr += diff[j];
}

diff[i]++;
size[i]++;
totalSize++;
incr++;
freecells = n - totalSize;
alpha = 0.10 * freecells / m;
beta = 0.90 * freecells / incr;

// se todas as stack estiverem cheias


if (freecells < 1)
throw new StackException("Stack overflow.");

// cálculo para novas bases


for (int j=1; j<m; j++) {
tau = sigma + alpha + diff[j-1] * beta;
B[j] = B[j-1] + size[j-1] + (int)Math.floor(tau) - (int)Math.floor(sigma);
sigma = tau;
}

// Restabeleça tamanho da stack que teve overflowed para o seu antigo valor
size[i]--;

// cálculo para o novo endereço de topo


for (int j=0; j<m; j++)
T[j] = B[j] + size[j];
oldT = T;
}

Para testar esta classe insira o seguinte método principal:


public static void main(String args[]) {
MStack stack = new MStack(5, 500);
System.out.println(stack.n);
for (int i=0; i<stack.m; i++){
System.out.println("B: " + stack.B[i] +

Estruturas de Dados 22
JEDITM

" T: " + stack.T[i] + " oldT: " + stack.oldT[i]);


}
stack.push(1, "a");
for (int i=0; i<stack.m; i++){
System.out.println("B: " + stack.B[i] +
" T: " + stack.T[i] + " oldT: " + stack.oldT[i]);
}
}

Estruturas de Dados 23
JEDITM

7. Exercícios
1. Converta as expressões seguintes da forma infix para postfix e mostre a stack resultante.
a) a+(b*c+d)-f/g^h
b) 1/2-5*7^3*(8+11)/4+2

2. Converta as expressões seguintes para a forma postfix:


a) a+b/c*d*(e+f)-g/h
b) (a-b)*c/d^e*f^(g+h)-i
c) 4^(2+1)/5*6-(3+7/4)*8-2
d) (m+n)/o*p^q^r*(s/t+u)-v

3. Estratégias de realocação para overflow de stack para os números 3 e 4:

a) Desenhe um diagrama mostrando o estado atual da stack.


b) Desenhe um diagrama mostrando o estado da stack depois da implementação do unit-shift
policy.
c) Desenhe um diagrama mostrando o estado da stack depois de usar o algoritmo Garwick's
mostrando como as novas bases foram computadas.
4. Cinco stack coexistindo em um array de 500 posições. Uma inserção é tentada na stack 2. O
estado de computação está definido por:
OLDT(0:4) = (89, 178, 249, 365, 425)
T(0:4) = (80, 220, 285, 334, 433)
B(0:5) = (-1, 99, 220, 315, 410, 499)

5. Três stack coexistem em um array de tamanho 300. Uma inserção é tentada na stack 3. O estado
de computação está definido por:
OLDT(0:2) = (65, 180, 245)
T(0:2) = (80, 140, 299)
B(0:3) = (-1, 101, 215, 299)

7.1. Exercícios para Programar


1. Escreva uma classe de Java que verifica se os parênteses e chaves estão equilibrados em uma
expressão aritmética.

2. Crie uma classe Java que implementa a conversão bem-formada de uma expressão infix e seu
postfix equivalente.

3. Implemente a conversão de infix para postfix usando uma implementação encadeada de stack. A
classe solicitará uma entrada do usuário e verificará se a entrada está correta. Mostre a produção e
conteúdo da stack em toda interação.

4. Crie uma definição de classe Java de uma stack múltipla em um array dimensional. Implemente
as operações básicas em stack (push e pop, etc) para ser aplicável em múltipla Stack. O nome da
classe será MStack.

5. Uma loja de livro tem estantes com divisórias ajustáveis. Quando uma divisória fica cheia, a
divisória será ajustada para obter mais espaço. Crie uma classe Java que relocará o espaço da
estante usando o algoritmo de Garwick.

Estruturas de Dados 24
Módulo 3
Estruturas de Dados

Lição 3
Queue

Versão 1.0 - Mai/2007


JEDITM

1. Objetivos
Uma queue (fila) é um conjunto de elementos ordenado linearmente que têm as características
First-In (Primeiro a entrar) e First-Out (Primeiro a sair). Conhecido também como lista FIFO.

Existem duas operações básicas para queue: (1) inserção no final, e (2) remoção no início.

Ao final desta lição, o estudante será capaz de:

• Definir os conceitos básicos e operações com queue ADT


• Implementar uma queue ADT usando representação seqüencial e encadeada
• Realizar operações em queues circulares
• Usar a ordenação topológica para produzir uma organização dos elementos que satisfaça a
um padrão estabelecido

Estruturas de Dados 4
JEDITM

2. Representação de Queue
Para definir uma queue em Java, devemos usar a seguinte interface:

interface Queue{
// Insere um item
void enqueue(Object item) throws QueueException;

// Remove um item
Object dequeue() throws QueueException;
}

Assim como na stack, devemos usar o seguinte código de Exception a fim de tratarmos as exceções:

class QueueException extends RuntimeException{


public QueueException(String err){
super(err);
}
}

Como a stack, a queue deve ser implementada utilizando a representação seqüencial ou


encadeada.

2.1. Representação Seqüencial

Se a execução utiliza uma representação seqüencial, um array unidimensional é usado, portanto o


tamanho é estático. Se a queue tem dados, front aponta para seu primeiro elemento, enquanto que
rear aponta para a célula seguinte à última ocupada. A queue está vazia quando front é igual a
rear e cheia quando front é igual a zero e rear é igual a n. Tentar remover um item de uma queue
vazia causa um underflow, enquanto que tentar inserir em uma queue cheia causa um overflow. A
figura a seguir mostra um exemplo de queue:

Figura 1: Operações em uma fila

Para iniciar, igualamos front e rear a 0:

front = 0;
rear = 0;

Para inserir um item, digamos x, fazemos o seguinte:

Estruturas de Dados 5
JEDITM

Q [rear] = item;
Rear ++;

e para remover um item, fazemos o seguinte:

x = Q[front];
front ++;

Para implementar uma queue utilizando a representação seqüencial:

class SequentialQueue implements Queue{


Object Q[];
int n = 100 ; // tamando da fila , padrão 100
int front = 0; // inicio e fim iniciar como 0
int rear = 0;

// Cria uma queue com tamanho definido de 100 elementos


public SequentialQueue() {
Q = new Object[n];
}

// Cria uma queue com tamanho a definir


public SequentialQueue(int size) {
n = size;
Q = new Object[n];
}

// Insere um item na queue


public void enqueue(Object item) throws QueueException {
if (rear == n)
throw new QueueException("Inserting into a full queue.");
Q[rear] = item;
rear++;
}

// Remove um item da queue


public Object dequeue() throws QueueException {
if (front == rear)
throw new QueueException("Deleting from an empty queue.");
Object x = Q[front];
front++;
return x;
}
}

Sempre que uma remoção é feita, um espaço vago é criado na frente da queue. Portanto, existe a
necessidade de mover os itens de forma que o espaço vazio fique no fim da queue para futuras
inserções. O método moveQueue executa esse procedimento. Esse método é chamado pelo código
abaixo:

public void moveQueue() throws QueueException {


if (front==0)
throw new QueueException("Inserting into a full queue");
for (int i=front; i<n; i++)
Q[i-front] = Q[i];
rear = rear - front;

Estruturas de Dados 6
JEDITM

front = 0;
}

É preciso modificar a execução do método enqueue para se utilizar do método moveQueue:


public void enqueue(Object item) {
// se rear está no fim do array
if (rear == n)
moveQueue();
Q[rear] = item;
rear++;
}

Podemos testar esta representação com a seguinte classe:


public class TestQueue {
public static void main(String [] args) {
SequentialQueue queue = new SequentialQueue(4);
queue.enqueue("1");
queue.enqueue("2");
queue.enqueue("3");
queue.enqueue("4");
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
}
}

Como resultado, lembre-se que a queue armazena “Enfileirando” os dados, e retirá-os do primeiro ao
último elemento armazenado.

2.2. Representação Encadeada

Representação encadeada também pode ser utilizada para uma queue. Da mesma forma que para
stacks, utilizará nodes com os campos INFO e LINK. Na representação acoplada, um node com a
estrutura definida a seguir será utilizado:

class Node {
private Object info;
private Node link;
public Node(Object o, Node n) {
info = o;
link = n;
}
}

A figura a seguir mostra uma queue implementada como uma queue encadeada:

Figura 2: Representação encadeada de uma Queue

Estruturas de Dados 7
JEDITM

As definições já vistas sobre node serão utilizadas aqui.


A queue está vazia se front é igual a null. Na representação encadeada, desde que a queue cresça
dinamicamente, o overflow acontecerá somente quando a classe ficar sem espaço para novas
inserções e tratar disso está fora do escopo desse tópico.
O seguinte classe executa a representação encadeada de uma queue ADT:
class LinkedQueue implements Queue {
private Node front;
private Node rear;

// Cria uma queue vazia


public LinkedQueue() {
}
// Cria uma queue com n NODES inicialmente
public LinkedQueue(Node n) {
front = n;
rear = n;
}
// Insere um item na queue
public void enqueue(Object item) {
Node n = new Node(item, null);
if (front == null) {
front = n;
rear = n;
} else {
rear.link = n;
rear = n;
}
}
// Remove um item da queue
public Object dequeue() throws QueueException {
Object x;
if (front == null)
throw new QueueException("Deleting from an empty queue.");
x = front.info;
front = front.link;
return x;
}
}

Podemos testar esta representação com a seguinte classe:


public class TestQueue {
public static void main(String [] args) {
LinkedQueue queue = new LinkedQueue();
queue.enqueue("1");
queue.enqueue("2");
queue.enqueue("3");
queue.enqueue("4");
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
System.out.println(queue.dequeue());
}
}

Estruturas de Dados 8
JEDITM

3. Queue Circular
Uma desvantagem da implementação seqüencial anterior é a necessidade de se mover os elementos,
no caso de rear ser igual a n e front ser maior que 0, para abrir espaço a fim de inserir um novo
elemento. Se a queue fosse vista como um círculo não haveria necessidade de executar esse
movimento. Em uma queue circular, os elementos são considerados como se estivessem organizados
dentro de um círculo. O front aponta para o elemento atual no início da queue, enquanto o rear
aponta para o elemento à direita do último, no momento (sentido horário). A figura a seguir mostra
uma queue circular:

Figura 3: Queue Circular

Para iniciar uma queue circular:


front = 0;
rear = 0;

Para inserir um item, por exemplo, x:


Q[rear] = x;
rear = (rear + 1) mod n;

Para apagar:
x = Q[front];
front = (front + 1) mod n;

Utilizamos a função MOD (módulo) para realizar um teste no início e final da queue. Quando
inserções e remoções são feitas, a queue é movimentada em sentido horário. Se o início alcançar o
final, isto é, se front é igual a rear, então teremos uma queue vazia. Se o final alcançar o início, uma
condição também indicada por front igual a rear, então todos os elementos estão em uso e teremos
uma queue cheia.
Para evitarmos ter a mesma relação significando duas condições diferentes, não permitiremos que o
final alcance o início considerando que a queue está cheia quando existir apenas uma célula livre.
Portanto a queue cheia é indicada por:
front == (rear + 1) mod n

Estruturas de Dados 9
JEDITM

Este é um exemplo completo para uma implementação de uma queue circular:


public class CircularQueue implements Queue{
Object Q[];
int n = 20; // tamanho da queue, por padrão 20
int front = 0;
int rear = 0;

// Cria a circular queue de tamanho padrão


public CircularQueue(){
Q = new Object[n];
}

// Cria a circular queue de tamanho n


public CircularQueue(int size){
n = size;
Q = new Object[n];
}

public void enqueue(Object item) throws QueueException {


if (front == (rear % n) + 1)
throw new QueueException("Inserting into a full queue.");
Q[rear] = item;
rear = (rear % n) + 1;
}

public Object dequeue() throws QueueException {


Object x;
if (front == rear)
throw new QueueException("Deleting from an empty queue.");
x = Q[front];
front = (front % n) + 1;
return x;
}

// Método principal para testar a queue


public static void main(String args[]) {
CircularQueue q = new CircularQueue(5);
for (int i=1; i < 7; i++) {
q.enqueue(new Integer(i));
System.out.println(i +" inserted");
System.out.println(q.dequeue() + " retrieved");
System.out.println("front:"+q.front+" rear:"+q.rear);
}
}
}

Estruturas de Dados 10
JEDITM

4. Aplicação: Classificação Topológica


A Classificação Topológica é um problema característico de redes ativas. Utiliza ambas as técnicas de
alocação, seqüencial e encadeada, na qual a queue encadeada está inserida em um array seqüencial.

É um processo aplicado em elementos parcialmente ordenados. A entrada é um conjunto de pares de


condicionamento parcial e a saída é a lista de elementos, onde não existe nenhum elemento cujo
antecessor já não esteja na saída.

4.1. Ordenação Parcial

É definida como uma relação entre os elementos de um conjunto S, caracterizada pelo símbolo ≼
(que é lido como 'precede ou igual a'). A seguir estão as propriedades da condição parcial ≼:

• Transitividade: se x ≼ y e y ≼ z, então x ≼ z
• Anti-simetria: se x ≼ y e y ≼ x, então x = y
• Reflexividade: x ≼ x

Resultado. Se x ≼ y e x ≠ y então x ≺ y.

Propriedades equivalentes são:

• Transitividade: se x ≺ y e y ≺ z, então x ≺ z
• Simetria: se x ≺ y então y ≺ x
• Não-Reflexividade: x ≺ x

Um exemplo familiar de condição parcial na matemática é a relação u ⊆ v entre os conjuntos u e v. A


seguir temos outro exemplo onde a lista de condição parcial é mostrada à esquerda; o gráfico que
ilustra a condição parcial é mostrado no centro, e a saída esperada é mostrada à direita.
0,1
0,3
0,5
1,2
1,5
2,4
3,2
Saída:
3,4
06371254
5,4
6,5
6,7
7,1
7,5
Exemplo de Classificação Topológica

4.2. Algoritmo

Juntamente com a execução do algoritmo, devemos considerar alguns componentes discutidos no


capítulo 1- input (entrada), output (saída), e o algoritmo apropriado.

Estruturas de Dados 11
JEDITM

• Input (Entrada): um conjunto de pares com a forma (i, j) para cada relação i ≼ j que poderia
representar o ordenamento parcial dos elementos. Os pares de entrada podem estar em
qualquer ordem;
• Output (Saída): O algoritmo se torna uma seqüência linear de itens, de modo que nenhum
item aparece na seqüência antes de seu antecessor direto;
• Algoritmo apropriado: Uma condição da Classificação Topológica é não visualizar/imprimir os
itens cujos seus antecessores ainda não tenham executado esta tarefa. Para fazer isso, é
necessário manter os números dos antecessores em cada item. Um array pode ser usado
para esse propósito. Chamaremos esse array de COUNT (contador). Quando um item é
enviado à saída o count de cada sucessor do item é decrementado. Se o count de um item
é zero, ou se torna zero como resultado de todos os seus antecessores terem sido enviados à
saída, esse seria o tempo em que os itens estão prontos para a visualização/impressão. Para
manter-se a par dos sucessores, uma lista de ligações, chamada SUC, com a estrutura
(INFO, LINK), será usada, onde INFO contém o rótulo do sucessor direto enquanto LINK
aponta para o próximo sucessor, se existir.

Segue a definição de node:

class Node {
int info;
Node link;
}

O COUNT (array contador) é inicialmente definido como 0 e o array SUC como null para cada
entrada do par (i, j),

COUNT[j]++;

e um newNODE (nó) é adicionado à SUC(i):

Node newNode = new Node();


newNode.info = j;
newNode.link = SUC[i];
SUC[i] = newNode;

Figura 4: Adicionando um newNODE

Estruturas de Dados 12
JEDITM

Segue exemplo:

Figura 5: Representação de um exemplo de Classificação Topológica

Para gerar a saída, que é uma classificação linear de objetos de modo que nenhum objeto apareça
na seqüência antes de seu antecessor direto, procedemos da seguinte maneira:

1. Procuramos por um item, digamos k, com o contador dos antecessores diretos igual a zero,
ex., COUNT[k]==0. Coloque k na saída;
2. Buscamos a lista de sucessores diretos de k, e decrementamos 1 do contador de cada
sucessor;
3. Repetir passos 1 e 2 até que todos os itens estejam na saída.

Para evitar percorrer todo o array COUNT repetidamente enquanto procuramos por objetos com o
contador igual a zero, iremos constituir todos os objetos em uma queue encadeada. Inicialmente, a
queue irá consistir de itens sem antecessor direto (sempre haverá ao menos um item).
Subseqüentemente, cada vez que o contador de antecessores diretos de um item cair para zero, este
será inserido na queue, pronto para a saída. Desde que para cada item, digamos j, com seu
contador igual a 0, podemos reutilizar COUNT [j] como um campo de ligação de modo que:

COUNT [j] = k se k é o próximo item na queue


= 0 se j for o último elemento na queue

Conseqüentemente temos uma embedded linked queue em um array seqüencial.

Se a entrada para o algoritmo estiver correta, isto é, se as relações de entrada satisfazem a condição
parcial, o algoritmo termina quando a fila estiver vazia e com todos os objetos colocados na saída.
Se, por outro lado, a condição parcial é violada de modo que existam objetos que constituem um ou
mais laços de repetição (por exemplo, 1≺2; 2≺3; 3≺4; 4≺1), ainda assim este algoritmo termina,
mas objetos incluídos nos laços de repetição não serão colocados na saída.

Estruturas de Dados 13
JEDITM

Essa abordagem da Classificação Topológica usa tanto técnicas seqüenciais quanto técnicas
encadeamento de alocamento, e o uso de uma queue encadeada inserida em um array seqüencial.

Estruturas de Dados 14
JEDITM

5. Exercícios
a) Ordenação Topológica. Dado o ordenamento parcial de sete elementos, como eles podem ser
arranjados de forma que nenhum elemento apareça na seqüência antes de seu antecessor direto?

1. (1,3), (2,4), (1,4), (3,5), (3,6), (3,7), (4,5), (4,7), (5,7), (6,7), (0,0)
2. (1,2), (2,3), (3,6), (4,5), (4,7), ( 5,6), (5,7), (6,2), (0,0)

5.1. Exercícios para Programar

1. Crie uma execução de múltiplas queue que coexista num array simples. Use o algoritmo de
Garwick's para realocação de memória durante o overflow.

2. Escreva uma classe que execute o algoritmo de ordenação topológica.

3. Relação de matérias (escolares) utilizando Ordenação Topológica.

Execute uma relação de matérias utilizando o algoritmo de ordenação topológica. A classe deve
solicitar um arquivo que contenha o conjunto de matérias e a ordenação parcial destas. As matérias
devem estar na seguinte forma, no arquivo, (número, matéria) onde número é um inteiro
atribuído a matéria e matéria é o identificador do curso [ex.: (1, CS 1)]. Cada par (número,
matéria) deve estar em linhas separadas no arquivo. Para terminar a inclusão deve-se usar o par
(0, 0). Os pré-requisitos das matérias devem ser obtidos a partir do mesmo arquivo. A definição de
um pré-requisito deve estar na forma (i, j), uma linha por par, onde i é um número atribuído ao
pré-requisito da matéria de número j. O último pré-requisito deve ser o par (0, 0).

A saída deve ser também em um arquivo, e seu nome deve ser solicitado ao usuário. A saída deve
ser em uma tabela contendo o número do semestre (um número auto-incrementável de 1 a n) junto
com as matérias do mesmo.

Para simplificar, consideraremos apenas matérias semestrais.

Exemplo de arquivo de entrada Exemplo de arquivo de saída


(1, CS 1)
(2, CS 2) Sem 1 Sem 2
. CS 1 CS 12
.
.
 Início da definição
(0, 0) Sem 2 Sem 4
de ordem parcial.
(1, 2) CS 2 CS 135
(2, 3) CS 3 CS 140
. CS 150
.
.
(0, 0)

Estruturas de Dados 15
Módulo 3
Estruturas de Dados

Lição 4
Árvores Binárias

Versão 1.0 - Mai/2007


JEDITM

1. Objetivos
Uma árvore binária é um tipo de dado abstrato que é estruturalmente hierárquico. É uma coleção de
nodes que pode estar vazia ou pode consistir de uma raiz e duas árvores binárias distintas chamadas
de sub-árvores à esquerda e à direita. É semelhante a uma árvore no sentido de que existe o
conceito de uma raiz, galhos e folhas. Entretanto, diferem na orientação já que a raiz de uma árvore
binária está no topo como primeiro elemento, ao contrário do que ocorre com uma árvore real na
qual a raiz localiza-se no final da árvore como último elemento.

Árvores Binárias são mais utilizadas em pesquisa, classificação, localização eficiente em cadeias de
caracteres, listas de prioridades, tabelas de decisão e tabelas de símbolos.

Ao final desta lição, o estudante será capaz de:

• Explicar os conceitos básicos e definições relacionadas a árvores binárias


• Identificar as propriedades de uma árvore binária
• Enumerar os diferentes tipos de árvores binárias
• Discutir como as árvores binárias são representadas na memória dos computadores
• Percorrer árvores binárias usando três algoritmos de varredura: pré-ordem, em ordem, pós-
ordem
• Discutir aplicações da varredura em árvores binárias
• Usar heaps e o algoritmo heapsort para classificar um conjunto de elementos

Estruturas de Dados 4
JEDITM

2. Definições e Conceitos Relacionados


Uma árvore binária T tem um node especial, chamado r, que é o node raiz. Cada node v de T, que é
diferente de r, possui um node pai p. O node v é chamado de child (ou filho) do node p. Um node
pode ter no mínimo zero (0) e no máximo dois (2) children que são classificados como child
esquerdo ou child direito. As sub-árvores de T cuja raiz é v são consideradas filhas de v. É uma
sub-árvore à esquerda se for o child esquerdo do node v ou uma sub-árvore à direita se estiver
ligada ao child direito do node v. O grau de um node é o número de sub-árvores não-nulas deste
node. Se um node tiver grau zero, ele é classificado como folha ou um node terminal.

Figura 1: Uma Árvore Binária

O nível de um node se refere à distância do node à raiz. Portanto, a raiz da árvore tem nível 0, as
suas sub-árvores têm nível 1 e assim por diante. A altura ou profundidade de uma árvore é o nível
dos seus nodes mais inferiores, que também é o tamanho do maior caminho da raiz para qualquer
folha. Por exemplo, a árvore binária a seguir possui altura 3:

Figura 2: Níveis de uma Árvore Binária

Um node é externo, se não tiver children, caso contrário ele é interno. Se dois nodes tiverem o
mesmo pai, são irmãos. O ancestral de um node é ele próprio ou um ancestral de seu pai.
Inversamente, o node u é um descendente do node v se v é um ancestral do node u.
Uma árvore binária pode estar vazia. Se a árvore binária tiver zero ou dois children, é classificada

Estruturas de Dados 5
JEDITM

como uma árvore binária equilibrada ou balanceada. Deste modo, cada árvore binária
equilibrada possui nodes internos com dois children ou nenhum.
A figura abaixo mostra os diferentes tipos de árvores binárias: (a) mostra uma árvore binária vazia;
(b) mostra uma árvore binária com apenas um node, a raiz; (c) e (d) mostram árvores sem children
à direita e à esquerda respectivamente; (e) mostra uma árvore binária inclinada à esquerda
enquanto (f) mostra uma árvore binária completa.

Figura 3: Diferentes Tipos de Árvore Binária

2.1. Propriedades de uma Árvore Binária

Para uma árvore binária (equilibrada ou balanceada) de profundidade k,


• O número máximo de nodes no nível i é 2i , i ≥ 0.
• O número de nodes é no mínimo 2k + 1 e no máximo 2k+1 – 1.
• O número de nodes externos é no mínimo h+1 e no máximo 2k.
• O número de nodes internos é no mínimo h e no máximo 2k – 1.
• Se no é o número de nodes folhas e n2 é o número de nodes de grau 2 numa árvore binária,
então no = n2 + 1.

2.2. Tipos de Árvores Binárias

Uma árvore binária pode ser classificada como degenerada, estritamente binária, cheia ou completa.
Uma árvore binária degenerada à direita (esquerda) é uma árvore em que os nodes não têm
sub-árvores à esquerda (direita). Dado um número de nodes, uma árvore binária degenerada à
esquerda ou à direita tem profundidade máxima.

Estruturas de Dados 6
JEDITM

Figura 4: Árvores Binárias Degeneradas à Esquerda e à Direita

Uma árvore estritamente binária é uma árvore em que todos os nodes têm duas sub-árvores ou
nenhuma sub-árvore.

Figura 5: Árvore Estritamente Binária

Uma árvore binária cheia é uma árvore estritamente binária em que todos os nodes terminais
estão no nível mais baixo. Dada uma profundidade, esta árvore tem o número máximo de nodes.

Figura 6: Árvore Binária Cheia

Uma árvore binária completa é uma árvore que resulta quando zero ou mais nodes são deletados
de uma árvore binária cheia em ordem reversa de nível, isto é, da direita para a esquerda e de baixo
para cima.

Figura 7: Árvore Binária Completa

Estruturas de Dados 7
JEDITM

3. Representação das Árvores Binárias


A maneira mais ‘natural’ para se representar uma árvore binária na memória do computador é
utilizando a representação por links. A seguinte figura mostra a estrutura do node de uma árvore
binária utilizando esta representação:

Figura 8: Nodes de Árvores Binárias

A seguinte classe Java implementa a representação acima:


public class BTNode {
private Object info;
private BTNode left, right;

public BTNode(Object info) {


this.setInfo(info);
}
public BTNode(Object info, BTNode left, BTNode right) {
this.setInfo(info);
this.setLeft(left);
this.setRight(right);
}
public void setLeft(BTNode left) {
this.left = left;
}
public BTNode getLeft() {
return left;
}
public void setRight(BTNode right) {
this.right = right;
}
public BTNode getRight() {
return right;
}
public Object getInfo() {
return info;
}
public void setInfo(Object info) {
this.info = info;
}
}

O exemplo abaixo mostra a representação com links de uma árvore binária:

Estruturas de Dados 8
JEDITM

Figura 9: Representação com Links de uma Árvore Binária

Em Java, a seguinte classe define uma árvore binária:


public class BinaryTree {
private BTNode root;

public BinaryTree(BTNode node) {


this.root = node;
}
public BinaryTree(BTNode node, BTNode left, BTNode right) {
this.root = node;
this.root.setLeft(left);
this.root.setRight(right);
}
}

Estruturas de Dados 9
JEDITM

4. Percorrendo Árvores Binárias


Pesquisas em árvores binárias geralmente envolvem uma busca ou varredura. Busca é um
procedimento que percorre os nodes de uma árvore binária de maneira linear de modo que cada
node é visitado apenas uma única vez. Visitar pode ser definido como a realização de computações
locais no node.
Há três maneiras de se percorrer uma árvore: pré-ordem, em ordem e pós-ordem. Os prefixos (pré,
em e pós) referem-se à ordem em que a raiz de cada sub-árvore é visitada.

4.1. Busca Pré-Ordem

Na busca pré-ordem de uma árvore binária, a raiz é primeiro node a ser visitado. Depois os children
são percorridos recursivamente da mesma maneira. Este algoritmo é útil nas aplicações que
requerem a listagem de elementos onde os pais sempre devem aparecer antes de seus children.

Figura 10: Percorrimento Pré-ordem

Método:
Se a árvore binária estiver vazia, não faça nada (busca finalizada).
Caso contrário:
Visite a raiz.
Percorra a sub-árvore esquerda em pré-ordem.
Percorra a sub-árvore direita em pré-ordem.
Em Java, adicione o seguinte método à classe BinaryTree:
// Listagem pré-ordem dos elementos da árvore
public void preorder() {
if (root != null) {
System.out.println(root.getInfo().toString());
new BinaryTree(root.getLeft()).preorder();
new BinaryTree(root.getRight()).preorder();
}
}

4.2. Busca em Ordem

A busca em ordem de uma árvore binária pode ser definida, informalmente, como a pesquisa “da
esquerda para a direita” de uma árvore binária. Isto é, a sub-árvore da esquerda é percorrida
recursivamente em ordem, seguida por uma visita ao seu node pai, e finalizando com a pesquisa
recursiva também em ordem da sub-árvore direita.

Estruturas de Dados 10
JEDITM

Figura 11: Percorrimento em Ordem

Método:
Se a árvore binária estiver vazia, não faça nada (busca finalizada).
Caso contrário:
Percorra a sub-árvore esquerda em ordem.
Visite a raiz.
Percorra a sub-árvore direita em ordem.
Em Java, adicione o seguinte método à classe BinaryTree:
// Listagem em ordem dos elementos da árvore
public void inorder() {
if (root != null) {
new BinaryTree(root.getLeft()).inorder();
System.out.println(root.getInfo().toString());
new BinaryTree(root.getRight()).inorder();
}
}

4.3. Busca Pós-ordem

A busca pós-ordem é o contrário da pré-ordem, isto é, recursivamente percorrem-se primeiro os


children e depois os pais.

Figura 12: Percorrimento Pós-ordem

Método:
Se a árvore binária estiver vazia, não faça nada (percorrimento finalizado).
Caso contrário:

Estruturas de Dados 11
JEDITM

Percorra a sub-árvore esquerda em pós-ordem.


Percorra a sub-árvore direita em pós-ordem.
Visite a raiz.
Em Java, adicione o seguinte método à classe BinaryTree:

// Listagem pós-ordem dos elementos da árvore


public void postorder() {
if (root != null) {
new BinaryTree(root.getLeft()).postorder();
new BinaryTree(root.getRight()).postorder();
System.out.println(root.getInfo().toString());
}
}

A seguir temos alguns exemplos:

Figura 13: Exemplo 1

Figura 14: Exemplo 2

Estruturas de Dados 12
JEDITM

5. Aplicações de Busca em Árvores Binárias


A busca em árvores binárias tem várias aplicações. Nesta seção, duas aplicações serão abordadas: a
duplicação de uma árvore binária e a verificação da equivalência entre duas árvores binárias.

5.1. Duplicando uma Árvore Binária

Existem momentos em que a duplicação de uma árvore binária é necessária para um


processamento. Para isso, pode ser utilizado o seguinte algoritmo para duplicar uma árvore binária
existente:
1. Percorra a sub-árvore esquerda do node α em pós-ordem e faça uma cópia dela
2. Percorra a sub-árvore da direita do node α em pós-ordem e faça uma cópia dela
3. Faça uma cópia do node α e anexe as cópias de suas sub-árvores da esquerda e da direita

O seguinte método da classe BinaryTree implementa este algoritmo:


public BTNode copy() {
BTNode newRoot;
BTNode newLeft;
BTNode newRight;
if (root != null) {
newLeft = new BinaryTree(root.getLeft()).copy();
newRight = new BinaryTree(root.getRight()).copy();
newRoot = new BTNode(root.getInfo(), newLeft, newRight);
return newRoot;
}
return null;
}

5.2. Equivalência entre Duas Árvores Binárias

Duas árvores binárias são equivalentes se uma é cópia exata da outra. O seguinte algoritmo verifica
a equivalência entre duas árvores binárias:
1. Verifique se o node α e o node β contêm os mesmos dados
2. Percorra as sub-árvores da esquerda, nodes α e β em pré-ordem e verifique se são equivalentes
3. Percorra as sub-árvores da direita, nodes α e β em pré-ordem e verifique se são equivalentes

O seguinte método da classe BinaryTree implementa este algoritmo:


public boolean equivalent(BinaryTree t2) {
boolean answer = false;
if ((root == null) && (t2.root == null))
answer = true;
else {
answer = (root.getInfo().equals(t2.root.getInfo()));
if (answer) answer = new BinaryTree(root.getLeft()).equivalent(
new BinaryTree(t2.root.getLeft()));
if (answer) answer = new BinaryTree(root.getRight()).equivalent(
new BinaryTree(t2.root.getRight()));
}
return answer;
}

É possível testar esta classe implementando o seguinte método main:

Estruturas de Dados 13
JEDITM

public static void main(String args[]) {


BinaryTree bt1 = new BinaryTree(
new BTNode("Root1"), new BTNode("Left1"), new BTNode("Right1"));
BinaryTree bt2 = new BinaryTree(
new BTNode("Root2"), new BTNode("Left2"), new BTNode("Right2"));
BinaryTree bt3 = new BinaryTree(
new BTNode("Root1"), new BTNode("Left1"), new BTNode("Right1"));

System.out.println("Preorder(bt1): ");
bt1.preorder();
System.out.println("Inorder(bt1): ");
bt1.inorder();
System.out.println("Postorder(bt1): ");
bt1.postorder();

BinaryTree bt4 = new BinaryTree(bt1.copy());


System.out.println("Preorder (bt4): ");
bt4.preorder();

System.out.println(bt1.equivalent(bt2));
System.out.println(bt1.equivalent(bt3));
}

E na execução desta classe, como resultado teremos:


Preorder(bt1):
Root1
Left1
Right1
Inorder(bt1):
Left1
Root1
Right1
Postorder(bt1):
Left1
Right1
Root1
Preorder (bt4):
Root1
Left1
Right1
false
true

Estruturas de Dados 14
JEDITM

6. Aplicação de Árvore Binária: Heaps e o Algoritmo


Heapsort
Um heap é definido como uma árvore binária completa que tem elementos armazenados em seus
nodes e satisfaz a propriedade ordem-heap. Uma árvore binária completa, como definida na lição
anterior, resulta quando zero ou mais nodes são excluídos de uma árvore binária completa em
ordem inversa de nível. Conseqüentemente, suas folhas ficam no máximo em dois níveis adjacentes
e as folhas do nível mais baixo ficam na posição mais à esquerda da árvore binária completa.
A propriedade ordem-heap define que para todo node u exceto a raiz, a chave armazenada em u
é menor ou igual à chave armazenada em seu respectivo node pai. Então, a raiz sempre contém o
valor máximo.
Nesta aplicação, os elementos armazenados em um heap satisfazem a ordem total. Uma ordem
total é uma relação entre os elementos de um conjunto de objetos, nomeado S, que satisfazem as
propriedades de quaisquer objetos x, y e z em S:
• Transitividade: se x < y e y < z então x < z.
• Tricotomia: para quaisquer dois objetos x e y em S, exatamente uma destas relações é
verdadeira: x > y, x = y ou x < y.

Figura 15: Duas representações dos elementos a l g o r i t h m s

6.1. Shift-Up

Uma árvore binária completa pode ser convertida em uma stack por meio da aplicação de um
processo chamado shift-up. Neste processo, chaves maiores “sobem” a árvore para satisfazer a
propriedade de ordenamento de stacks. Este é um processo que se dá de baixo para cima e da
direita para a esquerda, durante o qual as sub-árvores menores de uma árvore binária completa são
convertidas em stacks, seguido pela conversão das sub-árvores que as contêm, e assim por diante,
até que toda a árvore binária seja convertida em uma stack.

Estruturas de Dados 15
JEDITM

Observe que quando uma sub-árvore com raiz em algum node, por exemplo α, é convertida em uma
stack, as sub-árvores da esquerda e da direita, vinculadas à mesma, já são stacks. Este tipo de sub-
árvore é denominada quase-stack. Quando uma quase-stack é convertida em uma stack, pode
ocorrer de uma de suas sub-árvores deixar de ser uma stack (ex. Pode se tornar uma quase-stack).
A mesma, porém, pode ser convertida em uma stack e o processo continua com sub-árvores
pequenas e outras menores ainda perdendo e reconquistando a propriedade de stack, com chaves
MAIORES migradas para o topo.
Para testarmos os exemplos, criamos uma nova classe denominada SequentialHeap e a iniciamos
com o seguinte código:
public class SequentialHeap {
int key[];

public SequentialHeap(){
key = new int[10];
}
public SequentialHeap(int size){
key = new int[size];
}
public SequentialHeap(int k[]){
key = k;
}
}

6.2. Representação Seqüencial de uma Árvore Binária Completa

Uma árvore binária completa pode ser representada seqüencialmente em um vetor unidimensional
de tamanho n em ordem de nível. Se os nodes de uma árvore são numerados, como ilustrado
abaixo,

Figura 16: Uma árvore binária completa

pode então ser representada seqüencialmente por meio de um vetor de nome CHAVE, conforme
demonstrado abaixo:

Figura 17: Representação Seqüencial de uma Árvore Binária Completa

Estruturas de Dados 16
JEDITM

A representação seqüencial de uma árvore binária completa possibilita a localização dos children (se
houver), do pai (se existir) e do pai de um node em tempo constante, por meio da utilização da
seguinte fórmula:
• se 2i ≤ n, o child à esquerda do node i é 2i; senão, o node i não possui nenhum child à
esquerda
• se 2i + 1 ≤ n, o child à direita do node i é 2i + 1; senão, o node i não possui nenhum child
à direita
• se 1 < i ≤ n, o pai do node i é  (i)/2 

O método abaixo, inserido na classe SequentialHeap, implementa o processo de shift-up em uma


árvore binária completa representada seqüencialmente:

// Converte uma árvore binária com n nodes e raiz em uma stack


private void shiftUp (int i, int n) {
int k = key[i]; // mantém a chave na raiz da stack
int child = 2 * i; // child à esquerda
while (child <= n) {
// se child à direita é maior, aponta-o para o da direita
if (child < n && key[child+1]> key[child])
child++ ;
// se a propriedade de stack não for satisfeita
if (key[child] > k) {
key[i] = key[child] ; // Move child para cima
i = child;
child = 2 * i; //Considera child da esquerda novamente
} else
break;
}
key[i] = k ; // aqui começa a raiz
}

Para converter uma árvore binária em uma quase-stack:

// Converte chave em quase-stack


for (int i=n/2; i>1; i--){
// o primeiro node que tem children é n/2
shiftUp(i, n);
}

6.3. O Algoritmo Heapsort

Heapsort é um algoritmo elegante de ordenação que foi desenvolvido em 1964 por R. W. Floyd e J.
W. J. Williams. O heap é a base deste algoritmo de ordenação elegante:
1. Atribua as chaves a serem ordenadas aos nodes de uma árvore binária completa
2. Converta esta árvore binária em um heap aplicando o método shift-up aos seus nodes em
ordem reversa de nível
3. Repita o seguinte até que o heap esteja vazio:
(a) Remova a chave na raiz do heap (o menor valor no heap) e coloque-o na saída
(b) Extraía do heap o node-folha mais à direita no nível mais baixo, obtenha sua chave
e armazene-a na raiz do heap
(c) Aplique shift-up à raiz para converter a árvore binária em um heap novamente

O método a seguir, inserido na classe SequentialHeap, implementa o algoritmo heapsort em Java:

Estruturas de Dados 17
JEDITM

public void sort() {


int n = key.length-1;
// converte a chave para almost-heap
for (int i=n/2; i>1; i--) {
// primeiro node como child é n/2
shiftUp(i, n);
}
// Muda o corrente tamanho da chave[1] com a chave[i]
for (int i=n; i>1; i--) {
shiftUp(1, i);
int temp = key[i];
key[i] = key[1];
key[1] = temp;
}
}

O exemplo seguinte mostra a execução do heapSort com as chaves de entrada:


algorithms

Estruturas de Dados 18
JEDITM

Estruturas de Dados 19
JEDITM

Estruturas de Dados 20
JEDITM

Estruturas de Dados 21
JEDITM

Estruturas de Dados 22
JEDITM

Figura 18: Exemplo de Heapsort

Poderíamos testar esta classe com a implementação do seguinte método principal:


public static void main(String args[]){
int keys[] = {'a','l','g','o','r','i','t','h','m','s'};
SequentialHeap heap = new SequentialHeap(keys);
System.out.print("Before sorting: ");
for (int i=0; i < keys.length;i++)
System.out.print((char) keys[i] + " ");
System.out.println();
heap.sort();
System.out.print("Before sorting: ");
for (int i=0; i < keys.length;i++)
System.out.print((char) keys[i] + " ");
System.out.println();
}

E iremos obter o seguinte resultado:


Before sorting: a l g o r i t h m s
Before sorting: a g h i l m o r s t

Estruturas de Dados 23
JEDITM

7. Exercícios
1. Varredura. Percorra a seguinte árvore em pré-ordem, em ordem e pós-ordem.

a) b)

c)

2. Heapsort. Organize os seguintes elementos em ordem crescente. Mostre o estado da árvore em


cada passo.

a) C G A H F E D J B I
b) 1 6 3 4 9 7 5 8 2 12 10 14

7.1. Exercícios para Programar

1. Heaps podem ser usadas para avaliação de expressões. Um método alternativo é a utilização de
árvores binárias. Os usuários vêem as expressões na forma pré-fixada, mas a forma pós-fixada é
a mais adequada para que computadores avaliem as expressões. Neste exercício de programação,
crie um algoritmo que faça a conversão da expressão pré-fixada para sua forma pós-fixada,
utilizando árvore binária. Cinco operações binárias são apresentadas e estão listadas aqui de
acordo com a precedência:

Estruturas de Dados 24
JEDITM

Operação Descrição
^ Exponenciação (maior precedência)
*/ Multiplicação e Divisão
+- Adição e Subtração

Na sua implementação, considere a precedência e prioridade dos operadores.

2. Modifique o algoritmo heapsort para retornar as chaves em ordem decrescente, ao invés de


crescente, deslocando para cima as chaves menores ao invés das chaves maiores.

Estruturas de Dados 25
Módulo 3
Estruturas de Dados

Lição 5
Árvores

Versão 1.0 - Mai/2007


JEDITM

1. Objetivos
Árvores podem ser ordenadas, orientadas ou livres. A alocação de ponteiros pode ser usada para
representar árvores e estas podem ser representadas seqüencialmente usando a representação
aritmética da árvore. Zero ou mais árvores separadas fazem juntas uma floresta e esta ordenada
pode ser convertida em uma árvore binária única e vice-versa usando correspondência natural.
Ao final desta lição, o estudante será capaz de:
• Discutir os conceitos básicos e definições de árvores
• Identificar os tipos de árvores: ordenadas, orientadas e árvores livres
• Usar a representação de árvores com ponteiros
• Explanar os conceitos básicos e definições sobre florestas
• Converter uma floresta na sua representação de árvore binária e vice-versa usando a
correspondência natural
• Percorrer uma floresta usando o processo pré-ordem, pós-ordem, por nível e por família
• Criar representações de árvores usando a alocação seqüencial
• Utilizar a representação aritmética de árvores
• Utilizar árvores em uma aplicação: O problema da equivalência

Estruturas de Dados 4
JEDITM

2. Definições e Conceitos Relacionados


2.1. Árvores Ordenadas

Uma árvore ordenada é um conjunto finito, chamado T, de um ou mais nodes de modo que há um
node especialmente chamado de raiz, e os demais nodes raiz são particionados em n ≥ 0 conjuntos
disjuntos (sem elementos em comum) T1, T2, ... , Tn, onde cada um desses conjuntos é por sua vez
uma árvore ordenada. Em uma árvore ordenada, a ordem de cada node na árvore é importante.

Figura 1: Uma árvore ordenada

O grau de uma árvore é definido como o grau do(s) node(s) com o maior grau. Por essa razão, a
árvore acima tem o grau 3.

2.2. Árvore Orientada

Uma árvore orientada é uma árvore em que a ordem de cada subárvore de cada node da árvore é
secundário.

Figura 2: Uma Árvore Orientada

No exemplo acima, as duas árvores são duas árvores orientadas diferentes, mas são a mesma
árvore.

Estruturas de Dados 5
JEDITM

2.3. Árvore Livre

Uma árvore livre não tem um node designado como raiz e a orientação de um node para outro é sem
importância.

Figura 3: Uma Árvore Livre

2.4. Progressão de Árvores

Quando em uma árvore livre é designado um node raiz, ela torna-se uma árvore orientada. Quando
a ordem dos nodes é definida em uma árvore orientada, ela torna-se uma árvore ordenada. O
seguinte exemplo demonstra essa progressão.

Figura 4: Progressão de Árvores

Estruturas de Dados 6
JEDITM

3. Representação Ligada de Árvores


Alocação ligada pode ser usada para representar árvores. A figura abaixo mostra a estrutura dos
nodes usados nessa representação.

Figura 5: Estrutura de node de uma Árvore

Na estrutura de nodes, k é o grau da árvore. SON 1, SON2, ..., SONk são ponteiros para os possíveis k
filhos de um node.
Aqui temos algumas propriedades de uma árvore com n nodes e com grau k:
• O número de campos ponteiros é igual a n*k
• O número de ponteiros não vazios é igual a n-1 (Número de ramos)
• O número de ponteiros vazios é igual a n*k – (n-1), ou seja, n(k-1) + 1

A representação ligada é a maneira mais natural de representar uma árvore. Porém, devido às
propriedades acima, uma árvore com grau 3 terá 67% de ponteiros nulos, enquanto que em uma
árvore com grau 10, o espaço vazio será de 90%. Uma perda considerável de espaço é introduzida
por essa abordagem. Caso a utilização de espaço seja um assunto importante, podemos optar por
usar a estrutura alternativa.

Figura 6: Node de Árvore Binária

Com essa estrutura, LEFT aponta para o filho à esquerda do node enquanto RIGHT aponta para o
próximo irmão mais novo.

Estruturas de Dados 7
JEDITM

4. Florestas
Quando zero ou mais árvores disjuntas são associadas, são conhecidas como florestas. A seguir um
exemplo de floresta:

Figura 7: Floresta F

Se as árvores que compreendem a floresta são árvores ordenadas e se a sua ordem na floresta é
essencial, ela é conhecida como floresta ordenada.

4.1. Correspondência Natural: Árvore Binária, representação de Floresta

Uma floresta ordenada, digamos F, pode ser convertida em uma árvore binária única, digamos B(F),
e vice-versa, usando um processo bem definido conhecido como correspondência natural.
Formalmente:
Seja F = (T1, T2, ..., Tn) uma floresta ordenada de árvores ordenadas. A árvore binária
B(F) correspondente a F é obtida da seguinte maneira:

a) Se n = 0, então B(F) é vazia.

b) Se n > 0, então a raiz de B(F) é a raiz de T 1; a subárvore esquerda de B(F) é B(T11,


T12, ... T1m), onde T11, T12, ... T1m são subárvores da raiz de T1; e a subárvore direita
de B(F) é B(T2, T3, ..., Tn).

A correspondência natural pode ser implementada usando uma abordagem não-recursiva:


1. Ligar os filhos de cada família da esquerda para direita. (Nota: as raízes da árvore na
floresta são irmãs, filhas de um pai desconhecido.)

2. Remover ligações de um pai para todos os seus filhos exceto o filho mais velho (ou
mais à esquerda).

3. Inclinar a figura resultante em 45 graus.

O exemplo a seguir ilustra a transformação de uma floresta em sua árvore binária equivalente:

Estruturas de Dados 8
JEDITM

Figura 8: Exemplo de Correspondência Natural

Em correspondência natural, a raiz é trocada pela raiz da primeira árvore, a subárvore esquerda é
trocada pelas subárvores da primeira árvore e a subárvore direita é trocada pelas árvores
remanescentes.

4.2. Atravessando a Floresta

Como em árvores binárias, florestas podem ser atravessadas (percorridas). Contudo, uma vez que o
conceito de node intermediário não esteja definido, uma floresta somente pode ser atravessada em
pré-ordem e pós-ordem.
Se a floresta estiver vazia, o atravessar é considerado executado; senão:

Estruturas de Dados 9
JEDITM

• Atravessar em Pré-Ordem
• Visitar a raiz da primeira árvore
• Atravessar as subárvores da primeira árvore em pré-ordem
• Atravessar as árvores restantes em pré-ordem
• Atravessar em Pós-Ordem
• Atravessar as subárvores da primeira árvore em pós-ordem
• Visitar a raiz da primeira árvore
• Atravessar as árvores remanescentes em pós-ordem

Figura 9: Floresta F

Floresta pré-ordem : A B C D E F G H I K J L M N
Floresta pós-ordem : C D E F B A H K I L J G N M

A árvore binária equivalente da floresta resultará na seguinte listagem para pré-ordem em-ordem e
pós-ordem
B(F) pré-ordem :ABCDEFGHIKJLMN
B(F) em-ordem :CDEFBAHKILJGNM
B(F) pós-ordem :FEDCBKLJIHNMGA

Observe que a floresta pós-ordem produz o mesmo resultado que na floresta em-ordem B(F). Não é
coincidência.

Estruturas de Dados 10
JEDITM

4.3. Representação Seqüencial de Florestas

Florestas podem ser implementadas usando representação seqüencial. Considere a floresta F e sua
árvore binária correspondente. Os exemplos a seguir mostram a árvore usando representações
seqüenciais de pré-ordem, família-ordem e nível-ordem.

4.3.1. Representação Seqüencial em Pré-ordem

Nesta representação seqüencial, os elementos são listados nas suas seqüências pré-ordem e dois
arrays adicionais são mantidos – RLINK e LTAG. RLINK é um ponteiro de um irmão mais velho para
um irmão mais novo na floresta, ou de um pai para o seu filho da direita na sua representação de
árvore binária. LTAG indica se um node é terminal ( node folha) e o símbolo ')' é usado como
indicativo.

Figura 10: Representação seqüencial pré-ordem da floresta F

Figura 11: Representação interna real

RLINK contém o node apontado pelo node atual e LTAG tem um valor de 1 para cada ')' na
representação.
Uma vez que o node final sempre precede imediatamente um node apontado por uma seta, exceto o
último node na seqüência, o uso do dado pode ser diminuído pela
(1) Eliminação de LTAG; ou
(2) Substituição de RLINK com RTAG que simplesmente identifica os nodes onde uma seta procede
Usando a segunda opção, será necessária a utilização de uma stack para estabelecer a relação entre
os nodes, já que as setas têm estrutura “último a entrar, primeiro a sair”, e usando-a levará a
seguinte representação:

E isto é representado internamente como:

Estruturas de Dados 11
JEDITM

Tendo valores de bit para RTAG e LTAG, a última opção mostra claramente o menor espaço
requerido para armazenamento. Porém, acarreta mais processamento na recuperação da floresta.

4.3.2. Representação Seqüencial de Percurso por Família (Family-Order)

Nesta representação seqüencial, a listagem por família de elementos é usada. Atravessar por família,
a primeira família a ser listada consiste de nodes raízes de todas as árvores na floresta e
subseqüentemente, as famílias são listadas com base em primeiro-a-entrar primeiro-a-sair
(FIFO). Esta representação faz uso de LLINK e RTAG. LLINK é um ponteiro para o filho mais à
esquerda de um node ou o filho à esquerda numa representação de árvore binária. RTAG identifica o
irmão mais novo na linhagem ou o último membro da família.

Figura 12: Representação seqüencial família-ordem da floresta F

Figura 13: Representação interna real

Assim como na representação seqüencial pré-ordem, uma vez que um valor RTAG sempre precede
imediatamente uma seta, exceto para o último node da seqüência, uma estrutura alternativa é a
substituição de LLINK com LTAG, que é determinado se uma seta provier dele:

Estruturas de Dados 12
JEDITM

Figura 14: Representação interna real

4.3.3. Representação Seqüencial de Percurso por Nível (Level-Order)

A terceira opção de representação seqüencial é o percurso por nível. Nesta representação, a floresta
é atravessada por nível, por exemplo, de-cima-para-baixo, da-esquerda-para-direita, para obter a
listagem de elementos por nível. Assim como no percurso por família, irmãos (os quais constituem
uma família) são listados consecutivamente. LLINK e RTAG são usados na representação. LLINK é
um ponteiro para o filho mais velho na floresta ou o filho da esquerda na sua representação em
árvore binária. RTAG identifica o irmão mais novo na descendência (ou o último membro da família).
Usando a representação seqüencial de percurso por nível, temos o seguinte para a floresta F:

Figura 15: Representação seqüencial de percurso por nível

Observe que ao contrário da pré-ordem ou do percurso por família, as setas se cruzam nesta
representação. Todavia, é possível ser observado que a primeira cruz a começar é também a
primeira a finalizar. Tendo a estrutura FIFO (first-in, first-out), uma queue poderia ser utilizada para
estabelecer o relacionamento entre os nodes. Conseqüentemente, assim como nos métodos
anteriores, poderia ser representada como:

4.3.4. Convertendo Representação Seqüencial para Representação por Link

Em termos de espaço, a representação seqüencial é ideal para florestas. Todavia, uma vez que a
representação por link é mais natural para florestas, existem instâncias em que teremos que utilizar
esta última. A classe a seguir implementa um método para conversão de representação seqüencial
para a representação por link:

public class SeqForest{


int RTAG[];
int INFO[];
int LTAG[];
int n = 0;

Estruturas de Dados 13
JEDITM

public SeqForest(int size) {


n = size;
RTAG = new int[n];
INFO = new int[n];
LTAG = new int[n];
}
public SeqForest(int[] rtag, int[] info, int[] ltag) {
n = rtag.length;
RTAG = rtag;
INFO = info;
LTAG = rtag;
}
public BinaryTree convert() {
BTNode alpha = new BTNode(null);
BinaryTree t = new BinaryTree(alpha);
LinkedStack stack = new LinkedStack();
BTNode sigma;
BTNode beta;

// Gera o resto de uma árvore binária


for (int i=0; i<n-1; i++) {
// alpha.setInfo(INFO[i]);
beta = new BTNode(null);
if (RTAG[i] == 1)
stack.push(alpha);
else
alpha.setRight(null);

if (LTAG[i] == 1){
alpha.setLeft(null);
sigma = (BTNode) stack.pop();
sigma.setRight(beta);
} else
alpha.setLeft(beta);

alpha.setInfo(INFO[i]);
alpha = beta;
}
// Preenche os campos do node mais a direita
alpha.setInfo(INFO[n-1]);
return t;
}
public static void main(String args[]) {
int[] RTAG = {1,0,1,1,1,0,1,1,1,0,0,0,0,0};
int[] INFO = {'A','B','C','D','E','F','G','H','I','K','J','L','M','O'};
int[] LTAG = {0,0,1,1,1,1,0,1,0,1,0,1,0,1};
SeqForest f = new SeqForest(RTAG, INFO, LTAG);
BinaryTree b = f.convert();
b.preorder();
}
}

Estruturas de Dados 14
JEDITM

5. Representações de Árvores Aritméticas


As árvores podem ser representadas seqüencialmente utilizando uma representação aritmética. A
árvore pode ser armazenada seqüencialmente baseada em sua pré-ordem, pós-ordem e ordem de
níveis de seus nodes. O grau ou o peso da árvore pode ser armazenado com sua informação. O
grau, como definido anteriormente, se refere ao número de filhos que um node possui, enquanto
que o peso se refere ao número de descendentes de um node.

Figura 5.1 árvore T ordenada

A seguir são representadas as diversas maneiras de se representar a árvore acima.


Sequência de Pré-ordem com Grau
INFO 1 2 5 11 6 12 13 7 3 8 4 9 14 15 16 10
GRAU 3 3 1 0 2 0 0 0 1 0 2 1 2 0 0 0

Sequência de Pré-ordem com Peso


INFO 1 2 5 11 6 12 13 7 3 8 4 9 14 15 16 10
PESO 15 6 1 0 2 0 0 0 1 0 5 3 2 0 0 0

Sequência de Pós-ordem com Grau


INFO 11 5 12 13 6 7 2 8 3 15 16 14 9 10 4 1
GRAU 0 1 0 0 2 0 3 0 1 0 0 2 1 0 2 3

Sequência de Pós-ordem com Peso


INFO 11 5 12 13 6 7 2 8 3 15 16 14 9 10 4 1
PESO 0 1 0 0 2 0 6 0 1 0 0 2 3 0 5 15

Sequência de ordem de níveis com Grau


INFO 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
GRAU 3 3 1 2 1 2 0 0 1 0 0 0 0 2 0 0

Sequência de ordem de níveis com Peso


INFO 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
PESO 15 6 1 5 1 2 0 0 3 0 0 0 0 2 0 0

Estruturas de Dados 15
JEDITM

5.1. Aplicação: Árvores e o problema da Equivalência

O problema de equivalência é uma outra aplicação que faz uso de uma árvore internamente
representada como uma árvore aritmética.
Uma relação de equivalência é uma relação entre os elementos de uma coleção de objetos S que
satisfaçam as 3 propriedades para qualquer objeto x, y e z (não necessariamente distintos) em S:

(a) Transitividade: se x ≡ y e y ≡ z então x ≡ z


(b) Simetria: se x ≡ y então y ≡ x
(c) Reflexitividade: x ≡ x

Exemplos de relações de equivalência são as relações “é igual a” (= ) e a “similaridade” entre as


árvores binárias.

5.1.1. O problema da Equivalência

Dados quaisquer pares de relações de equivalência na forma de i ≡ j para qualquer i, j em S,


determine se K é equivalente a i, para qualquer K, i pertence a S, com base nos pares dados. Para
solucionar o problema utilizaremos o seguinte teorema:
Uma relação de equivalência particiona seu conjunto S em classes disjuntas, chamadas
classes de equivalência, tal que dois elementos são equivalentes se e somente se eles
pertencerem a mesma classe de equivalência.
Por exemplo, considere o conjunto S = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}. Suponha que as
relações de equivalência definidas no conjunto são: 1 ≡ 10, 9 ≡ 12, 9 ≡ 3, 6 ≡ 10, 10 ≡ 12, 2 ≡ 5, 7 ≡
8, 4 ≡ 11, 2 ≡ 13 e 1 ≡ 9. Agora, a pergunta, 10 ≡ 2 é verdadeiro? 6 ≡ 12 é verdadeiro? Para
responder a essas perguntas precisamos criar as classes de equivalência.

Entrada Classes de equivalência Modificações


1 ≡ 10 C1 = {1, 10} Criar uma nova classe(C1) que contenha 1 e 10
9 ≡ 12 C2 = {9, 12} Criar uma nova classe(C2) que contenha 9 e 12
9≡3 C2 = {9, 12, 3} Adicionar 3 a C2
6 ≡ 10 C1 = {1, 10, 6} Adicionar 6 a C1
10 ≡ 12 C2 = {1, 10, 6, 9, 12, 3} Juntar C1 e C2 dentro de C2, descartar C1
2≡5 C3 = {2, 5} Criar uma nova classe(C3) que contenha 2 e 5
7≡8 C4 = {7, 8} Criar uma nova classe(C4) que contenha 7 e 8
4 ≡ 11 C5 = {4, 11} Criar uma nova classe(C5) que contenha 4 e 11
6 ≡ 13 C2 = {1, 10, 6, 9, 12, 3, 13} Adicionar 13 a C2
1≡9 Sem mudanças

Desde que 13 não tenha equivalências, as classes finais são:


C2 = {1, 10, 6, 9, 12, 3, 13}
C3 = {2, 5}
C4 = {7, 8}
C5 = {4, 11}

Estruturas de Dados 16
JEDITM

E 10 ≡ 2? Já que se encontram em classes diferentes, não são equivalentes.


E 6 ≡ 12? Já que se ambos pertencem a C2, são equivalentes.

5.1.2. Implementação no Computador

Para implementar a solução para o problema da equivalência, é preciso um modo de representar as


classes de equivalência. Precisa-se também de um modo de unir as classes de equivalência(a
operação union) e determinar se 2 objetos pertencem a uma mesma classe de equivalência ou
não(a operação find).
Para solucionar a primeira preocupação, as árvores podem ser usadas para representar as classes de
equivalência, i.e., uma árvore representa a classe. Neste caso, configurando uma árvore, chamemos
t1, como uma sub-árvore de uma outra árvore, chamemos t2, pode-se implementar a união de
classes equivalentes. Também é possível informar se dois objetos pertencem a uma mesma classe
ou não respondendo a uma simples questão, “os objetos possuem a mesma origem na árvore? “
Considere o seguinte:

Figura 16: União de classes Equivalentes

São dadas as relações de equivalência i ≡ j onde i e j são raízes. Para unir as duas classes, dizemos
que a raiz de i é um novo child (tronco) da raiz de j. Este é o processo de união, e o código que o
implementa é:
while (FATHER[i] > 0)
i = FATHER[i];
while (FATHER[j] > 0)
j = FATHER[j];
if (i != j)
FATHER[j] = k;

Os exemplos seguintes mostram a ilustração gráfica do problema de equivalência descrito:

Entrada Floresta
1 ≡ 10

1 2 3 4 5 6 7 8 9 10 11 12 13
10 0 0 0 0 0 0 0 0 0 0 0 0

9 ≡ 12

Estruturas de Dados 17
JEDITM

1 2 3 4 5 6 7 8 9 10 11 12 13
10 0 0 0 0 0 0 0 12 0 0 0 0

9≡3

1 2 3 4 5 6 7 8 9 10 11 12 13
10 0 0 0 0 0 0 0 12 0 0 3 0

6 ≡ 10

1 2 3 4 5 6 7 8 9 10 11 12 13

10 0 0 0 0 10 0 0 12 0 0 3 0

10 ≡ 12

1 2 3 4 5 6 7 8 9 10 11 12 13

10 0 0 0 0 10 0 0 12 3 0 3 0

2≡5

1 2 3 4 5 6 7 8 9 10 11 12 13

10 5 0 0 0 10 0 0 12 3 0 3 0

7≡8

1 2 3 4 5 6 7 8 9 10 11 12 13

10 5 0 0 0 10 8 0 12 3 0 3 0

4 ≡ 11

1 2 3 4 5 6 7 8 9 10 11 12 13

10 5 0 11 0 10 8 0 12 3 0 3 0

Estruturas de Dados 18
JEDITM

6 ≡ 13

1 2 3 4 5 6 7 8 9 10 11 12 13

10 5 13 11 0 10 8 0 12 3 0 3 0

1≡9

1 2 3 4 5 6 7 8 9 10 11 12 13

10 5 13 11 0 10 8 0 12 3 0 3 0

Figura 5.4 Exemplo de equivalência

A seguir a implementação do algoritmo para resolver o problema da equivalência:


class Equivalence {
int[] FATHER;
int n;

public Equivalence() {
}

public Equivalence(int size) {


n = size+1; // +1 desde FATHER[0] que não será usado
FATHER = new int[n];
}
public void setSize(int size) {
FATHER = new int[size+1];
}
// Gera a equivalência de classes baseada na equivalência dos pares j,k
public void setEquivalence(int a[], int b[]) {
int j, k;
for (int i=0; i<a.length; i++) {
// Obtêm a equivalência do par j,k
j = a[i];
k = b[i];

// Pega a raiz de j e k
while (FATHER[j] > 0)
j = FATHER[j];
while (FATHER[k] > 0)
k = FATHER[k];

// Se não é equivalente, combina as duas árvores


if (j != k)
FATHER[j] = k;
}

Estruturas de Dados 19
JEDITM

}
/* Aceita dois elementos j e k.
Retorna verdadeiro se equivalente, senão returna falso */
public boolean test(int j, int k) {
// Obtém as raízes de j e k
while (FATHER[j] > 0)
j = FATHER[j];
while (FATHER[k] > 0)
k = FATHER[k];

// Se possuírem a mesma raiz, são equivalentes


return (j == k);
}
public static void main(String args[]){
int[] j = {1, 9, 9, 6, 10, 2, 7, 4};
int[] k = {10, 12, 3, 10, 12, 5, 8, 11};
int n = 13;
Equivalence eq = new Equivalence(n);
eq.setEquivalence(j, k);
System.out.println(eq.test(10, 2));
System.out.println(eq.test(6, 12));
}
}

5.1.3. Degeneração e o Enfraquecimento da Regra para União

O problema com o algoritmo anterior similar ao problema resolvido pelo enfraquecimento, i.e.,
criando uma árvore que possua o maior número de níveis em profundidade possível, feito isso é
criado um prejuízo de performance, i.e., tempo de complexidade para O(n). Para esta ilustração,
considere o conjunto S = { 1, 2, 3, ..., n } e a relação de equivalência 1 ≡ 2, 1 ≡ 3, 1≡ 4, ..., 1 ≡ n. A
seguinte figura mostra como a árvore é montada:

Figura 17: pior caso Floresta (Árvore) em um Problema Equivalente.

Agora, considere a seguinte árvore:

Estruturas de Dados 20
JEDITM

Figura 18: Melhor-caso Floresta (Árvore) em um Problema Equivalente

Em termos de classificações equivalentes, as duas árvores simbolizam a mesma classe. Contudo a


segunda árvore possui apenas um ramo a ser atravessado de qualquer node à raiz enquanto que na
primeira árvore possui n-1 ramos.
Exemplificaremos com a seguinte classe:
public class Equivalence2 {
private int[] FATHER;
private int n;

public Equivalence2(){
}
public Equivalence2(int size){
n = size+1;
FATHER = new int[n];
}
public void setEquivalence(int a[], int b[]){
int j, k;
for (int i=0; i<FATHER.length; i++) FATHER[i] = -1;
for (int i=0; i<a.length; i++){
j = a[i];
k = b[i];
j = find(j);
k = find(k);
if (j != k) union(j, k);
}
}
}

Para resolver este problema, utilizaremos uma técnica conhecida como a balanceamento por
união. Definida a seguir:
primeiro o node i e o node j são raízes. Se o número de nodes da árvore cuja raiz i for
maior que o número de nodes com raiz j, faz-se o node i pai do node j; senão, faz o node j
pai do node i.

No algoritmo, Um array COUNT pode ser usado para contar o número de nodes de cada árvore da
floresta. Porém, se o node é raiz de classes equivalentes, eles não entram no array PAI não tem
importância e a raiz não possui pai. Para entender a vantagem sobre isto, podemos usar esta
abertura como array PAI no lugar de usar outro. Para diferenciar entre contadores e rótulos no PAI,
um sinal de menos é adicionado aos contadores. O seguinte método adicionado na classe
Sequence2, implementa o balanceamento por união:
// Implementa o balanceamento por união
public void union(int i, int j) {
int count = FATHER[i] + FATHER[j];
if (Math.abs(FATHER[i]) > Math.abs(FATHER[j])) {

Estruturas de Dados 21
JEDITM

FATHER[j] = i;
FATHER[i] = count;
} else {
FATHER[i] = j;
FATHER[j] = count;
}
}

A operação de UNIÃO possui tempo de complexidade O(1). Se o balanceamento por união não for
aplicado, a ordem é O(n) pesquisa-união na operação de inserção, no pior caso, O(n 2). Fora isso, se
aplicado, o tempo de tempo de complexidade é O(n log2n).

5.1.4. Árvores Pior Caso

Outra observação em usar árvores em problemas semelhantes é mostrado para o pior caso até
quando a criação balanceamento por união é aplicada. Considere a seguinte ilustração:

Figura 19: Árvores pior caso

A figura mostra como a árvore pode crescer logaritmicamente apesar do balanceamento por união
ser aplicado. Isto é, o pior caso das árvores com n nodes é log2 n. podemos prevenir, uma árvore
possui n-1 nodes filhos apenas um node pode ser usado para representear a classe de equivalência.
Neste caso, a profundidade para a árvore de pior caso pode ser reduzido por aplicar outra técnica, e
esta é a desmontar por ordem de pesquisa. Com este, o caminho pode ser feito buscando o
caminho percorrido da raiz ao node p. Isto é , em um processo de busca, Se o atual caminho
descoberto não for o ótimo, ele é “desmontada” para conseguir o ótimo. A ilustração anterior mostra
isso, considere a figura seguinte:

Estruturas de Dados 22
JEDITM

Figura 20: Desmontar por Ordem de Pesquisa 1

Em um relacionamento i ≡ j para qualquer j, requer pelo menos m passos, i.e., executar o i =


PAI(i), para receber a raiz.
Na desmontar por ordem de pesquisa, descobrir n1, n2, ..., nm quais nodes estão no caminho entre o
node n1 ao node raiz r. para desmontar, faremos r o pai de np, 1 ≤ p < m:

Figura 21: Desmontar por Ordem de Pesquisa 2

O seguinte método adicionado na classe Sequence2, implementa este processo:

// Implementa Desmontar por Ordem de Pesquisa. Retorna a raiz de i


public int find(int i) {
int j, k, l;
k = i;

// Procura raiz
while (FATHER[k] > 0)
k = FATHER[k];

// Resumir caminho do node i


j = i;
while (j != k){

Estruturas de Dados 23
JEDITM

l = FATHER[j];
FATHER[j] = k;
j = l;
}
return k;
}

A operação find é proporcional ao tamanho do caminho do node i para a raiz.

5.1.5. Solução final para o Problema Equivalente

Os métodos a seguir implementados na classe Sequence2, finalizam a solução para o problema


equivalente:

// Gerar equivalentes classes baseada na equivalência do par j,k


public void setEquivalence(int a[], int b[]){
int j, k;
for (int i=0; i < FATHER.length; i++)
FATHER[i] = -1;
for (int i=0; i < a.length; i++) {
// Retorna a equivalência entre o par j,k
j = a[i];
k = b[i];

// Retorna as raizes de j e k
j = find(j);
k = find(k);

// Se não são equivalentes, junte as duas árvores


if (j != k)
union(j, k);
}
}

/* Aceitar dois elementos j e k.


Retorna verdadeiro se forem equivalentes, senão retorna falso*/
public boolean test(int j, int k) {
// retorna raízes para j e k
j = find(j);
k = find(k);

// Se possuírem a mesma raiz, são equivalentes


return (j == k);
}

A seguir é mostrado o estado de classes equivalentes após esta solução final de equivalência o
problema é resolvido:

Entrada Floresta PAI


1 ≡ 10

1 2 3 4 5 6 7 8 9 10 11 12 13

10 0 0 0 0 0 0 0 0 0 0 0 0

Estruturas de Dados 24
JEDITM

Entrada Floresta PAI


9 ≡ 12

1 2 3 4 5 6 7 8 9 10 11 12 13
10 0 0 0 0 0 0 0 12 0 0 0 0

9 ≡ 3,
balanceando count(12) > count(3)
1 2 3 4 5 6 7 8 9 10 11 12 13
10 0 12 0 0 0 0 0 12 0 0 0 0

6 ≡ 10

1 2 3 4 5 6 7 8 9 10 11 12 13

10 0 12 0 0 10 0 0 12 0 0 0 0

10 ≡ 12, count(10) = count(12)

1 2 3 4 5 6 7 8 9 10 11 12 13

12 0 12 0 0 12 0 0 12 12 0 0 0

2≡5

1 2 3 4 5 6 7 8 9 10 11 12 13

10 5 12 0 0 12 0 0 12 12 0 0 0

7≡8

1 2 3 4 5 6 7 8 9 10 11 12 13

10 5 12 0 0 12 8 0 12 12 0 0 0

4 ≡ 11

1 2 3 4 5 6 7 8 9 10 11 12 13

12 5 12 11 0 12 8 0 12 12 0 0 0

Estruturas de Dados 25
JEDITM

Entrada Floresta PAI

6 ≡ 13

1 2 3 4 5 6 7 8 9 10 11 12 13

10 5 12 11 0 12 8 0 12 12 0 0 0

1≡9

1 2 3 4 5 6 7 8 9 10 11 12 13

12 5 12 11 0 12 8 0 12 12 0 0 0

Figura 22: Um Exemplo Usando Balanceamento por União e Desmontar por Ordem de Pesquisa

Podemos utilizar o seguinte método principal para testar esta classe:


public static void main(String args[]){
int[] j = {1, 9, 9, 6, 10, 2, 7, 4, 6, 1};
int[] k = {10, 12, 3, 10, 12, 5, 8, 11, 13, 9};
int n = 13;
Equivalence2 eq = new Equivalence2(n);
eq.setEquivalence(j, k);
System.out.println(eq.test(10, 2));
System.out.println(eq.test(1, 3));
System.out.println(eq.test(6, 12));
for (int i=0; i<n;i++) System.out.println(eq.FATHER[i]);
}

Estruturas de Dados 26
JEDITM

6. Exercícios
1. Para cada uma das florestas abaixo,

FLORESTA 1

FLORESTA 2

FLORESTA 3

a) Converta para a árvore binária correspondente.


b) Dê a representação seqüencial em pré-ordem com pesos.
c) Dê a representação seqüencial em ordem de família com graus.
d) Usando representação seqüencial em pré-ordem, mostre a disposição interna usada para
armazenar a floresta (com seqüências LTAG e RTAG).

2. Mostre a representação da floresta abaixo usando a representação seqüencial em pré-ordem com


pesos:

a b c d e f g h i j k l m n o
5 2 0 0 1 0 0 2 1 0 4 1 0 0 0

3. Classes de Equivalência

Estruturas de Dados 27
JEDITM

a) Dado o conjunto S = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10} e os pares de equivalência: (1,4), (5,8), (1,9),


(5,6), (4,10), (6,9), (3,7) e (3,10), construir a classe de equivalência usando florestas. 7≡6? 9≡
10?

b) Desenhe a floresta correspondente e o array pai resultante das classes equivalentes obtidas dos
elementos de S = {1,2,3,4,5,6,7,8,9,10,11,12} na base das relações equivalentes 1≡2, 3≡5, 5≡7,
9≡10, 11≡12, 2≡5, 8≡4, e 4≡6. Use a regra dos pesos para a união.

6.1. Exercícios para Programar

1. Criar a definição da classe de Java de representações seqüenciais em ordem de nível de florestas.


Criar também um método que converta a representação seqüencial em ordem de nível em sua
representação de ponteiros.

Estruturas de Dados 28
Módulo 3
Estruturas de Dados

Lição 6
Grafos

Versão 1.0 - Mai/2007


JEDITM

1. Objetivos
Essa lição cobre a nomenclatura para o Grafo ADT. Discute diferentes modos para representar um
grafo. Os dois algoritmos de grafos transversais também são discutidos, assim como o problema de
árvores geradoras de custo mínimo e o problema do caminho mais curto.
Ao final dessa lição, o estudante deverá ser capaz de:
• Explicar conceitos básicos e definições de grafos
• Discutir métodos de representação de grafos: matriz de adjacência e lista de adjacência
• Grafos Transversais usando os algoritmos depth-first search (busca em primeira-
profundidade) e breadth-first search (busca em primeira-largura)
• Entender árvores geradoras de custo mínimo para grafos não-dirigidos usando o algoritmo
de Prim e de Kruskal
• Resolver o problema de menor caminho com início único usando o algoritmo de Dijkstra
• Resolver o problema de menor caminho para todos os pares usando o algoritmo de Floyd

Estruturas de Dados 4
JEDITM

2. Definições e Conceitos Relacionados


Um grafo, G = (V, E), consiste de um conjunto finito não-vazio de vértices (ou nodes), V, e um
conjunto de arestas, E. São exemplos de grafos:

Figura 1: Exemplos de Grafos

Um grafo não-dirigido é um grafo no qual o par de vértices representando uma aresta é


desordenado. Por exemplo, (i,j ) e (j,i) representa o mesmo vértice.

Figura 2: Grafo não-dirigidos

Dois vértices i e j são adjacentes se aresta(i, j) é uma aresta em E. A aresta(i, j) é conhecida com
sendo incidente nos vértices i e j.

Figura 3: Aresta (i, j)

Um grafo não-dirigido completo é um grafo no qual uma aresta conecta todos os pares de
vértices. Se um grafo não-dirigido complete tem n vértices, existirão n(n -1)/2 arestas nele.

Estruturas de Dados 5
JEDITM

Figura 4: Grafo não-dirigido completo

Um grafo dirigido ou dígrafo é um grafo no qual cada aresta é representada por um par ordenado
<i, j>, onde i é o vértice principal e j é o vértice secundário da aresta. As arestas <i,j> e <j,i> são
duas arestas distintas.

Figura 5: Grafo dirigido

Um grafo dirigido completo é um grafo no qual todos os pares de vértices i e j são conectados por
duas arestas <i,j> e <j,i>. Existem n(n-1) arestas nele.

Figura 6: Grafo dirigido completo

Um subgrafo de um grafo não-dirigido (dirigido) G = (V,E) é um grafo não-dirigido (dirigido) G’ =


(V’,E’) no qual V ⊆ V e E’ ⊆ E.

Figura 7: Exemplo de subgrafo

Estruturas de Dados 6
JEDITM

Um caminho do vértice u para o vértice w em um grafo não-dirigido [dirigido] G = (V,E) é uma


seqüência de vértices vo, v1, v2, ..., vm-1, vm onde vo ≡ u e vm ≡ w, no qual (v0,v1),(v1,v2),...,
(vm-1, vm) [ <v0,v1>, <v1,v2>, ..., <vm-1, vm>] são arestas em E.
O comprimento de um caminho refere-se ao número de arestas contidas nele.
Um caminho simples é um caminho no qual todos os vértices são distintos, exceto, possivelmente,
o primeiro e o último.
Um caminho simples é um ciclo simples se ele tiver o mesmo vértice de começo e fim.
Dois vértices i e j são conectados se existir um caminho do vértice i para o vértice j. Se para cada
par de vértices distintos i e j existir um caminho direto de e para ambos os vértices, isso é conhecido
como sendo fortemente conectado. Um subgrafo conectado máximo de um grafo não-dirigido é
conhecido como componente conectado em um grafo não-dirigido. Em um grafo dirigido G, o
componente fortemente conectado refere-se ao componente fortemente conectado em G.
Um grafo ponderado é um grafo com pesos e custos designados para suas arestas.

Figura 8: Grafo Ponderado

Uma árvore geradora é um subgrafo que conecta todos os vértices de um grafo. O custo de uma
árvore geradora, se for ponderada, é a soma dos pesos dos galhos (arestas) da árvore geradora.
Uma árvore geradora que tem o custo mínimo é conhecida como árvore geradora de custo
mínimo. Isso não é necessariamente único para um determinado grafo.

Figura 9: Árvore Geradora

Estruturas de Dados 7
JEDITM

3. Representação de Grafos
Existem diversas maneiras de se representar um grafo, e existem alguns fatores que devem ser
considerados:
• Operações nos grafos
• Número de arestas relativas ao número de vértices no grafo

3.1. Matriz de Adjacência para grafos dirigidos


Um grafo dirigido pode ser representado usando uma matriz bidimensional, digamos A, com
dimensões n x n, onde n é o número de vértices. Os elementos de A são definidos como:
A(i,j) =1 se a aresta <i,j> existir, 1 ≤ i, j ≤ n
= 0 se a aresta <i,j> não existir

A matriz de adjacência pode ser declarada como uma matriz de lógicos se o grafo não for ponderado.
Se o grafo for ponderado, A(i, j) é configurado para conter o custo da aresta <i, j>, mas se não
existir aresta <i, j> no grafo, A(i, j) é configurado para um valor muito grande. A matriz é então
chamada de matriz custo-adjacência.
Por exemplo, a representação de custo-adjacência do seguinte grafo é mostrada abaixo:

1 2 3 4 5

1 0 1 ∞ 9 ∞
2 ∞ 0 2 5 10
3 ∞ ∞ 0 ∞ 3
4 ∞ ∞ 4 0 8
5 6 ∞ ∞ ∞ 0

Figura 10: Representação da Matriz de Custo Adjacência para Grafos Dirigidos

Não é permitida referência a si própria, portanto, os elementos diagonais são sempre zeros. O
número de elementos não-zero em A é menor ou igual a n(n-1), o qual é o limite se o dígrafo é
completo. O grau de saída do vértice i, ou seja, o número de setas derivadas dele, é o mesmo de
números de elementos não-zero na linha i. O caso é parecido para o grau de entrada do vértice j,
em que o número de setas apontando para ele é o mesmo do número de elementos não-zero na
coluna j.
Com essa representação, saber se existe uma aresta <i, j> leva O(1) tempo. No entanto, mesmo se
o dígrafo tiver menos que n2 arestas, a representação implica em requerimento de espaço de O(n2).

3.2. Lista de Adjacência para Grafos Dirigidos

Uma tabela seqüencial ou lista pode também ser usada para representar um dígrafo G em n vértices,
digamos LIST. A lista é mantida como se para qualquer vértice i em G, LIST(i) aponta para a lista de
vértices adjacentes de i.
Por exemplo, segue a representação da lista de adjacência o grafo anterior:

Estruturas de Dados 8
JEDITM

LISTA INFO CUSTO PRÓX


1 4 9 2 1 Λ
2 5 10 4 5 3 2 Λ
3 5 3 Λ
4 5 8 3 4 Λ
5 1 6 Λ
Figura 11: Representação dos custos em Lista de Adjacência para Grafos Direcionados

3.3. Matriz de adjacência para Grafos Não Direcionados


Assim como o diagrama, a matriz de adjacência pode ser usada para representar Grafos Não
Direcionados. Entretanto, eles diferem no sentido que são simétricos para Grafos Não Direcionados,
por exemplo, A(i, j) = A(j, i). Para representar o Grafo são necessários elementos na menor ou
maior diagonal. A outra parte pode ser considerada como ”Não se preocupe” (*).
Para um Grafo Não Direcionado G com n vértices, o número de elementos não-zero é A ≤ n(n-1)/2. O
limite superior é alcançado quando G é completado.
Por exemplo, o seguinte Grafo Não Direcionado não é ponderado. Por essa razão, a matriz de
adjacência tem como entrada A(i,j) = 1 se a aresta existir, senão a entrada é 0.
1 2 3 4 5

1 0 * * * *
2 0 0 * * *
3 0 1 0 * *
4 1 0 1 0 *
5 1 1 0 0 0
Figura 12: Representação dos custos em uma Matriz de Adjacência para Grafos Não Direcionados

3.4. Lista de Adjacência para Grafos Não Direcionados

A representação é similar a lista de adjacência para Grafos Direcionados. Contudo, para Grafos Não
Direcionados, existem duas entradas na lista para uma aresta (i, j).
Por exemplo, veja a seguir a representação da Lista de Adjacência do Grafo anterior:

LISTA INFO PRÓX


1 5 4 Λ
2 5 3 Λ
3 4 2 Λ
4 3 1 Λ
5 2 1 Λ

Figura 13: Representação dos custos em Lista de Adjacência para Grafos Não Direcionados

Estruturas de Dados 9
JEDITM

4. Percurso em Grafos
Um grafo, diferentemente de uma árvore, não tem o conceito de um node raiz no qual o método de
percurso pode ser iniciado. Também não há uma ordem natural entre vértices de ou para o vértice
mais recentemente visitado que indica o próximo a ser visitado. No percurso em grafos, também é
importante perceber que desde que um vértice possa ser adjacente de ou para muitos vértices, há a
possibilidade de encontrá-lo novamente. Por essa razão, existe a necessidade de indicar se um
vértice já foi visitado.
Nessa seção, cobriremos dois algoritmos de percurso em grafos: busca em profundidade e busca em
largura.

4.1. Busca em Profundidade


Na busca em profundidade (depth first search - DFS), o grafo é percorrido da forma mais profunda
possível. Dado um grafo com vértices marcados como não visitados, o percurso é executado
conforme apresentado a seguir:
1. Selecione um vértice não visitado para iniciar. Se nenhum vértice for encontrado, a busca em
profundidade termina

2. Marque o vértice inicial como visitado

3. Processar o vértice adjacente:

a) Selecione um vértice não visitado, por exemplo u, adjacente do vértice inicial


b) Marque o vértice adjacente como visitado
c) Inicie a busca em profundidade usando u como vértice inicial. Se nenhum vértice for
encontrado, vá para o passo (1)

4. Se mais vértices adjacentes forem encontrados, vá para o passo (3c)

Sempre que houver ambigüidade em relação a qual deve ser o próximo vértice a ser visitado, no
caso de existirem muitos vértices adjacentes, aquele com o menor número deve ser escolhido. Por
exemplo, iniciando no vértice 1, a busca em profundidade percorrerá os elementos conforme o
seguinte grafo:

Estruturas de Dados 10
JEDITM

Figura 14: Exemplo de Busca em Profundidade

Estruturas de Dados 11
JEDITM

4.2. Busca em Primeira Largura

A Busca em Primeira Largura (Breadth First Search - BFS) percorre o grafo da forma mais ampla o
possível. Dado um grafo com vértices marcados como não visitados, o percurso é executado da
seguinte forma:
1. Selecione um vértice não visitado, por exemplo i, e marque-o como visitado. Então,
busque o mais amplamente possível partindo de i e visitando seus vértices adjacentes.

2. Repetir (1) até que todos os vértices no grafo sejam visitados.

Assim como na busca em profundidade, no caso de dúvida sobre qual node será o próximo a ser
visitado, deve-se considerar a ordem numérica crescente dos elementos.
Por exemplo, iniciando no vértice 1, a busca em largura percorrerá os elementos conforme o
seguinte grafo:

Estruturas de Dados 12
JEDITM

Figura 15: Exemplo de Busca em Largura

Estruturas de Dados 13
JEDITM

5. Árvore Geradora de Custo Mínimo para Grafos Não


Direcionados
A Árvore Geradora de Custo Mínimo (Minimum Cost Spanning Tree - MST), como definida
anteriormente, é um subgrafo de um dado grafo G, no qual todos os vértices estão conectados e tem
o custo mais baixo. Isso é particularmente útil para encontrar o caminho mais barato para conectar
computadores em uma rede, bem como aplicações similares.
Encontrar árvore geradora de custo mínimo para um grafo não direcionado usando abordagem de
força bruta não é aconselhável se o número de árvores geradoras para n vértices distintos seja nn-2.
Por essa razão, é imperativo usar outra abordagem na busca da árvore geradora de custo mínimo e
nessa lição, iremos cobrir algoritmos que utilizam uma abordagem gulosa. Nessa abordagem, uma
seqüência de escolhas oportunistas terão êxito na busca pelo ótimo global. Para resolver o problema
da árvore geradora de custo mínimo, usaremos os algoritmos de Prim e Kruskal, os quais são,
ambos, algoritmos gulosos.

5.1. Teorema MST

Considere G = (V, E) um grafo não-dirigido, ponderado e conexo. Considere U seja algum conjunto
apropriado de V e (u, v) seja uma aresta de menor custo tal que u ∈ U e v ∈ (V – U). Existe uma
árvore geradora de custo mínimo T tal que (u, v) é uma aresta em T.

5.2. Algoritmo Prim


Esse algoritmo encontra a aresta de menor custo ligando algum vértice U para um vértice v em (V –
U) para cada passo do algoritmo:
Considere G = (V,E) um grafo não-dirigido, ponderado e conexo. Considere que U indica o conjunto
de vértices escolhidos e T indica o conjunto de arestas preparadas incluído em alguma instância do
algoritmo.
1. Escolha um vértice inicial de V e coloque-o em U

2. Entre os vértices em V - U escolha aquele vértice, v, que é conexo a algum vértice,


u, em U por uma aresta de menor custo. Adicione vértice v para U e a aresta (u, v)
para T

3. Repita (2) até U = V, em que, T é uma árvore geradora de custo mínimo para G

Por exemplo,

Figura 16: Um grafo não-dirigido ponderado

Estruturas de Dados 14
JEDITM

Tomando a como o vértice de partida, a figura a seguir mostra a execução do algoritmo Prim para
resolver o problema MST:

Figura 17: Resultado da aplicação do algoritmo Prim sobre o grafo anterior

5.3. Algoritmo Kruskal


Outro importante algoritmo usado para encontrar a MST foi desenvolvido por Kruskal. Nesse
algoritmo, os vértices são listados em ordem crescente de peso. A primeira aresta a ser adicionada
em T, que é o MST, é a de menor custo. Uma extremidade é considerada se pelo menos um do
vértices não estiver na longe da árvore encontrada.
Agora o algoritmo:
Considere G = (V,E) um grafo não-dirigido, ponderado e conexo. A árvore geradora de custo
mínimo, T, é construída aresta por aresta, com as arestas consideradas em ordem crescente de seus
custos.
1. Escolha a aresta com o baixo custo como a aresta inicial.

2. A aresta de baixo custo entre as arestas restantes em E é considerada para inclusão


em T. Se o ciclo for criado, a aresta em T é rejeitada.

Por exemplo,

Figura 18: Um grafo não-dirigido ponderado

A tabela a seguir mostra a execução do algoritmo Kruskal para resolver o problema MST do grafo
acima:

Estruturas de Dados 15
JEDITM

Arestas MST U V-U Comentário


(c, e) – 1 - a, b, c, Lista de arestas em ordem
(c, d) – 4 d, e, f crescente de peso
(a, e) – 5
(a, c) – 6
(d, e) – 8
(a, d) – 10
(a, b) – 11
(d, f) – 12
(b, c) – 13
(e, f) – 20
(c, e) – 1 aceito (c,e) – 1 c, e a, b, d, c e e não em U
(c, d) – 4 f
(a, e) – 5
(a, c) – 6
(d, e) – 8
(a, d) – 10
(a, b) – 11
(d, f) – 12
(b, c) – 13
(e, f) – 20
(c, d) – 4 aceito (c, e) – 1 c, d, e a, b, f d não em U
(a, e) – 5 (c,d) – 4
(a, c) – 6
(d, e) – 8
(a, d) – 10
(a, b) – 11
(d, f) – 12
(b, c) – 13
(e, f) – 20
(a, e) – 5 aceito (c, e) – 1 a, c, d, e b, f a não em U
(a, c) – 6 (c, d) – 4
(d, e) – 8 (a, e) – 5
(a, d) – 10
(a, b) – 11
(d, f) – 12
(b, c) – 13
(e, f) – 20

(a, c) – 6 rejeitado (c, e) – 1 a, c, d, e b, f a e c estão em U


(d, e) – 8 (c, d) – 4
(a, d) – 10 (a, e) – 5
(a, b) – 11
(d, f) – 12
(b, c) – 13
(e, f) – 20

Estruturas de Dados 16
JEDITM

Arestas MST U V-U Comentário


(d, e) – 8 rejeitado (c, e) – 1 a, c, d, e b, f d e e estão em U
(a, d) – 10 (c, d) – 4
(a, b) – 11 (a, e) – 5
(d, f) – 12
(b, c) – 13
(e, f) – 20

(a, d) – 10 rejeitado (c, e) – 1 a, c, d, e b, f a e d estão em U


(a, b) – 11 (c, d) – 4
(d, f) – 12 (a, e) – 5
(b, c) – 13
(e, f) – 20

(a, b)– 11 aceito (c, e) – 1 a, b, c, f b não em U


(d, f) – 12 (c, d) – 4 d, e
(b, c) – 13 (a, e) – 5
(e, f) – 20 (a,b) – 11

(d, f)– 12 aceito (c, e) – 1 a, b, c, f não em U


(b, c) – 13 (c, d) – 4 d, e, f
(e, f) – 20 (a, e) – 5
(a,b) – 11
(d,f) – 12

(b, c) – 13 Todos os vértices estão agora


(e, f) – 20 em U
COST = 33
Figura 19: Resultado da aplicação do algoritmo Kruskal sobre o grafo anterior

Desde que todos os vértices estejam prontos em U, a MST deve ser obtida. O algoritmo resultante
para a MST possui o custo 33.
Nesse algoritmo, o maior fator em custo computacional é a ordenação de arestas em ordem
crescente.

Estruturas de Dados 17
JEDITM

6. Problemas de Menor Caminho para Grafos Direcionados


Outro tipo clássico de problemas em grafos é achar o menor caminho dado um grafo ponderado.
Para achar o menor caminho, é necessário obter o comprimento que, neste caso, é a soma dos
custos não negativos de cada aresta do caminho.
Existem dois tipos de problemas com grafos ponderados:
• Problema de Menor Caminho de Início Único (Single Source Shortest Paths (SSSP)) que
determina o custo do menor caminho de um vértice inicial u para um vértice final v, onde u e v
são elementos de V.

• Problema de Menor Caminho para Todos os Pares (All-Pairs Shortest Paths (APSP)) que
determina o custo do menor caminho de cada vértice para todos os vértices de V.

Vamos discutir o algoritmo criado por Dijkstra para resolver o Problema SSSP, e, para o Problema
APSP, vamos usar o algoritmo desenvolvido por Floyd.

6.1. Algoritmo de Dijkstra para o Problema SSSP

Assim como os algoritmos de Prim e Kruskal, o algoritmo de Dijkstra usa o caminho "ganancioso".
Neste algoritmo, para cada vértice é atribuída uma classe e um valor, onde:
• Um vértice de Classe 1 é um vértice o qual a sua menor distância para o vértice
inicial, digamos k, já foi encontrada; seu valor é igual a sua distância do vértice k
pelo menor caminho.

• Um vértice de Classe 2 é um vértice o qual a sua menor distância de k ainda


precisa ser encontrada; seu valor é a sua distância do vértice k encontrada até
agora.

Seja u o vértice inicial e v o vértice final. Seja pivô o vértice que foi mais recentemente considerado
parte do caminho. Seja caminho de um vértice seu início direto no menor caminho. Agora o
algoritmo:
1. Coloque o vértice u na classe 1 e todos os outros vértices na classe 2

2. Defina o valor de vértice u para zero e o valor de todos os outros vértices para
infinito

3. Faça o seguinte até o vértice v seja colocado na classe 1:


a. Defina o vértice pivô como o vértice colocado mais recentemente na classe 1
b. Ajuste todos os nodes da classe 2 do seguinte modo:
i. Se um vértice não está conectado ao vértice pivô, seu valor permanece o
mesmo
ii. Se um vértice está conectado ao vértice pivô, substitua seu valor pelo
mínimo entre seu valor atual e o valor do vértice pivô mais a distância do
pivô até o vértice na classe 2. Defina seu caminho como pivô
c. Escolha um vértice de classe 2 com valor mínimo e coloque-o na classe 1

Por exemplo, dado o seguinte grafo ponderado e direcionado, encontre o menor caminho do vértice
1 ao vértice 7.

Estruturas de Dados 18
JEDITM

Figura 20: Um gráfico ponderado e direcionado para o exemplo SSSP

A tabela seguinte mostra a execução do algoritmo:

Vértice classe valor caminho Descrição


1 1 0 0 O vértice inicial é 1. Sua classe é definida como 1.
2 2 ∞ 0 Todas as outras são definidas como 2. O valor do
3 2 ∞ 0 vértice 1 é 0 enquanto os demais forem ∞. Os
4 2 ∞ 0 caminhos de todos os vértices são definidos como 0
5 2 ∞ 0 uma vez que os caminhos do vértice inicial para o
6 2 ∞ 0 vértice ainda não foram encontrados.
7 2 ∞ 0
Pivô 1 1 0 0 O vértice inicial é o primeiro pivô. Ele está
2 2 4 0 conectado aos vértices 2 e 3. Portanto, os valores
3 2 3 0 dos vértices estão definidos como 4 e 3
4 2 ∞ 0 respectivamente. O vértice de classe 2 de menor
5 2 ∞ 0 valor é 3, então ele é escolhido como o próximo
6 2 ∞ 0 pivô.
7 2 ∞ 0
1 1 0 0 A classe do vértice 3 é definida como 1. Ele é
2 2 4 1 adjacente aos vértices 1, 2 e 4, mas o valor de 1 e 2
Pivô 3 1 3 1 é menor que o valor se o caminho que inclui o
4 2 7 3 vértice 3 é considerado, então não haverá mudança
5 2 ∞ 0 em seus valores. Para o vértice 4, o valor é definido
6 2 ∞ 0 como (valor de 3) + custo(3, 4) = 3 + 4 = 7.
7 2 ∞ 0 Caminho(4) é definido como 3.
1 1 0 0 O próximo pivô é 2. Ele é adjacente a 4, 5 e 6.
Pivô 2 1 4 1 Adicionar o pivô ao menor caminho atual para 4
3 1 3 1 aumentará seu custo, mas o mesmo não ocorre com
4 2 7 3 5 e 6, onde os valores são mudados para 5 e 16
5 2 5 2 respectivamente.
6 2 16 2
7 2 ∞ 0

Estruturas de Dados 19
JEDITM

Vértice classe valor caminho Descrição


1 1 0 0 O próximo pivô é 5. Ele está conectado aos vértices
2 1 4 1 6 e 7. Adicionar o vértice 5 ao caminho mudará o
3 1 3 1 valor do vértice 6 para 7 e do vértice 7 para 11.
4 2 7 3
Pivô 5 1 5 2
6 2 7 5
7 2 11 5
1 1 0 0 O próximo pivô é 4. Embora ele seja adjacente ao
2 1 4 1 vértice 6, o valor de 6 não mudará se o pivô for
3 1 3 1 adicionado ao seu caminho.
Pivô 4 1 7 3
5 1 5 2
6 2 7 5
7 2 11 5
1 1 0 0 Tornar 6 o pivô impõe mudar no valor do vértice 7
2 1 4 1 de 11 para 8 e também adicionar o vértice 6 ao
3 1 3 1 caminho do anterior.
4 1 7 3
5 1 5 2
Pivô 6 1 7 5
7 2 8 6
1 1 0 0 Agora, o vértice final v é colocado na classe 1. O
2 1 4 1 algoritmo termina.
3 1 3 1
4 1 7 3
5 1 5 2
6 1 7 5
Pivô 7 1 8 6

O caminho do vértice inicial 1 até o vértice final 7 pode ser obtido recuperando o valor do
caminho(7) na ordem inversa, que é,
caminho(7) = 6
caminho(6) = 5
caminho(5) = 2
caminho(2) = 1

Portanto, o menor caminho é 1 --> 2 --> 5 --> 6 --> 7, e o custo é valor(7) = 8.

6.2. Algoritmo Floyd para o problema APSP


Para encontrar o menor caminho para todos os pares, algoritmo Dijkstra pode ser usado com todos
os pares de origens e destinos. Contudo, essa não é a melhor solução existente para o problema
APSP. Uma solução mais elegante e apropriada é usar o algoritmo criado por Floyd.
O algoritmo faz uso da representação de matriz de adjacência de custo de um grafo. Ela tem uma
dimensão de n x n para n vértices. Nesse algoritmo, o COST é dado pela matriz de adjacência. A é
a matriz que contém p custo do caminho mais curto, inicialmente igual ao COST. Outra matriz n x n,
PATH, contém os vértices eminentes ao lado do caminho mais curto:

PATH (i,j) = 0 inicialmente, indica que o caminho mas curto entre i e j. É a aresta (i,j) se a

Estruturas de Dados 20
JEDITM

mesma existir

=k se incluir k no caminho de i a j pela k-ésima interação, produz um caminho de


maior custo

O algoritmo é como se segue:


1. Inicialize A para ser igual ao COST:

A(i, j) = COST(i, j), 1 ≤ i, j ≤ n

2. Se o custo de passagem atravessar o vértice intermediário k do vértice i ao vértice j custar menos


que o acesso direto de i to j, substitua A(i,j) com esse custo e atualize PATH(i,j), ou seja:

Para k = 1, 2, 3, ..., n

a) A(i, j) = mínimo [ Ak-1(i, j), Ak-1(i, k) + Ak-1(k, j) ] , 1 ≤ i, j ≤ n

b) Se ( A(i, j) == Ak-1(i, k) + Ak-1(k, j) ) atribua PATH(i, j) = k

Por exemplo, resolva o problema APSP do grafo a seguir usando o algoritmo de Floyd:

Figura 21: Um Grafo Ponderado e Direcionado para o exemplo APSP

Como auto-referência não é permitida, não é necessário computar A(j, j), para 1≤j≤n. Também, para
a k-ésima iteração, não haverá mudanças para as k-ésimas linhas e colunas em A e no PATH, uma
vez que só somará 0 ao valor atual. Por exemplo, se k = 2:
A(2, 1) = mínimo( A(2, 1), A(2,2) + A(2,1) )

Como A(2, 2) = 0, nunca haverá mudança na k-ésima linha e coluna.

A seguir é mostrada a execução do algoritmo de Floyd:


1 2 3 4 1 2 3 4

1 0 2 ∞ 12 1 0 0 0 0
2 8 0 ∞ 7 2 0 0 0 0
3 5 10 0 7 3 0 0 0 0
4 ∞ ∞ 1 0 4 0 0 0 0
A PATH

Para a primeira interação k=1:

Estruturas de Dados 21
JEDITM

A(2, 3) = mínimo( A(2, 3) , A(2, 1) + A(1, 3) ) = mínimo(∞, ∞) = ∞


A(2, 4) = mínimo( A(2, 4) , A(2, 1) + A(1, 4) ) = mínimo(12, 20) = 12
A(3, 2) = mínimo( A(3, 2) , A(3, 1) + A(1, 2) ) = mínimo(10, 7) = 7
A(3, 4) = mínimo( A(3, 4) , A(3, 1) + A(1, 4) ) = mínimo(7, 17) = 7
A(4, 2) = mínimo( A(4, 2) , A(4, 1) + A(1, 2) ) = mínimo(∞, ∞) = ∞
A(4, 3) = mínimo( A(4, 3) , A(4, 1) + A(1, 3) ) = mínimo(1, ∞) = 1

1 2 3 4 1 2 3 4

1 0 2 ∞ 12 1 0 0 0 0
k=1 2 8 0 ∞ 7 2 0 0 0 0
3 5 7 0 7 3 0 1 0 0
4 ∞ ∞ 1 0 4 0 0 0 0
A PATH
Para k=2:
A(1, 3) = mínimo( A(1, 3) , A(1, 2) + A(2, 3) ) = mínimo(∞, ∞) = ∞
A(1, 4) = mínimo( A(1, 4) , A(1, 2) + A(2, 4) ) = mínimo(12, 9) = 9
A(3, 1) = mínimo( A(3, 1) , A(3, 2) + A(2, 1) ) = mínimo(5, 15) = 5
A(3, 4) = mínimo( A(3, 4) , A(3, 2) + A(2, 4) ) = mínimo(7, 12) = 7
A(4, 1) = mínimo( A(4, 1) , A(4, 2) + A(2, 1) ) = mínimo(∞, ∞) = ∞
A(4, 3) = mínimo( A(4, 3) , A(4, 2) + A(2, 3) ) = mínimo(1, ∞) = ∞

1 2 3 4 1 2 3 4

1 0 2 ∞ 9 1 0 0 0 2
k=2 2 8 0 ∞ 7 2 0 0 0 0
3 5 7 0 7 3 0 1 0 0
4 ∞ ∞ 1 0 4 0 0 0 0
A PATH
Para k=3:
A(1, 2) = mínimo ( A(1, 2) , A(1, 3) + A(3, 2) ) = mínimo (2, ∞) = ∞
A(1, 4) = mínimo ( A(1, 4) , A(1, 3) + A(3, 4) ) = mínimo (9, ∞) = ∞
A(2, 1) = mínimo ( A(2, 1) , A(2, 3) + A(3, 1) ) = mínimo (8, ∞) = ∞
A(2, 4) = mínimo ( A(2, 4) , A(2, 3) + A(3, 4) ) = mínimo (7, ∞) = ∞
A(4, 1) = mínimo ( A(4, 1) , A(4, 3) + A(3, 1) ) = mínimo (∞, 6) = 6
A(4, 2) = mínimo ( A(4, 2) , A(4, 3) + A(3, 2) ) = mínimo (∞, 8) = 8

1 2 3 4 1 2 3 4

1 0 2 ∞ 9 1 0 0 0 2
k=3 2 8 0 ∞ 7 2 0 0 0 0
3 5 7 0 7 3 0 1 0 0
4 6 8 1 0 4 3 3 0 0
A PATH
Para k=3:

A(1, 2) = mínimo( A(1, 2) , A(1, 4) + A(4, 2) ) = mínimo (2, 17) = 2

Estruturas de Dados 22
JEDITM

A(1, 3) = mínimo ( A(1, 3) , A(1, 4) + A(4, 3) ) = mínimo (∞, 10) = 10


A(2, 1) = mínimo ( A(2, 1) , A(2, 4) + A(4, 1) ) = mínimo (8, 13) = 8
A(2, 3) = mínimo ( A(2, 3) , A(2, 4) + A(4, 3) ) = mínimo (∞, 8) = 8
A(3, 1) = mínimo ( A(3, 1) , A(3, 4) + A(4, 1) ) = mínimo (5, 13) = 5
A(3, 2) = mínimo ( A(3, 2) , A(3, 4) + A(4, 2) ) = mínimo (7, 15) = 7
1 2 3 4 1 2 3 4

1 0 2 10 9 1 0 0 4 2
k=4 2 8 0 8 7 2 0 0 4 0
3 5 7 0 7 3 0 1 0 0
4 6 8 1 0 4 3 3 0 0
A PATH

Após a nth interação, A contém o menor custo enquanto PATH contém o caminho de menor custo.
Para ilustrar como usar o resultado das matrizes, vamos encontrar o caminho mais curto do vértice 1
para o vértice 4:
A (1, 4) = 9
PATH (1, 4) = 2 --> Desde que não seja 0, temos que pegar PATH(2, 4):
PATH (2, 4) = 0

Por essa razão, o caminho mais curto do vértice 1 para o vértice 4 é 1 --> 2 --> 4 com custo 9. Até
mesmo se existe uma aresta direta de 1 ao 4 (com custo 12), o algoritmo retornou outro caminho.
Esse exemplo mostra que ele não é sempre a conexão direta que é retornada ao obter o caminho
mais curto em um ponderado, grafo direcionado.

Estruturas de Dados 23
JEDITM

7. Exercícios
1. O que é DFS e BFS listando elementos dos seguintes grafos com 1 como vértice de partida?

a)

b)

2. Encontre a árvore geradora de custo mínimo dos seguintes grafos usando os algoritmos de
Kruskal e Prim. Dê o custo do MST.

a)

b)

3. Resolva o problema SSSP do seguinte grafo, usando o algoritmo Dijkstra. Mostre o valor, a
classe e o caminho dos vértices para cada interação:

Estruturas de Dados 24
JEDITM

a)

DIJKSTRA 1 (Origem: 8, Destino: 4)

b)

DIJKSTRA 2 (Origem: 1, Destino: 7)

4. Resolva o APSP dos grafos a seguir dando as matrizes A e Path usando o algoritmo Floyd:

7.1. Exercícios para Programar

1. Crie uma definição de classe Java para grafos direcionados ponderados usando representação de
matriz de adjacência.

2. Implemente os dois algoritmos de grafo transversal.

3. Caminho mais curto usando o algoritmo Dijkstra.

Implemente um caminho mais curto dado um mapa e o custo de cada aresta. O programa pedirá um
arquivo de entrada contendo as origens ou destinos (vértices) e as conexões entre as localizações
(aresta) com o custo. As localizações seguem a forma (número, localização), onde número é um
inteiro determinado para o vértice e localização é o nome do vértice. Todo par (número,
localização) tem que ser localizado em uma linha separada no arquivo de entrada. Para terminar a
entrada, use (0, 0). Conexões serão restritas também de um mesmo arquivo. Uma definição de tem
que se da forma (i, j, k), uma linha por aresta, onde i é o número determinado para a origem, j o

Estruturas de Dados 25
JEDITM

número determinado para o destino e k o custo da chegada de j a i (Lembre-se, usamos grafos


direcionados aqui). Conclua a entrada usando (0, 0, 0).
Após a entrada, crie um mapa e mostre-o para usuário. Solicite ao usuário informar a fonte e o
destino e dê o caminho mais curto usando o algoritmo Dijkstra.
Mostre a saída na tela. A saída consiste no caminho e seu custo. O caminho deve seguir a seguinte
forma:
origem  localização 2  …  localização n-1  destino

Amostragem do arquivo de entrada

(1, Math Building)


(2, Science Building)
(3, Engineering)
.
.  início da definição de aresta
(0, 0)
(1, 2, 10)
(1, 3, 5)
(3, 2, 2)
.
.
(0, 0, 0)

Amostragem do fluxo do programa

[ MAP and LEGEND ]


Origem de entrada: 1
Destino de entrada: 2
Origem: Math Building
Destino: Science Building
Caminho: Math Building  Engineering  Science Building
Custo: 7

Estruturas de Dados 26
Módulo 3
Estruturas de Dados

Lição 7
Listas

Versão 1.0 - Mai/2007


JEDITM

1. Objetivos
Lista é uma estrutura de dado que é baseada numa seqüência de itens. Nessa lição, iremos cobrir os
dois tipos de listas – linear e generalizada – e suas diferentes representações. Listas encadeadas
simples, circulares e encadeadas duplas também serão exploradas. Além disso, duas aplicações
irão ser apresentadas – aritmética polinomial e alocação dinâmica de memória. Aritmética polinomial
inclui a representação de operações polinomiais e aritméticas definidas nela. Alocação dinâmica de
memória cobre ponteiros e estratégias de alocação. Também haverá uma breve discussão sobre
conceitos de fragmentação.
Ao final desta lição, o estudante será capaz de:
• Explicar definições e conceitos básicos de listas
• Usar as diferentes representações de lista: seqüencial e encadeada
• Diferenciar lista encadeada simples, lista encadeada dupla, lista circular e lista com
header nodes
• Explicar como as listas são aplicadas na aritmética polinomial
• Discutir as estruturas de dados usadas na alocação dinâmica de memória usando
métodos sequential-fit e métodos buddy-system

Estruturas de Dados 4
JEDITM

2. Definições e conceitos relacionados


Uma Lista é um conjunto finito com nenhum ou "n" elementos. Os elementos de uma lista podem
ser átomos ou listas. Um átomo é distinguível de uma lista. Listas são classificadas em dois tipos –
lineares e generalizadas. Listas lineares contém apenas elementos átomos, e são ordenadas
seqüencialmente. Listas generalizadas podem conter ambos elementos átomo e lista.

2.1. Lista Linear

O ordenamento linear dos átomos é a propriedade essencial de uma lista linear. A seguir a notação
para esse tipo de lista:
L = ( i1, i2, i3, ..., in )

Várias operações podem ser feitas com uma lista linear. Inserção pode ser feita em qualquer
posição. Similarmente, qualquer elemento pode ser deletado de qualquer posição. A seguir estão as
operações que podem ser feitas nas listas lineares:
• Inicialização (a lista é igual a NULL)
• Determinar se a lista é vazia (checando se L != NULL)
• Achar o tamanho (obtendo o número de elementos)
• Acessar o j-ésimo elemento, 0 ≤ j ≤ n-1
• Atualizar o j-ésimo elemento
• Deletar o j-ésimo elemento
• Inserir um novo elemento
• Combinar duas ou mais listas em uma única lista
• Dividir uma lista em duas ou mais listas
• Duplicar uma lista
• Apagar uma lista
• Buscar por um valor
• Ordenar a lista

2.2. Lista Generalizada

Uma lista generalizada pode conter elementos átomo e lista. Ela tem profundidade e tamanho. A lista
generalizada é também conhecida como lista estruturada, ou simplesmente lista. A seguir um
exemplo:
L = ((a, b, ( c, ( ))), d, ( ), ( e,( ), (f, (g, (a))), d ))

Estruturas de Dados 5
JEDITM

Figura 1. Uma lista generalizada

No exemplo, a lista L possui quatro elementos. O primeiro elemento é a lista (a, b, (c, ( ) )), o
segundo é o átomo d, o terceiro é o conjunto null () e o quarto é a lista (e, ( ), (f, (g, (a))), d).

Estruturas de Dados 6
JEDITM

3. Representações de lista
Uma forma de representar uma lista é organizar os elementos um após o outro em uma estrutura
seqüencial como um array. Outra forma para implementar isso, é o encadeamento de nodes
contendo os elementos da lista usando uma representação encadeada.

3.1. Representação seqüencial de lista linear encadeada simples

Na representação seqüencial, os elementos são armazenados contiguamente. Há um ponteiro para


o último item na lista. A seguir é mostrada uma lista usando representação seqüencial:

Figura 2. Representação Seqüencial de Lista

Com essa representação, poderá ser tomado O(1) tempo para acessar e atualizar o j-ésimo
elemento na lista. Por fazer o caminho até o último item, poder ser tomado O(1) tempo para dizer se
a lista é vazia e para achar o tamanho da lista. Há uma condição, no entanto, em que o primeiro
elemento deve ser sempre armazenado no primeiro índice L(0). Nesse caso, inserir e deletar podem
requerer desvio de elementos para assegurar que a lista satisfaz essa condição. No pior caso, isso
pode obrigar desvio de todos os elementos no array, resultando na complexidade de tempo de O(n)
para inserir e excluir n elementos. Ao combinar duas listas, um array mais largo é requerido se o
tamanho combinado não couber em alguma das duas listas. Isso pode obrigar a copiar todos os
elementos das duas listas na nova lista. Duplicar uma lista pode requerer atravessar a lista inteira,
portanto, uma complexidade de tempo de O(n). Buscar um valor em particular pode tomar tempo de
O(1) se o elemento for o primeiro na lista; de outra forma, o pior caso é quando o elemento
pesquisado é o último, onde a passagem da lista inteira é necessária. Nesse caso, a complexidade
de tempo é O(n).
A alocação seqüencial, sendo estática por natureza, é uma desvantagem para listas de tamanho
incerto, por exemplo, o tamanho não é sabido no momento da inicialização, e com várias inserções e
remoções, pode eventualmente precisar crescer ou encolher. Copiando a lista transbordada em um
array mais largo e descartando o antigo pode funcionar, mas isso pode ser um desperdício de tempo.
Nesse caso, é melhor usar a representação encadeada.

3.2. Representação encadeada de lista linear encadeada simples

Uma corrente de nodes encadeados pode ser usada para representar uma lista.

Figura 3. Representação Encadeada de Lista

Para acessar o j-ésimo elemento com alocação encadeada, a lista tem que ser percorrida do primeiro
elemento até o j-ésimo elemento. O pior caso é quando i = n, onde n é o número de elementos.
Portanto, a complexidade de tempo para acessar o j-ésimo elemento é O(n). Similarmente,
encontrando o tamanho pode obrigar a percorrer a lista inteira, sendo a complexidade de O(n). Se
inserções forem feitas no início da lista, isso pode levar ao tempo O(1). De outra forma, com

Estruturas de Dados 7
JEDITM

remoção e atualização, a busca tem que ser realizada resultando na complexidade de tempo de
O(n). Para dizer se a lista é vazia, pode ser tomado um tempo constante, como na representação
seqüencial. Para copiar uma lista, cada node é copiado enquanto a lista original é percorrida.
A tabela seguinte resume as complexidades de tempo das operações realizadas em listas com os
dois tipos de alocação:

Representação Representação
Operação
Seqüencial Encadeada
Determinar se uma lista é vazia O(1) O(1)
Encontrar o tamanho O(1) O(n)
Acessar o j-ésimo elemento O(1) O(n)
Atualizar o j-ésimo elemento O(1) O(n)
Deletar o j-ésimo elemento O(n) O(n)
Inserir um novo elemento O(n) O(1)
Tabela 1: Representação Seqüencial x Encadeada

A representação seqüencial é apropriada para listas que são estáticas por natureza. Se o tamanho é
desconhecido, o uso de alocação encadeada é recomendado.
Ainda no encadeamento simples linear, há mais variedades de representações encadeadas de listas.
Encadeamento simples circular, encadeamento duplo e lista com header nodes são as variedades
mais comuns.

3.3. Lista circular encadeada simples

Uma lista circular encadeada simples é formada pelo envio do link do último node e apontar de volta
ao primeiro node. Isso é ilustrado na seguinte figura:

Figura 4. Lista Circular Encadeada Simples

Observe que o ponteiro para a lista nessa representação aponta para o último elemento na lista.
Com lista circular, existe a vantagem de ser possível acessar um node de qualquer outro node.
Iniciamos a classe que exemplificará a lista circular encadeada, com dois nodes de referência, para o
primeiro e o último elemento:
public class CircularList {
private Node F;
private Node L;
}

Lista circular pode ser usada para implementar uma stack. Nesse caso, inserção (push) pode ser
feita na ponta esquerda da lista, e remoção (pop) na mesma ponta. Similarmente, queue também
pode ser implementada permitindo inserção na ponta direita da lista e remoção na ponta esquerda.

Estruturas de Dados 8
JEDITM

3.3.1. Inserção a Esquerda

O seguinte método inserido na classe realiza o procedimento de inserir um elemento X na ponta da


esquerda da lista circular:
public void insertLeft(Object x) {
Node alpha = new Node(x,null);
if (F == null) {
alpha.link = alpha;
F = alpha;
} else {
alpha.link = F.link;
F.link = alpha;
}
L = alpha;
}

No método, se a lista é inicialmente vazia, resultará a seguinte lista circular:

Figura 5. Inserção em uma lista vazia

De outra forma, será realizado, conforme o seguinte diagrama:

Figura 6. Inserção em uma lista não vazia

3.3.2. Inserção a Direita


O seguinte método inserido na classe realiza o procedimento de inserir o elemento X na ponta da
direita da lista circular:
public void insertRight(Object x) {
Node alpha = new Node(x,null);
if (L == null) {
alpha.link = alpha;
F = alpha;
} else {
alpha.link = L.link;
L.link = alpha;

Estruturas de Dados 9
JEDITM

}
L = alpha;
}

Se a lista é inicialmente vazia, o resultado de insertRight é similar ao de insertLeft, entretanto para


uma lista não vazia,

Figura 7. Inserção à Direita

3.3.3. Exclusão a Esquerda

O seguinte método inserido na classe realiza o procedimento de eliminar o elemento mais à esquerda
da lista circular L:
public void deleteLeft() throws Exception {
Node alpha;
if (L == null)
throw new Exception("CList empty.");
else {
F.link = L.link;
alpha = L.link;
if (alpha == L)
L = null;
else
L = alpha;
}
}

Execução de deleteLeft em uma lista não vazia é ilustrada conforme a figura 8.

Figura 8. Remoção à Esquerda

Todos esses três procedimentos têm complexidade de tempo de O(1).


3.3.4. Concatenação de duas listas
Outra operação que possui tempo O(1) em uma lista circular é a concatenação. Isto é, dadas duas
listas:

Estruturas de Dados 10
JEDITM

L1 = (a1, a2, ..., am) e L2 = (b1, b2, ..., bn)


Onde m, n ≥ 0, as listas resultantes são:
L1 = (a1, a2, ..., am, b1, b2, ..., bn)
L2 = null
Isso pode ser feito por simples manipulações do campo de ponteiro dos nodes realizando uma junção
de duas listas:
public void concatenate(Node List2) {
if ((List2 != null) && (L != null)) {
Node alpha = L.link;
L.link = List2.link;
List2.link = alpha;
}
}

Se L2 é não vazia, o processo de concatenação é ilustrado abaixo:

Figura 9. Concatenação de duas listas

Terminamos esta classe com um método para mostrar o conteúdo sequencial da lista (lembrando
que a mesma é circular, então devemos listar uma determinada quantidade de elementos, pois não é
possível localizar o fim da lista).
public void showList(Node n, int t) {
if (t-- == 0)
return;
System.out.println(n.info);
showList(n.link, t);
}

public static void main(String[] args) throws Exception {


// Cria uma lista com 4 elementos inseridos pela esquerda
CircularList cL = new CircularList();
for (int i = 1; i < 5; i++)
cL.insertLeft("Element " + i + "L");
System.out.println("Left");
cL.showList(cL.F, 5);

Estruturas de Dados 11
JEDITM

// Elimina o elemento mais a esquerda


System.out.println("After DeleteLeft");
cL.deleteLeft();
cL.showList(cL.F, 4);

// Cria uma lista com 4 elementos inseridos pela direita


CircularList cR = new CircularList();
for (int i = 1; i < 5; i++)
cR.insertLeft("Element " + i + "R");
System.out.println("Right");
cR.showList(cR.F, 5);

// Concatena a lista da esquerda com a da direita


cL.concatenate(cR.F);
System.out.println("Concate");
cL.showList(cL.L, 8);
}

E como resultado será produzido:


Left
Element 1L
Element 4L
Element 3L
Element 2L
Element 1L
After DeleteLeft
Element 1L
Element 3L
Element 2L
Element 1L
Right
Element 1R
Element 4R
Element 3R
Element 2R
Element 1R
Concate
Element 3L
Element 4R
Element 3R
Element 2R
Element 1R
Element 2L
Element 1L
Element 3L

observe que sempre listamos um elemento a mais para mostrar a circularidade da lista.

3.4. Lista encadeada simples com header nodes

Um header node pode ser usado para armazenar informação adicional sobre a lista, como o número
de elementos. O header node é também conhecido como list header. Para uma lista circular, ele
serve como sentinela para indicar passagem completa pela lista. Também pode indicar tanto o
começo como o ponto final. Para uma lista encadeada dupla, ele é usado para apontar para ambos

Estruturas de Dados 12
JEDITM

os finais, para fazer inserção ou armazenamento de realocação rapidamente. O cabeçalho da lista é


também usado para representar a lista vazia por um objeto não nulo em uma linguagem de
programação orientada a objeto como Java, então esses métodos como inserção podem ser
invocados em uma lista vazia. A próxima figura ilustra uma lista circular com um cabeçalho de lista:

Figura 10. Lista Encadeada Simples com Cabeçalho de Lista

3.5. Lista encadeada dupla

Listas encadeadas duplas são formadas de nodes que possuem ponteiros para ambos vizinhos
(esquerdo e direito) na lista. A próxima figura mostra a estrutura de nodes para uma lista encadeada
dupla.

Figura 11. Estrutura de nodes de Lista Encadeada Dupla

A seguir um exemplo de uma lista encadeada dupla:

Figura 12. Lista Encadeada Dupla

Com lista encadeada dupla, cada node tem dois campos de ponteiro – LLINK e RLINK que apontam
para os vizinhos da esquerda e direita respectivamente. A lista pode ser percorrida em ambas as
direções. Um node i pode ser deletado sabendo apenas a informação sobre o node i. Ainda, um node
j pode ser inserido tanto antes quanto depois de um node i sabendo a informação sobre i. A figura
seguinte ilustra a remoção do node i:

Figura 13. Remoção em uma Lista Encadeada Dupla

Uma lista encadeada dupla pode ser constituída das seguintes formas:
• Lista linear encadeada dupla
• Lista circular encadeada dupla
• Lista circular encadeada dupla com cabeçalho de lista

Propriedades de lista encadeada dupla

Estruturas de Dados 13
JEDITM

• LLINK(L) = RLINK(L) = L significa que a lista L é vazia.


• Podemos deletar qualquer node, como o node α, em L no tempo O(1), sabendo apenas o
endereço α.
• Podemos inserir um novo node, como o node β, à esquerda (ou direita) de qualquer node,
como o node α, no tempo O(1), sabendo apenas α sendo que só precisa de ajustes de
ponteiro, como ilustrado abaixo:

Figura 14. Inserção em uma Lista Encadeada

Estruturas de Dados 14
JEDITM

4. Aplicação: Aritmética Polinomial


As listas podem ser usadas para representar polinômios nos quais podem ser aplicadas operações
aritméticas. Há dois assuntos que têm que ser tratados:
• Um caminho para representar os termos de um polinômio, tal que cada termo pode ser
acessado e processado facilmente pelas entidades que os incluem.
• Uma estrutura dinâmica, isto é, para crescer e diminuir conforme necessário.

Para tratar estes assuntos, lista circular simplesmente ligada com cabeça de lista pode ser usada,
com uma estrutura de node como ilustrado abaixo:

Figura 15. Estrutura do node

onde,
• O campo EXPO é dividido em um subcampo de sinal (S) e três subcampos de expoentes para
as variáveis x, y, z.
• S é negativo (-) se for a cabeça de lista, caso contrário é positivo
• ex, ey, ez são para as potências das variáveis x, y e z respectivamente
• O campo COEF pode conter qualquer número real, com ou sem sinal.

Por exemplo, a figura seguinte é a representação da lista do polinômio P(x,y,z) = 20x 5y4 – 5x2yz +
13yz3:

Figura 16. Um Polinômio Simbolizado, usando Representação Ligada

Neste aplicativo, há uma regra em que os nodes devem ser arranjados em valor decrescente do
triplo (exeyez). Um polinômio satisfazendo esta propriedade é dito estar em forma canônica. Esta
regra torna o desempenho de executar as operações aritméticas do polinômio mais rápida, em
comparação aos termos estarem organizados em nenhuma ordem particular.
Desde que a estrutura de lista tem uma cabeça de lista, para representar o zero polinomial, temos o
seguinte:

Figura 17. Zero Polinomial

Em Java, o seguinte é a definição de um termo polinomial:

Estruturas de Dados 15
JEDITM

class PolyTerm {
int expo;
int coef;
PolyTerm link;

// Cria um termo novo que contém o expo -001 (cabeça de lista)


public PolyTerm() {
expo = -1;
coef = 0;
link = null;
}
// Cria um termo novo com expo e o coeficiente
public PolyTerm(int e, int c) {
expo = e;
coef = c;
link = null;
}
}

e o seguinte é a definição de um Polinômio:


class Polynomial {
PolyTerm head = new PolyTerm(); // list head
public Polynomial() {
head.link = head;
}
// Cria um novo polinômio com head h
public Polynomial(PolyTerm h) {
head = h;
h.link = head;
}
}

Para inserir termos de forma canônica, o seguinte é um método da classe Polynomial:


/* Insere um termo para [this] polinômio inserindo
em seu próprio local, para manter a forma canônica */
public void insertTerm(PolyTerm p) {
PolyTerm alpha = head.link; // ponteiro móvel
PolyTerm beta = head;
if (alpha == head) {
head.link = p;
p.link = head;
return;
} else {
while (true) {
/* Se o termo corrente é menor do que alpha
ou é o menor no polinômio, então insire */
if ((alpha.expo < p.expo) || (alpha == head)) {
p.link = alpha;
beta.link = p;
return;
}

// Avançar alpha e beta


alpha = alpha.link;
beta = beta.link;

Estruturas de Dados 16
JEDITM

// Se o círculo está completo, retorna


if (beta == head)
return;
}
}
}

4.1. Algoritmos de Aritmética Polinomial

Esta seção cobre como adição polinomial, subtração e multiplicação podem ser implementadas,
usando a estrutura há pouco descrita.
4.1.1. Adição Polinomial

Somando dois polinômios P e Q, a soma é retida em Q. Três ponteiros de execução são necessário:
• α para apontar para o termo corrente (node) em P polinomial
• β para apontar para o termo corrente em Q polinomial
• σ apontar para o node atrás de β. Isto é usado durante a inserção e a deleção em Q para
obter a complexidade do tempo O(1).

O estado de α e β cairá em um dos seguintes casos:

• EXPO (α) < EXPO (β)

Ação: avançar os ponteiros para o polinômio Q um node acima

• EXPO (α) > EXPO (β)

Ação: copiar o termo corrente em P e insira-o antes do termo corrente em Q, então


avançar α

• EXPO (α) = EXPO (β)

• Se EXPO (α) < 0, ambos os ponteiros α e β têm uma volta completa no círculo e
estão agora apontando para as cabeças de lista

Ação: terminar o procedimento

• Senão, α e β estão apontando para dois termos que podem ser adicionados

Ação: adicionar os coeficientes. Quando o resultado é zero, deletar o node de Q.


Mover P e Q para o termo seguinte.

Por exemplo, adicionar os dois polinômios seguintes:


P = 67xy2z + 32x2yz – 45xz5 + 6x – 2x3y2z
Q = 15x3y2z - 9xyz + 5y4z3 - 32x2yz
Já que os dois polinômios não estão em forma canônica, seus termos devem ser reordenados antes
deles serem representados, como:

Estruturas de Dados 17
JEDITM

Figura 18. Polinômios P e Q

Adicionar os dois,

Expo(α) = Expo(β)
adicionar α e β:

Expo(α) = Expo(β)
adicionar α e β, resultados para excluir o node apontado por β:

Expo(α) > Expo(β)


inserir α entre σ ε β:

Estruturas de Dados 18
JEDITM

Expo(α) < Expo(β), avançar σ e β:


Expo(α) > Expo(β), inserir α em Q:

Expo(α) > Expo(β), inserir α em Q:

Figura 19. Adição dos Polinômios P e Q

Desde que ambos P e Q estejam em forma canônica, uma passagem é suficiente. Se os operando
não estiverem em forma canônica, o procedimento não produzirá o resultado correto. Se P tem m
termos e Q tem n termos, a complexidade de tempo do algoritmo é O(m+n).
Com este algoritmo, não há necessidade para manipulação especial do zero polinomial. Ele trabalha
com zero P e/ou Q. Porém, desde que a soma é retida em Q, ele tem que ser duplicado, se a
necessidade de usar Q depois da adição poderá surgir. Poderia ser feito chamando o método
adicionar(Q, P) da classe Polinomial, onde P é inicialmente o zero polinomial e contém a duplicata de
Q.
O seguinte é a implementação de Java deste Procedimento:
// Executa a operaçãoQ = P + Q, Q é [this] polinômio
public void add(Polynomial P) {
// Ponteiro móvel em P
PolyTerm alpha = P.head.link;
// Ponteiro móvel em Q
PolyTerm beta = head.link;

Estruturas de Dados 19
JEDITM

// Ponteiro para o node atrás de beta, usado na inserção para Q


PolyTerm sigma = head;
PolyTerm tau;
while (true) {
// Termo corrente em P > termo corrente em Q
if (alpha.expo < beta.expo) {
// Avançar os ponteiros em Q
sigma = beta;
beta = beta.link;
} else if (alpha.expo > beta.expo) {
// Inserir o termo corrente em P para Q
tau = new PolyTerm();
tau.coef = alpha.coef;
tau.expo = alpha.expo;
sigma.link = tau;
tau.link = beta;
sigma = tau;
alpha = alpha.link; // Avançar o ponteiro em P
} else { // Termos em P e Q podem ser adicionados
if (alpha.expo < 0)
return; // A soma já está em Q
else {
beta.coef = beta.coef + alpha.coef;
// Se adicionando causará cancelamento do termo
if (beta.coef == 0) {
// tau = beta;
sigma.link = beta.link;
beta = beta.link;
} else { // Avançar os ponteiros em Q
sigma = beta;
beta = beta.link;
}
// Avançar o ponteiro em P
alpha = alpha.link;
}
}
}
}

4.1.2. Subtração Polinomial

Subtração de um polinomial Q de P, i.e., Q = P-Q, é simplesmente uma adição polinomial com cada
termo em Q negado: Q = P + (-Q). Isto pode ser feito percorrendo Q e negando os coeficientes no
processo antes de chamar polyAdd.

// Executa a operação Q = Q-P, Q é [this] polinômio


public void subtract(Polynomial P){
PolyTerm alpha = P.head.link;
// Negar todos os termos em P
while (alpha.expo != -1) {
alpha.coef = - alpha.coef;
alpha = alpha.link;
}
// Adicionar P para [this] polinômio
this.add(P);
// Restaurar P

Estruturas de Dados 20
JEDITM

while (alpha.expo != -1) {


alpha = alpha.link;
alpha.coef = - alpha.coef;
}
}

4.1.3. Multiplicação Polinomial


Para multiplicar dois polinômios P e Q, um polinômio R inicialmente zero é necessário para conter o
produto, isto é, R = R + P*Q. No processo, todos os termos em P são multiplicados com todos os
termos em Q.
O seguinte é a implementação Java:
/* Executar a operação R = R + P*Q, onde T é inicialmente
um zero polinomial e R é this polinômio */
public void multiply(Polynomial P, Polynomial Q) {
// Criar o polinômio temporário T, para conter termo do produto
Polynomial T = new Polynomial();
// Ponteiro móvel em T
PolyTerm tau = new PolyTerm();
// Conter o produto
Polynomial R = new Polynomial();
// Ponteiro móvel em P e Q
PolyTerm alpha, beta;
// Inicializar T e tau
T.head.link = tau;
tau.link = T.head;
// Multiplicar
alpha = P.head.link;
// Para todos os termos em P
while (alpha.expo != -1) {
beta = Q.head.link;
// multiplicar com todos os termos em Q
while (beta.expo != -1) {
tau.coef = alpha.coef * beta.coef;
tau.expo = expoAdd(alpha.expo, beta.expo);
R.add(T);
beta = beta.link;
}
alpha = alpha.link;
}
this.head = R.head; // Fazer [this] polinômio ser R
}
/* Executar a adição de expoentes do triplo(x,y,z)
Método auxiliar usado por multiply */
public int expoAdd(int expo1, int expo2) {
int ex = expo1/100 + expo2/100;
int ey = expo1%100/10 + expo2%100/10;
int ez = expo1%10 + expo2%10;
return (ex * 100 + ey * 10 + ez);
}

Estruturas de Dados 21
JEDITM

5. Alocação Dinâmica de Memória


Alocação Dinâmica de Memória (DMA - Dynamic Memory Allocation) refere-se ao gerenciamento
de uma área contínua de memória, chamada memory pool, usando técnicas para alocar e desalocar
blocos. Assume-se que o memory pool consiste de unidades individuais endereçáveis chamadas
words. O tamanho de um bloco é mensurado em words. A alocação dinâmica de memória também é
conhecida como alocação dinâmica de armazenamento ou dynamic storage allocation. Na DMA,
blocos existem em tamanho variável, daqui, getNode e retNode, como discutido no lição 1, não serão
suficientes para gerenciar alocação de blocos.
Existem duas operações relacionadas à DMA: reserva e liberação. Durante a reserva, um bloco de
memória é alocado para uma tarefa de requisição (requesting). Quando um bloco não é mais
necessário, ele está pronto para ser liberado. Liberação é o processo para retorná-lo ao memory
pool.
Existem duas técnicas gerais na DMA – método sequential fit e buddy-system.

5.1. Gerenciando o Memory Pool

É necessário gerenciar o memory pool para que os blocos sejam alocados e desalocados conforme
a necessidade. Problemas aparecem após uma seqüência de alocar e desalocar blocos. Isto é,
quando o memory pool consiste de blocos livres e dispersos todos entre blocos reservados do pool.
Nestes casos, as linked lists podem ser utilizadas para organizar blocos livres tornando mais
eficientes os processos de reserva e liberação.
No método sequential-fit, todos os blocos livres estão constituídos em uma lista singly-linked
chamada de lista disponível. No método buddy-system, blocos são alocados em tamanhos
quantum apenas, i.e. 1, 2, 4, 2k apenas words. Assim, algumas listas disponíveis são mantidas, uma
para cada tamanho permitido.

5.2. Método Sequential-Fit: Reserva

Uma forma de constituir listas disponíveis é usar a primeira word de cada bloco livre como uma
control word (palavra de controle). Consiste de dois campos: SIZE e LINK. SIZE contém o tamanho
do bloco livre, enquanto LINK aponta para o próximo bloco livre no memory pool. A lista deve ser
ordenada de acordo com o tamanho, endereço, ou deve estar left unsorted.

Figura 20. lista disponível

Para satisfazer uma necessidade de n words, a lista disponível é escaneada por blocos que reúnem
um critério apropriado:
• first fit – o primeiro bloco com words m ≥ n
• best fit – o bloco best-fitting (mais apropriado), i.e. o menor bloco com words m≥n
• worst fit – o maior bloco

Após encontrar um bloco, n words das que estão reservadas e as restantes m-n são mantidas na
lista disponível. Todavia, se as words restantes m-n forem muito pequenas para satisfazer
qualquer pedido, devemos optar por alocar o bloco inteiro.

Estruturas de Dados 22
JEDITM

A abordagem acima é simples mas sofre de dois problemas. Primeiro, retorna para a lista
disponível o que quer que esteja à esquerda do bloco após a reserva. Isto remete a longas buscas
e muito do espaço livre não usado é dispersado na lista disponível. Segundo, a busca sempre
começa no início da lista disponível. Daqui, pequenos blocos a esquerda da parte conduzida da
lista resultando em buscas muito grandes.
Para resolver o primeiro problema, podemos alocar o bloco inteiro, se o que restar for pequeno para
satisfazer o pedido. Podemos definir um valor mínimo como minsize. Usando esta solução, será
necessário armazenar o tamanho do bloco desde que o tamanho reservado não coincida com o
tamanho atual do bloco alocado. Isto deverá ser feito adicionando-se um campo size para o bloco
reservado, que será usado durante a liberação.
Para resolver o segundo problema, poderemos manter a trilha do final da última busca e começar a
próxima busca na conexão do mesmo bloco atual, i.e. se chegarmos ao fina do bloco A, poderemos
iniciar a próxima busca no LINK(A). Um ponto sem destino, dizemos rover, é necessário para
manter a trilha deste bloco. O seguinte método implementa estas soluções.
Buscas curtas na lista disponível fazem a segunda abordagem mais rápida que a primeira. A última
abordagem é a que iremos usar para o primeiro método de ajuste da reserva de ajuste seqüencial.
Por exemplo, dado o estado do memory pool com um tamanho de 64K como ilustrado abaixo,

Figura 21. Um Memory Pool

reserve espaço para o seguinte pedido retornando o que quer que esteja a esquerda da alocação.

Task Request
A 2K
B 8K
C 9K
D 5K

Estruturas de Dados 23
JEDITM

Task Request
E 7K

Figura 22. Resultado de aplicação de três métodos Sequential Fit

O método best-fit reserva largos blocos para futuras requisições enquanto que na worst-fit, um
largo bloco é alocado para retornar uma grande disponibilidade de blocos para a lista disponível. No
método best-fit, há uma necessidade em procurar a lista inteira para achar o melhor bloco ajustado.
Há pouco semelhança com o primeiro ajuste, e também existe uma tendência em se acumular blocos
muito pequenos. Entretanto, isso pode ser minimizado utilizando minsize. Best-fit não significa
necessariamente que produzirá resultados melhores no primeiro ajuste. Algumas pesquisas mostram
que há muitos casos na qual há um resultado melhor que supera o primeiro ajuste. Em algumas
aplicações, produto de pior ajuste produz os melhores resultados entre os três métodos.

5.3. Método Sequential-Fit: Liberação

Quando um bloco reservado não é necessário, deve ser liberado imediatamente. Durante a liberação,
deve-se desmontar os blocos livres adjacentes para se formar um bloco maior (problema de
desmontagem). Esta é uma consideração importante em liberação de sequential-fit.

As figuras seguintes demonstram os quatro possíveis cenários durante liberação de um bloco:

Estruturas de Dados 24
JEDITM

(A) (A) (A) (A)


bloco bloco bloco bloco
reservado reservado livre livre
(B) (B) (B) (B)
bloco bloco bloco bloco
livre livre livre livre
(C) (C) (C) (C)
bloco bloco bloco bloco
reservado livre reservado livre

(a) (b) (c) (d)

Figura 23. Casos possíveis na Liberação

Na figura, o bloco B está liberado. (a) mostra dois blocos adjacentes liberados, (b) e (c) mostram um
bloco adjacente liberado e (d) mostra dois blocos adjacentes liberados. Para liberar, o bloco liberado
deve estar mesclado com o bloco adjacente livre, se existir algum.
Existem dois métodos para mesclar na liberação: a técnica sorted-list e a técnica boundary-tag.
5.3.1. A técnica Sorted-List

Na técnica sorted-list, a lista disponível é convertida em uma lista singly-linked e é considerada


para ser ordenada no aumento de endereços e memória. Quando um bloco está liberado, são
necessárias as seguintes interações:
• O bloco recentemente liberado vem antes de um bloco liberado;
• O bloco recentemente liberado vem após um bloco livre; ou
• O bloco recentemente liberado antes e após blocos livres.

Para saber se um bloco liberado está adjacente a quaisquer dos blocos livres na lista disponível,
usamos o tamanho do bloco. Para mesclar dois blocos, o campo SIZE do bloco mais baixo, que seu
endereço conhecido, é simplesmente atualizado para conter a soma dos tamanhos dos blocos
combinados.

Figura 24. Exemplos de Liberação

Estruturas de Dados 25
JEDITM

5.3.2. A Técnica Boundary-Tag

A técnica Boundary-Tag utiliza duas words de controle e uma dupla ligação. A primeira e a última
palavra contém detalhes de controle. A figura seguinte mostra a estrutura de ligação e os dois
estados de um bloco (reservado e livre):

(a) bloco livre (b) bloco reservado

Figura 25. node de estrutura na técnica Boundary-Tag

O valor da TAG é 0 se o bloco está livre, de outra maneira será 1. Ambos os campos TAG e SIZE
estão presentes para mesclar blocos livres executados no tempo O(1).
A lista disponível é concebida como uma lista doubly-linked como a lista principal (de cabeçalho).
Inicialmente, a lista disponível é formada por apenas um bloco, o memory pool inteiro, limitado
abaixo e acima pelos blocos de memória não disponíveis para DMA. A figura seguinte mostra o
estado inicial de uma lista disponível:

Figura 26. O estado inicial do Memory Pool

Estruturas de Dados 26
JEDITM

Após usar a memória por algum tempo, ficará com segmentos descontínuos, assim teremos a
seguinte lista disponível:

Figura 27. lista disponível após algumas alocações e desalocações

A técnica Sorted-list está em O(m), onde m é o número de blocos na lista disponível. A técnica
Boundary-tag tem tempo de complexidade O(1).

5.4. Método Buddy-System

Nos métodos buddy-system, blocos estão alocados em tamanhos quantum. Algumas listas
disponíveis são mantidas, uma para cada tamanho permitido. Existem dois métodos buddy-system:
• O método binary buddy-system – os blocos são alocados em tamanhos baseados nas
potências de 2: 1, 2, 4, 8, …, 2k words
• o método Fibonacci buddy-system – os blocos são alocados em tamanhos baseados na
seqüência numérica Fibonacci: 1, 2, 3, 5, 8, 13, … palavras (k-1)+(k-2)

Nesta sessão, iremos cobrir apenas o método binary buddy-system.


5.4.1. O método Binary Buddy-System

Figura 28. buddies no Binary Buddy-System

5.4.2. Reserva

Dado um bloco com tamanho 2k, o que segue é o algoritmo para reservar um bloco para um pedido
para n words:
1. Se o tamanho do bloco atual é < n:

• Se o bloco atual é o de maior tamanho, retorna: um bloco não suficientemente grande está
disponível
• Caso contrário vai para a lista disponível do próximo bloco no tamanho. Vai para 1
• Caso contrário, vai para 2

Estruturas de Dados 27
JEDITM

2. Se o tamanho do bloco é o menor múltiplo de 2 ≥ n, então retorna o bloco reservado para a


tarefa de requisição
Caso contrário vai para 3

3. Divide o bloco em duas partes. Estas duas partes são chamados buddies. Vai para 2, tendo a
metade superior dos novos limite de corte como o bloco corrente

Por exemplo, reserve espaço para os pedidos A (7K), B (3K), C (16K), D (8K), e E (15K) a partir de
um memory pool não usado de tamanho 64K.

Estruturas de Dados 28
JEDITM

Estruturas de Dados 29
JEDITM

Figura 29. Exemplo de método Binary Buddy System

5.4.3. Liberação

Quando um bloco está liberado de uma tarefa e se o buddy do bloco inicialmente liberado está livre,
é necessário mesclar os buddies. Quando o buddy dos blocos recentemente mesclados também está
livre, executa-se novamente uma mescla. Isto é feito repetidamente até que mais nenhum buddy
possa ser mesclado.

Localizar o buddy é um passo crucial na operação de liberação e é feito pela computação:

Deixe β(k:α) = endereço do buddy do bloco de tamanho 2k no endereço α

β(k:α) = α + 2k se α mod 2k+1 = 0

β(k:α) = α - 2k de outro modo.

Se o buddy localizado estiver livre, ele pode ser mesclado com o bloco mais recentemente liberado.
Para o método buddy-system ser eficiente, é necessário manter uma lista disponível para cada

Estruturas de Dados 30
JEDITM

tamanho alocável. A seguir temos o algoritmo para a reserva usando o método binary buddy
system:

1. Se um pedido para n words é feito e a lista disponível para blocos do tamanho 2k, onde k = log2n
, não está vazio, então temos um bloco da lista disponível. De outra forma, vá para 2

2. Obtenha um bloco da lista disponível de tamanho 2p onde p é o menor inteiro maior que k para
que a lista não seja vazia

3. Dividir o bloco p-k vezes, inserindo blocos não usados em suas respectivas listas disponíveis

Usando a alocação anterior como nosso exemplo, liberar os blocos com reserva B (3K), D (8K), e A
(7K) nesta ordem.

Estruturas de Dados 31
JEDITM

Figura 30. Método Binary Buddy System Exemplo de Liberação

Na implementação, a seguinte estrutura, uma lista doubly-linked, será usada para as listas
disponíveis:
LLINK TAG KVAL RLINK
TAG = 0 se o bloco estiver livre
1 se o bloco estiver liberado
KVAL = k se o bloco tem tamanho 2k

Para inicializar o memory pool para o método buddy-system, assumimos que seu tamanho é 2m. É
necessário manter m+1 listas. Os ponteiros avail(0:m) para as listas são armazenados em um
array de tamanho m+1.

5.5. Fragmentação interna e externa na DMA

Após as séries de reserva e divisão, blocos de tamanho muito pequeno para satisfazer qualquer
pedido irão sobrar na lista disponível. Sendo muito pequenos, eles terão pouca chance de serem
reservados, e, eventualmente serão dispersados na lista disponível. Isto irá resultar em buscas
muito longas. Além disso, mesmo que a soma dos tamanhos resulte em um valor que possa
satisfazer um pedido, eles não poderão ser utilizados se estiverem dispersos no memory pool. Isto
é o que chamamos de external fragmentation. Resolvemos este problema com métodos
sequential-fit usando minsize, onde um bloco inteiro é reservado se o que sobrar na alocação for
menor que o valor especificado. Durante a liberação, os blocos são dirigidos à uma mescla livre com
blocos adjacentes.
A abordagem de usar minsize em método sequential-fit ou arredondar os pedidos para 2log2n no
método binary buddy-system, ligações para 'overallocation' de espaço. Esta alocação de espaço
maior que a necessidade para tarefas é o que chamamos de internal fragmentation.

Estruturas de Dados 32
JEDITM

6. Exercícios
1. Mostre a representação de ligação circular do polinômio

P(x,y,z) = 2x2y2z2 + x5z4 + 10x4y2z3 + 12x4y5z6 + 5y2z + 3y + 21z2

2. Mostre a representação da lista dos seguintes polinômios e dê o resultado quando


Polynomial.add(P, Q) é executado:

P(x, y, z) = 5xyz2 - 12X2y3 + 7xy


Q(x, y, z) = 13xy - 10xyz2 + 9y33z2

3. Usando métodos first fit, best-fit e worst-fit aloque 15k, 20k, 8k, 5k, e 10k no memory pool
ilustrado abaixo, tendo minsize = 2:

4. Usando método binary buddy-system e dado um memory pool vazio de tamanho 128K, reserve
espaço para os seguintes pedidos:

Tarefa Pedido
A 30K
B 21K
C 13K
D 7K
E 14K

Mostre o estado do memory pool após cada allocation. Não é necessário mostrar as listas
disponíveis. Libere C, E e D nesta ordem. Execute a mescla se necessário. Mostre como os buddies
são obtidos

Estruturas de Dados 33
JEDITM

6.1. Exercícios para Programar

1. Crie uma interface Java contendo as operações insert, delete, isEmpty, size, update, append two
lists e search.

2. Escreva uma definição de classe Java que implemente a interface criada no exercício 1 utilizando
uma lista linear doubly-linked.

3. Escreva uma interface Java que faça uso de um node para criar uma lista generalizada.

4. Pela implementação da interface criada no exercício 1, crie uma classe para:

a) uma lista singly-linked

b) uma circular-list

c) uma lista doubly-linked

Estruturas de Dados 34
Módulo 3
Estruturas de Dados

Lição 8
Tabelas

Versão 1.0 - Mai/2007


JEDITM

1. Objetivos
Uma das operações mais comuns no processo de solução de problemas é a busca. Esta refere-se ao
problema de encontrar dados que estão em algum lugar da memória do computador. Algumas
informações identificadas como dados desejados são alimentados para mostrar resultados desejados.
Tabelas são mais comuns em estruturas de armazenamento de dados para buscas.
Ao final desta lição, o estudante será capaz de:
• Discutir os conceitos básicos e as definições sobre tabelas: chaves, operações e
implementação
• Explicar as organizações de tabelas – ordenadas e não ordenadas
• Executar buscas usando uma tabela sequencial, indexação sequencial, binária e busca por
Fibonacci

Estruturas de Dados 4
JEDITM

2. Definições e Conceitos Correlatos


Uma tabela é definida como um grupo de elementos, cada um chamado de registro. Cada registro
tem uma única chave associada com o seu registro distinto a ser utilizado.
Chave Dado
K0 X0
K1 X1
… …
Ki Xi
… …
Kn-1 Xn-1

Na tabela acima, n registros estão armazenados. Ki é a chave da posição i, enquanto Xi é associado


ao dado. A notação usada para um registro é (Ki, Xi).
A classe definição utilizada para a tabela em Java é
class Table {
int key[];
int data[];
int size;

// Cria uma tabela vazia


public Table() {
}

// Cria uma tabela de tamanho s


public Table(int s) {
size = s;
key = new int[size];
data = new int[size];
}
}

2.1. Tipos de Chaves


Se uma chave é contida dentro de um registro e este é relativo ao início do registro específico, esta é
conhecida como interna ou chave embutida. Se a chave esta contida em uma tabela separada
como ponteiros associando-a aos dados, a chave é classificada como uma chave externa.

2.2. Operações
Do lado da busca, muitas outras operações podem ser feitas numa tabela. A seguir uma lista das
operações possíveis:

• Busca por registro em que Ki = K, onde K é dado pelo usuário


• Inserção
• Deleção
• Busca do registro com chave menor (mais larga)
• Dada uma chave Ki, encontrar o registro com a próxima chave mais larga (menor)
• E outras…

Estruturas de Dados 5
JEDITM

2.3. Implementação

Uma tabela pode ser implementada usando alocação sequencial, alocação por link ou uma
combinação de ambas. Na Implementação da árvore ADT, existem diversos fatores a considerar:

• Tamanho de espaço de chavo Uk, isto é, o número de chaves possíveis


• Natureza da tabela: dinâmica ou estática
• Tipo e misto de operações realizadas na tabela

Se o espaço de chave é fixo, por exemplo m, não tão grande, então a tabela pode simplesmente ser
implementada como um array de m células. Com isto toda chave no conjunto é associada a um
campo na tabela. Se a chave é a mesma que o índice do array, ela é conhecida como tabela de
endereço direto.

Fatores de Implementação

Ao implementar uma tabela de endereçamento direto, as seguintes coisas devem ser consideradas:

• Desde que os índices identifiquem registros unicamente, não é necessário armazenar a chave ki
explicitamente.
• Os dados podem ser armazenados em qualquer lugar. Se não há espaço bastante para os dados Xi
com a chave Ki, utiliza-se uma estrutura externa à tabela, um ponteiro para o dado atual é então
armazenado como Xi. Neste caso, a tabela serve como um índice para o dado atual.
• É necessário para indicar células em desuso correspondentes a chaves em desuso.

Vantagens

Com as tabelas de endereços diretos, a busca é eliminada pois a célula X i que contém o dado ou um
ponteiro para o dado é apontado para a chave Ki. Da mesma forma, operações de inserção e deleção
são relativamente diretas.

Estruturas de Dados 6
JEDITM

3. Tabelas e Busca
Um algoritmo de busca aceita um argumento e tenta encontrar um registro ao qual a chave é igual
à especificada. Se a busca for executada com sucesso, um ponteiro é retornado. Recuperação
ocorre quando a busca é realizada com sucesso. Esta seção discute as maneiras de organizar uma
tabela, bem como as operações de busca nas diferentes organizações de tabelas.

3.1. Organização de Tabela


Há dois modos genéricos para organizar uma tabela: ordenado e desordenado. Em uma tabela
ordenada, os elementos são sorteados baseados em suas chaves. A referência ao primeiro elemento,
ao segundo elemento, e assim sucessivamente torna-se possível. Em uma tabela desordenada, não
existem relações presumidas entre os registros e suas chaves associadas.

3.2. Busca Sequencial em uma Tabela Desordenada


Buscas sequenciais lêem cada registro seguidamente do início até que o registro ou registros
procurados sejam encontrados. Isto é aplicável a uma tabela que é organizada também como um
array ou como uma lista linkada. Esta busca é também conhecida como busca linear.
CHAVE DADO
1 K0 X0
2 K1 X1
… … …
i Ki Xi
… … …
n Kn Xn

O algoritmo

Dado: Uma tabela de registros R0, R1, ..., Rn-1 com chaves K0, K1, ... Kn-1 respectivamente, onde n ≥
0. Procurar por um valor K:

1. Inicialize: faça i = 0
2. Compare: se K = Ki, pare – busca com sucesso
3. Avance: Incremente i por 1
4. Fim do arquivo?: se i < n, vá para o passo 2. então pare: Busca sem sucesso

Eis uma implementação de busca sequencial:

class Search {
final static int notFound = -1;
public int sequentialSearch(int k, int key[]) {
for (int i=0; i<key.length; i++)
if (k == key[i])
return i; // busca com sucesso
return -1; // busca sem sucesso
}
}

A busca sequencial realiza n comparações no pior caso, com uma complexidade de tempo O(n). Este
algoritmo trabalha bem quando a tabela é relativamente pequena ou é mal percorrida. A vantagem
sobre este algoritmo é que ele trabalha uniformemente se a tabela está desordenada.

Estruturas de Dados 7
JEDITM

3.3. Buscando em uma Tabela Ordenada


Existem três métodos de busca em uma tabela ordenada: busca sequencial indexada, busca binária
e busca de Fibonacci.

Busca Sequencial Indexada

Na busca sequencial indexada, uma tabela auxiliar, chamada índice, aponta para a tabela ordenada.
As seguintes são características do algoritmo de busca sequencial indexada:
• Cada elemento no índice consiste de uma chave e um ponteiro para o registro no arquivo
que corresponde a Kindex
• Elementos no índice devem ser ordenados baseados na chave
• O arquivo de dados atual deve ou não ser ordenado

A figura seguinte mostra um exemplo:

ID No Ponteiro ID No Nome Data Curso


Nascimento
1 12345 45678 Andres Agor 23/01/87 BSCS
2 23456 56789 Juan Ramos 14/10/85 BSIE
3 34567 23456 Maria dela Cruz 07/12/86 BSCS
4 45678 78901 Mayumi Antonio 18/09/85 BSCE
5 56789 34567 Jose Santiago 17/06/86 BS Biology
6 67890 12345 Bituin Abad 21/04/84 BSBA
7 78901 67890 Frisco Aquino 22/08/87 BSME

Com este algoritmo, o tempo de busca para um item particular é reduzido. Da mesma forma, um
índice poderá ser usado para apontar para uma tabela ordenada implementada como um array ou
com uma lista linkada. A última implementação implica em grande sobrecarga de espaço para
ponteiros mas inserções e deleções podem ser realizadas imediatamente.

Busca Binária

Busca binária começa com um intervalo ocupando a tabela inteira, este é o valor médio. Se o valor
procurado é menor que o item no meio do intervalo, o intervalo é encurtado para menos que a
metade. Senão, é encurtado para mais que a metade. Este processo de redução do tamanho da
busca pela metade é repetidamente realizado até que o valor seja encontrado ou o intervalo fique
vazio. O algoritmo para a busca binária faz uso das seguintes relações na busca pela chave K:
• K = Ki : pare, o registro desejado foi encontrado
• K < Ki : procura a menos metade – registros com as chaves K1 to Ki-1
• K > Ki : procura a maior metade – registros com as chaves Ki+1 to Kn

Onde I é inicialmente o valor médio do índice.

O Algoritmo
// Retorna o índice da chave k se encontrado, senão -1
public int binarySearch(int k, Table t) {
int lower = 0;

Estruturas de Dados 8
JEDITM

int upper = t.size - 1;


int middle;
while (lower < upper) {
// assume o médio
middle = (int) Math.floor((lower + upper) / 2);
if (k == t.key[middle])
return middle; // busca com sucesso
else if (k > t.key[middle])
lower = middle + 1; // menor metade descartada
else
upper = upper – 1; // maior metade descartada
}
return notFound; // busca sem sucesso
}

Por exemplo, busca pela chave k = 34567


0 1 2 3 4 5 6

12345 23456 34567 45678 56789 67890 78901

menor = 0, maior = 6, médio = 3: k < kmiddle (45678)

menor = 0, maior = 2, médio = 1: k > kmiddle (23456)

menor = 2, maior = 2, médio = 2: k = kmiddle (34567) ==> busca com sucesso

Então a área de procura é reduzida logaritmicamente, isto é, cada vez que o tamanho é reduzido, a
complexidade de tempo do algoritmo é O(log2n). O algoritmo pode se usado se a tabela usa
organização sequencial indexada. Entretanto, pode apenas ser usado com tabelas ordenadas
armazenadas como um array.

Busca Binária Multiplicativa

É similar ao algoritmo anterior, mas evita a divisão necessária para se encontrar a chave média. Para
fazer isto, é necessário reorganizar os registros na tabela:

1. Atribua chaves K1 < K2 < K3 < …< Kn aos nós de uma árvore binária completa na sequência
in order

2. Organize os registros na tabela de acordo com sequência level-order correspondente na


árvore binária

Algoritmo de Busca

A comparação começa na raiz da árvore binária , j = 0, que é a chave média na tabela original.

/*
* Recebe um conjunto de chaves representado como uma árvore binária completa
*/
public int multiplicativeBinarySearch(int k, int key[]) {
int i = 0;
while (i < key.length) {
if (k == key[i])
return i; // busca bem sucedida
else if (k < key[i])

Estruturas de Dados 9
JEDITM

i = 2 * i + 1; // vá para esquerda
else
i = 2 * i + 2; // vá para direita
}
return -1; // busca mal sucedida
}

Como a computação do elemento médio é eliminada, a busca binária multiplicativa é mais rápida que
a busca binária tradicional. Entretanto, há necessidade de uma reorganização de um dado conjunto
de chaves antes que o algoritmo possa ser aplicado.

Busca de Fibonacci

Busca de Fibonacci utiliza as propriedades simples da sequência numérica de Fibonacci definida pela
seguinte relação de recorrência:

F0 = 0
F1 = 1
Fj = Fi-2 + Fi-1 ,i≥2

Ou seja, a sequência 0, 1, 1, 2, 3, 5, 8, 13, 21, ...

Em Java,

public int fibonacci(int i) {


if (i == 0)
return 0;
else if (i == 1)
return 1;
else
return fibonacci(i-1) + fibonacci(i-2);
}

No algoritmo de busca, duas variáveis auxiliares, p e q, são usadas:


p = Fi-1
q = Fi-2

Kj é escolhida inicialmente de tal forma que j = F i, onde Fi é o maior número da série de Fibonacci
que é menor ou igual ao tamanho da tabela (n).

É uma suposição que a tabela é de seja de tamanho n=Fi+1-1.

Três estados de comparação são possíveis:

• Se K = Kj, pare: busca bem sucedida

• Se K < Kj
• Descarte todas as chaves com índices maiores que j
• Faça j = j – q
• Desloque p e q uma posição para a esquerda na sequência numérica

• Se K > Kj,
• Descarte todas as chaves com índices menores que j
• Faça j = j + q

Estruturas de Dados 10
JEDITM

• Desloque p e q duas posições para a esquerda na sequência numérica

• K < Kj e q=0 ou K > Kj e p=1: busca mal sucedida

Esse algorítimo encontra o elemento do índice 1 ao n e, como a indexação no Java começa em 0, há


uma necessidade de lidar com o caso onde k = key[0].

Por exemplo, procure pela chave k = 34567:

0 1 2 3 4 5 6

12345 23456 34567 45678 56789 67890 78901

0 1 1 2 3 5 8 13 F
01234567 i
i = 5; Fi = 5; (Suposição) table size = Fi+1 - 1 = 7

j = 5, p = 3, q = 2: k < key[j]
j = 3, p = 2, q = 1: k < key[j]
j = 2, p = 1, q = 1: k = key[j] Bem sucedido

Outro exemplo, procure pela chave = 15:

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19

i = 7; Fi = 13; (Suposição) table size = Fi+1 - 1 = 20

j = 13; p = 8, q=5: k > key[j]


j = 18; p = 3; q=2: k < key[j]
j = 15; p = 2; q=1: k = key[j] Bem sucedido

Este é o algorítimo:

public int fibonacciSearch(int k, int key[]) {


int p = 0, q = 0, j = 0;
int f = 0, i = 0;
int temp;
while (true) {
f = fibonacci(i);
if (key.length < f) {
j = f;
p = fibonacci(i-1);
q = fibonacci(i-2);
break;
}
i++;
}
if (k == key[0])
return 0;
while (true) {
if (j >= key.length)
j = key.length-1;
if (k == key[j])
return j;

Estruturas de Dados 11
JEDITM

else if (k < key[j]) {


if (q == 0)
break;
else {
j = j - q;
temp = p;
p = q;
q = temp - q;
}
} else {
if (p == 1)
break;
else {
j = j + q;
p = p - q;
q = q - p;
}
}
}
return notFound;
}

Podemos testar estas pesquisas através do seguinte método principal inserido na classe:
public static void main(String args[]) {
int size = 2000;
int key[] = new int[size];
for (int i = 0; i < size; i++)
key[i] = i;
Search s = new Search();
SimpleDateFormat sdT = new SimpleDateFormat("hh:mm:ss:SSSS");
SimpleDateFormat sdR = new SimpleDateFormat("ssSSS");

int inicial = Integer.parseInt(sdR.format(new Date()));


System.out.println("Time: " + sdT.format(new Date()));
for (int i = 0; i < size; i++)
s.sequentialSearch(i, key);
int fim = Integer.parseInt(sdR.format(new Date()));
System.out.println("Time: " + sdT.format(new Date()));
System.out.println("Result Sequential: " + (fim - inicial));

inicial = Integer.parseInt(sdR.format(new Date()));


System.out.println("Time: " + sdT.format(new Date()));
for (int i = 0; i < size; i++)
s.binarySearch(i, key);
fim = Integer.parseInt(sdR.format(new Date()));
System.out.println("Time: " + sdT.format(new Date()));
System.out.println("Result Binary: " + (fim - inicial));

inicial = Integer.parseInt(sdR.format(new Date()));


System.out.println("Time: " + sdT.format(new Date()));
for (int i = 0; i < size; i++)
s.multiplicativeBinarySearch(i, key);
fim = Integer.parseInt(sdR.format(new Date()));
System.out.println("Time: " + sdT.format(new Date()));
System.out.println("Result Multiplicative Binary: " + (fim - inicial));

inicial = Integer.parseInt(sdR.format(new Date()));

Estruturas de Dados 12
JEDITM

System.out.println("Time: " + sdT.format(new Date()));


for (int i = 0; i < size; i++)
s.fibonacciSearch(i, key);
fim = Integer.parseInt(sdR.format(new Date()));
System.out.println("Time: " + sdT.format(new Date()));
System.out.println("Result Fibonacci: " + (fim – inicial));
}

Este método criará um array de 2000 posicões, contendo o valor em cada posição. O que faremos
com ele será analizar os tempos iniciais e finais com cada método. O resultado final pode variar pois
depende do computador utilizado, eis um exemplo de saída:
Time: 05:19:43:0593
Time: 05:19:43:0609
Result Sequential: 16
Time: 05:19:43:0609
Time: 05:19:43:0734
Result Binary: 125
Time: 05:19:43:0734
Time: 05:19:43:0750
Result Multiplicative Binary: 16
Time: 05:19:43:0750
Time: 05:19:44:0187
Result Fibonacci: 437

Realize alguns testes modificando os valores e descubra qual o método de pesquisa ideal para as
suas necessidades.

Estruturas de Dados 13
JEDITM

4. Exercícios
Utilizando os métodos de pequisa abaixo,

a) Pesquisa Sequencial
b) Pesquisa Binária Multiplicativa
c) Pesquisa Binária
d) Pesquisa Fibonacci

Pesquisar por

1. A em S E A R C H I N G

2. D em W R T A D E Y S B

3. T em C O M P U T E R S

4.1. Exercício para Programar

1. Estenda a classe Java definida neste capítulo para que a mesma contenha métodos para a
inserção em um local específico e exclusão de um item com chave K. Utilize a representação
sequencial.

Estruturas de Dados 14
Módulo 3
Estruturas de Dados

Lição 9
Árvores de Pesquisa Binária

Versão 1.0 - Mai/2007


JEDITM

1. Objetivos
Outras aplicações das árvores binárias (ADT1) são a pesquisa e a classificação através da
aplicação de certas regras aos valores dos elementos armazenados em uma árvore binária.
Considere como exemplo a árvore binária apresentada.

Figura 1. Árvore Binária (Binary Tree)

O valor de cada node na árvore é maior que o valor do node à sua esquerda (se existir) e é menor
que o valor do node à sua direita (se existir). Em árvores de Pesquisa binária, esta propriedade
sempre deverá ser satisfeita.
Ao final desta lição, o estudante será capaz de:
• Discutir as propriedades de uma árvore de pesquisa binária
• Realizar as operações em árvores de pesquisa binária
• Aprimorar a pesquisa, inserção e remoção em árvores de Pesquisa binária mantendo o
balanceamento utilizando árvores AVL

1
ADT – sigla em inglês Abstract Data Type, Tipo de Dado Abstrato

Estrutura de Dados 4
JEDITM

2. Operações em Árvores de Pesquisa Binária


As operações mais comuns em uma Árvores de Pesquisa Binária (Binary Search Tree ou BST) são as
inserções de novas chaves, remoção de uma chave existente, pesquisa por uma chave e recuperação
de dados numa ordem de classificação. Nesta seção, as três primeiras operações serão
apresentadas. A quarta operação poderá ser realizada pela leitura da BST seguindo uma ordem.
Nas três operações, assume-se que a árvore de pesquisa binária não está vazia e que não armazena
valores duplicados. Além disso, é utilizada a estrutura de node BSTNode (Esquerda, Info, Direita),
como ilustrado abaixo:

Figura 2. BSTNode

Em Java,

class BSTNode {
int info;
BSTNode left, right;

public BSTNode(){
}
public BSTNode(int i) {
info = i;
}
public BSTNode(int i, BSTNode l, BSTNode r) {
info = i;
left = l;
right = r;
}
}

A árvore de pesquisa binária utilizada nesta lição tem uma cabeça de lista (list head) como ilustrado
na figura abaixo:

Figura 3. Representação da Árvore de Pesquisa Binária T2

Se a árvore de pesquisa binária está vazia, os ponteiros da esquerda e da direita do header da lista
apontam para si mesmos; senão, o ponteiro da direita apontará para a raiz da árvore. Na sequência
está a definição da classe de uma BST usando esta estrutura:
public class BST {

Estrutura de Dados 5
JEDITM

BSTNode bstHead = new BSTNode();

// Criar uma BST vazia


public BST() {
bstHead.left = bstHead;
bstHead.right = bstHead;
}
// Criar uma BST com raiz r, ponteiro para bstHead.right
public BST(BSTNode r) {
bstHead.left = bstHead;
bstHead.right = r;
}
}

2.1. Pesquisando

Na Pesquisa por um valor, digamos k, três condições são possíveis:

• k = valor que está no node (pesquisa com sucesso)


• k < valor que está no node (pesquisa pela subárvore da esquerda)
• k > valor que está no node (pesquisa pela subárvore da direita)

O mesmo processo é repetido até que uma correspondência seja encontrada ou que se atinja um
node folha. Neste caso, a pesquisa não teve sucesso.
A seguir está a implementação Java para o algoritmo acima:
// Pesquisa por k, retorna o node que contém k se encontrado
public BSTNode search(int k) {
BSTNode p = bstHead.right; // node raiz

// Se a árvore está vazia, retorna null


if (p == bstHead)
return null;

// Compare
while (true) {
if (k == p.info)
return p; // sucesso na Pesquisa
else if (k < p.info) // vá pela esquerda
if (p.left != null)
p = p.left;
else
return null; // não encontrou
else // vá pela direita
if (p.right != null)
p = p.right;
else
return null; // não encontrou
}
}

2.2. Inserção

Na inserção de um valor na árvore será realizada uma pesquisa para encontrar o local apropriado
para o novo valor. A seguir está o algoritmo:

Estrutura de Dados 6
JEDITM

1. Comece a Pesquisa no node da raiz. Declare um node p e faça-o apontar para a raiz.
2. Faça a comparação:
if (k == p.info) return false // se encontrou a chave, não permite inserção
else if (k < p.info) p = p.left // pela esquerda
else p = p.right // se (k > p.info) pela direita
3. Insira o node (p agora aponta para o novo pai do node para inserir):
newNode.info = k
newNode.left = null
newNode.right = null
if (k < p.info) p.left = newNode
else p.right = newNode

Em Java,
// Insere k na árvore de pesquisa binária
public boolean insert(int k) {
BSTNode p = bstHead.right; // node raiz
BSTNode newNode = new BSTNode();

// Se a árvore está vazia, torna o novo node raiz


if (p == bstHead) {
newNode.info = k;
bstHead.right = newNode;
return true;
}

// Procura o local certo para inserir k


while (true) {
if (k == p.info) // chave já existe
return false;
else if (k < p.info) // pela esquerda
if (p.left != null)
p = p.left;
else
break;
else if (p.right != null) // pela direita
p = p.right;
else
break;
}

// Insere a nova chave no local apropriado


newNode.info = k;
if (k < p.info)
p.left = newNode;
else
p.right = newNode;
return true;
}

2.3. Eliminação

Eliminar uma chave da árvore de pesquisa binária é um pouco mais complexo que as outras duas
operações discutidas. A operação inicia por encontrar a chave para deletar. Se não encontrar, o
algoritmo simplesmente retorna dizendo que a exclusão falhou. Se a Pesquisa retornar uma chave
encontrada, então existe necessidade de eliminar o node que contém a chave que procuramos.

Estrutura de Dados 7
JEDITM

Entretanto, excluir não é tão simples como remover um node encontrado que tenha um parente
apontando pra ele. Existe também a possibilidade que ele seja o parente de alguns outros nodes na
BST. Neste caso, existe a necessidade desses filhos serem “adotados” por outros nodes, e também
ajustar os ponteiros que apontavam para o node removido. E, no processo de atribuir apontadores, a
propriedade BST da ordem dos valores das chaves deve ser mantida.
Existem dois casos gerais para se considerar na exclusão de um node d:
1. node d é externo (folha):

Ação: Atualize o ponteiro filho do parente p:


Se d é um filho da esquerda, ajuste p.left=null
do contrário, ajuste p.right=null

Figura 4. Eliminar um node folha

2. node d é interno (Existem dois sub-casos):

Caso 1: Se d tem uma subárvore à esquerda,

1. Obtenha o node predecessor ip. Node predecessor é definido como o node mais à direita
na subárvore a esquerda do node corrente, o qual neste caso é d. Ele contém a chave
precedente se o BST é atravessado node
2. Obtenha o parente de ip, diga p_ip
3. Substitua d com ip
4. Remova ip de sua antiga localização para ajustar os apontadores:
a. Se ip não é uma folha node:
1. Ajuste o filho da direita de p_ip para apontar para o filho da esquerda de ip
2. Ajuste o filho da esquerda de ip para apontar para o filho da esquerda de d
b. Ajuste o filho da direita de ip para apontar para o filho da direita de d

Estrutura de Dados 8
JEDITM

Figura 5. Excluindo um node interno com uma subárvore à esquerda

Caso 2: Se d não tem filho a esquerda, mas possui uma subárvore à direita.

1. Obtenha o sucessor inorder, is. O sucessor Inorder é definido como o node mais à
esquerda na subárvore da direita do node atual. Ele contém a próxima chave se a APB
estiver sendo varrida em ordem direta.
2. Obtenha o pai de is, digamos p_is.
3. Substitua d por is.
4. Remova is de sua antiga locação pelo ajuste dos ponteiros:
a. Se is não é um node folha:
1. Ajuste o filho da esquerda de p_is para apontar para o filho da direita de is
2. Ajuste o filho da direita de is para apontar para o filho da direita de d
b. Ajuste o filho da esquerda de is para apontar para o filho da esquerda de d

Figura 6. Remoção de um node interno sem subárvore à esquerda

Estrutura de Dados 9
JEDITM

O seguinte código Java implementa este procedimento:

// Retorna true se a chave foi removida com sucesso


public boolean delete(int k) {
BSTNode delNode = bstHead.right; // o node raiz
boolean toLeft = false; // direção do pai
BSTNode parent = bstHead; // pai do node removido

// Pesquisa do node a remover


while (true) {
// delNode aponta para o node que será removido
if (k == delNode.info)
break;
else if (k < delNode.info) // Vá pela esquerda
if (delNode.left != null) {
toLeft = true;
parent = delNode;
delNode = delNode.left;
} else
return false; // não encontrado
else if (delNode.right != null) { // Vá pela direita
toLeft = false;
parent = delNode;
delNode = delNode.right;
} else
return false; // não encontrado
}

// Caso 1: Se delNode está na extremidade, atualiza o pai e remove o node


if ((delNode.left == null) && (delNode.right == null)) {
if (toLeft)
parent.left = null;
else
parent.right = null;
}

// Case 2.1: Se delNode é interno e tem filho a esquerda


else if (delNode.left != null) {
BSTNode inPre = delNode.left; // predecessor inorder
BSTNode inPreParent = null; // pai do predecessor inorder

// Procura pelo sucessor inorder de delNode


while (inPre.right != null) {
inPreParent = inPre;
inPre = inPre.right;
}

// Substitui delNode por inPre


if (toLeft)
parent.left = inPre;
else
parent.right = inPre;

// Remove inSuc de seu local de origem

// Se inPre não é um node folha


if (inPreParent != null) {

Estrutura de Dados 10
JEDITM

inPreParent.right = inPre.left;
inPre.left = delNode.left;
}
inPre.right = delNode.right;
}

// Case 2.2: Se delNode é interno e não tem filho a esquerda, mas a direita
else {
BSTNode inSuc = delNode.right; // successor inorder
BSTNode inSucParent = null; // pai do sucessor inorder

// Procura o sucessor inorder de delNode


while (inSuc.left != null) {
inSucParent = inSuc;
inSuc = inSuc.left;
}

// Substitui delNode por inSuc


if (toLeft)
parent.left = inSuc;
else
parent.right = inSuc;

// Remove inSuc de seu local de origem

// Se inSuc não é um node folha


if (inSucParent != null) {
inSucParent.left = inSuc.right;
inSuc.right = delNode.right;
}
inSuc.left = delNode.left;
}
delNode = null; // limpeza da memória (garbage collection)
return true; // retorna sucesso
}

2.4. Complexidade no tempo de resposta em uma BST

As três operações discutidas fazem pesquisas pelo node que contém uma chave determinada. Por
isso, concentraremos a atenção em relação a pesquisa que possui o melhor desempenho. O
algoritmo de pesquisa em uma BST gasta O(log2 n) vezes em média, quando a árvore de pesquisa
binária está balanceada, quando a altura está em O(log2 n). Entretanto, este nem sempre é o caso
num processo de pesquisa. Considere, por exemplo, o caso onde os elementos inseridos na BST
estão ordenados (ou em ordem inversa), então a BST resultante terá todo seus nodes da esquerda
(ou direita) apontando para NULL, resultando numa árvore degenerada. Neste caso, o tempo de
pesquisa deteriora para O(n), equivalendo a uma pesquisa seqüencial, como no caso da árvore
seguinte:

Figura 7. Exemplos de Árvores com pior caso de pesquisa

Estrutura de Dados 11
JEDITM

3. Árvore Binária de Pesquisa Balanceada


É o balanceamento pobre de uma Árvore Binária a faz ter uma performance de O(n). Por isso, deve-
se assegurar de que a Pesquisa gaste O(log2 n), de modo que o balanceamento possa ser mantido.
Uma árvore balanceada só será criada se metade dos registros inserida depois de um dado registro r
com chave k tenha chaves menores do que k e, similarmente, metade das chaves maiores do que k.
Isso quando não há tratamento de balanceamento durante a inserção. No entanto, em situações
reais, as chaves são inseridas em ordem aleatória. Portanto, há a necessidade de manter o
balanceamento à medida que chaves são inseridas ou apagadas.
O Balanceamento de um node é um fator muito importante na manutenção de balanceamento.
Ele é definido como a diferença de altura das subárvores de um node, i.e., a altura da subárvore à
esquerda menos a altura da subárvore à direita.

3.1. Árvore AVL

Uma das árvores de Pesquisa binárias mais comumente utilizadas é a árvore AVL. Foi criada por G.
Adel’son-Vel’skii e E. Landis, de onde advém o nome AVL. Uma árvore AVL é balanceada quando a
diferença de altura entre as subárvores de um node, para cada node da árvore, é no máximo 1.
Considere as árvores abaixo onde os nodes estão identificados com os fatores de balanceamento:

Figura 8. Árvores de Pesquisa Binária com Fatores de Balanceamento

As árvores binárias A, C e D todas têm nodes com balanceamentos na faixa [-1, 1], i.e., -1, 0 e +1.
Portanto, são árvores AVL. A árvore B tem um node com balanceamento +2, e está além da faixa,
não sendo uma árvore AVL.
Além de ter uma complexidade temporal O(log2 n) para Pesquisas, as seguintes operações terão a
mesma complexidade se a árvore AVL for usada:
• Encontrar o nº item, dado n
• Inserir um item em um lugar específico
• Excluir um item específico

3.1.1. Balanceamento da Árvore


As seguintes árvores são exemplos de árvores AVL:

Figura 9. Árvores AVL

Estrutura de Dados 12
JEDITM

As seguintes árvores são exemplos de árvores não-AVL:

Figura 10. Árvores não-AVL

Para manter o balanceamento de uma árvore AVL, as rotações devem ocorrer durante a inserção e a
exclusão. As seguintes rotações são usadas:
• Rotação simples à direita (Simple right rotation - RR) – Usada quando o novo item C esta
na árvore à esquerda do node filho B do ancestral mais próximo A com fator de
balanceamento +2

Figura 11. Rotação Simples à Direita

• Rotação simples à esquerda (Simple left rotation - LR) – Utilizada quando o novo item C
está na subárvores à direita do node filho B do ancestral mais próximo A com fator de
balanceamento -2.

Figura 12. Rotação simples para esquerda

• Rotação esquerda direita (Left right rotation - LRR) – utilizado quando o novo item C está
na subárvore da direita do filho a esquerda do ancestral mais próximo A com fator de
balanceamento +2.

Estrutura de Dados 13
JEDITM

Figura 13. Rotação Esquerda Direita

• Rotação direita esquerda (Right left rotation - RLR) – usado quando o novo item C estiver
na subárvore à esquerda do filho direita B do antecessor mais próximo A, com fator de
balanceamento -2.

Figura 14. Rotação Direita Esquerda

A inserção em uma árvore de AVL e o mesmo que na BST. Entretanto, o equilíbrio do resultado tem
que ser verificado dentro da árvore de AVL. Para introduzir um novo valor:

1. Uma folha do node é introduzido na árvore com equilíbrio 0;


2. Começando pelo node novo, uma mensagem de altura da subárvore que contém o node novo
incrementa por 1 é passada acima da árvore seguindo o trajeto até a raiz. Se a mensagem for
recebida por um node de sua subárvore à esquerda, 1 é adicionado a seu equilíbrio, caso contrário
-1 é adicionado. Se o equilíbrio resultante for +2 ou -2, a rotação tem que ser executada como
descrita.

Por exemplo, introduzir os seguintes elementos em uma árvore de AVL:


4 10 9 3 11 8 2 1 5 7 6

Estrutura de Dados 14
JEDITM

Figura 15. Inserção na árvore AVL

Estrutura de Dados 15
JEDITM

4. Exercícios
1. Insira as seguintes chaves em uma árvore AVL, baseada na ordem informada:

a) 1674258903

b) ARFGEYBXCSTIOPLV

4.1. Exercícios para programar

1. Estenda a classe BST definindo a construção de uma árvore AVL, i.e.,


class AVL extends BST{
}

Realize um override nos métodos insert e delete para implementar as rotações para a árvore AVL
para manter o balanceamento.

Estrutura de Dados 16
Módulo 3
Estruturas de Dados

Lição 10
Hash Table e Técnicas de Hashing

Versão 1.0 - Mai/2007


JEDITM

1. Objetivos
Hashing é a aplicação de uma função matemática (chamada função hash) em valores de chave que
resultam no mapeamento dos possíveis valores de chave para uma faixa menor de endereços
relativos. A função hash é algo como uma caixa trancada que tem necessidade de uma chave para
se obter a saída que, neste caso, é o endereço onde a chave está armazenada:

chave ====> Função Hash H(k) ===> endereço

No hashing não existe conexão óbvia entre a chave e o endereço gerado, pois a função “seleciona
randomicamente” um endereço para um valor específico de chave, sem se preocupar com a
seqüência física dos registros no arquivo. Por essa razão, hashing também é conhecido como
esquema de randomização.
Ao fim da lição, o estudante deve ser capaz de:
• Definir hashing e explicar como o hashing funciona
• Implementar técnicas simples de hashing
• Discutir como colisões são evitadas/minimizadas através da utilização de técnicas de
resolução de colisão
• Explicar os conceitos por trás de arquivos dinâmicos e hashing

Estruturas de Dados 4
JEDITM

2. Técnicas Simples de Hash


Duas ou mais chaves de entrada, sejam k1 e k2, quando aplicadas em uma função hash, podem
resultar no mesmo endereço, um acidente conhecido como colisão. A colisão pode ser reduzida
alocando-se mais espaço de arquivo que o mínimo necessário para armazenar a quantidade de
chaves. Entretanto, essa abordagem leva a desperdício de espaço. Existem diversas formas de lidar
com colisões, que serão discutidas mais adiante.
Em hashing, existe a necessidade de se escolher uma boa função hash e, conseqüentemente,
selecionar um método para resolver, se não eliminar, as colisões. Uma boa função hash executa
cálculos eficientes, com complexidade de tempo O(1), e produz pouca (ou nenhuma) colisão.
Existem muitas técnicas de hash disponíveis, mas discutiremos apenas duas – divisão por número
primo e desdobramento.

2.1. Método de Divisão por Números Primos

Este é um dos métodos mais comuns de randomização. Se o valor chave é dividido por um número
n, a faixa de endereços gerados vai variar entre 0 e n-1.

A fórmula é:

h(k) = k mod n

onde k é a chave, um número inteiro, e n é um número primo.


Se n é o número total de locações relativas no arquivo, este método pode ser usado para mapear as
chaves em n locações de registro. n deve ser escolhido de forma a reduzir o número de colisões. Se
n é par, o resultado da função hash é um número par, se n é impar, o resultado é ímpar. A divisão
por número primo não resultará em muitas colisões. Por isso é a melhor escolha para o divisor nesse
método. Poderíamos escolher um número primo que seja próximo ao número de registros no
arquivo. Entretanto, este método pode ser usado mesmo se n não for primo, mas prepare-se para
lidar com mais colisões.
Por exemplo, considere n = 13

Valor da Chave k Valor Hash h(k) Valor da Chave k Valor Hash h(k)
125 8 234 0
845 0 431 2
444 2 947 11
256 9 981 6
345 7 792 12
745 4 459 4
902 5 725 10
569 10 652 2
254 7 421 5
382 5 458 3

Para implementar este hashing em Java, basta usar:

Estruturas de Dados 5
JEDITM

public int hash(int k, int n) {


return (k % n);
}

2.2. Desdobramento

Outra técnica simples de hashing é o desdobramento. Nessa técnica, o valor chave é dividido em
duas ou mais partes e então passam por uma operação de adição, AND ou XOR para se obter o
endereço hash. Se o resultado obtido tiver mais dígitos que o maior endereço no arquivo, os dígitos
excedentes de maior ordem são eliminados.
Existem diferentes formas de desdobramento. O valor chave pode ser desdobrado ao meio. Isso é
ideal para valores de chave relativamente pequenos já que eles poderiam facilmente caber nos
endereços disponíveis. Se, por qualquer motivo, a chave for desdobrada de forma desigual, a parte
da esquerda deve ser maior que a parte da direita. A chave também pode ser desdobrada em
terços. Isso é ideal para valores de chave um pouco maiores. Também podemos ter
desdobramento por dígitos alternados. Os dígitos das posições ímpares formam uma parte e os
dígitos das posições pares formam outra. Desdobrar ao meio e em terços pode ser feito ainda de
duas formas. Uma é o desdobramento pelos extremos onde algumas partes da chave desdobrada
são invertidas (imitando o jeito em que dobramos papel) e então somadas. Por último, há o
desdobramento por substituição onde nenhuma parte desdobrada das chaves é invertida.
A seguir, alguns exemplos de desdobramento por substituição:
1. Dígitos pares, desdobrando ao meio
125758 => 125+758 => 883

2. Desdobrando em terços
125758 => 12+57+58 => 127

3. Dígitos ímpares, desdobrando ao meio


7453212 => 7453+212 => 7665

4. Dígitos desiguais, desdobrando em terços


74532123 => 745+32+123 => 900

5. Usando XOR, desdobrando ao meio


100101110 => 10010 XOR 1110 => 11100

6. Alternando dígitos
125758 => 155+278 => 433

A seguir, alguns exemplos de desdobramento pelos extremos:


1. Dígitos pares, desdobrando ao meio
125758 => 125+857 => 982

2. Desdobrando em terços
125758 => 21+57+85 => 163

3. Dígitos ímpares, desdobrando ao meio


7453212 => 7453+212 => 7665

4. Dígitos desiguais, desdobrando em terços


74532123 => 547+32+321 => 900

Estruturas de Dados 6
JEDITM

5. Usando XOR, desdobrando ao meio


100100110 => 10010 XOR 0110 => 10100

6. Alternando dígitos
125758 => 155+872 => 1027

Este método é útil para converter chaves com grande número de dígitos em chaves com menos
dígitos de forma que o endereço caiba em uma palavra de memória. É também mais fácil de
armazenar, pois as chaves não precisam de muito espaço para serem guardadas.

Estruturas de Dados 7
JEDITM

3. Técnicas de Resolução de Colisões


Escolher um bom algoritmo de hashing baseado em quão poucas colisões espera-se que ocorram é a
primeira etapa para se evitar colisões. Entretanto, isso irá apenas minimizar, e não erradicar o
problema. Para evitar colisões, poderíamos:
• espalhar os registros: por exemplo, encontrar um algoritmo de hashing que distribua os
registros de maneira uniforme entre os endereços disponíveis. Entretanto é difícil achar um
algoritmo de hashing que distribua os registros dessa forma.
• usar mais memória: se temos muitos endereços de memória para distribuir registros, é mais
fácil de se encontrar um algoritmo de hashing do que se tivermos aproximadamente o mesmo
número de endereços e registros. Uma vantagem é que os registros ficarão distribuídos
uniformemente, conseqüentemente diminuindo as colisões. Entretanto, esse método
desperdiça espaço.
• utilizar buckets: por exemplo, coloque mais de um registro no mesmo endereço.

Existem várias técnicas de resolução de colisões e nessa seção iremos cobrir encadeamento,
utilização de buckets e endereçamento aberto.

3.1. Encadeamento

No encadeamento, m listas ligadas são mantidas, uma para cada possível endereço na tabela hash.
Utilizando encadeamento para resolver colisão no armazenamento do exemplo de hashing do Método
de Divisão por Número Primo:

Valor Chave k Valor Hash h(k) Valor Chave k Valor Hash h(k)
125 8 234 0
845 0 431 2
444 2 947 11
256 9 981 6
345 7 792 12
745 4 459 4
902 5 725 10
569 10 652 2
254 7 421 5
382 5 458 3

Temos a seguinte tabela hash:

CHAVE LINK CHAVE LINK


0 Λ 0 845 234 Λ
1 Λ 1 Λ
2 Λ 2 444 431 652 Λ
3 Λ 3 458 Λ
4 Λ 4 745 459 Λ
5 Λ 5 902 382 421 Λ

Estruturas de Dados 8
JEDITM

CHAVE LINK CHAVE LINK


6 Λ 6 981 Λ
7 Λ 7 345 254 Λ
8 Λ 8 125 Λ
9 Λ 9 256 Λ
10 Λ 10 569 725 Λ
11 Λ 11 947 Λ
12 Λ 12 792 Λ
Tabela Inicial Depois de Inserções

As chaves 845 e 234 têm hash para o endereço 0, então estão conectadas ao endereço. É o mesmo
caso para os endereços 2, 4, 5, 7 e 10, enquanto que os demais endereços não têm colisão. O
método de encadeamento resolve a colisão fornecendo nodes de conexão adicionais para cada um
dos valores.

3.2. Utilização de Buckets

Assim como no encadeamento, este método divide a tabela hash em m grupos de registros onde
cada grupo contém exatamente b registros, sendo cada endereço considerado um bucket.
Por exemplo:

CHAVE1 CHAVE2 CHAVE3


0 845 234
1

2 444 431 652


3 458
4 745 459
5 902 382 421
6 981
7 345 254
8 125
9 256
10 569 725
11 947
12 792

A colisão é redefinida nessa abordagem. Ela acontece quando um bucket estoura – ou seja, quando
se tenta uma inserção em um bucket cheio. Por esse motivo, existe uma redução significativa no
número de colisões. Entretanto, este método desperdiça espaço e não está livre de ficar cheio
futuramente, caso em que uma regra para estouro deve ser criada. No exemplo acima, existem três
vagas em cada endereço. Por ter tamanho estático, surgirão problemas quando mais de três valores
tiverem hash para um mesmo endereço.

Estruturas de Dados 9
JEDITM

3.3. Endereçamento Aberto (Por Verificação)

No endereçamento aberto, quando o endereço produzido por uma função hash h(k) não tem mais
espaço para inserções, um slot diferente de h(k) é alocado na tabela hash. Este processo é chamado
de verificação. Neste slot vazio, o novo registro que colidiu com o anterior, conhecido como chave
de colisão, pode ser seguramente alocado. Neste método, introduzimos a permutação da tabela de
endereços, digamos β0, β1, ... βm-1, esta permutação é chamada seqüência de verificação.
Nesta lição, iremos cobrir duas técnicas: verificação linear e hashing duplo.

3.3.1. Verificação Linear

Verificação linear é uma das técnicas mais simples para lidar com colisões em que o arquivo é lido ou
verificado seqüencialmente, como um arquivo circular, e a chave de colisão é armazenada no espaço
disponível mais próximo ao endereço. Isso é usado nos sistemas em que o esquema “primeiro a
chegar, primeiro a sair” é utilizado. Um exemplo é o sistema de reservas de uma empresa de linhas
aéreas em que os lugares para os passageiros na lista de espera são oferecidos quando os
passageiros aos quais os lugares estavam reservados não aparecem. Sempre que uma colisão ocorre
em um certo endereço, os endereços seguintes são testados, ou seja, procurados seqüencialmente
até que um vago seja encontrado. A chave utiliza então esse endereço. Um array deve ser
considerado circular, de forma que quando a última localização é alcançada, a pesquisa continua na
primeira posição do array.
Neste método, é possível que o arquivo inteiro seja pesquisado, a partir da posição i+1, e as chaves
de colisão sejam distribuídas por todo o arquivo. Se uma chave tem hash para a posição i, que está
ocupada, as posições i+1, …,n são pesquisadas procurando-se por uma que esteja vaga.
Os slots em uma tabela hash podem conter somente uma chave ou também podem conter um
bucket. Nesse exemplo, buckets de capacidade 2 são usados para armazenar as seguintes chaves:

Valor Chave k Valor Hash h(k) Valor Chave k Valor Hash h(k)
125 8 234 0
845 0 431 2
444 2 947 11
256 9 981 6
345 7 792 12
745 4 459 4
902 5 725 10
569 10 652 2
254 7 421 5
382 5 458 3

resultando na seguinte tabela hash:

CHAVE1 CHAVE2
0 845 234
1

2 444 431
3 652 458

Estruturas de Dados 10
JEDITM

CHAVE1 CHAVE2
4 745 459
5 902 382
6 981 421
7 345 254
8 125
9 256
10 569 725
11 947
12 792

Nessa técnica, a chave 652 indicou endereço de hash 2, mas já está cheio. Verificar o próximo
endereço disponível nos leva ao endereço 3, onde a chave é armazenada. Prosseguindo no processo
de inserção, a chave 458 tem endereço de hash 3 e é armazenada no segundo slot do endereço.
Com a chave 421 que tem hash para o endereço cheio 5, o espaço disponível seguinte é o endereço
6, onde a chave é armazenada.
Esta abordagem resolve o problema de estouro no endereçamento do bucket. Além disso, procurar
por espaço disponível faz com que as chaves excedentes sejam armazenadas próximas de seus
endereços originais, na maioria dos casos. Entretanto, este método sofre com o problema de
deslocamento onde as chaves que por direito detém um endereço podem ser deslocadas por outras
chaves que simplesmente encontraram aquele endereço vago. Além disso, verificar uma tabela hash
cheia levará um tempo de complexidade de O(n).

3.3.2. Hashing Duplo

O hashing duplo faz uso de uma segunda função hash, digamos h2(k), sempre que houver colisão. O
registro é inicialmente indicado para um endereço de hash utilizando-se a primeira função. Se o
endereço de hash não estiver disponível, é aplicada uma segunda função hash e acrescentada ao
primeiro valor hash, e a chave de colisão é levada ao novo endereço hash se houver espaço
disponível. Se não houver, o processo é repetido. A seguir o algoritmo:

1. Utilize a função hash primária h1(k) para determinar a posição i onde colocar o valor.

2. Se houver colisão, utilize a função de rehash rh(i, k) sucessivamente até que um slot vago
seja encontrado:

rh(i, k) = ( i + h2(k)) mod m

onde m é a quantidade de endereços

Utilizando a segunda função hash h2(k)= k mod 11 no armazenamento das seguintes chaves:

Valor Chave k Valor Hash h(k) Valor Chave k Valor Hash h(k)
125 8 234 0
845 0 431 2
444 2 947 11
256 9 981 6

Estruturas de Dados 11
JEDITM

Valor Chave k Valor Hash h(k) Valor Chave k Valor Hash h(k)
345 7 792 12
745 4 459 4
902 5 725 10
569 10 652 2
254 7 421 5
382 5 458 3

Para as chaves 125, 845, 444, 256, 345, 745, 902, 569, 254, 382, 234, 431, 947, 981, 792, 459 e
725, o armazenamento é direto – não houve estouro.

CHAVE1 CHAVE2
0 845 234
1

2 444 431
3

4 745 459
5 902 382
6 981
7 345 254
8 125
9 256
10 569 725
11 947
12 792

Inserir 652 na tabela hash resulta em estouro no endereço 2, então fazemos rehash:

h2(652) = 652 mod 11 = 3


rh(2, 652) = (2 + 3) mod 13 = 5,

mas o endereço 5 já está cheio, então aplicamos rehash novamente:

rh(5, 652) = (5 + 3) mod 13 = 8, tem espaço – então armazena aqui.

CHAVE1 CHAVE2
0 845 234
1

2 444 431
3

4 745 459

Estruturas de Dados 12
JEDITM

CHAVE1 CHAVE2
5 902 382
6 981
7 345 254
8 125 652
9 256
10 569 725
11 947
12 792

Fazendo hash para 421 também resulta em colisão, então fazemos o rehash:
h2(421) = 421 mod 11 = 3

rh(5, 421) = (5 + 3) mod 13 = 8,

mas o endereço 8 já está cheio, então aplicamos rehash novamente:


rh(8, 421) = (8 + 3) mod 13 = 11, tem espaço – então armazenamos aqui.

CHAVE1 CHAVE2
0 845 234
1

2 444 431
3 458
4 745 459
5 902 382
6 981
7 345 254
8 125 652
9 256
10 569 725
11 947 421
12 792

Por último, a chave 458 é armazenada no endereço 3. Este método é uma evolução da verificação
linear em termos de performance, ou seja, o novo endereço é computado utilizando-se outra função
hash ao invés de percorrer a tabela hash seqüencialmente por um espaço vago. Entretanto, assim
como a pesquisa linear, este método também sofre com o problema de deslocamento.

Estruturas de Dados 13
JEDITM

4. Arquivos Dinâmicos & Hashing


As técnicas de hashing discutidas até agora fazem uso de espaço de endereçamento fixo (tabela
hash com n endereços). Com espaço de endereçamento estático, o estouro de armazenamento é
possível. Se os dados a serem armazenados tiverem natureza dinâmica, ou seja, muitas exclusões e
inclusões possíveis, não é recomendado usar tabelas hash estáticas. É aqui que tabelas hash
dinâmicas tornam-se úteis. Nessa seção, discutiremos dois métodos: hashing extensível e hashing
dinâmico.

4.1. Hashing Extensível

Hashing extensível utiliza uma estrutura auto-ajustável com tamanho de bucket ilimitado. Este
método de hashing é construído sobre o conceito de árvore.

4.1.1. Árvore

A idéia básica é construir um índice baseado na representação numérica binária do valor hash.
Utilizamos um conjunto mínimo de dígitos binários e acrescentamos mais dígitos se necessário. Por
exemplo, considere que cada chave em hash é uma seqüência de três dígitos, e no início, precisamos
de apenas três buckets:

BUCKET ENDEREÇO
A ,00
B 10
C 11

Figura 1. Árvore e Hash Table

A figura na esquerda mostra a árvore. A figura na direita mostra a tabela hash e os ponteiros para os
buckets reais. Para o bucket A, já que somente um dígito é considerado, ambos endereços 00 e 01
apontam para ele. O bucket tem a estrutura (PROFUNDIDADE, DADO) onde PROFUNDIDADE é a
quantidade de dígitos considerados no endereçamento ou a quantidade de dígitos considerados na
árvore. No exemplo, a profundidade do bucket A é 1, enquanto que para os buckets B e C, é 2.
Quando A estoura, um novo bucket é criado, digamos D. Isso irá criar mais espaço para inserção.

Estruturas de Dados 14
JEDITM

Por esse motivo o endereço A é expandido para dois endereços:

Figura 2. Hash Table

Quando B estoura, um novo bucket E é criado e o endereço de B é expandido para três dígitos:
Normalmente oito buckets são necessários para o conjunto de todas as chaves com três dígitos,
mas, nesta técnica, utilizamos o conjunto mínimo de buckets necessário. Note que quando o espaço
de endereçamento é aumentado, seu tamanho original é dobrado.
A exclusão de chaves leva a buckets vazios e resulta na necessidade de colapsar buckets vizinhos.
Dois buckets são vizinhos se eles estão na mesma profundidade e seus bits iniciais são os mesmos.
Por exemplo, na figura anterior, os buckets A e D têm a mesma profundidade e seus bits iniciais são
os mesmos. O caso é similar com os buckets B e E, pois ambos têm a mesma profundidade e seus
dois bits iniciais são iguais.

4.2. Hashing Dinâmico

O hashing dinâmico é muito semelhante ao hashing extensível, pois também aumenta em tamanho à
medida que novos registros são acrescentados. Eles diferem somente na forma em que o tamanho
cresce dinamicamente. Se no hashing extensível a tabela hash tem seu tamanho duplicado cada vez
que é expandida, no hashing dinâmico, o crescimento é lento e incremental. O hashing dinâmico
inicia com um tamanho de endereçamento fixo semelhante ao hashing estático, e então cresce
conforme necessário. Normalmente, duas funções hash são usadas. A primeira é para verificar se o
bucket está no espaço de endereçamento original e a segunda é usada caso não esteja. A segunda
função hash é usada para guiar a pesquisa através da árvore.

Estruturas de Dados 15
JEDITM

Figura 3. Exemplo de Hashing Dinâmico

O espaço de endereçamento original consiste de quatro endereços. Um estouro no endereço 4


resulta na sua expansão para usar dois dígitos 40 e 41. Existe também um estouro no endereço 2,
então ele é expandido para 20 e 21. Um estouro no endereço 41 resultou na utilização de três dígitos
410 e 411.

Estruturas de Dados 16
JEDITM

5. Exercícios de Fixação
1. Considere as chaves a seguir:

12345 21453 22414 25411 45324 13541 21534 54231 41254 25411

a) Qual deve ser o valor de n se o método de hashing usado for Divisão por Número Primo?

b) Com o n encontrado em (a), faça o hash das chaves em uma tabela hash com tamanho n e
endereçamento de 0 a n-1. No caso de colisão, utilize verificação linear.

c) Utilizando desdobramento ao meio pelos extremos que resulte em endereços de três dígitos,
quais são os valores hash?

2. Usando Hashing Extensível, armazene as chaves abaixo em uma tabela hash na ordem
apresentada. Utilize primeiro os dígitos mais à esquerda. Utilize mais dígitos quando necessário.
Inicie a tabela hash com tamanho 2. Apresente a tabela a cada nova extensão.

Chave Valor Hash Equivalente Binário


Banana 2 010
Melon 5 101
Raspberry 1 001
Kiwi 6 110
Orange 7 111
Apple 0 000

5.1. Exercícios para programar

1. Crie um programa Java que implemente o desdobramento ao meio por substituição. Este
programa recebe os parâmetros k, dk e da, onde k é a chave para fazer hash, dk é o número de
dígitos na chave e da é o número de dígitos no endereço. O programa deve retornar o endereço
com da dígitos.
2. Escreva um programa Java completo que usa o método da divisão como método de hash e
verificação linear como técnica de resolução de colisão.

Estruturas de Dados 17
JEDITM

Parceiros que tornaram JEDITM possível

Instituto CTS
Patrocinador do DFJUG.

Sun Microsystems
Fornecimento de servidor de dados para o armazenamento dos vídeo-aulas.

Java Research and Development Center da Universidade das Filipinas


Criador da Iniciativa JEDITM.

DFJUG
Detentor dos direitos do JEDITM nos países de língua portuguesa.

Banco do Brasil
Disponibilização de seus telecentros para abrigar e difundir a Iniciativa JEDITM.

Politec
Suporte e apoio financeiro e logístico a todo o processo.

Borland
Apoio internacional para que possamos alcançar os outros países de língua
portuguesa.

Instituto Gaudium/CNBB
Fornecimento da sua infra-estrutura de hardware de seus servidores para que os
milhares de alunos possam acessar o material do curso simultaneamente.

Estruturas de Dados 18

También podría gustarte