Recursão e Estruturas de Dados Avançadas: pilhas, filas e árvores

Recursão e Estruturas de Dados Avançadas: pilhas, filas e árvores

Recursão, pilhas, filas e árvores, com o que os materiais costumam omitir: por que a fila de prioridade quebra quando duas tarefas empatam, por que a árvore binária de busca deste tipo levanta RecursionError com dados ordenados, e quando a recursão vira a escolha errada.
Python

• • 21 min de leitura

Recursão e estruturas de dados avançadas são o ponto em que a programação começa a se parecer com engenharia. Pilhas, filas e árvores não são abstrações acadêmicas — estão presentes no histórico de navegação do seu browser, na fila de impressão do seu sistema operacional e na estrutura de pastas do seu computador. Entendê-las torna você capaz de resolver problemas que listas e dicionários simples não conseguem modelar bem.

Recursão

Uma função recursiva é aquela que chama a si mesma. Todo problema recursivo tem dois componentes obrigatórios:

  • Caso base — a condição que encerra as chamadas
  • Caso recursivo — a chamada que reduz o problema

Sem caso base, a recursão nunca termina e o Python lança RecursionError.

Exemplo clássico: fatorial

def fatorial(n):
    """
    fatorial(5) = 5 * 4 * 3 * 2 * 1 = 120
    Caso base: fatorial(0) = 1
    """
    if n == 0:          # caso base
        return 1
    return n * fatorial(n - 1)   # caso recursivo


print(fatorial(5))   # 120
print(fatorial(10))  # 3628800

Visualizando as chamadas:

fatorial(5)
  └─ 5 * fatorial(4)
           └─ 4 * fatorial(3)
                    └─ 3 * fatorial(2)
                             └─ 2 * fatorial(1)
                                      └─ 1 * fatorial(0)
                                                └─ 1  ← caso base

Sequência de Fibonacci

def fibonacci(n):
    """Retorna o n-ésimo número de Fibonacci."""
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)


for i in range(10):
    print(fibonacci(i), end=" ")
# 0 1 1 2 3 5 8 13 21 34

Esta implementação é correta, mas ineficiente — O(2ⁿ). O mesmo valor é calculado várias vezes. A solução é o memoization:

from functools import lru_cache

@lru_cache(maxsize=None)
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)


print(fibonacci(50))   # 12586269025 — instantâneo
print(fibonacci(100))  # 354224848179261915075

O decorator @lru_cache armazena resultados já calculados, reduzindo a complexidade para O(n).

Limite de recursão

O Python limita a profundidade de recursão para evitar estouro de pilha:

import sys
print(sys.getrecursionlimit())  # 1000 por padrão

sys.setrecursionlimit(5000)     # aumentar se necessário, com cautela

Para problemas com profundidade muito grande, prefira a versão iterativa.

Pilhas (Stacks)

Uma pilha segue o princípio LIFO — Last In, First Out. O último elemento inserido é o primeiro a sair. Pense em uma pilha de pratos.

Inserir (push): [ ] → [A] → [A,B] → [A,B,C]
Remover (pop):  [A,B,C] → [A,B] → [A] → [ ]

Em Python, listas já implementam pilhas eficientemente:

pilha = []

# push — adiciona no topo
pilha.append("página inicial")
pilha.append("produtos")
pilha.append("detalhes do produto")

print(pilha)
# ['página inicial', 'produtos', 'detalhes do produto']

# pop — remove do topo
pagina_atual = pilha.pop()
print(pagina_atual)  # detalhes do produto
print(pilha)         # ['página inicial', 'produtos']

Implementando uma classe Pilha

class Pilha:
    def __init__(self):
        self._dados = []

    def push(self, item):
        self._dados.append(item)

    def pop(self):
        if self.vazia():
            raise IndexError("Pop em pilha vazia.")
        return self._dados.pop()

    def topo(self):
        if self.vazia():
            raise IndexError("Pilha vazia.")
        return self._dados[-1]

    def vazia(self):
        return len(self._dados) == 0

    def tamanho(self):
        return len(self._dados)

    def __repr__(self):
        return f"Pilha({self._dados})"


