Cátia Vaz Amontoados Relaxados e Filas com Prioridade 9 de Setembro de 2009 ISEL, Instituto Politécnico de Lisboa Conteúdo 1 Introdução . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.1 Estado da Arte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 Amontoados Relaxados . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7 2.1 Árvores Binomiais . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7 2.2 Amontoados Relaxados . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9 2.2.1 Rank Relaxed Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10 2.2.2 Run Relaxed Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26 2.3 Detalhes de Implementação . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38 3 Filas com Prioridade . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41 3.1 Tipo de Dados Abstracto . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41 3.2 Detalhes de Implementação . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43 4 Avaliação Experimental . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45 4.1 Sequências de Números Aleatórios . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45 4.2 Algoritmo de Dijkstra . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49 5 Conclusão . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53 A Implementação em Java . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . A.1 Rank Relaxed Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . A.2 Run Relaxed Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . A.3 Priority Queue . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 3 55 55 77 107 Referências . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 117 1 Introdução O problema de gerir uma fila de elementos com prioridade surge frequentemente no desenvolvimento de algoritmos para resolver problemas em engenharia informática e, na maior parte dos casos, tem um impacto significativo na eficiência da solução implementada. Dado um conjunto de elementos x cuja prioridade é definida por uma chave k(x), uma fila com prioridade é um tipo de dados abstracto que suporta as seguintes operações: insert(x), que insere o elemento x na fila; removeMin(), que retorna e remove o elemento de menor chave na fila; minimum(), que retorna o elemento de menor chave na fila sem o remover. Neste caso o elemento com menor chave terá maior prioridade. Alternativamente, pode-se definir fila com prioridade como um tipo de dados abstracto com as operações removeMax, que retorna e remove o elemento de maior chave na fila, e maximum, que retorna o elemento de maior chave na fila mas sem o remover. Contudo a escolha entre as duas definições está directamente relacionada com a ordem total imposta aos elementos. Logo, sem perda de generalidade, neste trabalho adopta-se a primeira definição, i.e., o elemento com a menor chave tem maior prioridade. Na solução de alguns problemas é necessário que o tipo de dados abstracto fila com prioridade suporte a operação: decreaseKey(x, v), que atribui a chave v < k(x) ao elemento x, i.e., k(x) = v. Embora estas sejam as operações básicas, este tipo de dados abstracto pode incluir outras operações como a operação remove, que remove um dado elemento da fila, ou a operação meld, que funde duas filas com prioridade. Caso a operação remove seja suportada, a operação decreaseKey pode ser definida através da operação remove seguida da operação insert. Contudo esta não é a prática usual por razões de eficiência. Em engenharia informática as filas com prioridade são utilizadas na solução de vários problemas, e.g., na determinação de caminhos mais curtos em grafos [1], na determinação de árvores abrangentes de menor custo [2] e no escalonamento de processos e tarefas em 1 Introdução 2 sistemas operativos [3]. Muitos destes problemas surgem no contexto de sistemas de alta performance e em tempo real, onde a eficiência da implementação das filas com prioridade é determinante. Em geral a implementação varia bastante com o problema em estudo. Por exemplo, no escalonamento de processos e tarefas em sistemas operativos, as chaves tomam valor num conjunto de valores finito. Neste caso, dado que a dimensão do conjunto domı́nio é apenas de algumas dezenas, a implementação mais simples passa por associar uma lista de elementos a cada chave e todas as operações podem ser implementadas com tempo O(1). Porém, quer para o problema de determinar os caminhos mais curtos, quer para o problema de determinar a árvore abrangente de menor custo, esta abordagem não é em geral viável dada a dimensão do domı́nio e a natureza das chaves. Neste caso a implementação mais simples será utilizar uma lista em que os elementos são guardados por ordem arbitrária ou uma lista em que os elementos são ordenados pelo valor da chave. Contudo estas abordagens implicam que a operação removeMin, no primeiro caso, e a operação insert, no segundo caso, corram em tempo Ω(n) com n o número de elementos na fila. Embora outras implementações sejam possı́veis, nas últimas três décadas foram publicados vários resultados que apontam os amontoados como a estrutura de dados mais adequada para a implementação de filas com prioridade. De um modo geral um amontoado é uma árvore em que cada nó tem uma chave associada e todos os descendentes tem chaves de valor superior. Nos trabalhos publicados foram apresentadas diversas estruturas de dados e vários resultados teóricos a respeito dos tempos de execução das operações descritas. Contudo nem todas as estruturas de dados são viáveis para implementar na prática, conduzindo muitas vezes a piores resultados que estruturas de dados teoricamente menos eficientes. O objectivo deste estudo é analisar a viabilidade prática das estruturas de dados avançadas para implementar amontoados e, consequentemente, filas com prioridade. A motivação para este estudo prende-se com a utilidade prática destas estruturas de dados no desenvolvimento de inúmeros algoritmos e com o interesse pedagógico em ensiná-las em disciplinas avançados de engenharia informática. As contribuições deste estudo são: a implementação das várias estruturas de dados discutidas e a sua avaliação experimental; a implementação do tipo de dados abstracto fila com prioridades permitindo a utilização das várias estruturas de dados; a discussão do interesse pedagógico e viabilidade prática da inclusão destas implementações em bibliotecas de uso geral. 1.1 Estado da Arte 3 1.1 Estado da Arte A concretização mais simples de amontoados foi introduzida por Williams [4], conhecidos por amontoados binários. Uma fila com prioridade implementada sobre amontoados binários permite consultar o mı́nimo em tempo O(1) no pior caso e extrair o mı́nimo, inserir e remover elementos em tempo O(log n) no pior caso. Os amontoados binários, ainda que teoricamente não constituam a estrutura de dados mais eficiente, são os mais utilizados nas implementações de filas com prioridade disponı́veis nas diversas bibliotecas. Este facto deve-se à simplicidade de implementação e a ser possı́vel implementar amontoados binários sobre vectores recorrendo apenas à indexação, conduzindo a uma menor utilização de memória. Dado o tempo de execução das operações e os baixos requisitos de memória, os amontoados binários são em geral apropriados para aplicações que tenham de manipular grandes quantidades de dados. Nos últimos anos foram propostas novas concretizações de amontoados com vista a melhorar o tempo de execução das várias operações e que contribuı́ram para implementações eficientes de vários algoritmos, como por exemplo os algoritmos de Dijkstra [1] e de Prim [2]. Na sua grande maioria as estruturas de dados propostas suportam a operação meld, i.e., amontoados fundı́veis [5]. O principal objectivo consiste em (1) reduzir o tempo de execução de todas as operações para O(1) no pior caso, excepto as operações removeMin e remove que devem ter tempo de execução O(log n) no pior caso. A razão deste objectivo prende-se com o facto que na maioria dos casos o número de inserções e de diminuições do valor das chaves é superior ao número de remoções. Um exemplo é o algoritmo de Dijkstra [1] em que o número de actualizações é proporcional ao número de arcos m no grafo e o número de extracções é proporcional ao número de vértices n. Como o número de arcos é regra geral maior que o número de vértices, uma estrutura de dados que satisfaça (1) conduz a um tempo de execução do algoritmo O(n log n + m) no pior caso. Os amontoados binomiais foram propostos por Vuillemin [6] e suportam todas as operações em tempo O(log n) no pior caso, incluindo a operação meld que nos amontoados binários leva tempo O(n). Com base nos amontoados binomiais foram propostas várias estruturas de dados que exploram versões relaxadas. Fredman and Tarjan [7, 8] propuseram os amontoados de Fibonacci, uma relaxação dos amontoados binomiais em que se permite que as árvores binomiais não sejam completas. Os amontoados de Fibonacci suportam as operação de remoção e de extracção do mı́nimo em tempo O(log n) amortizado e todas as outras operações, em particular a actualização de chaves, em tempo O(1) amortizado [9]. Desta forma, os amontoados de Fibonacci permitiram melhorar a comple- 1.1 Estado da Arte 4 xidade assimptótica de vários algoritmos, em particular dos algoritmos de Dijkstra [1] e de Prim [2] para um tempo de execução O(n log n + m) no pior caso. Contudo, dada a complexidade da estrutura de dados e os requisitos de memória, na prática os amontoados de Fibonacci não provaram ser eficientes, conseguindo-se regra geral melhores resultados com os amontoados binários. Os amontoados emparelhados (pairing heaps) [10] foram introduzidos como uma alternativa aos amontoados de Fibonacci. Esta estrutura de dados garante para todas as operações tempo O(log n) amortizado e, conjecturava-se, tempo O(1) amortizado para a operação decreaseKey. No entanto em trabalhos posteriores provou-se que o tempo amortizado para √ a operação decreaseKey é O(22 log log n ) [11], verificando-se ainda Ω(log log n) [12]. Recentemente, Elmasry [13] introduziu uma variante de amontoados emparelhados com um tempo O(log log n) amortizado para a operação decreaseKey. A vantagem em relação aos amontoados de Fibonacci reside no facto de serem mais competitivos na prática. Driscoll et al. [14] propuseram duas estruturas de dados, rank relaxed heaps e run relaxed heaps, ambas com base nos amontoados binomiais. Tal como os amontoados de Fibonacci, a técnica consiste em relaxar os amontoados binomiais. Porém neste caso as árvores binomiais são completas e a relaxação verifica-se ao nı́vel da propriedade dos amontoados, i.e., um número limitado de nós pode ter uma chave menor que o seu pai. Os rank relaxed heaps têm a mesma complexidade assimptótica amortizada que os amontoados de Fibonacci. Os run relaxed heaps suportam as operações remove, removeMin e meld em tempo O(log n) no pior caso e todas as outras operações em tempo O(1) no pior caso. Brodal [15] obteve ainda uma estrutura de dados que suporta as operações de remoção em tempo O(log n) no pior caso e todas as outras operações em tempo O(1) no pior caso. No entanto é uma estrutura de dados bastante complexa quando comparada com os amontoados relaxados propostos por Driscoll et al.. Com vista a melhorar os tempos de execução das operações dos amontoados relaxados, nomeadamente o número de comparações, Elmasry et al. apresentou recentemente uma nova estrutura, two-tier relaxed heaps, composta por dois run relaxed heaps [16]. Na última década, o foco tem sido a melhoria das estruturas de dados de modo a conseguir-se na prática implementações competitivas, continuando a garantir os limites assimptóticos teóricos para o tempo de execução das operações [17, 18, 19, 20]. Os principais objectivos são reduzir o número instruções para cada operação, em particular o número de comparações [21], e reduzir os requisitos de memória. 1.1 Estado da Arte 5 Ainda que se tenham verificado todos estes esforços para obter implementações eficientes de filas com prioridade, a maior parte das bibliotecas disponı́veis não implementam nenhuma destas estruturas mais avançadas. No que respeita à implementação do tipo de dados abstracto fila com prioridades, a maior parte das implementações não suporta a operação decreaseKey directamente e são baseadas em amontoados binários. É claro que é possı́vel implementar um fila de prioridades sobre o tipo de dados abstracto mapa ordenado disponibilizado em quase todas as bibliotecas e que, regra geral, é implementado sobre árvores binárias de pesquisa balanceadas ou sobre amontoados binários. Em ambos os casos o custo das operações insert, removeMin e decreaseKey é O(log n), não garantindo os limites assimptóticos das estruturas de dados mais avançadas para as operações insert e decreaseKey. 2 Amontoados Relaxados Os amontoados relaxados foram propostos por Driscoll et al. [14] como uma alternativa aos amontoados de Fibonacci [8]. Quer os amontoados relaxados quer os amontoados de Fibonacci são baseados em amontoados binomiais e, com vista a melhorar o tempo de execução das operações, introduzem relaxações ao nı́vel das árvores binomiais. A principal diferença entre ambos reside precisamente na forma como são feitas as relaxações. Enquanto que os amontoados de Fibonacci introduzem relaxações ao nı́vel da estrutura das árvores binomiais, os amontoados relaxados consideram árvores binomiais completas e introduzem relaxações ao nı́vel da ordem dos nós. Esta última abordagem, para além de permitir implementações mais eficientes na prática, permite concretizar as operações remove, removeMin e meld em tempo O(log n) no pior caso e todas as outras operações em tempo O(1) no pior caso também, em particular a operação decreaseKey. Driscoll et al. propuseram duas estruturas de dados, rank relaxed heaps e run relaxed heaps. Ambas as estruturas de dados garantem os tempos anteriores para a execução das operações, contudo no primeiro caso os tempos são amortizados tal como acontece com os amontoados de Fibonacci. No que se segue apresentam-se e discutem-se em detalhe ambas as concretizações de amontoados relaxados. Antes é no entanto necessário introduzir árvores binomiais e alguma notação. 2.1 Árvores Binomiais Antes de definir árvore binomial é necessário rever alguns conceitos relativos a árvores em geral. Uma árvore é um grafo ligado, não dirigido e acı́clico, i.e., um par (V, E) com V o conjunto de vértices ou nós e E ⊆ V × V o conjunto de arcos tal que existe um e um só 2.1 Árvores Binomiais x 8 1 y 30 10 5 15 22 8 Br 25 15 11 13 22 21 Br 27 16 36 Figura 2.1. À esquerda, construção de uma árvore binomial Br+1 a partir de duas árvores binomiais Br . À direita, exemplo de uma árvore binomial B4 , i.e., com grau 4. caminho (constituı́do por vértices distintos) entre cada par de vértices. Uma árvore com raiz é uma árvore em que um vértice, designado por raiz, é distinguido dos outros. Considere-se uma árvore com raiz. Dado x um nó da árvore, designa-se por ascendente de x qualquer nó y que se encontre no caminho entre o nó x e a raiz r. Neste caso x é também um descendente de y. Dado um nó y e um nó x descendente de y e adjacente a y, x diz-se filho de y e y pai de x. Note-se que a raiz é o único nó sem pai. Dado um nó x, a sub-árvore de raiz x é a árvore induzida pelos descendentes de x, com raiz x. Um nó designa-se por folha ou terminal se não tiver descendentes, caso contrário designase por nó interno ou não terminal. Qualquer nó não terminal distinto da raiz pode também designar-se por nó intermédio. O grau ou rank de um nó é igual ao seu número de filhos e uma árvore diz-se n-ária se tiver pelo menos um nó de grau n. Dada uma árvore com raiz, define-se o nı́vel para cada nó e a altura da árvore. Um nó x diz-se estar no nı́vel n se o caminho entre a raiz r e o nó x tiver n arcos. Uma árvore tem altura h se o máximo dos nı́veis das suas folhas for h. Dada uma ordem total para os nós, uma árvore com raiz diz-se ordenada se os filhos de cada nó estiverem ordenados. Neste caso, se x tiver grau n, os seus filhos são identificados por primeiro filho, segundo filho, ..., n-ésimo filho. Neste documento o n-ésimo filho é também designado por último filho do nó x. As árvores binomiais definem-se recursivamente como se segue: a árvore binomial B0 consiste num único nó, a sua raiz; a árvore binomial Br+1 é composta por duas árvores binomiais Br em que o nó raiz de uma é filho do nó raiz da outra. Na Figura 2.1, à esquerda, ilustra-se esta construção. Por construção, uma árvore binomial Br tem 2r nós, altura r e a sua raiz tem grau r. Estes factos podem ser facilmente provados por indução em r. 2.2 Amontoados Relaxados 9 No contexto deste documento as árvores binomiais são ordenadas: os filhos de cada nó são ordenados por ordem crescente do grau. Uma árvore binomial Br é também designada por árvore binomial de grau r. A designação destas árvores advém do facto de existirem rl nós com nı́vel l. 2.2 Amontoados Relaxados Um amontoado é uma estrutura de dados baseada em árvores com raiz que satisfaz a seguinte propriedade: a chave de um nó (mais precisamente, a chave do elemento associado a um nó) é maior ou igual à chave do seu nó pai. Esta propriedade pressupõe uma ordem total para as chaves e, quando é satisfeita, diz-se que a árvore é ordenada em amontoado. Esta estrutura de dados ganha mais relevância com o facto de permitir implementações eficientes de filas com prioridade. Nesta secção discute-se em detalhe as duas versões de amontoados relaxados propostos por Driscoll et al. [14], rank relaxed heaps e run relaxed heaps, ambas com base nos amontoados binomiais. Os rank relaxed heaps suportam as operações com a mesma complexidade assimptótica amortizada que os amontoados de Fibonacci, enquanto que os run relaxed heaps suportam as operações remove, removeMin e meld em tempo O(log n) no pior caso e todas as outras operações em tempo O(1) no pior caso. Para além dos amontoados relaxados serem a primeira concretização de amontoados a garantir estes limites assimptóticos no pior caso, constituem também a base para o desenvolvimento de estruturas mais eficientes como os two-tier relaxed heaps propostos recentemente por Elmasry et al. [16], compostos por dois run relaxed heaps. Os amontoados relaxados são constituı́dos por uma colecção de árvores binomiais ordenadas em amontoado. Porém, para garantir o tempo O(1) para algumas operações como o decreaseKey, os amontoados relaxados permitem a existência de nós que não satisfazem a propriedade de ordenação de amontoado, designados por nós maus. É importante notar que o número de nós maus não pode ser arbitrário tendo em conta que se pretende que a operação removeMin ocorra em tempo O(log n). Portanto, uma vez que esta operação implica determinar o nó com menor chave e um nó mau pode ter a menor a chave, o número de nós maus não poderá exceder O(log(n)). Quer os rank relaxed heaps quer os run relaxed heaps não admitem mais do que log(n) nós activos, nos quais se incluem os possı́veis nós maus. Para garantir esta propriedade é necessário um conjunto de transformações para gerir eficientemente a estrutura de dados. 2.2 Amontoados Relaxados 0 1 7 2 10 10 7 5 12 10 9 0 13 12 11 14 Figura 2.2. Exemplo de um amontoado relaxado com chaves inteiras. Este amontoado tem 4 árvores binomiais e 2 nós activos, os nós com chave 5 e 0. Note-se que as árvores binomiais são ordenadas por grau, i.e., os filhos de cada nó estão ordenados por grau, e estão ordenadas em amontoado à excepção dos nós activos. Embora os rank relaxed heaps sejam menos flexı́veis quanto à ocorrência de nós maus, as transformações são idênticas. Deste modo começar-se-á por explicar as transformações e a concretização das operações para os rank relaxed heaps. De seguida discute-se a adaptação das transformações para os run relaxed heaps. No final discutem-se alguns detalhes importantes para a implementação eficiente dos amontoados relaxados. 2.2.1 Rank Relaxed Heaps Um rank relaxed heap é constituı́do por um conjunto de árvores binomiais, onde existem nós que são distinguidos por activos, e que satisfazem as seguintes propriedades: 1. existe no máximo uma árvore binomial no amontoado cuja raiz tenha um dado grau; 2. cada árvore binomial é ordenada em amontoado, embora possa existir no máximo um nó activo com grau r para cada grau r em toda a colecção; 3. cada nó activo no amontoado tem de ser o filho de maior grau de algum nó; 4. cada nó de uma árvore binomial no amontoado tem os filhos ordenados por grau. Na Figura 2.2.1 mostra-se um exemplo de um rank relaxed heap em que os nós com chave 5 e 0 são activos e, neste caso, maus. Note-se que estes nós violam a propriedade de ordenação em amontoado uma vez que são menores que os seus pais. Como referido acima, os amontoados relaxados são baseados em amontoados binomiais e, de facto, têm a mesma estrutura que os amontoados binomiais: um conjunto de árvores binomiais tal que cada árvore é ordenada em amontoado e existe no máximo uma árvore binomial de cada grau. A principal diferença reside na existência de nós maus, i.e., nós que não respeitam a propriedade de ordem do amontoado. Da primeira propriedade de rank relaxed heap tem-se que existe no máximo uma árvore binomial de grau r na colecção para cada grau r e, dado o número n de elementos no 2.2 Amontoados Relaxados 11 amontoado, podemos determinar quais as árvores na colecção pela análise da representação binária de n. Uma árvore binomial de grau r, Br , tem 2r nós. Portanto, considerando a representação binária de n, o número de árvores binomiais será igual ao número de bits P a 1. Note-se que a representação binária de n tem blog(n)c + 1 bits e n = blog(n)c bi 2i , i=0 onde bi é o i-ésimo bit na representação binária de n. Logo, a árvore binomial Bi surge no amontoado se e só se bi = 1. Por exemplo, se considerarmos um amontoado com 6 nós, em binário 110, existem duas árvores binomiais B1 e B2 com 2 e 4 nós respectivamente. Esta análise implica ainda que num amontoado com n elementos existem no máximo blog(n)c+1 árvores binomiais. A segunda propriedade permite inferir que existem no máximo blog(n)c nós activos. Da análise acima da representação binária do número n tem-se que Bblog(n)c é a maior árvore possı́vel no amontoado. Uma vez que todos os nós têm grau inferior ao seu pai e que o nó raiz da árvore Bblog(n)c tem o maior grau, blog(n)c, tem-se que todos os nós têm no máximo grau blog(n)c. Por outro lado, pela segunda propriedade, tem-se que existe no máximo um nó activo por cada grau. Logo, existem no máximo blog(n)c nós activos. As terceira e quarta propriedades são determinantes para a implementação das operações sobre o amontoado com os tempos de execução mencionados acima. Em particular permitem localizar os nós activos e realizar várias transformações no amontoado de forma eficiente. Estas propriedades serão analisadas em maior detalhe na concretização das operações. Antes de definir as operações, é necessário introduzir alguns detalhes a respeito da representação dos rank relaxed heaps. Com vista à simplificação da descrição das operações, assuma-se que existe um nó auxiliar head cuja chave terá sempre o menor valor possı́vel, i.e., key[head ] < key[a] qualquer que seja o nó a, e que tem como filhos todos os nós raiz das árvores binomiais no amontoado, ordenados por grau. Desta forma nenhum destes nós poderá vir a ser activo, todos os nós no amontoado têm nó pai e, importante em algumas situações que se seguem, todos os nós activos têm nó avô definido. Em relação aos nós, cada nó a tem os seguintes atributos: 1. 2. 3. 4. key[a], a chave do elemento associado ao nó a; parent[a], o nó pai do nó a; rank [a], o grau do nó a; child [a][r], o filho do nó a com grau r, em que 0 ≤ r < rank [a]. É ainda necessário guardar o nó activo active[r] para cada grau r, caso exista, e uma referência min para o nó com a chave mı́nima. Nota-se que estas são definições abstractas dos atributos e que não pressupõem a utilização de uma estrutura de dados particular. No 2.2 Amontoados Relaxados 12 entanto é indispensável que os atributos sejam acessı́veis em tempo O(1) e a implementação terá de garantir isso. Na Secção 2.3 discutir-se-ão os detalhes a ter em conta para uma implementação eficiente das estruturas de dados. Um rank relaxed heap é inicializado da seguinte forma: makeRankRelaxedHeap(n) 1 2 3 4 5 6 7 8 n é a capacidade deste amontoado min ← nil key[head ] ← −∞ parent[head ] ← nil rank [head ] ← 0 for r ← 0 to blog(n)c do child [head ][r] ← nil active[r] ← nil Operação decreaseKey Os rank relaxed heaps permitem executar a operação decreaseKey em tempo O(1) amortizado. Esta operação será a mais difı́cil de concretizar uma vez que, ao diminuir-se a chave de um elemento do amontoado, pode criar-se um novo nó mau e portanto activo. Este facto implica que uma única operação de diminuição de chave pode fazer com que as propriedades 2 e 3 se deixem de verificar. A dificuldade é então restaurar estas duas propriedades garantindo um tempo de execução O(1) amortizado. Considere-se um nó a ao qual se diminui a chave key[a]. É importante salientar que caso a seja raiz de uma árvore binomial do amontoado, a nunca se tornará num nó mau, note-se que key[head ] = −∞, e portanto nunca se tornará activo. Seja r o grau e p o nó pai do nó a, i.e., rank [a] = r e parent[a] = p. A diminuição do valor da chave key[a] conduz a quatro casos possı́veis: 1. 2. 3. 4. key[a] ≥ key[p]; key[a] < key[p], a é o último filho e não existe outro nó activo com grau r; key[a] < key[p], a é o último filho e existe outro nó activo com grau r; key[a] < key[p] e a não é o último filho. Os dois primeiros casos resolvem-se sem dificuldade. No primeiro caso o nó a é bom, logo se for activo pode passar a inactivo. Neste caso o nó a não poderia ser mau antes da operação, caso contrário continuaria a ser mau uma vez que a chave diminui. Contudo poderia ainda 2.2 Amontoados Relaxados 13 assim estar marcado como activo devido a operações anteriores, e.g., a chave key[p] pode ter diminuı́do de valor numa operação anterior. No segundo caso o nó a é mau e portanto terá de ser marcado como activo, o que pode ser feito uma vez que é último filho e não existe outro nó activo com grau r. Portanto, em ambos os casos todas as propriedades de rank relaxed heap são verificadas. O terceiro e quarto casos levantam mais dificuldades, uma vez que é necessário realizar transformações nas árvores para garantir as propriedades. No terceiro caso o nó a não pode ser marcado activo uma vez que existe outro nó activo com grau r e, portanto, a propriedade 2 não se verificaria. No caso quatro o nó a não pode ser marcado activo porque não é o último filho de p, caso contrário a propriedade 3 não se verificaria. Deste modo, uma vez que nos terceiro e quarto casos o nó a é mau e não pode ser marcado activo, é necessário introduzir um conjunto de transformações para reduzir o número de nós activos e garantir que todos os nós activos são últimos filhos. Dado um nó a e um novo valor v para a chave key[a], a operação decreaseKey definese da seguinte forma: decreaseKey(a, v) 1 if key[a] < v 2 then error “a nova chave é maior do que a chave actual” 3 else key[a] ← v 4 update(a) A última instrução, update(a), verifica se a deve ficar activo e realiza as transformações necessárias no amontoado, de forma a restaurar as propriedades de rank relaxed heap. Se key[a] ≥ key[p] e a estiver marcado como activo, i.e., active[r] = a, então a deverá ser marcado inactivo, i.e., active[r] = nil. Se key[a] < key[p], a for o último filho de p e não existir nenhum nó activo com grau r, i.e., active[r] = nil, então a deverá ser marcado como activo, i.e., active[r] = a. Se key[a] < key[p] e já se verificar active[r] = a, então não é necessário fazer nada. Nos outros casos em que key[a] < key[p] é necessário realizar uma ou mais das seguintes transformações: 1. pairTransform, aplica-se quando a é o filho de maior grau, i.e., o último filho, e existe um nó activo com grau r diferente de a; 2. goodSiblingTransform, aplica-se quando a não é último filho e o irmão direito s, i.e., s = child [p][r + 1], não é activo, i.e., active[r + 1] 6= s; 3. activeSiblingTransform, aplica-se quando a não é último filho e o irmão direito é activo, i.e., active[r + 1] = s. 2.2 Amontoados Relaxados 14 Note-se que a só será marcado activo após a execução das transformações, quando não existir outro nó activo com grau r. O procedimento update define-se da seguinte forma: update(a) 1 r ← rank [a] 2 p ← parent[a] 3 if key[a] ≥ key[p] 4 then if active[r] = a 5 then active[r] ← nil 6 else key[a] < key[p] 7 if r = rank [p] − 1 a é o último filho de p 8 then if active[r] = nil 9 then active[r] = a 10 else if active[r] 6= a 11 then pairTransform(a) 12 else a não é o último filho de p 13 s ← child [p][r + 1] s é o irmão direito de a 14 if active[r + 1] = s se s é activo 15 then activeSiblingTransform(a) 16 else goodSiblingTransform(a) No caso da transformação pairTransform tem-se que a é um nó mau e que existe um nó activo b com grau r, em que ambos são últimos filhos. O objectivo desta transformação é reduzir o número de nós activos em pelo menos um nó por forma a resolver o conflito entre o nó a e o nó activo com grau r. Na Figura 2.3 ilustra-se a execução da transformação pairTransform. Sejam pa o nó pai de a, pb o nó pai de b, ga o nó avô de a, i.e., nó pai de pa, e gb o nó pai de b, i.e., nó pai de pb. Em primeiro lugar os nós a e b são removidos do amontoado, constituindo duas árvores binomiais de grau r com raı́zes a e b, e são combinadas para formar uma árvore binomial de grau r +1. Sem perda de generalidade assuma-se que key[a] > key[b]. A árvore resultante terá grau r + 1, raiz b e o último filho b será o nó a. Em segundo lugar, uma vez que os nós pa e pb tinham grau r + 1 antes de perderem o último filho (agora têm grau r), a árvore com raiz b irá substituir um deles. Assuma-se sem perda de generalidade que key[pa] < key[pb]. Então, caso pb seja um nó activo, pb é marcado inactivo e, enquanto filho do nó gb, é substituı́do pelo nó b. Finalmente o nó pb é colocado como o filho de grau r do nó pa. Importa referir que estas operações 2.2 Amontoados Relaxados 15 ga 4 1 a) pa 5 7 gb 30 12 10 5 8 3 1 pb a 14 20 15 11 22 25 15 11 13 2 b 22 27 24 21 10 36 ga 4 1 b) pa 5 7 gb 12 30 10 5 8 3 1 pb 14 20 13 b 15 22 25 15 11 10 2 a 21 27 22 36 11 24 Figura 2.3. Aplicação da transformação pairTransform ao nó a cuja chave foi diminuı́da para 11. O nó b é um nó activo com o mesmo grau que o nó a. A numeração dos cortes e uniões ilustra uma ordem possı́vel das operações. não afectam os restantes nós do amontoado e que ou a deixa de ser mau ou b deixa de ser activo. Portanto, tendo em conta que a deveria de ser marcado activo, o número de nós activos foi reduzido em pelo menos um. Este facto deve-se a que ao combinar os nós a e b garante-se que um deles não é mau. A transformação pairTransform executa ainda update(b) uma vez que b pode ser um nó mau em relação a gb. Em particular, dado que pb não era necessariamente o último filho do nó gb, b pode não ser o último filho e, por outro lado, pode existir outro nó activo com grau r + 1. Logo, podem ser necessárias outras transformações. A Figura 2.4 ilustra o caso geral da transformação pairTransform e a Figura 2.5 exemplifica o caso em que o resultado de combinar os nós a e b produz um nó activo. Adiante ver-se-á como resolver este caso. A transformação pairTransform define-se da seguinte forma: 2.2 Amontoados Relaxados 16 pairTransform(a) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 r ← rank [a] b ← active[r] active[r] ← nil pa ← parent[a] pb ← parent[b] ga ← parent[pa] gb ← parent[pb] c ← combine(a, b) if key[pa] ≤ key[pb] then child [pa][r] ← pb tornar pb o último filho de pa parent[pb] ← pa child [gb][r + 1] ← c substituir pb enquanto filho de gb por c parent[c] ← gb rank [pb] ← rank [pb] − 1 if active[r + 1] = pb then if key[c] < key[gb] then active[r + 1] = c else active[r + 1] = nil else update(c) else child [pb][r] ← pa tornar pa o último filho de pb parent[pa] ← pb child [ga][r + 1] ← c substituir pa enquanto filho de ga por c parent[c] ← ga rank [pa] ← rank [pa] − 1 if active[r + 1] = pa then if key[c] < key[ga] then active[r + 1] = c else active[r + 1] = nil else update(c) Na definição da transformação pairTransform, linha 8, é invocada a operação combine. Dados dois nós a e b com o mesmo grau r, esta operação retorna uma árvore com grau r + 1 que resulta de combinar a sub-árvore com raiz a e a sub-árvore com raiz b. 2.2 Amontoados Relaxados ga gb ga 17 gb k(pa)<k(pb) Br pa Br a Br pb Br Br pa Br b Br pb Br Br c Br Br d Br Figura 2.4. Caso geral da transformação pairTransform. Os nós c e d são a ou b mediante a comparação da chaves key[a] e key[b]. ga pa gb 4 1 c) a 1 5 3 2 30 12 10 5 pb 8 2 14 20 15 13 b 22 0 15 27 21 11 13 22 36 14 24 ga pa gb 4 d) 1 1 5 3 5 12 30 10 0 pb 22 b=c 8 2 20 13 21 15 22 a 2 14 15 11 13 22 36 14 24 Figura 2.5. Aplicação da transformação pairTransform ao amontoado b) da Figura 2.3 após uma operação decreaseKey. Primeiro a chave do nó b passou de 25 para 0, constituindo um nó activo, e numa segunda operação a chave do nó a passou de 7 para 2. Uma vez que a e b têm o mesmo grau, é necessário aplicar a transformação pairTransform ao nó a conduzindo ao amontoado d). Neste caso ga = head e o nó c, que resulta de combinar a e b, fica activo e requer outra transformação. Note-se que c não é o último filho. Se key[a] ≤ key[b], então a árvore retornada terá como raiz o nó a e o nó b passa a ser o último filho de a. Se key[a] > key[b] verifica-se o oposto. Porém esta operação pode fazer com que a propriedade 3 não se verifique. Assuma-se, sem perda de generalidade, que key[a] ≤ key[b]. Se antes da operação combine o último filho do nó a for activo, i.e., active[r − 1] = child [a][r − 1], após a operação esse nó continuará activo mas já não será o último filho uma vez que o grau do nó a passou a ser r + 1 e o último filho é o nó b. Nestas 2.2 Amontoados Relaxados c Br 18 c xc d Br Br Br xd xd d Br Br Br xa Br Figura 2.6. Aplicação da operação clean ao nó xc. situações é necessário aplicar a operação clean que permuta o filho activo de a com grau r − 1 com o filho r − 1 de b. Note-se que se o filho de a com grau r − 1 for activo, o filho de b com grau r − 1 não pode ser activo pela propriedade 2. Portanto a operação clean produz sempre o efeito desejado, restaurando a propriedade 3. A Figura 2.6 ilustra esta operação no caso geral. A operação combine define-se da seguinte forma: combine(a, b) 1 2 3 4 5 6 7 8 9 10 11 12 a e b são dois nós com o mesmo grau r if key[a] ≤ key[b] then child [a][r] ← b parent[b] ← a rank [a] ← rank [a] + 1 c←a else child [b][r] ← a parent[a] ← b rank [b] ← rank [b] + 1 c←b clean(child [c][r − 1]) return c Considere-se agora a transformação activeSiblingTransform invocada na operação update, linha 15. Esta transformação aplica-se quando o nó a é mau, não é o último filho e o seu irmão direito é activo. Recorde-se que p = parent[a] é o pai de a, r = rank [a] é o grau de a e s = child [p][r + 1] é o irmão direito de a. Pela propriedade 3 tem-se que s é o último filho de p e, portanto, p tem grau r + 2 uma vez que a tem grau r e s tem grau r + 1. Seja g = parent[p] o nó pai de p, avô de a. A Figura 2.7 ilustra a sequência de operações da transformação activeSiblingTransform. Começa-se por remover o nó mau a e nó activo s do amontoado. De seguida remove-se o nó p do amontoado que agora 2.2 Amontoados Relaxados 19 g k(a) > k(s) s a B r+1 g Br p p Br Br a Br s B r+1 g k(a) < k(s) a Br p Br s B r+1 Figura 2.7. Caso geral da transformação activeSiblingTransform. tem grau r uma vez que perdeu os dois filhos de maior grau. Em terceiro lugar aplicam-se duas vezes a operação combine. Primeiro aos nós p e a com grau r dando origem a uma árvore de grau r + 1 com raiz a. Neste caso a raiz é a porque a era um nó mau, i.e., key[a] < key[p]. A árvore de grau r + 1 é combinada de seguida com a sub-árvore com raiz s dando origem a uma árvore com grau r + 2. Finalmente a raiz c da nova árvore de grau r + 2 passa a ser o filho de grau r + 2 de g. Tal como na transformação pairTransform é necessário verificar se o nó c é mau, i.e., se key[c] < key[g]. E da mesma forma que na transformação pairTransform, basta invocar update(c) no final. É importante notar que esta transformação também reduz o número de nós activos em pelo menos um. No inı́cio existiam dois nós activos, o nó mau a e o nó activo s, e no final apenas c poderá vir a ser activo. Todos os outros nós do amontoado permanecem inalterados. A transformação activeSiblingTransform define-se da seguinte forma: 2.2 Amontoados Relaxados 20 activeSiblingTransform(a) 1 2 3 4 5 6 7 8 9 10 11 12 13 r ← rank [a] p ← parent[a] g ← parent[p] s ← child [p][r + 1] active[r + 1] ← nil s era activo, i.e., active[r + 1] = s rank [p] ← rank [p] − 2 p perde os dois filhos de maior grau a ← combine(a, p) como a era mau, i.e., key[a] < key[p], a raiz continua a ser a c ← combine(a, s) c é raiz de uma árvore de grau r + 2 child [g][r + 2] ← c parent[c] ← g if active[r + 2] = p depois de combinar p com a, p já não pode ser activo then active[r + 2] = nil update(c) A operação update depende ainda da transformação goodSiblingTransform. Esta transformação aplica-se quando o nó a é mau, não é o último filho e o irmão direito não é activo. Como nos casos anteriores, p = parent[a] é o pai de a, r = rank [a] é o grau de a, s = child [p][r + 1] é o irmão direito de a e g = parent[p] o pai de p. Ao contrário do que acontece na transformação activeSiblingTransform, aqui s pode não ser o último filho. A transformação começa por distinguir dois casos. A Figura 2.8 ilustra a transformação goodSiblingTransform no caso geral. Se o último filho c de s não for activo, basta aplicar a operação clean ao nó a e este permuta com o nó c, passando a ser um último filho de s. Caso exista outro nó activo com grau r, é ainda necessário aplicar a transformação pairTransform ao nó a. Na prática aplica-se a operação update ao nó a após a operação clean. Este é o caso do amontoado d) da Figura 2.5, em que é necessário aplicar a transformação goodSiblingTransformation. No segundo caso o nó c é activo e portanto basta aplicar a transformação pairTransform ao nó a, garantindo que s continua a ser filho de p. Note-se que neste caso o pai de s e avô de c é o nó p, logo à partida poderia haver problemas com a aplicação da operação pairTransform. Porém s não é activo e portanto é um nó bom, i.e., key[p] < key[s], sendo que no resultado da transformação pairTransform o nó s continua a ser filho do nó p. Apenas é necessário garantir que s também continua a ser filho de p quando key[p] = key[s], o que se verifica na definição da transformação pairTransform, linha 9. É importante referir que, embora 2.2 Amontoados Relaxados g g p p 21 a) Br a s Br Br ... Br c ... c s Br Br a Br Br g g p p b) Br a Br s Br ... c Br Br s Br COMBINE a c Br Br ... Figura 2.8. Caso geral a), quando o nó c não é activo, e caso geral b), quando o nó c é activo, da transformação goodSiblingTransform. no segundo caso a transformação pairTransform garante que o número de nós activos é reduzido em pelo menos um, no primeiro caso pode não ocorrer redução do número de nós activos. No entanto, se a operação update não reduzir o número de nós activos no primeiro caso, é porque não existe outro nó activo com grau r e as propriedades de rank relaxed heap são válidas. A transformação goodSiblingTransform define-se da seguinte forma: goodSiblingTransform(a) 1 2 3 4 5 6 7 8 9 r ← rank [a] p ← parent[a] g ← parent[p] s ← child [p][r + 1] irmão direito de a c ← child [s][r] último filho de s if active[r] = c se c for activo then pairTransform(a) else clean(a) update(a) Embora não seja óbvio, a operação decreaseKey decorre em tempo O(1) amortizado. Cada uma das transformações descritas opera em tempo O(1) no pior caso, excluindo a possı́vel execução no final de outra transformação. Pela propriedade 2 de rank relaxed heap 2.2 Amontoados Relaxados 22 existem no máximo blog(n)c nós activos. Portanto, tendo em conta que cada transformação reduz o número de nós activos em pelo menos um e que pode executar outra transformação, a operação decreaseKey decorrerá em tempo O(log n) no pior caso. Porém, considere-se m operações decreaseKey. Cada uma destas operações pode introduzir no máximo um nó activo ao diminuir a chave do mesmo. Por outro lado cada transformação reduz um nó activo quando é executada. Logo, independentemente do número de transformações que é executado em cada operação decreaseKey, em m operações decreaseKey apenas podem ser executadas m transformações no máximo admitindo que no inı́cio não existiam nós activos. Portanto, m operações decreaseKey decorrem em tempo O(m) no pior caso, i.e., a operação decreaseKey decorre em tempo O(1) amortizado. Operação removeMin A operação removeMin encontra e retorna o nó com a menor chave no amontoado. O nó com menor chave ou é uma raiz das árvores binomiais no amontoado, i.e., um dos filhos do nó head , ou é um dos nós activos. Como discutido anteriormente, o número de árvores binomiais no amontoado é no máximo blog(n)c+1 e o número de nós activos no amontoado é no máximo blog(n)c. Logo o nó com menor chave pode ser encontrado em tempo O(log(n)), bastando percorrer os filhos do nó head e os nós activos. Aqui pressupõe-se que é possı́vel enumerar os nós activos de forma eficiente, ver-se-á adiante como fazer. Após determinar o nó com a menor chave, denotado por nó min, este será removido do amontoado. A Figura 2.9 ilustra a eliminação do nó min no caso deste se encontrar na raiz de uma árvore binomial no amontoado, i.e., quando parent[min] = head . Primeiro retira-se o nó raiz y no amontoado com menor grau e adicionam-se todos os seus filhos como filhos do nó head , i.e., os filhos de y passam a ser raı́zes do amontoado. Note-se que esta operação não produz árvores binomiais com o mesmo grau no amontoado, basta observar que todos os nós filhos de y têm grau distinto e inferior ao grau de y e y é a raiz com menor grau. Logo, as propriedades de rank relaxed heap permanecem válidas. Caso o nó min seja o próprio y, a operação removeMin está concluı́da. Caso min 6= y é necessário recombinar o nó y com os filhos do nó min e substituir o nó min no amontoado pela árvore resultante da recombinação. Na recolocação dos filhos de y e na operação de recombinação de y com os filhos do nó min dever-se-á desmarcar os nós que estejam activos e que deixam de o ser. É ainda necessário verificar se a substituição do nó min dá origem a um novo nó activo. Uma vez que o número de filhos de um nó é no máximo blog(n)c e que a operação combine decorre em tempo O(1) no pior caso, tem-se que estas operações 2.2 Amontoados Relaxados ... y x0 . . . Br ... min y0 . . . yr 23 xk Bk y0 ... yr ... COMBINE y x0 ... xk Figura 2.9. Operação removeMin quando o nó min é a raiz de uma das árvores binomiais no amontoado. Quando o nó min é um dos nós activos a operação é idêntica. decorrem em tempo O(log n) no pior caso. Repare-se que a operação removeMin não aumenta o número de nós activos e, portanto, não afecta o tempo amortizado da operação decreaseKey. A operação removeMin define-se da seguinte forma: 2.2 Amontoados Relaxados removeMin() 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 if rank [head ] = 0 se o amontoado for vazio then return nil min ← head y ← nil for r = 0 to blog(n)c procurar o nó min e o filho y do nó head com menor grau do x ← child [head ][r] filho de grau r do nó head if x 6= nil ∧ key[min] > key[x] then min ← x if y = nil verificar se y já foi encontrado then y ← x x ← active[r] nó activo de grau r if x 6= nil ∧ key[min] > key[x] then min ← x for r = 0 to rank [y] adicionar todos os filhos de y ao nó head do child [head ][r] ← child [y][r] parent[child [y][r]] ← head if active[rank [y] − 1] = child [y][rank [y] − 1] se o último filho de y for activo then active[rank [y] − 1] = nil rank [y] ← 0 p ← parent[min] if active[rank [min]] = min se min for um nó activo then active[rank ] ← nil if min 6= y then for r = 0 to rank [min] recombinar y com todos os filhos do nó min do y ← combine(y, child [min][r]) child [p][rank [min]] ← y parent[y] ← p if key[y] < key[p] then active[rank [y]] = y isto só ocorre se o nó min era activo else active[rank [y]] = nil return min 24 2.2 Amontoados Relaxados 25 Outras Operações Para além das operações já descritas, os rank relaxed heaps também suportam as operações minimum e insert com tempo O(1) no pior caso e as operações remove e meld em tempo O(log(n)) no pior caso. Nesta Secção ver-se-á como concretizar as operações minimum, insert e meld. A operação remove é em tudo idêntica à operação removeMin, apenas o nó é dado como argumento em vez de ser o nó min. A operação minimum pode ser facilmente definida com tempo O(1) no pior caso alterando a definição da operação decreaseKey por forma a manter o nó min actualizado. Nesse caso a operação deleteMin não precisa de procurar o nó com a menor chave no inı́cio, mas terá de actualizar o nó com a menor chave depois de eliminar o nó min. Reparese que estas alterações não alteram os tempos das operações decreaseKey e deleteMin. No primeiro caso basta comparar o nó a actualizar com o nó min actual e no segundo caso basta fazer a procura no final em vez de no inı́cio. Da mesma forma a operação insert terá de actualizar o nó min aquando da inserção de um novo nó. Com estas alterações o nó min será sempre o nó com a menor chave e a operação minimum só tem de retornar esse nó, o que é feito em tempo O(1) no pior caso. Definir a operação insert com tempo O(log(n)) no pior caso é relativamente simples. Cria-se um novo nó x e, enquanto existir uma raiz y no amontoado, i.e., y é filho do nó head , com o mesmo grau que x, substitui-se y pelo resultado de combine(x, y). Uma vez que o número de filhos do nó head é no máximo blog(n)c + 1 e que a operação combine decorre em tempo O(1) no pior caso, tem-se que esta versão da operação insert decorre em tempo O(log(n)) no pior caso. Veja-se agora como concretizar a operação insert com tempo O(1) no pior caso. A ideia é também recombinar as raı́zes, contudo será feito de forma gradual. O nó head passa a poder ter filhos com o mesmo grau, de facto poderá ter no máximo dois com o mesmo grau r para cada r. Cada par de raı́zes com o mesmo grau é mantido numa lista de pares e, sempre que for inserido um novo nó, a operação insert retira um par de raı́zes da lista de pares e aplica a operação combine reduzindo o número de pares, i.e., o número de raı́zes com o mesmo grau, em uma unidade. Note-se que desta operação pode surgir um novo par, i.e., pode já existir outra raiz com o mesmo grau. Portanto, o número de pares de raı́zes com o mesmo grau pode manter-se. Todavia, dado que o número de raı́zes é finito, sucessivas inserções levarão à diminuição do número de pares que será sempre inferior a blog(n)c + 1. Uma vez que a operação combine decorre em tempo O(1) no pior caso, a operação insert também decorre em tempo O(1) no pior caso. Existe apenas mais um detalhe a ter em conta, a procura do nó com menor chave 2.2 Amontoados Relaxados 26 terá de ser modificada. A forma mais simples é, na operação removeMin onde se procura entre as raı́zes e os nós activos o nó com menor chave, resolver todos os conflitos na lista de pares. Repare-se que isto não altera o tempo de execução da operação removeMin pois, embora o número de raı́zes possa ser agora 2 blog(n)c + 2, este continua a ser O(log(n)). A operação meld, i.e., a fusão de dois amontoados, pode ser definida com tempo O(log(n)) de forma idêntica à primeira versão da operação insert discutida antes. Portanto, o processo consiste em juntar todas as raı́zes por ordem crescente de grau e aplicar a operação combine tantas vezes quanto as necessárias. Assumindo-se que os amontoados têm n e n0 elementos, a operação combine tem ser aplicada blog(n)c + blog(n0 )c + 1 vezes no pior caso. Logo, a operação meld decorre em tempo O(log(n)) no pior caso. 2.2.2 Run Relaxed Heaps Tal como nos rank relaxed heaps, a ideia é permitir a ocorrência de nós maus no amontoado. Contudo, ainda que continuem a existir no máximo blog(n)c nós activos, existe maior flexibilidade quanto à sua ocorrência. De facto no caso dos run relaxed heaps os nós activos podem ocorrer livremente no amontoado e não apenas em determinados locais. Esta diferença permitirá definir a operação decreaseKey com tempo O(1) no pior caso. Portanto, um run relaxed heap é constituı́do por um conjunto de árvores binomiais, onde existem nós que são distinguidos por activos, e que satisfazem as seguintes propriedades: 1. existe no máximo uma árvore binomial no amontoado cuja raiz tenha um dado grau; 2. cada árvore binomial é ordenada em amontoado, embora possam existir no máximo blog(n)c nós activos em toda a colecção; 3. cada nó de uma árvore binomial no amontoado tem os filhos ordenados por grau. Uma vez que os nós activos podem ocorrer livremente no amontoado, podem existir sequências de dois ou mais irmãos activos. Estas sequências são designadas na literatura por runs, daı́ a designação run relaxed heap. Quando um nó activo não faz parte de uma sequência de nós activos, diz-se que é um nó singular. A principal dificuldade nestes amontoados é gerir os nós activos de forma a garantir os tempos desejados para as operações. Para tal, considere-se a estrutura de dados auxiliar run-singleton: 1. para cada grau r existe uma lista singleList[r] que contém todos os nós activos singulares com grau r; 2. a lista runList guarda o último nó, i.e., o nó de maior grau, de cada sequência de nós activos; 2.2 Amontoados Relaxados 27 3. a lista pairList guarda os graus r para os quais a lista singleList[r] tem mais de dois elementos, i.e., |singleList[r]| ≥ 2. Dado que existem no máximo blog(n)c nós activos, tem-se que esta estrutura requer no máximo O(log(n)) espaço. Contudo, isto implica que uma implementação eficiente utilize, por exemplo, listas duplamente ligadas. Logo, para garantir todas as operações com tempo O(1) é necessário guardar informação a respeito da posição na lista em que cada nó ocorre. Na prática cada nó deverá manter uma referência para a posição na lista, caso ocorra em alguma das listas singleList[r] ou pairList. Note-se que um nó não pode ocorrer em simultâneo em ambas as listas. Da mesma forma convém guardar para cada grau r, caso existam mais de dois nós activos com grau r, a referência da sua posição na lista pairList. Estas referências para os nós das listas são importantes para que se possam eliminar elementos em tempo O(1). Tendo em conta estas observações é então possı́vel concretizar as seguintes operações em tempo constante: 1. addSingleton(x), adiciona o nó x com grau r à lista singleList[r] e, caso se verifique |singleList[r]| ≥ 2, adiciona r à lista pairList; 2. removeSingleton(x), remove o nó x com grau r da lista singleList[r] e, caso se verifique |singleList[r]| < 2, remove r da lista pairList; 3. addRun(x), adiciona o nó x à lista runList; 4. removeRun(x), remove o nó x da lista runList. Na representação dos run relaxed heaps, cada nó terá os atributos vistos no caso dos rank relaxed heaps e um quinto atributo booleano isActive. Este atributo permite marcar cada nó como activo ou inactivo e determinar se um dado nó é activo em tempo constante. Repara-se que a estrutura run-singleton não guarda todos os nós activos, apenas os singulares e último nó de cada sequência de nós activos, pelo que não permite decidir em tempo constante se um dado nó é activo ou não. A inicialização de um run relaxed heap é em tudo igual à inicialização de um rank relaxed heap, acrescendo apenas a instrução isActive[x] ← false. Antes de se discutirem as operações sobre o amontoado é útil definir as operações auxiliares setActive e setInactive. Dado um nó a, estas operações actualizam o estado de a e a estrutura de dados run-singleton. Sejam r = rank [a] o grau de a, p = parent[a] o nó pai de a, s = child [p][r + 1] o irmão direito de a e t = child [p][r − 1] o irmão esquerdo de a. Note-se que s e t podem não existir, e.g., o nó a pode ser o último filho de p e nesse caso não existe o nó s. A operação setActive começa por marcar o nó a activo e actualiza a estrutura de dados run-singleton da seguinte forma: 2.2 Amontoados Relaxados 28 1. se os nós t e s não forem activos, então a é nó activo singular e basta ser adicionado como nó singular, addSingleton(a); 2. se t não for activo e s for activo, então a passa a fazer parte de uma sequência de nós activos e, se s for nó activo singular, é necessário efectuar as operações removeSingleton(s) e addRun(s); 3. se t for activo e s não for activo, então a é o nó de maior grau numa sequência de nós activos, i.e., o último nó da sequência, e é necessário efectuar a operação addRun(a) e ou a operação removeSingleton(t) ou a operação removeRun(t) conforme t seja um nó activo singular ou não, respectivamente (uma vez que t é activo, ou era singular ou era o último nó de uma sequência); 4. se os nós t e s forem activos, então a faz parte de uma sequência de nós activos e processam-se os nós s e t como nos casos 2 e 3, respectivamente. Sejam s0 = child [p][r + 2] o irmão direito de s e t0 = child [p][r − 2] o irmão esquerdo de t, que podem não existir. A operação dual, setInactive, começa por marcar o nó a não activo e actualiza a estrutura de dados run-singleton da seguinte forma: 1. se os nós t e s não forem activos, então a era nó activo singular e basta efectuar a operação removeSingleton(a); 2. se t não for activo e s for activo, então a fazia parte de uma sequência de nós activos e, se s0 não existir ou não for activo, s era o último nó da sequência e é necessário efectuar as operações removeRun(s) e addSingleton(s); 3. se t for activo e s não for activo, então a era o nó de maior grau numa sequência de nós activos, i.e., o último nó da sequência, portanto efectua-se a operação removeRun(a), e se t0 não existir ou não for activo, t passa a ser singular e efectua-se a operação addSingleton(t), caso contrário t passa a ser o último da sequência e efectua-se a operação addRun(t); 4. se os nós t e s forem activos, então a faz parte de uma sequência de nós activos e processam-se os nós s e t como nos casos 2 e 3, respectivamente. Tendo em conta que as operações addSingleton, removeSingleton, addRun e removeRun decorrem em tempo O(1) no pior caso, tem-se que as operações setActive e setInactive também ocorrem em tempo O(1) no pior caso. Em detalhe, setActive e setInactive definem-se da seguinte forma: 2.2 Amontoados Relaxados setActive(a) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 r ← rank [a] p ← parent[a] if r < rank [p] − 1 se existir irmão direito de a then s ← child [p][r + 1] else s ← nil if r > 0 se existir irmão esquerdo de a then t ← child [p][r − 1] else t ← nil if r < rank [p] − 2 se existir irmão direito de s then s0 ← child [p][r + 2] else s0 ← nil if r > 1 se existir irmão esquerdo de t then t0 ← child [p][r − 2] else t0 ← nil isActive[a] ← true if ¬ isActive[t] ∧ ¬ isActive[s] then addSingleton(a) else if ¬ isActive[t] ∧ isActive[s] then if s0 = nil ∨ ¬ isActive[s0 ] se s for singular then removeSingleton(s) addRun(s) else if isActive[t] ∧ ¬ isActive[s] then if t0 = nil ∨ ¬ isActive[t0 ] se t for singular then removeSingleton(t) else removeRun(t) addRun(a) else if s0 = nil ∨ ¬ isActive[s0 ] se s for singular then removeSingleton(s) addRun(s) 0 if t = nil ∨ ¬ isActive[t0 ] se t for singular then removeSingleton(t) else removeRun(t) 29 2.2 Amontoados Relaxados setInactive(a) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 r ← rank [a] p ← parent[a] if r < rank [p] − 1 se existir irmão direito de a then s ← child [p][r + 1] else s ← nil if r > 0 se existir irmão esquerdo de a then t ← child [p][r − 1] else t ← nil if r < rank [p] − 2 se existir irmão direito de s then s0 ← child [p][r + 2] else s0 ← nil if r > 1 se existir irmão esquerdo de t then t0 ← child [p][r − 2] else t0 ← nil isActive[a] ← false if ¬ isActive[t] ∧ ¬ isActive[s] then removeSingleton(a) else if ¬ isActive[t] ∧ isActive[s] then if s0 = nil ∨ ¬ isActive[s0 ] s torna-se singular then removeRun(s) addSingleton(s) else if isActive[t] ∧ ¬ isActive[s] then removeRun(a) if t0 = nil ∨ ¬ isActive[t0 ] t torna-se singular then addSingleton(t) else addRun(t) else if s0 = nil ∨ ¬ isActive[s0 ] s torna-se singular then removeRun(s) addSingleton(s) 0 if t = nil ∨ ¬ isActive[t0 ] t torna-se singular then addSingleton(t) else addRun(t) 30 2.2 Amontoados Relaxados 31 Operação decreaseKey Os run relaxed heaps permitem executar a operação decreaseKey em tempo O(1) no pior caso. A diferença em relação aos rank relaxed heaps reside nas transformações realizadas para garantir as propriedades dos amontoados, nomeadamente na definição da operação update. Nesta Secção discutir-se-ão as alterações e as novas transformações necessárias. A operação update verifica se um dado nó deve ser activo ou não e, caso seja um nó mau e não activo, marca-o como activo e actualiza a estrutura de dados run singleton. Uma vez que esta operação aumenta o número de nós activos, a ideia consiste em tentar reduzir um nó activo sempre que é adicionado um novo nó activo. Para isso verifica-se se existem dois nós activos singulares com o mesmo grau e, caso existam, aplica-se a transformação singletonTransform. Em segundo lugar verifica-se se existe alguma sequência de nós activos e, caso exista, aplica-se a transformação runTransform. Quer a transformação singletonTransform quer a transformação runTransform reduzem o número de nós activos em pelo menos um. Note-se que, caso ambas as listas pairList e runList sejam vazias, existe no máximo um nó activo por cada grau. Logo, existem no máximo blog(n)c nós activos e, portanto, as condições de run relaxed heap verificam-se. A operação update define-se da seguinte forma: update(a) 1 p ← parent[a] 2 if key[a] ≥ key[p] 3 then if isActive[a] 4 then setInactive(a) 5 else if ¬ isActive[a] a não é activo e key[a] < key[p] 6 then setActive(a) 7 if pairList 6= ∅ 8 then r ← removeFirst(pairList) 9 x ← removeFirst(singleList[r]) 10 y ← removeFirst(singleList[r]) 11 singletonTransform(x, y) 12 if runList 6= ∅ 13 then x ← removeFirst(runList) 14 runTransform(x) 2.2 Amontoados Relaxados 32 As operações singletonTransform e runTrasnform consistem na combinação de versões simplificadas das transformações discutidas para o caso dos rank relaxed heaps. Os run relaxed heaps não exigem que apenas exista um grau activo por cada grau nem que os nós activos tenham de ser os últimos filhos, portanto não será necessário realizar a operação update no final das transformações pairTransform, activeSiblingTransform e goodSiblingTransform. Da mesma forma não é necessário realizar a operação clean após ligar dois nós com o mesmo grau na operação combine. No que se segue utiliza-se uma versão simplificada da operação combine, designada link, cuja única diferença é não invocar a operação clean. A operação pairTransform passa a receber dois parâmetros e define-se da seguinte forma: pairTransform(a, b) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 setInactive(a) setInactive(b) r ← rank [a] pa ← parent[a] pb ← parent[b] ga ← parent[pa] gb ← parent[pb] c ← link(a, b) if key[pa] ≤ key[pb] then child [pa][r] ← pb tornar pb o último filho de pa parent[pb] ← pa child [gb][r + 1] ← c substituir pb enquanto filho de gb por c parent[c] ← gb rank [pb] ← rank [pb] − 1 if isActive[pb] then setInactive(pb) if key[c] < key[gb] then setActive(c) else . . . da mesma forma, mas trocando a, pa e ga com b, pb e gb A operação activeSiblingTransform passa a definir-se da seguinte forma: 2.2 Amontoados Relaxados 33 activeSiblingTransform(a) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 r ← rank [a] p ← parent[a] g ← parent[p] s ← child [p][r + 1] setInactive(a) setInactive(s) if isActive[p] then setInactive(p) rank [p] ← rank [p] − 2 p perde os dois filhos de maior grau a ← link(a, p) como a era mau, i.e., key[a] < key[p], a raiz continua a ser a c ← link(a, s) c é raiz de uma árvore de grau r + 2 child [g][r + 2] ← c parent[c] ← g if key[c] < key[g] se c for mau then setActive(c) E a operação goodSiblingTransform passa a retornar true se reduziu o número de nós activos, false caso contrário, e define-se da seguinte forma: goodSiblingTransform(a) 1 2 3 4 5 6 7 8 9 10 11 12 r ← rank [a] p ← parent[a] g ← parent[p] s ← child [p][r + 1] irmão direito de a c ← child [s][r] último filho de s if isActive[c] se c for activo then pairTransform(a, c) return true pairTransform reduz pelo menos um nó activo else setInactive(a) clean(a) setActive(a) return false Uma vez que a operação update deixou de ser invocada, todas as transformações decorrem agora em tempo O(1) no pior caso. 2.2 Amontoados Relaxados 34 Dados dois nós activos a e b singulares com grau r, a transformação singletonTransform reduz o número de nós activos em pelo menos um, em tempo O(1) no pior caso. Sejam pa = parent[a] o nó pai de a, sa = child [pa][r +1] o irmão direito de a, pb = parent[b] o nó pai de b e sb = child [pb][r + 1] o irmão direito de b. Note-se que os nós sa e sb ou não existem (quer a quer b podem ser últimos filhos) ou não são activos, caso contrário a e b não seriam singulares. Existem quatro casos a considerar: 1. se a e b são ambos últimos filhos, i.e., rank [pa] = r + 1 and rank [pb] = r + 1, então aplica-se a transformação pairTransform a a e b, cf. Figura 2.4; 2. se a é último filho e b não é último filho, então aplica-se a transformação goodSiblingTransform a b cf. Figura 2.8 e, caso não reduza o número de nós activos cf. Figura 2.8 a), aplica-se a transformação pairTransform aos nós a e b; 3. se a não é último filho e b é último filho, idêntico ao caso anterior trocando a com b; 4. se a e b não forem últimos filhos, então processam-se ambos os nós como nos casos 1 e 3 e, se a transformação goodSiblingTransform não reduzir o número de nós activos, aplica-se no final a transformação pairTransform aos nós a e b. Como a transformação pairTransform reduz sempre o número de nós activos em pelo menos um, tem-se que a transformação singletonTransform também reduz o número de nós activos. A transformação runTransform aplica-se ao último nó a de uma sequência de nós activos e reduz o número de nós activos em pelo menos um. Sejam p = parent[a] o nó pai de a, t = child [p][r − 1] o irmão esquerdo de a e s = child [p][r + 1] o irmão direito de a. Note-se que t existe sempre e é activo, uma vez que a é último de uma sequência de nós activos. Por outro lado, s pode não existir se a for o último filho de p. Existem dois casos a considerar. Se a for o último filho de p, então basta aplicar a transformação activeSiblingTransform ao nó t cf. Figura 2.7. Se a não for último filho de p, então aplica-se a transformação ilustrada na Figura 2.10. A transformação consiste em trocar os nós t e a pelo penúltimo filho d e último filho c de s, respectivamente. É importante notar que, se a tiver grau r, então t tem grau r − 1, s tem grau r + 1, d tem grau r − 1 e c tem grau r. Portanto, a árvore binomial continua consistente. Também importante é o facto dos nós c e d poderem ser activos e de poderem continuar a ser activos, sendo necessário actualizar a estrutura de dados run-singleton face à troca. Em particular porque os nós c e d podiam fazer parte de uma sequência de nós activos ou vir a integrar uma nova sequência nós activos. Os nós t, a e s são combinados para formar uma árvore de grau r + 1 com raiz 2.2 Amontoados Relaxados 35 p Br−1 t a s Br−1 Br Br−1 ... d c Br−1 Br p Br−1 ... d c Br−1 Br LINK(t,a) t Br−1 s a Br−1 Br Figura 2.10. Aplicação da transformação runTransform quando a não é o último filho de p. O nó que resulta de ligar a e t é activo, uma vez que t e a eram activos. Se d e c forem activos, é necessário verificar se o continuam a ser. c que passará a ser o filho de grau r + 1 de p, i.e., substitui s. Note-se que da recombinação dos nós t, a e c pode resultar um nó activo, a raiz da nova árvore pode ser um nó mau. No entanto foi reduzido o número de nós activos em pelo menos um, visto os nós a e t serem ambos activos. A transformação runTransform decorre também em tempo O(1) no pior caso, note-se que todas as operações envolvidas decorrem em tempo constante. As transformações singletonTransform e runTransform definem-se da seguinte forma: 2.2 Amontoados Relaxados singletonTransform(a, b) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 r ← rank [a] pa ← parent[a] pb ← parent[b] if r < rank [pa] − 1 se existir irmão direito de a then sa ← child [pa][r + 1] else sa ← nil if r < rank [pb] − 1 se existir irmão direito de b then sb ← child [pb][r + 1] else sb ← nil if sa = nil ∧ sb = nil se a e b forem últimos filhos then pairTransform(a, b) else if sa = nil ∧ sb 6= nil se a for último filho mas b não then if ¬goodSiblingTransform(b) then pairTransform(a, b) else if sa 6= nil ∧ sb = nil se a não for último filho mas b for then if ¬goodSiblingTransform(a) then pairTransform(a, b) else se a e b não forem últimos filhos if ¬goodSiblingTransform(a) then if ¬goodSiblingTransform(b) then pairTransform(a, b) 36 2.2 Amontoados Relaxados runTransform(a) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 r ← rank [a] p ← parent[a] t ← child [p][r − 1] t é o irmão esquerdo de a if r < rank [p] − 1 se existir irmão direito de a then s ← child [p][r + 1] d ← child [s][r − 1] penúltimo filho de s c ← child [s][r] último filho de s if isActive[d] then setInactive(d) if isActive[c] then setInactive(c) else s ← nil if s = nil se a for o último filho de p then activeSiblingTransform(t) else setInactive(a) setInactive(t) rank [s] ← rank [s] − 2 o nó s perde os dois últimos filhos child [p][r − 1] ← d d substitui t parent[d] ← p child [p][r] ← c c substitui a parent[c] ← p t ← link(t, s) e ← link(t, a) child [p][r + 1] ← e e substitui s parent[e] ← p if key[c] < key[p] then setActive(c) if key[d] < key[p] then setActive(d) if key[e] < key[p] then setActive(e) 37 2.3 Detalhes de Implementação 38 Outras operações As restantes operações são em tudo idênticas às discutidas no caso dos rank relaxed heaps. Apenas é necessário ter em conta que no caso das operações removeMin, remove, insert e meld ter-se-á que utilizar a operação link em vez da operação combine para combinar os nós. E que em todas as operações os nós passam a ser marcados activos ou inactivos com as operações setActive e setInactive, respectivamente. Os tempos das operações são também os mesmos, i.e., as operações removeMin, remove e meld decorrem em tempo O(log(N )) no pior caso e as operações insert e minimum decorrem em tempo O(1) no pior caso, tal como agora a operação decreaseKey. 2.3 Detalhes de Implementação As árvores binomiais são normalmente implementadas com ponteiros ou referências entre os nós. Esta abordagem tem a vantagem de que a estrutura de dados é dinâmica e, por exemplo, pode-se facilmente extender a sua capacidade. Contudo este é também o facto que penaliza mais a implementação dos amontoados baseados em árvores binomiais. Na prática, a implementação dos amontoados binários sobre arrays é bastante mais eficiente, não obstante a maior dificuldade em extender a sua capacidade. Seguindo algumas das ideias propostas por Driscoll et al. [14], propõe-se aqui uma implementação dos amontoados relaxados sobre arrays. Nos anexos A.1 e A.2 apresenta-se a implementação em Java. A principal dificuldade tem a haver com a relação entre os ı́ndices do vector e os nós por forma a aceder eficientemente aos descendentes e ao pai de cada nó. Seja n a capacidade do amontoado. No que se segue, assume-se que os elementos são inteiros maiores ou iguais a 0 e menores que n. Note-se que não existe perda de generalidade, visto que em geral os elementos podem ser guardados num vector de dimensão n e o amontoado apenas tem conhecimento dos ı́ndices. Neste caso, o grau máximo de cada nó é blog(n)c, excepto o nó head . Como foi visto atrás o nó head pode ter blog(n)c + 1 filhos. Desta forma uma abordagem consiste em representar cada nó como uma sequência de 2 + log(n) inteiros. O amontoado será representado num vector com n ∗ (2 + blog(n)c) entradas cf. Figura 2.11. Esta representação requer O(n log(n)) espaço. parent rank child0 child1 childlog (n) 2 Figura 2.11. Representação de um amontoado sobre arrays, nomeadamente a representação de um nó. 2.3 Detalhes de Implementação 39 Seguindo uma sugestão proposta por Driscoll et al. [14] é possı́vel reduzir o espaço e ainda assim obter uma implementação eficiente. Em vez de cada nó representar um elemento, cada nó passa a representar um conjunto ou grupo de elementos. Cada grupo terá gn = dlog(n)e elementos no máximo. Portanto, o amontoado terá n/gn nós e a representação proposta requer O(n/gn log(n/gn)) espaço. Note-se que O((n/gn) log(n/gn)) = O((n/ log(n))(log(n) − log(log(n))) = O(n − log(log(n))/ log(n)). Logo, o espaço neste caso é linear em relação a n. É importante notar que agora cada nó tem de manter o ı́ndice do menor elemento do grupo que representa cf. Figura 2.12. min parent rank child0 child1 childlog(n) Figura 2.12. Representação de um amontoado sobre arrays, nomeadamente a representação de um nó, quando cada nó representa um grupo de elementos. O facto de cada nó representar gn elementos não interfere com o tempo das operações. A diferença reside na forma como se encontra o mı́nimo, em particular quando se remove o elemento com menor chave. Este é o único caso em que é necessário processar todos os elementos de um grupo para encontrar o novo elemento com menor chave. Contudo, uma vez que cada grupo tem O(log(n)) elementos, o tempo de procurar o novo elemento é O(log(n)). Por outro lado, dado que cada nó é um inteiro e que cada elemento é também um inteiro, pode-se mapear cada elemento para o nó a que pertence de forma eficiente. Basta mapear o k-ésimo elemento para o nó k/gn. Logo, esta representação não influência negativamente o tempo das operações. No que diz respeito à gestão dos nós activos, no caso dos rank relaxed heaps basta considerar um array de dimensão blog(n)c para guardar o nó activo em cada grau, se existir. Note-se que na operação removeMin é necessário percorrer este array para actualizar o nó com menor chave. Porém, dada a dimensão do array, o tempo da operação removeMin mantém-se O(log(n)). No caso dos run relaxed heaps, a implementação da estrutura runsingleton sobre listas duplamente ligadas garante os tempos assimptóticos discutidos. É no entanto importante relembrar que é necessário manter para cada nó a a referência para o nó da lista em que a ocorre, caso ocorra em alguma das listas. 3 Filas com Prioridade As filas com prioridade são utilizadas nas mais diversas aplicações. Por exemplo, em sistemas operativos, este tipo de dados abstracto é utilizado no escalonamento de processos e de tarefas. Também na compressão de dados são utilizadas filas com prioridade, nomeadamente em conjunto com os códigos de Huffman. No área da inteligência artificial, o algoritmo de procura A∗ também recorre a filas de prioridade. Outros exemplos de aplicação são vários algoritmos em grafos, como o algoritmo de Dijkstra e o algoritmo de Prim. É portanto importante definir o tipo de dados abstracto fila com prioridade, em particular a interface a disponibilizar ao utilizador. No que se segue começa-se por discutir o tipo de dados abstracto fila com prioridade e, em particular, a interface tı́pica a disponibilizar. Em segundo lugar discute-se alguns detalhes de implementação. 3.1 Tipo de Dados Abstracto Dado um conjunto de elementos que possuam uma chave, as operações mais comuns suportadas por este tipo de dados abstracto são: criar fila; inserir um novo elemento; remover o elemento com maior prioridade; consultar o elemento com maior prioridade; remover um elemento; aumentar a prioridade de um elemento; e juntar duas filas de prioridade. Como referido anteriormente, neste trabalho considera-se que o elemento com menor chave é o mais prioritário. Alternativamente, pode-se definir filas com prioridade em que o elemento com maior prioridade é o que tiver maior chave. Neste caso, a operação alternativa à diminuição de chave será a de incrementação de chave. A maior parte das bibliotecas apenas suporta parcialmente a implementação do tipo de dados abstracto fila com prioridade, não suportando por exemplo a operação de aumentar a prioridade de um elemento. Um exemplo desta situação é a classe PriorityQueue, definida na Java Platform Standard Ed. 6. Esta classe apenas suporta, para além das operações 3.1 Tipo de Dados Abstracto 42 comuns de uma colecção, as operações de fila, i.e., as operações da interface Queue. Como se pode observar na Figura 3.1, a interface Queue contém os seguintes métodos: poll, que remove o elemento de maior prioridade; peek, que retorna o elemento de maior prioridade, sem o remover; e offer, que insere um elemento na fila. Esta interface também contém os métodos remove, element e add, que diferem dos outros em condições fronteira. Por exemplo, o método add lança uma excepção quando o elemento não pode ser inserido devido a restrições que possam existir na capacidade. Já no caso dos métodos remove e element, estes lançam uma excepção caso a fila esteja vazia. public interface Queue<E> extends Collection<E> { boolean add(E e); boolean offer(E e); E remove(); E poll(); E element(); E peek(); } Figura 3.1. A interface java.util.Queue. Com as operações disponı́veis na interface Queue, se for necessário diminuir a chave de um determinado elemento, este terá de ser removido, a sua chave alterada e inserido novamente. Esta abordagem não é eficiente, especialmente em algoritmos como o de Dijkstra [1], em que o número de actualizações de chave é proporcional ao número de arcos no grafo. É claro que é possı́vel implementar uma fila com prioridade sobre o tipo de dados abstracto mapa ordenado, disponibilizado em quase todas as bibliotecas, como por exemplo na Java Platform Standard Ed. 6. No entanto, o custo das operações insert, removeMin e decreaseKey é O(log n), não garantindo os limites assimptóticos das estruturas de dados mais avançadas para as operações insert e decreaseKey, como por exemplo os amontoados relaxados. Deste modo, de forma a estender o tipo de dados abstracto Queue, propõe-se a interface PriorityQueue, definida na figura 3.2. Esta interface inclui o método update, o qual neste trabalho apenas suportará a diminuição de chave. Como se pode observar na figura 3.2, o método insert permite a inserção do elemento na fila com prioridade, retornando o ı́ndice onde este foi inserido. O retorno do ı́ndice pode ser gerido pela aplicação que utilizar uma implementação deste tipo de dados abstracto fila com prioridade, de modo a garantir que a 3.2 Detalhes de Implementação 43 operação update de um elemento não precisa de procurar por esse mesmo elemento. Visto o método update receber como parâmetro o elemento e o ı́ndice, o utilizador pode, por exemplo, encapsular no elemento o ı́ndice onde este ocorrer ou até definir um mapa que a cada elemento faz corresponder um ı́ndice. A outra operação que é proposta é também uma operação comum nas filas com prioridade, a operação de junção de duas filas. public interface PriorityQueue<E> extends Queue<E> { int insert(E e); boolean update(int idx, E e); boolean remove(int idx); void meld(PriorityQueue<E> pq); } Figura 3.2. A interface PriorityQueue. 3.2 Detalhes de Implementação Na implementação do tipo de dados abstracto PriorityQueue, realizada no contexto deste trabalho, é utilizado um outro tipo de dados, nomeadamente o tipo de dados abstracto amontoado, para a gestão das prioridades. A definição do amontoado como um tipo de dados foi essencial para que a implementação realizada para o tipo de dados abstracto fila com prioridade pudesse utilizar qualquer tipo de amontoado, como por exemplo os amontoados binários ou os amontoados relaxados. A definição do tipo de dados abstracto amontoado encontra-se na Figura 3.3. public interface Heap{ public Comparator<Integer> comparator(); public void insert(int idx); public void decreaseKey(int idx); public void remove(int idx); public int minimum(); public int removeMin(); } Figura 3.3. A interface Heap. 3.2 Detalhes de Implementação 44 O tipo de dados abstracto amontoado não guarda os elementos da fila com prioridade, apenas mantém a informação estritamente necessária para identificar os elementos. A implementação do tipo de dados abstracto fila com prioridade é que terá que os armazenar. Deste modo, na implementação do tipo de dados fila com prioridade feita no contexto deste trabalho, foi associada à fila um array, designado por store e definido internamente na classe, onde se encontram os elementos. Nesta implementação, o amontoado identifica os elementos pelos ı́ndices e mapeia-os para os nós internos do amontoado. Por exemplo, se no topo do amontoado estiver o ı́ndice 3, significa que o elemento mais prioritário da fila se encontra no ı́ndice 3 do array store. No entanto, poderia ter sido utilizada outra estrutura que a cada elemento fizesse corresponder um inteiro distinto. Ainda que o amontoado não contenha os elementos da fila com prioridade, as operações realizadas sobre o mesmo necessitam de comparar a chave dos elementos, i.e., é necessário existir um critério de comparação entre os elementos da fila com prioridade. Esse critério de comparação terá então de ser fornecido através de um comparador que seja definido de forma indirecta sobre os elementos da fila. Como se pode observar na Figura 3.3, o tipo de dados abstracto amontoado contém o método comparator, que retorna o comparador indirecto que está a ser utilizado. Dado que o tipo de dados amontoado definido contém inteiros e que a cada elemento da fila corresponde um inteiro distinto, um comparador indirecto pode ser definido da seguinte forma: dados dois inteiros, o comparador retorna um inteiro que expressa uma ordem de grandeza entre os elementos da fila identificados pelos os inteiros estão questão. No caso concreto da implementação feita no contexto deste trabalho, esses inteiros correspondem aos ı́ndices do array store. Visto que os elementos estão armazenados num array, foi necessário considerar uma estrutura auxiliar que contivesse os ı́ndices das posições livres desse mesmo array. Note-se que, ao remover um dado elemento, a sua posição no array store fica disponı́vel. Deste modo, para que se pudesse inserir um novo elemento sem recorrer a uma estrutura auxiliar, ou ter-se-ia que procurar uma posição disponı́vel no array ou trocar de posição os elementos do array, mantendo a sua ordem relativa. No entanto, ambas as operação seriam O(n) no pior caso, em que n é o número de elementos desse mesmo array. Deste modo, optou-se por utilizar um array auxiliar de ı́ndices livres, que permite, em tempo constante, obter uma posição livre para realizar uma nova inserção. 4 Avaliação Experimental Os amontoados relaxados garantem limites assimptóticos óptimos para as operações sobre amontoados, contudo na prática nem sempre se verificam melhores resultados. Com vista a testar a implementação proposta para amontoados relaxados, realizaram-se várias experiências. Em primeiro lugar testou-se a estrutura de dados com sequências de chaves aleatórias e com diferentes taxas de inserção, actualização e remoção. Em segundo lugar implementou-se o algoritmo de Dijkstra com os amontoados relaxados e realizaram-se várias experiências com grafos de diferentes tamanhos e densidades. Discutem-se de seguida os resultados obtidos. Ambas as implementações de amontoados relaxados são comparadas com uma implementação eficiente de amontoados binários sobre vectores. 4.1 Sequências de Números Aleatórios De forma a determinar em que situações os amontoados relaxados obtêm melhores resultados, realizaram-se vários testes em que o número de remoções e actualizações varia face ao número de elementos no amontoado. A ideia consiste em inserir um conjunto com n elementos no amontoado e de seguida realizar k iterações em que em cada iteração é possı́vel actualizar uma chave com probabilidade pu ou remover o elemento com menor chave com probabilidade pr . A motivação para esta formulação tem a haver com o objectivo de comparar o impacto do número das operações, em particular quando o número de actualizações é elevado ou quando nem todos os elementos têm de ser removidos. Note-se que é comum em problemas de anexação preferencial em grafos utilizar-se um fila com prioridades para guardar os arcos e não ser necessário removerem-se todos. Normalmente o algoritmo termina quando os vértices estão todos ligados, o que obviamente não implica considerar todos os arcos. 4.1 Sequências de Números Aleatórios 46 O primeiro teste consistiu apenas em inserir elementos num amontoado. Na Figura 4.1 verifica-se que ambas as implementações de amontoados relaxados obtêm tempos idênticos e que os amontoados binários obtêm melhores resultados para os valores de n considerados, 0 ≤ n ≤ 200000. Contudo, considerando valores de n superiores como na Tabela 4.1, os amontoados relaxados demonstram melhores resultados. Este facto é consistente com os limites assimptóticos teóricos e com as constantes multiplicativas devidas à complexidade dos amontoados relaxados. Após a inserção de n elementos, no segundo teste removeu-se o nó com a menor chave até o amontoado ficar vazio. Na Figura 4.2 apresentam-se as estatı́sticas deste teste. É importante notar que os tempos apresentados reflectem apenas as remoções, não contabilizando a inserção dos elementos. Neste caso os amontoados binários obtêm novamente melhores tempos. Porém a diferença para os amontoados relaxados é menos significativa que no caso da inserção. Nos terceiro e quarto testes, Figuras 4.3 e 4.4, avaliou-se a eficiência das implementações quando o número de actualizações é superior a n e superior ao número de remoções. Na Figura 4.3 o número de actualizações é dez vezes o número de elementos no amontoado, i.e., pu = 1.0 e k = 10n. Na Figura 4.4 o número de actualizações é cem vezes o número de elementos no amontoado, i.e., pu = 1.0 e k = 100n. Como era esperado, quanto maior o número de actualizações, melhor se comportam os amontoados relaxados. Embora os testes descritos até ao momento mostrem que a implementação proposta para os amontoados relaxados é eficiente e consistente com os limites assimptóticos teóricos, não se observou um ganho significativo em relação aos amontoados binários. Na Tabela 4.1 apresentam-se resultados em que o número de elementos é da ordem dos 50 milhões. O número de iterações nos três primeiros testes é 200 milhões e em cada iteração ocorre uma actualização, pu = 1.0. O número de extracções do elemento com menor chave varia, i.e., consideram-se diferentes valores para pr . Mesmo para um número elevado de elementos a diferença em relação aos amontoados binários não é significativa, embora os resultados mostrem que os amontoados relaxados têm neste caso melhores resultados. Portanto, para grandes volumes de dados, os amontoados relaxados não só são eficientes como devem obter melhores resultados que os amontoados binários. É também importante referir que a implementação proposta para os amontoados relaxados é eficiente do ponto de vista de memória, requerendo significativamente menos memória. O último teste da Tabela 4.1 4.1 Sequências de Números Aleatórios 0.30 47 binary heap rank relaxed heap run relaxed heap 0.25 seg. 0.20 0.15 0.10 0.05 0.00 0 50000 100000 n 150000 200000 Figura 4.1. Tempo de inserção de n elementos no amontoado. Os valores correspondem à média de 10 execuções por implementação. 0.80 binary heap rank relaxed heap run relaxed heap 0.70 0.60 seg. 0.50 0.40 0.30 0.20 0.10 0.00 0 50000 100000 n 150000 200000 Figura 4.2. Tempo de remoção de todos os n elementos no amontoado. Os valores correspondem à média de 10 execuções por implementação. 4.1 Sequências de Números Aleatórios 1.80 48 binary heap rank relaxed heap run relaxed heap 1.60 1.40 seg. 1.20 1.00 0.80 0.60 0.40 0.20 0.00 0 50000 100000 n 150000 200000 Figura 4.3. Tempo de actualização e remoção num amontoado com n elementos, em que o número de iterações m é igual a 10n, a probabilidade de actualização pu é 1.0 e a probabilidade de remoção pr é 0.05. Os valores correspondem à média de 10 execuções por implementação. binary heap rank relaxed heap run relaxed heap 10.00 seg. 8.00 6.00 4.00 2.00 0.00 0 50000 100000 n 150000 200000 Figura 4.4. Tempo de actualização e remoção num amontoado com n elementos, em que o número de iterações m é igual a 100n, a probabilidade de actualização pu é 1.0 e a probabilidade de remoção pr é 0.005. Os valores correspondem à média de 10 execuções por implementação. 4.2 Algoritmo de Dijkstra 49 pretende aferir em que condições os run relaxed heaps obtêm melhores resultados que os rank relaxed heaps. Neste teste são apenas feitas 200000 iterações e, em cada iteração, é feita uma actualização e uma extracção do nó com menor chave. Observa-se portanto que os run relaxed heaps apenas se justificam em situações em que o número de actualizações é baixo ou em que é necessária a garantia que as actualizações são feitas em tempo constante. Tabela 4.1. Estatı́sticas para um amontoado com 50 × 106 elementos. Neste caso cada elemento é um real e é guardado num vector auxiliar key. Legenda: k é o número de iterações; pu é a probabilidade de ocorrer uma actualização; pr é a probabilidade de ocorrer uma remoção; Ti é o tempo de inserção dos elementos em segundos; T é o tempo das k iterações onde podem ocorrer actualizações e remoções em segundos; M é a memória em MB. Implementação binary rank relaxed run relaxed binary rank relaxed run relaxed binary rank relaxed run relaxed binary rank relaxed run relaxed k 6 200 × 10 200 × 106 200 × 106 200 × 106 200 × 106 200 × 106 200 × 106 200 × 106 200 × 106 200 × 103 200 × 103 200 × 103 pu pr 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 0.0001 0.0001 0.0001 0.001 0.001 0.001 0.25 0.25 0.25 1.0 1.0 1.0 Ti (seg.) T (seg.) M (MB) 9.985 7.734 7.766 9.985 7.734 7.766 9.985 7.734 7.766 9.985 7.734 7.766 228.871 195.515 206.031 230.075 196.319 208.896 624.432 383.578 606.622 1.840 1.617 1.563 1736 1316 1357 1736 1316 1357 1736 1316 1357 1736 1316 1357 4.2 Algoritmo de Dijkstra O algoritmo de Dijkstra [1] para determinar caminhos mais curtos num grafo é, provavelmente, o exemplo mais conhecido em que a implementação da fila com prioridade é determinante para a eficiência do algoritmo. Para detalhes a respeito do algoritmo pode-se consultar, por exemplo, o livro de introdução aos algoritmos por Cormen et al. [22]. O ponto importante para a análise aqui realizada é o facto de que, para um grafo com n vértices e m arcos, uma implementação do algoritmo de Dijkstra com amontoados binários requer tempo O(n log(n) + m log(n)), no pior caso, para determinar os caminhos mais curtos a partir de um dado vértice fonte, enquanto que uma implementação com amontoados relaxados requer tempo O(n log(n) + m). Porém, na prática, dada a complexidade dos 4.2 Algoritmo de Dijkstra 50 amontoados relaxados, é difı́cil conseguir melhores tempos. Note-se que nos amontoados relaxados, embora algumas operações decorram em tempo constante, as constantes multiplicativas são mais elevadas que no caso dos amontoados binomiais. Por outro lado o logaritmo é uma função monótona crescente, com a primeira derivada a convergir para 0 quando n → ∞ e com a segunda derivada negativa. Logo, as constantes multiplicativas elevadas podem conduzir a piores resultados mesmo para conjuntos de dados de dimensão razoável. Neste estudo efectuaram-se duas avaliações com o algoritmo de Dijkstra. Na primeira avaliação foram gerados grafos aleatórios segundo o modelo de duplicação [23], um modelo que reflecte muitas das propriedades das redes reais, e.g., redes sociais. Dado um grafo não dirigido G0 = (V0 , E0 ) e uma probabilidade 0 ≤ p ≤ 1, o modelo de duplicação permite construir recursivamente um grafo dirigido Gt = (V, E) por duplicação parcial da seguinte forma: 1. Gt = Gt−1 e selecciona-se aleatoriamente um vértice u ∈ Vt−1 ; 2. adiciona-se um novo vértice e um novo arco u, v a Gt ; 3. para cada vizinho w de u em Gt−1 , adiciona-se o arco (v, w) a Gt com probabilidade p. Os arcos adicionados no passo 2 fazem com que o grafo seja sempre ligado. Usualmente, G0 é um grafo constituı́do apenas por um vértice e, no passo t, Gt terá t + 1 vértices. Chung et al. [23] provaram que, com p → 1 e n → ∞, o modelo de duplicação gera grafos em que distribuição dos graus dos vértices é exponencial com expoente β tal que p(β − 1) = 1 − pβ−1 . (4.1) Em particular, se 21 < p < 1, então β < 2. Porém, os valores de β interessantes, entre 1 e 2, são obtidos apenas com probabilidades 0.5 < p < 0.56714329.... No seguimento considerar-se-á p = 0.5. Os pesos para os arcos foram gerados aleatoriamente entre 0 e 100. Na segunda avaliação foram gerados grafos completos, i.e., cliques, com pesos aleatórios. Portanto, o número de arcos é n(n − 1)/2 em que n é o número de vértices. Os pesos dos arcos tomam também valores entre 0 e 100. Os resultados obtidos mostram que a implementação proposta é competitiva na prática, com resultados comparáveis aos amontoados binários e, no caso dos grafos gerados com o modelo de duplicação, com melhores resultados. Para grafos maiores, os ganhos deverão ser ainda mais notórios. É importante referir que no caso dos grafos obtidos por duplicação 4.2 Algoritmo de Dijkstra 30.0 51 binary heap rank relaxed heap run relaxed heap 25.0 seg. 20.0 15.0 10.0 5.0 0.0 0 50000 100000 n 150000 200000 Figura 4.5. Tempo de execução do algoritmo de Dijkstra para grafos com n vértices gerados a partir do modelo de duplicação com p = 0.5. Os valores correspondem à média de 10 execuções por implementação. 2.0 binary heap rank relaxed heap run relaxed heap seg. 1.5 1.0 0.5 0.0 0 1000 2000 3000 4000 n 5000 6000 7000 8000 Figura 4.6. Tempo de execução do algoritmo de Dijkstra para grafos completos com n vértices. Os valores correspondem à média de 10 execuções por implementação. 4.2 Algoritmo de Dijkstra 52 o número de arcos é significativamente maior que o número de vértices, e.g., os grafos com 200000 vértices têm cerca de 30 milhões de arcos. 5 Conclusão Neste trabalho estudou-se a implementação eficiente de filas com prioridade, um tipo de dados abstracto com inúmeras aplicações. De um modo geral, as implementações mais eficientes têm por base a estrutura de dados amontoado. Nos últimos anos surgiram vários tipos de amontoados que melhoram os limites assimptóticos para o tempo das operações. Contudo, os amontoados binários continuam a ser os mais utilizados na prática. Este facto deve-se a que as alternativas são bastante complexas e difı́ceis de implementar, em parte porque as estruturas mais complexas implicam constantes multiplicativas elevadas. No presente estudo dá-se uma implementação eficiente de duas variantes de amontoados com limites assimptóticos inferiores aos dos amontoados binários e, ainda que complexos, competitivos na prática. As variantes estudadas são os amontoados relaxados propostos por Driscoll et al. [14], nomeadamente os rank relaxed heaps e os run relaxed heaps. Os run relaxed heaps foram a primeira variante a suportar a operação decreaseKey em tempo constante no pior caso, o que não acontece com os amontoados binários, que suportam esta operação em tempo O(log(n)) no pior caso, com n o número de elementos no amontoado. O estudo consistiu na análise detalhada das estruturas de dados, das operações e dos requisitos a nı́vel de tempo e espaço necessários. Como resultado conseguiu-se uma implementação eficiente, quer ao nı́vel de tempo quer ao nı́vel de espaço. A avaliação experimental permitiu concluir que, na prática, a implementação proposta é competitiva com os amontoados binários, conseguindo melhores resultados para grandes conjuntos de dados. Discutiu-se ainda a implementação de filas com prioridade tendo por base os amontoados relaxados, incluindo a operação diminuição de chave em tempo constante. Esta operação é inexistente em muitas implementações de filas com prioridade disponı́veis em bibliotecas de uso geral. A implementação foi desenvolvida em Java e encontra-se em anexo. 5 Conclusão 54 No estudo realizado conclui-se que uma implementação cuidada de amontoados relaxados é competitiva. O facto de não existirem implementações eficientes tem sido o principal obstáculo para adopção na prática deste tipo de estruturas de dados. É importante referir que a implementação proposta pode ainda ser melhorada, em particular utilizando técnicas mais recentes que permitem, por exemplo, reduzir o número de comparações. É no entanto necessário uma análise cuidada destas técnicas, pois implicam em geral o uso de estruturas de dados mais complexas e, portanto, é necessário avaliá-las ao nı́vel da implementação. Este estudo contribui ainda com uma exposição completa das estruturas de dados e operações associadas aos amontoados relaxados. A maior parte dos livros inclui amontoados binários e, apenas alguns, incluem os amontoados binomiais ou os amontoados de Fibonacci. Não é do conhecimento da autora um livro que inclua ou discuta os amontoados relaxados. Note-se que estes são considerados uma das estruturas de dados mais promissoras nesta área e, portanto, com interesse pedagógico em disciplinas de algoritmia e estruturas de dados. Em particular é importante estudar as implicações em muitos algoritmos bem conhecidos. Referências 1. Dijkstra, E.W.: A note on two problems in connexion with graphs. In Numerische Mathematik 1 (1959) 269–271 2. Prim, R.C.: Shortest connection networks and some generalizations. Bell System Technical Journal 36 (1957) 1389–1401 3. Tanenbaum, A.S.: Modern Operating Systems. Prentice-Hall (2001) 4. Williams, J.: Algorithm 232 (heap sort). Commun. ACM 7 (1964) 347–348 5. Mendelson, R., Tarjan, R.E., Thorup, M., Zwick, U.: Melding priority queues. In Hagerup, T., Katajainen, J., eds.: SWAT. Volume 3111 of Lecture Notes in Computer Science., Springer (2004) 223–235 6. Vuillemin, J.: A data structure for manipulating priority queues. Commun. ACM 21(4) (1978) 309–315 7. Fredman, M.L., Tarjan, R.E.: Fibonacci heaps and their uses in improved network optimization algorithms. In: FOCS, IEEE (1984) 338–346 8. Fredman, M.L., Tarjan, R.E.: Fibonacci heaps and their uses in improved network optimization algorithms. J. ACM 34(3) (1987) 596–615 9. Tarjan, R.: Amortized computational complexity. SIAM Journal on Algebraic and Discrete Methods 6 (1985) 306–318 10. Fredman, M.L., Sedgewick, R., Sleator, D.D., Tarjan, R.E.: The pairing heap: A new form of self-adjusting 11. 12. 13. 14. heap. Algorithmica 1(1) (1986) 111–129 Pettie, S.: Towards a final analysis of pairing heaps. In: FOCS, IEEE Computer Society (2005) 174–183 Fredman, M.L.: On the efficiency of pairing heaps and related data structures. J. ACM 46(4) (1999) 473–501 Elmasry, A.: Pairing heaps with (o(log log ) decrease cost. In Mathieu, C., ed.: SODA, SIAM (2009) 471–476 Driscoll, J.R., Gabow, H.N., Shrairman, R., Tarjan, R.E.: Relaxed heaps: An alternative to fibonacci heaps with applications to parallel computation. Commun. ACM 31(11) (1988) 1343–1354 15. Brodal, G.S.: Worst-case efficient priority queues. In: SODA ’96: Proceedings of the seventh annual ACM-SIAM symposium on Discrete algorithms, Philadelphia, PA, USA, Society for Industrial and Applied Mathematics (1996) 52–58 16. Elmasry, A., Jensen, C., Katajainen, J.: Two-tier relaxed heaps. Acta Inf. 45(3) (2008) 193–210 17. Kaplan, H., Tarjan, R.E.: Thin heaps, thick heaps. ACM Transactions on Algorithms 4(1) (2008) 18. Moret, B., Shapiro, H.: A general technique for implementation of efficient priority queues. 3rd Israel Symposium on the Theory of Computing Systems (1995) 57–66 19. Peterson, G.: A balanced tree scheme for meldable heaps with updates. Technical report, School of Information and Computer Science, Georgia Institute of Technology (1987) 20. Takaoka, T.: Theory of 2-3 heaps. Discrete Applied Mathematics 126(1) (2003) 115–128 21. Elmasry, A.: Layered heaps. In Hagerup, T., Katajainen, J., eds.: SWAT. Volume 3111 of Lecture Notes in Computer Science., Springer (2004) 212–222 Referências 118 22. Cormen, T.H., Leiserson, C.E., Rivest, R.L., Stein, C.: Introduction to Algorithms. MIT Press (2001) 23. Chung, F., Lu, L., Dewey, T.G., Galas, D.J.: Duplication models for biological networks. Journal of Computational Biology 10(5) (2003) 677–687