Home > Tecnologia > Informática > Programação > C++ > Lista duplamente ligada com classes template (Parte I)

Lista duplamente ligada com classes template (Parte I)

Neste artigo vou exemplificar a implementação de uma lista duplamente ligada usando classes template em C++.

Pré-requisitos: Para melhor entender este artigo, deverá já ter algumas noções de listas em C++ e de classes template.

– Para uma introdução às listas em C++ e listas ligadas simples, consulte primeiro o artigo “Listas ligadas simples em C++“.
– Para uma introdução às listas duplamente ligadas em C++, consulte o artigo “Listas duplamente ligadas em C++“.
– Se precisar de rever alguma questão sobre o uso de classes template em C++, veja primeiro este artigo introdutório: “Classe template em C++“.

As classes para gestão de listas e semelhantes já existem na biblioteca STL (Standard Template Library) do C++. Porém, para estudantes ou curiosos, é importante perceber o mecanismo destas classes. Para tal, saber implementá-las é um bom caminho para melhor compreender esta matéria.

Vamos ver uma implementação simples que, apesar de não ser igual à das implementações da STL, irá permitir compreender o funcionamento destas listas em classes template e, até mesmo, poderá usar estas classes nos seus projectos. É importante ter em atenção que, como se trata de classes template, podemos ter qualquer tipo de dados nos nós. Por isso, a implementação não pode ser tão directa como nos exemplos que vimos anteriormente e irá exigir algum código adicional para usarmos os métodos destas classes.

Iremos ter 3 classes:

  • Classe “NoDuplo” – Esta classe irá definir um nó duplamente ligado
  • Classe “ListaDupla” – Esta classe fará a gestão de uma lista duplamente ligada
  • Classe “Iterador” – Esta classe permitirá iterar os vários nós da lista em ambos os sentidos

Mais uma vez, vou ignorar os “include” e outras instruções. No fim, poderá descarregar o código completo e comentado.

Vamos então começar por ver o código da classe “NoDuplo” que, por ser uma classe template, estará implementada num ficheiro “.h“:

template <class T>
class NoDuplo {
public:
     NoDuplo(const T &e = T(), NoDuplo *s = NULL, NoDuplo *a = NULL) : elemento(e), seguinte(s), anterior(a) {}
    ~NoDuplo() {}
    T elemento;
    NoDuplo *seguinte;
    NoDuplo *anterior;
};

Na linha 4, declaramos um construtor que, por omissão, irá construir um nó duplo com um elemento do tipo T (genérico), um apontador para o nó seguinte a NULL e um apontador para o nó anterior igualmente a NULL. Poderemos passar, no construtor, um dado elemento existente e os respectivos apontadores “seguinte” e “anterior”. Na linha 6, temos declarado um elemento do tipo T (genérico) e nas linhas 7 e 8 teremos os apontadores para os nós seguinte e anterior, respectivamente.

Antes de passarmos à lista, vejamos primeiro o iterador:

template <class T>
class Iterador {
private:
    NoDuplo<T> *noActual;
public:
    Iterador(NoDuplo<T> *no = NULL) : noActual(no) {}
    ~Iterador() {
        if (noActual==NULL) {
            noActual = NULL;
        }
    }
    NoDuplo<T> *noSeguinte() {
        if (noActual==NULL) {
            return NULL;
        }
        noActual = noActual->seguinte;
        return noActual;
    }
    NoDuplo<T> *noAnterior() {
        if (noActual==NULL) {
            return NULL;
        }
        noActual = noActual->anterior;
        return noActual;
    }
};

Na linha 4, temos um membro privado que será um apontador para o nó actual do iterador. Na linha 6, temos um construtor que deverá receber um dos nós da lista, ficando a  NULL se não for passado nenhum nó (mas isso fará com que o iterador não possa fazer nenhuma iteração). Na linha 12, declaramos o método “noSeguinte” que irá retornar NULL se já passámos da última posição. Caso contrário, avança para o nó seguinte e devolve esse nó. De forma semelhante, na linha 19, temos o método “noAnterior” que também irá devolver NULL se já passámos da primeira posição (para trás). Caso contrário, recua para o nó anterior e devolve esse nó.

