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

# теория · шаг 3 из 9

Многофазная сортировка слиянием

Коротко

Многопутевое слияние из прошлого урока держит в памяти по блоку на каждый прогон. Если потоков, которые можно вести одновременно, мало - три-четыре файла или ленты, - сливают по два, и тогда решает, сколько данных переписывает каждый проход. Наивная схема на трёх файлах сливает два в третий, а потом переписывает результат обратно на два, то есть почти каждые данные копируются вхолостую. Многофазное слияние (polyphase merge) обходится без этого копирования: прогоны изначально раскладываются по двум файлам неравно, по двум соседним числам Фибоначчи, и каждая фаза сливает, пока не опустеет меньший вход. На 21 прогоне и трёх файлах оно читает данные 4,57 раза против 9 у наивной схемы и 5 у схемы с четырьмя файлами.

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

Как думаешь, куда девается проход

У тебя 21 отсортированный прогон и три файла, которые можно читать и писать только подряд, от начала к концу. Слияние по два: берёшь по прогону из двух файлов и пишешь результат в третий. Схема «разложить поровну, слить, разложить результат снова поровну» требует после каждого слияния ещё и переписать файл на два. Сколько раз данные пройдут через диск?

Ответь себе до чтения. Пять уровней слияния (⌈log₂ 21⌉ = 5) плюс четыре копирования между ними - девять проходов по всем данным. Многофазная схема на тех же трёх файлах обходится 4,57 прохода: она обходится без отдельного полного перераспределения данных между каждой парой фаз, сливая за фазу только часть серий. Само слияние всё равно читает и записывает данные; фиктивная серия может вызвать переписывание одной настоящей.

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

Две колоды карт и пустое место на столе, третьей колоде больше лечь некуда. Если колоды равные, после слияния пар у тебя одна колода и пустые два места - чтобы слить снова, её придётся разложить обратно на две. А если колоды неравные - в одной 13 стопок, в другой 8, - сливаешь 8 пар на свободное место, и у тебя снова две колоды (5 стопок и 8 стопок) и одно пустое место. Раскладывать ничего не надо: остаток большей колоды сам становится вторым входом.

Механизм: фаза идёт до первого пустого входа

Модель из книги Макконнелла о внешней сортировке - файлы, доступные только подряд: создать прогоны, разложить их по файлам, а потом сливать пары прогонов, читая их одновременно из двух файлов. У него файлов четыре: два входа и два выхода, и выходы на следующем проходе меняются ролями со входами. Каждый проход читает все данные, проходов ⌈log₂ R⌉ при R прогонах.

Если файлов три, выход у слияния один. Фаза многофазного слияния: два непустых файла - входы, пустой - выход; сливаем по прогону из каждого входа, пока один из входов не опустеет. Опустевший файл становится выходом следующей фазы, а входами - вчерашний выход и остаток второго входа. Фаза читает не все данные, а только то, что успело слиться.

Когда это заканчивается одним прогоном? Последняя фаза сливает (1, 1) в один прогон. Пусть перед фазой во входах a ≥ b прогонов. Она сливает b пар: на выходе получается b слитых, в остатке большего - a−b. Как пара «больше, меньше» результат равен (max(b, a−b), min(b, a−b)); например, (7,3) переходит в (4,3).

Для простого обратного расписания выбираем шаги, у которых прежний выход не меньше остатка, то есть b ≥ a−b. Тогда пара уже упорядочена как (b,a−b), и один способ идти назад - (x,y) → (x+y,x): (1,1) ← (2,1) ← (3,2) ← (5,3) ← (8,5) ← (13,8). Это конкретное удобное расписание; произвольная раскладка может менять порядок файлов иначе.

Числа Фибоначчи - ряд 1, 1, 2, 3, 5, 8, 13, 21, 34, …, где каждое число - сумма двух предыдущих. Здесь они появляются не из любви к красивым рядам: обратный шаг (x, y) → (x + y, x) и есть правило «следующее - сумма двух предыдущих». Поэтому прогоны раскладывают по двум соседним числам Фибоначчи, а общее число прогонов в этой идеальной раскладке тоже выбирают числом Фибоначчи. Это удобное расписание, а не необходимое условие завершения: раскладка 7 и 3 тоже дойдёт до одного прогона, хотя 10 не число Фибоначчи. Ниже используем именно расписание Фибоначчи. Если прогонов меньше выбранной суммы, недостачу добирают фиктивными прогонами - пустыми, которые только числятся в файле.

При T файлах сливают по T − 1, и раскладка идёт по обобщённым числам Фибоначчи, где каждое - сумма T − 1 предыдущих; ниже мы разбираем три файла.

Правило

