Estrutura de Dados I
Listas encadeadas

Listas duplamente encadeadas

Nós com ponteiros para o próximo e para o anterior. Inserção, remoção sem procurar o anterior e percurso nos dois sentidos.

Na lista simplesmente encadeada, cada nó só conhece o seguinte. Isso traz duas limitações:

  • não dá para percorrer a lista de trás para frente de forma eficiente;
  • para remover um nó, mesmo tendo um ponteiro para ele, é preciso percorrer a lista desde o início para achar o anterior, em . Pelo mesmo motivo, remover o último nó custa , mesmo com o ponteiro fim.

Em uma lista duplamente encadeada, cada nó guarda dois ponteiros: um para o próximo e outro para o anterior. Dado um nó, os dois vizinhos estão a um passo.

Estrutura

typedef struct no {
    int dado;
    struct no *proximo;
    struct no *anterior;
} No;

typedef struct lista {
    No *inicio;
    No *fim;
    int tamanho;
} Lista;

A struct da lista é a mesma da lista simplesmente encadeada, com inicio, fim e tamanho. No primeiro nó, anterior é NULL; no último, proximo é NULL.

A struct Lista com inicio, fim e tamanho 3; três nós 2, 3 e 5, cada um com as células anterior, dado e proximo; as setas ligam cada nó ao seguinte e ao anterior, e o anterior do primeiro e o proximo do último são nulos
Lista duplamente encadeada: cada nó aponta para o próximo e para o anterior.
Dois ponteiros para manter

Toda inserção e toda remoção precisam acertar os dois sentidos: o proximo de um vizinho e o anterior do outro. Se um deles ficar errado, a lista funciona em um sentido e quebra no outro, e o erro só aparece quando alguém percorre a lista ao contrário.

Inserindo

Na inserção no início, o novo nó não tem anterior, e o seu próximo é o antigo primeiro. Se a lista não estava vazia, o antigo primeiro passa a ter o novo nó como anterior; se estava, o novo nó é também o último.

void insereInicioLista(Lista *lista, int valor) {
    No *novo = malloc(sizeof(No));
    novo->dado = valor;
    novo->proximo = lista->inicio;
    novo->anterior = NULL;               /* malloc não zera a memória */
    if (lista->fim == NULL)              /* lista vazia */
        lista->fim = novo;
    else
        lista->inicio->anterior = novo;  /* o antigo primeiro aponta de volta */
    lista->inicio = novo;
    lista->tamanho++;
}

A inserção no fim é simétrica: o novo nó não tem próximo, e o seu anterior é o antigo último.

void insereFimLista(Lista *lista, int valor) {
    No *novo = malloc(sizeof(No));
    novo->dado = valor;
    novo->proximo = NULL;
    novo->anterior = lista->fim;
    if (lista->fim == NULL)              /* lista vazia */
        lista->inicio = novo;
    else
        lista->fim->proximo = novo;
    lista->fim = novo;
    lista->tamanho++;
}
novo->anterior = NULL parece óbvio. O que acontece se essa linha for esquecida?

malloc não zera a memória: o campo anterior fica com o lixo que estava ali. A lista funciona normalmente percorrida do início para o fim, mas quem percorre de trás para frente chega ao primeiro nó e segue um ponteiro inválido em vez de parar. Veja o Programa 2 dos exercícios.

Removendo

Com o ponteiro para o nó p, os dois vizinhos são p->anterior e p->proximo. Para tirar p da lista, cada vizinho passa a apontar para o outro:

p->anterior->proximo = p->proximo;
p->proximo->anterior = p->anterior;

Nas pontas, um dos vizinhos não existe. Se p é o primeiro, quem muda é lista->inicio; se é o último, lista->fim. Cada sentido é tratado separadamente:

/* Remove a primeira ocorrência do valor */
void removeElementoLista(Lista *lista, int valor) {
    No *p = procuraLista(lista, valor);
    if (p == NULL)
        return;

    if (p == lista->inicio)              /* quem vem antes de p? */
        lista->inicio = p->proximo;
    else
        p->anterior->proximo = p->proximo;

    if (p == lista->fim)                 /* quem vem depois de p? */
        lista->fim = p->anterior;
    else
        p->proximo->anterior = p->anterior;

    free(p);
    lista->tamanho--;
}

