0Pricing
DSA Interview Prep · Aula

Reconhecendo DP: Subproblemas Sobrepostos

Identifique quando a recursão por força bruta resolve o mesmo subproblema novamente, desenhe a árvore de recursão de Fibonacci e observe o crescimento exponencial.

Reconhecendo DP: Subproblemas Sobrepostos é uma aula grátis de DSA Interview Prep no CoddyKit. Esta é a aula 1 de 4. Você pode ler a aula completa abaixo gratuitamente — depois pratica ao vivo no navegador com um editor de código integrado e um tutor de IA 24/7. Faz parte do caminho de aprendizado de DSA Interview Prep, e seu progresso é sincronizado entre a web e o app CoddyKit. O curso de DSA Interview Prep inclui 4 aulas no total.

O que é programação dinâmica

Programação dinâmica (DP) resolve problemas complexos dividindo-os em subproblemas mais simples e sobrepostos, resolvendo cada subproblema uma única vez e armazenando o resultado para evitar cálculos redundantes. A DP se aplica quando um problema tem dois elementos: subproblemas sobrepostos (o mesmo subproblema é resolvido várias vezes em uma recursão ingênua) e estrutura ótima (a solução ótima pode ser construída a partir de soluções ótimas para os subproblemas). Sem os dois elementos, a DP não ajuda.

# Two ingredients of DP:
# 1. Overlapping sub-problems:
#    fib(5) -> fib(4) + fib(3)
#    fib(4) -> fib(3) + fib(2)  <- fib(3) computed twice!
#    Without caching: O(2^n) calls for Fibonacci

# 2. Optimal substructure:
#    Shortest path from A to C through B:
#    shortest(A,C) = shortest(A,B) + shortest(B,C)
#    The sub-path A->B must itself be the shortest

# Contrast with greedy: greedy makes one locally optimal
# choice; DP tries all choices and picks the best.
print('DP = overlapping sub-problems + optimal substructure')

Fibonacci: o ponto de entrada clássico para DP

A sequência de Fibonacci (fib(n) = fib(n-1) + fib(n-2)) é o exemplo canônico de subproblemas sobrepostos. A recursão ingênua tem complexidade de tempo exponencial O(2^n), pois recalcula os mesmos valores repetidamente. A árvore de recursão de fib(6) mostra fib(3) sendo calculada 3 vezes, fib(2) 5 vezes e assim por diante. Essa explosão exponencial é exatamente o que a DP elimina ao armazenar os resultados calculados.

import time

def fib_naive(n):
    if n <= 1:
        return n
    return fib_naive(n-1) + fib_naive(n-2)

# Count the calls:
call_count = [0]
def fib_count(n):
    call_count[0] += 1
    if n <= 1: return n
    return fib_count(n-1) + fib_count(n-2)

fib_count(10)
print(f'Calls for fib(10): {call_count[0]}')  # 177 calls for n=10!

call_count[0] = 0
fib_count(20)
print(f'Calls for fib(20): {call_count[0]}')  # 21891 calls
# n=30 -> ~2.7 million calls: exponential growth

Visualizando a árvore de recursão

Desenhar a árvore de recursão de fib(5) revela o desperdício: cada nó gera dois filhos, e subárvores idênticas aparecem repetidamente. O número total de nós na árvore é O(2^n). Quando você vê este padrão — chamadas de função idênticas com os mesmos argumentos repetidas na árvore — isso indica que a DP pode ajudar armazenando os resultados em cache. Essa habilidade de visualização é essencial: se você conseguir identificar as subárvores repetidas, saberá que a DP é aplicável.

# fib(5) recursion tree (simplified):
#                fib(5)
#               /       \
#           fib(4)     fib(3)
#           /    \     /    \
#       fib(3) fib(2) fib(2) fib(1)
#       /   \       \       
#   fib(2) fib(1) fib(1)   
#   /   \
# fib(1) fib(0)

# fib(3) appears TWICE
# fib(2) appears THREE TIMES
# Each redundant call wastes exponential time

# Key insight: fib(n) only has O(n) DISTINCT sub-problems
# (fib(0), fib(1), ..., fib(n))
# DP computes each ONCE -> O(n) total
print('Distinct sub-problems: O(n) but naive calls: O(2^n)')