# Uso: verificador de parênteses balanceados
def parenteses_balanceados(expressao):
    """Verifica se os parênteses, colchetes e chaves estão balanceados."""
    pilha = Pilha()
    abre = "({["
    fecha = ")}]"
    pares = {")": "(", "}": "{", "]": "["}

    for char in expressao:
        if char in abre:
            pilha.push(char)
        elif char in fecha:
            if pilha.vazia() or pilha.pop() != pares[char]:
                return False

    return pilha.vazia()


print(parenteses_balanceados("(a + b) * [c - d]"))   # True
print(parenteses_balanceados("(a + b] * [c - d)"))   # False
print(parenteses_balanceados("{[()]}"))               # True
print(parenteses_balanceados("{[(])}"))               # False

Filas (Queues)

Uma fila segue o princípio FIFO — First In, First Out. O primeiro a entrar é o primeiro a sair. Pense em uma fila de banco.

Listas não são ideais para filas — remover do início (pop(0)) é O(n). Use collections.deque:

from collections import deque

fila = deque()

# enqueue — adiciona no final
fila.append("Cliente A")
fila.append("Cliente B")
fila.append("Cliente C")

print(fila)  # deque(['Cliente A', 'Cliente B', 'Cliente C'])

# dequeue — remove do início — O(1)
proximo = fila.popleft()
print(proximo)  # Cliente A
print(fila)     # deque(['Cliente B', 'Cliente C'])

Fila de prioridade com heapq

Quando a ordem de saída depende de uma prioridade e não da chegada:

import heapq

fila_prioridade = []

# (prioridade, tarefa) — menor número = maior prioridade
heapq.heappush(fila_prioridade, (3, "Enviar relatório"))
heapq.heappush(fila_prioridade, (1, "Corrigir bug crítico"))
heapq.heappush(fila_prioridade, (2, "Revisar pull request"))
heapq.heappush(fila_prioridade, (1, "Atender chamado urgente"))

while fila_prioridade:
    prioridade, tarefa = heapq.heappop(fila_prioridade)
    print(f"[P{prioridade}] {tarefa}")

# [P1] Atender chamado urgente
# [P1] Corrigir bug crítico
# [P2] Revisar pull request
# [P3] Enviar relatório

Olhe a ordem das duas tarefas de prioridade 1. O chamado urgente foi inserido por último e saiu primeiro — não por alguma regra de desempate do heapq, mas porque, empatada a prioridade, a comparação passa para o segundo elemento da tupla, e "Atender…" vem antes de "Corrigir…" no alfabeto. O heapq não é estável e não preserva ordem de chegada.

Isso é mais que uma curiosidade, porque quebra assim que a tarefa deixa de ser texto:

heapq.heappush(fila, (1, {"id": 1}))
heapq.heappush(fila, (1, {"id": 2}))
# TypeError: '<' not supported between instances of 'dict' and 'dict'

Com dicionários, objetos ou qualquer coisa sem ordem definida, o empate de prioridade derruba o programa — e só quando dois itens empatam, o que faz o defeito escapar dos testes. A solução canônica é acrescentar um contador monotônico como segundo elemento, que nunca empata e ainda devolve a ordem de chegada de brinde:

import itertools

contador = itertools.count()
fila = []

heapq.heappush(fila, (1, next(contador), {"id": "primeiro"}))
heapq.heappush(fila, (1, next(contador), {"id": "segundo"}))
# desempata pela chegada, e o terceiro elemento nunca é comparado

Árvores

Uma árvore é uma estrutura hierárquica composta por nós. Cada nó tem um valor e pode ter nós filhos. O nó sem pai é chamado de raiz.

        10          ← raiz
       /  \
      5    15       ← filhos da raiz
     / \     \
    3   7     20    ← folhas

Árvore Binária de Busca (BST)

Na BST, para cada nó: todos os valores à esquerda são menores, todos à direita são maiores.

class No:
    def __init__(self, valor):
        self.valor = valor
        self.esquerda = None
        self.direita = None


