INF01203 - Estruturas de Dados - Instituto de Informática (UFRGS) - Prof. Dennis Giovani Balreira



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 e x[n-1] é o último nodo;
  • x[k] é precedido por x[k-1] e sucedido por x[k+1];
  • n = 0 representa 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).

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

  • Lista linear: coleção ordenada e finita de nodos, onde cada um (exceto extremos) tem um predecessor e um sucessor bem definidos.
  • TAD vs. implementação: a lista é uma implementação concreta do TAD Lista; o cliente usa suas operações sem conhecer os detalhes internos, que são responsabilidade do programador.
  • Contiguidade física: os elementos ficam sempre a partir do índice 0 de um vetor, e um único campo tamanho indica quantas posições estão ocupadas.
  • Estática × dinâmica: a versão estática (padrão desta disciplina) usa Produto dados[CAPACIDADE];; a versão dinâmica usa Produto *dados;, alocado com malloc() — as duas compartilham a mesma interface.
  • Acesso é O(1) (acesso direto por índice), mas busca é O(n) (é preciso procurar, comparando elemento por elemento).
  • Inserção/remoção são O(n) no pior caso (deslocam elementos), e O(1) apenas quando ocorrem na última posição da lista.
  • Essa é apenas uma das formas de implementar uma lista — a outra, por encadeamento, será vista em aulas futuras.

Exercícios

  1. Explique, com suas próprias palavras, o que significa dizer que "a estrutura interna dos nodos é abstraída" em uma lista linear.
  2. Considerando a definição formal vista em aula (x[0], x[1], ..., x[n-1]), o que caracteriza x[0] e x[n-1]? O que representa n = 0?
  3. Na implementação por contiguidade física vista em aula, por que basta guardar o campo tamanho para saber quantos elementos a lista possui e onde ela termina, sem precisar de nenhuma outra variável de controle?
  4. Na função inserir, por que o pior caso ocorre ao inserir na posição 0, e o melhor caso ocorre ao inserir na posição tamanho(l) (ou seja, no final da lista)?
  5. Explique por que a operação acessar tem complexidade O(1), enquanto buscar tem complexidade O(n) no pior caso, mesmo as duas "lendo" elementos da lista.
  6. 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?
  7. 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ção inicializar para uma versão dinâmica, chamada ListaContDin, em que o vetor dados é alocado com malloc() (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ção destruir que libere corretamente essa memória.
  8. 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.
  9. Implemente uma função int buscarPorNome(ListaContEst *l, char *nome), análoga a buscar, mas que procure pelo campo nome (usando strcmp) em vez do campo cod. Qual é a complexidade dessa nova função?
  10. 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.
  11. Escreva um pequeno main() que: inicialize uma ListaContEst; insira três produtos (nas posições 0, 1 e 1, nessa ordem, como no exemplo de uso das aulas anteriores); e então chame imprimir para 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.ziplistaContEst.h, listaContEst.c e main.c.