4. Distinct Subsequences
«Количество путей к цели может быть огромным, но каждый из них строится из одних и тех же элементарных кирпичиков».
— Брайан Керниган
Задача о различных подпоследовательностях (Distinct Subsequences, LeetCode 115) переводит строковое динамическое программирование в плоскость комбинаторного анализа.
Если в 3. Longest Common Subsequence мы определяли длину максимального пересечения, то здесь перед нами стоит задача жесткого подсчета: сколькими уникальными способами можно вырезать строку $T$ из исходной строки $S$, удаляя произвольные символы, но не нарушая порядок оставшихся?
В архитектуре распределенных систем этот паттерн лежит в основе:
- Детекции паттернов в распределенном трейсинге (OpenTelemetry): сколько подпоследовательностей вызовов в неструктурированном потоке логов соответствуют целевому сценарию сбоя?
- Аудита сетевого трафика и DPI (Deep Packet Inspection): сопоставление подозрительных сигнатур атак в фрагментированном потоке пакетов.
1. Постановка задачи и комбинаторный взрыв
Условие:
Даны две строки: исходный текст s и целевой шаблон t.
Верните количество различных подпоследовательностей s, которые в точности равны t.
Вход: s = "rabbbit", t = "rabbit"
Выход: 3
Пояснение: в слове "rabbbit" три буквы 'b'. Чтобы получить "rabbit",
достаточно удалить любую одну из них: C(3, 1) = 3 способа.
2. Формулировка 2D DP: Дилемма «Брать или пропускать»
Определим состояние:
$dp[i][j]$ — число способов составить префикс $t[0 \dots j-1]$ из префикса $s[0 \dots i-1]$.
Рассматривая очередной символ исходного текста $s[i-1]$, мы сталкиваемся с выбором:
- Пропустить символ $s[i-1]$:
Мы игнорируем текущий символ текста. Число способов собрать $t[0 \dots j-1]$ в таком случае в точности равно результату без этого символа:
$$dp[i-1][j]$$ - Использовать символ $s[i-1]$ (если $s[i-1] == t[j-1]$):
Если текущий символ текста совпал с текущим символом шаблона, мы можем их спарить! Тогда число способов прибавляется из состояния без обоих этих символов:
$$dp[i-1][j-1]$$
Итоговое рекуррентное соотношение:
$$dp[i][j] = \begin{cases} dp[i-1][j] + dp[i-1][j-1], & \text{если } s[i-1] == t[j-1] \\ dp[i-1][j], & \text{если } s[i-1] \neq t[j-1] \end{cases}$$
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
Check{"Символы s[i-1] == t[j-1]?"}:::warning
Check -- "Да (Совпали)" --> BranchMatch["Два пути суммируются:<br/>1. Пропустить: dp[i-1][j]<br/>2. Спарить: dp[i-1][j-1]"]:::success
BranchMatch --> Sum["dp[i][j] = dp[i-1][j] + dp[i-1][j-1]"]:::entry
Check -- "Нет (Разные)" --> BranchSkip["Только пропуск:<br/>dp[i-1][j]"]:::process
BranchSkip --> Keep["dp[i][j] = dp[i-1][j]"]:::entry
Базовые краевые условия:
- $dp[i][0] = 1$: Пустую строку $t$ можно собрать из любого префикса $s$ ровно одним способом — удалив абсолютно все символы.
- $dp[0][j] = 0$ (при $j > 0$): Непустую строку $t$ невозможно получить из пустой строки $s$.
3. Архитектурный инсайт: Обратный обход (Сжатие до $O(N)$ памяти)
Как сжать матрицу до одного одномерного среза dp длиной $N+1$, не вводя дополнительных переменных для диагонали?
В формуле:
$$dp[j] = dp[j] + dp[j-1]$$
Нам нужны оба значения из предыдущей строки $i-1$:
- $dp[i-1][j]$ (старое значение в текущем столбце).
- $dp[i-1][j-1]$ (старое значение в левом столбце).
Если мы запустим внутренний цикл слева направо ($j = 1 \dots N$), то обновление $dp[j-1]$ сотрет старое значение, необходимое для $dp[j]$.
Инженерное решение (Паттерн 0/1 Рюкзака):
Запустим внутренний цикл справа налево — от $j = N$ до $1$!
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
subgraph ReverseLoop["Обратный проход: от j = N к 1"]
LeftOld["dp[j-1] (еще не перезаписан: строка i-1)"]:::process
CurrOld["dp[j] (еще не перезаписан: строка i-1)"]:::warning
New["dp[j] = CurrOld + LeftOld"]:::success
CurrOld & LeftOld --> New
end
При обратном ходе ячейка $dp[j-1]$ еще не обновлена на текущей строке $i$ — она все еще хранит значение с предыдущей строки $i-1$! Это полностью исключает порчу данных без единой вспомогательной переменной.
4. Идиоматичная реализация на Go 1.22+
package strings
// NumDistinct возвращает количество различных подпоследовательностей s, равных t.
// Временная сложность: O(M * N).
// Пространственная сложность: O(N) — один срез размера len(t)+1.
func NumDistinct(s string, t string) int {
m, n := len(s), len(t)
// Быстрый отсев: шаблон не может быть длиннее исходного текста
if n > m {
return 0
}
// Используем uint64 для защиты от переполнения промежуточных сумм
dp := make([]uint64, n+1)
// База: пустую строку t можно собрать 1 способом
dp[0] = 1
for i := 1; i <= m; i++ {
// ОБРАТНЫЙ ОБХОД: справа налево!
// Гарантирует, что dp[j-1] берется из предыдущей строки i-1.
for j := n; j >= 1; j-- {
if s[i-1] == t[j-1] {
dp[j] += dp[j-1]
}
// При несовпадении dp[j] остается неизменным: dp[j] = dp[j] (сверху)
}
}
return int(dp[n])
}
5. Mechanical Sympathy: Направление памяти и Hardware Prefetcher
Может ли обратный обход замедлить выполнение на уровне процессорного кэша?
- Обратный префетчинг: В современных микроархитектурах (Intel Core, AMD Zen, Apple Silicon) блоки предвыборки данных (Data Stream Prefetchers) поддерживают как положительный, так и отрицательный шаг (
stride = -1). Процессор распознает обратное чтение кэш-линий и подтягивает их заранее с той же эффективностью, что и при прямом обходе. - Плотность упаковки: Срез
dpимеет длину всего $N + 1$ (длина шаблона $T$, обычно не превышает сотен символов). Массив размером в несколько килобайт намертво оседает в L1D-кэше процессора. Все операции сложения производятся со скоростью кэша (~1 нс).
6. Вопросы для собеседования
Итог
- Комбинаторная формула: $dp[i][j] = dp[i-1][j] + (s[i-1] == t[j-1] \ ? \ dp[i-1][j-1] : 0)$.
- Обратный цикл — ключ к $O(N)$ памяти: Обход от $N$ к $1$ сохраняет значения предыдущей строки $i-1$ без вспомогательных переменных.
- Раннее отсечение: Проверка
if len(t) > len(s)защищает от лишней работы за $O(1)$.
В следующей лекции мы перейдем к одной из вершин алгоритмической сложности в строковом DP — реализации сопоставления с регулярными выражениями: 5. Regular Expression Matching.