Python

Exploração prática e conceitual de recursão e programação dinâmica, abordando problemas clássicos como Josephus, Fibonacci, QuickSort e QuickSelect, com foco em análise de limitações e otimizações.

Por Fábio Linhares • Instituto Infnet

QUESTÃO 01
Conceito Fundamental da Recursão
Que tipo de problema pode ser resolvido por recursão? Autossimilaridade e casos base.

Que tipo de problema pode ser resolvido por recursão?


A recursão é uma técnica poderosa para resolver problemas que podem ser divididos em subproblemas menores e de mesma natureza. Em outras palavras, um problema é "resolvível por recursão" se sua solução pode ser expressa em termos da solução de uma versão menor dele mesmo.


A característica fundamental desses problemas é a autossimilaridade. A solução para o problema original depende da solução para instâncias menores do mesmíssimo problema.

A recursão é útil quando um problema pode ser decomposto em subproblemas menores, semelhantes ao problema original. Exemplos típicos incluem:


  • Explorar diretórios e subdiretórios.
  • Cálculo de estruturas matemáticas (fatorial, Fibonacci).
  • Problemas em grafos e árvores (buscas, percursos).
  • Divisão e conquista (MergeSort, QuickSort).
Imagine um conjunto de bonecas russas (Matrioskas). Para abrir todas, você abre a maior e encontra uma versão menor dela mesma dentro. O processo se repete: você aplica a mesma lógica ("abrir a boneca") a uma versão menor do problema, até encontrar a última boneca, que não pode ser aberta. Este "ponto de parada" é o que chamamos de caso base em recursão.

Características de problemas recursivos:
  • Divisão em Subproblemas: O problema pode ser decomposto em instâncias menores de si mesmo. (Ex: Calcular o fatorial de 5 é o mesmo que 5 * fatorial de 4).
  • Caso Base: Existe pelo menos uma versão "mínima" do problema cuja solução é conhecida e não requer mais divisões. (Ex: O fatorial de 0 é 1. Este é o caso base).
  • Passo Recursivo: A solução para o problema maior é construída a partir da solução dos subproblemas.
Exemplos clássicos incluem: cálculo de fatorial, a sequência de Fibonacci, navegação em estruturas de dados de árvore (como diretórios de arquivos) e algoritmos de ordenação como o QuickSort.

Por que usar recursão? Ela permite soluções elegantes e intuitivas para problemas complexos, especialmente aqueles com estruturas hierárquicas ou repetitivas. No entanto, deve ser usada com cuidado devido às limitações de memória e eficiência.
QUESTÃO 02
Aplicações Práticas da Recursão
Navegação em diretórios, Problema de Josephus e exemplos matemáticos.

Descreva matematicamente como seria um algoritmo recursivo para realizar a navegação por todos os subdiretórios de um diretório qualquer. Apresente uma solução recursiva para o Problema de Josephus.


Navegação Recursiva por Diretórios:

Matematicamente, podemos descrever a navegação por um sistema de arquivos como uma função que opera sobre um diretório $D$. A estrutura é inerentemente recursiva, pois um diretório pode conter tanto arquivos quanto outros diretórios (subproblemas).

Seja uma função $V(D)$ que visita todo o conteúdo de um diretório $D$. Podemos defini-la da seguinte forma:


  1. Para cada arquivo $f$ em $D$, processe $f$.
  2. Para cada subdiretório $S$ em $D$, execute $V(S)$.

O caso base ocorre quando um diretório $D$ está vazio ou contém apenas arquivos, sem subdiretórios. Nesse ponto, a função processa os arquivos e termina, sem fazer novas chamadas recursivas.

Formalmente, podemos escrever: $V(D) = \{ \text{listar arquivos}(D) \} \cup \{ V(S) \mid S \in \text{Subdiretórios}(D) \}$

import os
def visitar(diretorio):
print(diretorio)
for item in os.listdir(diretorio):
caminho = os.path.join(diretorio, item)
if os.path.isdir(caminho):
visitar(caminho)

O Problema de Josephus:

Neste problema, $n$ pessoas estão em um círculo, e a cada passo, a $k$-ésima pessoa é eliminada. O objetivo é encontrar a posição do último sobrevivente. A solução recursiva se baseia na observação de que, após a primeira eliminação, o problema se reduz a um círculo com $n-1$ pessoas, mas com um ponto de partida diferente.


A relação de recorrência é: $J(n, k) = (J(n-1, k) + k - 1) \pmod n + 1$, onde $J(n,k)$ é a posição do sobrevivente. O caso base é $J(1, k) = 1$, pois com uma única pessoa, ela é a sobrevivente. Uma variante comum é $J(n,k) = (J(n-1,k) + k) \mod n$, com $J(1,k)=0$ (base 0).

def josephus(n, k):
if n == 1:
return 0
return (josephus(n-1, k) + k) % n

print(josephus(7, 3))
3
def josephus_base1(n, k):
"""Resolve o Problema de Josephus para n pessoas e passo k. Retorna a posição do último sobrevivente (base 1)."""
if n == 1:
return 1
else:
return (josephus_base1(n - 1, k) + k - 1) % n + 1

print(josephus_base1(7, 3))
4
O Problema de Josephus ilustra como a recursão pode modelar processos circulares e eliminação sequencial. É usado em teoria de jogos e algoritmos de simulação. Para n=7, k=3, o sobrevivente é a posição 4 (base 1) ou 3 (base 0), dependendo da implementação.
QUESTÃO 03
Limitações da Recursão e Otimização com Programação Dinâmica
Problemas comuns, stack overflow, redundância e técnicas de memoização.

Quais são os problemas mais comuns relacionados com implementações recursivas? Como podemos lidar com essas situações? Considere uma implementação recursiva da Série de Fibonacci. Descreva como poderíamos usar técnicas de Programação Dinâmica para reduzir sua complexidade.


Apesar de sua elegância, a recursão possui duas armadilhas comuns:


  1. Estouro da Pilha de Execução (Stack Overflow): Cada chamada recursiva ocupa memória na pilha. Em chamadas muito profundas, isso pode exceder a capacidade, encerrando o programa com erro.
  2. Redundância de Cálculos: Muitos subproblemas são resolvidos repetidamente. Isso pode gerar complexidade exponencial, tornando o algoritmo impraticável para valores maiores.

O Problema: Redundância na Recursão Ingênua

A implementação recursiva clássica $fib(n) = fib(n-1) + fib(n-2)$ sofre de redundância severa. Veja o código:


        # Complexidade de tempo: O(2^n)
        def fib_recursivo_ingenuo(n):
            if n <= 1:
                return n
            return fib_recursivo_ingenuo(n-1) + fib_recursivo_ingenuo(n-2)
        

Ao calcular fib_recursivo_ingenuo(5), a mesma função é chamada múltiplas vezes para o mesmo valor. Por exemplo, fib(3) é calculado 2 vezes e fib(2) é calculado 3 vezes, gerando uma árvore de chamadas ineficiente e explosiva.

A Solução com Programação Dinâmica!

Podemos resolver esse problema com Programação Dinâmica (PD), que consiste em decompor um problema em subproblemas menores, resolvê-los uma única vez e armazenar (ou "memorizar") seus resultados para reutilização.


  • Top-Down com Memoização: A abordagem recursiva é mantida, mas os resultados já calculados são guardados. Antes de calcular, verificamos se o resultado já existe.
  • Bottom-Up com Tabulação: Construímos uma tabela de resultados de forma iterativa, começando dos casos base até o valor desejado, eliminando a recursão.

Ambas as técnicas reduzem a complexidade de tempo de $O(2^n)$ para $O(n)$.

Exemplo 1: Top-Down com Memoização


        # Complexidade de tempo O(n), Complexidade de espaço O(n)
        def fib_memoizacao(n, memo=None):
            if memo is None:
                memo = {}
            if n in memo:
                return memo[n]
            if n <= 1:
                return n
            memo[n] = fib_memoizacao(n-1, memo) + fib_memoizacao(n-2, memo)
            return memo[n]
        

