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



Material Extra - Listas Recursivas

Para fechar esta unidade sobre listas encadeadas, vamos olhar para elas sob uma perspectiva diferente: uma lista encadeada é, por natureza, uma estrutura recursiva. Veremos como isso permite reescrever as operações vistas na Aula 4 usando recursão em vez de laços.


1. Por que uma lista é recursiva?

Lembrando a Aula 4: uma lista simplesmente encadeada representada por Nodo *lista é sempre uma destas duas coisas:

  • a lista vazia (lista == NULL); ou
  • um nodo, contendo um dado e um ponteiro prox que é, ele mesmo, o início de outra lista (menor).

Essa definição é praticamente idêntica à ideia de recursão vista no laboratório de revisão de linguagem C (Material Extra, contarAprovados): um caso base (a lista vazia) e um caso recursivo (um nodo mais uma lista menor). Isso sugere que qualquer operação sobre listas pode ser escrita de forma recursiva, tratando o prox de um nodo como "o restante da lista".

Nota. Nesta aula, por simplicidade, voltamos a representar a lista diretamente como Nodo *lista (sem a struct ListaEnc da Aula 4) — a "Observação" ao final da Aula 4 mostra por que isso muda a forma como as funções alteram o início da lista (via retorno, aqui).

2. Operações recursivas sobre listas

tamanho

Comparando com a versão iterativa da Aula 4:

// Versão iterativa (Aula 4)
int tamanho(Nodo *lista) {
    Nodo *aux = lista;
    int contador = 0;

    while (aux != NULL) {
        contador++;
        aux = aux->prox;
    }

    return contador;
}
// Versão recursiva
int tamanho(Nodo *lista) {
    if (lista == NULL) // caso base: lista vazia tem tamanho 0
        return 0;

    return 1 + tamanho(lista->prox); // caso recursivo: 1 (este nodo) + o restante
}

imprime

void imprime(Nodo *lista) {
    if (lista == NULL) // caso base: nada a imprimir
        return;

    printf("%d - %s - %.2f\n", lista->dado.cod, lista->dado.nome, lista->dado.preco);
    imprime(lista->prox); // caso recursivo: imprime o restante da lista
}

consulta

Produto consulta(Nodo *lista, int cod) {
    Produto prod = {-1, "", 0.0f};

    if (lista == NULL) // caso base: não encontrado
        return prod;

    if (lista->dado.cod == cod) // caso base: encontrado neste nodo
        return lista->dado;

    return consulta(lista->prox, cod); // caso recursivo: procura no restante
}

insereInicio

Inserir no início não é realmente "recursivo" (o novo nodo não depende do restante da lista para ser calculado), mas se encaixa perfeitamente na definição: a nova lista é um nodo cujo prox é a lista inteira anterior:

Nodo* insereInicio(Nodo *lista, Produto prod) {
    Nodo *novo = (Nodo*) malloc(sizeof(Nodo));
    if (novo == NULL)
        return lista;

    novo->dado = prod;
    novo->prox = lista; // o restante da lista, sem nenhuma alteração
    return novo;
}

destroi

Este é o caso mais interessante: para destruir a lista, primeiro destruímos o restante (recursivamente) e só depois liberamos o nodo atual — a ordem importa, pois precisamos do ponteiro prox ainda válido para poder chamar a recursão antes do free():

Nodo* destroi(Nodo *lista) {
    if (lista == NULL) // caso base: nada a destruir
        return NULL;

    destroi(lista->prox); // destrói o restante primeiro
    free(lista);
    return NULL;
}

3. O custo escondido da recursão

As versões recursivas costumam ser mais curtas e, para quem já entende a definição recursiva de lista, mais diretas de ler. Mas elas têm um custo que não aparece na complexidade Big-O: relembrando o Material Extra — Conceitos de Linguagens de Programação, cada chamada recursiva empilha um novo quadro de ativação na pilha.

Para uma lista com n elementos, uma função como tamanho ou destroi empilha n chamadas recursivas antes de começar a retornar. Se n for muito grande, isso pode esgotar o espaço da pilha, causando um stack overflow — exatamente o mesmo risco discutido no Material Extra — Conceitos de Linguagens de Programação para recursões profundas.