Quanto ao código da classe “ListaDupla“, no ficheiro “ListaDupla.h”, por ser um pouco extenso, vou reparti-lo, indicando os métodos que ficarão disponíveis.

Comecemos pelos membros privados para a cabeça e para a cauda:

template <class T>
class ListaDupla {
private:
    NoDuplo<T> *cabeca;
    NoDuplo<T> *cauda;

Repare que, tanto a cabeça como a cauda, serão do tipo “NoDuplo” para um tipo genérico “T”.

Quanto ao construtor e destrutor:

public:
    ListaDupla() {
        cabeca = new NoDuplo<T>();
        cauda = new NoDuplo<T>();
    }
    ~ListaDupla() {
        limpar();
        delete cabeca;
        delete cauda;
    }

Repare que, no construtor (linha 2), estamos a declarar a cabeça e a cauda como nós! Isto quer dizer que o primeiro elemento da lista será apontado por “cabeca->seguinte” e o último elemento da lista por “cauda->anterior“. Repare também que estamos a usar o construtor de “NoDuplo” vazio e, como tal, iremos ter estes apontadores a NULL (a lista estará vazia).

No destrutor (linha 6), devemos limpar a lista para libertar todos os nós existentes da memória, e libertar também a cabeça e a cauda.



Vejamos os restantes métodos da classe, um a um:

– vazia()

    bool vazia() { return (cabeca->seguinte == NULL); }

Este método retorna “true” se a lista estiver vazia. Simplesmente, devolve o resultado da comparação “cabeca->seguinte == NULL”, que, ao ser verdade, indica que a lista está vazia.

– inserePrimeiro(T)

    void inserePrimeiro(T &e) {
    if (vazia()) {
            NoDuplo<T> *novoNo = new NoDuplo<T>(e);
            cabeca->seguinte = novoNo;
            cauda->anterior = novoNo;
        }
        else {
            NoDuplo<T> *primeiroActual = cabeca->seguinte;
            NoDuplo<T> *novoNo = new NoDuplo<T>(e, primeiroActual);
            primeiroActual->anterior = novoNo;
            cabeca->seguinte = novoNo;
        }
    }

Este método insere o elemento, passado como parâmetro, à cabeça da lista. Repare que devemos testar se a lista está vazia (linha 2) e, caso esteja, criamos um novo nó onde passamos, no construtor, apenas o elemento a inserir (linha 3), pois sabemos que, por omissão, os apontadores “seguinte” e “anterior” ficarão a NULL e é o que nos interessa para definir um primeiro elemento para a lista. Em seguida, na linha 4, alteramos o apontador “seguinte” da cabeça que irá apontar para o novo nó, que será o primeiro da lista e, na linha 5, alteramos o apontador “anterior” da cauda para este mesmo novo nó, pois também será o último.

Caso a lista não esteja vazia, no bloco “else” da linha 7, vamos buscar o apontador para o primeiro nó actual da lista (linha 8) e declaramos um apontador para o novo nó, passando o novo elemento e o apontador para o primeiro nó actual, que irá passar a ser o segundo (linha 9). Repare que, ao não passarmos um apontador para o nó anterior, este ficará NULL e é o que precisamos pois estamos a inserir um novo primeiro nó (inserção à cabeça). Em seguida, devemos alterar o apontador “anterior” no primeiro nó actual, para que este aponte para o novo primeiro nó (linha 10). Finalmente, na linha 11, o apontador “seguinte” da cabeça passará a apontar para o novo nó, que passará então a ser o novo primeiro nó da lista.

– InsereUltimo(T)

