7. Interleaving String
«Два параллельных потока соединяются воедино лишь тогда, когда в них соблюден строгий порядок каждого шага».
— Брайан Керниган
Задача о перемежающейся строке (Interleaving String, LeetCode 97) — одна из наиболее концептуальных задач двумерного динамического программирования на стыке матричной геометрии и строковых потоков.
Если в 6. Edit Distance мы свободно вставляли, удаляли и заменяли символы за определенную цену, то здесь правила строги: у нас есть два упорядоченных источника данных, и мы обязаны восстановить результирующий поток символ в символ, не нарушая относительный порядок ни в одном из источников.
В архитектуре бэкенда это точная модель мультиплексирования и ресинхронизации параллельных потоков событий:
- Слияние логов двух независимых реплик БД в единый аудиторский след (Audit Log Reassembly).
- Демультиплексирование TCP/QUIC фреймов при агрегации каналов.
- Проверка согласованности репликации в системах с Event Sourcing.
1. Постановка задачи и ранняя фильтрация
Условие:
Даны три строки: s1, s2 и s3.
Определите, можно ли получить s3 путем чередования (перемежения) символов из s1 и s2.
Относительный порядок символов внутри s1 и s2 обязан строго сохраняться.
Вход: s1 = "aabcc", s2 = "dbbca", s3 = "aadbbcbcac"
Выход: true
Пояснение: разбиение по источникам:
s3 = "aa" (из s1) + "dbbc" (из s2) + "bc" (из s1) + "a" (из s2) + "c" (из s1)
2. Формулировка 2D DP: Два источника истины
Определим состояние:
$dp[i][j]$ — булево значение: можно ли составить префикс $s3$ длиной $i + j$, используя первые $i$ символов строки $s1$ и первые $j$ символов строки $s2$.
Текущий символ $s3[i+j-1]$ мог поступить только из двух мест:
- Из строки $s1$: если символ $s1[i-1]$ совпадает с $s3[i+j-1]$, и мы могли успешно собрать предыдущую часть без него:
$dp[i-1][j] == \text{true}$ - Из строки $s2$: если символ $s2[j-1]$ совпадает с $s3[i+j-1]$, и мы могли успешно собрать предыдущую часть без него:
$dp[i][j-1] == \text{true}$
Итоговое рекуррентное соотношение (дизъюнкция):
$$dp[i][j] = \Big(dp[i-1][j] \land s1[i-1] == s3[i+j-1]\Big) \;\lor\; \Big(dp[i][j-1] \land s2[j-1] == s3[i+j-1]\Big)$$
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,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
Top["dp[i-1][j]<br/>(Символ взят из s1)"]:::process
Left["dp[i][j-1]<br/>(Символ взят из s2)"]:::process
Target["dp[i][j] = true / false"]:::entry
Top -- "s1[i-1] == s3[i+j-1]" --> Target
Left -- "s2[j-1] == s3[i+j-1]" --> Target
3. Двойственность представлений: Сетка как граф переходов
Матрицу $dp[i][j]$ можно рассматривать как ориентированный ациклический граф (DAG):
- Стартовая вершина: $(0, 0)$ (обе строки пусты).
- Ребро вниз в $(i+1, j)$: берем символ из $s1$.
- Ребро вправо в $(i, j+1)$: берем символ из $s2$.
- Финишная вершина: $(m, n)$.
Задача эквивалентна поиску пути из $(0, 0)$ в $(m, n)$ на графе, что делает возможным решение через BFS/DFS с мемоизацией!
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
V00["(0,0): Start"]:::entry -->|"Шаг вправо: s2"| V01["(0,1)"]:::process
V00 -->|"Шаг вниз: s1"| V10["(1,0)"]:::process
V01 --> V11["(1,1)"]:::warning
V10 --> V11
V11 -.-> Vmn["(m,n): Goal"]:::success
4. Идиоматичная реализация на Go 1.22+ ($O(N)$ памяти)
Вместо полноразмерной матрицы [][]bool мы используем сжатый одномерный срез []bool длиной $n+1$:
package interleaving
// IsInterleave проверяет, является ли s3 корректным перемежением s1 и s2.
// Временная сложность: O(M * N).
// Пространственная сложность: O(N) — один компактный булевый срез.
func IsInterleave(s1 string, s2 string, s3 string) bool {
m, n := len(s1), len(s2)
// Быстрый отсев: закон сохранения длины
if m+n != len(s3) {
return false
}
// dp[j] хранит истинность для префикса s1 длины i и s2 длины j
dp := make([]bool, n+1)
for i := 0; i <= m; i++ {
for j := 0; j <= n; j++ {
if i == 0 && j == 0 {
dp[j] = true // Две пустые строки образуют пустую строку
} else if i == 0 {
// Первая строка: берем символы строго из s2
dp[j] = dp[j-1] && s2[j-1] == s3[j-1]
} else if j == 0 {
// Первый столбец: берем символы строго из s1
// dp[0] берет старое значение сверху (с предыдущего i)
dp[0] = dp[0] && s1[i-1] == s3[i-1]
} else {
// Общий случай: либо сверху (из s1), либо слева (из s2)
fromS1 := dp[j] && s1[i-1] == s3[i+j-1]
fromS2 := dp[j-1] && s2[j-1] == s3[i+j-1]
dp[j] = fromS1 || fromS2
}
}
}
return dp[n]
}
5. Mechanical Sympathy: Плотность упаковки и Bitset DP
В Go значение типа bool занимает 1 полный байт (8 бит) памяти, а не 1 бит.
Для $N = 1000$ булевый срез занимает 1000 байт — он легко помещается в L1D-кэш процессора.
Однако на уровне ассемблера вычисления && и || порождают ветвления. Если символы в строках чередуются хаотично, процессорный предсказатель ветвлений (Branch Predictor) сталкивается с частыми сбросами конвейера.
Архитектурный горизонт: Bitset DP за $O((M \cdot N) / 64)$
В высоконагруженных сетевых парсерах булевы срезы упаковывают в 64-битные числа uint64.
Один регистр процессора RAX хранит сразу 64 состояния таблицы, а логические операции AND, OR и битовые сдвиги SHL вычисляют 64 ячейки матрицы за 1 такт CPU. Это фундаментальный паттерн, используемый в сетевых фильтрах BPF / eBPF для мгновенного матчинга заголовков пакетов.
6. Вопросы для собеседования
Итог кластера 2D DP
Мы полностью завершили блок двумерного динамического программирования:
- Теория: Понимание пространства состояний $(i, j)$, отказ от джаггед-слайсов в пользу непрерывных буферов и сжатие до Rolling Vector.
- Сетки: Unique Paths I & II — комбинаторика путей против преград и отсечение ветвлений.
- Оптимальные пути: Minimum Path Sum — иммутабельность, кэш L1 и обратное восстановление маршрута.
- Сравнение строк: Edit Distance — классическая матрица Левенштейна, диагональный
prevDiagи масштабирование поиска. - Мультиплексирование: Interleaving String — слияние упорядоченных потоков и логические инварианты.
В следующем кластере мы углубимся в тончайшие строковые алгоритмы: Longest Common Subsequence, Distinct Subsequences, а также регулярные выражения: 1. Теория. DP на строках.