Preview only show first 10 pages with watermark. For full document please download

Caderno2

Teoria da disciplina Computação para Automação. Algoritmos e Estrutura de Dados. Parte 2. Estruturas básicas.

   EMBED


Share

Transcript

Universidade Regional do Noroeste do RS Departamento de Tecnologia Algoritmos e Estruturas de Dados Parte II  Estruturas Básicas Marcos César C. Carrard Apresentação Este trabalho é a continuidade daquele com o mesmo nome, que tratava dos fundamentos da área de algoritmos e estruturas de dados. O objetivo aqui é introduzir o estudos das estruturas básicas, sequenciais e encadeadas, sob a ótica dos seus algoritmos e sua eficiência de trabalho. Para isto serão apresentadas as estruturas pilha, fila, fila circular e listas encadeadas e, para todas elas, além da discussão da sua definição, discutiremos as formas de implementação, algoritmos e análise destes algoritmos. O objetivo é não só propor a estrutura, mas sim entendê-la de forma a se obter um grau de liberdade mais amplo no uso das mesmas. Como estruturas de dados se caracterizarão como proposições, na maioria das vezes lógicas, para uso dos dados, existe um objetivo não tão claro presente que é a idéia de que as pessoas possam não só utilizar estas estruturas de forma correta como possam modificá-las e até apresentar estruturas novas para os seus problemas específicos. Isto só é possível após um bom e perfeito entendimento deste item. Finalmente e mais uma vez, por ser um material em constante atenção e experimentação, caso o leitor localize algum incorreção, melhoria ou tenha qualquer comentário, entre em contato comigo pelo e-mail abaixo. Marcos Carrard [email protected] 2 Índice Capítulo 1 − Fundamentos 4 5 7 8 1.1 Introdução 1.2 Estruturas de Dados 1.3 Formas de Organização 1.4 Exercícios Capítulo 2 − Estruturas de Dados Sequenciais 9 9 9 11 12 15 16 16 17 18 20 22 22 23 23 26 26 2.1 Caracterização 2.2 Pilha 2.2.1 Apresentação 2.2.2 Implementação 2.2.3 Algoritmos 2.2.4 Análise 2.3 Fila 2.3.1 Apresentação 2.3.2 Implementação 2.3.3 Algoritmos 2.3.4 Análise 2.4 Fila Circular 2.4.1 Apresentação 2.4.2 Implementação 2.4.3 Algoritmos 2.4.4 Análise 2.5 Exercícios Capítulo 3 − Estruturas de Dados Encadeadas 3.1 Caracterização 3.2 Listas Encadeadas 3.2.1 Apresentação 3.2.2 Implementação 3.2.3 Algoritmos 3.2.3.1 Lista unicamente encadeada 3.2.3.2 Lista duplamente encadeada 3.2.4 Análise 3.3 Exercícios 29 30 30 33 35 36 46 55 57 Bibliografia 59 3 1 Fundamentos 1.1 Introdução Na primeira parte deste trabalho ([Car98]) foi abordada e trabalhada a importância dos temas “algoritmos” e “análise de algoritmos” para uma boa compreensão no estudo de estruturas de dados. Por outro lado, isto é realmente necessário? Isto é fundamental! Note que aquele material trabalha algoritmos sob um ponto de vista muito próximo do teórico. Já estruturas de dados necessariamente são orientadas para uma dada aplicação, ou seja, são eminentemente práticas. Onde então eles se encontram? Vamos considerar um situação prática hipotética. Suponha que, para um dado problema existem várias estruturas de dados capazes, através de diferentes caminhos, de chegar até a resposta correta. Destas estruturas sairão vários algoritmos que as manipulam. Assim sendo, qual destes algoritmos será o escolhido? Para chegarmos até esta resposta é imprescindível que, além de entender os algoritmos, precisamos analisá-los de maneira a chegar até uma medida quantitativa e qualitativa que permita a comparação dos mesmos entre si e assim, ao mesmo tempo, estaremos comparando as estruturas de dados. Se não bastasse isto, ao trabalhar uma estrutura, ela convergirá para algoritmos de manipulação, ou seja, algoritmos que descrevem as operações básicas de uso da mesma. Será então muito importante extrair destes algoritmos características que permitam conhecer não só o funcionamento da estrutura, mas também seus aspectos positivos e negativos. Isto é perfeitamente possível e factível quando aplicamos técnicas de análise de algoritmos como aquelas vistas. 4 Finalmente, vale salientar que está é uma interpretação própria ao assunto. A experiência tem mostrado que não basta apresentar as estruturas e seus algoritmos, enfatizando o seu aspecto funcional. Devemos entender as estruturas estudando o seu comportamento em situações variadas para que ela se contextualize com as formas e alternativas para organização da informação. Se isto for possível, poderemos então desenvolver novas soluções para casos específicos. O leque de estruturas presentes na bibliografia é vasto. Entretanto aqui serão apresentadas apenas uma parcelas das mesmas. Isto é devido ao entendimento que estas estruturas cobrem a parcela mais significativa do horizonte de uso, além de permitirem que, com o seu domínio, a extensão para outros casos seja facilitada. 1.2 Estruturas de Dados Em várias oportunidades neste trabalho estará presente o tema “estruturas de dados”, mas o que na realidade é isto? Em que situação elas se aplicam? Vamos tentar encontrar respostas para estas questões. Todos que trabalham com o processamento de informações, de qualquer natureza, tem uma idéia razoavelmente boa sobre o tema. Entretanto esta idéia, muitas vezes, precisa ser melhor trabalhada. Façamos o seguinte: escreva em uma folha de papel a sua própria definição de estrutura de dados e vá acompanhando e comparando-a com aquela que este texto irá desenvolver. Em primeiro lugar, vamos definir o que significam cada um dos elementos presentes no nome, neste caso, estrutura e dados. Segundo [Fra87]: • Estrutura: “disposição e ordem das partes num todo; considerada a forma por que se dispõem as suas partes”; • Dados: “elemento ou quantidade conhecida que serve de base à resolução de um problema”. um todo, Para a área de informática, os termos dados e informações, por mais que diferentes, representam os elementos básicos de qualquer trabalho. Não há dúvidas quanto a isto. Já o termo estrutura merece algumas reflexões. Quando falamos no verbo “estruturar”, de onde deriva a definição acima, estamos pensando em algum tipo de organização. Esta pode ser uma boa indicação de caminho. 5 Considere dois elementos adicionais: o que manipulamos ou desejamos que o computador manipule, são informações; e, o computador armazena suas informações em algum tipo de memória. Bem, como compatibilizar as duas situações? Precisamos encontrar uma forma condizente de representação das informações que permita a manipulação (por nós ou por uma linguagem) e que seja passível de armazenamento na memória escolhida. Neste momento vamos nos preocupar somente com o armazenamento em memória principal, deixando para mais tarde os outros casos. Juntando estes conceitos, é óbvio que estrutura de dados tem um relação muito próxima com a organização da partes de informação em um todo coerente. É preciso fazê-lo de acordo com critérios muito bem estabelecidos e que atendam à finalidade que motivou a proposição. Afinal, para que iremos organizar a informação? Para utilizá-la na solução dos nossos problemas computacionais. Assim: Estrutura de dados é a forma de organização dada às informações de maneira a facilitar o acesso a elas por um algoritmo ou programa durante as operações de manipulação que ocorrem na execução de uma tarefa. Veja que a definição dada acima ressalta algumas palavras que são chaves para o bom entendimento de estruturas de dados. Em primeiro lugar falase de organização. Uma vez que desejamos fazer algum uso destas informações, precisamos dar à elas uma organicidade para que este acesso seja o mais eficiente possível. Se esta afirmação é válida, estamos lançando uma idéia que devemos cultivar: ao pensarmos uma estruturação para os nossos dados, devemos considerar sempre o problema a ser resolvido, a forma e tipo de uso que desejamos fazer deles e com será a sua manipulação através de algoritmos ou programas. Sendo tudo isso considerado como parte natural da estrutura de dados ela é muito mais ampla do que a simples ordem dada aos dados, ela é um grande conjunto de regras para o armazenamento e uso destes dados de forma coerente. Uma vez que isto esteja claro, pode-se afirmar que estrutura de dados é, muitas vezes, criação própria e podemos ampliar os conceitos aprendidos de forma a criar novas soluções para os nossos novos problemas. Ao criarmos estas novas soluções, definimos novas regras, e, consequentemente, novas estruturas de dados. 6 1.3 Formas de Organização É ainda necessário fazer uma distinção fundamental em relação a organização das informações que irá reforçar mais os conceitos anteriores. Esta distinção é quanto a organicidade dos dados, que pode ser física ou lógica. Vamos clarear isto através de um exemplo. Todo o programador é capaz de imaginar um vetor de 10 elementos tipo caracter sem maiores dificuldades. Estes elementos, por estarem armazenados em um vetor, estão fisicamente lado a lado. Ou seja, o elemento da quinta posição é, e sempre será, precedido por aquele da quarta posição e sucedido pelo da sexta. Esta é a organização física dos dados ou elementos. Independente da organização física acima, eu posso utilizar estes dados na ordem que bem desejar. Por exemplo, primeiro eu uso o dado da primeira posição; após o oitavo; depois o terceiro e assim por diante. É fácil perceber que eu posso determinar 10! possíveis combinações para o uso dos elementos do vetor. Estas são as possíveis organizações lógicas dos dados. Veja: Organização física: Î Organização lógica 1: Ordem 5, 7, 9, 2, 6, 4, 10, 1, 3, 8 Resultado A, B, C, E, M, N, O, P, R, U Î Î Organização lógica 2: Ordem 1, 3, 5, 7, 9, 2, 4, 6, 8, 10 Resultado P, R, A, B, C, E, N, M, U, O Î Dando uma definição mais formal, organização física é a forma ou ordem como os dados estão fisicamente armazenados. Já a organização lógica é a forma ou ordem na qual os dados são acessados ou utilizados. Estrutura de dados atua nas duas frentes. Ao mesmo tempo que estabelece formas de organização lógica para as informações, respeita as restrições impostas pelas organizações físicas. Entretanto vale lembrar que a liberdade de ação está mais próxima da organização lógica que da física. Esta última é altamente condicionada as possibilidades de representação das 7 linguagens. Por exemplo, vetores, matrizes, arquivos, alocação dinâmica de memória, dentre outras possibilidades. Para a organização lógica, desde que fisicamente seja possível, qualquer possibilidade é válida (desde que atenda as suas necessidades). 1.4 Exercícios 1. Faça uma comparação fundamentada e crítica da sua própria definição de estrutura de dados com aquela presente no texto. 2. Busque definições alternativas para estrutura de dados na bibliografia e compare-as. 3. Quais são as alternativas de organização física dos dados nas linguagens que você conhece? Busque também informações sobre C, Pascal e para aquela utilizada para descrever os algoritmos deste texto (veja em [Car98] ). 4. Defina algumas regras para a organização lógica de um vetor de n valores do tipo inteiro. 8 2 Estruturas de Dados Sequenciais 2.1 Caracterização É possível caracterizar as estruturas de dados em grupos, de acordo com a maneira pela qual elas se comportam frente as organizações mencionadas no capítulo anterior. O primeiro grupo a ser estudado é formado pelas estruturas de dados sequenciais. Estas estruturas são caracterizadas pelo fato da organização lógica dos dados coincidir com a organização física dos mesmos. Desta forma, se a informação guardada na posição i for precedida por aquela da posição i-1 e sucedida pela da posição i+1, caracterizando a sua organização física, a sua utilização será idêntica, ou seja, após utilizarmos o elemento da posição i, somente será possível a utilização dos elementos das posições i-1 ou i+1, sem nenhuma outra alternativa. 2.2 Pilha 2.2.1 Apresentação A primeira estrutura que veremos chama-se pilha. Antes de definí-la, vamos caracterizá-la por analogia com uma situação muito comum no nosso diaa-dia. 9 Todos nós já tivemos a oportunidade de nos depararmos com um grupo de objetos quaisquer empilhados. Por exemplo, uma pilha de latas em um supermercado. Neste caso, sempre que desejamos colocar mais uma lata na pilha, existe somente um local possível: a parte mais alta ou o topo da pilha. Se, por outro lado, desejamos retirar um objetos desta pilha, o faremos também na parte mais alta ou topo, ou correremos o risco de derrubar tudo. Esta analogia pode ser transposta sem maiores dificuldades para o trato de informações, basta utilizar estas em substituição às latas. Considere um grupo de informações onde existe uma delas considerada a “mais alta”. Se desejamos colocar uma nova informação (empilhar), isto irá ocorrer após esta “mais alta”. Se o que desejamos é retirar uma informação, retiraremos esta e atualizaremos o indicador de “mais alta” para a informação anterior a ela. Esta é a pilha, veja: Pilha é uma estrutura de dados onde as operações de manipulação dos seus elementos (inserção e retirada) acontecem em um mesmo local denominado de topo da pilha. Note que esta definição é uma regra de uso para os elementos armazenados, ou seja, é uma organização lógica. De acordo com a caracterização dada anteriormente, esta organização lógica coincide com a organização física, pois elementos serão inseridos e/ou retirados da pilha em posições subsequentes. Figura 2.1 – Caracterização de um estrutura tipo pilha. Vamos olhar esta definição de forma gráfica na figura 2.1. Nela está descrito todo o processo de manipulação da pilha e qual é o critério que a rege. Este critério é conhecido como LIFO  Last In First Out, ou seja, o último elemento que entrou na estrutura será o primeiro a sair dela. Toda e qualquer 10 aplicação que utilizar este critério na solução do seu problema poderá ser trabalhada utilizando uma pilha. São variados os usos para a estrutura pilha, entre eles podemos destacar a sua aplicação no controle de chamadas de procedimentos na maioria das linguagens de programação, conversão e solução de expressões matemáticas na análise sintática, léxica e semântica de alguns interpretadores, controle de algoritmos iterativos na implementação de uma solução recursiva, dentre outros. No primeiro exemplo, sempre que a linguagem (módulo de execução) encontrar a chamada a um procedimento ou função, ela empilha o status atual (valores de variáveis, endereço de retorno, etc...) em uma pilha. Caso encontre novos procedimentos pelo caminha, realiza a mesma tarefa. Quando o procedimento em execução terminar, a linguagem desempilha um status (que é da última chamada à procedimento), recupera os dados de acordo com este e segue o seu trabalho. Para a conversão de expressões matemáticas, a pilha permite descrevêlas utilizando um sistema de notação conhecido por poloneza (original ou inversa), o que facilita o processo de solução das mesmas (veja em [TA86] e [SM94] para maiores detalhes). Durante a conversão, podem ser verificadas as condições formais da expressão e detectados erros. No último exemplo citado, a pilha pode ser utilizada para dar suporte à um algoritmo iterativo durante a implementação de uma definição naturalmente recursiva. Na prática, ela substitui a pilha implementada pela linguagem para o controle das chamadas aos procedimentos, de forma que o algoritmo consiga controlá-los internamente apenas. 2.2.2 Implementação Como foi mencionado anteriormente, a definição de uma pilha estabelece “apenas” uma organização lógica para as informações. A única menção à organização física está no fato desta necessitar coincidir com a primeira. Entretanto, precisamos definir formas de armazenamento para uma pilha, pois estas formas, que ditam a organização física, influenciarão de maneira decisiva a implementação da estrutura. Na verdade, qualquer organização física de dados que estabeleça “vizinhança” entre elementos e este critério de vizinhança seja permanente (duas posições vizinhas nunca mudarão de posição), é capaz de suportar a organização lógica da pilha. Para isto basta fazer com que as operações de inserção e retirada aconteçam em um mesmo e único local chamado de topo. 11 Assim sendo, estruturas físicas como vetores, arquivos, alocação dinâmica de memória (ponteiros), dentre outras, podem fazê-lo. Por uma questão de praticidade, vamos apresentar a versão da pilha implementada em um vetor. Os outros casos são análogos a este, pelo menos na lógica de manipulação da estrutura. Note, por outro lado, que independentemente da natureza dos dados armazenados na pilha (inteiros, reais, caracteres, etc...), o indicador de topo está vinculado ao tipo de estrutura física escolhida. Ou seja, se escolhemos um vetor, o topo deve armazenar uma posição deste vetor. Para isto, ele pode ser uma variável inteira. Se a opção for pelo uso de alocação dinâmica de memória, o topo necessitará ser um ponteiro para o tipo de estrutura dada à cada nó, e assim por diante. Ainda quanto a isto, uma vez que o topo pode ser de vários tipos de dados, o seu avanço (incremento) ou retração (decremento) nas operações de inserção e retirada respectivamente, será também condicionado ao seu tipo. Veja, se ele for um número inteiro, bastará somar ou subtrair uma unidade do seu valor atual, mas isto não irá funcionar com um ponteiro. 2.2.3 Algoritmos Considerando então a existência de uma pilha a ser implementada em um vetor de n elementos do tipo caracter, vamos discutir os algoritmos de manipulação da mesma. Antes de qualquer coisa precisamos decidir quais serão estes algoritmos. Dois deles são óbvios: a inserção de um novo elemento e a retirada de um elemento existente. Além destes existe mais algum algoritmo? Em geral, escreveremos algoritmos para as operações básicas permitidas na estrutura. Para o caso de pilha é possível fazer mais alguma coisa do que a inserção e a retirada? Enquanto operação de manipulação, não há mais nenhuma. Então serão somente estes dois? Neste caso, existe ainda uma pequena tarefa a ser realizada que é independente e diferente da inserção e retirada e, em razão disto, terá o seu algoritmo específico. Na verdade existem algumas indicações ou recomendações para desenvolver uma “boa programação”. Uma delas é para que jamais sejam misturadas em um mesmo algoritmo tarefas distintas. Isto, além de tornar mais claro o algoritmo, facilita muito a busca e correção de erros. Já que estamos tratando da pilha em um vetor, qual é a garantia que temos de que este vetor e o indicador de topo estarão em condições de serem utilizados pelos procedimentos de manipulação quando for necessário? Muito poucas, pois as linguagens, em geral, não se preocupam com isso. 12 Então, o procedimento ou algoritmo que está faltando deve trabalhar para dar esta garantia desejada. Ele é o algoritmo de inicialização da estrutura, ou seja, ele inicializa todos os elementos necessários para o bom funcionamento dentro dos parâmetros conhecidos. Vamos começar por ele. procedimento inicializar_pilha( var topo: inteiro ) início topo := 0; fim Algoritmo 2.1 – Inicialização da estrutura pilha. Este algoritmos de inicialização está descrito no algoritmo 2.1. Dois comentários sobre ele são importantes. Em primeiro lugar, o procedimento ou algoritmo recebe como parâmetro somente o indicador de topo da pilha e não esta. Isto acontece porque é no local indicado pelo topo que as operações ocorrem, independentemente do que há dentro do vetor. Na inclusão, um novo elemento será inserido na posição após aquela indicada pelo topo. Na retirada, o elemento atualmente apontado (que foi inserido como descrito antes), sairá. Então, topo jamais deixará que qualquer elemento impróprio dentro do vetor interfira no processo. A segunda questão diz respeito à razão de colocar o valor zero no topo e não outro valor qualquer como -1 ou 1. Se inicializarmos o topo com –1, ele necessitará ser incrementado em 2 unidades para alcançar a primeira posição válida do vetor, que começa em 1. Nas demais vezes o incremento será de apenas 1 unidade. Escolhendo o valor 0 (ou 1) para inicialização, este incremento será sempre uniforme. O valor 1 para a inicialização também não parece ser o ideal, pois ele já está indicando uma posição válida e esta não tem nada. Durante a inclusão, vamos inserir o novo elemento na posição indicada e depois incrementar o topo. Na retirada ocorre o inverso, pois precisamos primeiro decrementar o topo para chegar ao elemento a ser retirado. Isto não é um grande problema, mas por questão de clareza e correção, é preferível apontar sempre para posições que são válidas. Desta forma, o topo deve ser inicializado com o valor imediatamente anterior a primeira posição válida do vetor. O nosso próximo algoritmo é o que faz a inclusão de um novo elemento na estrutura. Ele é o algoritmo 2.2 e merece uma série de pequenos comentários, nem todos relacionados ao processo de inclusão, mas também às normas de uma boa programação. Em primeiro lugar, observe que, dentro do algoritmo existem dois momentos distintos. No primeiro deles verificamos se existe espaço dentro do 13 vetor para um novo elemento. Se a variável topo alcançar o tamanho do vetor, significa que todas as posições já foram ocupadas. Somente após isso é que se realiza a inserção do elemento, primeiro incrementando o topo (pois ele foi inicializado com zero) e depois guardando o elemento desejado. procedimento inserir_pilha( var pilha: vetor[1..N] de caracter; var topo: inteiro; elemento: caracter ): booleano; início se topo = N então retornar falso; /* A pilha está cheia */ topo := topo + 1; pilha[ topo ] := elemento; retornar verdade; /* Inserção Ok */ fim Algoritmo 2.2 – Inserção de um novo elemento em uma pilha. Outro ponto importante é a forma de montagem do procedimento. Uma vez que ele executa uma dada tarefa, algum outro procedimento ou programa o chamou e necessita saber o resultado desta tarefa, ou seja, conhecer se a inserção foi ou não realizada. Assim, a solução adotada foi a de manter um parâmetro de retorno do procedimento, do tipo booleano, que indica se a execução foi correta (verdade) ou errada (falso). procedimento retirar_pilha( var pilha: vetor[1..N] de caracter; var topo: inteiro; var elemento: caracter ): booleano; início se topo = 0 então retornar falso; /* A pilha está vazia */ elemento := pilha[ topo ]; topo := topo - 1; retornar verdade; /* Inserção Ok */ fim Algoritmo 2.3 – Retirada de um novo elemento de uma pilha. O último algoritmo proposto faz a retirada de um elemento da pilha e é apresentado no algoritmo 2.3. Analogamente à inserção, antes de retirarmos alguém é necessário verificar se existe algum elemento presente na estrutura. Sempre que o indicador de topo for zero (que é o valor de inicialização), a pilha está vazia. 14 Após isto, estamos em condições de proceder a retirada. Esta é realizada com o armazenamento do elemento indicado pelo topo na variável elemento e o posterior decremento daquele para apontar para a localização do novo topo estrutura. Uma vez que, ainda de forma análoga a inserção, este procedimento retorna um indicativo de execução correta ou não, é necessário o uso de um parâmetro adicional, também passado “por referência” (veja livros de programação), para retornar o elemento retirado. Já que a estrutura não faz a consulta aos seus elementos, o ato de retirada pressupõem o uso de elemento que está saindo e ele não pode ser simplesmente ignorado. 2.2.4 Análise A análise assintótica dos algoritmos de manipulação da pilha é óbvia, uma vez que qualquer uma das operações mencionadas e implementadas é realizada em tempo constante. Esta análise considerou como melhor caso a situação onde os algoritmos de inserção e retirada acusam pilha cheia ou vazia e como pior caso o trabalho completo do mesmo. Veja: Algoritmo Melhor Caso Pior Caso +- := se [] +- := se [] inicializar_pilha  1    1   inserir_pilha  1 1  1 3 1 1 retirar_pilha  1 1  1 3 1 1 Já que estes algoritmos fazem a implementação de uma pilha em um vetor de valores tipo caracter, vale perguntar se o desempenho seria o mesmo em outros casos? Na verdade sim, este desempenho permanece proporcionalmente o mesmo pois, em uma pilha, as operações de inserção e retirada acontecem sempre em posições pré-determinadas. Nestes casos não há a necessidade de nenhuma tarefa adicional, apenas verificamos a possibilidade da operação e, se houver, a realizamos. Desta maneira, independentemente da estrutura física que abriga a pilha, estas operações são realizadas em tempo assintótico constante e proporcional à O(1). O mesmo vale para o processo de inicialização. É necessário ainda fazer um comentário sobre o tipo de operação realizada. Por quê fizemos apenas a inclusão e a retirada de elementos? E se desejarmos olhar ou consultar o que há armazenado na pilha? É possível? 15 Note que, por definição, a estrutura de dados pilha permite que sejam realizadas apenas operações no local apontado pela variável topo. Estas operações, em razão disto, se restringem a colocar um novo elemento ou retirar um elemento existente, mas ambas nesta posição indicada. Então, não há como vasculhar a estrutura em busca de um dado elemento. Isto é ainda mais problemático, pois se acessarmos qualquer outro elemento que não aquele indicado pelo topo, estaremos infringindo a regra de definição da pilha e, em razão disto, deixando de ter esta estrutura. Ou seja, se permitirmos acessos ou operações em outro local da estrutura que não seja o topo, não estaremos trabalhando com uma pilha. 2.3 Fila 2.3.1 Apresentação Da mesma forma que fizemos com a pilha, vamos utilizar a analogia para caracterizar a forma inicial da estrutura de dados fila e depois converteremos este estudo para uma representação adequada ao nosso caso. Considere um caso comum e corriqueiro que enfrentamos diariamente: entrar em uma fila, seja em um banco, supermercado, restaurante, etc... Observe que neste caso, todo o novo integrante da fila ingressa na mesma em um único local: o final dela (a menos, é claro, que esteja disposto a arrumar confusão). Por sua vez, o atendimento às pessoas na fila se dá em outro local: o começo desta. Esta é a caracterização do funcionamento de uma fila em nosso “mundo real”. Basta agora transcrever esta idéia ao tratamento de informações armazenadas em um computador. Para isso, considere um grupo delas “enfileiradas”, onde a inserção de um novo elemento só pode acontecer no final desta fileira e a retirada no outro extremo, o começo da mesma. Este é a fila. Sua definição formal é: Fila é uma estrutura de dados onde a operação de inserção de um novo elemento acontece em uma extremidade denominada final (término, fim,...) e a retirada em outra extremidade, diferente da primeira, denominada começo (início, frente,...). Mais uma vez, estamos definindo uma organização lógica para o uso de elementos armazenados em alguma estrutura física. Ainda aqui existe a necessidade de que as duas organizações sejam equivalentes, pois elementos 16 “vizinhos” na estrutura física serão utilizados logicamente em sequência, ou seja, serão vizinhos lógicos também. A estrutura fila também tem um critério de regência, este chama-se FIFO  First In First Out, ou seja, o primeiro elemento a ser inserido na estrutura, será o primeiro a ser utilizado ou retirado. Graficamente este critério é apresentado como na figura 2.2. Figura 2.2 – Caracterização de uma fila. Esta estrutura tem um uso muito frequente em situações onde se faz necessário respeitar um critério de enfileiramento das informações. Dentre muitos outros casos, podemos citar: o controle de arquivos enviados para impressão, onde não pode haver mistura nem intercalação dos mesmos e respeita-se o ordem de chegada; o controle de processos que utilizarão o processador em um ambiente multiprogramado; simulações dos mais variados tipos; compiladores e linguagens de programação; e outros mais. 2.3.2 Implementação Para discutirmos a implementação de uma fila, precisamos antes definir qual será a estrutura física que a abrigará. Da mesma forma que a pilha, qualquer estrutura física que permita estabelecer este critério de “vizinhança” física e lógica, necessária ao caso, pode ser utilizada. Neste caso, as opções também são idênticas àquelas da pilha, pois podemos implementar a fila em vetores (forma matricial), estruturas com alocação dinâmica de memória, arquivos e outros. Desta vez, note que são necessárias duas varáveis de controle: o indicador de começo e o de final da fila. Estas duas variáveis executam a mesma função em qualquer uma das estruturas físicas escolhida, mas tem natureza ou tipo diferente em cada uma delas. Ou seja, para uma implementação matricial, elas podem ser do tipo inteiro; para estruturas e alocação dinâmica de memória, elas são ponteiros; e assim por diante. 17 Da mesma forma que antes e por entender que não há nenhum prejuízo em apresentar a fila desta forma, faremos a escolha da estrutura física para implementação como sendo matricial, na forma de um vetor com as informações. Isto torna mais simples e óbvios os algoritmos de manipulação. 2.3.3 Algoritmos Já sabemos que estrutura física irá abrigar a nossa fila: será um vetor com n elementos do tipo caracter. Precisamos então determinar quais serão os algoritmos que iremos implementar. Um destes algoritmos é óbvio e natural, pois necessitamos inicializar os controladores da estrutura para que ela funcione. De forma semelhante, não há aplicabilidade para uma estrutura que não permita inserir e retirar seus elementos. Este também é o caso de uma fila e escreveremos também estes dois algoritmos. Como esta estrutura define claramente os locais onde acontecerão as operações de inserção e retirada, que serão no começo e final, não há liberdade para realizarmos outras, pois estaríamos rompendo a regra que define uma fila. Desta maneira, a semelhança da pilha, implementaremos somente três algoritmos: inicialização, inserção e retirada de elementos. O primeiro algoritmo faz a inicialização da estrutura e está no algoritmo 2.4. Esta tarefa é resumida com a atribuição de valores às variáveis de controle começo e final. Para elas foram destinados os valores 1 e 0, respectivamente. Por que estes valores e não outros, como por exemplo, todos zerados? As razões já foram expressadas no estudo da pilha. É recomendável que exista coerência entre os apontadores, seu modo de incremento e a posição apontada. Se inicializarmos o começo com o valor 0, ele estará sempre apontando para uma posição inválida, que é antes do primeiro elemento válido para a retirada. Isto não acontece quando ele é inicializado com o valor 1, pois após a primeira inclusão, tanto o começo como o final indicarão sempre um elemento válido. Como é no final que realizamos a inclusão, a sua inicialização com o valor 0 (zero) tem a mesma justificativa da operação análoga realizada no topo da pilha. procedimento inicializar_fila( var começo,final: inteiro ) início começo := 1; final := 0; fim Algoritmo 2.4 – Inicialização da estrutura fila. 18 O próximo algoritmo, descrito no algoritmo 2.5, fará a inclusão de um novo elemento na fila. O primeiro passo do mesmo é descobrir se existe espaço disponível no vetor para mais um elemento. Como a variável final indica a posição da última inserção (final da fila), se ela já apontar para a última posição do vetor, não há mais onde colocar este novo elemento. Note que para esta tarefa, bem como para a inclusão propriamente dita, não há a necessidade da utilização do começo. Portanto, esta variável de controle não é passada como parâmetro ao procedimento. procedimento inserir_fila( var fila:vetor[1..N] de caracter; var final: inteiro; elemento: caracter ): booleano; início se final = N então retornar falso; /* A fila está cheia */ final := final + 1; fila[ final ] := elemento; retornar verdade; /* A inserção foi realizada */ fim Algoritmo 2.5 – Inserção de um elemento na fila. Após a verificação da existência de espaço, o novo elemento pode ser incluído na estrutura. Como isto acontece no final da fila e esta variável (final) indica o último elemento incluído, é necessário incrementá-la para a nova posição antes da efetiva inserção. Como vai se tornando hábito (espero!), os procedimentos informam a que os chamou como ocorreu a operação. Neste caso, se a fila já estava cheia ou foi possível inserir mais um elemento. procedimento retirar_fila( var fila:vetor[1..N] de caracter; var começo,final: inteiro; var elemento: caracter ): booleano; início se começo > final então retornar falso; /* A fila está vazia */ elemento := fila[ começo ]; começo := começo + 1; retornar verdade; /* A retirada foi realizada */ fim Algoritmo 2.6 – Retirada de um elemento da fila. 19 Finalmente podemos efetuar a retirada de um elemento da nossa fila. Acompanhe pelo algoritmo 2.6. Neste algoritmo, uma vez que desejamos fazer uma retirada, precisamos garantir que existe elementos dentro da estrutura, no mínimo um, para validar a operação. Isto é feito nas duas primeiras linhas válidas do mesmo, quando testamos as variáveis começo e final. Assim, como na inicialização, onde começo := 1 e final := 0, sempre que a variável começo ultrapassar (for maior) do que o final, a estrutura não tem nenhum elemento armazenado. Caso tenha alguma dúvida quanto isso, faça uma simulação e um teste de mesa com os algoritmos. Quando tivermos certeza da pertinência da operação, podemos executála. Para isso é necessário lembrar que, à semelhança da variável final, o começo sempre aponta para aquele que é o “primeiro” da fila, ou seja, não podemos incrementá-lo sem antes armazenar o elemento desta posição. Desta forma, guardamos o resultado na variável elemento e, logo após, incrementamos o começo. Com isso consumamos a retirada. 2.3.4 Análise Considerando os três algoritmos apresentados antes, valos proceder a análise da estrutura fila. Em primeiro lugar, veja a análise dos procedimentos de inicialização, inserção e retirada: Algoritmo Melhor Caso Pior Caso +- := se [] +- := se [] inicializar_fila  2    2   inserir_fila  1 1  1 3 1 1 retirar_fila  1 1  1 3 1 1 Para realizar este quadro, foi considerado como melhor caso para os algoritmos a situação onde a fila está cheia ou vazia e ambos terminam sua execução no primeiro dos testes. O pior caso é tomado pela continuidade do trabalho. Mais do que o valor quantitativo das colunas da análise, perceba que eles são constantes. Esta observação pode ser estendida para qualquer tipo de implementação de uma fila. Isto acontece, mais uma vez, porque a fila define a priori os locais e a formas da operações permitidas, ou seja, não existe nenhum trabalho extenso adicional, muito menos que seja proporcional ao tamanho da estrutura que abriga a fila. 20 Necessitamos ainda observar que a fila, assim como a pilha, não prevê (ou não permite) nenhuma outra operação além daquelas já vistas. Se desejarmos realizar algum trabalho com uma fila, temos que fazê-lo através de inclusões e retiradas de elementos, nada mais. Por outro lado, nem tudo é perfeito e eficiente com a fila. Analise a seguinte situação prática: implementamos uma fila em um vetor de n posições (de qualquer tipo). Durante a operação, realizamos a inclusão, em sequência, de n valores nesta fila. Logo após, retiraremos n-2 destes valores e tentaremos incluir mais um deles. O que irá acontecer? A fila irá acusar “fila cheia”, mas há n-2 espaços não utilizados na estrutura, veja: a) Incluir n valores na fila: V1 V2 V3 V4 1 2 3 4 ........ Vn-2 Vn-1 n-2 n-1 ⇑ começo Vn n ⇑ final b) Retirar n-2 valores da fila     1 2 3 4 ........  Vn-1 n-2 n-1 n ⇑ começo ⇑ final Vn Mais uma vez, se tentarmos incluir um novo elemento, não será possível, pois a fila está “cheia”, mesmo havendo espaço disponível. Como isto pode ser corrigido? Antes disto, vamos situar o problema. Por que ele acontece? Isto é devido a regra que define a fila? Não! O causador deste problema é a estrutura física escolhida para a implementação: o vetor. Este possui tamanho fixo e prédefinido, além de ser linearmente alocado. Assim, um elemento que foi retirado não cede a sua posição para um novo uso. Resumindo, não há, pelo menos por enquanto, como resolver este problema. Na próxima seção vamos apresentar uma proposta de solução. Se, entretanto, representarmos a fila com uma organização física mais dinâmica, este problema não ocorrerá. Por exemplo, utilizando estruturas e alocação dinâmica de memória. Neste caso, o processo de retirada não é um simples incremento de apontadores. Ocorre uma efetiva liberação do espaço alocado e sua consequente disponibilização para um novo uso. Assim, a fila 21 somente estará “cheia” quando toda a memória destinada aos dados estiver ocupada. 2.4 Fila Circular 2.4.1 Apresentação Como foi mencionado na final da última seção, existe um problema de desperdício de espaço quando implementamos uma fila na forma matricial. Isto ocorre por que o vetor é uma organização com características pré-definidas e de difícil alteração, como o tamanho e a linearidade de alocação entre os elementos. Na tentativa de solucionar isto será apresentada uma proposta alternativa. Vale salientar que ela pode ser aplicada em outras situações que apresentem problemas e características semelhantes. Esta proposta é baseada na estrutura “fila circular”. Decompondo a denominação dada, percebemos que ela é também um fila e, portanto, tem as mesmas características e restrições desta. A novidade está quanto ao “circular”. Este termo está ligado a forma de tratarmos a organização física que abriga os dados e tem a sua caracterização dizendo que a última posição da organização física é seguida, necessariamente, da primeira. Em outras palavras, se, ao chegarmos até o final do vetor, necessitarmos incrementar algum dos apontadores, este não indicará uma posição inválida, mas sim a primeira posição do vetor. Formalmente a fila circular fica assim definida: Fila circular é uma fila, portanto tem as inserções em uma extremidade (final) e as retiradas em outra (começo), na qual a organização física escolhida deve, necessariamente, propiciar que ocorra linearidade, mesmo que lógica, entre a última posição disponível e a primeira delas. Uma vez que aqui a única diferença reside na forma de tratar a organização física da fila, ela tem as mesmas aplicações e considerações desta. Veja graficamente, na figura 2.3, como se comporta uma estrutura tipo fila circular. 22 Figura 2.3 – Caracterização de uma fila circular 2.4.2 Implementação As condições para a implementação de uma fila circular são exatamente as mesmas da fila normal. Aqui, entretanto, é reforçada a implementação que utiliza como organização física um vetor de n elementos. Isto acontece porque é em razão desta organização que acontece o problema de desperdício de espaço da fila comum que objetivamos resolver com a circularidade. Note ainda que o princípio da fila circular pode ser adotado em todas as situações onde uma fila tem utilidade e mais, pode utilizar qualquer organização física que a fila utilize. Claro que o uso de uma estrutura fixa como o vetor salienta o problema que estamos resolvendo. 2.4.3 Algoritmos A fila circular, em razão de ser uma fila, terá os mesmos algoritmos desta. Por outro lado, a forma interna dos procedimentos deverá mudar. Para ela também utilizaremos um vetor de n elementos do tipo caracter para abrigar as informações. A primeira questão relevante diz respeito a forma de controlarmos “fila cheia” e “fila vazia”. No caso da fila, testamos fila cheia verificando se o indicador de final chegou ao limite do vetor. Isto obviamente não pode mais ser realizado, pois não existe mais um fim lógico do vetor, uma vez que após a última posição vem a primeira delas. Para o teste de fila vazia, o problema é ainda mais dramático. O teste proposto à fila, confrontando o começo e o final também não é mais válido. Facilmente poderemos encontrar situações que invalidam testes de igualdade e/ou maioridade entre estes elementos, sejam porque a fila circular está cheia ou vazia. 23 A solução mais simples a ser adotada é a utilização de um contador de elementos presentes na estrutura. Assim, sempre que um novo elemento é inserido, este contador será incrementado. Quando procedermos uma retirada, o contador será diminuído de uma unidade. Esta nova variável de controle, o contador, necessita ser inicializada junto com o começo e o final. Naturalmente, no início, como a fila circular está vazia, seu valor deve ser 0 (zero). Com isso, já podemos desenvolver o algoritmo de inicialização da estrutura, que é apresentado no algoritmo 2.7. Os valores utilizados neste algoritmo 2.7, para a inicialização do começo e final da fila circular, são os mesmos da fila e mais, são motivados exatamente pelas mesmas razões. Lembre-se que a fila circular é, antes de mais nada, uma fila. procedimento inicializar_filac( var começo,final,cont: inteiro ) início começo := 1; final := 0; cont := 0; fim Algoritmo 2.7 – Inicialização da estrutura fila circular. Uma segunda questão a ser comentada e que estará presente nos algoritmos de inserção e retirada, é a forma que usaremos para implementar a circularidade da estrutura. Note que este circularidade é lógica e não física, pois o vetor continua sendo o mesmo da outra fila. Neste caso, o nosso problema é muito bem localizado: temos que fazer com que os indicadores de posicionamento da fila circular (começo e final) retornem ao início do vetor sempre que tentarmos ultrapassar o final deste. Como estas variáveis de controle tem incremento linear, ou seja, são acrescidas sempre de uma unidade, sabemos que vamos passar o fim do vetor sempre que somarmos 1 (um) ao controlador que aponte para a posição n deste. Para contornarmos isso, basta acrescentar um pequeno teste ao código e garantir a circularidade. Veja: variável := variável + 1; se variável > N então variável := 1; Com estas linhas de programação garantimos que a variável será incrementada linearmente enquanto não chegar ao limite do vetor. Uma vez ultrapassado este limite, ela retorna à primeira posição fazendo a circularidade necessária à estrutura. 24 A partir deste comentário é possível delinear os algoritmos de inserção (algoritmo 2.8) e de retirada (algoritmo 2.9) da fila circular. A lógica dentro destes algoritmos é a mesma da fila. Antes de procedermos a inserção de um novo elemento, verificamos se a fila ainda tem espaço. Na retirada, verificamos se existe algum elemento lá dentro. Isto é realizado através do contador de elementos. Se este contador é zero, significa que nenhum elemento está presente; se ele for igual ao tamanho do vetor, todas as posições deste, independente de onde estiverem o começo e o final, já foram ocupadas. procedimento inserir_filac( var filac:vetor[1..N] de caracter; var final,cont: inteiro; elemento: caracter ): booleano; início se cont = N então retornar falso; /* A fila está cheia */ final := final + 1; se final > N então final := 1; filac[ final ] := elemento; cont := cont + 1; retornar verdade; /* A inserção foi realizada */ fim Algoritmo 2.8 – Inserção de um elemento na fila circular. procedimento retirar_filac( var fila:vetor[1..N] de caracter; var começo,cont: inteiro; var elemento: caracter ): booleano; início se cont = 0 então retornar falso; /* A fila está vazia */ elemento := filac[ começo ]; começo := começo + 1; se começo > N então começo := 1; cont := cont –1; retornar verdade; /* A retirada foi realizada */ fim Algoritmo 2.9 – Retirada de um elemento da fila circular. Após esta verificação, procedem-se as operações pedidas. Note que, em ambos os algoritmos, o incremento do começo e final é realizado de forma circular. Além disto, após a operação, atualiza-se o contador de elementos, somando-se uma unidade na inclusão e retirando-se uma unidade na retirada de um elemento. 25 Um última consideração quanto aos algoritmos dia respeito a retirada de elementos. Neste algoritmo não é mais necessário passar como parâmetro o apontador de final da fila, como no algoritmo 2.6. Aqui testamos o caso de fila vazia através do contador de elementos, sem haver necessidade de conhecer o final dela para isso. Esta era a única razão para que esta variável estivesse presente no algoritmo retirar_fila. 2.4.4 Análise Veja no seguinte quadro a análise dos algoritmos da manipulação de uma fila circular mencionados na seção anterior: Algoritmo Melhor Caso Pior Caso +- := se [] +- := se [] inicializar_filac  3    3   inserir_filac  1 1  2 5 2 1 retirar_filac  1 1  2 5 2 1 Nesta análise, como nas anteriores, foi considerado como melhor caso a situação da estrutura estar cheia ou vazia e sair no primeiro teste dos algoritmos de inserção e retirada. O pior caso é o trabalho completo no momento em que acontece a circularidade, quando há uma atribuição adicional ao normal. Note que os valores do quadro acima continuam constantes e, principalmente, equivalentes assintoticamente àqueles da fila. Isto mostra, mais uma vez, que estamos falando de estruturas de dados equivalentes e com comportamento semelhante. As demais considerações sobre o desempenho da fila circular são análogas às da fila, com exceção do desperdício de espaço que aqui não ocorre. 2.5 Exercícios 1. Escreva um algoritmo que duplique o conteúdo de uma pilha. A pilha resultado e a original devem apresentar os elementos na mesma ordem e esta deve idêntica àquela do começo das operações. 26 2. Escreva uma algoritmo que concatene duas pilhas A e B gerando um terceira onde devem estar, em primeiro lugar, os elementos da pilha A e depois os da pilha B, na ordem original. 3. Considere a existência de duas pilhas A e B onde os elementos estão ordenados da seguinte forma: o maior deles está no topo e o menor na base. Escreva um algoritmo que cria uma terceira pilha com os elementos das pilhas A e B também ordenados segundo o mesmo formato. 4. Escreva um procedimento que inverta o conteúdo de um pilha. 5. É possível implementar duas pilhas A e B em um único vetor de n elementos tal que não ocorra “pilha cheia” para nenhuma delas sem que todas as posições do vetor estejam ocupadas? Justifique a sua resposta e, caso seja afirmativa, descreva os algoritmos de manipulação. 6. Escreva um programa (escolha a linguagem) que faça a manipulação de uma ou mais pilhas em um computador através de um pequeno menu de opções. 7. Escreva um algoritmo que duplique o conteúdo de uma fila. A fila resultado e a original devem apresentar os elementos na mesma ordem e esta deve ser a mesma do começo das operações. 8. Escreva uma algoritmo que concatene duas filas A e B gerando um terceira onde devem estar, em primeiro lugar, os elementos da fila A e depois os da fila B, na ordem original. 9. Escreva um programa (escolha a linguagem) que faça a manipulação de uma ou mais filas em um computador através de um pequeno menu de opções. 10. Considere a existência de duas filas A e B onde os elementos estão ordenados da seguinte forma: o maior deles está no começo da fila e o menor no final. Escreva um algoritmo que crie uma fila circular com todos os elementos das filas A e B também ordenados pelo mesmo critério. 11. Considere a existência de uma fila F em um vetor de n elementos completamente cheia e uma pilha P, também em um vetor de n elementos, vazia. Utilizando apenas uma variável auxiliar e os algoritmos apresentados neste capítulo, inverta a ordem dos elementos da fila (obs: pode utilizar a pilha para isto). 12. Escreva um programa (escolha a linguagem) que faça a manipulação de uma ou mais filas circulares em um computador através de um pequeno menu de opções. 13. Encontre 5 aplicações práticas para uma pilha e uma fila. 14. Compara, segundo critérios de comportamento e desempenho, as estruturas de dados pilha, fila e fila circular. 27 15. Faça uma pequena pesquisa bibliográfica e proponha um algoritmo capaz de converter uma expressão matemática da notação usual (húngara) para a notação poloneza inversa. 16. Na mesma pesquisa solicitada acima, descubra e apresente um algoritmo que soluciona uma expressão em notação poloneza inversa uma vez dados os valores das variáveis envolvidas. 28 3 Estruturas de Dados Encadeadas 3.1 Caracterização No capítulo anterior foram apresentadas as estruturas de dados de alocação sequencial onde a organização física era coincidente com a organização lógica dos dados. Um novo grupo de estruturas é formado por aquelas onde isto não precisa acontecer, ou seja, onde a organização física pode ser diferente da organização lógica das informações armazenadas. Estas são as estruturas de dados de alocação encadeada. Assim, mesmo que tenhamos “vizinhança” física entre os dados, isto não implica na necessidade de utilizarmos estes dados na mesma sequência. As estruturas encadeadas podem propor uma sequência lógica alternativa de uso dos dados, sem prejuízo da sua proposição, coisa que nenhuma das estruturas vistas podia fazer. Para fazermos isto, vamos necessitar de uma liberdade maior quanto a maneira de guardarmos os dados, ou seja, a sua organização física. Qualquer estrutura que permita indicar uma sequência lógica diferente da sequência física de armazenamento poderá ser utilizada neste caso. Isso pressupondo que esta estrutura permita endereçar ou referenciar diretamente cada uma das unidades de informação armazenada. Existe, enquanto “estruturas básicas”, apenas um representante deste grupo, são as listas lineares encadeadas, onde o encadeamento define a “sequência lógica” de acesso aos dados. 29 3.2 Listas Encadeadas 3.2.1 Apresentação Para apresentar e definir uma lista linear encadeada, vamos utilizar o exemplo a seguir: 1 2 3 4 5 6 7 8 9 10 Código 027 013 033 103 017 042 021 001 098 069 Descrição Feijão Batata Tomate Milho Arroz Macarrão Espinafre Pimentão Cenoura Ervilha Neste pequeno exemplo agrário, encontraremos uma certa quantidade de informações sobre produtos alimentícios, com um código qualquer de referência, armazenados em uma estrutura matricial. Na visão mais simples de todas, podemos utilizar estes dados na forma e ordem na qual estão guardados, ou seja, pelas posições 1, 2, 3, 4,...., 9, 10. Entretanto poderemos desejar percorrer estes dados por qualquer outra ordem. Por exemplo, em ordem alfabética crescente de nome. Neste caso, a sequência lógica de uso, de acordo com o índice da posição, será: 5 - 2 - 9 - 10 - 7 - 1 - 6 - 4 - 8 - 3 Já se a ordem desejada for alfabética decrescente da coluna nome, teremos: 3 - 8 - 4 - 6 - 1 - 7 - 10 - 9 - 2 - 5 Podemos ainda propor o mesmo para o campo código. Na forma numérica crescente deste campo, a ordem será: 8 - 2 - 5 - 7 - 1 - 3 - 6 - 10 - 9 - 4 Observe que as sequências dadas acima são possíveis organizações lógicas para os dados e mais, elas são independentes e diferentes da organização física dos mesmos. 30 A noção de encadeamento é muito semelhante a isto, pois ela estabelece uma sequência lógica de uso dos dados. O primeiro raciocínio da fazermos encadeamento será o de transformarmos ou armazenarmos as sequências acima em vetores, por exemplo. Esta não parece uma solução eficiente, por mais que possa ser eficaz, pois a cada inclusão ou exclusão poderemos ter, no pior caso, uma pesquisa sequencial (para achar a posição ou o elemento) e um deslocamento inversamente proporcional a esta (para criar espaço à inclusão ou reorganizar os elementos na retirada). Este é um tempo muito grande e precisamos buscar outras alternativas. A solução vem da observação que uma das principais razões deste trabalho adicional é a disposição diferenciada dos elementos na estrutura. Por exemplo, o primeiro elemento de informação útil não tem relação direta com o primeiro elemento da sequência (a menos que seja o mesmo). Este descompasso faz com que as operações aconteçam em locais (posições) diferentes nas estruturas, obrigando a realização de, no mínimo, duas pesquisas e realocação de elementos. A proposta da lista encadeada vem de encontro com a solução deste problema. Nela, cada unidade de informação indica a próxima em uma ordem lógica qualquer e pré-definida. Formalmente, a definição seria: Lista linear encadeada é uma estrutura onde cada unidade de informação ou nó traz junto um apontador que indica o próximo elemento de acordo com uma sequência prédeterminada. Assim, em lugar de guardarmos a sequência lógica, faremos com que os nós “mostrem-na” dentro de cada um deles. Veja o exemplo: 1 2 3 4 5 6 7 8 9 10 Código 027 013 033 103 017 042 021 001 098 069 Descrição Feijão Batata Tomate Milho Arroz Macarrão Espinafre Pimentão Cenoura Ervilha 31 A 6 9  8 2 4 1 3 10 7 B 7 5 8 6  1 10 4 2 9 C 3 5 6  7 10 1 2 4 9 Sendo que cada uma das colunas tem o seu próprio início, definido por: Início A Î 5 Início B Î 3 Início C Î 8 Nesta nova lista, cada uma das colunas A, B e C representa uma das sequências dadas antes. Por exemplo, para a coluna A, que dá a ordem alfabética crescente do campo nome, o início é na posição 5, ou seja, o primeiro elemento nesta ordem é aquele armazenado fisicamente na quinta posição. Após, vem a informação da posição 2; isto está dito na posição da coluna A equivalente ao elemento trabalhado, no caso arroz. Depois vem a posição 9, obtida da mesma forma, e assim sucessivamente até chegarmos a posição 3 que será a última da sequência por não ter continuidade prevista na respectiva coluna. Para as outras colunas, o raciocínio é idêntico. Em uma lista encadeada, cada unidade de informação ou nó tem a seguinte característica: Área de Informações Área de Apontadores ou Elos Assim, ele é dividido em duas áreas distintas: uma delas, a área de informações, guarda todos os dados que compõem a razão de ser da estrutura; na outra, estão os apontadores, também conhecidos como elos, que formam as sequências lógicas de acesso ou encadeamentos. Sempre que formos utilizar uma lista destas, devemos trabalhar toda a linha horizontal onde estão as informações e os apontadores. Não faz sentido separarmos ambos e trabalhar entendendo que não há relação entre eles. Basta trocar um elemento de posição para verificar que todo o sequenciamento lógico é quebrado. Em relação ao tipo de encadeamento utilizado, uma lista pode apresentar dois tipos deles. Estes tipos fazem referência ao critério escolhido para a sequência lógica. Observe que, para a existência de uma destas listas, o critério escolhido deve ser capaz de estabelecer de forma inequívoca uma relação de sucessão entre os elementos envolvidos. Em cima disto, os tipos possíveis de lista são: a) Encadeamento simples ou único: Este encadeamento ocorre quando, em relação a um dado critério, a lista traz somente um dos sentidos possíveis. Aqui, cada unidade de informação indica 32 o seu sucessor ou antecessor naquela ordem solicitada, mas somente um deles. Assim, a aparência da lista é: b) Encadeamento duplo: Esta lista ocorre quando, em relação ao critério escolhido, cada unidade de informação indica sempre o seu antecessor e o seu sucessor. Sua aparência é: Observe entretanto que, em uma mesma lista podem estar presentes mais de um tipo de encadeamento. Veja o exemplo dado antes, onde as colunas A e B juntas estabelecem um duplo encadeamento pela ordenação alfabética do campo nome. Já a coluna C é um encadeamento simples ou único pela ordenação numérica do campo código no sentido crescente. 3.2.2 Implementação Uma vez claro o conceito da lista encadeada, precisamos discutir formas de implementá-la. Como foi caracterizado antes, o encadeamento estabelece a organização lógica que foi escolhida. Desta forma, necessitamos então escolher uma organização física para armazenar os elementos. A principal característica da lista reside no fato de cada unidade de informação indicar a próxima unidade a ser utilizada. Para que isto ocorra, esta “próxima” unidade deve ser acessível de forma direta, ou seja, deve haver uma forma de encontrarmos e utilizarmos diretamente esta informação, a partir do seu endereço definido. Desta maneira, qualquer organização física que permita esta tipo de endereçamento pode abrigar uma lista encadeada. Algumas organizações podem atender a estas necessidades e, em especial, representações matricial e através de alocação dinâmica de memória. Estas, além de atenderem à quase totalidade dos casos práticos, serão capazes de estabelecer uma lógica de uso que permitirá a adequação aos casos novos. Por outro lado, é necessário estabelecer algumas diferenças entre os casos. A representação matricial, que tem como vantagem a facilidade de implementação e entendimento para a maioria das pessoas, tem duas desvantagens potenciais. A primeira delas está no fato de uma matriz ser uma estrutura uniforme, ou seja, todos os elementos são de um mesmo tipo. Isto não 33 acontece na realidade e teremos informações de tipo variado na estrutura (veja o exemplo anterior). Se isto acontecer basta, em lugar de uma matriz uniforme, utilizar um conjunto de vetores, todos do mesmo tamanho, um para cada campo da lista, ou declarar uma estrutura que represente cada nó e criar uma matriz não uniforme com ela. Veja os dois casos para o exemplo dado antes: codigo: vetor[1..N] de inteiro; nome: vetor[1..N] de caracter; eloA, eloB, eloC: vetor[1..N] de inteiro; ou estrutura Tipo_Lista codigo: inteiro; nome: caracter; eloA, eloB, eloC: inteiro; fim lista: vetor[1..N] de Tipo_Lista; O segundo problema é muito mais sério. Ele ocorre devido ao fato da matriz (em qualquer uma das formas citadas) ter tamanho pré-definido, ou seja, necessitamos saber, antes de trabalhar a estrutura, qual será o número de elementos presentes na lista. Isto, além de ser difícil, tende a gerar desperdício de memória, pois iremos, na maioria das vezes, superdimensionar o espaço para que não faltem posições. A alternativa de implementação com alocação dinâmica de memória cria uma estrutura semelhante aquela mostrada acima (por exemplo, veja aquela utilizada no item b da sub-secção 3.2.3.1). Sempre que necessitamos de um novo elemento, solicitamos mais memória ao sistema operacional. Assim, não precisamos dimensionar nada a priori e não desperdiçaremos espaço. O mesmo acontece com a retirada. Entretanto, além de ser de entendimento e implementação mais difícil para alguns, esta implementação ocupa mais espaço, pois os elos dos encadeamentos serão ponteiros e estes, geralmente, são maiores (bem maiores) do que números inteiros utilizados na representação matricial. Como o objetivo deste material é didático, vamos apresentar e discutir ambas as formas de implementação, matricial e por alocação dinâmica de memória. Em qualquer um dos casos, teremos uma variável de controle adicional para cada um dos encadeamentos utilizados. Esta será o começo ou início da sequência lógica. No caso de encadeamento duplo, haverão duas variáveis, o começo e o final daquela sequência, sendo que o final pode ser visto como o indicador do primeiro elemento de cada uma das sequências invertidas. 34 3.2.3 Algoritmos Conforme mencionado antes, discutiremos dois tipos de implementação, a matricial e com alocação dinâmica de memória. Para cada um destes casos discutiremos um conjunto de algoritmos de manipulação (inicialização, inclusão, exclusão e consulta) para a lista unicamente encadeada e a lista duplamente encadeada. Isto é necessário em razão de algumas particularidades existentes em cada caso. Vamos também trabalhar com listas que tenham um só dos tipos de encadeamento. Casos mais complexos, como o exemplo dados antes, podem ser codificados com uma combinação dos casos tratados aqui. Antes de apresentarmos os algoritmos, precisamos discutir um problema adicional. Quando utilizamos alocação dinâmica de memória, o sistema operacional gerencia o espaço utilizado para nós. E para o caso matricial, quem faz e como isto é feito? Já que a matriz é criada dentro do programa, pelo programador, este necessita definir e implementar formas de realizar este controle. O mecanismo a ser proposto deve garantir o uso de todo o espaço disponível, inclusive àqueles nós que foram liberados durante o trabalho de exclusão. Para realizar isto vamos utilizar uma estrutura auxiliar que irá gerenciar o espaço livre. Está será conhecida por Pilha de Nós Disponíveis  PND. Na PND serão empilhados todas as posições livres da estrutura (pelo menos no começo dos trabalhos será assim) e sempre que quisermos espaço para um novo nó, desempilharemos uma posição da PND. Na retirada, após concluído do processo, a posição liberada será empilhada. Note que, como a localização física das informações é irrelevante para uma lista, não precisamos nos preocupar com o local da posição que será desempilhada, basta tomá-la para o uso. Veremos um pequeno exemplo da PND, em uma matriz de 10 elementos e com apenas 4 em uso, na forma de uma lista unicamente encadeada: Info 1 2 3 4 5 6 7 8 9 10 Elo A C B 4 6 3 D 0 PND 10 9 8 7 6 5 4 3 2 1 35 1 5 7 8 9 10 Começo 2 Topo PND 6 Esta técnica de gerenciamento é eficaz, mas pode ter a sua eficiência ampliada. Nela estaremos acrescentando mais uma estrutura, com tamanho prédefinido e gastaremos mais espaço de armazenamento. Vamos discutir uma solução para isto sem perder a eficácia da proposta. A solução vem da seguinte observação: a lista e a PND são complementares em número de elementos. Sempre que somarmos estas quantidades teremos um total equivalente ao tamanho da lista. Se isto é verdade, estamos afirmando que, dentro da lista, sempre há espaço suficiente disponível para armazenar a PND. Basta apenas discutir como isto será feito. A solução é considerar a PND como uma outra lista “lógica” dentro da mesma estrutura física da lista encadeada, que congrega apenas os elementos vazios. O seu começo será dado pela variável PND. Veja o exemplo: Info 1 2 3 4 5 6 7 8 9 10 A C B D Elo 5 4 6 3 7 0 8 9 10 0 Começo 2 PND 1 Neste caso, o primeiro elemento vazio é aquele da posição 1, por indicação da PND, e ele indica a próxima posição nesta condição no seu elo. Isto acontece desta forma por todos aqueles que estão vazios. Para mantermos o princípio da pilha devemos apenas inserir e retirar elementos a partir da indicação da variável de controle PND, que trabalhará neste caso, como o topo da pilha anterior. 3.2.3.1 Lista unicamente encadeada O primeiro grupo de algoritmos será apresentado e discutido para as listas unicamente encadeadas, assim vamos definir, antes de mais nada, o tipo de dado que irá abrigar a lista. À semelhança das estruturas do capítulo anterior, vamos armazenar um dado do tipo caracter apenas. Isto simplifica o trabalho de manipulação. Por analogia, o caso pode ser ampliado para situações mais amplas e complexas. 36 Vamos dividir, conforme dito, os algoritmos em dois grupos: implementação matricial e com alocação dinâmica de memória. Para cada um destes grupos veremos quatro algoritmos: inicialização da estrutura, inserção de um novo elemento, remoção de um elemento existente e a busca ou localização da posição de um dado valor. a) Representação matricial: Para implementar a lista descrita acima na forma matricial, vamos representar a mesma como um estrutura e um vetor de n elementos do tipo definido por ela. Veja: estrutura Tipo_LUE info: caracter; elo: inteiro; fim lista: vetor[1..N] de Tipo_LUE; começo, pnd: inteiro; Utilizando esta proposta, vamos realizar a inicialização da estrutura. Para isto, precisamos definir quais serão as tarefas a serem realizadas. Serão duas: a atribuição de um valor inicial à variável começo e a construção da lista PND que inicialmente estará cheia. Estas tarefas estão postas no algoritmo 3.1. Neste algoritmo é dado zero para o começo, indicando que a lista está vazia e construído o conjunto de elos que colocam todas as posições da lista como vagas ou disponíveis na PND. procedimento inicializar_lue( var lista:vetor[1..N] de Tipo_LUE; var começo,pnd: inteiro ); var i: inteiro; início começo := 0; /* A lista está vazia */ para i := 2 até N faça /* Preenche a lista de disponíveis */ lista[i-1].elo := i; lista[N].elo := 0; pnd := 1; /* Primeiro disponível */ fim Algoritmo 3.1 – Inicialização da lista unicamente encadeada na forma matricial. 37 O resultado do processo de inicialização é mostrado na figura 3.1. Note que, na inicialização, a lista de nós disponíveis é linear e sequencial. A quebra desta sequência se dará com a realização das operações de manipulação da estrutura. Figura 3.1 – Resultado da inicialização da lista unicamente encadeada Figura 3.2 – Casos especiais de inclusão de um elemento na lista unicamente encadeada. 38 Feita a inicialização da estrutura, que é sempre o primeiro passo a ser realizado, podemos fazer as demais operações. O algoritmo 3.2 apresenta a inclusão de um novo elemento na lista. Note que ele traz um novo parâmetro posição que indica o local após o qual será realizada a inclusão. Desta forma, ele presume que o processo de localização desta posição é externo a ele e independente da sua tarefa. procedimento incluir_lue( var lista:vetor[1..N] de Tipo_LUE; var começo,pnd,posição: inteiro; elemento:caracter ): booleano; var livre: inteiro; início se pnd = 0 então retornar falso; /* A lista está cheia */ /* Toma a próxima posição livre da pnd e a atualiza */ livre := pnd; pnd := lista[livre].elo; /* Insere o novo elemento */ lista[livre].info := elemento; /* Atualiza os apontadores de acordo com o caso da inclusão */ se posição = 0 então /* A inserção é no começo */ lista[livre].elo := começo; começo := livre; senão /* A inserção é no meio ou final */ lista[livre].elo := lista[posição].elo; lista[posição].elo := livre; fim retornar verdade; fim /* Inserção Ok. */ Algoritmo 3.2 – Inclusão de um elemento em uma lista unicamente encadeada na forma matricial. Quanto a inclusão propriamente dita, é importante notar que existem três casos diferentes de inserção de um novo elemento na lista: antes do começo (este novo elemento será o novo começo da lista); após o final da lista; e, no meio desta. Cada um destes casos altera parâmetros diferentes da estrutura. Veja isto na figura 3.2. 39 O algoritmo de inclusão generaliza os casos de inclusão no meio e final, fazendo as mesmas operações para ambos. Experimente implementá-las em separado e veja que o resultado é o mesmo. Não podemos esquecer ainda que é necessário atualizar a pnd após a inclusão. Neste algoritmo isto é feito no momento da retirada do nó disponível. Se não houver nenhum nó a ser retirado da PND (pnd = 0), a lista está cheia e não é possível realizar a inclusão. Lembre-se então que este processo é realizado em etapas: a primeira verifica e obtém o espaço para o armazenamento; após isto, o novo elemento é colocado no nó disponível; finalmente, são atualizados os elos, dependendo do caso, para que este novo nó participe da sequência lógica, ou seja, efetivamente entre na estrutura. Figura 3.3 – Retirada de um elemento de uma lista unicamente encadeada. Veremos agora o procedimento de retirada. Para este caso, de uma lista unicamente encadeada, este é um procedimento muito “problemático”. A razão disto é bastante simples e é espelhada na figura 3.3. Para retirar um elemento, precisamos fazer com que o elo do nó anterior a ele aponte para aquele nó que ele originalmente aponta. O problema é que, posicionados no nó a ser excluído, não sabemos quem é o seu antecessor na sequência lógica, apenas o sucessor. 40 A verdade é que uma lista unicamente encadeada não é uma boa estrutura para aqueles casos onde ocorrem muitas retiradas. Para isso recomendase o uso de outras estruturas, como a lista duplamente encadeada. Caso se insista em realizar a operação, existem duas soluções possíveis para este problema. Nenhuma desta soluções é “muito limpa”, ou seja, necessitaremos fazer algum arranjo que facilite a operação. Este arranjo será sempre no sentido de conhecer quem é o antecessor lógico do nó. As duas soluções mencionadas são passar como parâmetro a posição do nó a ser excluído e a posição do seu antecessor; ou passar somente a posição do antecessor já que, a partir dele, podemos facilmente chegar ao nó a ser retirado. O algoritmo 3.3 apresenta uma solução baseada na primeira das propostas. Neste algoritmo observaremos uma mudança em relação aos anteriores: não salvaremos o elemento excluído para o retorno. A razão disto é que já fizemos uma busca pela posição do elemento a ser retirado e se chegamos até esta operação é porque ele existe e já dispomos do mesmo para uso. Mesmo o teste inicial feito no algoritmo é supérfulo neste caso. Já quanto a posição, infelizmente não há como garantir se ela é válida ou não sem uma pesquisa anterior muito custosa sob o ponto de vista computacional. procedimento retirar_lue( var lista:vetor[1..N] de Tipo_LUE; var começo,pnd,posição,anterior: inteiro ): booleano; início se começo = 0 então retornar falso; /* A lista está vazia */ /* Retira o elemento, atualizando os ponteiros de acordo com o caso */ se posição = começo então /* Está retirando o primeiro elemento */ começo := lista[posição].elo; senão /* Está retirando no meio ou final */ lista[anterior].elo := lista[posição].elo; fim /* Retorna a posição liberada para a PND */ lista[posição].elo := pnd; pnd := posição; retornar verdade; fim /* Retirada Ok. */ Algoritmo 3.3 – Retirada de um elemento de uma lista unicamente encadeada na forma matricial. 41 Quanto a retirada propriamente dita, teremos também três casos de ajuste de apontadores a serem tratados: a retirada do primeiro elemento da sequência lógica; a retirada do último elemento desta sequência; e, a retirada de um elemento intermediário. Mais uma vez, o algoritmo implementa os dois últimos casos de forma idêntica sem prejuízo da tarefa realizada. Veja novamente a figura 3.3 para detalhes. Após a retirada do elemento, necessitamos devolver a sua posição à PND, para que volte a ser utilizado futuramente. Isto é realizado colocando-o novamente na sequência lógica de disponíveis. Finalmente podemos realizar a consulta ou pesquisa na lista unicamente encadeada, tentando localizar a posição física de uma dada informação. O algoritmo 3.4 apresenta esta operação. Ele retorna a posição da localização ou 0 (zero) se o elemento não existir na lista. Observe somente que não é possível percorrer a lista com laços de incremento linear, como para, pois a organização lógica pode ser diferente da física. Desta forma, só podemos fazer a varredura através dos elos. Outro detalhe é que o único método de pesquisa admissível é a pesquisa sequencial. Métodos como a pesquisa binária (veja em [AU92]) não funcionam porque não é possível localizar o “meio lógico” da lista necessário à realização da partição. procedimento pesquisar_lue( lista:vetor[1..N] de Tipo_LUE; começo: inteiro; elemento:caracter ): inteiro; var posição: inteiro; início posição := começo; enquanto posição <> 0 e lista[posição].info <> elemento faça posição := lista[posição].elo; fim retornar posição; fim Algoritmo 3.4 – Pesquisa a localização de um elemento na lista unicamente encadeada na forma matricial. b) Alocação dinâmica de memória Vamos apresentar agora a segunda possibilidade de implementação da lista unicamente encadeada, utilizando a alocação dinâmica de memória. Para 42 isso vamos definir como será a estrutura básica de trabalho que armazenará cada um dos nós. Veja: estrutura Tipo_LDE info: caracter; elo: ^Tipo_LDE; fim começo: ^Tipo_LDE; Neste caso não há necessidade de termos a PND, pois o sistema operacional se encarrega da tarefa de gestão do espaço. Outra observação importante é quanto ao elo e ao começo, pois eles agora são apontadores para endereços de memória, ou seja, ponteiros e não mais inteiros como no caso matricial. procedimento inicializar_lue( var começo: ^Tipo_LUE ); início começo := NULO; /* A lista está vazia */ fim Algoritmo 3.5 – Inicialização da lista unicamente encadeada em alocação dinâmica de memória. O algoritmo de inicialização é muito mais simples para esta situação já que não existe mais a PND. Agora, apenas necessitamos inicializar o começo, que é feito nulo quando não há ninguém na estrutura. Por nulo, que é um valor especial, se entende aqui o ponteiro que não aponta para nenhuma região de memória. Veja-o no algoritmo 3.5. Para realizarmos a inclusão de um novo elemento, o procedimento é muito semelhante ao caso anterior (algoritmo 3.2) pois a lógica da estrutura continua a mesma, mudou apenas a alocação física dela. Este algoritmo está descrito no algoritmo 3.6. Nele, os casos especiais da inclusão continuam os mesmos, inclusive a variável posição, com a mesma função de antes. Outro detalhe de extrema importância é a “ausência” da lista como parâmetro. Como ela é residente em memória, basta conhecer o começo para alcançar qualquer outra posição. A retirada apresenta os mesmos problemas já citados e adotaremos a mesma alternativa ou arranjo de solução do caso matricial. Aqui, entretanto, não temos o problema do espaço e precisamos apenas informar ao sistema operacional que aquele espaço não é mais útil. O resto é por conta dele. Este é o algoritmo 3.7. 43 Procedimento incluir_lue( var começo,posição: ^Tipo_LUE; elemento:caracter ): booleano ; var livre: ^Tipo_LUE; início /* Solicita espaço para o novo elemento */ livre := novo Tipo_LUE; se livre = NULO então retornar falso; /* A lista está cheia, pois não há mais memória */ /* Insere o novo elemento no espaço obtido*/ livre.info := elemento; /* Atualiza os ponteiros de acordo com o caso da inclusão */ se começo = NULO ou posição = NULO então /* Inserir no começo */ livre.elo := começo; começo := livre; senão /* A inserção é no meio ou final */ livre.elo := posição.elo; posição.elo := livre; fim retornar verdade; /* Inserção Ok. */ fim Algoritmo 3.6 – Inclusão de um elemento em uma lista unicamente encadeada em alocação dinâmica de memória. Finalmente o algoritmo de pesquisa. Aquele apresentado no algoritmo 3.4 é absolutamente análogo ao formato agora utilizado para armazenamento da lista. Veja-o no algoritmo 3.8. Ao final, a variável posição conterá nulo se não for encontrado o elemento que desejamos. Caso contrário, ela apontará para o endereço de memória onde este elemento está. 44 procedimento retirar_lue( var começo,posição,anterior: ^Tipo_LUE ): booleano; início se começo = NULO ou posição = NULO então retornar falso; /* A lista está vazia ou posição é inválida */ /* Retira o elemento, atualizando os ponteiros de acordo com o caso */ se posição = começo então /* Está retirando o primeiro elemento */ começo := posição.elo; senão /* Está retirando no meio ou final */ anterior.elo := posição.elo; fim /* Libera a memória utilizada */ liberar posição; retornar verdade; fim /* Retirada Ok. */ Algoritmo 3.7 – Retirada de um elemento de uma lista unicamente encadeada em alocação dinâmica de memória. Procedimento pesquisar_lue(começo: ^Tipo_LUE; elemento:caracter ): ^Tipo_LUE; var posição: ^Tipo_LUE; início posição := começo; enquanto posição <> NULO e posição.info <> elemento faça posição := posição.elo; fim retornar posição; fim Algoritmo 3.8 – Pesquisa a localização de um elemento na lista unicamente encadeada em alocação dinâmica de memória. 3.2.3.2 Lista duplamente encadeada Para implementarmos a lista de forma a utilizar um duplo encadeamento, manteremos analogia ao que foi proposto para o caso mais simples, ou seja, 45 armazenaremos apenas uma informação do tipo caracter. A única modificação será o acréscimo de um novo elo. Este, com aquele que já existia, serão denominados de eloa e elop, ou seja, elo para o elemento anterior (antecessor) e elo para o elemento posterior (sucessor), respectivamente. Os algoritmos apresentados e as formas de apresentação serão as mesmas de antes, ou seja, faremos a inicialização, inclusão, retirada e pesquisa na lista duplamente encadeada sob a forma matricial e com alocação dinâmica de memória. a) Representação matricial Para implementar a lista duplamente encadeada na forma matricial, necessitamos definir a matriz que a abrigará. Esta será: estrutura Tipo_LDE info: caracter; eloa, elop: inteiro; fim lista: vetor[1..N] de Tipo_LDE; começo, final, pnd: inteiro; Note que aqui também existe a lista auxiliar de nós disponíveis PND. Isto é necessário pois estamos novamente trabalhando com uma estrutura de tamanho fixo e pré-definido. Como agora temos dois elos e a lista PND é de encadeamento único ou simples, precisamos escolher um deles para definir a sua sequência. Vamos utilizar o elop para esta tarefa. Outra coisa a observar é a existência de um controlador para o final da lista. Ele é necessário porque indica o “início” da encadeamento através do elo anterior. Baseado então nestas definições, o algoritmo 3.9 descreve o procedimento de inicialização desta estrutura. A única novidade é a atribuição de zero para o final, indicando que a lista está vazia. O procedimento de inclusão de um novo elemento, no algoritmo 3.10, tem a mesma lógica da lista unicamente encadeada, mas aqui as três situações diferentes de inclusão, começo, meio e final da lista, devem ser implementadas em separado pois existe a variável final para ser ajustada na última da situações. Veja a figura 3.4 para o gráfico explicativo dos casos e suas respectivas ações. procedimento inicializar_lde( var lista:vetor[1..N] de Tipo_LDE; var começo,final,pnd: inteiro ); 46 var i: inteiro; início começo := 0; /* A lista está vazia */ final := 0; para i := 2 até N faça /* Preenche a lista de disponíveis */ lista[i-1].elop := i; lista[N].elop := 0; pnd := 1; /* Primeiro disponível */ fim Algoritmo 3.9 – Inicialização da lista duplamente encadeada na forma matricial. Figura 3.4 – Inserção de um novo elemento em uma lista duplamente encadeada procedimento incluir_lde( var lista:vetor[1..N] de Tipo_LDE; var começo,final,pnd,posição: inteiro; elemento:caracter ): booleano; var livre, posterior: inteiro; 47 início /* Verifica se a lista está cheia */ se pnd = 0 então retornar falso; /* A lista está cheia */ /* Toma a próxima posição livre da pnd e a atualiza */ livre := pnd; pnd := lista[livre].elop; /* Insere o novo elemento */ lista[livre].info := elemento; /* Atualiza os apontadores de acordo com o caso da inclusão */ se começo = 0 então /* A lista está vazia */ lista[livre].eloa := 0; lista[livre].elop := 0; começo := livre; final := livre; retornar verdade; /* Inserção Ok */ fim se posição = 0 então /* Inserção no começo da lista */ lista[livre].eloa := 0; lista[livre].elop := começo; lista[começo].eloa := livre; começo := livre; retornar verdade; /* Inserção Ok */ fim se posição = final então /* Inserção no final da lista */ lista[livre].eloa := final; lista[livre].elop := 0; lista[final].elop := livre; final := livre; retornar verdade; /* Inserção Ok */ fim /* A inserção é no meio da lista */ posterior := lista[posição].elop; lista[livre].eloa := posição; lista[livre].elop := posterior; lista[posição].elop := livre; 48 lista[posterior].eloa := livre; retornar verdade; /* Inserção Ok. */ fim Algoritmo 3.10 – Inclusão de um elemento em uma lista duplamente encadeada na forma matricial. Figura 3.5 – Retirada de um elemento de uma lista duplamente encadeada. Para a retirada de um elemento, também vão ocorrer três casos, descritos na figura 3.5, que devem ser tratados em separado pelo algoritmo. A principal diferença é que agora não precisamos informar quem é o nó anterior aquele a ser retirado. O seu campo eloa informa este nó, bem como o campo elop informa quem é o próximo nó da sequência lógica. Este processo é descrito no algoritmo 3.11. procedimento retirar_lde( var lista:vetor[1..N] de Tipo_LDE; var começo,final,pnd,posição: inteiro ): booleano; var anterior, posterior: inteiro; início 49 se começo = 0 ou posição = 0 então retornar falso; /* A lista está vazia ou a posição é inválida */ /* Retira o elemento, atualizando os ponteiros de acordo com o caso */ se posição = começo então /* Esta retirando o primeiro da lista */ posterior := lista[começo].elop; se posterior = 0 então /* Só tem um elemento na lista */ final := 0; começo := 0; senão lista[posterior].eloa := 0; começo := posterior; fim senão anterior := lista[posição].eloa; posterior := lista[posição].elop; se posição = final então /* O elemento é o último da lista */ lista[anterior].elop := 0; final := anterior; senão /* É um elemento do meio da lista */ lista[anterior].elop := posterior; lista[posterior].eloa := anterior; fim fim /* Retorna a posição liberada para a PND */ lista[posição].elop := pnd; pnd := posição; retornar verdade; fim /* Retirada Ok. */ Algoritmo 3.11 – Retirada de um elemento de uma lista duplamente encadeada na forma matricial. Finalmente, no algoritmo 3.12, está descrita a pesquisa pela posição de uma dado elemento na lista duplamente encadeada. Observe que foi implementada a pesquisa através do campo elop, ou seja, sequencialmente no sentido começo final da lista. Se desejar, pode fazê-lo pelo campo eloa, começando pela posição final e terminando no começo da lista. Por outro lado, esta alternativa não muda em nada o desempenho do algoritmo, pois o trabalho realizado por ele continuará o mesmo. Î procedimento pesquisar_lde( lista:vetor[1..N] de Tipo_LDE; 50 começo: inteiro; elemento:caracter ): inteiro; var posição: inteiro; início posição := começo; enquanto posição <> 0 e lista[posição].info <> elemento faça posição := lista[posição].elop; fim retornar posição; fim Algoritmo 3.12 – Pesquisa a localização de um elemento na lista duplamente encadeada na forma matricial. b) Alocação dinâmica de memória Em primeiro lugar vamos apresentar a estrutura que será utilizada para definir a unidade de informação ou nó: estrutura Tipo_LDE info: caracter; eloa, elop: ^Tipo_LDE; fim começo, final: ^Tipo_LDE; De posse desta estrutura, podemos compor o algoritmo de inicialização da lista duplamente encadeada. Este é o algoritmo 3.13. Como estamos utilizando alocação dinâmica de memória, não existe a PND e precisamos apenas atribuir um valor inicial às variáveis de controle começo e final. Ambas serão feitas nulas para indicar que a lista está vazia e, neste caso, elas não apontam para nenhuma região de memória. procedimento inicializar_lue( var começo, final: ^Tipo_LDE ); início começo := NULO; /* A lista está vazia */ final := NULO; fim 51 Algoritmo 3.13 – Inicialização da lista duplamente encadeada em alocação dinâmica de memória. procedimento incluir_lde( var começo,final,posição: ^Tipo_LDE; elemento:caracter ): booleano; var livre, posterior: ^Tipo_LDE; início /* Solicita memória para o novo elemento e testa de há espaço */ livre := novo Tipo_LDE; se livre = NULO então retornar falso; /* A lista está cheia */ /* Insere o novo elemento */ livre.info := elemento; /* Atualiza os apontadores de acordo com o caso da inclusão */ /* A lista está vazia */ se começo = NULO então livre.eloa := NULO; livre.elop := NULO; começo := livre; final := livre; retornar verdade; /* Inserção Ok */ fim se posição = NULO então /* Inserção no começo da lista */ livre.eloa := NULO; livre.elop := começo; começo.eloa := livre; começo := livre; retornar verdade; /* Inserção Ok */ fim se posição = final então /* Inserção no final da lista */ livre.eloa := final; livre.elop := NULO; final.elop := livre; final := livre; retornar verdade; /* Inserção Ok */ fim posterior := posição.elop; livre.eloa := posição; /* A inserção é no meio da lista */ 52 livre.elop := posterior; posição.elop := livre; posterior.eloa := livre; retornar verdade; /* Inserção Ok. */ fim Algoritmo 3.14 – Inclusão de um elemento em uma lista duplamente encadeada em alocação dinâmica de memória. procedimento retirar_lde( var começo, final, posição: ^Tipo_LDE ): booleano; var anterior, posterior: ^Tipo_LDE; início se começo = NULO ou posição = NULO então retornar falso; /* A lista está vazia ou a posição é inválida */ /* Retira o elemento, atualizando os ponteiros de acordo com o caso */ se posição = começo então /* Esta retirando o primeiro da lista */ posterior := começo.elop; se posterior = NULO então /* Só tem um elemento na lista */ final := NULO; começo := NULO; senão posterior.eloa := NULO; começo := posterior; fim senão anterior := posição.eloa; posterior := posição.elop; se posição = final então /* O elemento é o último da lista */ anterior.elop := NULO; final := anterior; senão /* É um elemento do meio da lista */ anterior.elop := posterior; posterior.eloa := anterior; fim fim /* Liberar o espaço ocupado ao sistema operacional */ liberar posição; retornar verdade; /* Retirada Ok. */ fim 53 Algoritmo 3.15 – Retirada de um elemento de uma lista duplamente encadeada em alocação dinâmica de memória. procedimento pesquisar_lde( começo: ^Tipo_LDE; elemento:caracter ): ^Tipo_LDE; var posição: ^Tipo_LDE; início posição := começo; enquanto posição <> NULO e posição.info <> elemento faça posição := posição.elop; fim retornar posição; fim Algoritmo 3.16 – Pesquisa a localização de um elemento na lista duplamente encadeada em alocação dinâmica de memória. Para a lista duplamente encadeada armazenada com alocação dinâmica de memória os processos são análogos aqueles já vistos. Estes estão descritos nos algoritmos 3.14 (inclusão), 3.15 (retirada) e 3.16 (pesquisa). Se ainda assim houver alguma dúvida, entenda bem os algoritmos anteriores, faça um teste de mesa com estes apresentados agora e analise/observe todos os detalhes do seu funcionamento. Outra observação bastante importante, antes tarde do que nunca, é recomendar à todos uma revisão/atualização forte no uso e tratamento de ponteiros pelas linguagens. Para isso, uma vez tendo escolhido a sua linguagem de programação, recorra a manuais ou livros sobre ela e verifique como são manipulados ponteiros e alocação dinâmica de memória. Faça todos os exercícios propostos nestes livros e se capacite no uso desta ferramenta de extrema importância para o uso de estruturas de dados. 3.2.4 Análise Vamos agora realizar a análise da complexidade dos algoritmos de manipulação das listas encadeadas vistos nesta seção. Veja, para isso, o somatório das operações realizadas em cada um dos casos de trabalho (melhor e pior) dados 54 na tabela a seguir. Como existe algoritmos com o mesmo nome nesta seção, eles trazem o seu número utilizado como referência neste texto. A tabela é: Melhor Caso Algoritmo +Inicializar_Lue (3.1) 2N-2 Pior Caso := se [] +- := se [] 2N+2 N N 2N-2 2N+2 N N Incluir_Lue (3.2)  1 1   6 2 5 Retirar_Lue (3.3)  1 1   4 2 3 Pesquisar_Lue (3.4)  2 1   N+2 2N+1 2N Inicializar_Lue (3.5)  1    1   Incluir_Lue (3.6)  2 1   5 3  Retirar_Lue (3.7)  1 1   2 3  Pesquisar_Lue (3.8)  2 1   N+2 2N+1  2N+3 N N 2N-2 2N+3 N N Inicializar_Lde (3.9) 2N-2 Incluir_Lde (3.10)  1 1   9 4 7 Retirar_Lde (3.11)  1 1   7 4 5 Pesquisar_Lde (3.12)  2 1   N+2 2N+1 2N Inicializar_Lde (3.13)  2    2   Incluir_Lde (3.14)  2 1   8 4  Retirar_Lde (3.15)  1 1   5 4  Pesquisar_Lde (3.16)  2 1   N+2 2N+1 2N É possível notar, a partir das quantificações dadas na tabela acima, que as tarefas de inclusão e retirada de elementos de uma lista encadeada, de qualquer tipo, são sempre constantes. A razão disto é que estas operações realizam apenas ajustes na sequência lógica da estrutura (apontadores) de forma a fazer com que o nó em questão entre ou saia da lista. A tarefa anterior a estas, de localização da posição de inserção ou retirada é externa aos procedimentos e, portanto, não computada aqui. 55 Os únicos processos que são proporcionais ao tamanho da lista são a inicialização para a representação matricial e a pesquisa em qualquer caso. A primeira delas é motivada pelo ajuste dos elos para a PND, que visita todos os nós da lista. O segundo algoritmo tem este tempo para o pior caso, quando o elemento pesquisado não está presente na lista e toda a estrutura necessita ser percorrida para dar tal garantia. Quanto aos casos verificados, a inicialização realiza a mesma tarefa em qualquer um deles. Para a inclusão, foi considerado como melhor caso a lista estar cheia, pois o algoritmo termina no primeiro testes, e como pior caso, a inserção no meio da lista, pois vai até o final do algoritmo. Na retirada, o melhor caso ocorre quando a lista está vazia, pois novamente saímos no primeiro dos testes, e o pior caso quando o nó a ser retirado está no meio da estrutura. Já na pesquisa, como foi dito, o melhor caso é quando o elemento solicitado se encontra no começo da lista, na primeira posição lógica dela, e o pior caso quando ele não está presente e percorremos toda a estrutura. Ainda quanto a isto, os testes consideraram o comando retornar como uma atribuição e os comandos de alocação de memória, como novo e liberar não foram computados. Outra observação importante deve ser feita para os testes compostos, com duas condições ligadas por um operador lógico e ou ou. Nestes, a semelhança das linguagens de programação usuais, quando a primeira parte do teste valida todo ele, o restante não é realizado. Isto foi utilizado nos melhores casos dos algoritmos. Em todos os casos as listas se mostram eficientes no seu trabalho, necessitamos apenas fazer duas considerações sobre isto. A primeira delas é quanto a retirada de elementos de uma lista unicamente encadeada. Em razão do arranjo feito para conhecer o elemento anterior ao excluído, a solução não se tornou muito “limpa” do ponto de vista computacional. Isto funciona, mas mostra que esta estrutura não é orientada para esta operação. Se desejamos fazer muitas retiradas com o mínimo de incômodo, devemos considerar a possibilidade de uso de uma lista duplamente encadeada para o caso. A segunda consideração, também já mencionada no texto, é relacionada com a pesquisa. Como não há relação da posição ou alocação física do nó com a sequência lógica de uso, é impossível encontrar o “meio” desta lista e, portanto, impossível de realizarmos uma pesquisa mais eficiente do que a sequencial. Naturalmente esta pesquisa é conhecida como pouco eficiente, pois não explora nenhuma característica dos dados armazenados para agilizar a busca. Infelizmente não há como procedermos de forma diferente neste caso. Resta-nos saber que existem, e veremos mais tarde, outras estruturas construídas para permitir a pesquisa das informações com muita eficiência. 56 3.3 Exercícios 1. Escreva um procedimento que concatena duas listas unicamente encadeadas armazenadas na forma matricial. 2. Escreva um procedimento que concatena duas listas duplamente encadeadas armazenadas em alocação dinâmica de memória. 3. Dadas duas listas unicamente encadeadas, armazenadas na forma matricial, onde os elementos (números inteiros) estão ordenados de forma crescente, escreva um procedimento que intercala estas listas e produz uma terceira, ordenada no mesmo formato. 4. Dadas duas listas duplamente encadeadas, armazenadas em alocação dinâmica de memória, onde os elementos (números inteiros) estão ordenados de forma crescente, escreva um procedimento que intercala estas listas e produz uma terceira, ordenada no mesmo formato. 5. Escreva um procedimento que ordena uma lista duplamente encadeada pelo método de bolha (ver [Aze96]). 6. Considere a existência de dois conjuntos numéricos, em ordem crescente, armazenados em listas duplamente encadeadas, sem repetição de elementos. Escreva os procedimentos para a realização das operações básicas com estes conjuntos. Algumas delas são: união, interseção, diferença, etc... 7. Considere um polinômio no seguinte formato: P(x) = A0 + A1x1 + A2x2 + A3x3 + ..... + Anxn Sabendo que este polinômio está armazenado em uma lista duplamente encadeada, onde os nós tem o seguinte formato: EloA i Ai EloP escreva os seguinte procedimentos: a) Solucione o polinômio para um dado valor de x. b) Multiplique um polinômio por uma constante k. c) Multiplique dois polinômios entre si. d) Divida um polinômio por uma constante k. e) Divida dois polinômio entre si. 57 8. Dadas duas listas unicamente encadeadas A e B, na forma matricial, ambas com n elementos, escreva um procedimento que informa o primeiro elemento da lista A que não está presente na lista B. 9. Considere uma lista duplamente encadeada com n elementos em alocação dinâmica de memória, cuja a informação armazenada é um número inteiro. Escreva um ou mais procedimentos que realizem a seguinte sequência: a) Encontre os dois menores valores na lista (em uma única varredura); b) Retire os nós onde estão estes valores; c) Crie um novo nó onde a informação armazenada é a soma das informações dos nós retirados; d) Insira este novo nó no fim da lista; e) Repita o processo enquanto a lista tiver mais do que 1 nó. 10. Escreva um procedimento que conta a quantidade de nós válidos em uma lista unicamente encadeada armazenada nos dois formatos possíveis. 11. Escreva um procedimento que inverte os apontadores de uma lista unicamente encadeada em alocação dinâmica de memória. 12. Faça o mesmo que o exercício 11 pede para uma lista duplamente encadeada. 13. Dadas duas listas unicamente encadeadas A e B, ambas com n elementos numéricos ordenados crescentemente, escreva um procedimento que recebe um valor k como parâmetro e informa quem é o k-ésimo maior elemento dentre as duas listas. 14. Faça a análise assintótica de todos os algoritmos escritos nestes exercícios. 15. Escreva os procedimentos destes exercícios em alguma linguagem de programação e execute-os em um computador. 58 Bibliografia [AHU74] Alfred Aho, John Hopcroft and Jeffrey Ullman. The Design and Analysis of Computer Algortihms. Addison-Wesley, 1974. [AHU83] Alfred Aho, John Hopcroft and Jeffrey Ullman. Data Strucutres and Algorithms. Addison-Wesley, 1983. [Amm88] Leendert Ammeraal. Programs and Data Structures in C. John Wiley & Sons, 1988. [AU92] Alfred Aho and Jeffrey Ullman. Foundations of Computer Science. Computer Science Press, 1992. [Aze96] Paulo Azeredo. Métodos de Classificação de Dados e Análise de suas Complexidades. Ed. Campus, 1996. [Car98] Marcos Carrard. Algoritmos e Estruturas de Dados, Parte I  Fundamentos. Cadernos da Unijuí, Série Informática, número 4. Editora Unijuí, 1998. [CLR91] Thomas Cormer, Charles Leiserson and Ronald Rivest. Introduction to Algorithms. McGraw-Hill, 1991. [EW__] Jeffrey Esakov and Tom Weiss. Data Structures: an Advanced Approach Using C. Prentice-Hall, ____. [Fra87] Ana Helena Fragomeni. Dicionário Enciclopédico de Informática. Campus, 87. [HS82] Ellis Horowitz and Sartaj Sahni. Fundamentals of Data Structures. Computer Science Press, 1982. [Knu73] Donald Knuth. The Art of Computer Programming: Fundamentals Algorithms. Addison-Wesley, 1973. [Man89] Udi Mamber. Introduction to Algorithms: A Creative Approach. Addison-Wesley, 1989. [Sam90] Hanan Samet. The Design and Analysis of Spatial Data Structures. Addison-Wesley, 1990. [Sch90] Herbert Schildt. C - The Complete Reference. McGraw-Hill, 1990. [Sed88] Robert Sedgewick. Algorithms, 2nd edition. Addison-Wesley, 1988. 59 [SM94] Jayme Szwarcfiter and Lilian Markenzon. Estruturas de Dados e seus Algoritmos. LTC, 1994. [Swa91] Joffre dan Swait Jr. Fundamentos Computacionais: Algoritmos e Estruturas de Dados. Makron Books, 1991. [TA86] Aaron Tenenbaun and Moshe Augenstein. Data Structures Using Pascal, 2nd edition. Prentice-Hall, 1986. [Ter91] Routo Terada. Desenvolvimento de Algoritmos e Estruturas de Dados. Makron Books, 1991. [TS84] Jean-Paul Tremblay and Paul Sorenson. An Introduction to Data Structures with Applications, 2nd edition. McGraw-Hill, 1984. [VF*93] Marcos Villas, Andréa Ferreira, Patrick Leroy, Cláudio Miranda and Christine Bockman. Estruturas de Dados: Conceitos e Técnicas de Implementação. Campus, 1993. [Ziv93] Nivio Ziviani. Projeto de Algoritmos com Implementações em Pascal e C. Pioneira, 1993. 60