• Nenhum resultado encontrado

Estruturas lineares

No documento Análise de Algoritmos e Estruturas de Dados (páginas 125-131)

Neste capítulo veremos as estruturas de dados mais simples e clássicas, que formam a base para muitos dos algoritmos vistos neste livro.

9.1 Vetor

Um vetor é uma coleção de elementos de um mesmo tipo que são referenciados por um identificador único. Esses elementos ocupam posições contíguas na memória, o que permite acesso direto (em tempo constante, Θ(1)) a qualquer elemento por meio de um índice inteiro. Denotamos um vetor A com capacidade para m elementos por A[1..m]. Se o vetor arma-zena n elementos, dizemos que seu tamanho é n e o denotamos também por A[1..n] ou por A = (a1, a2, . . . , an). Denotamos por A[i] o elemento que está armazenado na i-ésima posição de A (i.e., ai), para todo i com 1 ≤ i ≤ n. Para quaisquer i e j em que 1 ≤ i < j ≤ n, denotamos por A[i..j] o subvetor de A que contém os elementos A[i], A[i + 1], . . . , A[j].

A seguir temos uma representação gráfica do vetor A = (12, 99, 37, 24, 2, 15):

A 12 1 99 2 37 3 24 4 2 5 15 6

Como já foi discutido no Capítulo 2, o tempo para buscar um elemento em um vetor de tamanho n é O(n) se usarmos o algoritmo de busca linear pois, no pior caso, ela precisa acessar todos os elementos armazenados no vetor. A inserção de um novo elemento x em um vetor A de tamanho n pode ser feita em tempo constante, Θ(1), ao inseri-lo na primeira posição disponível, a posição n + 1. Já a remoção de algum elemento do vetor envolve saber

em que posição i encontra-se tal elemento. Sabendo que o vetor tem n elementos, então podemos simplesmente copiar o elemento A[n] para a posição i. Assim, se a posição i já for indicada, a remoção leva tempo Θ(1), mas caso contrário uma busca pelo elemento ainda precisa ser feita, levando assim tempo O(n).

Veja que, se o vetor estiver ordenado, então os tempos mencionados acima mudam. A busca binária nos garante que o tempo de busca em um vetor de tamanho n é O(log n). A inserção, no entanto, não pode mais ser feita em tempo constante em uma posição qualquer, pois precisamos garantir que o vetor continuará ordenado. Assim, potencialmente precisare-mos deslocar vários elementos do vetor durante uma inserção, de forma que ela leva tempo O(n). De forma similar, a remoção de um elemento que está em uma posição i precisa de tempo O(n) para deslocar os elementos à direita dessa posição e assim manter o vetor ordenado.

O fato do vetor estar ordenado ainda nos permite realizar a operação de encontrar o k-ésimo menor elemento do vetor em tempo Θ(1). Se o vetor não estiver ordenado, existe um algoritmo que consegue realizar tal operação em tempo O(n).

9.2 Lista encadeada

Uma lista encadeada é uma estrutura de dados linear onde cada elemento é armazenado em um nó, que armazena também endereços para outros nós da lista. Por isso, cada nó de uma lista pode estar em uma posição diferente da memória, sendo diferente de um vetor, onde os elementos são armazenados de forma contínua. Na forma mais simples, têm-se acesso apenas ao primeiro nó da lista. Em qualquer variação, têm-se acesso a um número constante de nós apenas (o primeiro nó e o último nó, por exemplo). Assim, listas não permitem acesso direto a um elemento: para acessar o k-ésimo elemento da lista, deve-se acessar o primeiro, que dá acesso ao segundo, que dá acesso ao terceiro, e assim sucessivamente, até que o (k − 1)-ésimo elemento dá acesso ao k-ésimo.

Consideramos que cada nó contém um atributo chave e, como sempre, pode conter outros atributos importantes. Iremos inserir, remover ou modificar elementos de uma lista baseados nos atributos chave, que devem conter números inteiros. Outros atributos importantes que sempre existem são os endereços para outros nós da lista e sua quantidade e significado de-pendem de qual variação da lista estamos lidando. Em uma lista encadeada simples existe apenas um atributo de endereço, chamado proximo, que dá acesso ao nó que está imediata-mente após o nó atual na lista. Em uma lista duplaimediata-mente encadeada existe, além do atributo proximo, o atributo anterior, que dá acesso ao nó que está imediatamente antes do nó atual na lista. Seja x um nó qualquer. Se x. anterior = nulo, então x não tem predecessor, de

modo que é o primeiro nó da lista, a cabeça da lista. Se x. proximo = nulo, então x não tem sucessor e é chamado de cauda da lista, sendo o último nó da mesma. Em uma lista circular, o atributo proximo da cauda aponta para a cabeça da lista, enquanto que o atributo anterior da cabeça aponta para a cauda. Dada uma lista L, o atributo L. cabeca é o primeiro nó de L, sendo que L. cabeca = nulo quando a lista estiver vazia.

A seguir temos uma representação gráfica de uma lista encadeada simples que contém os elementos 12, 99, 37, 24, 2, 15:

L.cabeca

15 2 24 37 99 12

