← Параллель BВсе параллели⬇ Скачать конспект

Занятие 2. Параллель B — Строки 1

Префикс-функция и алгоритм Кнута–Морриса–Пратта, Z-функция, бор (trie) и бинарный бор. Во второй части — разбор контеста по графам.
Лекция + разбор контеста ≈ 4 ч 8 мин Язык кода: C++

1. Бордеры и префикс-функция 0:00:00

Бордер строки $S$ — это её префикс, равный её суффиксу (при наложении символы должны совпадать). Например, у строки аааааа много бордеров.

Вся строка тоже является своим префиксом-суффиксом, поэтому вводят понятие собственного бордера — бордера, не совпадающего со всей строкой.

Префикс-функция $\pi[i]$ строки $S$ = длина наибольшего собственного бордера префикса $S[0..i]$.

2. От $O(n^3)$ к $O(n^2)$ 0:03:00

Наивно. Перебираем все префиксы, для каждого перебираем все длины и проверяем равенство префикса и суффикса — это $O(n^3)$.

Улучшение. Два наблюдения:

  1. Длины бордера перебираем по убыванию и останавливаемся на первом подходящем.
  2. При добавлении одного символа значение префикс-функции может вырасти не более чем на 1 (иначе, «отрезав» последний символ у бордера новой строки, мы получили бы бордер старой строки длиннее максимального — противоречие).
$\pi[i] \le \pi[i-1] + 1$.

Поэтому для нового префикса достаточно перебирать длины от $\pi[i-1]+1$ вниз до нуля. Получаем $O(n^2)$: суммарно внутренний цикл делает $O(n)$ подъёмов (по +1 на итерацию) и $O(n)$ спусков (каждый спуск «окупается» предшествующим подъёмом).

3. Алгоритм КМП за $O(n)$ 0:30:00

При переборе кандидатов-бордеров мы заново проверяем, что «почти вся» строка совпадает с префиксом. Но для кандидата, у которого всё, кроме последнего символа, заведомо совпадает, проверять нужно только один символ.

Ключевой факт. Все длины-кандидаты (префиксы, равные суффиксу, для которых всё, кроме последнего символа, уже проверено) — это в точности цепочка бордеров: $\pi[i-1],\ \pi[\pi[i-1]-1],\ \pi[\pi[\pi[i-1]-1]-1], \dots$ до нуля. Поэтому, когда кандидат не подошёл (последние символы не равны), переходим не к следующей длине, а к бордеру этого бордера.

Считаем π[7] для S = abacabab: перебор по цепочке бордеровКандидат j: первые j символов уже совпали с концом. Осталось сравнить S[j] и S[i].01234567ij = π[6] = 3бордер «aba»abacababS[3] = c ≠ S[7] = b ✗j = π[2] = 1бордер бордера «a»abacababS[1] = b = S[7] = b ✓Совпало при j = 1, значит π[7] = j + 1 = 2.Кандидат j = 2 даже не рассматривали: кандидаты — только цепочка бордеров π[i−1], π[π[i−1]−1], … до 0.
Если очередной кандидат не продолжается символом $S[i]$, переходим к бордеру этого бордера: $j \leftarrow \pi[j-1]$.

Алгоритм: $j$ — длина текущего кандидата (изначально $\pi[i-1]$). Пока $j > 0$ и $S[j] \ne S[i]$ — делаем $j = \pi[j-1]$. Затем, если $S[j] = S[i]$, увеличиваем $j$. В конце $\pi[i] = j$.

C++
vector<int> prefix_function(const string& s) {
    int n = s.size();
    vector<int> pi(n, 0);
    for (int i = 1; i < n; ++i) {
        int j = pi[i - 1];
        while (j > 0 && s[j] != s[i])
            j = pi[j - 1];          // переходим к бордеру бордера
        if (s[j] == s[i]) ++j;
        pi[i] = j;
    }
    return pi;
}
Почему $O(n)$
Значение $j$ поднимается не более чем на 1 за шаг (суммарно $O(n)$ подъёмов), а спуски «окупаются» подъёмами — всего $O(n)$ спусков. Каждая итерация внутреннего цикла — $O(1)$, итого $O(n)$.

4. Поиск подстроки (КМП) 0:57:00