Identificando subproblemas sobrepostos

Para reconhecer subproblemas sobrepostos, escreva a recursão de força bruta e depois pergunte: 'há várias chamadas recursivas com argumentos SAME?' Se sim, a DP pode ajudar. Sinais comuns nas descrições de problemas: 'número mínimo/máximo de X', 'quantas maneiras de fazer Y', 'podemos alcançar Z?'. Esses padrões de formulação quase sempre indicam um problema de estrutura ótima, no qual a resposta na posição i depende das respostas em posições anteriores.

# DP signal phrases in problem statements:
# 'minimum number of coins to make amount X'
# 'maximum profit from stock trades'
# 'number of ways to climb n stairs'
# 'can you reach the last index?'
# 'longest common subsequence'
# 'edit distance between two strings'

# All have this shape:
# solve(input) = f(solve(smaller_input_1), solve(smaller_input_2), ...)
# And multiple branches end up calling solve with the same argument.

# If the recursion tree has repeated nodes: DP
# If subproblems are all independent: divide-and-conquer (no DP needed)
print('Repeated arguments in recursion tree -> DP')

Estrutura ótima explicada

Estrutura ótima significa que a solução ótima do problema pode ser construída a partir de soluções ótimas para seus subproblemas. Por exemplo, o caminho mais curto de A até C passando por B é ótimo se, e somente se, os subcaminhos A→B e B→C forem individualmente ótimos. Se essa propriedade for válida, você poderá construir a solução ótima global de baixo para cima a partir de ótimos locais. Problemas sem estrutura ótima (por exemplo, o caminho mais longo em um grafo geral com ciclos) não podem ser resolvidos com DP.

# Optimal substructure examples:

# SHORTEST PATH: shortest(A,C) = min over all B: shortest(A,B) + w(B,C)
# -> Sub-paths must be optimal: YES, has optimal substructure

# LONGEST PATH (no cycles, DAG): can also use DP
# -> Longer path through node B means sub-path A->B must be longest

# LONGEST PATH (with cycles): NO optimal substructure
# -> Best path from A to C might reuse nodes: sub-problems not independent

# COIN CHANGE: min coins for amount n = 1 + min(min coins for n-coin_i)
# -> YES: optimal for n-coin_i is needed for optimal n

print('Optimal substructure: build global optimum from local optima')

Subindo escadas: sua primeira DP