Многофазное слияние сливает прогоны из всех файлов, кроме одного, в оставшийся, пока не опустеет один из входов; опустевший файл становится выходом следующей фазы. Отдельного полного перераспределения между каждой парой фаз не требуется. В описанном расписании три файла используют соседние числа Фибоначчи и при необходимости фиктивные прогоны. Другую раскладку можно использовать, отдельно проверив её завершение одним прогоном.

Разбор: 21 прогон по фазам

Раскладка 13 и 8, третий файл пуст. В скобках - сколько исходных прогонов слилось в каждый прогон файла.

            файл A        файл B        файл C      прочитано
начало      13 × (1)      8 × (1)       -
фаза 1      5 × (1)       -             8 × (2)       16
фаза 2      -             5 × (3)       3 × (2)       15
фаза 3      3 × (5)       2 × (3)       -             15
фаза 4      1 × (5)       -             2 × (8)       16
фаза 5      -             1 × (13)      1 × (8)       13
фаза 6      1 × (21)      -             -             21
                                               всего  96

Фаза 1 сливает восемь пар (1) + (1) и оставляет в A пять прогонов. Фаза 2 сливает эти пять с пятью прогонами (2) из C, и в C остаются три. После первой фазы вчерашний выход C содержит восемь прогонов и становится большим входом следующей; остаток A с пятью прогонами - меньшим. Размер выхода определяется меньшим входом текущей фазы, а не следующей. Всего прочитано 96 исходных прогонов из 21 - данные прошли через диск 96 / 21 ≈ 4,57 раза. Это отношение объёмов верно для исходных прогонов одинаковой длины, как в программе ниже. Для разных длин надо складывать число действительно прочитанных элементов и делить на общий объём входа, а не считать прогоны одинаковыми единицами. Шесть фаз, но фазы короткие: ни одна, кроме последней, не трогает все данные.

Разбор: наивные схемы и прогоны не по Фибоначчи

Программа сортирует по-настоящему: 100 случайных чисел на прогон, файлы - списки прогонов, читаемые только с начала. Считает, сколько раз через слияние и копирование прошли данные (запись прогонов в начале у всех схем одинакова и не считается).

mnogofaznoe.py
import random
from heapq import merge


def mnogofaznoe(runs):
    # три файла; серии раскладываются по двум соседним числам
    # Фибоначчи, недостающие - пустые «фиктивные» серии
    a, b = 1, 0
    while a + b < len(runs):
        a, b = a + b, a
    fikt = a + b - len(runs)
    f = [[[]] * fikt + runs[:a - fikt], runs[a - fikt:], []]
    prochitano = fazy = 0
    while sum(len(x) for x in f) > 1:
        vyh = [i for i in range(3) if not f[i]][0]
        x, y = [f[i] for i in range(3) if i != vyh]
        while x and y:              # сливаем, пока один вход не опустел
            s = list(merge(x.pop(0), y.pop(0)))
            prochitano += len(s)
            f[vyh].append(s)
        fazy += 1
    return [s for s in f if s][0][0], fazy, prochitano


def po_dva(runs, faylov):
    # 4 файла: два входа, два выхода; 3 файла: слить в один и
    # переписать его серии обратно на два - лишний проход копирования
    a, b = runs[0::2], runs[1::2]
    prochitano = prohody = 0
    while len(a) + len(b) > 1:
        slito = [list(merge(*p)) for p in zip(a, b)] + a[len(b):]
        prochitano += sum(len(s) for s in slito)
        prohody += 1
        a, b = slito[0::2], slito[1::2]
        if faylov == 3 and len(slito) > 1:
            prochitano += sum(len(s) for s in slito)
    return (a + b)[0], prohody, prochitano


rng = random.Random(5)
for R in [21, 20, 22, 23, 34, 35]:
    x = [rng.randint(0, 10 ** 6) for _ in range(R * 100)]
    runs = [sorted(x[s:s + 100]) for s in range(0, len(x), 100)]
    print("серий", R)
    vse = [["4 файла, по два", po_dva(runs, 4)],
           ["3 файла, по два", po_dva(runs, 3)],
           ["3 файла, Фибоначчи", mnogofaznoe(runs)]]
    for imya, (rez, f, ch) in vse:
        if R == 21 or imya != "3 файла, по два":
            print(f"  {imya:19} верно {rez == sorted(x)}  фаз {f}  "
                  f"данных прочитано {ch / len(x):.2f} раза")
↳  выводpython3 mnogofaznoe.py
серий 21
  4 файла, по два     верно True  фаз 5  данных прочитано 5.00 раза
  3 файла, по два     верно True  фаз 5  данных прочитано 9.00 раза
  3 файла, Фибоначчи  верно True  фаз 6  данных прочитано 4.57 раза
