Algoritmos 2Revisão em C

Aula 3 de 6

Funções recursivas

Problemas menores e uma condição de parada.

Ao final desta aula você será capaz de
  • Escrever uma função recursiva a partir de três perguntas: qual é o menor caso, como reduzir e como juntar as respostas.
  • Desenhar a pilha de chamadas e prever a saída de um programa recursivo.
  • Reconhecer padrões práticos: percorrer pastas, buscar em lista ordenada e gerar combinações.
  • Estimar, contando chamadas, se uma recursão termina em milissegundos ou em horas.

Palavras novas nesta aula: Backtracking, Busca binária, Caso base, Memoização, Overflow (estouro), Pilha de chamadas, Poda, Stack overflow. Cada uma tem uma explicação curta no glossário.

O problema

Você quer saber quantos arquivos existem na pasta Documentos. Ela tem alguns arquivos e algumas subpastas. Cada subpasta pode ter mais arquivos e mais subpastas, sem limite de profundidade. Uma quantidade fixa de laços for aninhados não resolve, porque você não sabe quantos níveis existem. (Dá para resolver com um laço e uma pilha montada à mão, mas a recursão é o caminho mais direto e é o que vamos estudar.)

Como repetir a mesma estratégia em cada subpasta e saber quando parar?

Pense em como você faria à mão: “o total de Documentos é o número de arquivos soltos nela mais o total de cada subpasta”. Para saber o total de uma subpasta, você aplica a mesma regra. Quando chega a uma pasta sem subpastas, a resposta é imediata. Uma regra que usa a si mesma em um problema menor é uma recursão. Vamos implementar exatamente esse problema mais adiante. Antes, um exemplo menor para enxergar o mecanismo.

Conceitos centrais

Uma função recursiva chama a si mesma com uma versão menor do problema, até chegar a um caso que sabe responder diretamente.

Caso base
O caso pequeno o bastante para responder sem nova chamada, como uma pasta sem subpastas. É ele que faz a recursão parar.
Redução
Transforma o problema em problemas menores, como cada subpasta em vez da pasta inteira.
Junção
Junta as respostas menores para montar a resposta atual. No exemplo: arquivos soltos + total de cada subpasta.
Pilha
Cada chamada em andamento fica guardada na memória, esperando a resposta das chamadas que ela fez.
A receita em três perguntas
  1. Qual é o menor caso? Ele vira o caso base.
  2. Se eu já tivesse a resposta para um problema menor, como montaria a resposta atual? Isso define a junção.
  3. Toda chamada se aproxima do caso base? Isso garante que a recursão termina.

Na pergunta 2, confie na chamada recursiva. Não tente simular tudo de cabeça: suponha que contar(subpasta) já funciona e pense apenas em como usar a resposta dela.

A pilha de chamadas

O exemplo mais simples possível: somar os números de 1 até n. A soma de 1 a 4 é 4 + (soma de 1 a 3), e assim por diante, até a soma de 1 a 0, que é 0.

Programa completo: compile e execute
#include <stdio.h>

// Pré-condição: n >= 0 (com n negativo, nunca chega ao caso base).
int soma(int n) {
    if (n == 0) return 0;          // caso base
    return n + soma(n - 1);        // redução + junção
}

int main(void) {
    printf("%d\n", soma(4));
    return 0;
}

Saída: 10

O comentário “Pré-condição” no código diz o que precisa ser verdade antes de chamar a função para que ela funcione. Aqui, n não pode ser negativo.

O que acontece por baixo é o que mais confunde quem está começando, e a figura mostra passo a passo:

Pilha de chamadas de soma(4) soma(4) espera 4 + soma(3), que espera 3 + soma(2), que espera 2 + soma(1), que espera 1 + soma(0). soma(0) é o caso base e devolve 0. Os resultados sobem: soma(1) devolve 1, soma(2) devolve 3, soma(3) devolve 6 e soma(4) devolve 10 ao main. São cinco chamadas abertas ao mesmo tempo na pilha. soma(4) espera 4 + soma(3) soma(3) espera 3 + soma(2) soma(2) espera 2 + soma(1) soma(1) espera 1 + soma(0) soma(0): caso base, devolve 0 devolve 6 devolve 3 devolve 1 devolve 0 devolve 10 ao main 5 chamadas abertasao mesmo tempo na pilha.Cada uma ocupa memóriaaté receber a resposta.No Windows (pilha de 1 MB):soma(10000) funcionou;soma(50000) travou comstack overflow. Azul: cada chamada espera a próxima. Verde: os resultados voltam, do caso base até o main.
As chamadas descem até o caso base, e cada uma fica parada esperando a de baixo. Só então os resultados sobem, e cada nível faz a sua soma. A conta acontece na volta, e não na ida.

