Estrutura de Dados I
Listas encadeadas

Listas simplesmente encadeadas

Nós ligados por ponteiros, o TAD Lista com início, fim e tamanho, e as operações de inserir, buscar, remover, copiar e liberar.

O vetor é a forma mais simples de guardar vários valores: um bloco contíguo de memória, com acesso direto a qualquer posição por v[i]. Mas ele tem limitações:

  • o tamanho precisa ser escolhido de antemão: grande demais desperdiça memória, pequeno demais não cabe tudo (e aumentá-lo com realloc pode exigir copiar todos os elementos);
  • inserir ou remover no meio exige deslocar todos os elementos seguintes.

Uma lista encadeada resolve esses problemas alocando cada elemento separadamente, só quando ele é inserido. Como os elementos ficam espalhados pela memória, cada um guarda, junto com o seu valor, o endereço do próximo. A lista cresce e diminui um elemento por vez, e inserir ou remover um elemento não exige mover os outros, só religar ponteiros.

Uma struct que aponta para outra

Uma struct pode ter campos de qualquer tipo, inclusive ponteiros. E nada impede que o ponteiro aponte para outra struct do mesmo tipo:

typedef struct no {
    int dado;              /* a informação guardada */
    struct no *proximo;    /* o endereço do próximo nó */
} No;

Uma estrutura assim é chamada de autorreferenciada. Cada elemento da lista é um nó (node): um campo com o dado e um ponteiro para o nó seguinte. Repare que, dentro da própria struct, o tipo é escrito como struct no *: o nome No do typedef só passa a existir depois do }.

Por que um ponteiro para outro nó, e não um nó dentro do nó (struct no proximo;)?

Um nó que contivesse outro nó inteiro teria tamanho infinito: esse nó interno conteria outro, que conteria outro, e assim por diante. O compilador recusa (field has incomplete type). Um ponteiro tem tamanho fixo (8 bytes, em sistemas de 64 bits), qualquer que seja o tipo para o qual aponta, e só guarda o endereço do próximo nó.

Nós encadeados à mão

Antes de qualquer função, vale montar uma lista com três nós "na mão": alocar cada nó, preencher o dado e ligar cada um ao seguinte.

nos.cC
Três nós com os valores 2, 3 e 5; o ponteiro primeiro aponta para o nó 2, cada nó aponta para o seguinte e o último tem o campo proximo nulo
Três nós encadeados. O campo proximo do último nó vale NULL (o traço na célula): é assim que se sabe onde a lista termina.
Depois de ligar os nós, ainda precisamos das variáveis segundo e terceiro?

Não. A partir de primeiro, os outros são alcançados seguindo o encadeamento: primeiro->proximo->dado é o 3 e primeiro->proximo->proximo->dado é o 5. Ter o endereço do primeiro nó basta para chegar a todos os outros. Foi só para liberar a memória, no fim do programa, que as outras variáveis continuaram em uso.

Escrever primeiro->proximo->proximo->... para chegar ao centésimo nó não é prático. Precisamos de funções que façam isso, e de uma estrutura que guarde a lista: o TAD Lista.

O TAD Lista

A lista é representada por uma struct própria, que guarda o endereço do primeiro nó e o número de elementos. Quem usa a lista não mexe nos nós diretamente: usa as operações do TAD (veja Tipo Abstrato de Dados).

typedef struct lista {
    No *inicio;      /* primeiro nó; NULL se a lista está vazia */
    int tamanho;
} Lista;

Uma alternativa comum na literatura é representar a lista só pelo ponteiro para o primeiro nó (No *lista). Guardar uma struct Lista à parte deixa as funções mais simples (elas sempre recebem Lista *, mesmo quando o primeiro nó muda) e permite acrescentar campos úteis, como o tamanho e, mais adiante, o último nó.

O programa abaixo tem as operações básicas. Cada uma é explicada em seguida.

lista.cC

Criar

criaLista aloca a struct da lista e a deixa vazia: inicio = NULL e tamanho = 0. Toda lista precisa ser criada por essa função antes de ser usada.

Inserir no início

O jeito mais simples de inserir é colocar o novo nó antes do primeiro:

  1. alocar o novo nó e guardar o valor nele;
  2. fazer o novo nó apontar para o antigo primeiro (novo->proximo = lista->inicio);
  3. fazer a lista começar no novo nó (lista->inicio = novo).
Três etapas da inserção do valor 1 no início da lista 2, 3, 5: o novo nó é criado com proximo indefinido, depois passa a apontar para o nó 2, e por fim inicio passa a apontar para o novo nó
Inserir no início: o novo nó aponta para o antigo primeiro, e só então inicio passa a apontar para ele.

