Блок «Алгоритмы на строках» закончился задачами на КМП, Z-функцию и палиндромы. Теперь начинается новый блок — динамическое программирование. Это одна из самых весомых тем технических интервью и олимпиад: по статистике LeetCode, задачи на DP составляют около 25% вопросов уровня Medium и больше половины Hard. За внушительным названием скрываются два конкретных приёма, которые вы освоите в этом уроке. Что такое динамическое программирование**

Динамическое программирование — это техника, которая применяется к задачам с двумя свойствами. Первое — оптимальная подструктура (optimal substructure): оптимальное решение задачи можно выразить через оптимальные решения её подзадач. Второе — перекрывающиеся подзадачи (overlapping subproblems): одни и те же подзадачи встречаются снова и снова при рекурсивном разложении.

Если выполнено только первое свойство — часто достаточно жадного алгоритма или «разделяй и властвуй». Бинарный поиск, например, делит задачу на подзадачи, но никогда не решает одну и ту же дважды: каждый следующий шаг работает с принципиально новыми данными. Сортировка слиянием аналогично делит массив без перекрытий. DP нужен именно тогда, когда рекурсия повторяет одну и ту же работу многократно — и мы хотим эту работу сохранить.

Перед тем как смотреть на код — аналогия. Представьте, что вы переводчик и работаете с книгой. Какое-то редкое слово встречается двести раз. Если каждый раз лезть в словарь — теряете массу времени. Если записать перевод на стикер и приклеить рядом — один раз нашли, двести раз использовали. DP — это «стикер для подзадач».

Разберём на классическом примере — числе Фибоначчи. F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2) для n >= 2. Наивная рекурсия кодирует определение напрямую:

int FibNaive(int n)
{
    if (n <= 1) return n;
    return FibNaive(n - 1) + FibNaive(n - 2);
}

Console.WriteLine(FibNaive(10));  // 55 — быстро
Console.WriteLine(FibNaive(40));  // 102334155 — несколько секунд
// FibNaive(50) — минуты; FibNaive(100) — больше возраста Вселенной

Почему так медленно? Нарисуйте дерево вызовов для F(6). Чтобы вычислить F(6), нужны F(5) и F(4). Чтобы вычислить F(5) — нужны F(4) и F(3). F(4) вызывается уже дважды. F(3) — трижды. F(2) — пять раз. Общее число вызовов при FibNaive(n) растёт как O(2^n): при n = 50 это больше квадриллиона рекурсивных обращений. Алгоритм правильный, но безмерно расточительный.

Ключевое наблюдение: одна и та же подзадача FibNaive(k) вычисляется из раза в раз, всегда давая один и тот же ответ. Это и есть перекрывающиеся подзадачи. Если бы каждая подзадача встречалась только один раз — рекурсия работала бы нормально. Но здесь — встречаются экспоненциальное число раз. Подход 1: мемоизация (top-down)**

Мемоизация — это та же рекурсия, но с кешем. Перед тем как начать считать F(n), проверяем: нет ли уже готового ответа в словаре? Если есть — возвращаем немедленно. Если нет — вычисляем, сохраняем, возвращаем.

// Мемоизация через Dictionary<int, long>
static Dictionary<int, long> _memo = new Dictionary<int, long>();

static long FibMemo(int n)
{
    if (n <= 1) return n;

    // Проверяем кеш — TryGetValue быстрее, чем ContainsKey + индексер
    if (_memo.TryGetValue(n, out long cached))
        return cached;

    // Ничего не нашли — считаем и сохраняем
    long result = FibMemo(n - 1) + FibMemo(n - 2);
    _memo[n] = result;
    return result;
}

Console.WriteLine(FibMemo(10));  // 55
Console.WriteLine(FibMemo(50));  // 12586269025 — мгновенно
Console.WriteLine(FibMemo(90));  // 2880067194370816120

Что изменилось принципиально? Теперь каждое значение F(k) вычисляется ровно один раз, а потом берётся из словаря за O(1). Дерево вызовов из разросшегося куста превращается в прямую цепочку: сначала рекурсия уходит вглубь до F(0), потом каждое значение вычисляется при подъёме — ровно один раз. Итоговая сложность: O(n) по времени и O(n) по памяти (словарь хранит n значений).

