W5–W6. Динамическое программирование (DP), максимальный подмассив, НОП (LCS), rod cutting, рюкзак 0–1
1. Краткое содержание
1.1 Введение в динамическое программирование (DP)
Динамическое программирование (dynamic programming, DP) — мощный алгоритмический приём для задач оптимизации (optimization problems): задача разбивается на перекрывающиеся подзадачи (overlapping subproblems), каждая подзадача решается один раз, а результаты запоминаются, чтобы не повторять лишние вычисления. В отличие от divide and conquer, где подзадачи независимы, DP опирается на то, что одна и та же подзадача может встречаться в разных ветках рекурсии многократно.
Название «динамическое программирование» слегка обманчиво: оно не про «динамическое написание программ». Термин ввёл Ричард Беллман в 1950-х. Здесь programming — в смысле оптимизации (как в linear programming), а dynamic — про поэтапное решение во времени.
1.1.1 Когда применять DP
DP уместен, если у задачи есть два ключевых свойства:
- Оптимальная подструктура (optimal substructure): оптимальное решение исходной задачи складывается из оптимальных решений подзадач. Иначе говоря, оптимум строится из оптимумов на меньших экземплярах той же задачи.
- Перекрывающиеся подзадачи (overlapping subproblems): при наивной рекурсии одни и те же подзадачи решаются много раз — и появляется возможность ускорения за счёт хранения уже найденных ответов.
Бытовая аналогия: вы ищете кратчайший путь из дома на работу через промежуточные точки. Если уже посчитан кратчайший путь из точки A до офиса, этот факт можно переиспользовать везде, где маршрут проходит через A, — не пересчитывать заново каждый раз.
1.1.2 Задачи оптимизации
Задача оптимизации (optimization problem) имеет такую структуру:
- Может быть много допустимых решений (кандидатов).
- У каждого решения есть значение (value) (обычно число).
- Нужно найти решение с оптимальным значением (минимум или максимум).
Оптимальных решений с тем же оптимальным значением может быть несколько. DP помогает найти хотя бы одно эффективно.
Примеры задач оптимизации, которые уже встречались:
- Сортировка: среди всех перестановок массива найти ту, что даёт отсортированный порядок.
- Кратчайший путь: среди всех путей между двумя вершинами — путь с минимальной суммой длин рёбер.
- Максимальный подмассив (maximum subarray): среди всех подотрезков — подотрезок с максимальной суммой.
1.2 Шаги построения DP-алгоритма
Есть четыре опорных шага:
Шаг 1: Описать структуру оптимального решения
Разобраться, как оптимум исходной задачи собирается из оптимумов подзадач. Здесь важно увидеть рекурсивную структуру.
Важно: чтобы DP было выгодно, оптимальное решение должно переиспользовать ответы на перекрывающиеся подзадачи. Если подзадачи не пересекаются по работе (как в merge sort), чаще уместен divide and conquer, а не DP.
Шаг 2: Рекурсивно определить значение оптимального решения
Выразить значение оптимума через значения на меньших подзадачах — обычно это рекуррентное соотношение (recurrence relation).
Шаг 3: Вычислить значения оптимальных решений
Использовать bottom-up (табуляция, tabulation) или top-down (мемоизация, memoization) так, чтобы каждая подзадача решалась один раз.
Шаг 4: Восстановить само оптимальное решение
По данным, накопленным при вычислении значений, построить конкретный оптимальный объект (не только число).
Замечание: шаг 4 не нужен, если требуется только оптимальное значение.
1.3 Bottom-up и top-down
Два основных способа реализации DP:
1.3.1 Bottom-up (табуляция, tabulation)
Итеративно строим ответы от меньших подзадач к большим:
- Инициализировать таблицу (массив) для хранения решений подзадач.
- Заполнить таблицу, начиная с самых маленьких подзадач.
- Считать ответ к исходной задаче из таблицы.
Плюсы:
- Обычно быстрее на практике (нет накладных расходов на рекурсивные вызовы).
- Проще анализировать space complexity.
- Часто можно сжать память, выкидывая уже ненужные слои таблицы.
Минусы:
- Нужно явно задать порядок заполнения.
- Можно посчитать подзадачи, которые для конкретного входа не понадобятся.
Пример: числа Фибоначчи от \(F_0\), \(F_1\) вверх до \(F_n\).
1.3.2 Top-down (мемоизация, memoization)
Рекурсия + кэш:
- Рекурсивная функция сначала проверяет, есть ли результат для данных параметров.
- Если да — возвращает значение из кэша.
- Иначе считает рекурсивно и сохраняет в кэш перед возвратом.
Плюсы:
- Ближе к «естественной» рекурсивной формулировке.
- Считаются только нужные подзадачи.
- Часто легко получить из наивной рекурсии, просто добавив кэш.
Минусы:
- Накладные расходы на вызовы.
- Риск stack overflow при большой глубине.
- Сложнее ужимать память.
Пример: Фибоначчи со словарём/картой для уже вычисленных значений.
1.4 Пример: числа Фибоначчи
Последовательность Фибоначчи — классическая иллюстрация разрыва между наивной рекурсией и DP.
Последовательность: \(0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, \ldots\)
Рекуррентность: \(F_n = F_{n-1} + F_{n-2}\) при \(F_0 = 0\) и \(F_1 = 1\).
1.4.1 Наивная рекурсия
def fibonacci(n):
if n == 0:
return 0
if n == 1:
return 1
else:
return fibonacci(n-1) + fibonacci(n-2)Анализ:
- Временная сложность: \(\Theta(\phi^n)\), где \(\phi = \frac{1+\sqrt{5}}{2} \approx 1.618\) (golden ratio)
- Из \(T(n) = T(n-1) + T(n-2) + O(1)\) получаем экспоненциальный рост
- Верхняя оценка: \(T(n) \le 2T(n-1) = O(2^n)\)
- Более точно: \(T(n) = \Theta(F_n) = \Theta(\phi^n)\)
- Память: \(\Theta(n)\) из‑за глубины стека
Почему медленно? В дереве вызовов огромная избыточность: например, для f(6) значение f(4) считается дважды, f(3) — трижды, f(2) — пять раз и т.д.; число вызовов растёт экспоненциально.
1.4.2 Bottom-up DP
def fibonacci(n):
fib = [0] * (n + 1)
fib[0] = 0
fib[1] = 1
for i in range(2, n + 1):
fib[i] = fib[i - 1] + fib[i - 2]
return fib[n]Анализ:
- Время: \(\Theta(n)\) — один проход от \(2\) до \(n\)
- Память: \(\Theta(n)\) на массив
Можно ли по памяти? Да: нужны только два предыдущих значения — две переменные, \(\Theta(1)\) дополнительной памяти:
def fibonacci(n):
if n == 0: return 0
if n == 1: return 1
prev2, prev1 = 0, 1
for i in range(2, n + 1):
current = prev1 + prev2
prev2, prev1 = prev1, current
return prev11.4.3 Top-down DP (мемоизация)
def fibonacci(n):
memo = {}
return fib_memo(n, memo)
def fib_memo(n, memo):
if n in memo:
return memo[n]
if n == 0:
result = 0
elif n == 1:
result = 1
else:
result = fib_memo(n-1, memo) + fib_memo(n-2, memo)
memo[n] = result
return resultАнализ:
- Время: \(\Theta(n)\) — каждое \(n\) вычисляется ровно один раз
- Память: \(\Theta(n)\) на словарь и стек рекурсии
1.5 Задача о максимальном подмассиве (maximum subarray)
1.5.1 Постановка
Вход: последовательность из \(n\) чисел \(\langle a_1, a_2, \ldots, a_n \rangle\).
Выход: индексы \(l\) и \(r\) такие, что \(\sum_{i=l}^{r} a_i\) максимально среди всех \(1 \le l \le r \le n\).
Применения:
- Анализ временных рядов: интервалы максимального прироста или просадки
- Трейдинг: лучший период удержания, если по дням заданы приращения цены
- Обработка сигналов: участки максимальной «энергии»
Пример: для \(A = [-2, 1, -3, 4, -1, 2, 1, -5, 4]\) максимальный подотрезок — \([4, -1, 2, 1]\) (индексы 4–7), сумма \(6\).
Что уже было:
- Полный перебор: все \(O(n^2)\) подотрезков — время \(\Theta(n^2)\)
- Divide and conquer: лево, право, пересекающийся случай — \(\Theta(n \log n)\)
Дальше — DP-решение за \(\Theta(n)\).
1.5.2 Идея DP
Для каждой позиции \(i\) введём:
- maxSuffixValue(A, i): максимальная сумма подотрезка, заканчивающегося в \(i\)
- maxSuffix(A, i): индекс начала такого суффикса с максимальной суммой
Ключевое наблюдение: лучший суффикс, заканчивающийся в \(i\), либо
- продолжает лучший суффикс для \(i-1\) (если его сумма положительна), либо
- начинается заново в \(i\) (если продолжение ухудшает сумму)
Рекуррентность для суммы суффикса:
\[\text{maxSuffixValue}(A, i) = \begin{cases} A[1], & \text{if } i = 1 \\ \max(\text{maxSuffixValue}(A, i-1) + A[i], A[i]), & \text{otherwise} \end{cases}\]
Почему так: если лучший суффикс для \(i-1\) даёт положительный вклад, разумно добавить \(A[i]\); если нет — выгоднее взять только \(A[i]\).
Рекуррентность для начала суффикса:
\[\text{maxSuffix}(A, i) = \begin{cases} 1, & \text{if } i = 1 \\ j, & \text{if } j = \text{maxSuffix}(A, i-1) \text{ and } \text{maxSuffixValue}(A, i-1) > 0 \\ i, & \text{otherwise} \end{cases}\]
Глобальный максимум:
\[\text{maxSum} = \max_{1 \le i \le n} \text{maxSuffixValue}(A, i)\]
1.5.3 Алгоритм Кадане (Kadane’s algorithm)
Bottom-up реализация этой идеи — алгоритм Кадане (Kadane’s algorithm):
def max_subarray(A, n):
max_suffix_sum = A[1]
max_sum = max_suffix_sum
for i in range(2, n + 1):
max_suffix_sum = max(max_suffix_sum + A[i], A[i])
max_sum = max(max_sum, max_suffix_sum)
return max_sumКак читать код:
max_suffix_sum— максимальная сумма подотрезка, заканчивающегося в текущей позицииmax_sum— лучшая сумма среди всех префиксов «до текущего \(i\)»- на каждом шаге выбор: продлить текущий отрезок или начать новый
Анализ:
- Время: \(\Theta(n)\) — один проход
- Память: \(\Theta(1)\)
Это оптально по порядку: без просмотра каждого элемента задачу не решить.
Индексы подотрезка: дополнительно хранятся границы текущего суффикса и лучшего ответа:
def max_subarray_with_indices(A, n):
max_suffix_sum = A[1]
max_sum = max_suffix_sum
start = 1 # start of current suffix
best_start = 1 # start of best subarray
best_end = 1 # end of best subarray
for i in range(2, n + 1):
if max_suffix_sum + A[i] > A[i]:
max_suffix_sum = max_suffix_sum + A[i]
else:
max_suffix_sum = A[i]
start = i # start new suffix here
if max_suffix_sum > max_sum:
max_sum = max_suffix_sum
best_start = start
best_end = i
return (best_start, best_end, max_sum)1.6 Наибольшая общая подпоследовательность (LCS)
1.6.1 Подпоследовательности
Последовательность \(Z = \langle z_1, z_2, \ldots, z_k \rangle\) — подпоследовательность (subsequence) \(X = \langle x_1, x_2, \ldots, x_m \rangle\), если существует строго возрастающая последовательность индексов \(\langle i_1, i_2, \ldots, i_k \rangle\) такая, что \(z_j = x_{i_j}\) для всех \(j\).
Проще: \(Z\) получается из \(X\) удалением некоторых элементов (возможно, нуля) без перестановки оставшихся.
Примеры:
- \(\langle B, D, A \rangle\) — подпоследовательность \(\langle A, B, C, B, D, A, B \rangle\) (индексы 2, 5, 6)
- \(\langle B, C, D, B \rangle\) — тоже подпоследовательность (2, 3, 5, 7)
- \(\langle A, C, D, C \rangle\) — не подпоследовательность: после \(C\) в позиции 3 и \(D\) в позиции 5 второго \(C\) справа уже нет
Отличие от подстроки (substring): подстрока непрерывна, подпоследовательность — нет.
1.6.2 Общие подпоследовательности
\(Z\) — общая подпоследовательность (common subsequence) \(X\) и \(Y\), если \(Z\) — подпоследовательность и \(X\), и \(Y\).
Пример: \(X = \langle A, B, C, B, D, A, B \rangle\), \(Y = \langle B, D, C, A, B, A \rangle\):
- \(\langle B, C, A \rangle\) — общая подпоследовательность
- \(\langle B, C, D \rangle\) — не общая (не укладывается в \(Y\) по порядку)
- \(\langle B, C, A, B \rangle\) — общая
1.6.3 Постановка LCS
Задача LCS (longest common subsequence):
Вход: две последовательности \(X = \langle x_1, x_2, \ldots, x_m \rangle\) и \(Y = \langle y_1, y_2, \ldots, y_n \rangle\).
Выход: общая подпоследовательность максимальной длины.
Применения:
- Сходство текстов и строк
- Биоинформатика: сравнение цепочек
- Контроль версий:
diffмежду файлами - Поиск заимствований
Наивно:
- Перебрать все \(2^m\) подпоследовательностей \(X\)
- Для каждой проверить вхождение в \(Y\) за \(O(n)\)
- Запомнить самую длинную
Время: \(O(n \cdot 2^m)\) — на практике неприемлемо уже при умеренных \(m\).
1.6.4 Оптимальная подструктура
Теорема (оптимальная подструктура LCS):
Пусть \(X_i = \langle x_1, \ldots, x_i \rangle\) — префикс \(X\) длины \(i\), аналогично \(Y_j\). Пусть \(Z_k = \langle z_1, \ldots, z_k \rangle\) — НОП для \(X\) и \(Y\). Тогда:
Если \(x_m = y_n\): тогда \(z_k = x_m = y_n\) и \(Z_{k-1}\) — НОП для \(X_{m-1}\) и \(Y_{n-1}\).
Смысл: совпавший «хвост» обязан участвовать в НОП; убираем его и решаем меньшую задачу.
Если \(x_m \neq y_n\): тогда либо
- \(z_k \neq x_m\), и тогда \(Z_k\) — НОП для \(X_{m-1}\) и \(Y_n\), либо
- \(z_k \neq y_n\), и тогда \(Z_k\) — НОП для \(X_m\) и \(Y_{n-1}\)
Смысл: хотя бы один из последних символов не входит в НОП; пробуем оба варианта и берём лучший.
Отсюда — естественная схема для DP.
1.6.5 Рекурсивная формула для длины
Пусть \(\text{LCS}'(X_m, Y_n)\) — длина НОП для префиксов \(X_m\) и \(Y_n\):
\[\text{LCS}'(X_m, Y_n) = \begin{cases} 0, & \text{if } m = 0 \text{ or } n = 0 \\ 1 + \text{LCS}'(X_{m-1}, Y_{n-1}), & \text{if } x_m = y_n \\ \max(\text{LCS}'(X_{m-1}, Y_n), \text{LCS}'(X_m, Y_{n-1})), & \text{otherwise} \end{cases}\]
База: пустые строки дают длину 0.
Шаги:
- Совпали символы: прибавляем 1 и решаем задачу на префиксах без последних символов
- Не совпали: пробуем отбросить символ в \(X\) или в \(Y\) и берём максимум
1.6.6 Bottom-up: таблицы \(C\) и \(B\)
Строим таблицу \(C[i, j]\) — длина НОП для \(X_i\) и \(Y_j\).
Таблица \(B[i, j]\) фиксирует, откуда пришёл переход (для восстановления ответа):
- “XY” (стрелка «↖»): совпадение, переход из \(C[i-1, j-1]\)
- “X” («←»): из \(C[i, j-1]\) (исключили \(y_j\))
- “Y” («↑»): из \(C[i-1, j]\) (исключили \(x_i\))
Алгоритм:
def LCS_length(X, m, Y, n):
# Create tables
B = [[None] * n for _ in range(m)] # 1-indexed
C = [[0] * (n + 1) for _ in range(m + 1)] # 0-indexed
# Initialize base cases
for i in range(1, m + 1):
C[i][0] = 0
for j in range(1, n + 1):
C[0][j] = 0
# Fill the table
for i in range(1, m + 1):
for j in range(1, n + 1):
if X[i] == Y[j]:
C[i][j] = C[i - 1][j - 1] + 1
B[i][j] = "XY" # diagonal arrow
elif C[i][j - 1] < C[i - 1][j]:
C[i][j] = C[i - 1][j]
B[i][j] = "Y" # up arrow
else:
C[i][j] = C[i][j - 1]
B[i][j] = "X" # left arrow
return C, BАнализ:
- Время: \(\Theta(m \cdot n)\)
- Память: \(\Theta(m \cdot n)\)
Восстановление НОП: идти от \(B[m, n]\) по стрелкам назад:
def reconstruct_LCS(B, X, i, j):
if i == 0 or j == 0:
return []
if B[i][j] == "XY":
return reconstruct_LCS(B, X, i-1, j-1) + [X[i]]
elif B[i][j] == "Y":
return reconstruct_LCS(B, X, i-1, j)
else:
return reconstruct_LCS(B, X, i, j-1)1.7 Классические задачи DP
1.7.1 Rod cutting (резание стержня)
Задача: есть стержень длины \(n\); его режут на куски; для каждой длины \(i\) известна цена \(p[i]\); разрезы бесплатны. Как резать, чтобы максимизировать выручку?
Вход: \(n\) и таблица цен \(p[1 \ldots n]\).
Выход: способ разрезания с максимальной суммарной ценой кусков.
Пример: \(n = 7\) и цены:
| Длина (м) | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| Цена ($) | 1 | 5 | 8 | 9 | 10 | 17 | 17 |
Варианты:
- Не резать: выручка \(17\)
- \(1 + 6\): \(1 + 17 = 18\) ✓ (оптимум)
- \(2 + 2 + 3\): \(5 + 5 + 8 = 18\) ✓ (тоже оптимум)
Оптимальная подструктура: максимальная выручка \(r_n\) для длины \(n\):
- отрезаем кусок длины \(i\) (\(1 \le i \le n\)), получаем \(p_i\),
- остаток длины \(n-i\) режем оптимально.
Рекуррентность:
\[r_n = \max_{1 \le i \le n} (p_i + r_{n-i})\]
с базой \(r_0 = 0\).
Bottom-up:
def rod_cutting(p, n):
r = [0] * (n + 1)
for j in range(1, n + 1):
max_revenue = -float('inf')
for i in range(1, j + 1):
max_revenue = max(max_revenue, p[i] + r[j - i])
r[j] = max_revenue
return r[n]Время: \(\Theta(n^2)\); память: \(\Theta(n)\).
1.7.2 Рюкзак 0–1 (0–1 knapsack)
Задача: рюкзак грузоподъёмностью \(W\) и \(n\) предметов с весами \(w_i\) и ценностями \(v_i\). Выбрать подмножество с максимальной суммарной ценностью при ограничении \(\sum w_i \le W\); каждый предмет берётся не более одного раза.
Вход: \(W\), массивы \(w[1 \ldots n]\) и \(v[1 \ldots n]\).
Выход: подмножество \(S \subseteq \{1, \ldots, n\}\) с максимумом \(\sum_{i \in S} v_i\) при \(\sum_{i \in S} w_i \le W\).
Пример: \(W = 35\) кг:
| Предмет | Suitcase | Tablet | Laptop | Water | Tent | Plug | Bed |
|---|---|---|---|---|---|---|---|
| Вес (кг) | 12 | 2 | 6 | 3 | 24 | 1 | 9 |
| Ценность ($) | 36 | 4 | 24 | 2 | 64 | 1 | 22 |
Почему жадный по отношению ценность/вес не всегда верен: локально «выгодный» предмет может вытеснить комбинацию с лучшим суммарным эффектом.
Оптимальная подструктура: \(K(i, w)\) — максимальная ценность по первым \(i\) предметам при лимите веса \(w\):
\[K(i, w) = \begin{cases} 0, & \text{if } i = 0 \text{ or } w = 0 \\ K(i-1, w), & \text{if } w_i > w \text{ (item too heavy)} \\ \max(K(i-1, w), v_i + K(i-1, w - w_i)), & \text{otherwise} \end{cases}\]
Смысл:
- База: нет предметов или нет вместимости — ценность 0
- Не помещается: вариант с предметом \(i\) недоступен
- Помещается: сравниваем «не брать \(i\)» и «взять \(i\)»
DP:
def knapsack(W, w, v, n):
K = [[0] * (W + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for wt in range(1, W + 1):
if w[i] > wt:
K[i][wt] = K[i-1][wt]
else:
K[i][wt] = max(K[i-1][wt], v[i] + K[i-1][wt - w[i]])
return K[n][W]Время: \(\Theta(nW)\) — псевдополиномиально (pseudo-polynomial) по величине \(W\).
Память: \(\Theta(nW)\).
Замечание про сложность: время зависит от числового значения \(W\), а не только от числа предметов \(n\). Если \(W\) астрономическое (например, \(2^{100}\)), таблица по \(W\) станет неподъёмной при «маленьком» с виду входе в битах.
1.8 Divide and conquer и DP: сравнение
Оба подхода дробят задачу на подзадачи, но критично различаются пересечением работы:
| Аспект | Divide and conquer | Dynamic programming |
|---|---|---|
| Пересечение подзадач | Подзадачи независимы | Подзадачи перекрываются |
| Когда уместно | Каждая подзадача считается один раз в своей ветке | Одна и та же подзадача нужна во многих ветках |
| Реализация | Прямая рекурсия | Tabulation или memoization |
| Примеры | Merge sort, quicksort, binary search | Фибоначчи, LCS, knapsack, rod cutting |
| Память | В основном стек рекурсии | Хранение ответов на подзадачи |
Смысл: если в дереве рекурсии вы снова и снова встречаете одинаковые подзадачи, DP с запоминанием ответов обычно устраняет лишнюю работу.
2. Определения
- Dynamic Programming (DP): приём решения задач оптимизации через перекрывающиеся подзадачи с однократным вычислением и запоминанием результатов.
- Optimization problem: задача с множеством кандидатов, у каждого — числовое значение; нужен кандидат с оптимальным (min/max) значением.
- Optimal substructure: оптимум целого строится из оптимумов частей.
- Overlapping subproblems: при рекурсии одни и те же подзадачи возникают многократно.
- Bottom-up (tabulation): DP снизу вверх заполнением таблицы.
- Top-down (memoization): DP сверху вниз с кэшем на рекурсии.
- Memoization: сохранение результатов дорогих вызовов и возврат из кэша при повторе аргументов.
- Subsequence: последовательность после удаления части элементов без смены порядка остальных.
- Common subsequence: общая подпоследовательность двух (или более) последовательностей.
- Longest Common Subsequence (LCS): самая длинная общая подпоследовательность двух последовательностей.
- Maximum subarray: непрерывный подотрезок с максимальной суммой.
- Kadane’s algorithm: алгоритм за \(O(n)\) для maximum subarray на идее DP.
- Rod cutting problem: максимизация выручки при разрезании стержня по таблице цен.
- 0–1 knapsack problem: максимизация ценности при весе \(\le W\), каждый предмет не более одного раза.
- Pseudo-polynomial time: время полиномиально по величине чисел во входе, но экспоненциально по числу битов.
3. Формулы
- Рекуррентность Фибоначчи: \(F_n = F_{n-1} + F_{n-2}\), \(F_0 = 0\), \(F_1 = 1\)
- Время наивной рекурсии Фибоначчи: \(T(n) = \Theta(\phi^n)\), \(\phi = \frac{1+\sqrt{5}}{2} \approx 1.618\)
- Время DP для Фибоначчи: \(\Theta(n)\)
- Золотое сечение: \(\phi = \frac{1+\sqrt{5}}{2}\)
- maxSuffixValue: \(\text{maxSuffixValue}(A, i) = \begin{cases} A[1], & \text{if } i = 1 \\ \max(\text{maxSuffixValue}(A, i-1) + A[i], A[i]), & \text{otherwise} \end{cases}\)
- Время Кадане: \(\Theta(n)\)
- Рекуррентность LCS: \(\text{LCS}'(X_m, Y_n) = \begin{cases} 0, & \text{if } m = 0 \text{ or } n = 0 \\ 1 + \text{LCS}'(X_{m-1}, Y_{n-1}), & \text{if } x_m = y_n \\ \max(\text{LCS}'(X_{m-1}, Y_n), \text{LCS}'(X_m, Y_{n-1})), & \text{otherwise} \end{cases}\)
- Время LCS: \(\Theta(mn)\) при \(m = |X|\), \(n = |Y|\)
- Память LCS: \(\Theta(mn)\)
- Rod cutting: \(r_n = \max_{1 \le i \le n} (p_i + r_{n-i})\), \(r_0 = 0\)
- Время rod cutting: \(\Theta(n^2)\)
- Knapsack: \(K(i, w) = \begin{cases} 0, & \text{if } i = 0 \text{ or } w = 0 \\ K(i-1, w), & \text{if } w_i > w \\ \max(K(i-1, w), v_i + K(i-1, w - w_i)), & \text{otherwise} \end{cases}\)
- Время knapsack: \(\Theta(nW)\) (pseudo-polynomial)
- Память knapsack: \(\Theta(nW)\)
4. Примеры
4.1. Назначение разработчиков на проекты (Набор задач 5, Задание 1)
Компании нужно сдать \(S\) проектов за квартал. Есть \(D\) разработчиков; каждый не более чем на одном проекте весь квартал (на один проект можно поставить нескольких). Цель — минимизировать суммарное время завершения всех проектов.
Оценки времени завершения проекта в зависимости от числа назначенных разработчиков даны в таблице \(C\), где \(C[\text{project}][\text{devs}]\) — срок в днях.
(1a)(i) Как выразить оптимальное решение через оптимальные решения подзадач, которые можно считать «независимо» в смысле DP? На конкретном примере покажите, что наивная рекурсия повторно пересчитывает одни и те же подзадачи.
(1b) Псевдокод DP (top-down или bottom-up), который возвращает и минимальную суммарную длительность, и конкретное распределение разработчиков по проектам.
(2) Рассмотрите жадную стратегию: проекты по порядку A, B, C, D, E; для каждого проекта выбирается число разработчиков, минимизирующее срок именно этого проекта при оставшихся людях; затем переход к следующему. Докажите, что это не всегда оптимально: (i) маленький контрпример, (ii) жадное назначение и суммарное время, (iii) оптимальное назначение и время, (iv) кратко, почему жадный шаг ошибается глобально.
(3) Асимптотика в худшем случае для: (a) наивной рекурсии, (b) DP-алгоритма.
(4) Примените эффективный алгоритм к таблице ниже при \(D = 8\), \(S = 5\): таблица оптимумов для всех подзадач и итоговый ответ.
| Число разработчиков | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| Проект A | \(\infty\) | 24 d | 21 d | 18 d | 14 d | 9 d | 9 d | 8 d | 7 d |
| Проект B | \(\infty\) | 28 d | 22 d | 20 d | 16 d | 11 d | 10 d | 10 d | 9 d |
| Проект C | \(\infty\) | 38 d | 31 d | 30 d | 24 d | 20 d | 18 d | 14 d | 13 d |
| Проект D | \(\infty\) | 34 d | 33 d | 22 d | 20 d | 16 d | 14 d | 12 d | 11 d |
| Проект E | \(\infty\) | 32 d | 26 d | 21 d | 17 d | 13 d | 11 d | 10 d | 9 d |
Нажмите, чтобы увидеть решение
Ключевая идея: классическое DP-распределение \(D\) разработчиков по \(S\) проектам. Здесь минимизируется сумма сроков: выбрать \(d_i\) на проект \(i\) так, что \(\sum d_i = D\) и \(\sum C[i][d_i]\) минимально.
1(a)(i) Оптимальная подструктура:
Пусть \(\text{opt}(k, d)\) — минимальная суммарная длительность по первым \(k\) проектам при ровно \(d\) разработчиках. Тогда
\[\text{opt}(k, d) = \min_{0 \le j \le d} \bigl(\text{opt}(k-1,\, d - j) + C[k][j]\bigr)\]
с базой \(\text{opt}(0, d) = 0\) для всех \(d\) (нет проектов — нет вклада) и \(\text{opt}(k, 0) = \infty\) при \(k \ge 1\) (нельзя оставить проект без людей).
Наивная рекурсия многократно запрашивает один и тот же \(\text{opt}(k, d)\) из разных «родителей»: например, \(\text{opt}(2, 3)\) нужен и при разборе \(\text{opt}(3, 5)\) с одним \(j\), и при других ветках с иными \(j\).
1(b) Псевдокод (bottom-up DP):
function ASSIGN-DEVELOPERS(S, D, C):
// dp[k][d] = min total time for first k projects with d developers
dp[0..S][0..D] ← ∞
dp[0][d] ← 0 for all d
assign[k][d] ← 0 // stores how many devs assigned to project k
for k ← 1 to S:
for d ← 0 to D:
for j ← 0 to d:
if C[k][j] + dp[k-1][d-j] < dp[k][d]:
dp[k][d] ← C[k][j] + dp[k-1][d-j]
assign[k][d] ← j
// Reconstruct assignment
remaining ← D
for k ← S downto 1:
devs[k] ← assign[k][remaining]
remaining ← remaining - devs[k]
return dp[S][D], devs
2. Контрпример к жадному:
Возьмём 2 разработчика и 2 проекта с \(C[A][0] = C[B][0] = \infty\), \(C[A][1] = C[B][1] = 5\), \(C[A][2] = C[B][2] = 1\).
- (i) Маленькая таблица выше.
- (ii) Жадный порядок A, B: на A нельзя взять 2 человек (B останется без людей), значит на A берём 1 (5 дней), на B — 1 (5 дней), сумма 10.
- (iii) Оптимум: в этом симметричном примере тоже 10 при \((1,1)\); чтобы показать строгое отличие жадного от глобального оптимума, нужна несимметричная таблица, где локальный минимум по текущему проекту «съедает» людей, нужных дальше по списку.
- (iv) Жадный шаг смотрит только на стоимость текущего проекта и не моделирует, как остаток разработчиков повлияет на будущие строки \(C\); локально лучшее число людей на раннем проекте может удорожить сумму по всем.
3. Сложность:
- (a) Наивная рекурсия: до \(S\) уровней ветвления и до \((D+1)\) вариантов на уровень — порядок \(O((D+1)^S)\) вызовов в худшем случае, экспоненциально по \(S\).
- (b) DP: состояний \(O(S \cdot D)\), на состояние перебор \(j\) до \(d\) — суммарно \(\Theta(S \cdot D^2)\) времени и \(\Theta(S \cdot D)\) памяти.
4. Применение к таблице (\(D = 8\), \(S = 5\)):
Считаем \(\text{opt}(k, d)\) для \(k = 1 \ldots 5\), \(d = 0 \ldots 8\).
После \(k = 1\) (только проект A):
| \(d\) | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| \(\text{opt}(1,d)\) | \(\infty\) | 24 | 21 | 18 | 14 | 9 | 9 | 8 | 7 |
| assign A | — | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
Для следующих проектов перебираются все разбиения \(d\) между новым проектом и уже учтённым префиксом; оптимальные сплиты накапливаются в таблице.
Итог по всем 8 разработчикам и 5 проектам:
Чтобы найти минимум, перечисляются допустимые распределения с \(\sum d_i = 8\), \(d_i \ge 1\), и минимизируется \(\sum C[i][d_i]\):
- A=1(24), B=1(28), C=1(38), D=1(34), E=4(17): сумма 141 — слишком много проектов с одним разработчиком
- A=2(21), B=2(22), C=1(38), D=1(34), E=2(26): devs = 8, сумма 141
- A=3(18), B=2(22), C=1(38), D=1(34), E=1(32): devs = 8, сумма 144
- A=2(21), B=1(28), C=2(31), D=1(34), E=2(26): devs = 8, сумма 140
- A=1(24), B=2(22), C=2(31), D=1(34), E=2(26): devs = 8, сумма 137
- A=2(21), B=2(22), C=2(31), D=1(34), E=1(32): devs = 8, сумма 140
- A=1(24), B=2(22), C=1(38), D=2(33), E=2(26): devs = 8, сумма 143
- A=2(21), B=1(28), C=1(38), D=2(33), E=2(26): devs = 8, сумма 146
- A=1(24), B=2(22), C=2(31), D=2(33), E=1(32): devs = 8, сумма 142
- A=2(21), B=2(22), C=2(31), D=2(33), E=0: невозможно (проекту E нужен ≥1)
- A=1(24), B=1(28), C=2(31), D=2(33), E=2(26): devs = 8, сумма 142
- A=2(21), B=1(28), C=2(31), D=2(33), E=1(32): devs = 8, сумма 145
- A=1(24), B=2(22), C=3(30), D=1(34), E=1(32): devs = 8, сумма 142
- A=2(21), B=2(22), C=1(38), D=2(33), E=1(32): devs = 8, сумма 146
- A=1(24), B=3(20), C=2(31), D=1(34), E=1(32): devs = 8, сумма 141
- A=2(21), B=3(20), C=1(38), D=1(34), E=1(32): devs = 8, сумма 145
- A=1(24), B=3(20), C=1(38), D=2(33), E=1(32): devs = 8, сумма 147
- A=3(18), B=1(28), C=2(31), D=1(34), E=1(32): devs = 8, сумма 143
- A=1(24), B=2(22), C=3(30), D=2(33), E=0: невозможно
- A=2(21), B=2(22), C=3(30), D=1(34), E=0: невозможно
- A=1(24), B=1(28), C=3(30), D=2(33), E=1(32): devs = 8, сумма 147
- A=2(21), B=1(28), C=3(30), D=1(34), E=1(32): devs = 8, сумма 145
- A=3(18), B=2(22), C=2(31), D=1(34), E=0: невозможно
- A=1(24), B=2(22), C=2(31), D=1(34), E=2(26): devs = 8, сумма 137 ← кандидат
DP находит глобальный оптимум. Минимальная суммарная длительность — 137 дней, в том числе так:
- A: 1 разработчик → 24 дня
- B: 2 → 22 дня
- C: 2 → 31 день
- D: 1 → 34 дня
- E: 2 → 26 дней
- Итого людей: \(1+2+2+1+2=8\); сумма: \(24+22+31+34+26=137\)
Ответ: минимум 137 дней, назначение A=1, B=2, C=2, D=1, E=2.
4.2. Вычислить \(F_6\) наивной рекурсией (Лекция 5, Пример 1)
Найдите \(F_6\) наивным рекурсивным алгоритмом. Сколько всего делается вызовов функции?
Нажмите, чтобы увидеть решение
Ключевая идея: проследить дерево вызовов и увидеть массовое дублирование работы.
Рекурсивное определение:
F(0) = 0
F(1) = 1
F(n) = F(n-1) + F(n-2) for n ≥ 2
Фрагмент дерева для F(6):
F(6)
/ \
F(5) F(4)
/ \ / \
F(4) F(3) F(3) F(2)
/ \ / \ / \ / \
F(3) F(2) F(2) F(1) F(2) F(1) F(1) F(0)
... (continues with more branches)
Сколько раз вызывается каждое значение (типичный подсчёт по дереву):
- \(F(0)\): 5 раз
- \(F(1)\): 8 раз
- \(F(2)\): 5 раз
- \(F(3)\): 3 раза
- \(F(4)\): 2 раза
- \(F(5)\): 1 раз
- \(F(6)\): 1 раз
Суммарно вызовов: при полной трассировке наивной рекурсии для \(F(6)\) получается 25 вызовов функции (см. дерево вызовов; отдельные подсчёты по «типам» значений дают те же порядки повторного использования).
Точнее, число вызовов связано с \(T(n) = T(n-1) + T(n-2) + 1 \approx 2F_n - 1 = \Theta(\phi^n)\).
Ответ: вычисление \(F_6\) наивной рекурсией даёт 25 вызовов функции в стандартной трассировке; экспоненциальный рост делает наивный подход непригодным уже при умеренных \(n\).
4.3. Фибоначчи и bottom-up DP (Лекция 5, Пример 2)
Вычислите \(F_{10}\) bottom-up DP. Покажите таблицу.
Нажмите, чтобы увидеть решение
Ключевая идея: заполнить ряд значений от \(F_0\) вверх.
Таблица:
| \(i\) | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| \(F_i\) | 0 | 1 | 1 | 2 | 3 | 5 | 8 | 13 | 21 | 34 | 55 |
Шаги:
- Инициализация: \(F_0 = 0\), \(F_1 = 1\)
- Для \(i = 2,\ldots,10\): \(F_i = F_{i-1} + F_{i-2}\)
- \(F_2 = 1\), \(F_3 = 2\), \(F_4 = 3\), \(F_5 = 5\), \(F_6 = 8\), \(F_7 = 13\), \(F_8 = 21\), \(F_9 = 34\), \(F_{10} = 55\)
Ответ: \(F_{10} = 55\); нужно всего 10 сложений по одному на новый индекс — вместо тысяч вызовов при наивной рекурсии.
4.4. Максимальный подмассив и Кадане (Лекция 5, Пример 3)
Найдите максимальный подотрезок для \(A = [-2, 1, -3, 4, -1, 2, 1, -5, 4]\) методом DP (Кадане).
Нажмите, чтобы увидеть решение
Ключевая идея: поддерживать лучшую сумму суффикса, заканчивающегося в \(i\), и глобальный максимум.
Трассировка:
| \(i\) | \(A[i]\) | \(\text{max\_suffix\_sum}\) (до) | \(\text{max\_suffix\_sum} + A[i]\) | \(A[i]\) | \(\text{max\_suffix\_sum}\) (после) | \(\text{max\_sum}\) |
|---|---|---|---|---|---|---|
| 1 | -2 | — | — | -2 | -2 | -2 |
| 2 | 1 | -2 | \(-2 + 1 = -1\) | 1 | 1 ✓ | 1 |
| 3 | -3 | 1 | \(1 + (-3) = -2\) | -3 | -2 | 1 |
| 4 | 4 | -2 | \(-2 + 4 = 2\) | 4 | 4 ✓ | 4 |
| 5 | -1 | 4 | \(4 + (-1) = 3\) ✓ | -1 | 3 | 4 |
| 6 | 2 | 3 | \(3 + 2 = 5\) ✓ | 2 | 5 | 5 |
| 7 | 1 | 5 | \(5 + 1 = 6\) ✓ | 1 | 6 | 6 |
| 8 | -5 | 6 | \(6 + (-5) = 1\) ✓ | -5 | 1 | 6 |
| 9 | 4 | 1 | \(1 + 4 = 5\) ✓ | 4 | 5 | 6 |
Пояснение:
- В позиции 2 продолжение суффикса с 1 даёт \(-1\), новый старт в 2 даёт \(1\) — берём \(1\).
- В позиции 4 выгоднее начать новый отрезок с \(4\), а не продолжать с \(-2\).
- На участке 4–7 продолжение каждый раз не хуже нового старта.
- Глобальный максимум \(6\) достигается в позиции 7.
Индексы: подотрезок \([4, -1, 2, 1]\) (позиции 4–7), сумма \(6\).
Ответ: максимальная сумма 6, индексы 4–7.
4.5. НОП (LCS) двух последовательностей (Лекция 5, Задание 1)
Примените алгоритм LCS к:
- \(X = \langle A, B, C, B, D, A, B \rangle\)
- \(Y = \langle B, D, C, A, B, A \rangle\)
Нажмите, чтобы увидеть решение
Ключевая идея: таблица DP по всем парам префиксов \(X\) и \(Y\).
Таблица \(C[i, j]\):
| \(j=0\) | B | D | C | A | B | A | |
|---|---|---|---|---|---|---|---|
| \(i=0\) | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| A | 0 | 0 | 0 | 0 | 1 | 1 | 1 |
| B | 0 | 1 | 1 | 1 | 1 | 2 | 2 |
| C | 0 | 1 | 1 | 2 | 2 | 2 | 2 |
| B | 0 | 1 | 1 | 2 | 2 | 3 | 3 |
| D | 0 | 1 | 2 | 2 | 2 | 3 | 3 |
| A | 0 | 1 | 2 | 2 | 3 | 3 | 4 |
| B | 0 | 1 | 2 | 2 | 3 | 4 | 4 |
Восстановление: от \(C[7, 6] = 4\) идти по стрелкам назад:
- \((7, 6)\): \(B = B\), диагональ → взять B
- \((6, 5)\): \(A = A\), диагональ → взять A
- \((5, 4)\): \(D \neq A\), переход слева или сверху
- далее аналогично…
Одна из НОП: \(\langle B, C, A, B \rangle\), длина 4.
Ответ: длина НОП равна 4; пример НОП — \(\langle B, C, A, B \rangle\).
4.6. Rod cutting (Лекция 5, Задание 2)
Примените алгоритм rod cutting к стержню длины \(n = 10\) с ценами:
| Длина \(i\) | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| Цена \(p[i]\) | 1 | 5 | 8 | 9 | 10 | 17 | 17 | 20 | 24 | 30 |
Заполните строку оптимальных выручек \(r[i]\) для каждой длины \(i\).
Нажмите, чтобы увидеть решение
Ключевая идея: для каждой длины перебрать все варианты «первого куска» и взять максимум.
Таблица \(r[i]\):
| Длина \(i\) | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Выручка \(r[i]\) | 0 | 1 | 5 | 8 | 10 | 13 | 17 | 18 | 22 | 25 | 30 |
Пошаговый расчёт:
- \(i = 1\): \(r[1] = 1\)
- \(i = 2\): \(\max(1+1,\,5+0)=5\)
- \(i = 3\): \(\max(1+5,\,5+1,\,8+0)=8\)
- \(i = 4\): \(\max(9,10,9,9)=10\)
- \(i = 5\): \(13\)
- \(i = 6\): \(17\)
- \(i = 7\): \(18\)
- \(i = 8\): \(22\)
- \(i = 9\): \(25\)
- \(i = 10\): \(30\) (оптимально не резать)
Ответ: \(r[10]=30\); один из оптимумов — продать целиком за \(30\).
4.7. Рюкзак 0–1 (Лекция 5, Задание 3)
Заполните идею DP-таблицы \(K[i,w]\) для вместимости \(W = 24\) кг и предметов:
| Предмет | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| Вес (кг) | 12 | 2 | 6 | 3 | 24 | 1 | 9 |
| Ценность ($) | 36 | 4 | 24 | 2 | 64 | 1 | 22 |
Нажмите, чтобы увидеть решение
Ключевая идея: \(K[i, w]\) — максимальная ценность по первым \(i\) предметам при лимите веса \(w\).
Разбор при \(W = 24\):
- Предмет 5 (палатка, tent) весит ровно 24 кг и даёт ценность \(64\).
- Любая другая комбинация из таблицы не превосходит \(64\) (например, \(1+3+6\): вес 19 кг, ценность \(61\); \(1+7+6\): 22 кг, \(59\)).
Ответ: максимум $64 — взять только предмет 5.
4.8. Сравнить реализации Фибоначчи (Лекция 5, Задание 4)
Сравните число операций для \(F_{20}\) при:
- наивной рекурсии
- мемоизации
- bottom-up DP
Нажмите, чтобы увидеть решение
Ключевая идея: сравнить число сложений и/или вызовов.
(a) Наивная рекурсия:
- Вызовов порядка \(T(n) \approx 2F_n - 1\)
- При \(n = 20\): \(F_{20} = 6765\), значит около \(2 \cdot 6765 - 1 = 13\,529\) вызовов
- Сложений: порядка \(6\,765\)
(b) Мемоизация:
- Каждое из \(F_0,\ldots,F_{20}\) вычисляется один раз
- Вызовов функции: порядка \(n+1 = 21\)
- Сложений: \(n-1 = 19\)
- Итого: порядка 21 обращений к кэшу + 19 сложений \(\approx 40\) элементарных шагов в такой модели
(c) Bottom-up DP:
- Цикл \(i = 2,\ldots,20\): 19 итераций, по одному сложению
- Итого: 19 сложений
Сравнение:
| Метод | Вызовы | Сложения | Порядок |
|---|---|---|---|
| Наивная рекурсия | ~13 529 | ~6 765 | \(O(2^n)\) |
| Мемоизация | 21 | 19 | \(O(n)\) |
| Bottom-up DP | 0 рекурсии | 19 | \(O(n)\) |
Ответ: по числу сложений выигрывает bottom-up (19); наивная рекурсия экспоненциально медленнее.
4.9. Rod cutting со стоимостью реза (Лекция 5, Задание 5)
Модификация: каждый разрез стоит \(c\) долларов. Как меняется рекуррентность?
Нажмите, чтобы увидеть решение
Ключевая идея: из выручки при каждом реальном разрезе вычитается \(c\).
Если делаем разрез:
- выручка за первый кусок: \(p_i\)
- за остаток: \(r_{n-i}\)
- плюс штраф \(-c\) за сам разрез
Тогда
\[r_n = \max\begin{cases} p_n & \text{(no cut)} \\ \max_{1 \le i < n} (p_i + r_{n-i} - c) & \text{(make a cut)} \end{cases}\]
Эквивалентно:
\[r_n = \max\left(p_n, \max_{1 \le i < n} (p_i + r_{n-i} - c)\right)\]
Отличие: при большом \(c\) может быть выгоднее резать реже или не резать вовсе.
Пример: \(n = 4\), \(p = [1, 5, 8, 9]\), \(c = 2\): без разреза \(9\); с разрезами выигрыш меньше после вычитания \(c\).
Ответ: в рекуррентность входит вычитание \(c\) на каждый сделанный разрез: \(r_n = \max(p_n, \max_{1 \le i < n}(p_i + r_{n-i} - c))\).