Baixe o app para aproveitar ainda mais
Prévia do material em texto
Recife, 2010 Algoritmos e Estrutura de Dados Rodrigo de Souza Hugo Rodrigues Universidade Federal Rural de Pernambuco Reitor: Prof. Valmar Corrêa de Andrade Vice-Reitor: Prof. Reginaldo Barros Pró-Reitor de Administração: Prof. Francisco Fernando Ramos Carvalho Pró-Reitor de Extensão: Prof. Paulo Donizeti Siepierski Pró-Reitor de Pesquisa e Pós-Graduação: Prof. Fernando José Freire Pró-Reitor de Planejamento: Prof. Rinaldo Luiz Caraciolo Ferreira Pró-Reitora de Ensino de Graduação: Profª. Maria José de Sena Coordenação Geral de Ensino a Distância: Profª Marizete Silva Santos Produção Gráfica e Editorial Capa e Editoração: Allyson Vila Nova, Rafael Lira, Italo Amorim, Glaucia Micaele e Marcella Almeida Revisão Ortográfica: Marcelo Melo Ilustrações: Allyson Vila Nova, Moisés de Souza, Lilian Cabral e Diego Almeida Coordenação de Produção: Marizete Silva Santos Sumário Capítulo 1 – Listas ligadas ...................................................................5 1. Listas lineares .................................................................................5 2. Ponteiros e registros .......................................................................8 3. A definição de lista ligada .............................................................13 4. Outros tipos de lista ......................................................................20 Capítulo 2 – Pilhas ..............................................................................27 1. Definição de pilha .........................................................................27 2. Implementando uma pilha em um vetor .......................................29 3. Implementando uma pilha em uma lista ligada ............................32 Capítulo 3 – Filas ................................................................................39 1. Definição de fila ............................................................................39 2. Implementação de uma fila num vetor ..........................................40 3. Implementação de uma fila numa lista ligada ...............................44 Capítulo 4 – Árvores ...........................................................................50 1. A definição de árvore ....................................................................50 2. Implementação de árvores binárias ..............................................53 3. Percursos em uma árvore ............................................................55 4. Árvores de busca ..........................................................................60 Considerações Finais .........................................................................70 Conheça os Autores ...........................................................................72 5 Algoritmos e Estrutura de Dados Capítulo 1 – Listas ligadas Vamos conversar sobre o assunto? Listas ligadas são estruturas de dados muito fundamentais, e mostram-se bastante flexíveis para uma variedade de aplicações. Listas ligadas podem ser a maneira mais adequada de armazenamento de dados para a manutenção de um conjunto dinâmico cuja quantidade de elementos é desconhecida de antemão. Certos tipos de listas permitem que inserções e remoções sejam realizadas em complexidade constante, ou seja, sem o deslocamento de posições de memória, uma vez que a posição do item em questão seja conhecida. Como estudamos no módulo anterior, o uso de vetores para a representação de um conjunto pode exigir o deslocamento de porções de memória para a realização de certas operações. Neste capítulo vamos introduzir o conceito de lista ligada, estudar algumas aplicações e ganhar experiência na sua manipulação através de exercícios. Nos capítulos seguintes, listas ligadas (e vetores) serão novamente utilizadas para a representação de conjuntos dinâmicos onde inserções e remoções obedecem a uma certa disciplina, as pilhas e as filas. Temos certeza de que o material deste capítulo será importante para sua atividade de programador ou projetista de algoritmo, e esperamos que você o estude com prazer. Bom trabalho! 1. Listas lineares Começaremos nosso estudo com duas seções de preparação para a introdução do conceito de lista ligada. A presente seção discute o conceito de lista linear, a próxima trata de registros e ponteiros, recursos presentes na maior parte de linguagens de programação e que permitem uma implementação eficiente de listas ligadas. Ambas as seções podem ser lidas rapidamente. No entanto, é importante ter segurança de que os conceitos expostos foram compreendidos. O conceito de lista ligada se consolidará naturalmente a partir dessa introdução. Vetores e as próximas estruturas de dados que estudaremos para a representação de um conjunto dinâmico - listas ligadas, pilhas e 6 Algoritmos e Estrutura de Dados filas - baseiam-se na disposição dos elementos desse conjunto em sequência, um após o outro. As operações que desejamos realizar no conjunto (buscas, inserções, remoções) correspondem, nesse caso, a manipulações dessa sequência. Como já observamos anteriormente, a eficiência de uma operação pode variar de estrutura para estrutura com que o conjunto é implementado, indo de constante até a execução de um número de operações proporcional ao tamanho do conjunto. No entanto, em todos os casos a operação realizada na sequência é a mesma. Dessa forma, a fim de definirmos precisamente a estrutura “lista ligada”, cabe explicitar primeiro a ideia de “disposição dos elementos um após o outro”. Para isso, definimos o conceito de lista linear: Uma lista linear é uma sequência a1, a2, ..., an A propriedade estrutural básica de uma lista linear é a posição relativa de seus elementos: • o primeiro elemento é a1; • o último é an; • para 1< k < n, ak é precedido por ak-1 e seguido por ak+1. Por exemplo, considere a lista linear a seguir: 5, 8, 3, 6, 8, 4 O primeiro elemento dessa lista é o 5, o último é o 4, o elemento que precede o 3 é o 8, o elemento que o segue é o 6. Note que elementos podem se repetir: em nosso exemplo, o 8 aparece em duas posições, a segunda e a quinta. O tamanho dessa lista é 6. É importante compreender que a ordem dos elementos faz parte da definição de uma lista linear. Por exemplo, a lista seguinte é diferente da anterior, apesar de representar os mesmos elementos: 3, 4, 8, 5, 8, 6 Como acabamos de mencionar, podemos apresentar um conjunto dinâmico como uma lista linear, dispondo os elementos um após o outro. Nessa representação, operações no conjunto correspondem a certas manipulações na lista. Desejaremos dessa forma: • acessar o elemento na posição k de uma lista; 7 Algoritmos e Estrutura de Dados • buscar um elemento em uma lista; • inserir um elemento antes ou depois da posição k; • remover o elemento na posição k; • combinar duas ou mais listas em uma; • separar uma lista em duas ou mais; • contar o número de elementos em uma lista. Em nosso exemplo, a inserção do número 1 após a segunda posição produz a lista 5, 8, 1, 3, 6, 8, 4 A remoção do elemento na penúltima posição produz a lista 5, 8, 1, 3, 6, 4 Que tal inventar exemplos para as demais operações? Entendido o conceito de lista linear, o próximo passo é estudar meios para construir e manipular listas em um computador. Para introduzir o assunto, um esclarecimento. Você já deve ter notado que a definição de lista é muito semelhante à de vetor, e talvez esteja se perguntando se há alguma diferença entre ambas. Não confunda: • A definição de lista linear é abstrata, não depende de uma estrutura para sua representação na memória de um computador. • Vetores são estruturas de dados. São uma possibilidade para a representação de listas lineares em um computador, mas não a única. Na representaçãoem um vetor, a lista é armazenada em posições contíguas de memória. Estudamos essa representação no capítulo 2 do módulo anterior, bem como as operações de inserção e remoção de um elemento. Recorde que essas operações exigem o deslocamento de uma parte da memória, e por isso sua complexidade não é constante. Podemos dizer que a complexidade dessas operações é limitada pelo tamanho da lista. Listas lineares também podem ser representadas com listas ligadas, assunto principal deste capítulo e que introduziremos mais abaixo. Implementando-se convenientemente essas listas ligadas, inserções e remoções podem ser realizadas em complexidade 8 Algoritmos e Estrutura de Dados constante. Curioso para saber como? A razão principal você já pode intuir: vetores obedecem à restrição rígida de que os elementos são armazenados em posições contíguas de memória; nas listas ligadas, essa restrição não existe. Por outro lado, o uso de listas ligadas exige que os endereços de memória dos elementos representados sejam explicitamente conhecidos. Por isso, antes de definir a estrutura de lista ligada vamos discutir brevemente os mecanismos para a manipulação de endereços de memória em linguagens de programação. 2. Ponteiros e registros Ponteiros e registros são mecanismos comuns a muitas linguagens de programação importantes, e proporcionam um meio eficiente para a implementação de uma lista linear em um computador. Os termos “ponteiro” e “registro” podem variar de linguagem para outra, mas o principio é o mesmo. Se você, de sua experiência em programação, entende bem a definição e funcionamento desses recursos, essa seção servirá apenas para fixar algumas convenções. Senão, é importante lê-la com cuidado, buscar mais informações em outras referências, e se certificar de ter entendido antes de prosseguir. Em sua experiência de programação, talvez você já tenha trabalhado com ponteiros. Ponteiros são variáveis como outra qualquer, ou seja, posições de memória que armazenam um dado de um tipo determinado. Mas ponteiros, ao invés de armazenar um inteiro, um caractere ou um valor booleano, por exemplo, armazenam um endereço de memória. Além da operação de atribuição, a operação básica com ponteiros é a obtenção do valor armazenado no endereço representado. Como exemplo, considere a representação pictórica a seguir da memória de um computador. Recorde que a memória pode ser vista como um longo vetor, onde cada posição é identificada por um endereço. Nessa figura, os endereços são escritos à direita. Duas variáveis também são representadas nessa figura, a e b: 9 Algoritmos e Estrutura de Dados A variável a está armazenada na posição 874 da memória, e é um ponteiro para outra posição de memória, 1462. Também podemos dizer que essa variável é um ponteiro para a variável b, que está armazenada na posição 1462 e representa um inteiro, 17. Registros são tipos de dados complexos, e podem ser entendidos como aglomerados de outros tipos de dados. Podemos fazer uma analogia com um Banco de Dados, que são coleções de registros. Um registro, por sua vez, é composto de uma série de campos, não necessariamente do mesmo tipo: nome, endereço, idade, data de admissão, etc. De fato, um conjunto dinâmico pode ser visto como um Banco de Dados, onde desejamos realizar inserções, buscas e remoções de registros. A figura a seguir representa um registro de um tipo de dados que podemos chamar de pessoa. Esse registro é composto por quatro campos ou membros: • “id”, do tipo inteiro; • “nome” e “sobrenome”, do tipo cadeias de caracteres; • “nascimento”, do tipo data. Temos aqui em um único registro quatro variáveis, de tipos diferentes, agrupadas em uma mesma unidade lógica. Essas variáveis ocupam posições consecutivas de memória a partir de uma posição de base, considerada como o endereço do registro. 10 Algoritmos e Estrutura de Dados Tenha em mente que registros são tipos de dados legítimos, como os inteiros. Assim, podemos declarar e usar variáveis representando registros, da mesma forma que o fazemos com uma variável que representa um inteiro. Em particular, podemos intercambiar os valores entre variáveis que representam registros. Por exemplo, se o registro ilustrado acima é representado pela variável p, e q é uma variável do mesmo tipo (pessoa), a atribuição q ← p copia o conteúdo (ou seja, cada campo individualmente) de p em q. Trata-se de uma cópia de uma região de memória. O que ponteiros e registros têm a ver com listas lineares? Já o dissemos no início dessa seção: o uso combinado de ponteiros e registros é um recurso poderoso e permite representar eficientemente listas lineares e outras estruturas em um computador, além de proporcionar meios flexíveis para a manipulação dessas estruturas. Essa representação será explicitada na próxima seção, que é dedicada à introdução das listas ligadas. Mas você já pode manter em mente os seguintes elementos: • uma lista ligada é uma lista linear de registros; • cada um desses registros têm, além dos dados representados (nome, data de nascimento...), um campo particularmente importante, um ponteiro para o próximo item da lista (que é outro registro); • o primeiro elemento da lista é identificado também por um ponteiro. O que é um ponteiro para um registro? É o endereço de base para a porção de memória ocupada por esse registro. Todos os campos do registro podem ser acessados a partir desse endereço de base com a soma de um deslocamento conveniente. Um aspecto importante dos ponteiros é a eficiência no intercâmbio dos dados representados nos Lembrete Registros em C também são chamados de estruturas, e podem ser definidos através da palavra reservada struct. Em Pascal, registros são definidos com a palavra reservada record. Familiarize- se com essas palavras reservadas antes de resolver os exercícios de implementação. 11 Algoritmos e Estrutura de Dados registros. A atribuição entre ponteiros realiza a cópia de um endereço de memória, e não de toda a memória ocupada pelo registro. Assim, certas operações com listas podem ser feitas de forma eficiente com algumas poucas manipulações de ponteiros. Java, objetos e coleta de lixo Você já usou a linguagem Java? Trata-se de uma linguagem orientada a objetos, um paradigma de programação bastante em voga atualmente, com muita aceitação no mercado. Os objetos criados e destruídos em um programa Java são semelhantes aos registros discutidos nessa seção, com a diferença de que um objeto pode agrupar funções especiais para a manipulação de seus membros. Além disso, em Java, a liberação de memória após o uso de um objeto é feita automaticamente, mecanismo conhecido como coleta de lixo (garbage collection).Os recursos da linguagem Java são especialmente adequados para a implementação de uma série de estruturas de dados com naturalidade. Se você não conhece essa linguagem, essa disciplina é uma excelente oportunidade de aprender! Estude os exercícios deste e do próximo capítulo, e tente implementar suas soluções em Java. Ponteiros e registros oferecem meios para a manutenção de um conjunto cuja quantidade de elementos não é conhecida ou mesmo limitada de antemão (evidentemente, desde que esse conjunto não seja maior do que a memória disponível). Lembra-se de que com vetores estabelecemos um limite para o número de elementos representados? Com o uso de ponteiros e registros, podemos reservar quantidades adicionais de memória para a inserção de um novo registro. De fato, vamos encontrar em muitas linguagens de programação funções especiais que solicitam ao sistema operacional alocação de memória – é o caso do “malloc” no C e do “new” no Pascal. Simetricamente, a remoção de um novo elemento deve ser acompanhada pela liberaçãode memória, tarefa que dependendo da linguagem deve ser gerenciada pelo programador. Por exemplo, na linguagem C pode-se liberar memória com a função “free”. Esse mecanismo de alocação e liberação de memória ao longo da execução do programa é denominado alocação dinâmica (contrariamente à alocação estática, onde a quantidade de memória necessária é fixa e alocada antes da execução do programa). Mas nem tudo são flores. Ponteiros são um instrumento potencialmente perigoso e devem ser utilizados com atenção. Erros de programação que induzem o surgimento de “ponteiros perdidos”, ou seja, ponteiros que indicam uma posição de memória indisponível, Atenção Você já viu um programa em C que parou de funcionar com a mensagem “Segmentation Fault”? Trata-se de uma tentativa de acesso a uma posição de memória indevida – possivelmente um ponteiro perdido. 12 Algoritmos e Estrutura de Dados são uma das principais fontes de problemas de execução. Como tudo isso funciona em Portucal? Como serão os registros e ponteiros com que implementaremos listas lineares e outras estruturas em nossa linguagem hipotética? Novamente, não vamos nos ater em detalhes de implementação, pois não desejamos desviar nossa atenção do estudo de propriedades de algoritmos para a manipulação de um conjunto de dados; você terá contato direto com ponteiros e registros em exercícios de programação. Vamos assumir tacitamente que registros são um tipo de dados disponível em Portucal, e que podemos acessar os campos de um registro através de colchetes. Por exemplo, se consideramos em um programa em Portucal uma variável p que aponta para o registro com os dados de Donald Knuth ilustrado no início dessa seção, podemos acessar os valores dos campos “nome”, “sobrenome” e “nascimento” desse registro escrevendo nome[p], sobrenome[p], nascimento[p], respectivamente. Ou seja: nome[p] = Donald sobrenome[p] = Knuth nascimento[p] = 10/1/1938 Antes de concluir, um último aviso. Tenha em mente que, em nosso estudo de algoritmos e estruturas de dados, a natureza dos dados representados em um registro não é tão importante. Por isso, normalmente consideraremos registros que representam simplesmente um número inteiro. Você está preparado para estudar a estrutura de lista ligada e entender como implementá-la. E ficará ainda mais preparado pesquisando, antes de começar, como funciona o mecanismo de alocação dinâmica de sua linguagem favorita. Faça algumas experiências simples de criação e liberação de memória para registros. Procure sentir-se seguro com o uso de ponteiros, se não tem experiência com esse recurso. Precisando de ajuda ou mais informações, consulte as referências ao final dessa seção. Tenha em mente que o uso de ponteiros exige uma certa experiência, por isso, perseverança, não desanime nas primeiras tentativas. 13 Algoritmos e Estrutura de Dados 3. A definição de lista ligada O conceito de lista ligada emerge naturalmente dos assuntos discutidos nas duas seções anteriores. Encontramos uma boa definição no item “Listas encadeadas” do sítio “Projeto de Algoritmos” do professor Paulo Feofiloff, http://www.ime.usp.br/~pf/algoritmos/. Vamos reproduzí-la: Uma lista encadeada é uma representação de uma sequência de objetos na memória do computador. Dito de outra forma: uma lista ligada (ou encadeada) é uma lista linear representada no computador através de ponteiros, registros, ou outro tipo de construção. Para simplificar, vamos estudar, inicialmente, um tipo de lista bastante elementar, composta de uma sequência de registros e onde cada registro possui dois campos: info e prox. Seus significados: • “info”, um número inteiro, é o dado armazenado no registro; • “prox” é um ponteiro para um registro. A partir de agora, vamos utilizar o termo “célula” ao invés de “registro”. A seguinte representação pictórica de uma célula é muito útil: Essa figura apresenta os dois campos do registro: o valor de info é 26, e o ponteiro prox é representado por uma flecha. Para onde aponta essa flecha? Que endereço está armazenado nesse ponteiro? Você talvez já tenha adivinhado: estamos representando uma lista linear; essa flecha aponta para o próximo item dessa lista. Para ilustrar, vamos considerar um exemplo mais completo. Suponha que desejamos representar a seguinte lista de inteiros em uma lista ligada: 5, 8, 3, 6, 8, 4 Usando nossa notação pictórica, o resultado é: 14 Algoritmos e Estrutura de Dados Essa lista ocupa seis regiões de memória, distintas e não necessariamente consecutivas. Note que nenhuma célula é apontada pela última flecha, justamente por essa ser a última. O apontador armazenado nessa célula recebe um valor especial, representado por uma cruz, que indica que nenhum endereço válido é representado, e assim a lista terminou. Na linguagem C, uma boa ideia é adotar a constante NULL para esse valor especial. Em Portucal, vamos usar o termo “nil”. É fácil acessar o item posterior a uma célula: seu endereço é indicado pelo ponteiro armazenado nessa célula. Usando-se esses ponteiros, uma lista pode ser completamente percorrida a partir do primeiro elemento (confira o programa em Portucal mais abaixo). Por isso, podemos representar uma lista simplesmente por um ponteiro para o primeiro elemento. E se a lista está vazia? Uma lista vazia é um objeto perfeitamente legítimo. Representamos uma lista vazia com um ponteiro cujo valor é a constante “nil”. Continuando nosso exemplo, suponha que a primeira célula da lista seja apontado pela variável L. Podemos indicar essa situação da seguinte forma: A seguinte instrução imprime o campo info da primeira célula: imprima info[L] Se desejamos armazenar o endereço da segunda célula em uma variável M, podemos escrever: M ← prox[L] O resultado da instrução imprima info[M] é assim a impressão do número 8, o segundo elemento da lista. O resultado é o mesmo se escrevemos imprima info[prox[L]] De forma análoga, a seguinte instrução imprime o inteiro armazenado na terceira célula, 3: imprima info[prox[prox[L]]] 15 Algoritmos e Estrutura de Dados De fato, prox[L] é um ponteiro para a segunda célula, e prox[prox[L]] é precisamente o ponteiro armazenado nessa célula. Estude esses exemplos com cuidado, certifique-se de que compreendeu. O seguinte algoritmo em Portucal percorre uma lista seguindo os ponteiros, e a cada célula visitada imprime o campo info: algoritmo IMPRIME-LISTA (L) enquanto L <> nil faça imprima info[L] L←prox[L] ► Recebe uma lista ligada e imprime seu ► conteúdo Vamos estudar esse algoritmo com cuidado. Há um único parâmetro, um ponteiro L para uma lista de inteiros. Para simplificar, vamos relaxar um pouco nosso discurso, e dizer diretamente que “L é uma lista”, ao invés de “L é um ponteiro para uma lista”. Não se sabe de antemão quantos elementos a lista L representa, mas essa informação não é relevante: a impressão pode ser feita seguindo-se os ponteiros, o fim da lista é detectado pela constante “nil”. No corpo do laço enquanto, a própria variável L recebe seu sucessor. Isso faz com que o endereço do antecessor seja perdido pelo algoritmo, mas não externamente ao algoritmo (pois o ponteiro L foi passado por valor – recorde as convenções da linguagem Portucal detalhadas no primeiro módulo). Perceba que esse algoritmo é correto mesmo se L representa a lista vazia. Nesse caso, o valor de L é “nil”, o primeiro teste realizado pelo “enquanto” falha, e o algoritmo termina sem imprimir nada. Já a seguinte versão não funciona se L é a lista vazia: algoritmo IMPRIME-LISTA (L) repita imprima info[L] L←prox[L] até que L = nil ► Recebe uma lista ligada e imprime seu ► conteúdo O problema é que essa versão tentaimprimir o campo info da primeira célula mesmo no caso em que a lista é vazia. Essa situação pode conduzir a uma falha de execução. 16 Algoritmos e Estrutura de Dados Às vezes pode ser útil incluir em uma lista uma célula especial que aponta para o primeiro elemento da lista, mas que não armazena inteiro algum. Essa célula é comumente chamada de cabeça da lista, e sua finalidade é simplesmente marcar o início da lista. Nesse caso, a lista é representada para um apontador para a cabeça. Nesse tipo de lista, a cabeça sempre existe, mesmo que a lista seja vazia, e seu endereço nunca muda. Para ilustrar, segue um algoritmo que imprime uma lista com cabeça. O parâmetro L é um ponteiro para a cabeça, e o primeiro elemento da lista é prox[L]. algoritmo IMPRIME-LISTA (L) L←prox[L] enquanto L <> nil faça imprima info[L] L←prox[L] ► Recebe uma lista com ► cabeça e imprime seu ► conteúdo O uso de uma cabeça não é obrigatório, mas pode facilitar algumas situações, pois evita a necessidade de tratar explicitamente o caso em que a lista é vazia. Enfatizamos que, se L aponta para uma lista sem cabeça vazia, então L é “nil”, enquanto que um apontador para uma lista com cabeça nunca é nil (pois a lista sempre terá pelo menos a cabeça). Você verificará esse fato por si mesmo lendo o restante dessa seção. A seguir, vamos apresentar algoritmos para busca, inserção e remoção em uma lista ligada. Na próxima seção introduziremos outros tipos de lista, ainda mais flexíveis para a realização das operações de inserção e remoção. Busca Suponha que desejamos verificar se um inteiro x pertence a uma lista L. Caso pertença, desejamos obter um ponteiro para uma célula que armazena esse inteiro (pode haver mais de uma). Se não pertence, é natural devolver o valor nil. O seguinte algoritmo realiza essa tarefa para uma lista sem cabeça. Escreva uma versão para uma lista com cabeça: 17 Algoritmos e Estrutura de Dados algoritmo BUSCA (L, x) enquanto L <> nil E info[L] <> x faça L←prox[L] devolva L ► Recebe uma lista L e um inteiro x. ► Devolve um ponteiro para uma ► célula que armazena x, e nil se ► uma tal célula não existe Perceba que a corretude desse algoritmo depende da ordem de avaliação do teste no laço enquanto, que em nossas convenções é da esquerda para a direita. De fato, é preciso testar se L é diferente de nil antes de qualquer tentativa de acesso ao endereço apontado por essa variável. Também note que esse algoritmo, bastante conciso, é correto caso a entrada seja a lista vazia, e que devolve a primeira célula que armazena o valor desejado. Também podemos resolver o problema de forma recursiva: algoritmo BUSCA-REC (L, x) se L = nil então devolva nil se info[L] = x então devolva L devolva BUSCA-REC (prox[L], x) ► Recebe uma lista L e um ► inteiro x. Devolve um ponteiro ► para uma célula que armazena ► x, e nil se tal célula não existe No pior caso (por exemplo, se o elemento não pertence à lista), toda a lista é percorrida. Assim, a complexidade pessimista da busca em uma lista de n elementos é proporcional a n. Podemos dizer que esse algoritmo é O(n). Inserção Considere agora a operação de inserção de um inteiro em uma lista L. Vamos supor que esse inteiro está armazenado no campo info de uma célula, e que o algoritmo recebe um ponteiro M para essa célula. A função do algoritmo é, então, realizar alguma manipulação de ponteiros para a inserção da célula M na lista. Pode parecer que essa suposição é artificial. De fato, podemos desejar que nosso algoritmo de inserção receba precisamente o inteiro a ser inserido, e não uma célula. Essa consideração não chega a ser um problema: a fim de inserir um inteiro, aloca-se espaço em memória 18 Algoritmos e Estrutura de Dados para a criação de uma nova célula, atribui-se o inteiro recebido ao campo info dessa célula recém-criada, e executa-se o algoritmo para a inserção de uma célula que vamos descrever a seguir. A inserção logo após a célula apontada por L (a primeira célula da lista) está ilustrada a seguir. O primeiro passo é fazer com que o campo prox de M aponte para o campo prox de L. Em seguida, o campo prox de L aponta para M. Essa estratégia sempre funciona para uma lista com cabeça, e para uma lista sem cabeça e que não é vazia. O caso de uma lista vazia é mais delicado, deve ser tratado à parte e implica na necessidade de administrar outras variáveis que apontam para a mesma lista (toda variável que aponta para uma lista vazia onde ocorre uma inserção deve receber o endereço da célula inserida). O algoritmo completo de inserção, para o caso de uma lista com cabeça, é o seguinte: algoritmo INSERE (L, M) prox[M]←prox[L] prox[L]←M ► Recebe uma lista com cabeça L ► e uma célula M. Insere M no começo da ►lista. Tenha em mente que uma inserção será visível para todo apontador para a lista L, ou seja, nenhuma inconsistência é introduzida. De fato, a célula apontada é a cabeça, que é a mesma antes e depois da inserção. A inserção ocorre em tempo constante, O(1). Recorde que a inserção em uma lista representada por um vetor pode exigir a cópia de blocos de memória, e não é portanto constante no pior caso. Remoção Considere finalmente que desejamos remover uma célula de uma lista. Essa operação é menos trivial do que as anteriores devido ao fato de que, no tipo de lista que estamos estudando, o elemento 19 Algoritmos e Estrutura de Dados anterior à uma célula não pode ser acessado a partir da mesma (pois uma célula não possui um apontador para o elemento anterior). Como “emendar” as duas partes da lista que restam após a remoção? Uma solução, que sempre funciona para listas com cabeça, consiste em fornecer um ponteiro para a célula anterior àquela que será removida (ao invés de um ponteiro para a célula que será removida). A figura abaixo ilustra essa solução. Se desejarmos remover a célula do meio, o algoritmo recebe um ponteiro para a célula da esquerda. A remoção consiste então de uma simples atualização de um ponteiro: Essa atualização se faz em uma linha: algoritmo REMOVE (M) prox[M]←prox[prox[M]] ► Remove a célula prox[M] de uma ► lista ligada Note que a informação do início da lista é irrelevante para essa operação. A remoção de uma célula esconde uma questão: o que fazer com a célula removida? Normalmente, o programador tem o dever de liberar explicitamente a memória ocupada por essa célula (caso contrário, seu programa utilizará regiões de memória). Em uma linguagem com coleta de lixo, como o Java, essa liberação é automática. Uma questão mais elaborada é a remoção de um inteiro do conjunto. Nesse caso, uma célula representando esse inteiro deve ser localizada e em seguida removida. O algoritmo é uma combinação do algoritmo de busca com o algoritmo de remoção visto logo acima. A diferença de que a busca deve parar na célula anterior à que será removida. Ei-lo: 20 Algoritmos e Estrutura de Dados algoritmo BUSCA-REMOVE (L, x) p←L q←prox[L] enquanto q <> nil E info[q] <> x faça p←q q←prox[q] se q <> nil então prox[p] ←prox[q] ► Remove a primeira célula ► da lista L que armazena x 4. Outros tipos de lista As listas ligadas que acabamos de descrever são as mais simples possíveis, e o caminho é livre para inovações. Você pode inventar outros tipos de lista, que se mostrem mais adequados ao problema que deseja resolver. Vamos apresentar duas possibilidades populares, a lista circular e a duplamente ligada. Listas circulares Se fazemos com que o ponteiro da última célula da lista aponte para a primeira ao invés de “enterrá-lo”, obtemos uma lista com o aspecto de um anel que chamamos de lista circular. Podemosconsiderar listas circulares com ou sem cabeça. Eis um exemplo de uma lista circular sem cabeça: Nessa figura, se L é um apontador para a célula qualquer, temos prox[prox[prox[L]]] = L Em particular, em uma lista circular que contém apenas uma célula, o apontador dessa célula armazena o endereço da mesma. Em uma lista com cabeça, o apontador da última célula armazena o endereço da cabeça. Em uma lista vazia, o apontador da cabeça aponta para ela própria. 21 Algoritmos e Estrutura de Dados Em uma lista circular, é possível visitar todas as células a partir de uma célula qualquer (para as listas estudadas anteriormente, isso só é possível a partir da primeira célula ou da cabeça). Eis um algoritmo que percorre uma lista circular, imprimindo o campo info de cada célula. Note que esse algoritmo precisa se recordar de onde partiu: algoritmo IMPRIME-LISTA (L) se L <> nil então M← L repita imprima info[M] M←prox[M] até que M = L ► Recebe uma lista circular ► e imprime seu conteúdo Inserções e remoções em listas circulares são feitas de forma semelhante ao caso das listas estudadas previamente. Na inserção em uma lista sem cabeça, é preciso tomar um cuidado adicional no caso de inserção em uma lista vazia: o apontador da célula inserida deve ser explicitamente definido com o endereço da mesma. Listas circulares são particularmente convenientes para a operação de união de listas, que é uma generalização da operação de inserção de uma célula. Por exemplo, a união das listas lineares seguintes 6, 2, 8, 4 e 4, 5, 9 é a seguinte lista de 7 elementos: 6, 2, 8, 4, 4, 5, 9 A operação de união de listas circulares pode ser definida como segue: dados ponteiros L e M para listas circulares sem cabeça, desejamos inserir M imediatamente após L, obtendo uma nova lista circular. O algoritmo devolve um ponteiro para a nova lista. A união pode ser feita com um simples intercâmbio de ponteiros, conforme ilustrado na figura a seguir. 22 Algoritmos e Estrutura de Dados Eis como se processa essa operação: algoritmo UNIÃO-LISTA (L, M) se L = nil então devolva M se M = nil então devolva L X← prox[L] prox[L]← prox[M] prox[M]← X devolva L ► Devolve um ponteiro para a lista circular ► obtida com a inserção de uma ►lista circular M após uma lista ► circular L Perceba uma característica notável desse algoritmo: a união de duas listas é feita com um número constante de operações, ou seja, que independe do número de elementos representados. O algoritmo também funciona para o caso em que uma das listas ou ambas têm apenas um ou mesmo nenhum elemento. Listas duplamente ligadas Em uma lista duplamente ligada, cada célula tem dois ponteiros: prox, para o próximo elemento, e ant, para o anterior. Um exemplo, que também mostra como uma tal lista pode ser ilustrada: Listas duplamente ligadas também podem ser circulares: 23 Algoritmos e Estrutura de Dados Conhecer o elemento anterior de cada célula é uma facilidade muito útil. Por exemplo, a operação de remoção de uma célula em uma lista duplamente ligada é muito mais “limpa” do que a operação correspondente para os casos anteriores, pois a remoção pode ser feita conhecendo-se um ponteiro para a célula ao invés de um ponteiro para a célula anterior. Por outro lado, toda operação com listas duplamente ligadas exige um cuidado adicional na administração dos ponteiros. O caso da inserção está ilustrado na figura a seguir. Note que tanto o ponteiro para a próxima célula quanto o ponteiro para a anterior devem ser atualizados na célula inserida. Segue a inserção para uma lista duplamente ligada com cabeça (circular ou não): algoritmo INSERE (L, M) prox[M]←prox[L] prox[L]←M ant[M]←L ant[prox[M]]←M ► Recebe uma lista duplamente ligada com ► cabeça L e uma célula M. Insere M no começo ► da lista. Eis a operação de remoção, que, como observamos, pode ser feita a partir de um ponteiro para a célula a ser removida. Perceba que nenhum dado além desse ponteiro é necessário. Recorde também que a memória utilizada pela célula removida deve ser liberada após a operação. algoritmo REMOVE (M) prox[ant[M]]←prox[M] ant[prox[M]]←ant[M] ► Remove uma célula M de uma lista duplamente ► ligada. 24 Algoritmos e Estrutura de Dados Para ficar mais claro, que tal desenhar uma figura que ilustra essa operação? Você Sabia? Você já ouviu falar da linguagem Lisp (seu nome vem de LISt Processing)? É uma das mais antigas linguagens de programação - foi concebida na década de 1950. Mas é bastante diferente das linguagens com que você talvez esteja acostumado, como a C. Tanto os dados quanto os programas em Lisp são baseados em listas, que podem ser compostas com outras listas para a formação de estruturas mais complexas. A linguagem Lisp é importante em Inteligência Artificial. Se quiser aprender Lisp, talvez valha a pena conhecer o célebre editor de textos Emacs, onde essa linguagem funciona como uma extraordinária ferramenta de extensão. Conheça Mais Listas ligadas são um assunto muito fundamental em Computação e há uma vasta bibliografia que trata do assunto. Recomendamos em particular: Wirth, N. Algoritmos e Estruturas de Dados. Rio de Janeiro: Ed. Prentice Hall do Brasil, 2002. Feofiloff, P. Algoritmos em linguagem C. Ed. Campus/Elsevier. 2008 Você também encontrará um vasto material na Internet a respeito. É muito interessante que você compreenda como implementar e manipular listas em uma linguagem de programação. Estude em particular as linguagens C e Java. Duas referências básicas para essas linguagens são as seguintes: Kernighan, B, Ritchie, D. C - A Linguagem de Programação Padrão ANSI. Ed. Campus/Elsevier. 1989 25 Algoritmos e Estrutura de Dados Deitel, H. M., Deitel, P. J. Java: Como Programar. Ed. Prentice- Hall, 2005 Se você não conhece a linguagem Java, é uma excelente oportunidade para aprender. A implementação de classes para os diversos tipos de listas vai auxiliá-lo de quebra a entender programação orientada a objetos. Nos exercícios propostos a seguir, busque implementar suas soluções numa dessa linguagens. Atividades e Orientações de Estudo O exercício a seguir vai auxiliá-lo a ganhar experiência com a manipulação de listas ligadas. Você pode assumir que em todos os casos as listas em questão são com cabeça a menos que o contrário seja explicitamente dito. Você encontrará muitos outros exercícios nas referências bibliográficas. Problema 1 Escreva um algoritmo que devolve o elemento mínimo de uma lista. Faça duas versões, uma para listas circulares que não têm cabeça, outra para listas que não são circulares. Qual a complexidade de seu algoritmo? Problema 2 Escreva um algoritmo que devolve o último elemento de uma lista (o caso de uma lista duplamente ligada é muito elementar; considere que a lista não é duplamente ligada). De forma mais geral, escreva um algoritmo que devolve o k-ésimo elemento de uma lista. Qual a complexidade do seu algoritmo? Problema 3 Escreva um algoritmo que inverte uma lista ligada sem usar espaço auxiliar, ou seja, apenas alterando ponteiros. Repita o exercício para uma lista circular e uma lista duplamente ligada circular. Problema 4 Escreva um algoritmo que devolve a soma de uma lista ligada de inteiros. 26 Algoritmos e Estrutura de Dados Problema 5 Escreva um algoritmo que faz a união de duas listas ligadas não necessariamente circulares. Problema 6 Escreva um algoritmo que busca um inteiro em uma lista em ordem crescente. Problema 7 Escreva um algoritmo que ordena uma lista de inteiros. Problema 8 Escreva um algoritmo que recebe uma lista ligada de inteiros em ordem crescente,um inteiro (ou um ponteiro para uma célula representando um inteiro), e insere esse inteiro mantendo a ordenação. Problema 9 Escreva um algoritmo que recebe duas listas de inteiros ordenadas e constrói uma nova lista que representa a intercalação das duas sequências. Seu algoritmo não usa espaço auxiliar, mas faz isso alterando convenientemente os ponteiros das listas. Veja o módulo anterior para uma descrição da operação de intercalação. Problema 10 Implemente as funções de inserção, remoção, busca e mínimo para um conjunto dinâmico de inteiros usando listas ligadas. Programe também uma interface simples que exibe o conteúdo do conjunto e apresenta ao usuário um menu para a escolha de uma operação. Decida a linguagem de programação com seu tutor. Vamos Revisar? Estudamos neste capítulo a estrutura de lista ligada, onde elementos de um conjunto dinâmico são representados na memória de um computador com o auxílio de ponteiros. Estudamos as operações de inserção, busca e remoção em listas, bem como suas complexidades. Também consideramos dois tipos bastante úteis de lista, as listas circulares e as listas duplamente ligadas. 27 Algoritmos e Estrutura de Dados Capítulo 2 – Pilhas Vamos conversar sobre o assunto? Se inserções e remoções podem ser realizadas em apenas uma extremidade de uma lista linear, temos uma pilha. O nome dessa estrutura de dados é uma analogia com pilhas de pratos ou livros, onde só podemos inserir ou retirar um novo item na extremidade superior. Também podemos dizer que uma pilha é um conjunto dinâmico onde só podemos remover o último elemento inserido. Qual a utilidade dessa disciplina particular de inserções e remoções? Muitas. Encontramos frequentemente problemas em Computação que podem ser modelados em uma estrutura de pilha. Um exemplo importante é o gerenciamento de memória durante a execução de um programa. Quando uma função é chamada, o estado da memória (o conjunto das variáveis definidas pelo programa e o ponto de execução antes da chamada da função) devem ser armazenados, para que a execução retome esse mesmo estado após o término da execução da função. E o que acontece se várias funções são executadas, cada uma dentro de outra mais externa, como é o caso de funções recursivas? Esses estados de memória são empilhados e, posteriormente, desempilhados. Essa técnica é tão importante que certos processadores possuem registradores normalmente dedicados ao gerenciamento de uma estrutura de pilha. Como vemos, o conceito de pilha é bastante natural. Vamos neste capítulo aprender como implementar pilhas em vetores e em listas ligadas, e adquirir alguma experiência com essa estrutura através de algumas aplicações. Bom estudo! 1. Definição de pilha A estrutura de dados pilha é semelhante a uma pilha de pratos ou livros. Você não ousará retirar ou enfiar livros no meio de uma pilha, a menos que queira correr o risco de um desastre. Há um único local seguro para fazer isso: o topo da pilha. 28 Algoritmos e Estrutura de Dados Em Computação, não empilhamos livros ou pratos, mas sim dados. Essa é da definição de pilha: uma lista linear (de inteiros, por exemplo) onde inserções e remoções só podem ser realizadas em uma extremidade. Dando nomes aos bois, essa extremidade é chamada topo, e uma pilha P suporta duas operações básicas: • EMPILHA (P, x): insere o elemento x na pilha, ou seja, após o topo (o novo topo é a posição do elemento inserido); • DESEMPILHA (P): retira o elemento no topo da pilha (o novo topo é o elemento anterior ao retirado). Perceba que em uma pilha o último elemento a entrar, aquele que está na estrutura há menos tempo, é o primeiro a sair. Há uma sigla em inglês popular para descrever esse comportamento: dizemos que pilhas são estruturas LIFO, de last-in, first-out. É claro que a operação desempilha só faz sentido se a pilha não estiver vazia. O empilhamento a princípio sempre é possível, mas em certas implementações um limite para o tamanho da pilha é imposto. É o caso de implementação com vetores, que estudaremos na próxima seção. Atenção Na literatura em inglês, pilha é “stack”, empilha é “push” e desempilha é “pop”. 29 Algoritmos e Estrutura de Dados 2. Implementando uma pilha em um vetor Podemos representar uma pilha de no máximo N elementos em um vetor P de tamanho N. A cada instante, os elementos são armazenados da posição 1 até uma posição t, o topo da pilha. A pilha está vazia se t = 0 e cheia se t = N. Eis um vetor que representa uma pilha de 5 elementos, e para o qual N = 7: Algumas operações nessa pilha: • EMPILHA (P, 73) • DESEMPILHA (P) • EMPILHA (P, 52) • EMPILHA (P, 38) O que acontece se um novo empilhamento é realizado na última pilha do exemplo acima? Ocorre um transbordamento (em inglês, overflow). Essa situação é anormal, e em um programa correto não 30 Algoritmos e Estrutura de Dados deveria ocorrer nunca. Também é anormal tentar desempilhar de uma pilha vazia (situação em inglês conhecida como underflow). É preciso ficar claro que, mesmo que em um vetor temos acesso automático a qualquer posição, não faz sentido espiar os elementos de uma pilha abaixo do topo. De fato, só vamos usar uma pilha em aplicações onde o único elemento relevante é aquele que está no topo – senão, usamos outra estrutura. Vamos concluir essa seção com código em Portucal para as operações empilha e desempilha de uma pilha de inteiros. Também importa termos funções para testar se uma pilha está cheia, vazia e obter o elemento no topo de uma pilha. Cada uma dessas operações é executada em complexidade constante. Para simplificar, vamos admitir que uma pilha é um registro que engloba o vetor, o topo da pilha, e o número máximo de elementos; também vamos usar a mesma letra para denotar um ponteiro para esse registro e o vetor que armazena a pilha. Assim, para acessar a posição do topo e o tamanho de uma pilha P, escrevemos topo[P], N[P] e para acessar o elemento no topo escrevemos P[topo[P]] Fica claro aqui, mais uma vez, a utilidade do uso de registros (ou estruturas, ou classes – depende da linguagem) que encapsulam as propriedades relevantes de uma pilha em uma unidade lógica. De fato, quando desejamos passar uma pilha para uma função, basta passar um ponteiro para esse registro ao invés de três argumentos (um para o vetor, outro para o topo e um terceiro para o número máximo de elementos). Muito mais simples, claro e seguro. A partir daqui, habitue-se a pensar em termos de registros, estruturas ou classes para implementação de estruturas de dados. Enfatizamos que o topo será a única posição acessada na pilha (como acabamos de comentar, não se deve bisbilhotar o interior de uma pilha). Não vamos incluir nesse código um tratamento explícito para a ocorrência de overflow. O caso de underflow na operação de desempilhamento pode ser detectado a partir do retorno da função. Lembrete Em programação orientada a objetos (o paradigma adotado na linguagem Java, entre outras) as operações descritas a seguir seriam normalmente codificadas na própria classe pilha, e não em funções avulsas 31 Algoritmos e Estrutura de Dados Em uma implementação robusta para aplicações práticas, essas situações de exceção, se detectadas, devem resultar em um código de erro ou disparar um tratamento adequado. algoritmo VAZIA (P) se topo [P] = 0 então devolva V senão devolva F ► Testa se uma pilha está vazia algoritmo CHEIA (P) se topo [P] = N[P] então devolva V senão devolva F ► Testa se uma pilha está cheia algoritmo EMPILHA (P, x) se NÃO VAZIA (P) então topo[P] ←topo[P] + 1 P[topo[P]]←x ►Empilha um inteiro x em uma pilha P algoritmo DESEMPILHA(P) se NÃO VAZIA (P) então x ←P [topo[P]] topo[P] ←topo[P] - 1 devolva P[topo[P]] devolva nil ► Desempilha o elemento no topo de uma pilha P Um comentário sobre a função DESEMPILHA. Note que não é necessário “apagar” o elemento desempilhado do vetor. De fato, o que está acima do topo não tem relevância para a pilha, e assim, basta 32 Algoritmos e Estrutura de Dados diminuir o topo de uma unidade. Também atente para o fato de que a função DESEMPILHA devolve o elemento no topo da pilha, e caso a pilha esteja vazia, retorna um valor que permite identificar o erro (nil, que é diferente de todo inteiro). O mesmo ocorre na função a seguir: algoritmo TOPO (P) se NÃO VAZIA (P) então devolva P[topo[P]] devolva nil ► Devolve o elemento no topo de uma pilha P Nos exercícios propostos deste capítulo, queremos convidá-lo a implementar essas funções em uma linguagem de programação. 3. Implementando uma pilha em uma lista ligada Também podemos implementar uma pilha em uma lista ligada com cabeça, não necessariamente circular nem duplamente ligada. O topo da pilha é o primeiro elemento da lista após a cabeça. A pilha é acessada a partir de um ponteiro para a cabeça. Vejamos um exemplo. A figura abaixo apresenta uma pilha, P, e o empilhamento de uma célula. Antes do empilhamento o topo contém o número 5. A vantagem da representação com uma lista é a inexistência de um limite superior para o tamanho da pilha (exceto evidentemente o tamanho da memória), graças à alocação dinâmica de espaço. Em particular, a função CHEIA descrita na seção anterior não faz sentido aqui. Quando a pilha está vazia? Quando o ponteiro da cabeça vale nil. 33 Algoritmos e Estrutura de Dados Segue a descrição das operações fundamentais de uma pilha implementada como uma lista. Todas têm complexidade constante. algoritmo VAZIA (P) se prox[P] = nil então devolva V senão devolva F ► Testa se uma pilha está vazia algoritmo EMPILHA (P, M) prox [M] ←prox [P] prox [P] ← M ► Empilha uma célula M em uma pilha P algoritmo DESEMPILHA (P) se NÃO VAZIA (P) então M ←prox [P] prox [P] ←prox[M] devolva M devolva nil ► Desempilha o elemento no topo de uma pilha P algoritmo TOPO (P) se NÃO VAZIA (P) então devolva info[prox[P]] devolva nil ►Devolve o elemento no topo de uma pilha P Aprenda Praticando Vamos estudar uma aplicação da estrutura de pilha, desenvolvendo uma calculadora para expressões aritméticas em notação posfixa. Quanto vale a expressão seguinte? 5 – 2 + 4 34 Algoritmos e Estrutura de Dados Depende da ordem das operações. Se a soma é aplicada primeiro, o resultado é -1. Senão, é 7. Para evitar ambiguidades, o uso de parênteses é necessário. Esse tipo de expressão onde o operador (soma, produto...) é escrito entre os operandos (números) é chamado de notação infixa (vamos considerar apenas operadores binários, como os aritméticos). Não é a única maneira de escrever uma expressão aritmética. Na notação posfixa, o operador vem após os operandos. Eis a soma de 2 e 4 em notação posfixa: 2 4 + Uma expressão mais complicada: 5 2 4 + - Como interpretá-la? • leia a expressão da esquerda para a direita, armazenando os números na ordem da leitura; • tão logo encontre um operador, aplique-o aos dois números mais à direita, ou seja, os dois últimos armazenados (em uma expressão correta, sempre haverá dois operandos quando um operador é encontrado); • sempre que uma operação é realizada, retire os operandos e armazene o resultado. Continue a leitura. Em nosso exemplo, após a leitura dos números 5, 2 e 4, chegamos ao operador de soma. Fazemos então a soma de 2 e 4, que são os dois últimos que foram armazenados. O resultado é 6. Após retirar os números 2 e 4, armazenamos esse resultado, obtendo a seguinte configuração: 5 6 (note que não bisbilhotamos os dados armazenados anteriormente; simplesmente eles continuam lá). Continuando a leitura, encontramos o operador -, que é aplicado finalmente a 5 e 6. O resultado é -1. Em notação infixa, acabamos de calcular o seguinte: 5 - (2 + 4) Note que a ordem dos operandos é a mesma nas duas notações. Lembrete A notação posfixa também é chamada de notação polonesa reversa. Isso em homenagem ao matemático polonês Jan Lukasiewicz, que inventou na década de 1920 a notação prefixa: o operador é escrito antes dos operandos. 35 Algoritmos e Estrutura de Dados A expressão que corresponde a (5 – 2) + 4 é 5 2 – 4 + Que tal calcular o resultado dessa expressão usando o mesmo processo? Também perceba que não foi necessário usar parênteses para distinguir entre uma e outra. De fato, uma das vantagens da notação posfixa é que ela não admite ambiguidades, dispensando assim o uso de parênteses. Talvez você tenha percebido que podemos usar uma pilha para calcular o resultado de uma expressão em notação posfixa. Traduzindo as instruções acima: • leia a expressão da esquerda para a direita; • encontrando um número, empilhe-o; • encontrando um operador, desempilhe dois operandos, e empilhe o resultado da operações com esses operandos. Nosso objetivo é descrever esse algoritmo. Estaremos assim simulando uma calculadora baseada na notação posfixa. Antes de começar, vamos fixar algumas convenções. A expressão é fornecida pelo usuário item após item, e a leitura continua até a entrada de um código de finalização: qualquer valor diferente de um operador e um número. Os operadores considerados são +, -, * e /. Vamos supor que a expressão é correta e que não ocorre divisão por zero. Para simplificar, vamos supor a existência de uma função que testa se uma variável representa um número, chamada NUMERO, e de uma função que testa se uma variável representa um operador, chamada OPERADOR. Também vamos supor que dispomos de uma implementação de pilha e das operações EMPILHA e DESEMPILHA. Não é importante se essa pilha é implementada com vetores ou listas ligadas. Esse detalhe está escondido de nosso algoritmo. Lembrete A notação polonesa reversa tornou-se bastante popular na década de 1960 após ser adotada em alguns modelos de calculadoras científicas. 36 Algoritmos e Estrutura de Dados algoritmo CALCULADORA-POSFIXA (P) inicialize P como uma pilha de inteiros vazia repita leia c se NUMERO (c) então EMPILHA (P, c) senão se OPERADOR (c) então r = DESEMPILHA (P) l = DESEMPILHA (P) se c = + então EMPILHA(P, l + r) se c = - então EMPILHA (P, l - r) se c = * então EMPILHA (P, l * r) se c = / então EMPILHA (P, l / r) até que NÃO NUMERO (c) E NÃO OPERADOR (c) imprima DESEMPILHA (P) ► Imprime o resultado de ► uma expressão em ► notação posfixa Perceba que no começo desse código solicitamos a criação de uma pilha vazia. Em uma linguagem como o C e o Pascal, essa instrução corresponde à alocação de memória para uma estrutura do tipo pilha. Em uma linguagem como o Java, trata-se da criação de um objeto da classe pilha. Agora é com você. Nos exercícios propostos, você terá a oportunidade de aplicar a estrutura de pilha para a solução de outros problemas igualmente interessantes. 37 Algoritmos e Estrutura de Dados Atividades e Orientações de Estudo A principal atividade proposta nesse capítulo é o desenvolvimento de soluções baseadas na estrutura de pilha. É muito interessante que você tente implementar suas respostas em uma linguagem de programação. O primeiro exercício segue esse espírito. Problema 1 Implemente em C ou Java a estrutura de pilha e as operações correspondentes. Escolha entre implementação com vetores ou lista ligada.Em seguida, implemente a calculadora de expressões em notação posfixa usando sua pilha. Teste intensivamente sua implementação. Problema 2 Simule a execução da calculadora posfixa para as seguintes expressões: 5 2 – 4 + 8 6 2 * 4 7 + - * 1 2 3 4 - - - Problema 3 Escreva um algoritmo que testa se uma expressão envolvendo parênteses e colchetes é bem formada. Em uma expressão bem formada, todo parênteses aberto deve ser fechado mais à direita. Ademais, tudo o que está entre a abertura e o fechamento também é uma expressão bem formada. O mesmo vale para colchetes. Por exemplo, a seguinte expressão é bem formada: ( ) ( [ ( ) [ ] ] ( ) ] ) A seguinte não: ( [ ) ] Qual é o maior tamanho que a pilha pode atingir, em função do comprimento da expressão? Dê um exemplo de expressão onde esse valor é atingido. 38 Algoritmos e Estrutura de Dados Problema 4 Escreva um algoritmo que lê uma sequência de caracteres e imprime seu reverso, ou seja, a mesma sequência lida da direita para a esquerda. Problema 5 (Desafio) Escreva um algoritmo que lê uma expressão aritmética em notação infixa e imprime a expressão correspondente em notação posfixa. Suponha que a expressão lida é bem-formada e não-ambígua, ou seja, contém parênteses que indicam a ordem das operações. Deixe bem claro outras suposições sobre as quais repousa sua solução. Dica: use uma pilha de operadores e parênteses; operandos não precisam ser empilhados, e são impressos imediatamente (recorde que a ordem dos operadores é a mesma em ambos os tipos de expressão). Conheça Mais A estrutura de pilha é estudada em detalhes no primeiro volume da série The Art of Computer Programming de Donald Knuth: Knuth, D. The Art of Computer Programming. Volume 1: Fundamental Algorithms. Massachusetts: Addison-Wesley, 1997. Vale a pena consultar essa referência. Você encontrará em particular exercícios excelentes, alguns bastante desafiadores. Consulte também Feofiloff, P. Algoritmos em linguagem C. Ed. Campus/Elsevier. 2008 Vamos Revisar? Estudamos neste capítulo a estrutura de pilha: listas lineares onde inserções e remoções só ocorrem em uma das extremidades. Descrevemos implementações de uma pilha com vetores e com listas ligadas, e estudamos uma aplicação, a avaliação de uma expressão aritmética em notação posfixa. 39 Algoritmos e Estrutura de Dados Capítulo 3 – Filas Vamos conversar sobre o assunto? Filas são outro tipo de lista linear com uma importância particular em aplicações. Filas modelam muitas situações práticas como escalonamento de processos em sistemas operacionais, determinação do caminho mínimo em redes e gerenciamento de requisições em um ambiente de recursos compartilhados, para citar três exemplos tradicionais. Em uma fila, inserções e remoções só ocorrem nas extremidades da lista, e em extremidades diferentes. Essa disciplina funciona exatamente como uma fila de banco. Este capítulo segue o mesmo espírito do anterior. Vamos introduzir o conceito de fila, as operações fundamentais em uma fila e discutir possíveis implementações. No final, você será convidado a se familiarizar com essa estrutura através de exercícios. Aproveite bem! 1. Definição de fila Em uma fila de banco, os clientes entram no final da fila, e saem no início. Atende-se primeiro quem esperou mais tempo. A estrutura de dados fila obedece à mesma política. Uma fila é uma lista linear onde elementos só podem ser inseridos em uma extremidade, o final da fila, e retirados na outra, a frente da fila. Por isso, dizemos que uma fila é uma estrutura FIFO, do inglês First In First Out (primeiro a chegar primeiro a sair). Perceba a diferença com Atenção Felizmente, em Computação ninguém fura a fila. 40 Algoritmos e Estrutura de Dados relação à estrutura de pilha: numa pilha, sai o último que chegou. Por exemplo, considere a lista linear de inteiros a seguir. 54, 72, 37, 42 Fixemos o final da fila na extremidade à direita. Então a inserção de 25 leva à configuração 54, 72, 37, 42, 25 Só um elemento pode ser removido, o 54, que está na frente da fila. Sua remoção leva à configuração 72, 37, 42, 25 Vamos em seguida estudar como implementar uma fila e as operações de inserção e remoção usando um vetor, e em seguida usando uma lista ligada. 2. Implementação de uma fila num vetor Podemos implementar uma fila em um vetor com o auxílio de duas variáveis, frente e final. A fila ocupa uma fatia de posições consecutivas do vetor, e essas variáveis indicam os limites dessa fatia. Se o vetor tem tamanho n, a fila terá no máximo n – 1 elementos – a razão ficará clara ao longo da explicação. No exemplo a seguir, a frente da fila está na posição 3, e o final na posição 6. Perceba que o valor da variável final é uma posição após o último elemento da fila – novamente, a razão ficará clara em breve. As demais posições do vetor não têm nada a ver com a fila. Fazendo-se inserções e remoções, a fila caminha ao longo do vetor. Por exemplo, se inserimos o elemento 36 na fila e fazemos em seguida uma remoção, obtemos O que ocorre se a variável final atinge a última posição do vetor? Lembrete Em inglês, fila é queue, e as operações de inserção e remoção são comumente chamadas de enqueue e dequeue, respectivamente. 41 Algoritmos e Estrutura de Dados Em nosso exemplo, chegamos a essa situação se inserimos mais dois números na fila, digamos, 26 e 48: Poderíamos dizer que a fila está cheia. Mas ainda há espaço no vetor! A solução para aproveitar esse espaço é voltar ao início do vetor, como se a primeira posição fosse a continuação da primeira. Por exemplo, se inserimos o inteiro 72 na configuração acima, obtemos Em seguida, se inserimos o inteiro 68 obtemos A fila começa na posição 4, vai até o final do vetor, e volta ao início, terminando na primeira posição. Remoções também obedecem à técnica de voltar ao início. Considere a configuração a seguir, onde a frente da fila está na última posição do vetor: A remoção de um elemento leva então à seguinte configuração: É instrutivo visualizar essa implementação de fila como um anel, entortando o vetor de forma a colar a última posição na primeira. Esse tipo de estrutura é chamada de vetor circular, ou buffer circular. Um vetor circular não tem extremidades, não tem fim, como é o caso de um vetor comum. As inserções e remoções fazem com que a fila 42 Algoritmos e Estrutura de Dados caminhe ao longo dessa estrutura circular. Vejamos um exemplo. Considere o vetor a seguir: O vetor circular correspondente é Podemos agora explicar o porquê de assumir que o último elemento da fila está na posição anterior àquela indicada pela variável final. Essa convenção é útil para que possamos detectar se a fila está cheia ou vazia. A fila está vazia quando frente = final e cheia quando frente = final + 1 ou frente = 1 e final = N Se admitimos que a variável final indica a última posição com um elemento da fila, não podemos fazer a distinção entre essas duas situações (procure se certificar da razão). Antes de implementarmos as operações de inserção e remoção nessa estrutura, cabe uma reflexão sobre como acessar a posição seguinte a uma posição k num vetor circular. Vamos supor que o vetor tem tamanho N. Se k < N, então a próxima posição é k + 1. O caso delicado ocorre quando k = N. Neste caso, a próxima posição não é mais k + 1, mas sim a primeira. Assim, podemos descobrir a próxima posição com um teste condicional. Também é possível calcular a próxima posição diretamente, em uma única fórmula. A resposta está no uso do resto da divisão inteira, 43 Algoritmos e Estrutura de Dados que em Portucal é dado pelo operador mod (consulte a descrição da linguagem noprimeiro módulo). Se k < N, então k mod N é igual a k, se k = N, então k mod N é igual a 0. Assim, a expressão seguinte fornece a próxima posição para qualquer situação: (k mod N) + 1 Usando essa técnica, também podemos decidir se a fila está cheia com um único teste: frente = (final mod N) + 1 No código a seguir, vamos considerar uma fila como um registro (ou uma estrutura, ou um objeto) que além do vetor circular engloba as variáveis N (o tamanho do vetor), frente e final. Como fizemos no estudo da implementação de pilhas em vetores, não incluiremos um tratamento explícito de erros (overflows e underflows). algoritmo VAZIA (Q) se frente[Q] = final[Q] então devolva V senão devolva F ► Testa se uma fila está vazia algoritmo CHEIA (Q) se frente[Q] = (final[Q] MOD N[Q]) + 1 então devolva V senão devolva F ► Testa se uma fila ► implementada em um ► vetor circular está cheia algoritmo ENTRA-FILA (Q, x) se NÃO CHEIA (Q) então Q[final[Q]] ← x final[Q] ← (final[Q] MOD N[Q]) + 1 ► Insere um elemento numa fila 44 Algoritmos e Estrutura de Dados algoritmo SAI-FILA (Q) se NÃO VAZIA (Q) então x ← Q[frente[Q]] frente[Q] ← (frente[Q] MOD N[Q]) + 1 devolva x devolva nil ► Remove um elemento de uma ► fila Vamos em seguida discutir a implementação de uma fila com listas ligadas. 3. Implementação de uma fila numa lista ligada Podemos implementar uma fila com uma lista com cabeça. O primeiro elemento da fila é a célula após a cabeça. Vamos supor que essa lista é uma estrutura que engloba dois ponteiros, frente, que aponta para a cabeça (e não para o primeiro elemento, que vem após a cabeça), e final, que aponta para o último elemento. A fila está vazia quando final aponta para a cabeça, ou seja, quando final = frente. A figura a seguir ilustra uma fila, uma operação de inserção e uma remoção. Confira a seguir as operações de inserção, remoção e teste do vazio para essa implementação. Novamente, não vamos nos preocupar nesses exemplos com o tratamento de situações de exceção. Todas as operações têm complexidade constante. 45 Algoritmos e Estrutura de Dados algoritmo VAZIA (Q) se frente[Q] = final[Q] então devolva V senão devolva F ► Testa se uma fila está vazia algoritmo ENTRA-FILA (Q, M) prox[final[Q]] ← M final[Q] ← M ► Insere uma célula, M, numa fila algoritmo SAI-FILA (Q) se NÃO VAZIA (Q) então x = prox[frente[Q]] se prox[frente[Q]] = final[Q] então final[Q] = frente[Q] prox[frente[Q]] ← prox[x] devolva x devolva nil ► Remove um elemento de uma ► fila Como já mencionamos, a memória ocupada por uma célula removida deve ser devolvida ao sistema. Note que a remoção de um elemento exige um tratamento especial para o caso de uma fila com um único elemento (o ponteiro final deve receber o endereço da cabeça). Há outras maneiras de se implementar uma fila. Nos exercícios, você será convidado a estudar uma alternativa. Aprenda Praticando Vamos nos aquecer no uso de filas com um exercício que envolverá também a estrutura de pilha. Além da experiência com as operações dessas estruturas, esse exemplo discute uma solução para um 46 Algoritmos e Estrutura de Dados problema, enunciado no parágrafo a seguir. Que tal tentar soluciona- lo antes de ler o restante da seção? Nosso objetivo é implementar uma fila utilizando duas pilhas. Ou seja, só dispomos dessas pilhas como meio de armazenamento, e desejamos manipulá-las de forma a reproduzir o comportamento de uma fila. Do ponto de vista do usuário, essa implementação ficará escondida, as operações de inserção obedecerão à política que define uma fila. Vamos implementar a solução descrita no volume 1 (Fundamental Algorithms) da coleção “The Art of Computer Programming”, de D. Knuth (Seção 2.2.1, Exercício 14). Chamemos as pilhas de R e S. A solução consiste em enxergar a fila começando no topo de R, indo até a base, e em seguida continuando da base de S até o topo de S. A frente da fila é o topo de R, o final o topo de S. Inserções na fila são empilhamentos em S, remoções são desempilhamentos em R. A figura a seguir ilustra essa situação, para uma fila Q que armazena os inteiros 53, 71, 64, 34, 17 Há uma situação que exige um tratamento especial, o caso em que a pilha R fica vazia após uma remoção. Nesse caso, todos os elementos de S são imediatamente desempilhados e empilhados em R, na ordem. Se S também está vazia, situação que corresponde à fila vazia, a próxima inserção deve ser um empilhamento diretamente em R, ao invés de S. Note que, para testar se a fila está vazia, basta testar se R está vazia. Segue a implementação dessa solução. Vamos considerar que uma fila é uma estrutura que comporta duas pilhas, R e S, acessíveis com as construções R[Q] e S[Q]. 47 Algoritmos e Estrutura de Dados algoritmo VAZIA (Q) se VAZIA[R[Q]] então devolva V senão devolva F ► Testa se uma fila implementada com duas ► pilhas está vazia algoritmo ENTRA-FILA (Q, x) se VAZIA[R[Q]] então EMPILHA (R[Q], x) senão EMPILHA (S[Q], x) ► Insere x em uma fila implementada ► em duas pilhas algoritmo SAI-FILA (Q) se NÃO VAZIA (Q) então x ← DESEMPILHA (R[Q]) se VAZIA (R[Q]) então ► Remove um elemento ► de uma fila enquanto NÃO VAZIA (S[Q]) faça EMPILHA (R[Q], DESEMPILHA (S[Q])) devolva x devolva nil Uma nota sobre a eficiência dessa solução. A complexidade de uma inserção é constante, mas a de uma remoção, no pior caso, não é: o pior caso ocorre quando a pilha R fica vazia, o que implica em uma cópia dos elementos de S em R. Por outro lado, se contamos o número médio de operações realizadas ao longo de uma sequência de inserções e remoções, vamos nos deparar com um comportamento interessante: esse valor não depende do número de elementos. A razão é que cada elemento é empilhado no máximo duas vezes, e desempilhado também no máximo duas vezes (duas em cada pilha). Essa contagem de operações ao longo do tempo é chamada de complexidade amortizada. Podemos dizer que a complexidade amortizada das operações de inserção e remoção em nossa implementação é constante. 48 Algoritmos e Estrutura de Dados Conheça Mais As mesmas referências citadas no capítulo anterior servem como fonte de consulta sobre filas. Encontraremos outros detalhes de implementação, aplicações e exercícios no primeiro volume da série The Art of Computer Programming de Donald Knuth: Knuth, D. The Art of Computer Programming. Volume 1: Fundamental Algorithms. Massachusetts: Addison-Wesley, 1997. O mesmo comentário vale para Feofiloff, P. Algoritmos em linguagem C. Ed. Campus/Elsevier. 2008 As estruturas de pilha e fila são muito tradicionais. Além das referências mencionadas nesse módulo, qualquer livro sobre Estruturas de Dados trata do assunto. Claro que a qualidade e profundidade do tratamento pode variar. Atividades e Orientações de Estudo Propomos abaixo uma lista de exercícios que segue o mesmo espírito daqueles propostos em nosso estudo sobre pilhas. Novamente, é muito interessante que você tente implementar suas respostas em uma linguagem de programação. Problema 1 Implemente em uma linguagem de programação “real” uma fila e as operações discutidas neste capítulo. Você pode escolher entre implementação sequencial ou ligada. Teste intensivamente sua implementação com sequências interessantes de inserções e remoções. Problema 2 Escreva uma função que calcula em complexidade constante (sem percorrer a estrutura) o tamanho de uma fila implementada num vetor circular. 49 Algoritmos e Estrutura de Dados Problema 3 Considere uma implementação de filas emuma lista ligada circular onde não dispomos de um ponteiro para o último elemento. Descreva como a operação de inserção pode ser realizada em complexidade constante, ou seja, sem uma busca pelo último elemento. Você tem o direito de criar uma nova célula para representar a cabeça. Problema 4 Uma fila dupla ou deque (do inglês double end queue) é uma lista onde tanto inserções quanto remoções são permitidas em ambas as extremidades. Descreva uma implementação de um deque, incluindo suas operações fundamentais. Problema 5 Mostre como uma pilha pode ser implementada usando duas filas. Discuta a eficiência de sua solução. Problema 6 Descreva um algoritmo que recebe duas filas e devolve outra fila consistindo da segunda concatenada no final da primeira. Você só tem o direito de usar as operações de inserção e remoção disponíveis (ou seja, você não tem acesso à implementação dessas filas – não pode mexer nos ponteiros da lista ligada ou nas posições do vetor onde as mesmas vivem). Você pode modificar as filas durante a realização da operação, mas ao final elas devem ser iguais às recebidas. Vamos Revisar? Estudamos neste capítulo a estrutura de fila, listas lineares onde inserções ocorrem em uma extremidade e remoções em outra. Descrevemos implementações dessa estrutura em vetores circulares e em listas ligadas. 50 Algoritmos e Estrutura de Dados Capítulo 4 – Árvores Vamos conversar sobre o assunto? Neste último capítulo, estudaremos uma nova estrutura de dados para a representação de um conjunto dinâmico, as árvores. Como acontece com as demais, o conceito de árvore é fundamental em Computação e suas aplicações aparecem em muitos domínios, indo do corriqueiro ao avançado. Já observou a estrutura do sistema de arquivos no seu computador? A organização hierárquica dos diretórios lembra uma árvore e seus ramos. Árvores são úteis para o armazenamento de dados em bancos de dados e para a representação da estrutura sintática de um programa durante o processo de compilação, para citar dois exemplos importantes. Nosso objetivo será introduzir o conceito de árvore (e mais especificamente de árvore de busca), três métodos de visita dos nós de uma árvore, e algoritmos para inserção, busca e remoção de elementos. Constataremos que a eficiência dessas operações no pior caso empata com o uso de listas ligadas, mas pode ser muito melhor se forem realizadas tomando-se certos cuidados. Bom estudo! 1. A definição de árvore Muitas estruturas comuns na Ciência da Computação possuem uma estrutura hierárquica, formada por um elemento de origem, que podemos chamar de raiz, e ramificações para elementos em níveis mais internos. Tais estruturas são o que chamamos de árvores. Eis uma representação típica de uma árvore de diretórios num computador: 51 Algoritmos e Estrutura de Dados A raiz dessa árvore é a pasta na parte superior e representa o diretório mais externo, ou seja, aquele a partir do qual a árvore é construída. Os subdiretórios dentro desse diretório mais externo são desenhados imediatamente abaixo, e são também árvores, que representam diretórios mais internos. Dizemos que essas últimas são subárvores da árvore original. Que tal verificar uma árvore de diretórios por si mesmo em algum computador? Árvores também são úteis para modelar a estrutura de documentos XML e atributos compostos em bancos de dados. A figura a seguir apresenta um exemplo. A raiz é o atributo livro, que é composto de três subatributos: ISBN, título e autores. O atributo autores, por sua vez, tem dois subatributos, ambos chamados de autor. Segue um exemplo mais simples, uma árvore que representa um conjunto de inteiros. Trata-se do tipo de dados que vamos considerar nesse texto. 52 Algoritmos e Estrutura de Dados Analisando esses exemplos com atenção, constatamos que árvores são estruturas recursivas, ou seja, são formadas por subestruturas do mesmo tipo, mas menores. Esse fato aparece explicitamente na definição formal de árvore, que apresentamos a seguir: Uma árvore é uma coleção de elementos chamados nós, onde se distingue um nó chamado raiz, e onde cada nó pode estar associado a zero ou mais nós chamados filhos. Formalmente, uma árvore é definida recursivamente como segue: • um único nó é uma árvore; esse nó também é a raiz da árvore; • se r é um nó e t1, t2, ..., tn são árvores cujas raízes são r1, r2, ..., rn, respectivamente, então a união de r com t1, t2, ..., tn, definindo-se como filhos de r os nós r1, r2, ..., rn, é uma árvore cuja raiz é r. Nessa definição, t1, t2, ..., tn são subárvores da árvore enraizada em r; toda subárvore de alguém em t1, t2, ..., tn idem. Fixemos outros termos técnicos úteis: • os nós que não possuem nenhum filho são chamados de folhas; • se r é filho de s, dizemos que s é o pai de r; • cada nó está em um nível: a raiz no nível 0, os filhos da raiz no nível 1, etc.; • a altura de uma árvore é o máximo dos níveis de seus nós. Nos exemplos a seguir, a raiz é colorida em cinza escuro, seus filhos em cinza claro e as folhas são os nós pretos. O número de nós da árvore é representado por n, e a altura por h. 53 Algoritmos e Estrutura de Dados A árvore t1 é formada por um único nó, que é ao mesmo tempo a raiz e uma folha. Também note que t3 nada mais é do que uma lista linear. De fato, poderíamos dizer que a estrutura de árvore é uma generalização das listas, permitindo que cada célula tenha mais de um sucessor. Às vezes é útil incluir na definição a árvore vazia, ou árvore que não possui nenhum nó. Em certas aplicações convém considerar árvores cujos nós têm um número limitado de filhos. Em particular, uma árvore binária é tal que todo nó tem no máximo dois filhos. No exemplo anterior, t1 e t3 são árvores binárias, enquanto que t2 não. Desse ponto em diante, nosso estudo concentra-se em árvores binárias; mais informações sobre outros tipos de árvores podem ser encontradas nas referências bibliográficas. Essas árvores têm uma importância particular na Computação, que você mesmo poderá constatar durante a leitura. Vamos a seguir apresentar uma possível implementação de árvores binárias em computadores (outro método será proposto nos exercícios). Depois, aprofundaremos nosso estudo de árvores binárias discutindo métodos para a visita dos nós de uma árvore, e finalmente a representação de um conjunto dinâmico em uma árvore. 2. Implementação de árvores binárias Em uma árvore binária é conveniente ordenar os filhos de cada nó em filho esquerdo e filho direito. Com essa convenção, podemos implementar uma árvore usando registros e ponteiros, de forma semelhante à implementação de listas ligadas apresentada no primeiro capítulo. Mais precisamente, cada nó da árvore é um registro composto de um campo info, nesse texto sempre um inteiro, e de dois ponteiros, esq e dir, que armazenam os endereços dos filhos desse nó. Representamos um tal registro como segue: info esq dir Em um nó que não tem um dos filhos, o ponteiro correspondente vale nil. Em particular, em uma folha ambos os ponteiros valem nil. Em Portucal acessamos esses campos, como de hábito, usando Lembrete As árvores em Computação crescem invertidas: a raiz fica em cima, as folhas embaixo. (Folclore) 54 Algoritmos e Estrutura de Dados colchetes: se r é um registro, escrevemos info[r], esq[r], dir[r] Encontramos abaixo a implementação da árvore representando um conjunto de inteiros ilustrada no início desse capítulo. Nessa figura, o ponteiro r armazena o endereço do registro correspondente à raiz. É hábito relaxar um pouco o discurso e dizer, ao invés da sentença precisa “r é um ponteiro para a raiz”, que r é a árvore de fato. Essa convenção é coerente: para passar uma árvore a uma função, basta passar
Compartilhar