Cada chamada em espera ocupa um pedaço da pilha, uma área de memória pequena (cerca de 1 MB no Windows e 8 MB no Linux, por padrão). Por isso, uma recursão muito profunda trava o programa, mesmo com um caso base correto. Nos testes desta aula (Windows, compilador MSVC sem otimizações, pilha padrão de 1 MB), soma(10000) funcionou, e soma(50000) terminou com stack overflow. O limite exato muda com o sistema, o compilador e as opções de compilação, e o tamanho da pilha pode ser configurado. Para somar 50 mil números, um laço for é a escolha certa.

Ida ou volta: onde fica o printf muda tudo

Imagine uma função que imprime n e depois chama a si mesma com n − 1, parando em 0. Com n = 3, a impressão acontece na descida, e a saída é 3 2 1. Se a mesma função chamar a si mesma primeiro e imprimir depois, o printf só roda na volta, e a saída se inverte para 1 2 3. O exercício 2 mostra o código das duas versões para você prever a saída.

Exemplo real: contar arquivos em pastas

Agora o problema do início. Cada pasta é uma struct com o número de arquivos soltos e ponteiros para as subpastas. É a junção das duas aulas anteriores.

Programa completo: compile e execute
#include <stdio.h>

typedef struct Pasta {
    const char *nome;
    int arquivos;               // arquivos diretamente nesta pasta
    int qtd_sub;                // quantas subpastas ela tem
    struct Pasta *sub[4];       // ponteiros para as subpastas
} Pasta;
// Pré-condições: 0 <= qtd_sub <= 4, ponteiros válidos e nenhuma pasta
// contida em si mesma (sem ciclos), senão a recursão nunca termina.

int contar(const Pasta *p) {
    int total = p->arquivos;                  // arquivos soltos
    for (int i = 0; i < p->qtd_sub; i++) {
        total += contar(p->sub[i]);           // mesma regra em cada subpasta
    }
    return total;
}

int main(void) {
    Pasta viagem = {"Viagem", 3, 0, {0}};
    Pasta fotos  = {"Fotos", 5, 1, {&viagem}};
    Pasta relat  = {"Relatorios", 4, 0, {0}};
    Pasta trab   = {"Trabalho", 1, 1, {&relat}};
    Pasta docs   = {"Documentos", 2, 2, {&fotos, &trab}};

    printf("Total de arquivos: %d\n", contar(&docs));
    return 0;
}

Saída: Total de arquivos: 15

Contagem recursiva de arquivos em uma árvore de pastas Documentos tem 2 arquivos e duas subpastas: Fotos, com 5 arquivos e a subpasta Viagem com 3, e Trabalho, com 1 arquivo e a subpasta Relatórios com 4. Viagem e Relatórios não têm subpastas e devolvem 3 e 4. Fotos devolve 5 + 3 = 8. Trabalho devolve 1 + 4 = 5. Documentos devolve 2 + 8 + 5 = 15. Documentos2 arquivos Fotos5 arquivos Trabalho1 arquivo Viagem3 arquivos Relatórios4 arquivos devolve 152 + 8 + 5 devolve 85 + 3 devolve 51 + 4 devolve 3sem subpastas: caso base devolve 4sem subpastas: caso base Setas azuis: contar() chama a si mesma para cada subpasta. Texto verde: o valor que cada chamada devolve para a pasta de cima.
Cada pasta faz a mesma pergunta às suas subpastas e soma as respostas. Viagem e Relatórios não têm subpastas: o laço não executa nenhuma vez, e elas devolvem só os próprios arquivos. Esse é o caso base, mesmo sem um if explícito.

