16.3 Пути по функциям-переходам¶
Идея раздела¶
В этом разделе мы рассматриваем специальный вид ориентированных графов, где из каждой вершины выходит ровно одно ребро. Такие графы очень важны в олимпиадном программировании: они встречаются в задачах про телепорты, переходы по указателям, повторяющиеся процессы, переходы по перестановкам, симуляции состояний и циклические системы.
Такой граф удобно воспринимать не просто как граф, а как функцию перехода:
- для каждой вершины
xзадана вершинаsucc[x], - из
xмы обязаны перейти именно вsucc[x].
Поэтому такие графы называют:
- successor graph — граф преемников,
- functional graph — функциональный граф.
Главный вопрос раздела:
Если мы стартуем из вершины x и сделаем k переходов вперёд, в какой вершине окажемся?
Это и есть запрос вида succ(x, k).
Что такое successor graph¶
В обычном ориентированном графе из вершины может выходить много рёбер. В successor graph — ровно одно.
Например, пусть задано:
succ[1] = 3succ[2] = 5succ[3] = 7succ[4] = 6succ[5] = 2succ[6] = 2succ[7] = 1succ[8] = 6succ[9] = 3
Тогда из каждой вершины мы всегда знаем единственный следующий шаг.
Структура таких графов¶
У functional graph есть очень важное свойство:
Каждая компонента связности состоит из одного цикла и нескольких цепочек, которые в этот цикл ведут.
Почему так происходит:
- Из каждой вершины есть ровно один выход.
- Если идти достаточно долго, мы не можем бесконечно попадать только в новые вершины.
- Значит, рано или поздно попадём в уже посещённую вершину.
- С этого момента начинается цикл.
Иными словами, любая траектория в таком графе в конце концов «впадает» в цикл.
Наглядная схема¶
flowchart LR
A1[1] --> A3[3]
A3 --> A7[7]
A7 --> A1
A2[2] --> A5[5]
A5 --> A2
A4[4] --> A6[6]
A6 --> A2
A8[8] --> A6
A9[9] --> A3
Здесь видно:
- цикл
1 -> 3 -> 7 -> 1, - цикл
2 -> 5 -> 2, - вершины
4,6,8ведут в цикл2 <-> 5, - вершина
9ведёт в цикл1 -> 3 -> 7.
Запрос succ(x, k)¶
Обозначим через succ(x, k) вершину, в которой мы окажемся, если:
- стартуем в вершине
x, - сделаем ровно
kпереходов по рёбрам.
Примеры:
succ(x, 0) = x, потому что без переходов мы остаёмся на месте;succ(x, 1) = succ[x];succ(x, 2) = succ[succ[x]].
Если считать «в лоб», то для каждого запроса нужно просто сделать k шагов. Это работает за O(k).
Но если:
kочень велико,- запросов много,
kможет быть порядка10^9,10^18и больше,
то прямой проход слишком медленный.
Нужна идея ускорения.
Ускорение через степени двойки¶
Это один из самых полезных приёмов в графах и деревьях.
Вместо того чтобы хранить только непосредственного преемника, будем хранить:
- куда попадём через
2^0 = 1шаг, - куда попадём через
2^1 = 2шага, - куда попадём через
2^2 = 4шага, - куда попадём через
2^3 = 8шагов, - и так далее.
Обозначим:
up[j][x]— вершина, в которую мы попадём изxчерез2^jшагов.
Тогда:
up[0][x] = succ[x]up[1][x] = succ(x, 2)up[2][x] = succ(x, 4)- ...
Ключевая рекуррентная формула¶
Если мы умеем прыгать на 2^(j-1) шагов, то прыжок на 2^j шагов можно получить так:
up[j][x] = up[j-1][ up[j-1][x] ]
Смысл очень простой:
- сначала делаем
2^(j-1)шагов, - приходим в какую-то вершину,
- потом ещё раз делаем
2^(j-1)шагов.
Итого получаем 2^j шагов.
Пример построения¶
Пусть:
succ[1] = 3succ[3] = 7succ[7] = 1
Тогда:
up[0][1] = 3up[0][3] = 7up[0][7] = 1
Теперь считаем переходы на 2 шага:
up[1][1] = up[0][ up[0][1] ] = up[0][3] = 7up[1][3] = up[0][7] = 1up[1][7] = up[0][1] = 3
А на 4 шага:
up[2][1] = up[1][ up[1][1] ] = up[1][7] = 3
То есть из вершины 1 за 4 шага мы попадём в 3.
Проверим вручную:
1 -> 3 -> 7 -> 1 -> 3
Всё верно.
Как отвечать на запросы¶
Теперь нужно научиться быстро вычислять succ(x, k).
Для этого раскладываем число k по степеням двойки.
Например:
13 = 8 + 4 + 1- в двоичной записи это
1101
Значит, чтобы сделать 13 шагов, можно:
- сделать 8 шагов,
- потом 4 шага,
- потом 1 шаг.
Если бит j в числе k равен 1, то нужно применить переход up[j][x].
Пример¶
Если хотим найти succ(x, 13), то делаем:
- прыжок на
2^3 = 8шагов, - прыжок на
2^2 = 4шага, - прыжок на
2^0 = 1шаг.
Именно это и даёт время O(log k) вместо O(k).
Почему это работает¶
Потому что любое натуральное число можно единственным образом представить как сумму степеней двойки.
Например:
11 = 8 + 2 + 119 = 16 + 2 + 137 = 32 + 4 + 1
Если для каждой степени двойки мы заранее знаем результат перехода, то длинный путь можно собрать из нескольких больших прыжков.
Их количество равно числу битов в k, то есть порядка log k.
Оценка сложности¶
Пусть:
n— число вершин,U— максимальное число шагов, которое может встретиться в запросах.
Тогда:
Предобработка¶
Мы строим таблицу up[j][x] для всех:
- вершин
x, - уровней
j, где2^j <= U.
Это занимает:
O(n log U)по времени,O(n log U)по памяти.
Один запрос¶
Чтобы вычислить succ(x, k), мы просматриваем биты числа k.
Это занимает:
O(log k)времени.
Если запросов много, это даёт огромный выигрыш.
Код на C++¶
Ниже — базовая реализация для запросов вида: из вершины x сделать k переходов.
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, q;
cin >> n >> q;
const int LOG = 60;
vector<vector<int>> up(LOG, vector<int>(n + 1));
for (int i = 1; i <= n; i++) {
cin >> up[0][i];
}
for (int j = 1; j < LOG; j++) {
for (int i = 1; i <= n; i++) {
up[j][i] = up[j - 1][up[j - 1][i]];
}
}
while (q--) {
int x;
long long k;
cin >> x >> k;
for (int j = 0; j < LOG; j++) {
if (k & (1LL << j)) {
x = up[j][x];
}
}
cout << x << '\n';
}
return 0;
}
Что делает этот код¶
1. Считывает граф¶
Мы предполагаем, что для каждой вершины i задан succ[i].
То есть вход хранит ровно один переход из каждой вершины.
2. Строит таблицу прыжков¶
up[j][i] — это вершина, куда попадём из i через 2^j шагов.
Сначала знаем переходы на 1 шаг. Потом строим переходы на 2, 4, 8, 16 и так далее.
3. Отвечает на запросы¶
Для каждого запроса разбираем число k по битам.
Если очередной бит равен 1, делаем соответствующий прыжок.
Небольшой пример¶
Пусть дан граф:
1 -> 22 -> 33 -> 44 -> 55 -> 3
То есть есть цепочка, которая входит в цикл 3 -> 4 -> 5 -> 3.
flowchart LR
A1[1] --> A2[2]
A2 --> A3[3]
A3 --> A4[4]
A4 --> A5[5]
A5 --> A3
Тогда:
succ(1, 1) = 2succ(1, 2) = 3succ(1, 3) = 4succ(1, 4) = 5succ(1, 5) = 3succ(1, 6) = 4succ(1, 7) = 5
Видно, что после входа в цикл движение становится периодическим.
Очень важное наблюдение про циклы¶
В successor graph путь всегда устроен так:
- сначала мы идём по цепочке,
- потом попадаем в цикл,
- дальше просто ходим по циклу.
Это означает, что в задачах часто полезно знать:
- расстояние до цикла,
- номер вершины в цикле,
- длину цикла.
Тогда можно отвечать не только на запросы про succ(x, k), но и на более сложные:
- где окажемся через очень много шагов,
- когда впервые попадём в цикл,
- сколько разных вершин посетим,
- встретятся ли две траектории,
- через сколько шагов два указателя окажутся в одной вершине.
В самой основе всех таких задач лежит именно модель functional graph.
Практический пример задачи¶
Есть n телепортов. Для каждого города i задан город succ[i], куда телепорт переносит мгновенно. Нужно ответить на q запросов:
- стартуем из города
x, - используем телепорт ровно
kраз, - в каком городе окажемся?
Это классическая задача на successor paths.
Почему симуляция плоха¶
Если в каждом запросе честно делать k переходов, то при больших k решение будет слишком медленным.
Почему бинарное поднятие хорошо¶
Потому что вместо миллиарда шагов можно сделать около 30–60 прыжков по степеням двойки.
Ещё один пример кода: отдельная функция запроса¶
Иногда удобнее вынести вычисление в отдельную функцию.
#include <bits/stdc++.h>
using namespace std;
const int LOG = 60;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<vector<int>> up(LOG, vector<int>(n + 1));
for (int i = 1; i <= n; i++) {
cin >> up[0][i];
}
for (int j = 1; j < LOG; j++) {
for (int i = 1; i <= n; i++) {
up[j][i] = up[j - 1][up[j - 1][i]];
}
}
auto jump = [&](int x, long long k) {
for (int j = 0; j < LOG; j++) {
if (k & (1LL << j)) {
x = up[j][x];
}
}
return x;
};
int q;
cin >> q;
while (q--) {
int x;
long long k;
cin >> x >> k;
cout << jump(x, k) << '\n';
}
return 0;
}
Типичные ошибки¶
1. Слишком маленький LOG¶
Если k может быть до 10^18, то LOG = 30 уже не хватит.
Безопасный вариант:
LOG = 60дляlong long.
2. Путаница с индексацией¶
Нужно заранее решить:
- вершины нумеруются с 0 или с 1,
- и везде придерживаться этого соглашения.
3. Неправильное построение таблицы¶
Формула должна быть именно такой:
up[j][i] = up[j - 1][ up[j - 1][i] ]
А не что-то похожее, но с перепутанными индексами.
4. Использование int для очень большого k¶
Если k большое, хранить его нужно в long long.
Где это применяется¶
Техника successor paths и бинарного поднятия встречается в задачах про:
- телепорты,
- перестановки и многократное применение перестановки,
- переходы по указателям,
- моделирование автоматов,
- поиск
k-го предка в дереве, - повторение операций много раз,
- циклы в функциональных графах.
На самом деле идея очень универсальна:
если есть операция «сделать один переход», то часто можно ускорить её многократное применение через прыжки по степеням двойки.
Связь с деревьями¶
Эта же идея позже появляется в задачах на деревья как binary lifting для поиска предков:
up[0][v]— родитель вершины,up[1][v]— предок через 2 ребра,up[2][v]— предок через 4 ребра,- и так далее.
То есть successor graph — очень удобное место, где эта техника объясняется в наиболее простом виде.
Итого¶
- В successor graph из каждой вершины выходит ровно одно ребро.
- Каждая компонента такого графа состоит из цикла и деревьев-путей, ведущих в него.
- Запрос
succ(x, k)означает: куда попадём изxчерезkпереходов. - Наивное решение работает за
O(k). - Если заранее посчитать прыжки на степени двойки, можно отвечать за
O(log k). - Для этого используется таблица
up[j][x]. - Основная формула:
up[j][x] = up[j - 1][ up[j - 1][x] ]. - Это одна из самых полезных техник в олимпиадном программировании.
Что полезно потренировать после этого раздела¶
- Реализовать запросы
succ(x, k). - Научиться находить длину цикла, в который попадает вершина.
- Научиться находить число шагов до входа в цикл.
- Решить задачи на телепорты и функциональные графы.
- Сравнить successor paths и binary lifting в деревьях.