ДП на подпоследовательностях: LIS и LCS

Два знаковых ДП о подпоследовательностях: самая длинная возрастающая в одном массиве и самая длинная общая для двух строк.

Подпоследовательность — то, что получается из последовательности удалением некоторых элементов без изменения порядка оставшихся (в отличие от подотрезка, элементы не обязаны идти подряд).

Зачем это нужно

LIS (Longest Increasing Subsequence) и LCS (Longest Common Subsequence) — два самых известных ДП на подпоследовательностях, и оба регулярно появляются на олимпиадах. LIS лежит в основе задач о «росте», расстановках, разбиениях на возрастающие цепочки. LCS — это база для сравнения строк, диффа файлов, биоинформатики (сравнение ДНК). Помимо практической пользы, эти задачи учат двум разным техникам: LIS показывает неочевидную оптимизацию до O(n log n) через бинпоиск, а LCS — классическое двумерное ДП по двум строкам.

LIS: наибольшая возрастающая подпоследовательность

Дан массив; нужно найти длину самой длинной строго возрастающей подпоследовательности. Например, в [10, 9, 2, 5, 3, 7, 101, 18] это [2, 3, 7, 18] или [2, 5, 7, 101] — длина 4.

Простое ДП за O(n^2): dp[i] — длина LIS, оканчивающейся в позиции i; для каждого i смотрим все j < i с a[j] < a[i]. Но есть элегантный приём за O(n log n). Идея: поддерживаем массив tails, где tails[k]наименьший возможный последний элемент возрастающей подпоследовательности длины k+1. Для нового элемента бинпоиском находим место и обновляем.

from bisect import bisect_left

a = [10, 9, 2, 5, 3, 7, 101, 18]
tails = []                          # tails[k] = мин. хвост LIS длины k+1
for x in a:
    i = bisect_left(tails, x)       # куда встаёт x (строгое возрастание)
    if i == len(tails):
        tails.append(x)             # x продлевает самую длинную
    else:
        tails[i] = x                # x улучшает хвост длины i+1
print("Длина LIS:", len(tails))

Почему это работает? Длина tails в любой момент равна длине текущей LIS. Когда приходит x, мы либо удлиняем последовательность (если x больше всех хвостов), либо «улучшаем» хвост подходящей длины, делая будущее удлинение более вероятным. Каждый элемент — один бинпоиск O(log n), итого O(n log n). Заметь: сам tailsне LIS, но его длина верна.

Вывод:

Длина LIS: 4

LCS: наибольшая общая подпоследовательность

Даны две строки; нужно найти длину самой длинной подпоследовательности, общей для обеих. Для "AGGTAB" и "GXTXAYB" это "GTAB" — длина 4. Это классическое двумерное ДП.

Состояние dp[i][j] — длина LCS префиксов s[:i] и t[:j]. Переход:

  • Если s[i−1] == t[j−1] — символы совпали: dp[i][j] = dp[i−1][j−1] + 1 (берём этот символ в общую подпоследовательность).
  • Иначе берём лучшее, отбросив один символ: dp[i][j] = max(dp[i−1][j], dp[i][j−1]).
s, t = "AGGTAB", "GXTXAYB"
m, n = len(s), len(t)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
    for j in range(1, n + 1):
        if s[i - 1] == t[j - 1]:
            dp[i][j] = dp[i - 1][j - 1] + 1
        else:
            dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

# Восстановление самой подпоследовательности по таблице:
i, j = m, n
res = []
while i > 0 and j > 0:
    if s[i - 1] == t[j - 1]:
        res.append(s[i - 1]); i -= 1; j -= 1
    elif dp[i - 1][j] >= dp[i][j - 1]:
        i -= 1
    else:
        j -= 1
print("Длина LCS:", dp[m][n], "| сама LCS:", "".join(reversed(res)))

Таблица заполняется по строкам; dp[m][n] — длина ответа. Чтобы вывести саму подпоследовательность, идём по таблице назад из правого нижнего угла: при совпадении символов берём его и шагаем по диагонали, иначе движемся туда, откуда пришёл максимум. Сложность — O(m·n) по времени и памяти.

Вывод:

Длина LCS: 4 | сама LCS: GTAB

Сравнение

ЗадачаСостояниеСложность
LIS (наивно)dp[i] — LIS, кончающаяся в iO(n^2)
LIS (быстро)массив хвостов + бинпоискO(n log n)
LCSdp[i][j] по двум префиксамO(m·n)

Подводные камни

  • Подпоследовательность ≠ подотрезок. Элементы не обязаны идти подряд — это частая путаница в формулировках.
  • Строгое или нестрогое возрастание в LIS? Для строгого — bisect_left, для нестрогого (неубывание) — bisect_right.
  • Массив tails в LIS — не ответная последовательность. Его длина верна, но содержимое может не быть настоящей LIS; для восстановления самой LIS нужен дополнительный учёт.
  • LCS-таблица квадратична по памяти; при огромных строках применяют оптимизацию Хиршберга, хранящую лишь две строки.

Итог

  • LIS — самая длинная возрастающая подпоследовательность; быстрый способ через массив хвостов и бинпоиск даёт O(n log n).
  • LCS — самая длинная общая подпоследовательность двух строк; двумерное ДП за O(m·n) с восстановлением ответа по таблице назад.
  • Подпоследовательность — это удаление элементов с сохранением порядка, а не непрерывный кусок.
  • Следи за строгостью неравенства в LIS и за памятью в LCS.
Проверьте себя
1. За какую сложность можно найти длину LIS с помощью массива хвостов и бинарного поиска?
AO(n^2)
BO(n log n)
CO(n)
DO(2^n)
2. Каков переход в ДП для LCS, когда символы s[i-1] и t[j-1] совпадают?
Adp[i][j] = max(dp[i-1][j], dp[i][j-1])
Bdp[i][j] = dp[i-1][j-1] + 1
Cdp[i][j] = dp[i-1][j-1]
Ddp[i][j] = 0
3. Чем подпоследовательность отличается от подотрезка (подстроки)?
AНичем
BЭлементы подпоследовательности не обязаны идти подряд, но порядок сохраняется
CПодпоследовательность всегда длиннее
DПодпоследовательность идёт в обратном порядке

Закрепите практикой

Задачи с автоматической проверкой — решайте прямо здесь, не уходя из учебника.

Поддержать проект