Aula 2c - Listas por Contiguidade Física
Nesta aula iniciamos o estudo das Listas Lineares, a primeira de diversas estruturas de dados que veremos ao longo da disciplina.
1. Listas Lineares
Uma lista linear é uma sequência de nodos com as seguintes características:
- Simples manipulação;
- Possui uma relação de ordem entre os nodos;
- É sequencial;
- A estrutura interna de cada nodo é abstraída (não importa, do ponto de vista da lista, o que cada nodo guarda);
- Todos os elementos são do mesmo tipo.
Podemos visualizar uma lista como uma sequência de nodos ligados por uma relação de ordem, onde cada nodo guarda um conjunto de informações:
(a) - (b) - (c) - (d) ... (z)
|
v
+--------+----+------+-------+-------+
| Número | RG | Nome | Nasc. | Cargo |
+--------+----+------+-------+-------+
2. Definição formal
Uma lista linear é uma coleção n >= 0 de nodos x[0], x[1], ..., x[n-1], cujas propriedades estruturais relevantes envolvem as posições relativas dos nodos:
x[0]é o primeiro nodo ex[n-1]é o último nodo;x[k]é precedido porx[k-1]e sucedido porx[k+1];n = 0representa a lista vazia.
3. Aplicações
- Notas de alunos;
- Cadastro de funcionários de uma empresa;
- Dias da semana;
- Letras de uma palavra;
- Cartas de baralho.
4. Operações
- Criação;
- Destruição;
- Inserção de um nodo na i-ésima posição;
- Exclusão do i-ésimo nodo;
- Acesso ao i-ésimo nodo;
- Alteração do i-ésimo nodo;
- Concatenação de duas listas;
- Cópia da lista;
- Localizar nodo a partir da informação (busca);
- Contar elementos da lista.
| As operações em destaque acima são as mais comuns — e por isso precisam ser eficientes. |
5. Relação com TAD
Uma lista linear é uma implementação concreta do TAD Lista (visto na Aula 2b, com seus dois papéis: dados + operações):
- O cliente não precisa saber como a lista é implementada internamente;
- O programador deve conhecer a estrutura interna para implementá-la corretamente.
Essa implementação pode ser feita de duas formas:
- Contiguidade física (assunto desta aula);
- Encadeamento (veremos em aulas futuras).
A partir dessas duas formas de implementação, surgem diversos tipos de listas que estudaremos ao longo da disciplina:
- Linear (estática/dinâmica);
- Linear circular (estática/dinâmica);
- Simplesmente encadeada;
- Duplamente encadeada;
- Circular encadeada;
- Pilhas;
- Filas;
- Deques.
6. Contiguidade física
- A representação utiliza a estrutura linear da memória para armazenar a lista;
- Um arranjo de uma dimensão (vetor) é usado;
- Nodos logicamente adjacentes são mantidos em posições de memória fisicamente adjacentes;
- A lista sempre começa no índice 0 do vetor — basta guardar quantos elementos estão ocupados (o tamanho da lista) para saber exatamente onde ela termina;
- A lista possui uma capacidade máxima estabelecida (tamanho do vetor);
- Pode ser estática (capacidade fixada em tempo de compilação) ou dinâmica (capacidade definida em tempo de execução, com
malloc()) — veremos a estática nesta aula, que será o padrão adotado no restante da disciplina.
A ideia é manter os elementos sempre a partir da posição 0, ocupando as primeiras tamanho posições de um vetor de capacidade fixa:
índice: 0 1 2 3 4 5 6 7 ... CAPACIDADE-1
dados: [ L0 ][ L1 ][ L2 ][ L3 ][ L4 ][ ][ ]...[ ]
^
|
tamanho = 5 (posições 0 a 4 ocupadas)
Estática × dinâmica. Nesta disciplina, a implementação padrão (usada por padrão em todas as aulas, laboratórios e trabalhos, a menos que indicado o contrário) é a estática (ListaContEst), com um vetor de tamanho fixo declarado diretamente na struct: Produto dados[CAPACIDADE];. Existe também uma variação dinâmica (ListaContDin), em que esse campo passa a ser um ponteiro alocado com malloc() — Produto *dados; — permitindo que a capacidade seja definida em tempo de execução, e não fixada em tempo de compilação. As duas variações compartilham a mesma interface (as mesmas operações, com o mesmo comportamento); a única diferença está em como o campo dados é declarado e alocado.
|
7. TAD Lista Linear (estática)
Vamos implementar uma lista de Produto, onde cada nodo guarda um código, um nome e um preço. Esse é o dado a ser armazenado em cada nodo da lista — poderia ser um simples int, float, ou qualquer outro tipo:
typedef struct { int cod; char nome[50]; float preco; } Produto;
E o tipo da lista propriamente dita, com o vetor de capacidade fixa e um único campo de controle, tamanho:
#define CAPACIDADE 50 typedef struct { int tamanho; Produto dados[CAPACIDADE]; } ListaContEst;
Operações
Inicialização — uma lista vazia é representada por tamanho = 0:
void inicializar(ListaContEst *l) {
l->tamanho = 0;
}
Tamanho — número de elementos atualmente armazenados na lista. Como o próprio campo tamanho já guarda esse valor, a operação apenas o devolve — mas continua sendo uma função, e não um acesso direto ao campo, para que quem usa a lista (o cliente) não precise conhecer o nome do campo interno:
int tamanho(const ListaContEst *l) { return l->tamanho; }
Acesso — retorna o produto armazenado na posição pos, sem removê-lo. Como a lista sempre começa no índice 0, a posição lógica é o índice físico no vetor:
Produto acessar(const ListaContEst *l, int pos) { Produto pAux; if (pos >= l->tamanho || pos < 0) // posição inválida pAux.cod = -1; else pAux = l->dados[pos]; return pAux; }
Inserção — a posição pos indica onde o novo produto deve entrar (0 é sempre o início da lista). Para abrir espaço, a função desloca todos os elementos a partir de pos uma posição para a direita:
int inserir(ListaContEst *l, Produto novo, int pos) { int i; if (l->tamanho == CAPACIDADE) return 0; // lista cheia if (pos > l->tamanho || pos < 0) return 0; // posição inválida for (i = l->tamanho; i > pos; i--) // abre espaço, empurrando para a direita l->dados[i] = l->dados[i - 1]; l->dados[pos] = novo; l->tamanho++; return 1; }
Remoção — remove o produto da posição pos, puxando os elementos seguintes uma posição para a esquerda:
int remover(ListaContEst *l, int pos) { int i; if (pos >= l->tamanho || pos < 0) return 0; // posição inválida for (i = pos; i < l->tamanho - 1; i++) // puxa elementos seguintes para a esquerda l->dados[i] = l->dados[i + 1]; l->tamanho--; return 1; }
Busca — diferente de acessar (que já sabe exatamente onde procurar), a busca recebe apenas um código e precisa encontrar a posição correspondente, comparando produto por produto até achar (ou até chegar ao fim da lista). É a mesma ideia da busca sequencial vista em aulas anteriores, aplicada agora sobre os elementos da lista:
int buscar(const ListaContEst *l, int cod) { int i; for (i = 0; i < l->tamanho; i++) { if (l->dados[i].cod == cod) { return i; // posição onde o produto foi encontrado } } return -1; // não encontrado }
Por que const ListaContEst *l, e não apenas ListaContEst l? Note que tamanho, acessar e buscar apenas leem a lista — nenhuma delas altera l. Ainda assim, elas recebem um ponteiro (ListaContEst *l), e não a struct inteira por valor (ListaContEst l): passar por valor copiaria todo o vetor dados a cada chamada, o que é caro e desnecessário. O const na frente do tipo comunica exatamente essa intenção — "recebo um ponteiro para não copiar os dados, mas prometo não alterá-los" — e o próprio compilador passa a impedir, em tempo de compilação, qualquer tentativa acidental de escrever em l dentro dessas funções. Por consistência, usaremos const sempre que uma função de um TAD receber um ponteiro apenas para leitura, ao longo do restante da disciplina.
|
Destruição — para esta implementação (estática, sem alocação dinâmica), basta invalidar o tamanho:
void destruir(ListaContEst *l) {
l->tamanho = 0;
}
8. Complexidade das operações
Retomando o que vimos na Aula 2a, podemos analisar o pior caso de cada operação em função do número de elementos n atualmente na lista:
| Operação | Complexidade | Motivo |
|---|---|---|
inicializar, tamanho, destruir |
O(1) |
Não dependem do número de elementos da lista. |
acessar |
O(1) |
Acesso direto pelo índice do vetor (l->dados[pos]), sem percorrer nenhum outro elemento. |
inserir |
O(n) |
No pior caso (inserir na posição 0), é preciso deslocar todos os n elementos existentes. |
remover |
O(n) |
No pior caso (remover da posição 0), é preciso deslocar todos os elementos seguintes. |
buscar |
O(n) |
No pior caso (produto não encontrado, ou encontrado apenas na última posição), é preciso comparar com todos os n elementos. |
Note a diferença entre acessar e buscar, apesar de ambas "lerem" elementos da lista: acessar já sabe exatamente qual índice acessar (recebido como parâmetro), então é sempre O(1); buscar não sabe onde está o elemento procurado, e por isso pode precisar examinar a lista inteira até encontrá-lo (ou concluir que ele não existe).
Já inserir e remover só são O(1) no melhor caso — quando a operação ocorre exatamente na última posição da lista (pos == tamanho(l) para inserir, pos == tamanho(l) - 1 para remover), caso em que nenhum elemento precisa ser deslocado.
Resumo
|
Exercícios
- Explique, com suas próprias palavras, o que significa dizer que "a estrutura interna dos nodos é abstraída" em uma lista linear.
-
Considerando a definição formal vista em aula (
x[0], x[1], ..., x[n-1]), o que caracterizax[0]ex[n-1]? O que representan = 0? -
Na implementação por contiguidade física vista em aula, por que basta guardar o campo
tamanhopara saber quantos elementos a lista possui e onde ela termina, sem precisar de nenhuma outra variável de controle? -
Na função
inserir, por que o pior caso ocorre ao inserir na posição0, e o melhor caso ocorre ao inserir na posiçãotamanho(l)(ou seja, no final da lista)? -
Explique por que a operação
acessartem complexidadeO(1), enquantobuscartem complexidadeO(n)no pior caso, mesmo as duas "lendo" elementos da lista. -
Cite uma situação em que inserir sempre no final da lista (posição
pos = tamanho(l)) evitaria o custo de deslocar elementos. Essa situação ainda seria vantajosa se também precisássemos remover elementos com frequência do início da lista? -
A lista implementada nesta aula é chamada de "estática" (
ListaContEst) porque sua capacidade (CAPACIDADE) é fixada em tempo de compilação. Reescreva a struct e a funçãoinicializarpara uma versão dinâmica, chamadaListaContDin, em que o vetordadosé alocado commalloc()(como visto nas Aulas 1b e 3), permitindo que a capacidade da lista seja definida em tempo de execução, através de um parâmetro. Lembre-se de também escrever uma funçãodestruirque libere corretamente essa memória. -
Implemente uma função
void imprimir(ListaContEst *l)que percorra a lista e imprima o código, o nome e o preço de cada produto, um por linha. -
Implemente uma função
int buscarPorNome(ListaContEst *l, char *nome), análoga abuscar, mas que procure pelo camponome(usandostrcmp) em vez do campocod. Qual é a complexidade dessa nova função? -
Implemente uma função
float precoMedio(ListaContEst *l)que retorne o preço médio dos produtos da lista. Tome cuidado com o caso da lista vazia. -
Escreva um pequeno
main()que: inicialize umaListaContEst; insira três produtos (nas posições 0, 1 e 1, nessa ordem, como no exemplo de uso das aulas anteriores); e então chameimprimirpara exibir o resultado. Qual é a ordem final dos produtos na lista?
Sugestões de Respostas dos Exercícios
Exercício 1
Significa que a lista, enquanto estrutura, não precisa saber o que cada nodo guarda internamente (se é um int, uma struct como Produto, ou qualquer outro tipo). A lista se preocupa apenas com a organização e a ordem dos nodos; o conteúdo de cada nodo é um detalhe à parte, definido por quem usa a lista.
Exercício 2
x[0] é o primeiro nodo da lista, e x[n-1] é o último. Cada nodo intermediário x[k] é precedido por x[k-1] e sucedido por x[k+1], ou seja, existe uma relação de ordem bem definida entre eles.
n = 0 representa a lista vazia, ou seja, uma coleção sem nenhum nodo.
Exercício 3
Porque, nesta implementação, a lista sempre começa no índice 0 do vetor dados — diferente de uma versão que permitisse a lista "deslizar" pelo vetor, aqui não existe a possibilidade de o primeiro elemento estar em outra posição. Assim, sabendo apenas que existem tamanho elementos, já sabemos exatamente onde eles estão: nas posições 0 até tamanho - 1.
Exercício 4
Inserir na posição 0 é o pior caso porque todos os n elementos já existentes precisam ser deslocados uma posição para a direita, para abrir espaço no início. Já inserir na posição tamanho(l) (logo após o último elemento) é o melhor caso porque nenhum elemento precisa ser deslocado — o novo produto é simplesmente colocado na primeira posição livre do vetor.
Exercício 5
acessar é O(1) porque já recebe, como parâmetro, exatamente qual posição acessar — basta um acesso direto ao vetor (l->dados[pos]), sem percorrer nenhum outro elemento.
buscar, por outro lado, não sabe de antemão onde está o elemento procurado: no pior caso (elemento não encontrado, ou encontrado apenas na última posição), é preciso comparar com todos os n elementos da lista até encontrá-lo ou concluir que ele não existe — por isso O(n).
Exercício 6
Inserir sempre no final evita o deslocamento de elementos na inserção, desde que ainda haja espaço livre no vetor — nesse caso, a operação é O(1).
Porém, se também precisarmos remover com frequência do início da lista, cada remoção continuará custando O(n), pois é necessário deslocar os elementos restantes para preencher o espaço aberto. Ou seja, otimizar apenas a inserção não resolve o custo da remoção — esse é exatamente o tipo de situação em que uma implementação por encadeamento (vista em aulas futuras) tende a ser mais vantajosa.
Exercício 7
A struct passa a guardar um ponteiro para os produtos (em vez de um vetor de tamanho fixo) e a capacidade real da lista, definida em tempo de execução:
typedef struct { int tamanho; int capacidade; Produto *dados; // vetor alocado dinamicamente, em vez de tamanho fixo } ListaContDin;
A função inicializar agora recebe a capacidade desejada e aloca o vetor com malloc(), verificando a falha de alocação:
void inicializar(ListaContDin *l, int capacidade) { l->tamanho = 0; l->capacidade = capacidade; l->dados = (Produto *) malloc(capacidade * sizeof(Produto)); if (l->dados == NULL) { printf("Erro: memoria insuficiente.\n"); exit(1); } }
Todas as demais funções (inserir, acessar, remover, buscar) continuam praticamente iguais — basta trocar toda ocorrência da constante CAPACIDADE pelo campo l->capacidade. Por exemplo, a verificação de lista cheia em inserir passa a ser:
if (l->tamanho == l->capacidade) return 0; // lista cheia
Por fim, como agora existe memória alocada no heap, a função destruir precisa liberá-la de fato, e não apenas zerar o tamanho:
void destruir(ListaContDin *l) {
free(l->dados);
l->dados = NULL;
l->tamanho = 0;
}
Note que, mesmo com a mudança de "onde" o vetor mora (pilha, na versão estática; heap, na dinâmica), a forma de acessar os elementos continua idêntica (l->dados[pos]) — exatamente o que discutimos na Material Extra — Conceitos de Linguagens de Programação sobre vetores contíguos.
Exercício 8
void imprimir(ListaContEst *l) { int i; for (i = 0; i < tamanho(l); i++) { Produto p = acessar(l, i); printf("%d - %s - %.2f\n", p.cod, p.nome, p.preco); } }
Note que a função reaproveita tamanho e acessar, que já lidam com o acesso aos elementos — não é preciso acessar l->dados diretamente.
Exercício 9
int buscarPorNome(ListaContEst *l, char *nome) { int i; for (i = 0; i < l->tamanho; i++) { if (strcmp(l->dados[i].nome, nome) == 0) { return i; // posição onde o produto foi encontrado } } return -1; // não encontrado }
A complexidade continua sendo O(n), pelo mesmo motivo de buscar: no pior caso (produto não encontrado, ou encontrado apenas na última posição), é preciso comparar com todos os n elementos da lista — a única diferença é qual campo é comparado (nome, com strcmp, em vez de cod, com ==).
Exercício 10
float precoMedio(ListaContEst *l) { int i; float soma = 0; if (tamanho(l) == 0) { return 0; // evita divisão por zero } for (i = 0; i < tamanho(l); i++) { Produto p = acessar(l, i); soma += p.preco; } return soma / tamanho(l); }
O caso da lista vazia precisa de tratamento especial, já que dividir por tamanho(l) == 0 resultaria em uma divisão por zero.
Exercício 11
int main() { ListaContEst l; Produto p1 = {1, "Caneta", 2.50}; Produto p2 = {2, "Caderno", 15.90}; Produto p3 = {3, "Lapis", 1.20}; inicializar(&l); inserir(&l, p1, 0); // lista: [Caneta] inserir(&l, p2, 1); // lista: [Caneta, Caderno] inserir(&l, p3, 1); // lista: [Caneta, Lapis, Caderno] imprimir(&l); return 0; }
A ordem final é Caneta, Lapis, Caderno. A terceira inserção, na posição 1, empurra o "Caderno" (que estava na posição 1) uma posição para a direita, abrindo espaço para o "Lapis" exatamente no meio da lista.
Código para Download
O TAD ListaContEst completo, exatamente como visto nesta aula, dividido em interface (listaContEst.h) e implementação (listaContEst.c), junto com um main.c de exemplo:
listaContEst.zip — listaContEst.h, listaContEst.c e main.c.
|