Re:Loop

Implementando uma Lista Ligada Dupla

6 min de leitura

Introdução

Se você acompanhou a nossa implementação da Lista Ligada Simples (Singly Linked List), viu como o encadeamento dinâmico de nós resolve problemas de tamanho fixo que encontramos nos Arrays tradicionais. No entanto, a lista simples possui uma limitação clara: só podemos navegar em uma única direção, da cabeça (head) para a cauda (tail). Se estivermos no meio da lista e precisarmos acessar o elemento anterior, teremos que recomeçar a busca desde o início.

É para resolver esse problema que entra em cena a Lista Duplamente Ligada (Doubly Linked List). Nela, cada nó possui conhecimento tanto do seu sucessor quanto do seu antecessor. Utilizamos um dos pilares da Orientação a Objetos: a Herança. para estender a nossa classe anterior (SLinkedList) e re-aproveitar toda a lógica que já funciona, sobrescrevendo apenas os métodos que precisam lidar com as novas referências.

Evoluindo os Nós: A classe DNode

Na nossa lista simples, utilizamos a classe Node, que guardava o valor e a referência para o próximo nó (next). Para a lista dupla, precisamos de uma evolução desse nó, adicionando uma referência para o nó anterior (prev).

Em vez de recriar tudo do zero, criamos a classe DNode que herda de Node:

package structures.linkedList;

public class DNode<E> extends Node<E> {
    private DNode<E> prev;

    public DNode(E element, DNode<E> prev) {
        super(element);
        this.prev = prev;
    }

    public DNode<E> getPrev() {
        return prev;
    }

    public void setPrev(DNode<E> prev) {
        this.prev = prev;
    }
}

A grande novidade aqui é a referência prev (previous/anterior). Note como o construtor utiliza o super(element) para delegar a inicialização do valor e do next para a classe pai, focando apenas em configurar o ponteiro anterior.

A Estrutura DLinkedList e a introdução do Tail (Cauda)

Agora vamos à estrutura principal. Nossa DLinkedList irá herdar de SLinkedList. Ao fazermos isso, herdamos automaticamente o head e o count (que foram declarados como protected na classe pai, permitindo esse acesso). Porém, com a lista dupla, ganhamos um superpoder: a capacidade de manter uma referência direta para o último elemento da lista, que chamaremos de tail (cauda).

package structures.linkedList;

public class DLinkedList<E> extends SLinkedList<E> {
    private DNode<E> tail;

    public DLinkedList() {
        super();
        tail = null;
    }

Manter o tail nos traz uma vantagem gigantesca de performance: inserir ou remover no final da lista agora é uma operação imediata, sem precisarmos percorrer todos os nós desde o head.

Inserindo elementos: Sobrescrevendo o método Insert

Como a manipulação de referências agora é dupla (temos que conectar a “ida” e a “volta”), a lógica de inserção muda. Por isso, utilizamos a anotação @Override para sobrescrever o método insert da classe pai.

Como a manipulação de referências agora é dupla (temos que conectar a “ida” e a “volta”), a lógica de inserção muda. Por isso, utilizamos a anotação @Override para sobrescrever o método insert da classe pai.

    @Override
    public void insert(int index, E element) throws IndexOutOfBoundsException {
        if (index < 0 || index > this.size()) throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + this.size());

        DNode<E> node = new DNode<>(element, null);

        if(index == 0){
            if(this.getHead() == null){
                this.head = node;
                this.tail = node;
            } else {
                DNode<E> current = (DNode<E>) this.getHead();
                node.setNext(current);
                current.setPrev(node);
                this.head = node;
            }
        } else if(index == this.size()){
            DNode<E> current = this.tail;
            current.setNext(node);
            node.setPrev(current);
            this.tail = node;
        } else {
            DNode<E> previous = (DNode<E>) this.getElementAt(index - 1);
            DNode<E> current = (DNode<E>) previous.getNext();

            node.setNext(current);
            previous.setNext(node);
            current.setPrev(node);
            node.setPrev(previous);
        }
        this.count++;
    }