Название «сверху вниз» (top-down) описывает направление: мы начинаем с большой задачи F(n) и рекурсивно спускаемся к маленьким. Код сохраняет форму оригинальной рекурсии — изменений минимум. Это большое практическое преимущество: когда вы уже написали правильную, но медленную рекурсию, добавить мемоизацию можно за три строки.

В C# для плотных целочисленных ключей массив работает быстрее словаря примерно в 2-5 раз:

static long[] _cache;

static long FibMemoArray(int n)
{
    _cache = new long[n + 1];
    Array.Fill(_cache, -1L); // -1 означает «ещё не посчитано»
    return Solve(n);
}

static long Solve(int n)
{
    if (n <= 1) return n;
    if (_cache[n] != -1L) return _cache[n]; // в кеше есть — берём

    _cache[n] = Solve(n - 1) + Solve(n - 2);
    return _cache[n];
}

Массив требует знать максимальное n заранее. Словарь гибче: когда ключи — строки, пары (int, int), кортежи или разреженные числа — словарь незаменим. В задачах на строках (поиск подпоследовательности, редакционное расстояние) ключ подзадачи — пара индексов (i, j), и Dictionary<(int, int), int> описывает это естественно.

Важный подводный камень мемоизации: глубина рекурсии в .NET ограничена. При n = 10 000 рекурсивный вызов вглубь до FibMemo(0) создаст стек из 10 000 фреймов. По умолчанию стек .NET-потока позволяет примерно 500-1000 рекурсивных вызовов до StackOverflowException. Таблирование этой проблемы лишено полностью.

Подход 2: таблирование (bottom-up)

Таблирование — итеративный подход «снизу вверх» (bottom-up): начинаем с базовых случаев и последовательно заполняем таблицу, пока не дойдём до нужного значения. Никакой рекурсии, никакого стека вызовов.

static long FibTab(int n)
{
    if (n <= 1) return n;

    long[] dp = new long[n + 1];
    dp[0] = 0; // база 1
    dp[1] = 1; // база 2

    // Заполняем слева направо — каждый dp[i] зависит только от уже готовых
    for (int i = 2; i <= n; i++)
        dp[i] = dp[i - 1] + dp[i - 2];

    return dp[n];
}

Console.WriteLine(FibTab(10));  // 55
Console.WriteLine(FibTab(50));  // 12586269025

Результат идентичен мемоизации — O(n) время, O(n) память. Преимущества таблирования: нет глубокой рекурсии (нет риска StackOverflowException), проще отлаживать (можно распечатать массив dp и увидеть, как растут значения), чуть быстрее на практике (нет накладных расходов на вызовы функций и проверки кеша).

Порядок заполнения таблицы имеет значение. В примере выше мы заполняем dp слева направо, потому что dp[i] зависит от dp[i-1] и dp[i-2] — то есть от уже готовых значений. Если бы зависимость шла вправо (dp[i] зависит от dp[i+1]), мы бы заполняли справа налево. Правило: заполняй в том порядке, в котором зависимости уже вычислены.

Оптимизация памяти до O(1)

В задаче Фибоначчи dp[i] зависит только от двух предыдущих значений. Зачем хранить весь массив длиной n+1, если нам нужны только последние два элемента? Достаточно двух переменных.

static long FibOptimal(int n)
{
    if (n <= 1) return n;

    long prev2 = 0; // F(n-2): начинаем с F(0)
    long prev1 = 1; // F(n-1): начинаем с F(1)

    for (int i = 2; i <= n; i++)
    {
        long current = prev1 + prev2; // F(i) = F(i-1) + F(i-2)
        prev2 = prev1;                // сдвигаем окно вправо
        prev1 = current;
    }

    return prev1; // prev1 теперь хранит F(n)
}

Console.WriteLine(FibOptimal(10));  // 55
Console.WriteLine(FibOptimal(90));  // 2880067194370816120