Задача. Найти все вхождения шаблона $S$ в текст $T$.

Идея. Склеиваем строку $S\ \#\ T$, где $\#$ — разделитель, не встречающийся ни в $S$, ни в $T$. Считаем префикс-функцию этой строки. В позиции, где $\pi$ равно $|S|$, заканчивается вхождение шаблона.

Поиск S = abc в T = abxabcab через π строки S#TТам, где π = |S| = 3, в тексте заканчивается вхождение шаблона.S#Tπa00b01c02#03a14b25x06a17b28c39a110b211↑ π = 3Вхождение заканчивается в позиции 9 склейки, то есть начинается в T с индекса 3.Разделитель # не даёт π стать больше |S| — совпадение не «перетечёт» через границу.
Склейка $S\#T$: значение $\pi = |S|$ отмечает конец вхождения шаблона в тексте.

Зачем разделитель? Без него префикс-функция могла бы «перетечь» через границу шаблона и текста и выдать ложное вхождение (например, шаблон аа в тексте ааа дал бы бордер, пересекающий границу). Разделитель гарантирует, что $\pi$ никогда не превысит $|S|$.

Сложность — $O(|S| + |T|)$.

Память
Префикс-функция позволяет добавлять символы по одному и не хранить всю префикс-функцию: для вычисления $\pi[i]$ нужны только $\pi[i-1]$ и значения $\pi$ внутри шаблона. Поэтому можно читать большой текст по одному символу при малом расходе памяти.

5. Z-функция 1:08:00

Z-функция $z[i]$ строки — длина наибольшего префикса строки, совпадающего со строкой, начинающейся с позиции $i$ (то есть наибольший общий префикс всей строки и суффикса $S[i..]$).

Линейный алгоритм

Поддерживаем самое правое из «изведанных» совпадений — отрезок $[l, r)$, где строка $S[l..r)$ совпадает с префиксом $S[0..r-l)$.

C++
vector<int> z_function(const string& s) {
    int n = s.size();
    vector<int> z(n);
    int l = 0, r = 0;
    for (int i = 1; i < n; ++i) {
        if (i < r)
            z[i] = min(r - i, z[i - l]);   // заведомо совпадающий кусок
        while (i + z[i] < n && s[z[i]] == s[i + z[i]])
            ++z[i];
        if (i + z[i] > r) {                // расширили правую границу
            l = i;
            r = i + z[i];
        }
    }
    return z;
}

Идея. Если позиция $i$ лежит внутри $[l,r)$, то строка $S[i..r)$ совпадает со строкой $S[i-l..r-l)$, и мы уже знаем её общий префикс с началом строки — это $z[i-l]$. Поэтому инициализируем $z[i] = \min(z[i-l],\ r-i)$ и расширяем дальше только в случае необходимости. Каждая успешная проверка во while сдвигает правую границу $r$, поэтому суммарно $O(n)$.

Для поиска подстроки склеиваем $S\ \#\ T$ и ищем позиции, где $z[i] = |S|$ (разделитель здесь даже необязателен, но для удобства можно оставить).

6. Сравнение префикс- и Z-функций 1:32:00

Префикс-функцияZ-функция
Определениедлина наибольшего бордера префиксадлина общего префикса с суффиксом
Добавление символапозволяет (смотрим только «назад»)не позволяет (добавление в конец меняет все значения)
Простотанужно думать про переходы по бордерамвзял наивный алгоритм и добавил $[l,r)$

Префикс-функция смотрит только на предыдущий префикс и добавляемый символ, поэтому её можно считать онлайн, добавляя символы по одному. Z-функция так не умеет.

Автомат КМП
Чтобы удалять символы по одному (откатываться), строят автомат: для каждой позиции шаблона и каждого символа заранее вычисляют, куда перейдёт состояние. Это упрощённая версия автомата Ахо–Корасик.

7. Бор (trie) 1:44:00

Задача. Хранить множество слов с операциями «добавить», «удалить», «есть ли слово».

Идея. Слова, имеющие общий префикс, «схлопываются» — общий префикс хранится один раз. Получается корневое дерево, рёбра которого помечены символами.