class ArvoreBinariaBusca:
    def __init__(self):
        self.raiz = None

    def inserir(self, valor):
        self.raiz = self._inserir(self.raiz, valor)

    def _inserir(self, no, valor):
        if no is None:
            return No(valor)
        if valor < no.valor:
            no.esquerda = self._inserir(no.esquerda, valor)
        elif valor > no.valor:
            no.direita = self._inserir(no.direita, valor)
        return no

    def buscar(self, valor):
        return self._buscar(self.raiz, valor)

    def _buscar(self, no, valor):
        if no is None:
            return False
        if valor == no.valor:
            return True
        if valor < no.valor:
            return self._buscar(no.esquerda, valor)
        return self._buscar(no.direita, valor)

    def em_ordem(self):
        """Percurso em ordem — retorna valores em ordem crescente."""
        resultado = []
        self._em_ordem(self.raiz, resultado)
        return resultado

    def _em_ordem(self, no, resultado):
        if no:
            self._em_ordem(no.esquerda, resultado)
            resultado.append(no.valor)
            self._em_ordem(no.direita, resultado)


arvore = ArvoreBinariaBusca()
for valor in [10, 5, 15, 3, 7, 20]:
    arvore.inserir(valor)

print(arvore.em_ordem())    # [3, 5, 7, 10, 15, 20]
print(arvore.buscar(7))     # True
print(arvore.buscar(99))    # False

O que esta árvore não faz: balancear

A promessa de busca em O(log n) vale para uma árvore balanceada, e esta implementação não balanceia nada — ela apenas insere onde a comparação mandar. Enquanto os valores chegam embaralhados, o resultado é aceitável; quando chegam ordenados, cada novo valor é sempre maior que o anterior e vai sempre para a direita. A árvore vira uma lista ligada com passos extras, e a busca volta a ser O(n).

Medindo com mil valores na 3.14:

1000 valores embaralhados -> altura 21   (log2(1000) é cerca de 10)
1000 valores ordenados    -> RecursionError: maximum recursion depth exceeded

O caso ordenado não fica apenas lento: ele quebra. Como a inserção é recursiva e desce um nível por elemento, mil inserções em ordem crescente significam mil chamadas aninhadas, e o limite padrão de recursão é exatamente 1000. E ordenado é o caso mais comum que existe em dado real — registros lidos de um banco por identificador, eventos por data, linhas de um arquivo já classificado. É a entrada mais provável, e é a que derruba.

Escrever uma árvore que se reequilibra sozinha, como AVL ou rubro-negra, é assunto de outro nível e raramente necessário: para busca por chave, o dicionário do Python já entrega tempo praticamente constante, com muito menos código. A BST vale como exercício de estrutura e para os casos em que se precisa percorrer em ordem ou consultar faixas — e, mesmo aí, uma lista ordenada com bisect costuma resolver melhor. Implementá-la ensina; usá-la em produção exige saber o que ela não garante.

Exemplo Completo: Avaliador de Expressões

Usando pilha para avaliar expressões matemáticas em notação pós-fixada (RPN — Reverse Polish Notation):

def avaliar_rpn(expressao):
    """
    Avalia expressão em notação pós-fixada.
    Exemplo: "3 4 + 2 *" = (3 + 4) * 2 = 14
    """
    pilha = []
    operadores = {
        "+": lambda a, b: a + b,
        "-": lambda a, b: a - b,
        "*": lambda a, b: a * b,
        "/": lambda a, b: a / b,
    }

    for token in expressao.split():
        if token in operadores:
            b = pilha.pop()
            a = pilha.pop()
            resultado = operadores[token](a, b)
            pilha.append(resultado)
        else:
            pilha.append(float(token))

    if len(pilha) != 1:
        raise ValueError(f"expressão malformada: sobraram {len(pilha)} valores")
    return pilha.pop()


print(avaliar_rpn("3 4 +"))         # 7.0  → 3 + 4
print(avaliar_rpn("3 4 + 2 *"))     # 14.0 → (3 + 4) * 2
print(avaliar_rpn("5 1 2 + 4 * + 3 -"))  # 14.0 → 5 + ((1+2)*4) - 3

Repare na guarda antes do return. A versão natural de escrever essa função termina com return pilha[0], e é ela que aparece na maioria dos materiais — mas devolve um número plausível para entrada malformada, em vez de recusar:

avaliar_rpn("3 4")       # 3.0  — faltou o operador, e ninguém avisa
avaliar_rpn("1 2 3 +")   # 1.0  — sobrou valor na pilha, e ninguém avisa

