Распознавание DP: пересекающиеся подзадачи
Определяйте случаи, когда рекурсия методом полного перебора повторно решает одну и ту же подзадачу, рисуйте дерево рекурсии для Фибоначчи и наблюдайте экспоненциальный рост вычислений
«Распознавание DP: пересекающиеся подзадачи» — бесплатный урок DSA Interview Prep на CoddyKit. Это урок 1 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения DSA Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс DSA Interview Prep содержит 4 уроков всего.
Что такое динамическое программирование?
Динамическое программирование (DP) решает сложные задачи, разбивая их на более простые перекрывающиеся подзадачи, решая каждую подзадачу один раз и сохраняя результат, чтобы избежать повторных вычислений. DP применимо, когда у задачи есть два свойства: перекрывающиеся подзадачи (одна и та же подзадача решается многократно при наивной рекурсии) и оптимальная подструктура (оптимальное решение можно построить из оптимальных решений подзадач). Без обоих свойств DP не помогает.
# 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')Числа Фибоначчи: классический вход в DP
Последовательность Фибоначчи (fib(n) = fib(n-1) + fib(n-2)) — классический пример перекрывающихся подзадач. Наивная рекурсия имеет экспоненциальную временную сложность O(2^n), поскольку многократно пересчитывает одни и те же значения. Дерево рекурсии для fib(6) показывает, что fib(3) вычисляется 3 раза, fib(2) — 5 раз и так далее. Именно этот экспоненциальный рост DP устраняет, сохраняя уже вычисленные результаты.
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Визуализация дерева рекурсии
Построение дерева рекурсии для fib(5) показывает неэффективность: каждый узел порождает двух потомков, а одинаковые поддеревья повторяются. Общее количество узлов в дереве равно O(2^n). Когда Вы видите такой шаблон — одинаковые вызовы функции с одинаковыми аргументами, повторяющиеся в дереве, — это указывает на возможность применить DP и кэшировать результаты. Навык визуализации здесь крайне важен: если Вы можете определить повторяющиеся поддеревья, значит, DP применимо.
# 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)')Выявление перекрывающихся подзадач
Чтобы распознать перекрывающиеся подзадачи, запишите переборную рекурсию, а затем спросите себя: «Есть ли несколько рекурсивных вызовов с аргументами SAME?» Если да, DP может помочь. Распространённые признаки в описаниях задач: «минимальное или максимальное количество X», «сколькими способами можно получить Y», «можно ли достичь Z?». Такие формулировки почти всегда указывают на задачу с оптимальной подструктурой, где ответ в позиции i зависит от ответов в предыдущих позициях.
# 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')Объяснение оптимальной подструктуры
Оптимальная подструктура означает, что оптимальное решение задачи можно построить из оптимальных решений её подзадач. Например, кратчайший путь из A в C через B является оптимальным тогда и только тогда, когда подпути A→B и B→C оптимальны каждый по отдельности. Если это свойство выполняется, глобальный оптимум можно построить снизу вверх из локальных оптимумов. Задачи без оптимальной подструктуры (например, поиск самого длинного пути в общем графе с циклами) нельзя решить с помощью 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')Подъём по лестнице: Ваша первая задача на DP
Подъём по лестнице (LeetCode №70): сколькими различными способами можно подняться по лестнице из n ступеней, делая за один раз 1 или 2 шага? Пусть dp[i] — количество способов достичь ступени i. На ступень i можно прийти со ступени i-1 (один шаг) или со ступени i-2 (два шага), поэтому dp[i] = dp[i-1] + dp[i-2]. Это числа Фибоначчи! Базовые случаи: dp[1] = 1, dp[2] = 2. Понимание того, что задача о подъёме по лестнице сводится к числам Фибоначчи, — классический инсайт для собеседований.
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!Схема DP: состояние, рекуррентность и порядок
Надёжная трёхэтапная схема DP: 1. Определите состояние — что означает dp[i] (или dp[i][j])? Сформулируйте это по-английски. 2. Запишите рекуррентное соотношение — выразите dp[i] через меньшие подзадачи. Учтите все случаи. 3. Определите порядок заполнения — убедитесь, что dp[i-1] и другие зависимости вычислены до dp[i]. Базовые случаи задают значения на границе. Эта схема превращает смутное понимание DP в конкретный план реализации.
# 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')Когда не следует использовать DP
DP не всегда является ответом. Используйте жадный алгоритм, когда один локально оптимальный выбор всегда приводит к глобально оптимальному решению (выбор активностей, задача о прыжках I). Используйте разделяй и властвуй, когда подзадачи не пересекаются (сортировка слиянием, двоичный поиск). Используйте BFS, когда задача сводится к поиску кратчайшего пути в графе без весов. DP даёт правильное решение, но часто оказывается избыточным, если существует жадный или более простой подход. На собеседованиях объясняйте, почему вы выбрали DP, а не другие варианты.
# 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.')Подсчёт различных подзадач
Число различных подзадач определяет временную сложность DP и сложность по памяти. Для одномерного DP на входных данных размера n существует O(n) подзадач. Для двумерного DP на двух входных данных размеров m и n существует O(mn) подзадач. Если каждая подзадача решается за O(k) времени (при k вариантах на каждом шаге), общая временная сложность составляет O(n*k) или O(mn*k). Всегда сначала подсчитывайте различные подзадачи — так вы определите временную сложность DP ещё до написания кода.
# 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')Грабитель домов: пересекающиеся варианты
Грабитель домов (LeetCode #198) — задача о поиске максимальной суммы, которую можно украсть из домов, стоящих в ряд, не грабя соседние дома. В каждом доме нужно выбрать одно из двух действий: ограбить его (прибавить его стоимость и пропустить предыдущий дом) или пропустить его (взять лучший результат для предыдущего дома). dp[i] = max(dp[i-1], dp[i-2] + nums[i]). Эта схема выбора на каждом шаге — простейшее одномерное рекуррентное соотношение DP, которое встречается в десятках задач на собеседованиях.
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Проверка здравого смысла: полный перебор и DP
Всегда проверяйте DP по решению методом полного перебора на небольших входных данных. Полный перебор — ваше эталонное решение. Если DP совпадает с полным перебором на всех тестах, значит, рекуррентное соотношение верно. Только после этого оптимизируйте использование памяти. Такой подход, основанный на тестах, — полный перебор → нисходящий DP → восходящий DP → DP с оптимизацией памяти — является профессиональным способом разработки и проверки решений на основе DP во время собеседования.
# 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}')Быстрая проверка
Проверьте своё понимание концепций структур данных и алгоритмов для подготовки к собеседованию по программированию, рассмотренных в этом уроке.
Итоги урока
В этом уроке вы узнали о двух компонентах DP (пересекающихся подзадачах и оптимальной структуре), о том, как визуализировать дерево рекурсии, чтобы находить повторяющиеся вызовы, а также о трёхэтапной схеме DP (определение состояния, рекуррентное соотношение, порядок заполнения) и первых примерах, включая числа Фибоначчи, подъём по лестнице и задачу о грабителе домов. Далее мы реализуем нисходящий DP с мемоизацией.
Часто задаваемые вопросы
Урок «Распознавание DP: пересекающиеся подзадачи» бесплатный?
Да — полный текст урока «Распознавание DP: пересекающиеся подзадачи» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс DSA Interview Prep, подпишись на CoddyKit PRO. Курс DSA Interview Prep содержит 4 уроков всего.
Чему я научусь в уроке «Распознавание DP: пересекающиеся подзадачи»?
Определяйте случаи, когда рекурсия методом полного перебора повторно решает одну и ту же подзадачу, рисуйте дерево рекурсии для Фибоначчи и наблюдайте экспоненциальный рост вычислений Ты практикуешь DSA Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.
Нужен ли мне опыт, чтобы начать DSA Interview Prep?
Предыдущий опыт не требуется. DSA Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 1 из 4.
Сколько времени занимает урок «Распознавание DP: пересекающиеся подзадачи»?
Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.
Можно ли писать и запускать код в этом уроке DSA Interview Prep?
Да. Каждый урок DSA Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.
Все уроки этого курса
- Распознавание DP: пересекающиеся подзадачи
- DP сверху вниз с мемоизацией
- DP снизу вверх с табуляцией
- Размен монет и подъём по лестнице с минимальной стоимостью