A ordem dos passos 2 e 3 importa. Fazendo o passo 3 primeiro, o endereço do antigo primeiro nó se perderia, e com ele o resto da lista.

Questão

Qual o custo de insereInicioLista em uma lista com elementos?

Percorrer

Para visitar todos os elementos (para imprimir, somar, procurar...), um ponteiro auxiliar começa no primeiro nó e avança pelo campo proximo até chegar a NULL:

for (No *atual = lista->inicio; atual != NULL; atual = atual->proximo)
    printf("%d -> ", atual->dado);
No vetor, percorremos com um índice inteiro. Por que aqui é com um ponteiro?

Os nós não estão em posições consecutivas da memória, então não existe a conta "endereço do início + × tamanho do elemento" que o vetor usa para achar v[i] diretamente. O único jeito de chegar ao próximo nó é ler o endereço guardado no nó atual. Por isso também não há acesso direto ao -ésimo elemento: é preciso andar desde o início.

Buscar

procuraLista percorre a lista até achar o valor e devolve o nó (não o valor), ou NULL se não achar. Devolver o nó permite, depois, alterar o dado ou remover aquele nó sem procurá-lo de novo. O mesmo pode ser escrito com while:

No *procuraLista(const Lista *lista, int valor) {
    No *atual = lista->inicio;
    while (atual != NULL && atual->dado != valor)
        atual = atual->proximo;
    return atual;           /* o nó encontrado, ou NULL */
}

A ordem dos testes no while importa: atual != NULL vem primeiro e, se for falso, o && nem avalia atual->dado. Na ordem inversa, o fim da lista causaria um acesso por ponteiro nulo.

Lista vazia

A lista está vazia quando não tem nenhum nó, isto é, quando lista->inicio == NULL:

int listaVazia(const Lista *lista) {
    return lista->inicio == NULL;
}
lista == NULL não é lista vazia

lista == NULL testa se o próprio ponteiro para a lista é nulo, ou seja, se a lista nem existe. Uma lista criada por criaLista e esvaziada continua existindo: lista aponta para a struct, e quem é NULL é o campo inicio. Confundir os dois testes é um erro comum (veja o Programa 3 dos exercícios).

Inserir no fim

Para inserir depois do último nó, é preciso achar o último: o nó cujo proximo é NULL. Se a lista estiver vazia, não há último, e o novo nó passa a ser o primeiro.

void insereFimLista(Lista *lista, int valor) {
    No *novo = malloc(sizeof(No));
    novo->dado = valor;
    novo->proximo = NULL;            /* será o último */
    if (lista->inicio == NULL) {
        lista->inicio = novo;
    } else {
        No *atual = lista->inicio;
        while (atual->proximo != NULL)
            atual = atual->proximo;
        atual->proximo = novo;
    }
    lista->tamanho++;
}

Andar até o fim custa . Na próxima seção, um campo a mais na struct deixa essa operação .

Liberar

Cada nó foi alocado com um malloc próprio, então cada um precisa do seu free, além da struct da lista. Ao liberar um nó, o endereço do próximo precisa ter sido guardado antes: depois do free, ler atual->proximo é acessar memória liberada.

void liberaLista(Lista *lista) {
    No *atual = lista->inicio;
    while (atual != NULL) {
        No *proximo = atual->proximo;   /* guarda antes de liberar */
        free(atual);
        atual = proximo;
    }
    free(lista);
}

Guardando também o fim

Com um ponteiro para o último nó na struct da lista, inserir no fim não exige mais percorrer nada:

typedef struct lista {
    No *inicio;      /* primeiro nó */
    No *fim;         /* último nó */
    int tamanho;
} Lista;
A struct Lista com os campos inicio, fim e tamanho igual a 3; inicio aponta para o nó 2 e fim aponta para o nó 5, o último da cadeia 2, 3, 5
Uma lista com os campos inicio, fim e tamanho.

O preço é que toda operação que muda a lista precisa manter inicio, fim e tamanho corretos. Na criação, os dois ponteiros começam em NULL. Nas inserções:

void insereInicioLista(Lista *lista, int valor) {
    No *novo = malloc(sizeof(No));
    novo->dado = valor;
    novo->proximo = lista->inicio;
    if (lista->fim == NULL)          /* lista vazia: o novo é também o último */
        lista->fim = novo;
    lista->inicio = novo;
    lista->tamanho++;
}

