0Pricing
DSA Interview Prep · Урок

DP сверху вниз с мемоизацией

Добавляйте словарь мемоизации в рекурсивное решение, исключая повторные вызовы, и используйте @lru_cache для мемоизации с минимальным объёмом кода

«DP сверху вниз с мемоизацией» — бесплатный урок DSA Interview Prep на CoddyKit. Это урок 2 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения DSA Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс DSA Interview Prep содержит 4 уроков всего.

Нисходящий DP: идея мемоизации

Нисходящий DP начинается с исходного рекурсивного решения и добавляет мемоизацию: кэш, в котором при первом вычислении сохраняется результат каждой подзадачи. При последующих вызовах с теми же аргументами кэшированный результат возвращается сразу, без рекурсии. Это превращает наивную рекурсию O(2^n) в решение за O(n) при минимальных изменениях кода — часто достаточно добавить всего 2–3 строки к уже существующему рекурсивному решению.

# Top-down approach:
# 1. Write the recursive solution (natural but slow)
# 2. Add a memo dict to cache results
# 3. Before recursing, check if the result is cached
# 4. Before returning, store the result in the cache

# This is also called 'memoization' (US spelling)
# 'memoize' means 'to remember', not 'memorize'

# The cache key is the function arguments
# For fib: key is n
# For 2D DP: key is (i, j)
# For 3D DP: key is (i, j, k)
print('Top-down = recursion + memo cache')

Мемоизированные числа Фибоначчи

Добавление словаря мемоизации к наивной рекурсии для чисел Фибоначчи сокращает время работы с O(2^n) до O(n). Первый вызов fib(k) вычисляет и сохраняет результат. Все последующие вызовы для того же значения k мгновенно возвращают кэшированное значение. Сложность по памяти составляет O(n) для словаря мемоизации и ещё O(n) для стека вызовов. Сравните число вызовов: без мемоизации fib(30) выполняет около 2 миллионов вызовов, а с мемоизацией — ровно 30.

def fib_memo(n, memo=None):
    if memo is None:
        memo = {}
    if n in memo:
        return memo[n]  # return cached result
    if n <= 1:
        return n
    memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
    return memo[n]

# Verify speed improvement:
print(fib_memo(30))   # fast!
print(fib_memo(50))   # still fast
print(fib_memo(100))  # no problem

# Without memo, fib_naive(50) would take minutes
# With memo: each of the 50 sub-problems computed once

Использование @functools.lru_cache

Декоратор Python @functools.lru_cache(maxsize=None) (или псевдоним @cache в Python 3.9+) автоматически выполняет мемоизацию функции на основе её аргументов. Это самый удобный способ добавить нисходящий DP на собеседовании: напишите рекурсивное решение, добавьте декоратор — и готово. Декоратор сохраняет все результаты в словаре, ключами которого служат аргументы функции; они должны быть хешируемыми (списки использовать нельзя — вместо них применяйте кортежи).

import functools

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

print(fib(50))   # 12586269025
print(fib(100))  # works instantly

# Clear cache between tests if needed:
fib.cache_clear()

# Python 3.9+ shorthand:
# from functools import cache
# @cache
# def fib(n): ...

print(fib.cache_info())  # shows hits, misses, maxsize, currsize

Нисходящий DP для размена монет

