INF01203 - Estruturas de Dados - Instituto de Informática (UFRGS) - Prof. Dennis Giovani Balreira



Aula 6b - Listas Circulares

Nesta aula vemos uma segunda variação sobre as listas encadeadas: a lista circular, em que o último nodo, em vez de apontar para NULL, aponta de volta para o primeiro — fechando a lista em um "círculo".


1. A ideia central

Em uma lista circular (a partir da simplesmente encadeada da Aula 4), o último nodo aponta para o primeiro em vez de para NULL:

l.ini
  |
  v
+-------+     +-------+     +-------+
| Dados | --> | Dados | --> | Dados | --+
+-------+     +-------+     +-------+   |
    ^                                    |
    +------------------------------------+

Não existe mais um nodo cujo prox seja NULL — todos os nodos apontam para algum outro nodo da lista, inclusive o último, que aponta de volta para o início. Isso muda a forma como percorremos a lista: não podemos mais usar aux != NULL como condição de parada, pois esse caso nunca ocorre.

Cuidado. Percorrer uma lista circular com a mesma condição de parada de uma lista comum (while (aux != NULL)) resulta em um laço infinito — o ponteiro nunca chega a NULL, pois sempre há um próximo nodo.

2. Percorrendo uma lista circular

A forma usual de percorrer uma lista circular é comparar o ponteiro auxiliar com o próprio início da lista, parando quando voltarmos a ele. Como a comparação só faz sentido depois de avançar pelo menos uma vez, usamos um do-while em vez de um while:

void imprime(ListaCirc *l) {
    Nodo *aux;

    if (l->ini == NULL) // lista vazia: nada a imprimir
        return;

    aux = l->ini;
    do {
        printf("%d - %s - %.2f\n", aux->dado.cod, aux->dado.nome, aux->dado.preco);
        aux = aux->prox;
    } while (aux != l->ini);
}

Note o teste explícito de lista vazia antes do do-while: como o corpo de um do-while sempre executa pelo menos uma vez, seria necessário garantir que l->ini não é NULL antes de acessar aux->dado.


3. Inserção em uma lista circular

A principal diferença em relação à Aula 4 é que, ao inserir um novo nodo, o último nodo da lista (e não NULL) precisa apontar para o novo início — ou, de forma equivalente, o novo nodo precisa apontar de volta para o início da lista:

int insereInicio(ListaCirc *l, Produto prod) {
    Nodo *novo = (Nodo*) malloc(sizeof(Nodo));
    Nodo *ultimo;

    if (novo == NULL)
        return 0;

    novo->dado = prod;

    if (l->ini == NULL) { // lista vazia: o novo nodo aponta para si mesmo
        novo->prox = novo;
    } else {
        ultimo = l->ini;
        while (ultimo->prox != l->ini) // percorre até achar quem aponta para o início
            ultimo = ultimo->prox;

        novo->prox = l->ini;
        ultimo->prox = novo;
    }

    l->ini = novo;
    return 1;
}
Observação. Assim como na lista simplesmente encadeada da Aula 4, localizar o último nodo aqui custa O(n). Assim como discutimos na Aula 6a, guardar também um ponteiro para o último nodo na struct da lista resolveria esse problema — a mesma ideia se aplica a uma lista circular.

4. Aplicações

Listas circulares são úteis sempre que o conceito de "início" e "fim" não faz muito sentido, e o que importa é poder percorrer indefinidamente os elementos, voltando ao começo depois do último:

  • Escalonamento round-robin: um sistema operacional distribuindo tempo de processador entre processos, sempre voltando ao primeiro processo da fila após passar pelo último;
  • Buffers circulares: estruturas que reaproveitam posições já utilizadas, comuns em sistemas de streaming e comunicação;
  • Jogos por turno: alternar entre jogadores de forma cíclica (jogador 1, 2, 3, ..., voltando ao jogador 1).

Resumo

  • Lista circular: o último nodo aponta de volta para o início, em vez de para NULL.
  • Percurso: usa-se um do-while, comparando o ponteiro auxiliar com o início da lista (aux != l->ini) como condição de parada — nunca aux != NULL.
  • Cuidado com laços infinitos: esquecer de adaptar a condição de parada é o erro mais comum ao lidar com listas circulares.
  • Aplicações típicas: escalonamento round-robin, buffers circulares, jogos por turno — qualquer cenário onde faz sentido "voltar ao início" repetidamente.
  • É possível combinar os dois conceitos vistos nesta unidade: uma lista pode ser, ao mesmo tempo, duplamente encadeada e circular.

Exercícios

  1. Por que a condição while (aux != NULL), usada para percorrer listas na Aula 4, nunca é verdadeira em uma lista circular?
  2. Por que a função imprime desta aula verifica l->ini == NULL antes do do-while, em vez de deixar essa verificação apenas na condição de parada do laço?
  3. Cite um cenário (diferente dos vistos em aula) em que uma lista circular seria uma escolha natural, e explique por quê.
  4. Implemente uma função int tamanho(ListaCirc *l) que retorne o número de nodos de uma lista circular, tomando cuidado para não entrar em laço infinito.

Sugestões de Respostas dos Exercícios

Exercício 1

Porque, em uma lista circular, não existe nenhum nodo cujo prox seja NULL — mesmo o último nodo aponta de volta para o início da lista. Como a condição aux != NULL nunca se torna falsa, um laço que dependa dela nunca termina.


Exercício 2

Porque o corpo de um do-while sempre é executado pelo menos uma vez, antes mesmo de a condição ser testada. Se a lista estivesse vazia (l->ini == NULL) e não houvesse essa verificação antes, a primeira iteração tentaria acessar aux->dado com aux valendo NULL, o que é um erro.


Exercício 3

Um exemplo é um carrossel de imagens (slideshow) em um site: ao chegar na última imagem e clicar em "próxima", o usuário espera voltar para a primeira, e não receber um erro de "fim da lista". Representar as imagens como uma lista circular torna essa navegação natural, sem precisar de tratamento especial para o caso de "chegar ao fim".


Exercício 4
int tamanho(ListaCirc *l) {
    Nodo *aux;
    int contador = 0;

    if (l->ini == NULL)
        return 0;

    aux = l->ini;
    do {
        contador++;
        aux = aux->prox;
    } while (aux != l->ini);

    return contador;
}

O padrão é o mesmo de imprime: trata o caso da lista vazia separadamente, e usa do-while com aux != l->ini como condição de parada, evitando o laço infinito discutido no Exercício 1.