Nos dois casos a pilha terminou com mais de um elemento, o que é a definição de expressão incompleta, e o pilha[0] devolveu o primeiro valor que sobrou como se fosse o resultado. Numa calculadora de brinquedo isso é um incômodo; num interpretador que recebe entrada de fora, é um valor errado circulando pelo sistema sem sinal nenhum. Conferir que restou exatamente um elemento é a única linha que separa um avaliador de um gerador de resultados aleatórios — e pilha.pop() comunica melhor a intenção que pilha[0], porque deixa explícito que aquele é o último valor, não o primeiro.

As três estruturas deste artigo resolvem perguntas diferentes, e a escolha entre elas é quase sempre ditada pela ponta em que se mexe. Pilha atende quem sempre volta ao último item, e em Python a lista já faz isso bem, com append e pop. Fila atende quem atende por ordem de chegada, e aí a lista é a escolha errada, porque remover do início custa O(n) — o deque existe exatamente para isso. Fila de prioridade é o heapq, com a ressalva de que ele não é estável e, empatada a prioridade, compara o próximo elemento da tupla: com dicionários ou objetos, o empate levanta TypeError, e a correção é um contador monotônico no meio.

Recursão e árvore andam juntas porque uma é a forma natural de percorrer a outra, e as duas trazem o mesmo aviso. Toda recursão consome a pilha de chamadas, cujo limite padrão é mil, e é por isso que a árvore binária de busca deste artigo — que insere recursivamente e não reequilibra nada — não apenas fica lenta com dados ordenados: ela levanta RecursionError com mil valores em ordem crescente, que é a forma mais comum de dado chegar do mundo real. Vale implementá-la para entender como uma hierarquia se percorre; para buscar por chave em produção, o dicionário resolve melhor, com menos código e sem pré-condição que possa ser violada em silêncio.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um serviço indexa registros numa árvore binária de busca como a deste artigo. Nos testes, com dados de exemplo embaralhados, tudo funciona. Ao subir para produção, a carga inicial lê os registros do banco ordenados por identificador e o serviço morre com RecursionError antes de terminar a importação. Explique a ligação entre a ordem dos dados e o erro, e diga por que o teste não pegou.

Ver resposta

✓ Resposta: A árvore binária de busca deste artigo não se reequilibra: ela apenas compara o valor novo com o nó atual e desce para a esquerda ou para a direita. Com valores embaralhados, as descidas se distribuem entre os dois lados e a altura fica próxima de log₂(n) — medido com mil valores aleatórios, altura 21 contra os cerca de 10 do caso ideal, o que já mostra que mesmo o caso bom não é perfeito. Com valores em ordem crescente, porém, cada novo valor é maior que todos os anteriores e vai sempre para a direita: a árvore vira uma lista ligada de mil níveis. Como a inserção é recursiva e gasta um quadro de pilha por nível, mil inserções ordenadas produzem mil chamadas aninhadas, e o limite padrão de recursão do Python é exatamente 1000, o que faz o RecursionError aparecer perto do fim da carga. O teste não pegou por dois motivos que se somam. O primeiro é o volume: com poucas dezenas de registros de exemplo, nem a árvore degenerada chega perto do limite, e o defeito fica invisível. O segundo, e mais importante, é que os dados de teste estavam embaralhados enquanto os de produção vêm ordenados — e ordenado é o caso mais frequente no mundo real, porque bancos devolvem por identificador, arquivos vêm por data e exportações saem classificadas. A entrada mais provável era justamente a não testada. Aumentar o limite com sys.setrecursionlimit é um remendo que adia o problema e troca uma exceção clara por um consumo de memória maior; a busca continuaria O(n). As correções de verdade são três, em ordem de simplicidade: usar um dicionário, se o acesso é por chave, e ganhar tempo praticamente constante sem pré-condição alguma; embaralhar a carga inicial, se a árvore precisa existir; ou trocar por uma estrutura que se reequilibre, como uma AVL ou rubro-negra, o que raramente compensa escrever à mão.

Exercício 2

Uma fila de prioridade guarda tarefas como heapq.heappush(fila, (prioridade, tarefa)), em que tarefa é um dicionário. Funcionou por semanas e um dia levantou TypeError. O time observa que o erro só ocorre em horários de pico. Explique a relação com o pico e corrija.