    void insereUltimo(T &e) {
        if (vazia()) {
            NoDuplo<T> *novoNo = new NoDuplo<T>(e);
            cabeca->seguinte = novoNo;
            cauda->anterior = novoNo;
        }
        else {
            NoDuplo<T> *ultimoActual = cauda->anterior;
            NoDuplo<T> *novoNo = new NoDuplo<T>(e, NULL, ultimoActual);
            ultimoActual->seguinte = novoNo;
            cauda->anterior = novoNo;
        }
    }

De forma análoga ao método anterior, este método insere o elemento, passado como parâmetro, à cauda da lista. Repare que, mais uma vez, na linha 2 testamos se a lista está vazia e, caso esteja, aplicamos o mesmo código que vimos no método “inserePrimeiro”, pois estamos a introduzir um primeiro elemento na lista.

Caso a lista não esteja vazia, no bloco “else” da linha 7, vamos agora buscar o apontador para o último nó actual da lista (linha 8) e voltamos a declarar um apontador para o novo nó (linha 9). Desta vez, passamos o elemento, passamos NULL para o apontador “seguinte” deste nó, pois irá ser um novo último nó (inserção à cauda), e passamos, para o apontador “anterior”, o último nó actual, que passará a ser o penúltimo nó. Desta vez, na linha 10, alteramos o apontador “seguinte” do último nó actual para o novo nó, para que este aponte para o novo último nó. Finalmente, na linha 11, o apontador “anterior” da cauda passará a apontar para este novo último nó.

 – insereAntesDe(NoDuplo<T> *, T)

    bool insereAntesDe(NoDuplo<T> *ref, T &e) {
        if (vazia()) {
            return false;
        }
        NoDuplo<T> *procuraRef = cabeca->seguinte;
        while (procuraRef!=NULL) {
            if (procuraRef==ref) {
                break;
            }
            procuraRef = procuraRef->seguinte;
        }
        if (procuraRef==NULL) {
            return false;
        }
        else {
            NoDuplo<T> *antesRef = procuraRef->anterior;
            NoDuplo<T> *novoNo = new NoDuplo<T>(e, procuraRef, antesRef);
            if (antesRef==NULL) {
                cabeca->seguinte = novoNo;
            }
            else {
                antesRef->seguinte = novoNo;
            }
            procuraRef->anterior = novoNo;
            return true;
        }
    }

Neste método, pretendemos inserir um novo elemento antes de um “ref”, que será um apontador para um nó de “referência” para o procurarmos na lista. Este método irá retornar “true” se o elemento for inserido com sucesso, ou “false” se o nó de referência não existir na lista ou se esta estiver vazia. Mas, supostamente, se temos um nó da lista, esta não deverá estar vazia, mas, por cautela e porque poderíamos definir um nó qualquer, devemos testar se a lista está vazia (linha 2) e procurar se este nó existe.

Para tal, na linha 5, iniciamos um apontador para procurar o tal nó de “referência” e, na linha 6, iniciamos um ciclo para o procurar. Se esse nó de “referência” for encontrado, saímos do ciclo com a instrução “break” da linha 8. Porém, o ciclo pode chegar ao fim sem ter encontrado o nó de referência. Para tal, testamos na linha 12, se o apontador usado na procura é NULL. Caso seja, indica que o nó de referência não foi encontrado e retorna “false“.

Caso contrário, entramos no bloco “else” da linha 15. Note que, neste momento, temos no apontador “procuraRef”, o mesmo apontador do nó de “referência” e que é o nó que sucede a posição onde queremos inserir o novo nó. Precisamos, agora, de definir um apontador para o nó que antecede o nó de referência (linha 16), que ficará antes do novo nó que queremos inserir.

Agora, na linha 17, e porque estamos a fazer uma possível inserção a meio da lista, declaramos o apontador para o novo nó com o novo elemento, o apontador para o nó de referência (que será o que irá suceder ao novo nó) para o apontador “seguinte” e o nó “antesRef” (que será o nó que irá anteceder o novo nó) para o apontador “anterior“. Uma observação importante! Se o nó “referência” for o primeiro nó, estaremos a fazer uma inserção à cabeça da lista e o apontador “antesRef” será NULL! Neste caso, quando declarámos o novo nó, o apontador “anterior” deste ficará automaticamente a NULL! Mas devemos testar este caso, na linha 18, e fazer uma inserção à cabeça, alterando o apontador “seguinte” desta para o novo nó (linha 19). Caso não seja o primeiro nó, o apontador “antesRef” estará realmente a apontar para o nó que irá anteceder o novo nó, e deveremos alterar o seu apontador “seguinte” para o novo nó. Finalmente, na linha 24, alteramos o apontador “anterior” do nó de referência para o novo nó e retornamos “true” pois o nó foi inserido e temos a integridade da lista garantida!

– inserirDepoisDe(NoDuplo<T> *, T)