Subindo escadas (LeetCode #70): quantas maneiras distintas existem de subir n degraus, dando 1 ou 2 passos por vez? Defina dp[i] como o número de maneiras de chegar ao degrau i. Você pode chegar ao degrau i a partir do degrau i-1 (um passo) ou do degrau i-2 (dois passos), portanto dp[i] = dp[i-1] + dp[i-2]. Isto é Fibonacci! Casos-base: dp[1] = 1, dp[2] = 2. Reconhecer que 'subir escadas' se reduz a Fibonacci é uma percepção clássica em entrevistas.

def climb_stairs(n):
    if n <= 2:
        return n
    dp = [0] * (n + 1)
    dp[1] = 1  # 1 way to reach step 1
    dp[2] = 2  # 2 ways to reach step 2: (1+1) or (2)
    for i in range(3, n + 1):
        dp[i] = dp[i-1] + dp[i-2]  # come from i-1 or i-2
    return dp[n]

for n in range(1, 8):
    print(f'climb_stairs({n}) = {climb_stairs(n)}')
# 1, 2, 3, 5, 8, 13, 21 -- Fibonacci sequence!

A estrutura de DP: definir, formular a recorrência, ordenar

Uma estrutura confiável de DP em 3 etapas: 1. Defina o estado — o que dp[i] (ou dp[i][j]) representa? Escreva isso em inglês. 2. Escreva a recorrência — expresse dp[i] em termos de subproblemas menores. Inclua todos os casos. 3. Determine a ordem de preenchimento — certifique-se de que dp[i-1] (e as outras dependências) sejam calculados antes de dp[i]. Os casos-base inicializam a borda. Essa estrutura transforma a intuição vaga de DP em um plano concreto de implementação.

# Framework applied to climbing stairs:
# Step 1 - Define state:
#   dp[i] = number of distinct ways to reach step i
# Step 2 - Recurrence:
#   dp[i] = dp[i-1] + dp[i-2]  (come from step i-1 or i-2)
# Step 3 - Fill order:
#   Compute dp[1], dp[2], dp[3], ..., dp[n] in order
#   Because dp[i] depends on dp[i-1] and dp[i-2] (smaller)
# Base cases: dp[1]=1, dp[2]=2

# Framework applied to coin change:
# Step 1: dp[amount] = minimum coins to make that amount
# Step 2: dp[i] = 1 + min(dp[i-coin] for coin in coins if i >= coin)
# Step 3: Fill i from 1 to amount
# Base: dp[0] = 0 (zero coins for zero amount)
print('DP framework: define state -> recurrence -> fill order')

Quando NOT usar DP

DP nem sempre é a resposta. Use uma abordagem gulosa quando uma única escolha localmente ótima sempre levar à solução globalmente ótima (seleção de atividades, jogo de saltos I). Use divisão e conquista quando os subproblemas não se sobrepõem (ordenação por intercalação, busca binária). Use BFS quando o problema for encontrar o caminho mais curto em um grafo não ponderado. DP está correta, mas muitas vezes é exagerada quando existe uma abordagem gulosa ou mais simples. Em entrevistas, discuta por que você escolheu DP em vez das alternativas.

# DP vs alternatives:
# Problem: can you jump to the end of the array?
#   Greedy: track max reachable index -> O(n) O(1) BETTER than DP
# Problem: shortest path unweighted graph?
#   BFS: O(V+E) BETTER than DP on general graph
# Problem: sort an array?
#   Comparison sort: O(n log n), no DP needed

# DP IS the right choice when:
# - Greedy fails (choices interact)
# - Need to count/enumerate all possibilities
# - Problem has 'how many ways' or 'minimum/maximum' flavor
# - Recursion tree clearly shows overlapping sub-problems
print('Ask: does greedy fail? If yes, consider DP.')

Contagem de subproblemas distintos

O número de subproblemas distintos determina a complexidade de tempo e de espaço da DP. Em uma DP 1D para uma entrada de tamanho n, há O(n) subproblemas. Em uma DP 2D para duas entradas de tamanhos m e n, há O(mn) subproblemas. Cada subproblema é resolvido em O(k) tempo (para k escolhas em cada etapa), resultando em um tempo total de O(n*k) ou O(mn*k). Conte sempre primeiro os subproblemas distintos — isso fornece a complexidade de tempo da DP antes mesmo de você escrever o código.

# Sub-problem count examples:
# Problem          | Sub-problems  | Each costs | Total
# Fibonacci        | O(n)          | O(1)       | O(n)
# Coin change      | O(amount)     | O(coins)   | O(amount * coins)
# LCS (m,n chars) | O(m*n)        | O(1)       | O(m*n)
# Edit distance    | O(m*n)        | O(1)       | O(m*n)
# 0/1 Knapsack    | O(n*W)        | O(1)       | O(n*W)
# Matrix chain     | O(n^2)        | O(n)       | O(n^3)

# Rule: DP time = (# distinct sub-problems) * (time per sub-problem)
print('Time = subproblems * work-per-subproblem')

Roubo de casas: escolhas sobrepostas

Roubo de casas (LeetCode #198) solicita o valor máximo que pode ser roubado de casas enfileiradas sem roubar casas adjacentes. Em cada casa, escolha: roubar a casa (adicionar seu valor e pular a anterior) ou ignorá-la (usar o melhor resultado da anterior). dp[i] = max(dp[i-1], dp[i-2] + nums[i]). Esse padrão de escolha em cada etapa é a recorrência de DP 1D mais simples e aparece em dezenas de problemas de entrevista.

def rob(nums):
    if not nums: return 0
    if len(nums) == 1: return nums[0]
    dp = [0] * len(nums)
    dp[0] = nums[0]
    dp[1] = max(nums[0], nums[1])
    for i in range(2, len(nums)):
        dp[i] = max(dp[i-1],          # skip house i
                    dp[i-2] + nums[i]) # rob house i
    return dp[-1]

print(rob([1, 2, 3, 1]))   # 4: rob house 0 and 2 (1+3)
print(rob([2, 7, 9, 3, 1]))# 12: rob house 0, 2, 4 (2+9+1)
print(rob([2, 1, 1, 2]))   # 4: rob house 0 and 3

Verificação de consistência: força bruta versus DP

Sempre valide sua solução de DP comparando-a com uma solução de força bruta para entradas pequenas. A força bruta é sua referência correta. Quando a DP coincidir com a força bruta em todos os casos de teste, você saberá que a recorrência está correta. Só então otimize o espaço. Essa abordagem orientada por testes — força bruta → DP de cima para baixo → DP de baixo para cima → DP com espaço otimizado — é a forma profissional de desenvolver e verificar soluções de DP durante uma entrevista.

# Brute-force for house robber (exponential)
def rob_brute(nums, i=0):
    if i >= len(nums):
        return 0
    # Option 1: rob house i
    rob_it = nums[i] + rob_brute(nums, i + 2)
    # Option 2: skip house i
    skip_it = rob_brute(nums, i + 1)
    return max(rob_it, skip_it)

# Verify on small inputs:
test_cases = [[1,2,3,1], [2,7,9,3,1], [2,1,1,2]]
for tc in test_cases:
    bf = rob_brute(tc)
    dp = rob(tc)
    print(f'{tc}: brute={bf}, dp={dp}, match={bf==dp}')

Verificação rápida

Teste sua compreensão dos conceitos de Estruturas de Dados & Algoritmos — Preparação para Entrevistas de Programação desta lição.

Recapitulação da lição

Nesta lição, você aprendeu: dois ingredientes da DP (subproblemas sobrepostos e subestrutura ótima), como visualizar a árvore de recursão para identificar chamadas repetidas, a estrutura de DP em três etapas (definir o estado, a recorrência e a ordem de preenchimento) e os primeiros exemplos, incluindo Fibonacci, subida de escadas e roubo de casas. A seguir, implementaremos DP de cima para baixo com memoização.

Perguntas Frequentes

A aula “Reconhecendo DP: Subproblemas Sobrepostos” é grátis?

Sim — o texto completo de “Reconhecendo DP: Subproblemas Sobrepostos” é grátis para ler aqui na web. Para praticá-la interativamente (um editor de código integrado e um tutor de IA 24/7) e desbloquear o restante do curso de DSA Interview Prep, atualize para CoddyKit PRO. O curso de DSA Interview Prep inclui 4 aulas no total.

O que vou aprender em “Reconhecendo DP: Subproblemas Sobrepostos”?

Identifique quando a recursão por força bruta resolve o mesmo subproblema novamente, desenhe a árvore de recursão de Fibonacci e observe o crescimento exponencial. Você pratica DSA Interview Prep com código prático que executa diretamente no navegador, e um tutor de IA 24/7 responde suas dúvidas enquanto trabalha na aula.

Preciso ter experiência prévia para começar DSA Interview Prep?

Nenhuma experiência prévia é necessária. DSA Interview Prep no CoddyKit é estruturado para alunos iniciantes até avançados, então você pode começar aqui ou desde o início e aprender no seu ritmo. Esta é a aula 1 de 4.

Quanto tempo leva a aula “Reconhecendo DP: Subproblemas Sobrepostos”?

A maioria das aulas CoddyKit leva cerca de 5–10 minutos. Cada uma é compacta e interativa, então você faz progresso constante e retoma exatamente de onde parou entre web e app.

Posso escrever e executar código nesta aula de DSA Interview Prep?

Sim. Cada aula de DSA Interview Prep inclui um editor de código integrado, então você escreve e executa código real direto no navegador e recebe feedback de IA instantaneamente — nenhuma configuração local necessária.

Todas as aulas deste curso

  1. Reconhecendo DP: Subproblemas Sobrepostos
  2. DP de Cima para Baixo com Memoização
  3. DP de Baixo para Cima com Tabulação
  4. Troca de Moedas e Escada de Custo Mínimo
← Voltar para DSA Interview Prep