Ver resposta

✓ Resposta: O heapq mantém a ordem comparando os elementos que recebe, e ao comparar duas tuplas o Python vai elemento a elemento: só quando o primeiro empata é que o segundo é comparado. Enquanto as prioridades forem todas distintas, o dicionário da segunda posição nunca é tocado e tudo funciona. Basta duas tarefas chegarem com a mesma prioridade para o desempate exigir comparar dicionário com dicionário, e aí vem TypeError: '<' not supported between instances of 'dict' and 'dict'. A ligação com o horário de pico é direta: quanto mais tarefas entram por minuto, maior a chance de duas dividirem a mesma prioridade ao mesmo tempo na fila. O defeito sempre esteve lá, esperando a coincidência, e é por isso que escapou dos testes — testes costumam usar prioridades distintas justamente para deixar o resultado determinístico. A correção canônica é inserir um contador monotônico entre a prioridade e o dado: contador = itertools.count() e heapq.heappush(fila, (prioridade, next(contador), tarefa)). O contador nunca empata, então o terceiro elemento jamais é comparado, e de brinde a fila passa a respeitar a ordem de chegada dentro da mesma prioridade — que quase sempre é o comportamento desejado e que o heapq sozinho não oferece, por não ser estável. Vale notar que este artigo mostra a fila com tuplas de texto e a saída sai em ordem alfabética dentro da prioridade 1, não por ordem de chegada: funciona, mas por acidente. A alternativa mais limpa em código orientado a objetos é definir __lt__ na classe da tarefa, o que torna a comparação explícita em vez de acidental.

Exercício 3

Uma função recursiva calcula o tamanho total de uma árvore de pastas somando o tamanho dos arquivos de cada nível. Funciona no computador de todos os desenvolvedores e falha num servidor específico, com RecursionError. A estrutura de pastas é a mesma nos dois lugares. Levante as hipóteses e diga como resolver sem depender de qual delas é a verdadeira.

Ver resposta

✓ Resposta: Se a estrutura é a mesma, a profundidade real não é a diferença, e há três hipóteses a investigar. A primeira é o limite configurado: sys.getrecursionlimit() devolve 1000 por padrão, mas é um valor global e mutável, e basta uma biblioteca carregada no servidor ter chamado sys.setrecursionlimit com um número menor para o teto cair para todo o processo. A segunda é a presença de links simbólicos: um link apontando para uma pasta ancestral cria um ciclo, e a função desce para sempre — a estrutura "é a mesma" na listagem, mas o servidor pode ter um link que a máquina de desenvolvimento não tem, e aí não existe profundidade máxima. A terceira é a pilha já ocupada: o limite conta quadros de chamada do processo inteiro, então se no servidor essa função é chamada de dentro de uma cadeia mais profunda — um servidor de aplicação, um manipulador de eventos, um decorador —, sobra menos espaço para a recursão dela. O que se pede, porém, é resolver sem descobrir qual é a verdadeira, e a resposta é converter a recursão em iteração com pilha explícita. Trocando as chamadas aninhadas por uma lista de pastas a visitar, com um laço que retira uma, soma seus arquivos e empilha as subpastas, o limite de recursão deixa de existir: a profundidade passa a ser limitada pela memória, não por um contador. Isso resolve a primeira e a terceira hipóteses de uma vez. Para a segunda, a proteção é guardar os caminhos já visitados num conjunto e pular os repetidos, o que trata o ciclo independentemente de ele vir de link simbólico ou de qualquer outra causa. Vale registrar a regra geral: recursão é ótima para expressar percursos de árvore com clareza, e é a escolha errada quando a profundidade depende de dados externos que não se controla. E em Python há um agravante — a linguagem não faz otimização de chamada de cauda, então nem a recursão de cauda escapa do limite.

Exercício 4

Um avaliador de expressões em notação pós-fixada termina com return pilha[0]. Os testes passam. Em produção, para certas entradas vindas de um formulário, ele devolve números que não correspondem à conta pedida — sem erro nenhum. Mostre duas entradas que produzem resultado errado calado e explique a correção.

Ver resposta

