# теория · шаг 5 из 8
Алгоритм Мо: отвечать на запросы не в том порядке, в каком спросили
Коротко
Бывают вопросы к отрезку массива, которые не складываются из ответов половинок: «сколько РАЗНЫХ
значений на [l, r)», «какое значение встречается чаще всех». Дерево отрезков тут не помогает. Если
все запросы известны заранее, их можно переставить. Алгоритм Мо держит текущий отрезок и два
указателя, которые сдвигаются на один элемент с пересчётом ответа, а запросы сортирует так, чтобы
указатели почти не бегали: по блоку длины √n, в котором лежит левый конец, а внутри блока - по
правому концу. Внутри одного блока левый указатель за запрос сдвигается не больше чем
на √n; переходы между блоками суммарно стоят O(n). Правый делает O(n) шагов
на блок с учётом переходов - всего O((n + q)·√n) сдвигов вместо O(n·q).
Если ты знаешь, как упорядочить запросы по Мо, почему это даёт (n+q)·√n сдвигов и когда метод неприменим, листай до «Теперь сам».
Как думаешь, что делать с вопросом, который не складывается
Дерево отрезков отвечало на «сумму на отрезке», потому что сумма отрезка - это сумма сумм его половинок. А сколько различных чисел на отрезке? Знание «в левой половине 5 различных, в правой 7» ничего не говорит о целом: общих значений может быть сколько угодно.
Зато ответ легко ПОДПРАВИТЬ: если держать счётчики значений на текущем отрезке, добавление или
удаление одного элемента с краю меняет число различных за O(1). Прежде чем читать дальше, ответь
себе: 20 тысяч запросов к массиву из 20 тысяч элементов, указатели идут от запроса к запросу в том
порядке, как запросы пришли. Сколько примерно шагов они сделают?
На что это похоже
Курьер с пачкой заказов по одной длинной улице. Развозить в порядке поступления - значит метаться от одного конца улицы к другому. Разумный курьер сортирует заказы: делит улицу на кварталы и внутри квартала едет в одну сторону. Заказы те же, ответы клиентам те же, а пробег в десятки раз меньше. Алгоритм Мо - это курьер, у которого два «края» отрезка, и оба надо подвезти к каждому запросу.
Механизм: блоки по √n и два указателя
текущий отрезок [L, R), счётчики значений на нём, ответ для него
добавить(i): счётчик[a[i]] += 1; если стал 1 - различных +1
убрать(i): счётчик[a[i]] −= 1; если стал 0 - различных −1
для запроса [l, r): двигать R к r и L к l по одному элементу,
вызывая добавить / убрать, - и записать ответ
ПОРЯДОК ЗАПРОСОВ: блок = ⌊l / √n⌋, затем по r
(в нечётных блоках - по убыванию r: «змейка»)
Почему это дёшево. Запросы с левым концом в одном блоке идут подряд и по возрастанию r:
правый указатель внутри блока движется только вперёд, не больше n шагов. Переход к
началу обработки следующего блока тоже стоит не больше n. Блоков O(√n), поэтому
правый указатель суммарно делает O(n·√n) шагов. Левый конец внутри одного блока
прыгает не больше чем на √n за запрос: суммарно O(q·√n). Между непустыми блоками
он может прыгнуть гораздо дальше, если промежуточные блоки пропущены. Но номера
блоков растут: продвижение через их диапазоны и отклонения в пределах блоков
суммарно стоят O(n). Итого для левого O(q·√n + n), для обоих
O((n + q)·√n) сдвигов.
Змейка - мелкое улучшение: в нечётных блоках r идёт по убыванию, и правому указателю не нужно
возвращаться к малым r при каждой смене блока. Это часто уменьшает число шагов правого
указателя; точный выигрыш зависит от запросов.
Правило
Если все запросы к отрезкам известны заранее, а ответ легко подправить при добавлении и удалении
элемента с края, запросы переставляют: сортируют по блоку ⌊l/√n⌋, а внутри блока - по r
(змейкой). Два указателя ходят от запроса к запросу с пересчётом за O(1) на шаг; всего
O((n + q)·√n) шагов вместо O(n·q) в порядке поступления. Ответы записывают по номеру запроса и
выдают в исходном порядке. Метод работает только ОФЛАЙН и только для «добавляемых и удаляемых»
ответов.
Разбор: пять запросов на девяти элементах
n = 9, блок длины 3. Запросы [l, r): (0,5) (6,9) (1,3) (4,8) (2,9)
указатели стартуют с L = R = 0
как пришли: (0,5): 5 (6,9): 6+4 (1,3): 5+6 (4,8): 3+5
(2,9): 2+1 всего 37 шагов
по Мо: блок 0 (l = 0..2), r по возрастанию: (1,3) (0,5) (2,9)
блок 1 (l = 3..5), r по убыванию: (4,8)
блок 2 (l = 6..8), r по возрастанию: (6,9)
(1,3): 1+3 (0,5): 1+2 (2,9): 2+4 (4,8): 2+1 (6,9): 2+1
всего 19 шагов
Уже на пяти запросах вдвое меньше; при тысячах запросов разница становится порядками.
Разбор: двадцать тысяч запросов о различных значениях
Программа считает шаги указателей в порядке поступления и в порядке Мо, затем отвечает на все запросы по Мо - сколько различных значений на отрезке - и сверяет 200 ответов с перебором.
import math
import random
rng = random.Random(4)
n = q = 20000
a = [rng.randrange(5000) for _ in range(n)]
zaprosy = []
for _ in range(q):
l = rng.randrange(n)
zaprosy.append((l, rng.randrange(l, n) + 1)) # отрезок [l, r)
def peremeshcheniya(poryadok): # сколько шагов сделают указатели
L = R = 0
s = 0
for l, r in poryadok:
s += abs(l - L) + abs(r - R)
L, R = l, r
return s
blok = int(math.sqrt(n))
def klyuch_mo(z): # блок левого конца, затем правый
l, r = z
return (l // blok, r if (l // blok) % 2 == 0 else -r)
po_mo = sorted(zaprosy, key=klyuch_mo)
print("шагов указателей как пришли:", peremeshcheniya(zaprosy))
print("шагов указателей по Мо: ", peremeshcheniya(po_mo))
skolko = [0] * 5000 # различных на отрезке - по Мо
raznyh, L, R, otvet = 0, 0, 0, {}
for l, r in po_mo:
while R < r:
skolko[a[R]] += 1
raznyh += skolko[a[R]] == 1
R += 1
while L > l:
L -= 1
skolko[a[L]] += 1
raznyh += skolko[a[L]] == 1
while R > r:
R -= 1
skolko[a[R]] -= 1
raznyh -= skolko[a[R]] == 0
while L < l:
skolko[a[L]] -= 1
raznyh -= skolko[a[L]] == 0
L += 1
otvet[(l, r)] = raznyh
for l, r in zaprosy[:200]: # выборочная сверка с перебором
assert otvet[(l, r)] == len(set(a[l:r]))
print("сверено с перебором: 200 запросов")
шагов указателей как пришли: 230400537
шагов указателей по Мо: 2359125
сверено с перебором: 200 запросов
Двести тридцать миллионов шагов против двух с третью миллионов - почти в сто раз меньше. Оценка
(n + q)·√n ≈ 40000 · 141 ≈ 5.6 миллиона задаёт масштаб асимптотической оценки,
а не точный верхний предел с константой 1. В этом наборе змейка и случайные концы дают меньше шагов. Порядок поступления дал около 0.58·n шагов на запрос: соседние случайные отрезки отстоят друг
от друга в среднем на треть массива по левому концу и чуть меньше - по правому.
Что ломается без этого
Первая ошибка - применить Мо к запросам, которые приходят по одному и требуют ответа сразу (онлайн):
переставлять нечего, и метод не работает. Вторая - взять ответ, который нельзя подправить при
УДАЛЕНИИ: максимум на отрезке легко пересчитать при добавлении, но при удалении максимального
элемента следующий максимум неизвестен - нужна куча или другой вариант метода. Третья - выдать
ответы в порядке Мо: запросы переставлены, и ответ надо сохранить по номеру исходного запроса.
Размер блока тоже влияет на цену: для длины B указатели делают
O(qB + n²/B + n) шагов. B около √n - стандартный выбор, особенно когда
q порядка n. Блок 1 может заставить правый указатель повторять длинные проходы,
блок n - левый. Если q сильно отличается от n, полезно подобрать B по этой оценке.
Зачем это в работе
Офлайн-обработка - нормальный режим аналитики: отчёт «сколько уникальных пользователей было в каждом
из этих десяти тысяч временных окон» считают пачкой, и порядок окон выбирает программа, а не
заказчик. Та же идея «переставь запросы, чтобы соседние были похожи» стоит за сортировкой запросов к
диску по номеру блока, за группировкой операций в базе по ключу и за упорядочиванием тайлов при
отрисовке карты. Сама сортировка по блоку √n - частный случай приёма «разбей на корень из n
кусков», который ещё не раз выручит там, где логарифмическая структура не строится.
Теперь сам
n = 100, блок 10. Запросы [l, r): (35,90), (12,40), (31,60), (5,70), (18,25), (39,95).
Расставь их в порядке Мо со змейкой.
Ответ. Блоки по l: 5 → блок 0; 12, 18 → блок 1; 31, 35, 39 → блок 3. Блок 0
(чётный, r по возрастанию): (5,70). Блок 1 (нечётный, r по убыванию): (12,40), (18,25).
Блок 3 (нечётный, r по убыванию): (39,95), (35,90), (31,60). Итог: (5,70) (12,40) (18,25)
(39,95) (35,90) (31,60).
Главное
Алгоритм Мо отвечает на заранее известные запросы к отрезкам, когда ответ не складывается из
половинок, но легко подправляется при добавлении и удалении элемента с края. Запросы сортируют по
блоку √n левого конца и по правому концу внутри блока; два указателя делают O((n + q)·√n) шагов
вместо O(n·q) - в программе 2.4 миллиона против 230 миллионов на 20 тысячах запросов. Работает
только офлайн, и ответы надо вернуть в исходном порядке.
Комментарии
Пока нет комментариев. Будь первым!
Оставить комментарий