Repare que contar não sabe, e não precisa saber, quantos níveis a árvore tem. Ela funciona igual para 2 ou 200 níveis. Esse tipo de estrutura, em que cada item contém outros itens do mesmo tipo, aparece em todo lugar: pastas, menus com submenus, comentários com respostas, organogramas, o HTML desta página. Recursão é a ferramenta natural para percorrê-los.

Padrões recursivos

Muitos problemas recursivos de prova ou de entrevista se encaixam em um destes formatos. Reconhecer o formato é metade da solução.

1. Reduzir de um em um

Uma chamada por vez, cada uma com um problema uma unidade menor: soma, fatorial, contagem regressiva. É fácil de escrever, mas faz uma chamada por elemento. Para listas grandes, prefira um laço.

2. Uma chamada para cada filho

Pastas, menus, organogramas: a função se chama uma vez para cada item interno, como em contar. O número total de chamadas é igual ao número de itens da estrutura, sem desperdício.

3. Cortar pela metade: busca binária

Pense em procurar uma palavra no dicionário. Você não lê página por página: abre no meio, vê se a palavra vem antes ou depois e descarta metade do livro. Depois repete na metade que sobrou. Em um vetor ordenado, a busca binária faz exatamente isso:

Funções: copie para um programa que tenha main
// Procura alvo em v[ini..fim], vetor em ordem crescente. Retorna a posição ou -1.
int busca_binaria(const int v[], int ini, int fim, int alvo) {
    if (ini > fim) return -1;                    // não sobrou nada: não existe
    int meio = ini + (fim - ini) / 2;
    if (v[meio] == alvo) return meio;            // achou
    if (v[meio] < alvo)
        return busca_binaria(v, meio + 1, fim, alvo);   // só pode estar à direita
    return busca_binaria(v, ini, meio - 1, alvo);       // só pode estar à esquerda
}
Busca binária pelo valor 72 em um vetor ordenado de 15 posições Vetor ordenado: 3, 8, 12, 19, 25, 31, 40, 47, 56, 63, 72, 88, 91, 97, 99. Passo 1: o meio é 47, menor que 72, então a metade esquerda é descartada. Passo 2: o meio do que sobrou é 88, maior que 72, então a direita é descartada. Passo 3: o meio é 63, menor que 72. Passo 4: sobra a posição 10, que contém 72. Foram 4 valores examinados, de 15. 01234567891011121314 3812192531404756637288919799 Passo 1: meio = 47. Como 47 < 72, o alvo só pode estar à direita. 3812192531404756637288919799 Passo 2: meio = 88. Como 88 > 72, descarta a parte da direita. 3812192531404756637288919799 Passo 3: meio = 63. Como 63 < 72, sobra uma única posição. 3812192531404756637288919799 Passo 4: meio = 72. Achou na posição 10, olhando só 4 dos 15 valores.
A cada passo, o trecho onde o alvo pode estar cai pela metade. As posições em cinza foram descartadas. Delas, só os meios de cada passo (47, 88 e 63) chegaram a ser lidos; as outras nunca foram olhadas.

O ganho é enorme na prática. Nos testes desta aula, o pior caso foi:

Elementos examinados no pior caso, medidos
Tamanho da listaProcurando um por umBusca binária
100 nomes1007
1 000 nomes1 00010
1 milhão de nomes1 000 00020

Cada vez que a lista dobra de tamanho, a busca binária precisa de só um passo a mais. A condição é que a lista esteja ordenada, e é para isso que servem as próximas três aulas.

E onde está a recursão? Cada passo da figura é uma chamada de busca_binaria. Para achar o 72, a pilha chega a ter no máximo 4 chamadas abertas, uma por passo. Para 1 milhão de nomes, seriam no máximo 20.

4. Duas chamadas que se repetem: Fibonacci Aprofundamento

Na sequência de Fibonacci (0, 1, 1, 2, 3, 5, 8, 13…), cada número é a soma dos dois anteriores. A tradução direta para C é curta e elegante, e muito lenta:

Funções: copie para um programa que tenha main
long long fib_ingenuo(int n) {
    if (n < 2) return n;
    return fib_ingenuo(n - 1) + fib_ingenuo(n - 2);
}
Árvore de chamadas de fib(5) sem memoização fib(5) chama fib(4) e fib(3). fib(4) chama fib(3) e fib(2). O mesmo subproblema aparece várias vezes: fib(3) é calculado 2 vezes, fib(2) 3 vezes, e os casos base fib(1) e fib(0) aparecem 8 vezes. São 15 chamadas no total. Com memoização, cada valor é calculado uma vez, e o total cai para 9 chamadas. fib(5) fib(4) fib(3) fib(3) fib(2) fib(2) fib(1) fib(2) fib(1) fib(1) fib(0) fib(1) fib(0) fib(1) fib(0) fib(3): calculado 2 vezesfib(2): 3 vezescasos base15 chamadas · com memo: 9
Para calcular fib(5), a função calcula fib(3) duas vezes e fib(2) três vezes, sem lembrar que já fez esse trabalho. Cada valor a mais de n multiplica o trabalho por cerca de 1,6.
Medido em um Intel Core i5-9600K, Windows 11, MSVC 19.51 sem otimizações (/Od). As contagens de chamadas são exatas; os tempos variam com a máquina.
ChamadaChamadas feitasTempo
fib_ingenuo(20)21 891instantâneo
fib_ingenuo(30)2 692 537milissegundos
fib_ingenuo(40)331 160 2810,66 segundo
fib_ingenuo(45)3 672 623 805cerca de 7 segundos

A solução é anotar cada resposta na primeira vez que ela é calculada e consultar a anotação nas vezes seguintes. Essa técnica se chama memoização:

Funções: copie para um programa que tenha main
long long memo[91];           // global: começa zerado; fib(90) ainda cabe em long long

long long fib(int n) {        // Pré-condição: 0 <= n <= 90.
    if (n < 2) return n;
    if (memo[n] != 0) return memo[n];     // já calculado: só consulta
    memo[n] = fib(n - 1) + fib(n - 2);    // calcula uma vez e anota
    return memo[n];
}

Com a anotação, fib(40) faz 79 chamadas em vez de 331 milhões, e fib(90), que na versão ingênua levaria séculos, faz 179 chamadas e responde na hora. Repare no padrão: na primeira chamada, com a tabela memo ainda zerada e n ≥ 1, fib(n) faz exatamente 2n − 1 chamadas. Se a tabela já tiver valores anotados de chamadas anteriores, faz menos. Cada valor de 2 a n é calculado uma única vez e consulta a tabela no máximo uma vez, então o trabalho cresce junto com n, e não explode.

5. Testar todas as escolhas: backtracking Aprofundamento

Uma pizzaria quer listar todas as pizzas possíveis com três ingredientes opcionais. Para cada ingrediente, há duas escolhas: colocar ou não colocar. A recursão decide um ingrediente por nível e, ao voltar, experimenta a outra opção:

Árvore de decisões para montar pizzas com três ingredientes Na raiz, decide-se se a pizza leva queijo; no nível seguinte, calabresa; depois, cebola. Cada decisão tem dois ramos, sim e não. As 8 folhas são as pizzas possíveis: queijo, calabresa e cebola; queijo e calabresa; queijo e cebola; só queijo; calabresa e cebola; só calabresa; só cebola; só massa. O caminho sim, não, sim, destacado, leva à pizza de queijo com cebola. sim não sim não sim não sim não sim não sim não sim não Q+Ca+CeQ+CaQ+CeQCa+CeCaCemassa queijo?calabresa?cebola? Q = queijo · Ca = calabresa · Ce = cebola. Em destaque: sim, não, sim = pizza de queijo com cebola.Cada caminho da raiz até uma folha é uma pizza. 3 ingredientes → 8 pizzas; 10 → 1 024; 20 → 1 048 576.
A recursão percorre a árvore de decisões da esquerda para a direita. Cada folha é uma pizza impressa. Quando você executar o programa abaixo, a ordem da saída será exatamente a ordem das folhas.

Agora o código. Repare na linha escolhido[i] = 0;: é o momento em que a recursão volta a um nó da árvore e pega o outro ramo.

Programa completo: compile e execute
#include <stdio.h>

const char *ingredientes[] = {"queijo", "calabresa", "cebola"};

