Unidade I: 0
Unidade: Tabela Hash e Grafos 1 Tabelas Hash Um dos maiores problemas encontrados quando se estuda a alocação de estruturas de dados é o tempo de resposta da pesquisa de uma chave em um conjunto de elementos. Quando pensamos em pesquisa de uma chave, na maioria das vezes optamos por basear na comparação de um elemento a ser pesquisado (k) com todas as chaves da estrutura (ou somente algumas, como nas árvores). É interessante também se analisar uma alternativa, que é baseada no cálculo da posição do registro através de funções aritméticas f(k) que tem como argumento o valor da chave k, que permite que o tempo de pesquisa seja independente no número de elementos na estrutura. A tabela Hash, também conhecida como Tabela de Dispersão ou o termo em inglês Hash Table, é uma estrutura de dados não linear. Ao contrário das pilhas, filas e listas ordenadas, onde os elementos estão dispostos um após o outro, as tabelas Hash são estruturas de dados não lineares, assim como as árvores. Os elementos são inseridos, removidos ou pesquisados em uma posição determinada por uma função de hashing (ou função de dispersão) que, conforme uma chave de entrada, determina qual a posição que o elemento deve seguir. Uma das vantagens da utilização de tabelas Hash é o tempo constante para inserções, remoções e pesquisas visto que o elemento é acessado diretamente no endereço onde deve estar e não há a necessidade de varrer a estrutura. Porém, essa melhora na busca é contrabalanceada com um aumento de memória gasta. A função de Hashing pode, por vezes, gerar o mesmo endereço para chaves de entrada diferentes. Esse processo se chama colisão. Uma função de hashing perfeita não deve gerar colisões, mas na maioria dos casos, as colisões devem ser tratadas. O número gerado por uma função de hashing é o hash code. 1
Exemplo de Hashing Suponha que 1. O espaço de chaves são os números inteiros de quatro dígitos, e 2. Deseja-se traduzi-los no conjunto {0, 1,..., 7}. (8 posições disponíveis) Uma hash function poderia ser: f(x) = (5 * x) mod 8. Se o conjunto de dados for constituído pelos anos: 1055, 1492, 1776, 1812, 1918 e 1945, a hash function f (x) = (5 * x) mod 8 gerará o seguinte mapeamento: Ex: f (1055) = (5 1055) mod 8 = 5275 mod 8 = 3 Índice 0 1 2 3 4 5 6 7 Chave 1776 1055 1492 1812 1945 1918 Ex: f (2002) = (5 2002) mod 8 = 10010 mod 8 = 2 1.1 - Colisões No exemplo anterior dizemos que entre as chaves 1492 e 1812 ocorreu uma colisão, isto é estas duas chaves geraram o mesmo hash code, ou seja, foram mapeadas no mesmo índice. O desejável seria que a função fosse injetiva, de forma a evitar colisões, mas como isso é muito difícil, há vários esquemas para trabalhar a ocorrência de colisões. Há duas grandes classes de abordagens: 1. Closed Address Hashing (endereçamento fechado) 2. Open Address Hashing (endereçamento aberto) Closed Address Hashing (endereçamento fechado) Closed Address Hashing ou hashing encadeado é a forma mais simples de tratamento de colisão. Cada entrada H[i] da tabela hash é uma lista encadeada, cujos elementos têm hash code i. Para inserir um elemento na tabela: 1. Compute o seu hash code i, e 2. Insira o elemento na lista ligada H[i]. 2
Embora uma função hash bem escolhida promova um bom balanceamento, não se pode garantir que as listas terão tamanhos próximos. Seria possível substituir a lista ligada por estruturas mais eficientes de busca, como árvores balanceadas, mas isso não se faz na prática. Open Address Hashing (endereçamento aberto) É uma estratégia para guardar todas as chaves na tabela, mesmo quando ocorre colisão. H[i] contém uma chave, ao invés de um link. Em caso de colisão, um novo endereço é computado. Esse processo é chamado rehashing. Tem a vantagem de não usar espaço extra. Rehashing por Linear Probing A forma mais simples de rehashing é linear probing. Se o hash code f (K) = i, e alguma outra chave já ocupa a posição H[i], então a próxima posição disponível na tabela H será ocupada pela chave K : rehash (i) = (i+1) mod h. Ex: Se o conjunto de dados for constituído pelos anos: 1055, 1492, 1776, 1812, 1918 e 1945, a hash function f (x) = (5 x) mod 8, e usando linear probing na colisão rehash (4) = (4+1) mod 8 = 5 rehash (5) = (5+1) mod 8 = 6 3
rehash (6) = (6+1) mod 8 = 7 Note que: 1. É possível que uma posição i da tabela Hash já esteja ocupada com alguma chave cujo hash code é diferente de f (K). 2. Rehashing por linear probing não depende do valor da chave K. Para recuperar uma chave: 1. Compute o valor de f (K) = i. 2. Se H[i] está vazia, então K não está na tabela. 3. Se H[i] contém alguma chave diferente de K, Então, compute rehash (i) = i1 = (i + 1) mod h. 4. Se H[i1] está vazia, então K não está na tabela. Senão, se H[i1] contém alguma chave diferente de K, então, rehash (i1), etc... Rehashing por Linear Probing pode trazer sérios problemas de colisão se houver uma alta taxa de ocupação na tabela Hash. Para um bom desempenho é importante manter a taxa de ocupação da tabela próxima a 0,5 (50% do espaço). Rehashing por Double Hashing Ao invés de fazer os incrementos de 1 invariavelmente, os incrementos são feitos por um valor d, que depende da chave K. Ex: d = HashIncr (K) rehash (j, d) = (j+d) mod h. Ex: Conjunto de dados: 1055, 1492, 1776, 1812, 1918 e 1945 Hash function f (x) = (5 x) mod 8 Colisões resolvidas por double hashing com HashIncr(K) = (K mod 7) + 1 f (1812) = (5 1812) mod 8 = (9060 mod 8) = 4 HashIncr (1812) = (1812 mod 7) + 1 = 6 + 1 = 7 rehash (4, 7) = (4 + 7) mod 8 = 3 rehash (3, 7) = (3 + 7) mod 8 = 2 4
Removendo Elementos da Tabela A remoção é uma operação delicada em tabelas Hash. Usa-se um bit para indicar se a posição está, de fato, ocupada por um elemento válido da tabela, ou se o dado que se encontra naquela entrada não faz mais parte da estrutura. Exemplo em Java Um mapa (classe Map) é uma coleção de pares ordenados que mapeiam um valor associado a uma chave em um mapa. Em Java temos duas classes que implementam mapas com tabelas Hash. A classe mais antiga é a HashTable, que é sincronizada e não aceita null na chave ou no valor e está desde a versão Java 1.0. O problema de ser sincronizada é que não podemos inserir e remover elementos simultâneos (métodos put e get). A outra classe mais atual é a HashMap, que por sua vez não é sincronizada (permite inserção e remoção simultânea), aceita null tanto no valor como na chave e está presente desde a versão 1.2 do Java. Ambas as classes possuem algumas modificações durante as versões até a versão atual 1.6 do Java, porém a mais recomendada é a utilização da classe HashMap. É possível gerar uma versão sincronizada da classe HashMap, de modo a utilizar a sincronização apenas onde se faz necessário, fazendo: Map seumapasincronizado = Collections.synchronizedMap(seuMapa); O uso de mapas sincronizados só é interessante quando for necessário o uso de multi threads na sua implementação de mapas. A classe HashMap implementada o endereçamento fechado para tratamento de colisões. Os pricipais métodos da classe HashMap são: 5
boolean containskey(object key) Retorna true se o mapa contém um valor mapeado para essa chave boolean containsvalue(object value) Retorna true se o mapa contém uma chave mapeada para o valor especificado Object get(object key) Retorna o valor que essa chave mapeia na tabela boolean isempty() Retorna true se a tabela está vazia Object put(object key, Object value) Associa um determinado valor a uma chave na tabela Object remove(object key) Remove o mapeamento dessa chave para um valor da tabela int size() Retorna o número de chaves mapeadas na tabela Exemplo de código: HashMap ht = new HashMap(); //Insere um elemento na tabela hash associado a uma chave ht.put("01", "A"); ht.put("02", "B"); //Mostra a quantidade de valores mapeados na tabela System.out.println(ht.size()); //Recupera o valores baseado na chave System.out.println(ht.get("01")); //Remover um elemento da tabela baseado na chave ht.remove("01"); 6
//Retorna true se a tabela contém a chave especificada if(ht.containskey("01")) System.out.println("Achei a chave 01"); //Retorna true se a tabela contém o valor especificado if(ht.containsvalue("b")) System.out.println("Achei o valor B"); HashTable e HashMap trabalham de maneiras diferentes no espalhamento dos valores. Suas funções de hashing não garantem uma ordenação dos elementos. Veja o exemplo: Map ht = new Hashtable(); Map hs = new HashMap(); String[] chaves = {"um", "dois", "tres", "quatro", "cinco", "seis", "sete", "oito", "nove", "dez"}; String[] valores= {"eins", "zwei", "drei", "vier", "fuenf", "sechs", "sieben", "acht", "neun", "zehn"}; for (int i = 0; i < chaves.length; ++i) { ht.put (chaves[i], valores[i]); hs.put (chaves[i], valores[i]); } System.out.println ("Hashtable: " + ht); System.out.println ("HashMap: " + hs); 7
2 - Grafos A teoria dos grafos é a parte da matemática que se dedica a estudar as relações entre entidades (objetos), que possuem características relevantes si. O primeiro grafo que se tem notícia data de 1736, quando Euler propôs um problema conhecido como problema do carteiro chinês. Na Prússia, perto da cidade de Konigsberg, no rio Pregel, sete pontes ligavam duas ilhas entre si e ao continente. O problema era encontrar um caminho que não cruzasse uma mesma ponte duas vezes, tendo como ponto de partida e chegada o mesmo pedaço de terra. Vimos até agora as estruturas de dados lineares (Pilhas, Filas e Listas Ordenadas) e estruturas não lineares (árvores e tabelas hash). Aprendemos que as árvores são estruturas de dados baseadas em nós, onde cada nó na árvore pode ter qualquer quantidade de nós filhos, cujos filhos se comportam da mesma maneira de forma recursiva. 8
O que percebemos também é que uma árvore possui certas regras que limitam a sua formação. Vimos que os filhos de cada nó formam estruturas disjuntas, que não possuem vínculos com as outras. Desse modo, cada nó só pode possuir um pai. Se permitirmos que cada nó possa se conectar com qualquer outro nó da estrutura, então temos um Grafo. De maneira simples, um grafo é uma estrutura baseada em nós onde cada nó pode se conectar a qualquer outro nó na estrutura. Portanto, podemos dizer que uma árvore é um tipo de grafo onde as regras de formação são mais específicas. Um grafo é composto de duas partes, sendo a primeira os nós (chamados vértices), que contem a informação e a segunda as arestas, que basicamente são as linhas que conecta um nó ao outro. Um grafo G(V,A) é definido pelo par de conjuntos V e A, onde: V - conjunto não vazio: os vértices ou nodos do grafo; A - conjunto de pares ordenados a=(v,w), v e w Î V: as arestas do grafo. Seja, por exemplo, o grafo G(V,A) dado por: V = { p p é uma pessoa } A = { (v,w) < v é amigo de w > } Esta definição representa toda uma família de grafos. Um exemplo de elemento 9
desta família (ver G1) é dado por: V = { Maria, Pedro, Joana, Luiz } A = { (Maria, Pedro), (Joana, Maria), (Pedro, Luiz), (Joana, Pedro) } 2.1 - Tipos de Grafos Dependendo da aplicação, arestas podem ou não ter direção, pode ser permitido ou não arestas ligarem um vértice a ele próprio e vértices e/ou arestas podem ter um peso (numérico) associado. Se as arestas têm uma direção associada (indicada por uma seta na representação gráfica) temos um grafo direcionado, ou dígrafo. Veja um exemplo de dígrafo: V = { p p é uma pessoa da família Castro } A = { (v,w) < v é pai/mãe de w > } Um exemplo de deste grafo é: V = { Emerson, Isadora, Renata, Antonio, Rosane, Cecília, Alfredo } A = {(Isadora, Emerson), (Antonio, Renata), (Alfredo, Emerson), (Cecília, Antonio), (Alfredo, Antonio)} Existem muitos tipos diferentes de implementação de grafos, vamos ver 10
alguns mais importantes: 1) Grafos Bidirecionais: nesse caso, as arestas sempre possuem direção de ida e volta. 2) Grafos Unidirecionais: esse tipo de grafo é um pouco mais limitado do que o grafo bidirecional pois cada aresta tem apenas uma direção. É possível simular um grafo bidirecional utilizando grafos unidirecionais, que apesar de parecer mais trabalhoso, proporciona um maior controle da estrutura. Veja o modelo abaixo: Um grafo direcionado, ou dígrafo, G é descrito pelo par (V, E), onde: V é um conjunto finito, não vazio, de nós, chamados vértices de G; E é um conjunto finito de pares ordenados de vértices, chamados arestas ou arcos; 11
Exemplo: Seja G1 = (V1, E1) composto por 4 vértices e 6 arestas. G 1 a b V1 = { a, b, c, d } e E1 = { (a, b), (a, c), (b, c), (c, a), (c, d), (d, d) } c d 2.2 - Utilizando grafos para uma máquina de estados finita Uma máquina de estados finitos ou Autômatos Finitos é uma modelagem de um comportamento, composto por estados, transições e ações. Um estado armazena informações sobre o passado, isto é, ele reflete as mudanças desde a entrada num estado, no início do sistema, até o momento presente. Uma transição indica uma mudança de estado e é descrita por uma condição que precisa ser realizada para que a transição ocorra. Uma ação é a descrição de uma atividade que deve ser realizada num determinado momento. Máquinas de Estado Finito podem ser utilizadas no processo de reconhecimento de palavras dentro de um compilador ou para mapear o comportamento de um personagem dentro do cenário de um jogo. 2.2.1 - Definição formal de máquina de estado finito Um Autômato Finito pode ser determinístico (AFD), ou seja, para cada símbolo de entrada (ação) só existe um caminho a seguir ou não determinístico (AFND), que nesse caso, para um mesmo símbolo de entrada existem mais do que uma possibilidade de transição de estado. Um AFD é composto por uma quíntupla, M = (, Q,, q0, F), onde: : alfabeto de símbolos de entrada; Q: conjunto finito de estados do autômato; : função programa ou função de transição (parcial) : Q Q q0: estado inicial (q0 Q) F: conjunto de estados finais ou estados de aceitação (F Q) Exemplo: Autômato que reconhece a linguagem de números binários com quantidade ímpar de 1s. 12
M = (, Q,, qo, F) Q = { qo, q1 }, = { 0, 1 }, F = { q1 } Forma Tabular : Forma Gráfica : X q o q 1 0 1 q o q 1 q 1 q o 0 q o 1 1 q 0 A forma gráfica, chamada diagrama de transição de estados, é a representação mais apropriada para descrever um AF. Esta representação é um grafo direcionado rotulado com um nó para cada estado e uma aresta rotulada. Genericamente, uma aresta sai de um nó q para um nó p com o rótulo a, ou seja: (q, a) = p Do exemplo (qo, 0) = qo anterior (qo, 1) = q1 (q1, 0) = q1 (q1, 1) = qo 13
Exemplo 1: Construir um AFD que reconhece a linguagem a*. M = (, Q,, q o, F) Q = { q o }, = { a }, F = { q 0 } Forma Tabular : Forma Gráfica : a a q q o q o o Exemplo 2: Construir um AFD que reconhece a linguagem aa*. M = (, Q,, q o, F) Q = { q o, q 1 }, = { a }, F = { q 1 } Forma Tabular : Forma Gráfica : a a a q o q 1 q o q q 1 q 1 14
Exemplo 3: Construir um AFD que reconhece a linguagem (abb*a)*. M = (, Q,, q o, F) Q = { q o, q 1, q 2 }, = { a, b }, F = { q 0 } Forma Tabular : Forma Gráfica : a b a q o q 1 q 1 q 2 q 2 q 0 q 2 q o b q 1 a q 2 b 15
Referências PEREIRA, S. L. Estruturas de Dados Fundamentais: Conceitos e Aplicações. 9. ed. São Paulo: Erica, 2001. MAIN, M. Data Structures & Other Objects Using C++. 3. ed. Boston: Pearson Education., 2005. PENTON, R. Data Structures For Game Programmers. Ohio, Usa: Focal Press, 2003. SZWARCFITER, Jayme Luiz, Estruturas de Dados e Seus Algoritmos, LTC, Rio de Janeiro, 1994 16
17 Responsável pelo Conteúdo: Prof. Ms. Amilton Souza Martha Revisão Textual: Prof. Ms. Rosemary Toffolli www.cruzeirodosul.edu.br Campus Liberdade Rua Galvão Bueno, 868 01506-000 São Paulo SP Brasil Tel: (55 11) 3385-3000