Бор для слов: бор, бот, борд, домОбщий префикс «бо» хранится один раз. Зелёные вершины — конец какого-то слова.бдоортмд431312«бор»1«бот»1«дом»1«борд»кореньБуква — на ребре.Число в вершине —cnt: сколько словначинается с этогопрефикса.Поиск «бот»:б → о → т, вершиназелёная — слово есть.
Слова с общим началом делят путь от корня; cnt в вершине — сколько слов начинаются с этого префикса.

Терминальная вершина — вершина, в которой заканчивается какое-то слово. При поиске идём по символам; если в конце терминальна — слово есть.

C++
struct Node {
    bool term = false;
    unordered_map<char, Node*> next;   // переходы (экономия памяти)
    int cnt = 0;                        // сколько слов в поддереве
};

void add(Node* v, const string& s) {
    for (char c : s) {
        if (!v->next.count(c)) v->next[c] = new Node();
        v = v->next[c];
        v->cnt++;                        // слово проходит через v
    }
    v->term = true;
}

bool find(Node* v, const string& s) {
    for (char c : s) {
        if (!v->next.count(c)) return false;
        v = v->next[c];
    }
    return v->term;
}

Сложность всех операций — $O(\text{длина строки})$; суммарно по всем словам — $O(\text{сумма длин})$.

Практическое применение
Поле cnt (число слов в поддереве) позволяет отвечать «сколько слов начинаются с префикса $P$»: доходим до вершины, соответствующей $P$, и смотрим её cnt. При добавлении слова увеличиваем cnt на единицу на всём пути.

Хранить детей: массивом размера алфавита (быстро, но ест память), map (медленно, $O(\log\sigma)$), unordered_map (средний случай $O(1)$, хеш-таблица) или списком пар (экономично, но перебор).

8. Бинарный бор 2:33:00

Задача. Дан массив чисел $y_1, \dots, y_n$; для каждого запроса $x$ найти $y_j$, дающее максимальный $x \oplus y_j$.

Идея. Запишем числа в двоичной системе (с ведущими нулями) и построим бор, где алфавит — $\{0, 1\}$.

Бинарный бор: max(x XOR y) для x = 4 = 100₂Числа 3 = 011, 2 = 010, 7 = 111. Идём от старшего бита и стараемся взять бит, противоположный биту x.0110111301120107111бит 2: у x 1, хотим 0есть → идём в 0бит 1: у x 0, хотим 1есть → идём в 1бит 0: у x 0, хотим 1есть → идём в 1Нашли y = 011₂ = 3: 4 XOR 3 = 100 XOR 011 = 111₂ = 7 — максимум.
Жадный спуск по битам от старшего: где можно, идём в бит, противоположный биту $x$, — так XOR получается максимальным.

Для запроса $x$ идём по битам от старшего к младшему и на каждом шаге жадно пытаемся пойти в ветку, противоположную текущему биту $x$ (чтобы в XOR получилась единица); если такой нет — идём в другую.

C++
// максимальный x ^ y по всем y в боре
int max_xor(Node* v, int x) {
    int res = 0;
    for (int b = LOG - 1; b >= 0; --b) {
        int bit = (x >> b) & 1;
        int want = bit ^ 1;                 // хотим противоположный бит
        if (v->next[want]) {                // можем получить 1 в этом разряде
            res |= (1 << b);
            v = v->next[want];
        } else {
            v = v->next[bit];
        }
    }
    return res;
}

Аналогично находится $k$-е по возрастанию число: идём по битам, на каждом шаге смотрим cnt левой ветки — если $k \le cnt$ идём влево, иначе вычитаем $cnt$ и идём вправо. Сложность — $O(\log A)$ на запрос.

9. Разбор контеста (графы) 2:44:00

Вторая часть занятия — разбор контеста, который был по графам (не по строкам).