✓ Resposta: Uma expressão pós-fixada bem formada consome todos os valores e deixa exatamente um resultado na pilha ao terminar. O return pilha[0] não verifica isso — ele devolve o elemento do fundo da pilha, que só coincide com o resultado quando a expressão estava correta. Duas entradas que quebram: "3 4", em que falta o operador, deixa dois valores na pilha e a função devolve 3.0, o primeiro deles; e "1 2 3 +", em que sobra um valor, deixa [1.0, 5.0] e a função devolve 1.0, ignorando a soma que de fato calculou. Nos dois casos o retorno é um número plausível, do tipo certo, sem exceção nem aviso — o pior formato de defeito, porque não há nada para investigar depois. A correção é conferir o tamanho da pilha antes de devolver e recusar o que não fecha, com if len(pilha) != 1: raise ValueError(...). Trocar pilha[0] por pilha.pop() ajuda o leitor, porque deixa explícito que se quer o último valor, não o primeiro — e nas expressões corretas os dois são o mesmo, o que é exatamente o que mascarava o problema. Vale notar que a versão do artigo também explode com IndexError quando faltam operandos, como em "3 +", e que float(token) levanta ValueError para qualquer coisa que não seja número. Numa função que recebe texto de um formulário, essas três falhas precisam ser tratadas juntas e traduzidas numa mensagem única e compreensível — o usuário não deve ver IndexError. A lição que atravessa o exercício: validar a saída é tão necessário quanto validar a entrada, e a verificação mais barata costuma ser uma invariante de uma linha, como aqui a de que a pilha termina com um elemento só.

Exercício 5

Explique por que um verificador de parênteses balanceados precisa de uma pilha e não pode ser resolvido apenas contando aberturas e fechamentos. Em seguida, relacione esse fato com a razão de toda recursão usar internamente uma pilha.

Ver resposta

✓ Resposta: Contar resolve metade do problema e ignora a outra. Se houver um único tipo de delimitador, contar de fato basta: um contador que sobe no abre, desce no fecha, nunca fica negativo e termina em zero decide corretamente. O que a contagem não sabe é qual abertura cada fechamento está fechando, e por isso ela aprova "{[(])}", que tem três aberturas e três fechamentos perfeitamente equilibrados em número e mesmo assim está errado: o ] aparece quando o delimitador mais recente ainda aberto é o (. A informação que falta é a ordem dos que continuam abertos, e ela precisa ser consultada de trás para frente — o último aberto é o primeiro que deve ser fechado. Essa é literalmente a definição de pilha, e é por isso que a estrutura aparece: cada abertura empilha, cada fechamento desempilha e confere se o que saiu é o par correspondente. A ligação com a recursão é que ela resolve o mesmo tipo de problema com o mesmo mecanismo, só que implícito. Quando uma função chama a si mesma, o estado da chamada em andamento — variáveis locais e o ponto para onde voltar — precisa ser guardado em algum lugar até a chamada interna terminar, e recuperado na ordem inversa, porque a última chamada feita é a primeira a retornar. O interpretador mantém isso na pilha de chamadas, e é por isso que estruturas aninhadas, como expressões com parênteses, árvores e pastas dentro de pastas, se escrevem com tanta naturalidade de forma recursiva: a pilha que o problema exige já está lá, gerenciada pela linguagem. A consequência prática fecha o raciocínio nos dois sentidos. Toda recursão pode ser reescrita como laço com pilha explícita, e essa é a saída quando o limite de mil níveis atrapalha — troca-se a pilha do interpretador, que é limitada, pela pilha em memória, que não é. E quando a estrutura do problema não é aninhada, a pilha não aparece: percorrer uma lista não precisa nem de uma nem de outra.

Comentários

Mais em Python

Listas: criação, manipulação e métodos
Listas: criação, manipulação e métodos

A estrutura que aparece em praticamente todo programa Python: criação, índices…

Banco de Dados com SQLite e SQLAlchemy
Banco de Dados com SQLite e SQLAlchemy

SQLite com sqlite3 e SQLAlchemy 2.0 em Python, do CRUD ao repositório e às…

Introdução ao Desenvolvimento Web com Flask
Introdução ao Desenvolvimento Web com Flask

Flask começa com uma rota e uma função, e os erros aparecem no que ele deixa…