Compare com a remoção na lista simplesmente encadeada: não há mais um ponteiro anterior acompanhando a busca, nem quatro casos. Os dois if cobrem também o nó único (que é início e fim), deixando a lista vazia. Depois de procuraLista, a remoção em si é .

Percorrendo nos dois sentidos

Do início para o fim, o percurso é igual ao da lista simplesmente encadeada. Do fim para o início, basta começar em lista->fim e seguir os ponteiros anterior:

void imprimeReversoLista(const Lista *lista) {
    printf("NULL <-> ");
    for (No *p = lista->fim; p != NULL; p = p->anterior)
        printf("%d <-> ", p->dado);
    printf("NULL\n");
}

O programa abaixo reúne as operações. Imprimir a lista nos dois sentidos depois de cada alteração é uma boa forma de conferir se os dois encadeamentos continuam consistentes.

lista_dupla.cC

Quanto custa o ponteiro extra?

OperaçãoSimplesmente encadeadaDuplamente encadeada
Inserir no início ou no fim
Remover do início
Remover do fim
Remover um nó, dado o ponteiro para ele (busca o anterior)
Achar o anterior de um nó
Percorrer de trás para frente ou memória extra
Memória por nódado + 1 ponteirodado + 2 ponteiros

O custo é um ponteiro a mais por nó (8 bytes em sistemas de 64 bits) e mais um campo para manter correto em cada operação. Em troca, as operações nas duas pontas e a remoção de um nó conhecido ficam todas .

Questão

Um programa precisa inserir e remover elementos frequentemente nas duas pontas de uma sequência (como um histórico em que se pode voltar e avançar). Qual estrutura atende todas essas operações em ?

Exercícios

Encontre e corrija o erro

Programa 1

remove.cC
Resposta

A remoção acerta o proximo do vizinho de trás, mas não o anterior do vizinho da frente: falta

else
    p->proximo->anterior = p->anterior;

Depois de remover o 5, o nó 7 continua com anterior apontando para o nó liberado. A lista parece certa percorrida do início para o fim, mas, ao remover o 7, a função escreve em p->anterior->proximo, dentro do nó já liberado (heap-use-after-free).

Programa 2

insere.cC
Resposta

insereInicioLista não inicializa novo->anterior. O nó 2 foi inserido no início e ficou com lixo nesse campo; percorrendo de trás para frente, imprimeReversoLista chega ao 2 e segue esse ponteiro inválido em vez de parar em NULL. (O endereço 0xbebebebebebebebe da mensagem é o padrão que o AddressSanitizer escreve na memória recém-alocada, justamente para tornar esse erro visível.) Falta novo->anterior = NULL;.

Implemente

Nos dois exercícios, os campos estão em ListaInterno.h e as funções básicas (criar, inserir no fim, imprimir nos dois sentidos, procurar, liberar) já estão prontas em Lista.c.

Lista palíndromo. Verifique se a sequência de valores é a mesma lida nos dois sentidos, andando ao mesmo tempo a partir das duas pontas, sem copiar a lista para um vetor.

Lista palíndromoC
Executa o seu código uma vez, com os dados de entrada abaixo. Serve para experimentar; não conta como resposta.Executa o seu código em todos os casos de teste (os de exemplo e os ocultos) e mostra quais passaram.

Remover um nó em O(1). Dado um ponteiro para um nó da lista, remova-o sem percorrer a lista. Os testes fazem 100 mil remoções seguidas: procurar o nó ou o anterior a cada uma não termina a tempo.

Remover um nó em O(1)C
Executa o seu código uma vez, com os dados de entrada abaixo. Serve para experimentar; não conta como resposta.Executa o seu código em todos os casos de teste (os de exemplo e os ocultos) e mostra quais passaram.

Referências

  • W. Celes, R. Cerqueira e J. L. Rangel. Introdução a Estruturas de Dados: com técnicas de programação em C. 2ª ed. Grupo GEN, 2016.
  • A. Backes. Estrutura de Dados Descomplicada em Linguagem C. Elsevier, 2016.

Nesta página