// i: qual ingrediente está sendo decidido; escolhido[k] = 1 se o k-ésimo entrou.
void montar(int i, int escolhido[]) {
    if (i == 3) {                               // decidiu todos: imprime a pizza
        printf("Pizza:");
        int vazia = 1;
        for (int k = 0; k < 3; k++) {
            if (escolhido[k]) { printf(" %s", ingredientes[k]); vazia = 0; }
        }
        if (vazia) printf(" so massa");
        printf("\n");
        return;
    }
    escolhido[i] = 1;            // escolha 1: COM o ingrediente i
    montar(i + 1, escolhido);
    escolhido[i] = 0;            // escolha 2: SEM o ingrediente i (desfaz)
    montar(i + 1, escolhido);
}

int main(void) {
    int escolhido[3];
    montar(0, escolhido);
    return 0;
}
Pizza: queijo calabresa cebola Pizza: queijo calabresa Pizza: queijo cebola Pizza: queijo Pizza: calabresa cebola Pizza: calabresa Pizza: cebola Pizza: so massa

Esse padrão resolve problemas como gerar senhas, montar escalas de trabalho e escolher itens para caber em uma mochila. É a base do desafio hacker desta aula.

Quanto custa uma recursão? Aprofundamento

Não é preciso fórmula para ter uma boa estimativa. Faça duas perguntas:

  1. Quantas chamadas vão acontecer no total, e quanto trabalho cada uma faz? É o que define o tempo. Desenhe a árvore para uma entrada pequena e veja como ela cresce. Cuidado com o trabalho por chamada: as 8 pizzas são 8 folhas, mas imprimir cada uma percorre os ingredientes.
  2. Quantas chamadas ficam abertas ao mesmo tempo? É o que define a memória da pilha: a altura da árvore, e não o total.
Como cada padrão cresce na prática
FormatoExemploSe a entrada dobra…Na prática
Reduz de um em umsoma, fatorialas chamadas dobramok até alguns milhares, depois a pilha estoura
Uma chamada por itemcontar pastasas chamadas dobramproporcional ao tamanho da estrutura
Corta pela metadebusca bináriasó +1 chamada1 milhão de itens em 20 passos
Duas chamadas com repetiçãoFibonacci ingênuoexplodecerca de 7 s para n = 45; use memoização
Todas as combinaçõespizzas, subconjuntoscada item a mais dobra tudo20 itens: 1 milhão de casos; 40 itens: 1 trilhão

Nas próximas aulas, esses comportamentos ganham nomes: O(n) para “cresce junto com a entrada”, O(log n) para “corta pela metade” e O(2ⁿ) para “dobra a cada item”. A intuição da tabela vale mais que a notação.

Armadilhas frequentes
  • Esquecer o caso base, ou escrever um que nunca é alcançado: contagem(-1) com teste n == 0 nunca para.
  • Ignorar o retorno: escrever soma(n - 1); em vez de return n + soma(n - 1);. A conta é feita e jogada fora.
  • Calcular a mesma coisa duas vezes: f(n/2) + f(n/2) dobra o trabalho em cada nível, e esse dobro se acumula: o que seria cerca de log n chamadas vira algo proporcional a n. Guarde o resultado em uma variável e reutilize.
  • Recursão profunda demais: dezenas de milhares de níveis estouram a pilha. Para percorrer uma lista longa, use um laço.
  • Overflow: 13! não cabe em um int de 32 bits. Em C, estourar um inteiro com sinal é comportamento indefinido: o programa não avisa, e o resultado não é confiável.

Exercícios progressivos

Para cada exercício, desenhe a pilha ou a árvore de chamadas antes de escrever código, como nas figuras da aula. A dica não entrega a resposta: ela indica a pergunta certa a fazer.

1. Contagem regressiva do foguete Básico

O software de uma base de lançamento usa esta função para a contagem regressiva:

Funções: copie para um programa que tenha main
void contagem(int n) {
    if (n == 0) {
        printf("Lancar!\n");
        return;
    }
    printf("%d ", n);
    contagem(n - 1);
}

contagem(3) imprime 3 2 1 Lancar!. Um operador digitou -1 por engano, e o sistema travou. O que aconteceu? Corrija a função para que contagem(-1) apenas imprima Lancar!.

