Оптимизация памяти для двумерного DP
Сокращайте память для LCS и расстояния редактирования с O(mn) до O(min(m,n)), сохраняя только текущую и предыдущую строки таблицы DP
«Оптимизация памяти для двумерного DP» — бесплатный урок Coding Interview Prep на CoddyKit. Это урок 4 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения Coding Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс Coding Interview Prep содержит 4 уроков всего.
Почему объём памяти важен в двумерном DP
Таблица двумерного DP для строк длиной 1000 требует 1000×1000 = 1 000 000 ячеек — примерно 8 MB для 64-разрядных целых чисел. Для более длинных последовательностей (выравнивание DNA, сравнение больших текстов с помощью diff) это становится непрактичным. Ключевое наблюдение состоит в том, что большинство рекуррентных соотношений двумерного DP обращаются только к текущей и предыдущей строкам, поэтому всю таблицу можно сжать до одного или двух одномерных массивов. Это основа оптимизации памяти в двумерном DP.
# Full 2D DP: O(mn) space
# LCS for 1000-char strings
m, n = 1000, 1000
dp_2d_size = m * n * 8 # bytes (64-bit ints)
print(f'2D table: {dp_2d_size:,} bytes = {dp_2d_size//1024} KB')
# 1D rolling array: O(n) space
dp_1d_size = n * 8
print(f'1D array: {dp_1d_size:,} bytes = {dp_1d_size} bytes')
print(f'Space saving: {dp_2d_size // dp_1d_size}x')Шаблон скользящего массива
Шаблон скользящего массива заменяет полную двумерную таблицу одномерным массивом, представляющим предыдущую строку. При вычислении строки i обновляйте каждую ячейку j, используя текущее значение dp[j] (в нём всё ещё хранится значение dp[i-1][j] из предыдущей строки) и только что обновлённое значение dp[j-1] (то есть dp[i][j-1]). Переменная diagonal сохраняет dp[i-1][j-1] до его перезаписи. Этот шаблон применим к LCS, расстоянию редактирования и большинству задач двумерного DP.
# Rolling array template for 2D DP
# Before update: dp[j] holds dp[i-1][j] (previous row)
# After update: dp[j] holds dp[i][j] (current row)
def rolling_array_template(grid):
m, n = len(grid), len(grid[0])
dp = [0] * (n + 1) # represents one row
for i in range(1, m + 1):
diag = 0 # stores dp[i-1][j-1] before overwrite
for j in range(1, n + 1):
temp = dp[j] # save dp[i-1][j] before overwriting
# compute dp[i][j] using dp[j] (above) and dp[j-1] (left) and diag
dp[j] = diag + dp[j] + dp[j-1] # placeholder logic
diag = temp
return dp[n]LCS с памятью O(min m,n)
Для LCS убедитесь, что text1 — более короткая строка, чтобы n было небольшим. Выделите одномерный массив размера n+1. Обрабатывайте строки одну за другой. В каждой ячейке сохраните temp = dp[j] (это dp[i-1][j]). Затем: если символы совпадают, dp[j] = diag + 1; иначе dp[j] = max(dp[j], dp[j-1]). В конце установите diag = temp. После обработки всех строк в dp[n] хранится длина LCS.
def lcs_space_opt(text1, text2):
# Ensure text2 is the shorter one
if len(text1) < len(text2):
text1, text2 = text2, text1
m, n = len(text1), len(text2)
dp = [0] * (n + 1)
for i in range(1, m + 1):
diag = 0
for j in range(1, n + 1):
temp = dp[j] # dp[i-1][j]
if text1[i-1] == text2[j-1]:
dp[j] = diag + 1
else:
dp[j] = max(dp[j], dp[j-1])
diag = temp
return dp[n]
print(lcs_space_opt('ABCBDAB', 'BDCABA')) # 4
print(lcs_space_opt('AGGTAB', 'GXTXAYB')) # 4Расстояние редактирования с памятью O(n)
Для расстояния редактирования используется тот же шаблон скользящего массива. Исходный одномерный массив представляет строку 0: dp[j] = j (вставка j символов). Для каждой строки i установите dp[0] = i (удаление i символов) и сохраните diag = dp[0] перед обновлением. Во внутреннем цикле сохраните temp = dp[j], вычислите новое значение через вставку (dp[j-1]+1), удаление (dp[j]+1) и замену (diag + cost), а затем установите diag = temp.
def edit_dist_opt(s, t):
m, n = len(s), len(t)
dp = list(range(n + 1)) # row 0: dp[0][j] = j
for i in range(1, m + 1):
diag = dp[0] # dp[i-1][0] before dp[0] update
dp[0] = i # dp[i][0] = i
for j in range(1, n + 1):
temp = dp[j] # dp[i-1][j]
cost = 0 if s[i-1] == t[j-1] else 1
dp[j] = min(
dp[j-1] + 1, # insert
dp[j] + 1, # delete
diag + cost # replace or match
)
diag = temp
return dp[n]
print(edit_dist_opt('horse', 'ros')) # 3
print(edit_dist_opt('intention', 'execution')) # 5Минимальная сумма пути с O(n) памятью
Для задачи о минимальной сумме пути на сетке одномерный скользящий массив изначально содержит префиксные суммы первой строки (в каждую ячейку первой строки можно попасть только одним способом). Для каждой следующей строки обновляйте значения слева направо: dp[j] до обновления — это значение из строки выше (dp[i-1][j]), а только что обновлённое dp[j-1] — значение слева. Диагональ здесь не нужна, потому что для минимальной суммы пути диагональная ячейка не требуется.
def min_path_sum_opt(grid):
m, n = len(grid), len(grid[0])
dp = [float('inf')] * n
dp[0] = 0
for i in range(m):
# Update first column (only from above)
dp[0] += grid[i][0]
for j in range(1, n):
# min of above (dp[j] = old) and left (dp[j-1] = updated)
dp[j] = grid[i][j] + min(dp[j], dp[j-1])
return dp[n-1]
grid = [[1,3,1],[1,5,1],[4,2,1]]
print(min_path_sum_opt(grid)) # 7Когда необходим доступ к диагонали
Не все задачи двумерного DP можно сжать с помощью простого скользящего массива, поскольку для некоторых после перезаписи dp[j] требуется диагональный элемент dp[i-1][j-1]. Исправление всегда одно: сохраните temp = dp[j] до его обновления и используйте это значение как diag при вычислении следующего столбца. Такой просмотр на одну ячейку вперёд аккуратно обрабатывает все рекуррентные формулы с тремя направлениями (LCS, расстояние редактирования).
# Recap: the diagonal save pattern
# Without it: dp[j-1] updated (left) and dp[j] about to be overwritten
# With it:
def show_diagonal_pattern(s1, s2):
n = len(s2)
dp = [0] * (n + 1)
for ch1 in s1:
diag = 0 # was dp[i-1][0] = 0 for LCS
for j, ch2 in enumerate(s2, 1):
temp = dp[j] # SAVE before overwrite
if ch1 == ch2:
dp[j] = diag + 1 # use saved diagonal
else:
dp[j] = max(dp[j], dp[j-1])
diag = temp # advance diagonal
return dp[n]
print(show_diagonal_pattern('ABCBDAB', 'BDCABA')) # 4Оптимизация памяти двумерного рюкзака
Задача о рюкзаке 0/1 также выигрывает от оптимизации памяти. Полная двумерная таблица имеет размеры (n_items+1) × (capacity+1). Скользящий массив уменьшает их до O(capacity). Критическое отличие от LCS и расстояния редактирования: измерение вместимости нужно перебирать в обратном порядке (от большего значения к меньшему). Это гарантирует, что каждый предмет будет учтён не более одного раза: прямой перебор позволил бы выбирать один предмет многократно.
def knapsack_01(weights, values, capacity):
dp = [0] * (capacity + 1)
for w, v in zip(weights, values):
# Reverse order: prevents using the same item twice
for c in range(capacity, w - 1, -1):
dp[c] = max(dp[c], dp[c - w] + v)
return dp[capacity]
weights = [1, 3, 4, 5]
values = [1, 4, 5, 7]
cap = 7
print(knapsack_01(weights, values, cap)) # 9 (items 3+4: weight 3+4=7, value 4+5=9)Прямой и обратный порядок итерации
Крайне важно знать, в каком направлении перебирать внутренний цикл: в обратном — для задачи о рюкзаке 0/1 (каждый предмет используется не более одного раза; обращение к предыдущим состояниям предотвращает повторное использование); в прямом — для неограниченного рюкзака (каждый предмет можно использовать повторно; обращение к уже обновлённым состояниям позволяет использовать его несколько раз). Ошибка здесь незаметно превращает задачу 0/1 в неограниченный рюкзак или наоборот. Всегда проверяйте ограничение, прежде чем выбирать направление.
# 0/1 Knapsack: each item used AT MOST ONCE → iterate reverse
def knapsack_01_demo(weights, values, cap):
dp = [0] * (cap + 1)
for w, v in zip(weights, values):
for c in range(cap, w-1, -1): # REVERSE
dp[c] = max(dp[c], dp[c-w] + v)
return dp[cap]
# Unbounded Knapsack: items can be reused → iterate forward
def knapsack_unbounded(weights, values, cap):
dp = [0] * (cap + 1)
for c in range(1, cap + 1):
for w, v in zip(weights, values):
if c >= w:
dp[c] = max(dp[c], dp[c-w] + v) # FORWARD
return dp[cap]
print(knapsack_01_demo([2,3],[3,4],5)) # 7
print(knapsack_unbounded([2,3],[3,4],5)) # 8 (use weight-2 twice: 3+3=6? or 4+... )Уникальные пути с O(n) памятью
Для задачи об уникальных путях всю таблицу можно заменить одной строкой. Инициализируйте все ячейки единицами (это первая строка). Для каждой следующей строки обновляйте значения слева направо: dp[j] += dp[j-1]. Диагональ не нужна, поскольку рекуррентная формула использует только ячейку сверху (dp[j], текущее значение до обновления) и ячейку слева (dp[j-1], уже обновлённое значение). Это самый простой способ сжатия двумерной структуры в одномерную.
def unique_paths_opt(m, n):
dp = [1] * n # first row: all 1s
for i in range(1, m):
for j in range(1, n):
dp[j] += dp[j-1] # above (dp[j]) + left (dp[j-1])
return dp[n-1]
# With obstacles
def unique_paths_obstacles_opt(grid):
m, n = len(grid), len(grid[0])
dp = [0] * n
dp[0] = 1
for i in range(m):
if grid[i][0] == 1: dp[0] = 0 # blocked column
for j in range(1, n):
if grid[i][j] == 1: dp[j] = 0 # blocked
else: dp[j] += dp[j-1]
return dp[n-1]
print(unique_paths_opt(3, 7)) # 28
print(unique_paths_obstacles_opt([[0,0,0],[0,1,0],[0,0,0]])) # 2Двухстрочный буфер для сложных рекуррентных формул
Если рекуррентная формула использует ячейки из двух или более предыдущих строк (например, в некоторых вариантах интервального DP или при сведении 3D DP), применяется двухстрочный буфер: поддерживайте массивы prev и curr и меняйте их местами после каждой строки. Это даёт O(2n) = O(n) памяти. Для рекуррентных формул, обращающихся к строкам на расстоянии k, поддерживайте k массивов в виде кольцевого буфера. Это обобщает шаблон одномерного скользящего массива.
def lcs_two_row_buffer(s1, s2):
m, n = len(s1), len(s2)
prev = [0] * (n + 1) # dp[i-1]
curr = [0] * (n + 1) # dp[i]
for i in range(1, m + 1):
curr[0] = 0
for j in range(1, n + 1):
if s1[i-1] == s2[j-1]:
curr[j] = prev[j-1] + 1
else:
curr[j] = max(prev[j], curr[j-1])
prev, curr = curr, prev # swap (curr becomes prev)
return prev[n] # after swap, prev holds the last computed row
print(lcs_two_row_buffer('ABCBDAB', 'BDCABA')) # 4Когда оптимизация памяти невозможна
Оптимизация памяти не всегда возможна. Если необходимо восстановить оптимальное решение (а не только его значение), обычно требуется полная таблица для поиска с возвратом. Возможные обходные пути: (1) хранить отдельную таблицу решений такого же размера; (2) использовать алгоритм Хиршберга, который вычисляет LCS за O(mn) времени и использует O(min(m,n)) памяти, включая восстановление решения, рекурсивно разделяя задачу в её середине; (3) принять расход O(mn) памяти, если восстановление необходимо.
# When reconstruction needed: must keep full table or use Hirschberg
# Hirschberg's idea: compute LCS length in O(n) space at midpoint of s1,
# recurse on left and right halves. O(mn) time, O(n) space + reconstruction.
# For interview: mention the trade-off
# 'I can reduce to O(n) space if only the value is needed.
# To also reconstruct the sequence, I need the full O(mn) table
# or a more complex divide-and-conquer approach.'
print('Space opt: O(n) for length only')
print('Full table: O(mn) needed for reconstruction')Быстрая проверка
Проверьте понимание концепций «Структуры данных и алгоритмы — подготовка к техническому собеседованию» из этого урока.
Итоги урока
В этом уроке Вы узнали: таблицы двумерного DP можно сжать до O(n) памяти с помощью одномерного скользящего массива, если требуется только предыдущая строка, шаблон диагональной переменной (сохранение temp перед перезаписью) обрабатывает рекуррентные формулы, которым нужен dp[i-1][j-1], а в задаче о рюкзаке 0/1 вместимость перебирается в обратном порядке, тогда как в неограниченном рюкзаке — в прямом. Далее мы изучим шаблон поиска с возвратом: выбрать, исследовать, отменить выбор — основу алгоритмов исчерпывающего поиска.
Часто задаваемые вопросы
Урок «Оптимизация памяти для двумерного DP» бесплатный?
Да — полный текст урока «Оптимизация памяти для двумерного DP» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс Coding Interview Prep, подпишись на CoddyKit PRO. Курс Coding Interview Prep содержит 4 уроков всего.
Чему я научусь в уроке «Оптимизация памяти для двумерного DP»?
Сокращайте память для LCS и расстояния редактирования с O(mn) до O(min(m,n)), сохраняя только текущую и предыдущую строки таблицы DP Ты практикуешь Coding Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.
Нужен ли мне опыт, чтобы начать Coding Interview Prep?
Предыдущий опыт не требуется. Coding Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 4 из 4.
Сколько времени занимает урок «Оптимизация памяти для двумерного DP»?
Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.
Можно ли писать и запускать код в этом уроке Coding Interview Prep?
Да. Каждый урок Coding Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.
Все уроки этого курса
- Уникальные пути и минимальная сумма пути на сетках
- Наибольшая общая подпоследовательность
- Расстояние редактирования (Левенштейна)
- Оптимизация памяти для двумерного DP