Aula 6a - Listas Duplamente Encadeadas
Nesta aula estudamos uma variação da lista simplesmente encadeada (Aula 4): a lista duplamente encadeada, em que cada nodo conhece tanto o seu sucessor quanto o seu antecessor. Veremos como isso resolve algumas limitações da versão simples, ao custo de mais memória e mais cuidado ao manter os ponteiros consistentes.
1. Motivação: limitações da lista simplesmente encadeada
Na lista simplesmente encadeada (ListaEnc), cada nodo aponta apenas para o próximo. Isso traz algumas limitações que já sentimos na prática nas Aulas 4 e 5:
- Para percorrer a lista de trás para frente, não há como — só existe o ponteiro
prox, nunca um caminho de volta; - Para inserir no final (
inserirFim), é preciso percorrer a lista inteira até encontrar o último nodo, custandoO(n), mesmo sendo "só uma inserção"; - Para remover um nodo, mesmo que já tenhamos um ponteiro direto para ele, ainda precisamos percorrer a lista desde o início só para descobrir quem é o seu antecessor (o nodo
ant, necessário para "religar" a lista) — como fizemos emremoverPorCod.
A lista duplamente encadeada ataca exatamente esses três pontos, adicionando um segundo ponteiro a cada nodo.
2. Listas duplamente encadeadas
Em uma lista duplamente encadeada, cada nodo contém:
- Os dados propriamente ditos;
- Um ponteiro
proxpara o próximo nodo (como antes); - Um ponteiro
antpara o nodo anterior — a novidade desta aula.
ini fim
| |
v v
NULL <--[ant| dado |prox]<-->[ant| dado |prox]<-->[ant| dado |prox]--> NULL
O primeiro nodo tem ant == NULL, e o último tem prox == NULL — assim como na lista simples, esses valores continuam marcando as extremidades. A diferença é que agora também guardamos, na própria struct da lista, um ponteiro para o último nodo (fim), além do ponteiro para o primeiro (ini):
Por que guardar também fim? Sem um ponteiro direto para o último nodo, inserir no final continuaria exigindo percorrer a lista inteira, mesmo com os ponteiros ant — o problema não é "não saber voltar", é "não saber onde a lista termina" sem procurar. Guardando fim, resolvemos justamente a limitação de inserirFim ser O(n) na Aula 4.
|
3. TAD Lista Duplamente Encadeada (LDE)
Dados
Reaproveitamos o mesmo Produto das aulas anteriores:
typedef struct { int cod; char nome[50]; float preco; } Produto;
O nodo agora tem dois ponteiros:
typedef struct str_NodoD NodoD; struct str_NodoD { NodoD *ant; NodoD *prox; Produto dado; };
E a struct da lista guarda os dois extremos:
typedef struct {
NodoD *ini;
NodoD *fim;
} ListaDuplaEnc;
Operações
void inicializar(ListaDuplaEnc *l); void imprimir(ListaDuplaEnc *l); void imprimirInverso(ListaDuplaEnc *l); Produto acessar(ListaDuplaEnc *l, int cod); int inserirInicio(ListaDuplaEnc *l, Produto prod); int inserirFim(ListaDuplaEnc *l, Produto prod); int removerPorCod(ListaDuplaEnc *l, int cod); void destruir(ListaDuplaEnc *l); int tamanho(ListaDuplaEnc *l);
4. Implementação em C
inicializar
void inicializar(ListaDuplaEnc *l) {
l->ini = NULL;
l->fim = NULL;
}
imprimir e imprimirInverso
A grande vantagem prática do ponteiro ant aparece aqui: percorrer a lista de trás para frente é tão simples quanto percorrê-la para frente, bastando partir de l->fim e seguir por ant:
void imprimir(ListaDuplaEnc *l) { NodoD *aux = l->ini; while (aux != NULL) { printf("%d - %s - %.2f\n", aux->dado.cod, aux->dado.nome, aux->dado.preco); aux = aux->prox; } } void imprimirInverso(ListaDuplaEnc *l) { NodoD *aux = l->fim; while (aux != NULL) { printf("%d - %s - %.2f\n", aux->dado.cod, aux->dado.nome, aux->dado.preco); aux = aux->ant; } }
acessar
Igual à Aula 4 — a busca por código continua sendo sequencial, o ponteiro ant não ajuda a "pular" nodos:
Produto acessar(ListaDuplaEnc *l, int cod) { NodoD *aux = l->ini; Produto prod = {-1, "", 0.0f}; while (aux != NULL) { if (aux->dado.cod == cod) return aux->dado; aux = aux->prox; } return prod; }
inserirInicio
Além de encaixar o novo nodo antes do início, é preciso lembrar de duas atualizações que não existiam na lista simples: o ant do antigo início precisa passar a apontar para o novo nodo, e, se a lista estava vazia, o novo nodo também é o fim:
int inserirInicio(ListaDuplaEnc *l, Produto prod) { NodoD *novo = (NodoD*) malloc(sizeof(NodoD)); if (novo == NULL) return 0; novo->dado = prod; novo->ant = NULL; novo->prox = l->ini; if (l->ini != NULL) l->ini->ant = novo; // o antigo início passa a ter um antecessor else l->fim = novo; // lista estava vazia: novo nodo também é o fim l->ini = novo; return 1; }
inserirFim
Graças ao ponteiro l->fim, esta operação deixa de percorrer a lista inteira — vira uma inserção direta, assim como inserirInicio:
int inserirFim(ListaDuplaEnc *l, Produto prod) { NodoD *novo = (NodoD*) malloc(sizeof(NodoD)); if (novo == NULL) return 0; novo->dado = prod; novo->prox = NULL; novo->ant = l->fim; if (l->fim != NULL) l->fim->prox = novo; // o antigo fim passa a apontar para o novo nodo else l->ini = novo; // lista estava vazia: novo nodo também é o início l->fim = novo; return 1; }
removerPorCod
Aqui está o segundo grande ganho da lista duplamente encadeada: uma vez localizado o nodo a remover, não precisamos mais de um ponteiro auxiliar ant obtido durante a busca — o próprio nodo já sabe quem é o seu antecessor, através de aux->ant:
int removerPorCod(ListaDuplaEnc *l, int cod) { NodoD *aux = l->ini; while (aux != NULL && aux->dado.cod != cod) aux = aux->prox; if (aux == NULL) // não encontrado return 0; if (aux->ant != NULL) // existe antecessor: religa por ele aux->ant->prox = aux->prox; else // removendo o próprio início l->ini = aux->prox; if (aux->prox != NULL) // existe sucessor: religa por ele aux->prox->ant = aux->ant; else // removendo o próprio fim l->fim = aux->ant; free(aux); return 1; }
Comparando com a Aula 4. Em ListaEnc, precisávamos de dois ponteiros (ant e aux) andando juntos pela lista só para descobrir o antecessor. Aqui, ainda percorremos a lista para encontrar o nodo pelo código (isso não muda — a busca continua sendo O(n)), mas, uma vez encontrado, a remoção em si não depende mais de nenhum ponteiro auxiliar extra: aux->ant e aux->prox já bastam.
|
destruir
void destruir(ListaDuplaEnc *l) { NodoD *ant; NodoD *aux = l->ini; while (aux != NULL) { ant = aux; aux = aux->prox; free(ant); } l->ini = NULL; l->fim = NULL; }
tamanho
Assim como decidimos na Aula 4, não guardamos um contador na struct — tamanho continua sendo O(n):
int tamanho(ListaDuplaEnc *l) { NodoD *aux = l->ini; int contador = 0; while (aux != NULL) { contador++; aux = aux->prox; } return contador; }
5. Vantagens, desvantagens e complexidade
| Aspecto | Simplesmente encadeada (Aula 4) | Duplamente encadeada (esta aula) |
|---|---|---|
| Memória por nodo | 1 ponteiro (prox) + dado. |
2 ponteiros (ant, prox) + dado — um pouco mais de memória. |
| Percorrer de trás para frente | Não é possível diretamente. | O(n), partindo de l->fim e seguindo ant. |
Inserir no final (inserirFim) |
O(n) (percorre até achar o último nodo). |
O(1), graças ao ponteiro l->fim. |
| Remover um nodo já localizado | Exige ponteiro ant obtido durante a busca. |
O(1) a partir do nodo, via aux->ant. |
Buscar por código (acessar, removerPorCod) |
O(n) |
O(n) — sem diferença, a busca continua sequencial. |
Resumo
|
Exercícios
-
Por que a lista duplamente encadeada precisa de duas atualizações extras em
inserirInicio(oantdo antigo início, e possivelmentel->fim), que não existiam na versão simplesmente encadeada? -
Explique por que
inserirFimpassa a serO(1)na lista duplamente encadeada, mesmo sem alterar em nada a forma comoacessarouremoverPorCodlocalizam um nodo pelo código. -
Na função
removerPorCod, por que é necessário tratar separadamente os casos em que o nodo removido é o início (aux->ant == NULL) ou o fim (aux->prox == NULL) da lista? -
Se um nodo estiver "sozinho" na lista (é ao mesmo tempo o início e o fim), o que deve acontecer com
l->iniel->fimao removê-lo? Verifique se o código deremoverPorCodapresentado trata esse caso corretamente. -
Cite uma situação prática em que ser capaz de percorrer uma lista "de trás para frente" (com
imprimirInverso) é útil. -
Implemente uma função
int inserirAntes(ListaDuplaEnc *l, int cod, Produto prod)que insiraprodimediatamente antes do nodo cujo código écod, retornando1em caso de sucesso ou0caso o código não seja encontrado. Dica: use o ponteiroantdo nodo encontrado para "encaixar" o novo nodo entre os dois. -
Implemente uma função
Produto ultimoAcimaDe(ListaDuplaEnc *l, float valor)que percorra a lista de trás para frente (usandoimprimirInversocomo inspiração) e retorne o primeiro produto encontrado, nesse sentido, com preço maior quevalor. Caso nenhum seja encontrado, retorne um produto comcod = -1.
Sugestões de Respostas dos Exercícios
Exercício 1
Porque, além de o novo nodo passar a ser o início da lista (o que já acontecia na versão simples), agora existe um ponteiro ant que também precisa ficar correto: o nodo que era o início da lista precisa passar a apontar, através de ant, para o novo nodo — senão o encadeamento reverso ficaria quebrado logo na primeira inserção. Além disso, se a lista estava vazia, o novo nodo é ao mesmo tempo o início e o fim, então l->fim também precisa ser atualizado.
Exercício 2
Porque inserirFim não depende de buscar nada — ela só precisa saber onde está o último nodo, e isso já está disponível diretamente em l->fim, sem percorrer a lista. Já acessar e removerPorCod continuam precisando localizar um nodo específico pelo seu código, o que exige examinar os nodos um a um até encontrar (ou não) o código procurado — os ponteiros extras não ajudam nesse tipo de busca por conteúdo, apenas no acesso direto às duas pontas da lista.
Exercício 3
Porque o nodo do início não possui antecessor (aux->ant == NULL), então não há um aux->ant->prox válido para atualizar — nesse caso, é a própria struct da lista (l->ini) que precisa passar a apontar para o novo início. Da mesma forma, o nodo do fim não possui sucessor (aux->prox == NULL), então é l->fim quem precisa ser atualizado diretamente. Sem esses casos especiais, o código tentaria acessar campos de um ponteiro NULL.
Exercício 4
Nesse caso, tanto l->ini quanto l->fim devem passar a ser NULL após a remoção. O código apresentado já trata isso corretamente: como aux->ant == NULL (não há antecessor), a condição if (aux->ant != NULL) é falsa, então cai no else e faz l->ini = aux->prox, que também é NULL (pois aux->prox também é NULL, já que é o único nodo). O mesmo raciocínio, de forma simétrica, atualiza l->fim para aux->ant, também NULL.
Exercício 5
Um exemplo é o histórico de navegação de um navegador: os botões "voltar" e "avançar" precisam se mover pela sequência de páginas visitadas nos dois sentidos — algo natural de implementar com uma lista duplamente encadeada, em que a página atual é um nodo, e "voltar"/"avançar" apenas seguem ant/prox.
Exercício 6
int inserirAntes(ListaDuplaEnc *l, int cod, Produto prod) { NodoD *aux = l->ini; NodoD *novo; while (aux != NULL && aux->dado.cod != cod) aux = aux->prox; if (aux == NULL) // código não encontrado return 0; novo = (NodoD*) malloc(sizeof(NodoD)); if (novo == NULL) return 0; novo->dado = prod; novo->prox = aux; novo->ant = aux->ant; if (aux->ant != NULL) aux->ant->prox = novo; else l->ini = novo; // aux era o início: novo nodo passa a ser o início aux->ant = novo; return 1; }
A lógica é semelhante à de removerPorCod: primeiro localizamos o nodo de referência (aux), e depois tratamos separadamente o caso em que ele é o início da lista (sem antecessor).
Exercício 7
Produto ultimoAcimaDe(ListaDuplaEnc *l, float valor) { NodoD *aux = l->fim; Produto prod = {-1, "", 0.0f}; while (aux != NULL) { if (aux->dado.preco > valor) return aux->dado; aux = aux->ant; } return prod; }
Partir de l->fim e seguir por ant é exatamente o mesmo padrão usado em imprimirInverso — a diferença é que, em vez de imprimir cada elemento, paramos e retornamos assim que encontramos o primeiro produto (percorrendo de trás para frente) que satisfaz a condição.
Código para Download
O TAD ListaDuplaEnc completo, exatamente como visto nesta aula, dividido em interface (listaDuplaEnc.h) e implementação (listaDuplaEnc.c), junto com um main.c de exemplo:
listaDuplaEnc.zip — listaDuplaEnc.h, listaDuplaEnc.c e main.c.
|