Ver uma dica
Escreva a sequência de chamadas a partir de contagem(-1). Ela chega a 0 em algum momento? Que mudança no teste do caso base cobre 0 e qualquer valor abaixo de 0?
Conferir a resposta

A partir de −1, as chamadas seguem para −2, −3, −4…, se afastando de 0. O caso base nunca é alcançado. Cada chamada ocupa um pedaço da pilha até ela acabar, e o programa termina com stack overflow.

Funções: copie para um programa que tenha main
void contagem(int n) {
    if (n <= 0) {               // cobre 0 e qualquer negativo
        printf("Lancar!\n");
        return;
    }
    printf("%d ", n);
    contagem(n - 1);
}

Lição: o caso base deve cobrir todas as entradas em que não há mais o que reduzir, e não só a entrada “esperada”. Use <= em vez de == sempre que o valor puder passar do ponto de parada.

2. O log fora de ordem Básico

Um desenvolvedor adicionou mensagens de log a três versões de uma função e se surpreendeu com a ordem em que elas apareceram. Preveja a saída de cada uma para n = 3:

Funções: copie para um programa que tenha main
void ida(int n)   { if (n == 0) return; printf("%d ", n); ida(n - 1); }
void volta(int n) { if (n == 0) return; volta(n - 1); printf("%d ", n); }
void ambos(int n) { if (n == 0) return; printf("%d ", n); ambos(n - 1); printf("%d ", n); }
Ver uma dica
Use a figura da pilha. Um printf antes da chamada roda enquanto as chamadas descem. Um printf depois da chamada só roda quando a chamada de baixo terminar, ou seja, na subida. Em que ordem as chamadas terminam?
Conferir a resposta
  • ida(3): 3 2 1. Imprime na descida, do maior para o menor.
  • volta(3): 1 2 3. volta(3) só imprime depois que volta(2) termina, que só imprime depois de volta(1). A primeira a imprimir é a mais profunda.
  • ambos(3): 3 2 1 1 2 3. Imprime na descida e de novo na subida, como um espelho.

Esse comportamento é útil, e não só uma curiosidade. Para imprimir os dígitos de um número na ordem certa (123 → “1 2 3”), o truque é chamar a recursão com n / 10 antes de imprimir n % 10.

3. Ordem dos seminários Básico

Cinco grupos vão apresentar seminários, e o professor quer saber de quantas ordens diferentes as apresentações podem acontecer. Há 5 opções para o primeiro, 4 para o segundo, e assim por diante: 5 × 4 × 3 × 2 × 1, que é o fatorial de 5. Escreva int fatorial(int n) recursivo, para 0 ≤ n ≤ 12, e responda: quantas ordens existem para 5 grupos? E por que a função não se pode confiar no resultado para 13 grupos?

Ver uma dica
Aplique a receita. Qual é o menor caso? Quantas ordens existem com 1 grupo? E com 0 grupos: zero ordens ou uma? Escolha a resposta que faz a fórmula funcionar para n = 1. Se você soubesse quantas ordens existem para n − 1 grupos, como chegaria às de n grupos?
Conferir a resposta
Funções: copie para um programa que tenha main
int fatorial(int n) {
    if (n == 0) return 1;            // 0 grupos: 1 ordem (a vazia)
    return n * fatorial(n - 1);      // n escolhas para o 1º, vezes o resto
}

fatorial(5) = 120 ordens.

O fatorial cresce muito rápido. 12! = 479 001 600 ainda cabe em um int de 32 bits, cujo limite é cerca de 2,1 bilhões. Já 13! = 6 227 020 800 não cabe. Em C, estourar um int (inteiro com sinal) é comportamento indefinido: o padrão da linguagem não diz o que acontece. Em muitas máquinas o valor parece “dar a volta” e sai errado sem aviso, mas não se pode contar nem com isso. Só os tipos unsigned têm a volta garantida. A solução é verificar o limite antes de multiplicar. Para ir um pouco além, use long long, que comporta até 20!.

4. A pasta mais funda Intermediário