ЗадачаСутьИдея
B. Топсортпроверить корректность топологической сортировкидля каждого ребра $u\to v$ проверить $\text{pos}[u] < \text{pos}[v]$
C. КССкомпоненты сильной связностистандартный алгоритм (Косарайю / Тарьян)
D. Кратчайший путь в DAGкратчайший путь $S \to T$ДП по топсорту: $dp[v] = \min_{v\to u}(dp[u] + w)$
E. Сумма длин всех путейсумма по всем парам вершин деревавклад ребра = $w \cdot sz[v] \cdot (n - sz[v]) \cdot 2$
F. Сумма путей из каждой вершиныдля каждой вершины сумма расстояний до всехrerooting: пересчёт ответа родителя в ответ сына
G. Делимость переменныхмаксимум различных значенийКСС → ответ $2\cdot(\#\text{КСС}) - (\#\text{КСС размера }1)$
H. Деталивремя изготовления детали №1DFS из 1 + топсорт + сумма времён
I. Монеткаминимизировать максимум чисел на пути из $k$ вершинбинпоиск по ответу + цикл / длиннейший путь в DAG
J. Максимальный топсортлексикографически максимальный топсортобратная топсортировка со множеством/кучей
K. Пропускможно ли потерять пропускКСС по рёбрам 2-го типа + DFS по всем
L. Планарный графдостижимость восточного берегаКСС + ДП min/max на конденсации

Задача E: вклад каждого ребра 2:49:00

Вместо суммирования по путям, суммируем по рёбрам: ребро с весом $w$, разбивающее дерево на части размеров $c_1$ и $c_2$, участвует в $c_1 \cdot c_2$ путях. Подвесим дерево; для ребра «родитель–сын $v$»:

$c_1 = sz[v],\quad c_2 = n - sz[v]$  ⇒  вклад $= w \cdot sz[v] \cdot (n - sz[v]) \cdot 2$.

Умножение на $2$ — потому что пути $u\to v$ и $v\to u$ считаются различными. $sz[v]$ считается DFS-ом за $O(n)$.

Задача F: rerooting 2:56:00

Сначала считаем $dp[v]$ — сумму длин путей от $v$ до всех вершин в её поддереве. Затем, зная ответ для родителя, пересчитываем ответ для ребёнка:

$ans[v] = ans[parent] + w \cdot (n - 2\cdot sz[v])$.

Идея: когда мы «переносим корень» через ребро $(parent, v)$, пути, ведущие не в поддерево $v$, удлиняются на $w$ (их $n - sz[v]$ штук), а пути внутрь поддерева $v$ укорачиваются на $w$ (их $sz[v]$ штук). Второй DFS проходит дерево и пересчитывает все ответы.

Задача G: делимость и КСС 3:10:00

Факты «$x_i$ делится на $x_j$» дают ориентированный граф. Внутри цикла все значения обязаны совпадать по модулю (с точностью до знака) — значит, внутри каждой компоненты сильной связности значения равны с точностью до знака. В каждой КСС можно назначить два значения ($x$ и $-x$), кроме КСС размера 1 (там только одно).

Ответ $= 2 \cdot (\#\text{КСС}) - (\#\text{КСС размера } 1)$.

Задача I: монетка и бинпоиск 3:20:00

Нужно минимизировать максимум значений на пути из $k$ вершин. Бинпоиск по ответу $x$: оставляем только вершины со значением $\le x$. В оставшемся графе ищем цикл — если есть, можно набрать сколько угодно вершин (ответ «да»); иначе это DAG, и достаточно найти длиннейший путь и сравнить его длину с $k$.

Задача J: лексикографически максимальный топсорт 3:26:00

Чтобы лексикографически максимизировать порядок (первый элемент как можно больше, затем второй и т. д.), строим топсорт с начала: на каждом шаге из вершин без входящих рёбер (истоков) берём вершину с максимальным номером, ставим её следующей и удаляем из графа (уменьшая счётчики входящих степеней её соседей). Истоки поддерживаются в приоритетной очереди. Сложность $O((n+m)\log n)$. Если же в условии нужно максимизировать последний элемент, затем предпоследний и т. д., то порядок строят с конца: из стоков (вершин без исходящих рёбер) берут максимальную и ставят в конец; не путайте эти две постановки.

Задача K: пропуск и КСС 3:42:00

Рёбра двух видов: с пропуском и без. Строим КСС по рёбрам «без пропуска». Пропуск можно потерять, если существует вершина, достижимая из вершины $1$ (по всем рёбрам), чья компонента имеет исходящее ребро (по рёбрам «без пропуска»). Тогда оставляем пропуск в этой компоненте, выходим — и вернуться не сможем.