Dividimos essa lógica em três cenários práticos:

  • Cenário 1 (Inserção no início - index 0): Se a lista estiver vazia (head == null), o novo nó será tanto a cabeça quanto a cauda. Se já houver elementos, o novo nó aponta para o antigo head, e o prev do antigo head aponta para o novo nó, que se torna a nova cabeça.
Nó
  • Cenário 2 (Inserção no final - index == size): Pegamos a cauda atual, fazemos o next dela apontar para o novo nó, o prev do novo nó apontar para a cauda antiga, e atualizamos o ponteiro tail para esse novo último elemento.
Nó
  • Cenário 3 (Inserção no meio): Buscamos o nó anterior (previous) e o nó atual daquela posição (current). O novo nó é encaixado no meio conectando as quatro pontas: o seu next e prev, além de atualizar o next do previous e o prev do current.
Nó

Removendo elementos: Sobrescrevendo o método RemoveAt

A remoção exige o mesmo cuidado redobrado com as conexões duplas para não deixarmos nós “perdidos” na memória.

    @Override
    public E removeAt(int index) throws IndexOutOfBoundsException {
        if (index < 0 || index >= this.count) throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + this.count);

        DNode<E> current = (DNode<E>) this.head;

        if(index == 0){
            this.head = current.getNext();
            if(this.count == 1){
                this.tail = null;
            } else {
                ((DNode<E>) this.head).setPrev(null);
            }
        } else if(index == this.count - 1) {
            current = this.tail;
            this.tail = current.getPrev();
            if(this.tail != null) {
                this.tail.setNext(null);
            }
        } else {
            current = (DNode<E>) this.getElementAt(index);
            DNode<E> previous = current.getPrev();
            DNode<E> nextNode = (DNode<E>) current.getNext();
            previous.setNext(current.getNext());
            nextNode.setPrev(previous);
        }

        this.count--;
        return current.getValue();
    }

Analisando os cenários:

  • Cenário 1 (Remoção do início - index 0): O head avança para o próximo nó. Atenção ao detalhe crucial: se a lista só tinha 1 elemento (count == 1), ao removermos, a lista fica vazia, logo o tail também deve virar null. Caso contrário, garantimos que o prev do novo head seja desconectado (null).
Nó
  • Cenário 2 (Remoção do final - index == count - 1): Não precisamos iterar a lista toda. Basta acessar o tail, pegar o nó anterior a ele (current.getPrev()), elegê-lo como o novo tail e cortar sua conexão next.
Nó
  • Cenário 3 (Remoção no meio): Identificamos o nó que será removido (current). Para isolá-lo, pegamos o seu antecessor e o fazemos apontar diretamente para o seu sucessor, e vice-versa. Assim, o current é excluído da corrente.
Nó

Acessando a Cauda (getTail)

Adicionamos também o método getTail(), que nos permite inspecionar diretamente a cauda da lista. Casando com o que temos em nossa lista ligada simples, o metodo getHead() que nos permite acessar a cabeça da lista, como parte da herança temos acesso a esse e todos os outros metodos como: getElementAt, indexOf, size, isEmpty, toString sem que precisemos alterar a estrutura dos métodos feitos na estrutura anterior.

    public DNode<E> getTail() {
        return this.tail;
    }

Conclusão

Implementar uma Lista Duplamente Ligada nos mostra como pequenas adições — como o ponteiro prev e a referência tail — podem otimizar a performance da nossa estrutura, permitindo navegação bidirecional e operações quase instantâneas no final da lista.

Apesar de exigir um pouco mais de memória e atenção redobrada para não quebrar as referências durante as inserções e remoções, o ganho de flexibilidade compensa o esforço. Além disso, ao utilizarmos a Herança, provamos que é possível criar estruturas complexas escrevendo menos código e reaproveitando lógicas já validadas.

O código implementado nesta publicação está disponivel no seguinte repositório: https://github.com/ThalesSilva67/data-structure

Referências

  • GOODRICH, Michael T.; TAMASSIA, Roberto. Estruturas de Dados e Algoritmos em Java. 5. ed. Porto Alegre: Bookman, 2013.
  • GRONER, Loiane. Estruturas de dados e algoritmos com JavaScript: escreva um código JavaScript complexo e eficaz usando os recursos mais recentes do ECMAScript. 2. ed. São Paulo: Novatec Editora, 2019. 408 p