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
proxque é, 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
|
Exercícios
-
Identifique o caso base e o caso recursivo da função
consultaapresentada nesta aula. -
Por que, na função
destroirecursiva, é necessário chamardestroi(lista->prox)antes defree(lista), e não depois? -
Explique por que a versão recursiva de
tamanhoconsomeO(n)de espaço na pilha, enquanto a versão iterativa consome apenasO(1), mesmo as duas tendo a mesma complexidade de tempo. -
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. -
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 oproxdo 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.