    bool insereDepoisDe(NoDuplo<T> *ref, T &e) {
        if (vazia()) {
            return false;
        }
        NoDuplo<T> *procuraRef = cabeca->seguinte;
        while (procuraRef!=NULL) {
            if (procuraRef==ref) {
                break;
            }
            procuraRef = procuraRef->seguinte;
        }
        if (procuraRef==NULL) {
            return false;
        }
        else {
            NoDuplo<T> *depoisRef = procuraRef->seguinte;
            NoDuplo<T> *novoNo = new NoDuplo<T>(e, depoisRef, procuraRef);
            if (depoisRef==NULL) {
                cauda->anterior = novoNo;
            }
            else {
                depoisRef->anterior = novoNo;
            }
            procuraRef->seguinte = novoNo;
            return true;
        }
    }

Neste método, a principal diferença é que pretendemos inserir o novo nó depois do nó de “referência”. Não vou entrar em detalhes para este caso pois é praticamente igual ao caso anterior. A diferença está nos apontadores que, neste caso, o nó “referência” será o nó que irá anteceder o novo nó e a declaração de um nó “depoisRef” (linha 16) que será o nó que irá suceder ao novo nó. Porque podemos estar no fim da lista, na linha 18 também testamos se este nó “depoisRef” é NULL e fazemos uma inserção à cauda.

Para mais informações, veja os comentários do código fonte completo, cujo link para download está no final deste artigo.

– remove(NoDuplo<T> *)

    bool remove(NoDuplo<T> *r) {
        if (vazia()) {
            return false;;
        }
        NoDuplo<T> *remover = cauda->anterior;
        while (remover!=NULL) {
            if (remover==r) {
                break;
            }
            remover = remover->anterior;
        }
        if (remover==NULL) {
            return false;
        }
        else {
            NoDuplo<T> *depoisRemover = remover->seguinte;
            NoDuplo<T> *antesRemover = remover->anterior;
            if (depoisRemover==NULL) {
                cauda->anterior = antesRemover;
            }
            else {
                depoisRemover->anterior = antesRemover;
            }
            if (antesRemover==NULL) {
                cabeca->seguinte = depoisRemover;
            }
            else {
                antesRemover->seguinte = depoisRemover;
            }
            delete remover;
            return true;
        }
    }

Este método permite eliminar um determinado nó passado como parâmetro. A remoção do nó pode ser feita tanto à cabeça, como à cauda, ou a meio da lista. O método irá retornar “true” se o nó foi eliminado com sucesso ou “false” se a lista estiver vazia ou o nó não foi encontrado. Mais uma vez, é suposto que o nó exista, mas como podemos declarar um nó qualquer, devemos testar se a lista está vazia (linha 2) e procurar se o nó existe no ciclo “while” da linha 6. Para fazer a pesquisa, na linha 5, declaramos um apontador “remover” para procurar o nó a partir da cabeça. Se o nó for encontrado, a instrução “break” da linha 8 irá interromper o ciclo.

Após o ciclo terminar, na linha 12, verificamos se o nó “remover”, usado na pesquisa, é NULL. Se o for, quer dizer que o nó a eliminar não foi encontrado na lista e devolvemos “false“. Caso tenha sido encontrado, entramos no bloco “else” da linha 15.

Agora, declaramos 2 apontadores! Na linha 16, declaramos um apontador para o nó que está depois do nó que queremos remover (“depoisRemover”) e, na linha 17, um apontador para o nó que está antes do nó que queremos remover (“antesRemover”).

Porém, não sabemos em que parte da lista estamos e podemos estar a fazer uma remoção à cabeça ou à cauda. Para verificar isso, na linha 18, testamos se o apontador “depoisRemover” é NULL. Se assim for, estamos a remover o último nó da lista e estamos a fazer uma remoção à cauda! Como tal, deveremos alterar o apontador “anterior” da cauda para o nó que antecede o nó que queremos remover. Caso não seja o último nó, alteramos o apontador “anterior” de “depoisRemover” para o nó que antecede o nó que queremos remover. Agora, na linha 24, teremos que testar se estamos no primeiro nó da lista, se o apontador “antesRemover” for NULL. Se assim o for, estamos a fazer uma remoção à cabeça e temos que alterar o apontador “seguinte” desta para o nó “depoisRemover”. Se não for o primeiro nó, então alteramos o apontador “seguinte” do nó “antesRemover” para o nó “depoisRemover”.

Note ainda que, se ambos os apontadores “depoisRemover” e “antesRemover” estiverem a NULL, estamos a remover o único elemento da lista! Neste caso, ficaremos com ambos os apontadoresseguinte” da cabeça e “anterior” da cauda a NULL, indicando que a lista ficará vazia!

Finalmente, temos tudo pronto para libertar o nó que queremos remover da memória com a instrução “delete” da linha 30 e retornamos “true“.

– limpar()