Recursivo × iterativo. Ambas as versões de tamanho são O(n) em número de operações. A versão recursiva, porém, também consome O(n) de espaço extra na pilha (um quadro por chamada), enquanto a versão iterativa (com while) usa apenas espaço constante, O(1), independente do tamanho da lista.

Resumo

  • Uma lista é recursiva por natureza: ou é vazia (NULL), ou é um nodo mais uma lista menor (prox).
  • Toda operação iterativa da Aula 4 tem uma versão recursiva equivalente, trocando o laço por um caso base (lista vazia) e um caso recursivo (processa o nodo atual, chama a função para lista->prox).
  • destroi recursivo precisa destruir o restante da lista antes de liberar o nodo atual, já que o prox deixa de ser acessível após o free().
  • Custo escondido: versões recursivas usam O(n) de espaço na pilha de chamadas, além do tempo O(n), o que pode causar stack overflow em listas muito grandes — a versão iterativa não tem esse problema.

Exercícios

  1. Identifique o caso base e o caso recursivo da função consulta apresentada nesta aula.
  2. Por que, na função destroi recursiva, é necessário chamar destroi(lista->prox) antes de free(lista), e não depois?
  3. Explique por que a versão recursiva de tamanho consome O(n) de espaço na pilha, enquanto a versão iterativa consome apenas O(1), mesmo as duas tendo a mesma complexidade de tempo.
  4. Implemente uma versão recursiva de int soma(Nodo *lista), que retorne a soma dos preços (arredondados para inteiro) de todos os produtos da lista.
  5. Implemente uma versão recursiva de Nodo* insereFim(Nodo *lista, Produto prod). Dica: pense em qual é o caso base (lista vazia) e como a chamada recursiva deveria alterar apenas o prox do resultado retornado.

Sugestões de Respostas dos Exercícios

Exercício 1

Existem dois casos base: lista == NULL (não encontrado, retorna um produto "vazio") e lista->dado.cod == cod (encontrado no nodo atual, retorna lista->dado). O caso recursivo é o return consulta(lista->prox, cod);, que delega a busca para o restante da lista quando o código não é o do nodo atual.


Exercício 2

Porque, depois de chamar free(lista), o ponteiro lista->prox não pode mais ser lido com segurança — o bloco de memória já foi liberado. Se a chamada recursiva viesse depois do free(), tentaríamos acessar lista->prox (dentro da chamada recursiva) em um nodo já liberado, o mesmo tipo de erro discutido na Aula 4 para a versão iterativa de destroi.


Exercício 3

Porque cada chamada recursiva de tamanho permanece "aberta" na pilha, esperando o resultado da chamada seguinte para poder calcular 1 + tamanho(lista->prox) — para uma lista de n nodos, isso significa n quadros de ativação empilhados simultaneamente antes do primeiro retorno acontecer. Já a versão iterativa usa sempre as mesmas variáveis (aux, contador) dentro de uma única chamada de função, sem empilhar nada a mais conforme a lista cresce.


Exercício 4
int soma(Nodo *lista) {
    if (lista == NULL) // caso base: lista vazia soma 0
        return 0;

    return (int) lista->dado.preco + soma(lista->prox); // este nodo + o restante
}

Exercício 5
Nodo* insereFim(Nodo *lista, Produto prod) {
    if (lista == NULL) { // caso base: lista vazia, novo nodo é o único (e o fim)
        Nodo *novo = (Nodo*) malloc(sizeof(Nodo));
        if (novo == NULL)
            return NULL;

        novo->dado = prod;
        novo->prox = NULL;
        return novo;
    }

    // caso recursivo: mantém este nodo, insere no restante da lista
    lista->prox = insereFim(lista->prox, prod);
    return lista;
}

A cada chamada recursiva, o nodo atual permanece o mesmo — apenas seu prox é atualizado para o resultado de inserir no restante da lista, até que a recursão alcance a lista vazia (o verdadeiro final), onde o novo nodo é finalmente criado.