Это стандартная оптимизация таблирования: если рекуррентное соотношение dp[i] зависит только от dp[i-1] и dp[i-2], можно хранить только «окно» из двух значений. В более сложных задачах окно может быть шире: dp[i] зависит от dp[i-1], dp[i-2], dp[i-3] — тогда храним три переменные. Принцип тот же.

Итоговое сравнение четырёх вариантов для числа Фибоначчи:

Наивная рекурсия: время O(2^n), память O(n) (стек), допустима только при n не больше 30-35. Мемоизация (Dictionary): время O(n), память O(n), удобна при сложных ключах, риск переполнения стека при больших n. Таблирование (массив): время O(n), память O(n), нет риска стека, легко отлаживать. Оптимизированное таблирование: время O(n), память O(1), лучший выбор для больших n.

Когда выбирать мемоизацию, а когда таблирование**

На практике оба подхода дают одинаковую асимптотику. Выбор зависит от структуры задачи и контекста.

Мемоизация лучше подходит, когда пространство подзадач разреженное — вы вычислите лишь небольшую их часть. Например, в задаче поиска пути в графе с большим количеством состояний рекурсия с мемоизацией не тронет недостижимые состояния вовсе, а таблирование заполнит их «по умолчанию» нулями. Мемоизация также удобна, когда рекурсивная структура задачи читается легко — код выглядит почти как математическое определение. И особенно хороша при сложных ключах: строках, парах чисел, множествах.

Таблирование лучше подходит, когда пространство подзадач плотное (вы всё равно посчитаете большинство из них), когда рекурсия слишком глубокая и угрожает переполнением стека, или когда нужна оптимизация памяти — убрать «лишние» строки таблицы в итеративном коде проще, чем в рекурсивном.

Оба подхода требуют одного и того же ключевого навыка: правильно определить подзадачу, сформулировать рекуррентное соотношение и установить базовые случаи. Это единственное, что реально сложно в DP. Сам механизм кеша (мемоизация) или заполнения таблицы (таблирование) — технические детали, которые пишутся почти автоматически.

Хороший способ научиться: для каждой DP-задачи сначала напишите наивную рекурсию, убедитесь что она правильная, потом добавьте кеш (мемоизация) и убедитесь что ответ не изменился, потом переведите в таблирование. Три шага, три версии кода, нарастающий контроль.

В следующих уроках блока вы будете применять эти два приёма к конкретным задачам: числам Фибоначчи и счётным задачам, задаче о рюкзаке, наибольшей общей подпоследовательности и редакционному расстоянию. Схема везде одна и та же: сформулировать подзадачи, написать рекуррентное соотношение, выбрать мемоизацию или таблирование, при необходимости оптимизировать память.

Ещё одна полезная привычка: перед тем как писать код, проговорите вслух (или на бумаге) — что означает dp[i]? Что означает dp[i][j]? Если вы не можете чётко сформулировать смысл ячейки таблицы, код скорее всего будет неверным. «dp[i] — максимальная ценность при использовании первых i предметов» — это чёткое определение. «dp[i] — что-то про предметы» — нет.

Динамическое программирование — техника оптимизации рекурсии за счёт сохранения результатов подзадач; применима когда есть оптимальная подструктура и перекрывающиеся подзадачи.

Наивная рекурсия Фибоначчи — O(2^n), потому что одни и те же подзадачи вычисляются многократно в дереве вызовов.

Мемоизация (top-down) — рекурсия + Dictionary или массив; перед вычислением проверяем кеш; после вычисления — сохраняем; O(n) время, O(n) память.

Таблирование (bottom-up) — итеративное заполнение массива dp[] от базовых случаев к цели; O(n) время, O(n) память; без риска переполнения стека.

Оптимизация памяти — если dp[i] зависит только от dp[i-1] и dp[i-2], достаточно двух переменных; O(1) память.

Выбор подхода — мемоизация удобна при разреженных подзадачах и сложных ключах; таблирование лучше при плотном пространстве подзадач, глубокой рекурсии или необходимости экономить память.

Обсуждение урока

0
Комментарии видны всем. Чтобы участвовать в обсуждении, войдите или зарегистрируйтесь.
Модерация сообщества

Пожаловаться на комментарий

Расскажите модераторам, что именно требует внимания.