Veja que o acesso ao elemento que contém chave 24 (terceiro da lista) é feito de forma indireta: L.cabeca . proximo . proximo.

A seguir temos uma representação gráfica de uma lista duplamente encadeada circular que contém os elementos 12, 99, 37, 24, 2, 15:

L.cabeca L.cauda

15 2 24 37 99 12

A seguir vamos descrever os procedimentos de busca, inserção e remoção em uma lista duplamente encadeada, não ordenada e não-circular.

O procedimento BuscaNaLista mostrado no Algoritmo 9.1realiza uma busca pelo pri-meiro nó que possui chave k na lista L. Primeiramente, a cabeça da lista L é analisada e em seguida os elementos da lista são analisados, um a um, até que k seja encontrado ou até que a lista seja completamente verificada. No pior caso, toda a lista deve ser verificada, de modo que o tempo de execução de BuscaNaLista é O(n) para uma lista com n elementos.

A inserção de um elemento em uma lista é realizada, em geral, no começo da lista. Para inserir no começo, já temos de antemão a posição em que o elemento será inserido, que é L.cabeca. No Algoritmo 9.2inserimos um nó x na lista L. Portanto, caso L não seja vazia, o ponteiro x. proximo deve apontar para a atual cabeça de L e L. cabeca . anterior deve apontar para x. Caso L seja vazia, então x. proximo aponta para nulo. Como x será a cabeça de L, o ponteiro x. anterior deve apontar para nulo. Para algumas aplicações pode

Algoritmo 9.1: BuscaNaLista(L, k)

1 x = L.cabeca

/* Seguimos nós por meio dos endereços de proximo até chegar ao fim da lista

ou encontrar o elemento */

2 enquanto x 6= nulo e x. chave 6= k faça

3 x = x.proximo

4 devolve x

ser útil que exista um ponteiro que sempre aponta para a cauda de uma lista L. Assim, vamos manter um ponteiro L. cauda, que aponta para o último elemento de L, onde L. cauda = nulo quando L é uma lista vazia.

Algoritmo 9.2: InsereNoInicioLista(L, x)

1 x.anterior = nulo

2 x.proximo = L. cabeca

3 se L. cabeca 6= nulo então

/* Se há elemento na cabeça de L, este deve ter como anterior o nó x */

4 L.cabeca . anterior = x

5 senão

/* Se não há cabeça, também não há cauda, e o nó x será o último */

6 L.cauda = x

/* Em qualquer caso, x será a nova cabeça da lista */

7 L.cabeca = x

Como somente uma quantidade constante de operações é executada, o procedimento InsereNoInicioLista é executado em tempo Θ(1) para uma lista com n elementos. Note que o procedimento de inserção em uma lista encadeada ordenada levaria tempo O(n), pois precisaríamos inserir x na posição correta dentro da lista, tendo que percorrer toda a lista no pior caso.

Pode ser que uma aplicação necessite inserir elementos no fim de uma lista. Por exemplo, inserir no fim de uma lista facilita a implementação da estrutura de dados ‘fila’ com o uso de listas encadeadas. Outro exemplo é na obtenção de informações importantes durante a execução da busca em profundidade em grafos. O uso do ponteiro L. cauda torna essa tarefa análoga à inserção no início de uma lista. O procedimento InsereNoFimLista, mostrado no Algoritmo 9.3, realiza essa tarefa.

Algoritmo 9.3: InsereNoFimLista(L, x)

1 x.anterior = L. cauda

2 x.proximo = nulo

3 se L. cauda 6= nulo então

/* Se há elemento na cauda de L, este deve ter como próximo o nó x */

4 L.cauda . proximo = x

5 senão

/* Se não há cauda, também não há cabeça, e o nó x será o primeiro */

6 L.cabeca = x

/* Em qualquer caso, x será a nova cauda da lista */

7 L.cauda = x

com chave k de uma lista L. A remoção é simples, sendo necessário efetuar uma busca para en-contrar o nó x com chave k e atualizar os ponteiros x. anterior . proximo e x. proximo . anterior, tendo cuidado com os casos onde x é a cabeça ou a cauda de L. Caso utilizemos uma lista ligada L em que inserções são feitas no fim da lista, precisamos garantir que vamos manter L.cauda atualizado quando removemos o último elemento de L.

Como somente uma busca por uma chave k e uma quantidade constante de operações é efetuada, a remoção leva tempo O(n) no pior caso, como o algoritmo de busca.

Algoritmo 9.4: RemoveDaLista(L, k)

1 x = L.cabeca

2 enquanto x 6= nulo e x. chave 6= k faça

3 x = x.proximo

4 se x = nulo então

5 devolve nulo

/* Se x é a cauda de L, então o penúltimo nó passa a ser L. cauda */

6 se x. proximo == nulo então

7 L.cauda = x. anterior

8 senão

9 x.proximo . anterior = x. anterior

/* Se x é a cabeça de L, então o segundo nó passa a ser L. cabeca */

10 se x. anterior == nulo então

11 L.cabeca = x. proximo

12 senão

10

Capítulo

No documento Análise de Algoritmos e Estruturas de Dados (páginas 125-131)

Documentos relacionados