серий 20
  4 файла, по два     верно True  фаз 5  данных прочитано 5.00 раза
  3 файла, Фибоначчи  верно True  фаз 6  данных прочитано 4.50 раза
серий 22
  4 файла, по два     верно True  фаз 5  данных прочитано 5.00 раза
  3 файла, Фибоначчи  верно True  фаз 7  данных прочитано 5.14 раза
серий 23
  4 файла, по два     верно True  фаз 5  данных прочитано 5.00 раза
  3 файла, Фибоначчи  верно True  фаз 7  данных прочитано 5.13 раза
серий 34
  4 файла, по два     верно True  фаз 6  данных прочитано 6.00 раза
  3 файла, Фибоначчи  верно True  фаз 7  данных прочитано 5.29 раза
серий 35
  4 файла, по два     верно True  фаз 6  данных прочитано 6.00 раза
  3 файла, Фибоначчи  верно True  фаз 8  данных прочитано 5.89 раза

На 21 прогоне три файла по Фибоначчи читают данные 4,57 раза - меньше, чем четыре файла по два (5), и почти вдвое меньше наивной схемы на тех же трёх файлах (9: пять слияний и четыре копирования).

Граница правила видна на 22 прогонах. Ближайшая сумма соседних чисел Фибоначчи - 34, и программа добирает 12 фиктивных прогонов. Слияние с пустым прогоном - это переписывание настоящего прогона, фаза прибавилась, и 22 прогона стоят 5,14 прохода - больше, чем 5 у четырёх файлов. Сразу за числом Фибоначчи (21 → 22, 34 → 35) цена в показанных точках прыгает. Внутри промежутка она не обязана расти монотонно: при 23 прогонах программа даёт 5,13 прохода, немного меньше 5,14 при 22. Считай реальный объём чтений для своей раскладки. Где лежат фиктивные прогоны, тоже важно: здесь они все в начале большего файла и сливаются с настоящими; более аккуратная раскладка часть такого переписывания убирает.

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

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

Раскладка почти поровну. Разложи 21 прогон как 11 и 10: фаза 1 сольёт 10 пар, и в одном файле останется один прогон, а в другом - десять двойных. Дальше каждая фаза сливает ровно одну пару: растущий прогон с очередным двойным, читая 3, 5, 7, …, 21. Фаз 11, прочитано 20 + 120 = 140 прогонов - 6,67 прохода вместо 4,57. Неравенство раскладки - не деталь, а суть метода.

Непроверенное расписание. Произвольная раскладка не обязана завершиться одним прогоном. Например, 4 и 4 дадут четыре прогона в единственном файле: два других пусты, и следующую фазу с двумя входами начать нельзя. Раскладка 7 и 3, напротив, доходит до одного прогона без фиктивных. Поэтому фиктивные прогоны нужны для выбранного здесь расписания Фибоначчи, а не для любого многофазного слияния. Фаза по определению длится до опустошения одного входа: завершиться с двумя непустыми входами она не может.

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

Многофазная схема полезна при ограниченном числе независимых потоков и дорогом перераспределении прогонов. Файлы здесь играют роль последовательных лент: переключение ролей позволяет использовать освободившийся вход как следующий выход. Если памяти хватает на буферы широкого многопутевого слияния, сравни его стоимость с многофазным вариантом: меньшее число файлов само по себе не означает меньшее время. Отдельно считай чтения, записи, фиктивные прогоны и стоимость управления буферами.

Теперь сам

Три файла, 13 прогонов. Как их разложить, сколько будет фаз и сколько раз данные пройдут через слияние?

Ответ. 13 = 8 + 5: восемь прогонов в один файл, пять в другой. Фазы: (8, 5) → (5, 3) → (3, 2) → (2, 1) → (1, 1) → 1 - пять фаз. Длины прогонов растут по тем же числам: фаза 1 сливает 5 пар (1)+(1), читает 10; фаза 2 - три пары (1)+(2), 9; фаза 3 - две пары (2)+(3), 10; фаза 4 - одна пара (3)+(5), 8; фаза 5 - (5)+(8), 13. Всего 10 + 9 + 10 + 8 + 13 = 50 прогонов, то есть 50 / 13 ≈ 3,85 прохода по данным, а четыре файла по два сделали бы ⌈log₂ 13⌉ = 4.

Главное

Когда файлов мало, дорогими становятся не слияния, а холостые копирования между ними; многофазное слияние убирает их, заранее разложив прогоны неравно - по соседним числам Фибоначчи, которые получаются, если прокрутить фазы от конца к началу.

Комментарии

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

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

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