Недавно встретилась по работе интересная задача, прямо на те самые презираемые на интервью алгоритмы. Очередное доказательство, что, по крайней мере, в Гугле алгоритмы нужны. А значит и интервью по ним вообще-то не оторваны от реальности.
Итак, задача: есть лог видео конференции, состоящий из событий - в такое-то время такой-то пользователь подключился или отключился. И надо посчитать всякую разную статистику. Среди прочего скучного надо для каждого пользователя найти, сколько максимально пользователей было вместе с ним в какой-то момент времени.
Похоже, на интервью спрашивают слишком простые задачки, потому что тут другой программист впендюрил наивное решение за квадрат: перебираем события в порядке возрастания времени, поддерживаем хеш-сет активных пользователей, обновляем значение метрики для всех активных пользователей текущим размером хэш-сета. А квадратичное решение тут действительно плохо, ибо бывают конференции и на тысячи пользователей.
Набросок кода
std::unordered_set<UserID> active_users;
for (auto &event: events) {
if (event.type == Event::kJoin) {
active_users.insert(event.user_id);
for (auto &user_id : active_users) {
stats[user_id].max_concurrent_users =
max(stats[user_id].max_concurrent_users,
active_users.size());
}
} else {
active_users.erase(event.user_id);
}
}Обратите внимание, тут даже оптимизация есть - максимальное количество пользователей считается только при событии типа join. Примерно в 2 раза быстрее. Но это все еще решение за квадрат потому что тут n промежутков времени и в каждом из них может быть активно O(n) пользователей. Тут и далее n - количество событий.
Как всегда, программист просто не понял, что тут вообще есть какие-то эти ваши алгоритмы. У него есть задача - подсчитать метрики. Он эту задачу и решил, переведя требования на язык программирования. То, что тут вообще-то стандартнейшая алгоритмическая задача из книжек и не видно по постановке. Именно это свойство алгоритмических задач и создает у многих ошибочное ощущение, что алгоритмы им в работе вообще не нужны, и кроме литкода и интервью нигде не встречаются.
Задача Range Maximum Query
Эта задача на самом деле - стандартная задача Range Maximum Query: если рассмотреть, сколько пользователей было между двумя соседними событиями, то у нас получается массив из чисел, и для каждого пользователя надо найти максимум на отрезке от его события подключения до его события отключения. Правда, в литературе ставят задачу на минимум (Range Minimum Query), но это вообще не принципиально - абсолютно те же самые алгоритмы можно заставить искать максимум, лишь поменяв пару знаков в сравнениях. Или в худшем случае умножить все числа на -1, найти из них минимум неизмененным алгоритмом, а потом поменять у ответа знак назад.
Вот иллюстрация:

Выше фактически реализовано наивное решение задачи RMQ за , только порядок циклов вывернут, чтобы не хранить все значения в массиве. В наивном решении внешний цикл по запросам, а внутри цикл по всем числам отрезке. А тут, наоборот - происходит проход по всем числам и обновление ответа для всех запросов, включающих это число.
У этой задачи есть множество решений разной степени сложности кода и эффективности. На хабре уже есть множество статей:
https://habr.com/ru/articles/114980/
предподсчет +
на запрос благодаря предподсчету на всех отрезках длины, равной какой-то степени двойки.
https://habr.com/ru/articles/115026/
предпосдчет +
на запрос благодаря использованию структуры данных "дерево отрезков".
https://habr.com/ru/articles/138946/
предподсчет +
на запрос через разбиение на блоки размером
.
На хабре так и не написали об алгоритме Фараха-Колтона и Бендера c пердподсчтетом
и
на запрос. Но этот алгоритм весьма сложен в реализации и состоит из кучи шагов: Сначала RMQ сводится к задаче Lowest Common Ancestor (LCA) через построение Декартового дерева, потом LCA сводится к задаче +-1 RMQ (тот же RMQ, только числа в массиве отличаются на +1 или -1), и уже эта задача решается за линейную сложность через предподсчет и разбиение на блоки размера
.
Тут я приведу другой алгоритм с пердподсчетом и
на запрос, который даже проще многих перечисленных выше в реализации. Правда, в отличии от некоторых из них, этот алгоритм offline, т.е. он может обработать все запросы только вместе группой. Этот алгоритм в интернете иногда называют "Arpa's trick".
В итоге этот алгоритм дает решение исходной задачи за , при условии уже отсортированности событий по времени. Но даже с сортировкой он все еще быстрее и проще перечисленных выше алгоритмов.
Линейное решение задачи
Итак, основная идея: давайте для каждого числа найдем следующее большее-равное его справа и сохраним на него указатель. Тогда при запросе на максимум нам надо пройтись по этим ссылкам от левого края запроса, пока мы не выйдем из отрезка. Последний элемент в цепочке будет максимумом на отрезке. Но тут сложно быстро понять, где остановиться.
Тогда давайте обрабатывать запросы слева направо по концам и поддерживать ссылки только среди чисел левее границы. Т.е. мы будем дорисовывать стрелочки ведущие в текущее число, а потом находить все максимумы на запросы, кончающиеся в текущем числе. Тогда задача становится проще: надо просто идти до конца всегда. Мы не выйдем за границу отрезка, потому что ссылки за нее еще не созданы:

Итак, у нас есть структура, где мы от каждого элемента можем провести максимум одну стрелочку к какому-то другому элементу и нам надо уметь проходиться по стрелочкам до конца. Знакомые со структурами данных читатели заметят, что эта структура очень похожа на структуру данных Disjoint Set Union (или Union-Find, или "система непересекающихся множеств"). С той лишь разницей, что в DSU числа куда-то ссылаются всегда, но некоторые ссылаются сами на себя и итерация идет пока не зациклится в таких числах. Давайте тогда проводить ссылку от числа на само себя, если правее нет ничего больше-равного. И вот мы решили задачу стандартной структурой DSU. Прелесть этой структуры данных в том, что она позволяет искать конец цепочки за, где
- это обратная функция Аккермана, которая меньше 5 для всех мыслимых объемов данных (
), поэтому ее можно считать константой. Об этом свойстве уже есть замечательная статья на хабре.
Вот и алгоритм получается такой: идем слева направо по массиву и объединяем все значения в непересекающиеся множества - элементы принадлежат одному и тому же множеству, если в отрезке, начинающемся с них, максимумом будет один и тот же элемент. Этот элемент будет корнем множества. Чтобы найти максимум на отрезке, заканчивающимся в последнем обработанном числе, надо взять корень множества, которому принадлежит начало отрезка.
Остается вопрос о том, как эти множества поддерживать при добавлении нового элемента. Можно заметить, что множества эти образуют непрерывные отрезки, а сами максимумы будут на концах отрезков и идут в массиве в порядке убывания значений. Действительно, самый большой элемент в массиве будет корнем для всех чисел левее его. Это будет первое множество. Далее можно рассмотреть оставшиеся числа правее, там тоже есть какой-то глобальный максимум и он тоже откусит от этих чисел свое множество и так далее. Когда мы добавляем новый элемент, он "вытеснит" все корни множеств, которые меньше-равны ему, и поглотит их множества. Так что, если хранить эти корни множеств в порядке увеличения индексов, то они будут и в порядке уменьшения значений. И при добавлении нового элемента надо будет убрать все меньшие с конца. Т.е. хранить их надо в стеке и убирать с конца меньшие нового элемента.
Вот и код с "алгоритмами". Несмотря на то, что он работает в тысячи раз быстрее на больших объемах, он не сильно сложнее. Он даже работает не медленнее на маленьких конференциях, так что он всегда лучше наивного решения за квадрат:
Набросок кода
// Структура непересекающихся множеств, всегда указывает на больший элемент
// правее данного.
std::vector<int> dsu(events.size());
// Количество акивных пользователей после каждого события.
std::vector<int> user_counts(events.size());
// Индексы максимальных элементов, для которых нет большего правее.
// Они же корни в системе непересекающихся множеств.
// Всегда в порядке возрастания индексов и убывания значений.
std::deque<int> maximums_stack;
// запоминаем для каждого пользователя, когда он подключился
std::unordered_map<UserID, int> join_event_idx;
int active_user_count = 0;
// Основной цикл.
for (int event_idx = 0; event_idx < events.size(); ++event_idx) {
Event &event = events[event_idx];
if (event.type == Event::kJoin) {
++active_user_count;
// Запоминаем начало отрезка на будущее.
join_event_idx[event.user_id] = event_idx;
} else {
--active_user_count;
// Берем известное начало отрезка-запроса.
int join_idx = join_event_idx[event.user_id];
// Проходимся до корня от самого левого элемента в отрезке.
int max_users_count = user_counts[DsuRoot(join_idx, dsu)];
// Сохраняем ответ.
stats[event.user_id].max_concurrent_users = max(user_stats.max_concurrent_users,
max_users_count);
}
// Добавляем новый элемент в структуру.
user_counts[event_idx] = active_user_count;
// Правее него пока вообще ничего нет - он сам себе максимум.
dsu[event_idx] = event_idx;
// Какие-то из существующих максимумов меньше нового элемента.
// Но они хранятся в стеке, по убыванию, поэтому все они лежат на верхушке.
while (!maximums_stack.empty() &&
user_counts[stack.back()] <= active_user_count) {
// Новый элемент теперь следующий больший для вытесненного максимума.
dsu[maximums_stack.back()] = event_idx;
maximums_stack.pop_back();
}
// Новый элемент - максимум, и он должен быть в стеке.
stack.push_back(event_idx);
}
// Функция для работы с системой непересекающихся множеств.
// Находит корень множества, которому принадлежит idx.
int DsuRoot(int idx, vector<int> &dsu) {
int root = idx;
while (dsu[root] != root) {
root = dsu[root];
}
// Эвристика сжатия путей.
while (idx != root) {
int nxt = dsu[idx];
dsu[idx] = root;
idx = nxt;
}
return cur;
}
Тут, опять же, лишь набросок кода для примера. На самом деле для читабельности среди кучи метрик вся хитрая логика прячется в своем классе за методами AddSample() и FindMaxAmongLastK() и комментариев сильно меньше.
Замечание по оценке времени работы
Очень продвинутые в алгоритмах читатели возразят, что тут используется DSU лишь с эвристикой сжатия путей, без ранговой эвристики. И вообще DSU работает за а не за
Хоть это и можно считать константой на практике. И действительно, если бы у нас была произвольная задача RMQ так все и было бы. И этот код в итоге работал бы за O(n + m log n), где m количество запросов. А чтобы код работал хотя бы за
надо было бы реализовывать ранговую эвристику при слиянии множеств, и еще пришлось бы дополнительно хранить значение максимума для каждого множества (ведь максимум уже не обязательно был бы корнем).
Но у нас ведь задача немного ограниченная: числа в массиве не любые, они изменяются только на +-1 от соседних. Плюс, в каждом элементе может начинаться или заканчиваться максимум один запрос. И начало этого запроса всегда выпадает на элемент, который больше предыдущего. Все эти ограничения позволяют доказать строгую оценку O(n) для времени работы этого алгоритма и без ранговой эвристики. А без нее код получается чуть проще и работает чуть быстрее. Но у меня не получилось вывести достаточно простое и короткое доказательство. Так что оно, может быть, появится в следующей статье, если кому-то интересно.
Альтернативные объясниения алгоритма
Можно рассматривать этот алгоритм как смесь алгоритма Фараха-Колтона-Бендера, упомянутого в начале статьи, и алгоритма Тарьяна для LCA. Сначала точно такое же сведение RMQ к LCA в декартовом дереве, но потом применяем алгоритм Тарьяна с DSU для поиска LCA вместо сведения задачи к +-1 RMQ. Если упрощать код и смешивать выполнение LCA вместе с построением декартового дерева, то в итоге получится вот этот алгоритм.
Еще его можно вывести из другой логики, именно так я к нему и пришел. Давайте обрабатывать запросы слева направо в концах. Тогда нам надо уметь выполнять 2 операции: добавить элемент и взять максимум из скольки-то последних. Эта задача чем-то похожа на известную задачу о максимуме в sliding window. И тут применима такая же логика: какие-то элементы никогда не будут ответом ни для какого запроса. Это те, правее котрых есть что-то больше-равное их. Давайте их называть "плохими", а все остальные - "хорошими". Действительно, любой запрос, включающий такой плохой элемент, включает и больший его правее, а значит ответом будет вот тот другой элемент (или что-то еще большее), но точно не сам плохой элемент.
Поэтому плохие элементы можно вообще выкинуть из рассмотрения, тогда оставшиеся хорошие элементы образуют строго убывающую подпоследовательность. Действительно, если где-то они идут по возрастанию, то левый элемент не может быть хорошим, ведь правее его есть что-то большее. При добавлении нового элемента какие-то хорошие с конца станут плохими и будут выкинуты. А новый элемент всегда хороший, ведь правее его вообще ничего нет, не говоря уже о больших элементах. Эти хорошие элементы - это и есть те самые корни множеств и числа в стеке в алгоритме.
При запросе надо найти самый левый хороший элемент, который еще попадает в отрезок-запрос. Ведь в качестве ответа можно взять все хорошие элементы, но самый больший из них - самый левый, они же убывают. Тут можно было бы воспользоваться бинарным поиском по индексу, но можно пойти дальше. Давайте для всех возможных начал запросов следить, где бы они находились в стеке, если бы они там были. Можно их как бы повесить на элемент в стеке, который ближайший к ним справа. При удалении элемента из стека, повешенные на него начала будут перевешаны на новый элемент. Вот и получилось, что нам надо поддерживать непересекающиеся множества и объединять их, а максимум для каждого множества - это какой-то элемент в стеке, соответствующий какому-то множеству. Это можно быстро делать с помощью структуры данных DSU.
Комментарии (6)