O sistema de backup precisa saber quantos níveis de pastas existem, porque caminhos muito longos causam erro em alguns sistemas. Usando a struct Pasta da aula, escreva int profundidade(const Pasta *p). Uma pasta sem subpastas tem profundidade 1. Para a árvore de exemplo (Documentos → Fotos → Viagem), a resposta é 3.

Ver uma dica
Compare com contar. Lá, você somava as respostas das subpastas. Aqui, que operação faz sentido aplicar às profundidades das subpastas? Depois disso, quanto a pasta atual acrescenta?
Conferir a resposta
Funções: copie para um programa que tenha main
int profundidade(const Pasta *p) {
    int maior = 0;                              // maior profundidade entre as subpastas
    for (int i = 0; i < p->qtd_sub; i++) {
        int d = profundidade(p->sub[i]);
        if (d > maior) maior = d;
    }
    return 1 + maior;                           // a própria pasta conta como 1 nível
}

Para Viagem e Relatórios, o laço não executa, e a função devolve 1 + 0 = 1. Fotos devolve 1 + 1 = 2, Trabalho também 2, e Documentos devolve 1 + max(2, 2) = 3.

A estrutura é idêntica à de contar. Só muda a junção: soma virou máximo. Esse é o poder da receita: o esqueleto se repete, e a pergunta 2 muda.

5. Torre de Hanói Avançado

O quebra-cabeça tem três hastes (A, B, C) e n discos de tamanhos diferentes empilhados em A, do maior para o menor. O objetivo é levar todos para C, movendo um disco por vez e nunca colocando um disco maior sobre um menor.

  1. Escreva hanoi(n, origem, destino, aux), que imprime os movimentos.
  2. Quantos movimentos são necessários para 3 discos? E para 20?
  3. Uma lenda diz que monges movem 64 discos, um por segundo. Quanto tempo eles levam?
Ver uma dica
Não tente pensar disco por disco. Pense só no maior. Para ele sair de A e ir para C, onde precisam estar todos os outros n − 1 discos? Como eles chegam lá? Depois que o maior está em C, o que falta fazer? Confie que a recursão sabe mover n − 1 discos.
Conferir a resposta
Funções: copie para um programa que tenha main
void hanoi(int n, char origem, char destino, char aux) {
    if (n == 0) return;                          // nenhum disco: nada a fazer
    hanoi(n - 1, origem, aux, destino);          // 1) tira os n-1 menores do caminho
    printf("disco %d: %c -> %c\n", n, origem, destino);   // 2) move o maior
    hanoi(n - 1, aux, destino, origem);          // 3) coloca os n-1 sobre o maior
}
disco 1: A -> C disco 2: A -> B disco 1: C -> B disco 3: A -> C disco 1: B -> A disco 2: B -> C disco 1: A -> C

Para 3 discos, são 7 movimentos. Cada disco a mais exige mover a pilha menor duas vezes, mais um movimento: 1, 3, 7, 15, 31… o número dobra e soma 1 a cada disco. Para 20 discos, o programa contou 1 048 575 movimentos.

Para 64 discos, são cerca de 18 quintilhões (1,8 × 10¹⁹) de movimentos. A um por segundo, isso daria uns 585 bilhões de anos, mais de 40 vezes a idade do universo. É o mesmo crescimento explosivo da tabela de custos: cada item a mais dobra tudo.

./desafio --nivel=hacker

Desafio hacker: o cofre das somas

  • Tipo: opcional
  • Dificuldade: ★★★★
  • Tema: backtracking
  • Restrição: n ≤ 20

Um cofre abre quando você coloca na balança um conjunto de pesos que soma exatamente o valor alvo. Você recebe n pesos inteiros positivos. Imprima todos os subconjuntos cuja soma é alvo, cada um uma única vez, preservando a ordem original dos elementos. Os subconjuntos são de posições: se dois pesos têm o mesmo valor, as combinações com um ou com o outro contam como diferentes, mesmo que as linhas impressas fiquem iguais.

Entradav = {3, 34, 4, 12, 5, 2} alvo = 9
Saída{3, 4, 2} {4, 5}

Requisitos: use recursão, sem laços para gerar combinações, e corte os caminhos que não podem levar a uma solução. Esse corte se chama poda: abandonar um caminho da árvore assim que fica claro que ele não leva a nada. Ao final, estime até quantos pesos o programa continua rápido.