    void limpar() {
        int contador = 0;
        while (cauda->anterior!=NULL) {
            NoDuplo<T> *remove = cauda->anterior;
            cauda->anterior = remove->anterior;
            delete remove;
            contador++;
        }
        std::cout << "Libertados da memória " << contador << " nós" << std::endl;
    }

Este método é muito simples e destina-se a libertar todos os nós da lista. Este método é chamado no destrutor da lista mas também pode ser chamado pelo programa, caso se pretenda limpar a lista. Como nos exemplos anteriores vimos este processo a partir da cabeça, neste exemplo vou fazer a remoção dos nós a partir da cauda (linha 3). Neste ciclo, declaramos um apontador “remove” para o último nó da lista (linha 4) e movemos a cauda para o seu nó anterior (linha 5). Finalmente, apagamos o nó a remover na linha 6. Este ciclo termina após apagar o último elemento, quando o apontador “anterior” da cauda ficar a NULL.

Por regra, as classes não escrevem nada no ecrã. Mas para o leitor ter a noção dos nós que foram libertados, foi implementado um contador e o seu valor é mostrado no fim do método, indicando quantos nós foram libertados da memória.

– obtemIteradorCabeca()

    Iterador<T> *obtemIteradorCabeca() {
        Iterador<T> *i = new Iterador<T>(cabeca);
        return i;
    }

Este método limita-se a retornar um apontador para um iterador iniciado à cabeça da lista.

– obtemIteradorCauda()

    Iterador<T> *obtemIteradorCauda() {
        Iterador<T> *i = new Iterador<T>(cauda);
        return i;
    }

Este método limita-se a retornar um apontador para um iterador iniciado à cauda da lista.



E chegámos ao fim das nossas classes que vão suportar a nossa lista duplamente ligada com classes template.

Na segunda parte deste artigo, iremos ver exemplos de utilização destas classes.

Lista duplamente ligada com classes template (Parte II)

Este artigo foi escrito de acordo com a antiga grafia.

Código fonte (UTF-8 ; OS X / UNIX):

About Carlos Santos

Frequência em mestrado de Engenharia Electrotécnica e de Computadores. Programador freelancer: Websites, Aplicações Web (JAVA e PHP), J2SE/J2EE, C/C++ e Android. Explicador e formador em informática, matemática e electrotecnia. Formação presencial ou remota através de Skype e Team Viewer. Interesses: Música, áudio, vídeo, ciência, astronomia e mitologia.

Leave a Reply

Your email address will not be published and it is optional.