Размен монет (LeetCode #322): по заданным номиналам монет и целевой сумме найдите минимальное число необходимых монет. Рекурсивная формулировка: для каждой монеты возьмите её и решите задачу для оставшейся суммы, затем выберите минимум. Выполняйте мемоизацию по сумме, чтобы избежать повторных вычислений. Базовый случай: для суммы 0 нужно 0 монет; для недостижимой суммы возвращается бесконечность (или -1 после завершения рекурсии).

import functools

def coin_change_top_down(coins, amount):
    @functools.lru_cache(maxsize=None)
    def dp(remaining):
        if remaining == 0:
            return 0  # no coins needed
        if remaining < 0:
            return float('inf')  # impossible
        # Try each coin and take the minimum
        return 1 + min(dp(remaining - c) for c in coins)

    result = dp(amount)
    return result if result != float('inf') else -1

print(coin_change_top_down([1, 5, 6, 9], 11))  # 2: (5+6) or (2*5+1?no: 9+2?no) 5+6=11 YES
print(coin_change_top_down([2], 3))             # -1: impossible
print(coin_change_top_down([1, 2, 5], 11))      # 3: 5+5+1

Нисходящий DP для подъёма на K шагов

Обобщим задачу о подъёме по лестнице, разрешив делать от 1 до k шагов. Состояние — текущая ступень; со ступени i можно попасть на ступени i+1, i+2, ..., i+k. Рекуррентное соотношение: dp(i) = sum of dp(i-j) for j in 1..k if i-j >= 0. Мемоизация даёт сложность O(n*k) вместо O(k^n). Такая обобщённая постановка встречается в задачах вроде «минимальная стоимость достижения последней ступени» и «число способов заполнить таблицу».

import functools

def climb_k_steps(n, k):
    @functools.lru_cache(maxsize=None)
    def dp(i):
        if i == 0:
            return 1  # base: one way to stay at ground
        if i < 0:
            return 0  # impossible
        # From stair i, you could have come from i-1, i-2, ..., i-k
        return sum(dp(i - j) for j in range(1, k+1) if i - j >= 0)

    return dp(n)

# k=2 (original): should match fib-like sequence
print([climb_k_steps(n, 2) for n in range(7)])  # [1,1,2,3,5,8,13]
# k=3: more options
print([climb_k_steps(n, 3) for n in range(7)])  # [1,1,2,4,7,13,24]

Нисходящий LCS: двумерная мемоизация

Наибольшая общая подпоследовательность (LCS) требует двумерного состояния: dp(i, j) = длина LCS для s1[:i] и s2[:j]. Если s1[i-1] == s2[j-1], символы совпадают: dp(i,j) = 1 + dp(i-1, j-1). Иначе: dp(i,j) = max(dp(i-1,j), dp(i,j-1)) — пропустите один символ в одной из строк. Мемоизация по (i, j) даёт O(mn) вместо O(2^(m+n)).

import functools

def lcs_top_down(s1, s2):
    m, n = len(s1), len(s2)

    @functools.lru_cache(maxsize=None)
    def dp(i, j):
        if i == 0 or j == 0:
            return 0  # empty prefix has LCS of 0
        if s1[i-1] == s2[j-1]:
            return 1 + dp(i-1, j-1)  # characters match
        return max(dp(i-1, j), dp(i, j-1))  # skip one

    return dp(m, n)

print(lcs_top_down('abcde', 'ace'))   # 3: 'ace'
print(lcs_top_down('abc', 'abc'))     # 3: 'abc'
print(lcs_top_down('abc', 'def'))     # 0: no common chars

Словарь мемоизации или lru_cache: что выбрать

Используйте @lru_cache, когда аргументы функции имеют простые хешируемые типы (int, str, tuple). Используйте словарь мемоизации, созданный вручную, если нужно передавать изменяемое состояние (списки, словари), преобразуя его в кортежи, отслеживать, для каких ключей уже выполнено вычисление, или работать в методе класса, где self не должен кэшироваться. Ручной словарь мемоизации более нагляден и помогает избежать неочевидных проблем с замыканиями во вспомогательных рекурсивных функциях.

# @lru_cache: clean, automatic, O(1) overhead
# Use when: arguments are simple (int, str, tuple)
import functools
@functools.lru_cache(maxsize=None)
def simple_dp(n):
    if n <= 1: return n
    return simple_dp(n-1) + simple_dp(n-2)

# Manual memo dict: explicit, flexible
# Use when: complex state, need to inspect memo, class methods
def manual_memo_dp(s1, s2):
    memo = {}
    def dp(i, j):
        if (i,j) in memo: return memo[(i,j)]
        if i == 0 or j == 0:
            return 0
        if s1[i-1] == s2[j-1]:
            memo[(i,j)] = 1 + dp(i-1, j-1)
        else:
            memo[(i,j)] = max(dp(i-1,j), dp(i,j-1))
        return memo[(i,j)]
    return dp(len(s1), len(s2))

print(manual_memo_dp('abcde', 'ace'))  # 3

Нисходящий DP для целевой суммы

Целевая сумма (LeetCode #494): назначьте каждому числу знак + или - и подсчитайте количество вариантов, дающих заданную целевую сумму. Состояние: dp(index, current_sum). Для каждого индекса попробуйте прибавить (+) и вычесть (-) текущее число. Мемоизация по (index, current_sum) превращает полный перебор за O(2^n) в решение за O(n * S), где S — общая сумма всех чисел. Диапазон сумм ограничен общей суммой всех чисел, поэтому всего получается O(n * S) состояний.

import functools

def find_target_sum_ways(nums, target):
    @functools.lru_cache(maxsize=None)
    def dp(index, current_sum):
        if index == len(nums):
            return 1 if current_sum == target else 0
        # Try adding the number
        add = dp(index + 1, current_sum + nums[index])
        # Try subtracting the number
        subtract = dp(index + 1, current_sum - nums[index])
        return add + subtract

    return dp(0, 0)

print(find_target_sum_ways([1,1,1,1,1], 3))  # 5
print(find_target_sum_ways([1], 1))            # 1
print(find_target_sum_ways([1], -1))           # 1

Нисходящий и восходящий DP: преимущества и недостатки

Нисходящий DP (мемоизация) имеет следующие преимущества: его естественно писать, поскольку он начинается с рекурсивного решения; он вычисляет только действительно нужные подзадачи по мере необходимости; кэш легко добавлять постепенно. Восходящий DP (табуляция) не имеет накладных расходов стека вызовов и не зависит от ограничения рекурсии Python, обеспечивает более эффективный доступ к памяти и проще поддаётся оптимизации по памяти. У обоих подходов одинаковая асимптотическая сложность. На собеседованиях начните с нисходящего подхода, чтобы проверить правильность, а затем преобразуйте его в восходящий, если попросят уменьшить использование памяти.

# Top-down advantages:
# + Natural: write recursive, add @cache
# + Lazy: only computes needed sub-problems
# + Easy to reason about correctness
# - Uses call stack (recursion limit in Python)
# - Higher constant factor (function call overhead)

# Bottom-up advantages:
# + No recursion limit
# + Better cache performance (sequential memory)
# + Easier to space-optimise (rolling array)
# - Must compute all sub-problems in order
# - Less intuitive for complex 2D/3D problems

# Interview strategy:
# Start with top-down to verify recurrence,
# convert to bottom-up only if asked.
print('Top-down: easy to write | Bottom-up: efficient for large n')

Разбиение строки на слова с нисходящим DP

Разбиение строки на слова (LeetCode #139): можно ли разбить строку s на слова из словаря? Состояние: dp(i) = можно ли разбить на слова s[i:]. Начиная с индекса i переберите все слова: если s[i:i+len(w)] == w, выполните рекурсию для оставшегося суффикса. Мемоизация по начальному индексу превращает полный перебор за O(2^n) в решение за O(n^2) (или O(n × L), где L — максимальная длина слова) с проверкой принадлежности множеству.

import functools

def word_break(s, word_dict):
    word_set = set(word_dict)

    @functools.lru_cache(maxsize=None)
    def dp(start):
        if start == len(s):
            return True  # successfully segmented entire string
        for end in range(start + 1, len(s) + 1):
            if s[start:end] in word_set and dp(end):
                return True
        return False

    return dp(0)

print(word_break('leetcode', ['leet', 'code']))       # True
print(word_break('applepenapple', ['apple', 'pen'])) # True
print(word_break('catsandog', ['cats', 'dog', 'and', 'cat', 'san', 'andog'])) # False

Предел рекурсии и инструменты итерации

Ограничение рекурсии по умолчанию в Python равно 1000 (его задаёт sys.getrecursionlimit()). В задачах DP на больших входных данных (n = 10,000+) нисходящая мемоизация достигнет этого предела. Возможны два варианта: увеличить предел с помощью sys.setrecursionlimit(100000) или перейти к восходящему DP. В соревновательном программировании увеличение предела — обычная практика; в рабочем коде для надёжности всегда отдавайте предпочтение восходящим или итеративным решениям.

import sys

print('Default recursion limit:', sys.getrecursionlimit())  # 1000

# For large DP problems, increase if needed:
# sys.setrecursionlimit(100000)

# Better: convert to bottom-up DP for large n
def fib_bottom_up(n):
    if n <= 1: return n
    a, b = 0, 1
    for _ in range(2, n+1):
        a, b = b, a + b
    return b

# No recursion limit issue:
print(fib_bottom_up(10000))  # works fine, no recursion

Быстрая проверка

Проверьте своё понимание концепций структур данных и алгоритмов для подготовки к собеседованию по программированию, рассмотренных в этом уроке.

Итоги урока

В этом уроке вы узнали о нисходящем DP со словарём мемоизации и декоратором @lru_cache, о мемоизированных решениях задач о числах Фибоначчи, размене монет, LCS, целевой сумме и разбиении строки на слова, а также о том, когда выбирать нисходящий, а когда восходящий подход. Далее мы реализуем восходящий DP с табуляцией и оптимизацией памяти.

Часто задаваемые вопросы

Урок «DP сверху вниз с мемоизацией» бесплатный?

Да — полный текст урока «DP сверху вниз с мемоизацией» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс DSA Interview Prep, подпишись на CoddyKit PRO. Курс DSA Interview Prep содержит 4 уроков всего.

Чему я научусь в уроке «DP сверху вниз с мемоизацией»?

Добавляйте словарь мемоизации в рекурсивное решение, исключая повторные вызовы, и используйте @lru_cache для мемоизации с минимальным объёмом кода Ты практикуешь DSA Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

Нужен ли мне опыт, чтобы начать DSA Interview Prep?

Предыдущий опыт не требуется. DSA Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 2 из 4.

Сколько времени занимает урок «DP сверху вниз с мемоизацией»?

Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.

Можно ли писать и запускать код в этом уроке DSA Interview Prep?

Да. Каждый урок DSA Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.

Все уроки этого курса

  1. Распознавание DP: пересекающиеся подзадачи
  2. DP сверху вниз с мемоизацией
  3. DP снизу вверх с табуляцией
  4. Размен монет и подъём по лестнице с минимальной стоимостью
← Назад к DSA Interview Prep