1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10

# теория · шаг 5 из 10

Слияние меньшего в большее: ответы по всем поддеревьям за n · log n

Коротко

Вопросы «сколько разных цветов в поддереве каждой вершины», «какое значение чаще всего встречается в поддереве» решаются снизу вверх: набор вершины - это объединение наборов детей плюс сама вершина. Если каждый раз вливать наборы детей в новый набор, на длинной цепочке один и тот же элемент копируется на каждом уровне - O(n²). Приём слияния меньшего в большее: самый большой набор ребёнка забрать себе целиком, а в него вливать остальные, меньшие. Элемент переносится только из меньшего набора в больший, после переноса его набор хотя бы вдвое больше прежнего - значит, каждый элемент переносится не больше log₂ n раз, и всё стоит O(n · log n) операций с множеством.

Если ты знаешь, почему вливание меньшего набора в больший даёт каждому элементу не больше log n переносов, листай до «Теперь сам».

Как думаешь, сколько стоит собрать наборы всех поддеревьев

Дерево папок на диске, в каждом файле - тег. Для каждой папки нужно число разных тегов во всём её содержимом. Решение очевидное: обойти дерево снизу вверх, набор тегов папки - объединение наборов вложенных папок плюс свои файлы.

Прежде чем читать дальше, ответь себе: сколько раз будет скопирован тег файла, лежащего на глубине тысяча, если каждая папка собирает НОВОЕ множество из множеств детей? И что изменится, если папка не собирает новое, а берёт множество одного из детей и дописывает в него остальное?

На что это похоже

Объединение двух компаний. Когда маленький стартап покупает гигант, все сотрудники гиганта не переезжают в офис стартапа - переезжают люди стартапа к гиганту. Правило «меньшие переезжают к большим» гарантирует, что каждый человек переезжает редко: после каждого переезда его компания хотя бы вдвое больше прежней, а удваиваться до размера всей отрасли можно лишь несколько раз.

То же правило уже встречалось в непересекающихся множествах: объединение по рангу подвешивает меньшее дерево к большему - и по той же причине высота остаётся логарифмической.

Механизм: забрать большой набор, влить маленькие

обходим вершины так, чтобы дети шли раньше отца
для вершины v:
    набор = {свой элемент}
    для каждого ребёнка u:
        m = набор(u)
        если |m| > |набор|:  поменять набор и m   ← ключевая строка
        влить m в набор                 (|m| операций)
    ответ(v) = |набор|

Счёт переносов. Посмотрим на один элемент x. Его переносят, только когда он лежит в МЕНЬШЕМ из двух сливаемых наборов. После слияния набор, где лежит x, не меньше суммы двух, то есть хотя бы вдвое больше прежнего. Набор не может быть больше n, удваиваться от 1 до n можно log₂ n раз. Элементов n, переносов всего ≤ n · log₂ n. Без «ключевой строки» гарантии нет: на цепочке набор каждого уровня копируется на уровень выше целиком.

Важная деталь: набор, который «забрали», нельзя потом использовать как набор ребёнка - его уже изменили. Ответ для ребёнка надо записать ДО того, как его набор отдан отцу.

Правило

Когда ответ вершины собирается из наборов её детей, самый большой набор ребёнка забирают целиком, а меньшие вливают в него поэлементно. Элемент переносится только из меньшего набора в больший, и его набор после этого хотя бы удваивается - не больше log₂ n переносов на элемент, O(n · log n) операций с множеством на всё дерево. Ответ ребёнка фиксируют до того, как его набор забрал отец.

Разбор: цепочка из пяти вершин

цепочка 1 – 2 – 3 – 4 – 5, корень 1, цвета: 1:к 2:с 3:к 4:з 5:с

СОБИРАЕМ НОВЫЙ НАБОР НА КАЖДОМ УРОВНЕ
5: {с}                       переносов 0
4: {з} ∪ {с}                 1      (влили набор 5)
3: {к} ∪ {з с}               2
2: {с} ∪ {к з с}             3
1: {к} ∪ {к з с}             3      всего 9 ≈ n²/2

МЕНЬШЕЕ В БОЛЬШЕЕ
5: {с}
4: размеры равны → вливаем {с} в {з}                         1
3: забираем {з с}, вливаем {к}                               1
2: забираем {к з с}, вливаем {с} (уже был)                   1
1: забираем {к з с}, вливаем {к}                             1   всего 4

