1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

# теория · шаг 2 из 6

Где выигрыш исчезает: три режима одной формулы

Коротко

У формулы T(n) = a·T(n/b) + f(n) три исхода, и они отвечают на вопрос «куда девается время». Листья тяжелее - вся работа внизу, в мелких подзадачах, и объединение почти ничего не стоит. Поровну - каждый уровень дерева стоит одинаково, отсюда множитель log n. Корень тяжелее - больше половины времени уходит на верхнее деление и объединение, и рекурсия ничего не меняет. Практический вывод: выигрыш убивают три вещи - дорогое объединение, слишком много подзадач и слишком мелкое дробление, когда накладные расходы рекурсии превышают саму работу.

Если ты по виду рекуррентности определяешь режим, знаешь, зачем в реальном коде нужен порог перехода на простой алгоритм, и помнишь про цену рекурсии, листай до «Теперь сам».

Как думаешь, что дороже

Две функции сортируют миллион чисел. Первая - сортировка слиянием до самого дна, то есть до кусков по одному элементу. Вторая - та же сортировка, но на кусках короче 32 элементов она переключается на сортировку вставками, у которой честный квадрат.

Квадрат против n·log n - казалось бы, вопрос закрыт. Но в реальных библиотеках стоит именно вторая, и она быстрее первой примерно в полтора раза. Как квадратичный алгоритм может ускорять n·log n?

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

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

Механизм: три режима и три способа всё испортить

n^(log_b a)  - сколько работы В ЛИСТЬЯХ (число листьев дерева рекурсии)
f(n)         - сколько работы НА ВЕРХНЕМ уровне

листья тяжелее      f(n) = O(n^(log_b a − ε))     T(n) = Θ(n^(log_b a))
поровну             f(n) = Θ(n^(log_b a))         T(n) = Θ(n^(log_b a)·log n)
корень тяжелее      f(n) = Ω(n^(log_b a + ε))     T(n) = Θ(f(n))
ЧТО УБИВАЕТ ВЫИГРЫШ

дорогое объединение     2·T(n/2) + n²   → Θ(n²),  как у перебора
много подзадач          4·T(n/2) + n    → Θ(n²),  деление не спасает
мелкое дробление        рекурсия на кусках из 2-3 элементов:
                        вызов функции стоит дороже, чем сама работа

Третий пункт в асимптотике не виден вовсе: Θ(n·log n) останется Θ(n·log n), что бы ты ни делал на дне. Он виден только в замере - и лечится порогом: ниже него берётся простой алгоритм без рекурсии.

def sort(a, lo, hi):
    if hi - lo <= 32:            # порог: дальше дробить невыгодно
        insertion_sort(a, lo, hi)
        return
    mid = lo + (hi - lo) // 2    # не (lo + hi) // 2 - переполнение в C и Java
    sort(a, lo, mid)
    sort(a, mid, hi)
    merge(a, lo, mid, hi)

Правило

Три режима формулы: листья тяжелее (T = Θ(n^(log_b a))), поровну (множитель log n), корень тяжелее (T = Θ(f(n))). Выигрыш деления убивают дорогое объединение, слишком большое число подзадач и слишком мелкое дробление. Первые два видны в асимптотике, третий - только в замере, и лечится порогом перехода на простой алгоритм (в библиотеках это 16-64 элемента). Глубина рекурсии - log_b n кадров на стеке, и это отдельный ресурс: на миллионе элементов деление пополам даёт два десятка кадров, а деление на «один и остальные» - миллион и переполнение стека.

Разбор: почему вставки ускоряют слияние

Посчитаем честно. Пусть порог k = 32, всего n = 10⁶ элементов.

без порога:  дерево до листьев по 1 элементу, уровней log₂ 10⁶ ≈ 20
             вызовов функции ≈ 2·10⁶, и на дне каждый вызов делает почти ничего

с порогом:   дерево останавливается на кусках по 32, уровней log₂(10⁶/32) = 15
             вызовов ≈ 2·(10⁶/32) ≈ 62 500 - в тридцать раз меньше
             плюс 31 250 сортировок вставками по 32 элемента:
             32²/4 ≈ 256 операций каждая, итого ≈ 8·10⁶ операций

Восемь миллионов операций вставками против экономии почти двух миллионов вызовов функции (2·10⁶ − 62 500 ≈ 1.94·10⁶) и пяти уровней слияния (каждый уровень - это 10⁶ перемещений). Считаем уровни: 20 − 15 = 5 уровней по 10⁶ = 5·10⁶ перемещений сэкономлено. Плюс вызовы. Против 8·10⁶ дешёвых операций вставками на почти отсортированных коротких кусках, где вставки работают быстрее своего худшего случая.

Итог примерно в ноль по числу операций - а выигрыш в полтора раза берётся из того, чего в подсчёте операций нет вовсе: короткий кусок целиком помещается в кеш процессора, у вставок нет вызовов функций, и у них последовательный доступ к памяти. Именно поэтому порог ПОДБИРАЮТ замером под конкретную машину, а не выводят из формулы.

Разбор: одно и то же дерево, три разных ответа