Dica 1: é a pizzaria de novo

Cada peso é um “ingrediente”: entra ou não entra na balança. A árvore de decisões é a mesma da figura das pizzas, só que com 6 níveis em vez de 3. Em vez de imprimir todas as folhas, imprima só as que somam o alvo.

Dica 2: o que a função precisa saber?

Ela precisa do índice do peso que está decidindo (i), de quanto ainda falta para o alvo (resto) e dos pesos escolhidos até agora, em um vetor auxiliar com tamanho k. Quando resto chega a 0, a combinação é uma solução. Quando resto fica negativo, nenhum peso positivo pode consertar esse caminho: desista dele.

Solução comentada
Programa completo: compile e execute
#include <stdio.h>

void imprimir(const int escolhidos[], int k) {
    printf("{");
    for (int i = 0; i < k; i++) printf(i ? ", %d" : "%d", escolhidos[i]);
    printf("}\n");
}

// i: próximo peso a decidir; resto: quanto falta; escolhidos[0..k-1]: decisão parcial.
void buscar(const int v[], int n, int i, int resto, int escolhidos[], int k) {
    if (resto == 0) {                 // caso base 1: achou
        imprimir(escolhidos, k);
        return;                       // pesos positivos: adicionar mais só passaria do alvo
    }
    if (i == n || resto < 0) return;  // caso base 2: acabou ou estourou (poda)

    escolhidos[k] = v[i];                                   // escolha: INCLUIR v[i]
    buscar(v, n, i + 1, resto - v[i], escolhidos, k + 1);

    buscar(v, n, i + 1, resto, escolhidos, k);              // escolha: NÃO incluir v[i]
}

int main(void) {
    int v[] = {3, 34, 4, 12, 5, 2};
    int escolhidos[6];
    buscar(v, 6, 0, 9, escolhidos, 0);
    return 0;
}

Saída: {3, 4, 2} e {4, 5}.

Por que funciona. Cada caminho da raiz até uma folha corresponde a exatamente uma combinação de pesos, como cada caminho da figura corresponde a uma pizza. Por isso, nenhuma combinação aparece duas vezes e nenhuma é esquecida. O “desfazer” é implícito: ao voltar da primeira chamada, o próximo peso escolhido sobrescreve escolhidos[k], e k continua valendo o que valia antes.

Até onde é rápido? Cada peso a mais dobra o número de caminhos. Com 6 pesos, são no máximo 64 caminhos. Com 20, cerca de 1 milhão, o que leva uma fração de segundo. Com 40, cerca de 1 trilhão, o que levaria horas. A poda resto < 0 não ajuda no pior caso, mas na prática elimina a maior parte da árvore. Uma poda extra que respeita a ordem original: calcule antes, para cada posição i, a soma de todos os pesos de i até o fim. Se essa soma já for menor que resto, nenhuma escolha restante alcança o alvo, e o ramo pode ser cortado. (Ordenar os pesos também ajudaria a cortar, mas mudaria a ordem em que as soluções são impressas, que o enunciado exige preservar.)

Testes que você deve rodar: alvo impossível (nenhuma saída), alvo igual à soma de todos os pesos (uma saída), pesos repetidos como {2, 2} com alvo 2 (duas saídas, pois são posições diferentes) e comparação com uma força bruta que testa todas as 2ⁿ combinações.

Indo além: se você só precisa saber se existe solução, e o alvo é pequeno, dá para aplicar a mesma ideia da memoização de Fibonacci: anotar quais pares (i, resto) já foram testados e falharam. Essa técnica se chama programação dinâmica.

Revisão rápida

  • Três perguntas: menor caso, como reduzir, como juntar as respostas. Confie na chamada recursiva.
  • As chamadas descem e esperam. Os resultados sobem. Cada chamada aberta ocupa pilha.
  • Estruturas que contêm a si mesmas, como pastas e menus, pedem recursão.
  • Cortar pela metade é rápido. Repetir trabalho é lento: anote as respostas com memoização.
  • Testar todas as escolhas (backtracking) dobra o trabalho a cada item. Corte os caminhos sem futuro.