На цепочке длины n разница - n²/2 против n: каждый уровень вливает только свой элемент.

Разбор: пятьдесят тысяч вершин

Программа строит глубокое дерево (отец каждой вершины - одна из трёх предыдущих, отсюда длинные цепочки), даёт вершинам по одному цвету из тысячи и считает число разных цветов в каждом поддереве двумя способами, сверяя ответы.

menshee.py
import math
import random

rng = random.Random(9)
n = 50000
otec = [-1] + [rng.randrange(max(0, v - 3), v) for v in range(1, n)]
cvet = [rng.randrange(1000) for _ in range(n)]   # цвет - отдел, тег...
deti = [[] for _ in range(n)]
for v in range(1, n):
    deti[otec[v]].append(v)
poryadok = [0]
for v in poryadok:
    poryadok.extend(deti[v])


def razlichnye(umno):                 # число разных цветов в поддереве
    mnozh = [None] * n
    otvet = [0] * n
    perenosy = 0
    for v in reversed(poryadok):      # дети раньше отца
        nabor = {cvet[v]}
        for u in deti[v]:
            m = mnozh[u]
            if umno and len(m) > len(nabor):
                nabor, m = m, nabor   # вливаем меньшее в большее
            perenosy += len(m)
            nabor |= m
            mnozh[u] = None
        mnozh[v] = nabor
        otvet[v] = len(nabor)
    return otvet, perenosy


o1, p1 = razlichnye(umno=False)
o2, p2 = razlichnye(umno=True)
assert o1 == o2
print("вливание в отцовский набор:", p1, "переносов")
print("меньшее в большее:         ", p2, "переносов")
print("n · log2 n =", round(n * math.log2(n)))
↳  выводpython3 menshee.py
вливание в отцовский набор: 24619992 переносов
меньшее в большее:          70485 переносов
n · log2 n = 780482

Ответы совпали, а работы в 350 раз меньше. Даже оценка n · log₂ n здесь с запасом в десять раз: разных цветов всего тысяча, и наборы не растут выше тысячи. Наивный вариант копирует наборы до тысячи элементов на каждом из десятков тысяч уровней.

Что ломается без этого

Первая ошибка - сравнивать размеры, но вливать всё равно в «свой» набор вершины: выигрыш появляется только тогда, когда большой набор ЗАБИРАЮТ, а не копируют. nabor = nabor | m создаёт новое множество и копирует оба - это снова квадрат; нужно nabor |= m по большому набору. Вторая - забыть, что забранный набор принадлежал ребёнку: если ответ ребёнка считать потом по его набору, он окажется испорчен отцовскими элементами. Третья - применять приём к величинам, которые не «вливаются» поэлементно: медиана поддерева так не считается без дополнительной структуры.

Зачем это в работе

Агрегаты по иерархиям: уникальные теги в папке со всем содержимым, число разных клиентов в регионе со всеми филиалами, самый частый тип события в поддереве вызовов профилировщика. Тот же принцип «меньшее к большему» стоит в объединении по рангу у непересекающихся множеств, в слиянии куч и в переразмеривании хеш-таблиц при объединении словарей: a.update(b) дешевле, когда b меньше. Если объединяешь коллекции многократно, порядок аргументов - это разница между n log n и n².

Теперь сам

Корень с тремя детьми, у детей наборы размеров 100, 5 и 40, у корня свой элемент. Сколько переносов сделает «меньшее в большее» при обработке корня и сколько - наивный вариант, вливающий всё в набор корня?

Ответ. Меньшее в большее: забираем набор на 100, вливаем свой элемент, 5 и 40 - 46 переносов. Наивный: в набор корня из одного элемента вливаются все три - 145. На одной вершине разница втрое, а на глубоком дереве она накапливается до квадрата против n log n.

Главное

Набор вершины, собранный из наборов детей, строят не заново, а поверх самого большого из них, вливая остальные. Элемент переносится только из меньшего набора в больший, и набор при этом удваивается - не больше log₂ n переносов на элемент, O(n · log n) на всё дерево. На пятидесяти тысячах вершин - 70 тысяч переносов против 24.6 миллиона.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.