Возьмём три рекуррентности с одинаковым делением пополам и посмотрим, как перетекает работа по уровням. Пусть n = 8.

T(n) = 2·T(n/2) + n            T(n) = 2·T(n/2) + n²        T(n) = 2·T(n/2) + 1
уровень 0:  8                  уровень 0:  64              уровень 0:  1
уровень 1:  4 + 4 = 8          уровень 1:  16 + 16 = 32    уровень 1:  1 + 1 = 2
уровень 2:  2·4 = 8            уровень 2:  4·4 = 16        уровень 2:  4
уровень 3:  1·8 = 8            уровень 3:  1·8 = 8         уровень 3:  8
сумма: 4 уровня по 8 = 32      сумма: 64+32+16+8 = 120     сумма: 1+2+4+8 = 15
режим: поровну                 режим: корень тяжелее       режим: листья тяжелее
Θ(n·log n)                     Θ(n²)                       Θ(n)

Смотри, куда смещается масса. В первом столбце она размазана ровно - отсюда log n множителем. Во втором больше половины стоит на верхнем уровне, и сумма всего лишь вдвое больше верхнего: 120 < 2 · 64. В третьем наоборот: половина всей работы - в листьях, и сумма меньше удвоенного нижнего уровня.

Это и есть способ понять ответ без теоремы: посчитай верхний уровень, посчитай нижний и посмотри, какой из них растёт быстрее по мере спуска. Теорема лишь формализует этот взгляд.

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

Первое: гнать рекурсию до одного элемента и удивляться, что «теоретически быстрый» код медленнее библиотечного. Второе: ставить порог наугад и не мерить: оптимальное значение зависит от размера кеша и стоимости вызова, а на разных машинах оно разное. Третье: забывать про стек: log n кадров при делении пополам это мелочь, но при делении «один элемент и остальные» (худший случай быстрой сортировки) их будет n, и программа упадёт до того, как станет медленной. Четвёртое: считать, что f(n) - это только объединение: деление тоже стоит времени, и если оно требует сортировки, то f(n) = n·log n, а не n. Пятое: применять главную теорему к рекуррентности, которая под неё не подходит (подзадачи РАЗНОГО размера, как T(n) = T(n/3) + T(2n/3) + n) - там нужен разбор деревом.

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

Три режима - это готовый ответ на вопрос «стоит ли переписывать». Если твой алгоритм в режиме «корень тяжелее», ускорять рекурсию бессмысленно: время уходит на верхний уровень, и оптимизировать надо именно его. Если «листья тяжелее» - наоборот, смотри на базовый случай и на число подзадач. Порог перехода на простой алгоритм стоит в любой серьёзной реализации сортировки (в CPython это Timsort с minrunРазбирается в разделе 25, глава «Timsort, introsort и почему в библиотеке гибрид»: Timsort изнутри: серии, minrun и вставки двоичным поиском, в C++ - introsort с переходом на вставки), и знание, зачем он там, - это разница между «скопировал библиотечный код» и «понимаю, что будет, если убрать эту строчку». А глубина рекурсии - обычная причина падений на проде: обработка дерева каталогов или JSON рекурсией без ограничения глубины ломается ровно там, где данные оказались глубже, чем думал автор.

Теперь сам

Алгоритм делит массив на три части по n/3, решает каждую рекурсивно и объединяет за n. Назови режим и ответ. Что изменится, если объединение станет стоить n²?

Ответ. Рекуррентность: T(n) = 3·T(n/3) + n, то есть a = 3, b = 3, f(n) = n.

Считаем n^(log₃ 3) = n¹ = n. Сравниваем с f(n) = n - поровну, значит режим второй и ответ Θ(n·log n). Проверим по уровням: верхний стоит n, следующий - 3 · (n/3) = n, следующий - 9 · (n/9) = n. Каждый уровень стоит n, уровней log₃ n, итого n·log₃ n = Θ(n·log n) (основание логарифма в Θ-записи роли не играет - это множитель-константа).

Если объединение станет n²: T(n) = 3·T(n/3) + n². Считаем n^(log₃ 3) = n, сравниваем с n² - корень тяжелее, ответ Θ(n²). Проверим суммой: n² + 3·(n/3)² + 9·(n/9)² + ... = n²·(1 + 1/3 + 1/9 + ...) = n² · 3/2. Полтора квадрата - то есть рекурсия добавила к верхнему уровню всего половину его стоимости, и никакого выигрыша от деления нет.

Главное

Три режима формулы T(n) = a·T(n/b) + f(n) отвечают на вопрос, где лежит работа: в листьях (Θ(n^(log_b a))), поровну по уровням (множитель log n) или на верхнем уровне (Θ(f(n))). Выигрыш деления убивают дорогое объединение, слишком много подзадач и слишком мелкое дробление; первые два видны в асимптотике, третий - только в замере и лечится порогом перехода на простой алгоритм (16-64 элемента в библиотеках). Понять режим можно и без теоремы: посчитать верхний уровень и нижний и посмотреть, куда смещается масса. Отдельный ресурс - глубина рекурсии: при делении пополам это log n кадров, при делении «один и остальные» - n кадров и переполнение стека.

Комментарии

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

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

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