malkovsky
27.11.2025 13:51Это же классический Accidently quadratic! Давно уже есть идея написать по статью на обозрение всем, кто считает, что алгоритмы не нужны.
Мне кажется, что сбор статистики -- это то, что возникает всегда и везде. У меня возникла как-то задача, которую в целом можно описать как "нужно быстро считать какую-то статистику за недавний промежуток времени переменной длины в онлайн". Подсчет суммы/среднего/минимума мы сделали довольно легко с
на запрос, был даже довольно экзотичный случай подсчета среднеквадратиченого отклонения, который тоже считается и тут у меня тоже есть ссылочка на квадрат. Но вот у нас дело дошло до квантиля и тут мы поняли, что задаче резко стала сложнее -- внезапно в какой-то момент научились тоже делать за логарфим с помощью wavelet tree.

nick758
27.11.2025 13:51Но ведь человек, написавший изначальный код, тоже прошёл через алгоритмическое собеседование.
Кстати, интересно насколько быстрее работает ваш вариант. Предположим, на 10000 элементах.
sobeskiller
Ну т.е. встретилась задача, и разобраться, сделать research - не судьба? Обязательно алгоритмы нужны на собесе? А если какой-то алгоритма в памяти такого нанятого кандидата не оказалось, то всё, "у него лапки"?
lightln2
Я часто вижу такую точку зрения, что эти задачки на литкоде и собеседованиях - это такая лотерея, что человек их заучивает, и потом либо ему повезло, и он похожую задачу решал, либо не повезло.
Мне кажется, эти задачи скорее простые примеры из базового курса по Computer Science - то есть, если вы этот курс прошли, и прорешали эти задачи, то вероятность встретить задачу уровня easy/medium, к которой вообще непонятно, как подступиться, мала. Даже если в вузе такого курса не было, их на ютубе много, от MIT или МФТИ, например.
Другое дело, что многим быстрее рискнуть и бессистемно запомнить принцип решения пары сотен задач, чем проходить годовой курс и разбираться в теории, но я не знаю, насколько это проблема собеседований.
По поводу сделать research - далеко не всегда его можно делать, если вы не знаете основы computer science (А если знаете - то решение задачек на собеседовании не будет проблемой). В гугле часто возникают задачи, для которых надо придумать новый, прежде несуществующий, алгоритм, а не просто применить новый, они публикуют научные статьи каждый год.
Alexandroppolus
У "другого программиста" из статьи не возникло беспокойства по поводу O(N^2) и как следствие повода этот самый ресерч делать. Он запилил простой вариант за 10-15 минут и закрыл таск
wataru Автор
Без обширного знания алгортмов и навыков их применения вы этот research не сделаете.
Во-первых, вы скорее всего и не заметите, что тут вообще какие-то алгоритмы нужны. Но, допустим, вы профайлером нашли место где самый долгий цикл и даже подумали почему-то, а вдруг тут алгоритмами что-то можно сделать.
Вторая загвоздка: вы не найдете решение задачи, не зная ни про какие RMQ. В интернете вот такая постановка задачи не встречается. Там числа и отрезки, а у вас какие-то пользователи и события.
Ну, допустим, вам дико повезло. И вы нашли заветное описание алгоритма в интернете. Без понимания и знаний вы его заставить работать в вашей (напоминаю - измененной задаче) скорее всего не сможете.
Но, даже если вам необычайно повезет и вы все найдете в интернете, вы потратите на это дело во много раз больше времени, чем подготовленный человек, из-за преодоления упомянутых выше преград.
Если кандидат проходит собеседования и решает случайные задачи, то это значит, что или он выучил их очень много (что маловероятно и очень сложно), или он в них разбирается и способен выводить решения сам. Так что и новую неизвестную задачу сможет свести к известным алгоритмам или хотя бы понять, из какой области алгоритмы нужны и искать их по правильным ключевым словам в реальной работе.
А что касается интервью - там не дают каких-то особо заумных алгоритмов. Плюс для прохождения интервью не обязательно решать задачу именно тем методом, что имел ввиду автор задачи. Даже решать ее целиком и правильно не обязательно. Достаточно грамотно рассуждать, свести задачу к чему-то осмысленному и перечислить хоть какие-то вменяемые альтернативы, да выдать немного хорошего кода для самых инетересных частей предложенного решения. Не нужно знать все наизусть, надо знать базу и уметь ее применять.