void insereFimLista(Lista *lista, int valor) {
    No *novo = malloc(sizeof(No));
    novo->dado = valor;
    novo->proximo = NULL;
    if (lista->fim == NULL)          /* lista vazia */
        lista->inicio = novo;
    else
        lista->fim->proximo = novo;  /* o antigo último aponta para o novo */
    lista->fim = novo;
    lista->tamanho++;
}
Por que insereInicioLista também mexe em fim, se insere sempre no início?

Quando a lista está vazia, o nó inserido é ao mesmo tempo o primeiro e o último. Sem atualizar fim nesse caso, ele continuaria NULL com um elemento na lista, e o próximo insereFimLista trataria a lista como vazia, perdendo esse elemento.

Com o campo tamanho sempre atualizado, saber quantos elementos a lista tem também passa a ser , sem contar os nós.

Encontrando o nó anterior

Em uma lista simplesmente encadeada, cada nó conhece o próximo, mas não o anterior. Para achar o anterior de um nó p, é preciso percorrer a lista desde o início, em :

/* Devolve o nó anterior a p, ou NULL se p é o primeiro.
   Supõe que p pertence à lista. */
No *anteriorNoLista(const Lista *lista, const No *p) {
    No *anterior = NULL;
    No *atual = lista->inicio;
    while (atual != p) {
        anterior = atual;
        atual = atual->proximo;
    }
    return anterior;
}
Uma suposição escondida

Se p não pertencer à lista, atual chega a NULL sem nunca ser igual a p, e a próxima volta do laço lê atual->proximo com atual nulo. A função não confere essa condição; por isso ela está escrita no comentário.

É por causa do anterior que remover é a operação mais delicada da lista simplesmente encadeada.

Removendo um elemento

Para tirar um nó p da lista, o nó anterior a ele precisa passar a apontar para o nó seguinte a ele. Por isso a busca anda com dois ponteiros juntos: p, no nó examinado, e anterior, no nó examinado antes dele (começa em NULL, porque o primeiro nó não tem anterior).

Lista 2, 3, 4, 5 com o nó 4 em vermelho; anterior aponta para o nó 3 e p para o nó 4; o ponteiro do nó 3 deixa de apontar para o 4 e passa a apontar para o 5
Remover p do meio: o anterior passa a apontar para o seguinte de p, e só então p é liberado.

Encontrado o nó, há quatro situações:

  1. único nó (é o início e o fim): a lista fica vazia, e inicio e fim voltam a NULL;
  2. primeiro nó: não há anterior para religar; inicio avança para p->proximo;
  3. último nó: o anterior vira o último (anterior->proximo = NULL e fim = anterior);
  4. nó do meio: anterior->proximo = p->proximo, pulando p.
/* Remove TODOS os nós com o valor dado */
void removeElementoLista(Lista *lista, int valor) {
    No *p = lista->inicio;
    No *anterior = NULL;
    while (p != NULL) {
        if (p->dado != valor) {      /* não é este: os dois avançam */
            anterior = p;
            p = p->proximo;
            continue;
        }
        if (p == lista->inicio && p == lista->fim) {    /* único nó */
            lista->inicio = lista->fim = NULL;
            free(p);
            p = NULL;
        } else if (p == lista->inicio) {                /* primeiro nó */
            lista->inicio = p->proximo;
            free(p);
            p = lista->inicio;
        } else if (p == lista->fim) {                   /* último nó */
            anterior->proximo = NULL;
            lista->fim = anterior;
            free(p);
            p = NULL;
        } else {                                        /* nó do meio */
            anterior->proximo = p->proximo;
            free(p);
            p = anterior->proximo;
        }
        lista->tamanho--;
    }
}

Repare que a função remove todas as ocorrências do valor: depois de remover um nó, o laço continua a partir do seguinte. Para remover só a primeira, bastaria sair do laço depois da remoção.

Por que tratar quatro casos, em vez de usar só a regra do meio?

A regra do meio, anterior->proximo = p->proximo, supõe que existe um anterior (falso para o primeiro nó) e não atualiza fim (necessário quando p é o último). O caso do único nó é a combinação dos dois: não tem anterior e é o último. Em todos os casos, os ponteiros são religados antes do free(p): liberar primeiro perderia o endereço do restante da lista.

Inserindo em ordem

Às vezes a lista precisa ficar sempre ordenada. Para inserir um valor no lugar certo, procura-se o primeiro nó com dado maior ou igual ao novo valor, de novo com dois ponteiros, e o novo nó entra entre anterior e p. As pontas reaproveitam as funções que já existem:

void insereOrdenadoLista(Lista *lista, int valor) {
    No *p = lista->inicio;
    No *anterior = NULL;
    while (p != NULL && p->dado < valor) {
        anterior = p;
        p = p->proximo;
    }
    if (anterior == NULL) {                  /* entra no início */
        insereInicioLista(lista, valor);
        return;
    }
    if (p == NULL) {                         /* entra no fim */
        insereFimLista(lista, valor);
        return;
    }
    No *novo = malloc(sizeof(No));           /* entra entre anterior e p */
    novo->dado = valor;
    novo->proximo = p;
    anterior->proximo = novo;
    lista->tamanho++;
}

A inserção ordenada custa no pior caso, porque pode percorrer a lista inteira.

Copiando uma lista

Para ter uma segunda lista com os mesmos valores, não basta criar outra struct Lista e copiar o ponteiro inicio: as duas compartilhariam os mesmos nós, e alterar ou liberar uma estragaria a outra (liberar as duas seria liberar cada nó duas vezes). Uma cópia independente cria nós novos, um para cada valor:

Lista *copiaLista(const Lista *lista) {
    Lista *copia = criaLista();
    for (No *atual = lista->inicio; atual != NULL; atual = atual->proximo)
        insereFimLista(copia, atual->dado);
    return copia;
}

Com insereFimLista em , a cópia custa . Com a versão antiga, que percorre a lista a cada inserção, custaria .

Tudo junto

O programa abaixo reúne a lista com inicio, fim e tamanho e todas as operações desta página. Experimente remover outros valores, inserir em ordem valores repetidos, ou alterar a cópia e imprimir as duas listas.

lista_completa.cC

Lista ou vetor?

OperaçãoVetor (com elementos)Lista simplesmente encadeada (com início, fim e tamanho)
Acessar o -ésimo
Inserir no início (desloca tudo)
Inserir no fim (se houver espaço)
Remover do início
Remover do fim (precisa do anterior)
Buscar um valor, ou se ordenado, mesmo ordenada
Tamanho

Além disso:

  • a lista cresce e diminui à vontade, enquanto houver memória; o vetor tem capacidade fixa (ou precisa ser realocado);
  • cada nó da lista gasta memória extra com o ponteiro, além do dado;
  • com elementos grandes, religar ponteiros é muito mais barato que mover os elementos de lugar;
  • os elementos do vetor ficam lado a lado na memória, o que aproveita melhor a cache do processador; percorrer um vetor costuma ser bem mais rápido que percorrer uma lista com o mesmo número de elementos.

Remover o último nó continua , mesmo com o ponteiro fim: é preciso achar o anterior a ele. É isso que a lista duplamente encadeada resolve.

Exercícios

Encontre e corrija o erro

Cada programa tem um erro. Execute, leia a saída e as mensagens, corrija e execute de novo.

Programa 1

Deveria inserir os valores mantendo a lista em ordem crescente.

ordenada.cC
Resposta

Falta o caso em que o novo valor é menor que todos e deve entrar no início. Ao inserir o 3, o laço nem anda: anterior continua NULL, e anterior->proximo = novo acessa um ponteiro nulo. Quando anterior é NULL, o novo nó deve virar o primeiro:

if (anterior == NULL)
    lista->inicio = novo;
else
    anterior->proximo = novo;

Programa 2

libera.cC
Resposta

liberaLista libera o nó e depois lê atual->proximo, que está dentro da memória já liberada (heap-use-after-free). O endereço do próximo precisa ser guardado antes do free:

No *proximo = atual->proximo;
free(atual);
atual = proximo;

Programa 3

esvazia.cC
Resposta

listaVazia testa lista == NULL, que nunca é verdade para uma lista criada. Depois de remover os três elementos, a função ainda diz que a lista não está vazia, e removePrimeiroLista acessa lista->inicio, que já é NULL. O teste certo é lista->inicio == NULL.

Implemente

Nos dois exercícios, Lista.h é a interface e ListaInterno.h define os campos (inicio, fim e tamanho); as funções básicas já estão prontas em Lista.c. Os testes conferem, além dos valores, se inicio, fim e tamanho continuam corretos.

Inverter a lista. Inverta a ordem dos elementos religando os próprios nós, sem criar nem liberar nenhum.

Inverter a listaC
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.

Intercalar listas ordenadas. Dadas duas listas em ordem crescente, construa uma terceira, também em ordem crescente, com os elementos das duas. Os testes usam listas com até 150 mil nós: inserir um a um em ordem, como insereOrdenadoLista, custaria e não termina a tempo.

Intercalar listas ordenadasC
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.
  • P. Feofiloff. O que é uma lista encadeada e como implementá-la. IME-USP.

Nesta página