Exemplo 2: Bottom-Up com Tabulação


        # Complexidade de tempo O(n), Complexidade de espaço O(n)
        def fib_tabulacao(n):
            if n <= 1:
                return n
            dp = [0] * (n + 1)
            dp[1] = 1
            for i in range(2, n + 1):
                dp[i] = dp[i-1] + dp[i-2]
            return dp[n]
        

Complemento: Otimizando o Espaço para O(1)

Na abordagem de tabulação, percebemos que para calcular o próximo número, só precisamos dos dois anteriores. Isso nos permite descartar a tabela inteira e usar apenas duas variáveis.


        # Complexidade de tempo O(n), Complexidade de espaço O(1)
        def fib_otimizado(n):
            if n <= 1:
                return n
            a, b = 0, 1
            for _ in range(2, n + 1):
                a, b = b, a + b
            return b
        

Assim, a Programação Dinâmica não apenas evita cálculos redundantes, como também previne riscos de estouro da pilha na sua forma iterativa, oferecendo soluções eficientes e escaláveis.

QUESTÃO 04
Implementações Avançadas em Python
Fibonacci otimizado, QuickSort, QuickSelect e comparações de eficiência.

Implemente sua solução otimizada para a Série de Fibonacci, utilizando Programação Dinâmica. Implemente, em Python, sem utilizar funções prontas da linguagem, o algoritmo QuickSort. Implemente, em Python, sem utilizar funções prontas da linguagem, o algoritmo QuickSelect.


Série de Fibonacci Otimizada com Programação Dinâmica (Memoização)
memo_fib = {} # Cache para armazenar resultados
def fibonacci_dinamico(n):
if n in memo_fib:
return memo_fib[n]
if n <= 1:
return n
resultado = fibonacci_dinamico(n - 1) + fibonacci_dinamico(n - 2)
memo_fib[n] = resultado
return resultado

print(fibonacci_dinamico(10))
55
Fibonacci Bottom-Up (Iterativo)
def fib_bottom_up(n):
if n <= 1:
return n
dp = [0, 1]
for i in range(2, n+1):
dp.append(dp[i-1] + dp[i-2])
return dp[n]

print(fib_bottom_up(10))
55
Algoritmo QuickSort (Recursivo)
def quick_sort(arr):
if len(arr) < 2:
return arr
pivo = arr[len(arr) // 2]
menores = [x for x in arr if x < pivo]
iguais = [x for x in arr if x == pivo]
maiores = [x for x in arr if x > pivo]
return quick_sort(menores) + iguais + quick_sort(maiores)

print(quick_sort([3,6,8,10,1,2,1]))
[1, 1, 2, 3, 6, 8, 10]
QuickSort com Pivô no Início
def quicksort(arr):
if len(arr) <= 1:
return arr
pivo = arr[0]
menores = [x for x in arr[1:] if x <= pivo]
maiores = [x for x in arr[1:] if x > pivo]
return quicksort(menores) + [pivo] + quicksort(maiores)

print(quicksort([3,6,8,10,1,2,1]))
[1, 1, 2, 3, 6, 8, 10]
Algoritmo QuickSelect (k-ésimo menor elemento)
O QuickSelect é uma otimização do QuickSort para encontrar o k-ésimo menor elemento de uma lista. Em vez de ordenar a lista inteira, ele descarta a partição que não contém o elemento desejado, reduzindo a complexidade média para $O(n)$.
def quick_select(arr, k):
if not arr:
return None
pivo = arr[-1]
menores = [x for x in arr[:-1] if x <= pivo]
maiores = [x for x in arr[:-1] if x > pivo]
tamanho_menores = len(menores)
if k == tamanho_menores + 1:
return pivo
elif k <= tamanho_menores:
return quick_select(menores, k)
else:
return quick_select(maiores, k - tamanho_menores - 1)

print(quick_select([7,10,4,3,20,15], 3))
7
Comparando implementações: A versão bottom-up do Fibonacci evita recursão, prevenindo stack overflow. QuickSort varia com a escolha do pivô, afetando eficiência. QuickSelect é ideal para seleções parciais, não ordenação completa.