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

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

Линейное разбиение: поделить работу на k кусков подряд

Коротко

Есть очередь задач с длительностями, k исполнителей, и каждому достаётся отрезок очереди ПОДРЯД (тома собрания сочинений по полкам, главы книги по переводчикам, страницы по принтерам). Надо, чтобы самый загруженный был загружен как можно меньше. Это линейное разбиение. Динамика: d[j][i] - лучший наибольший кусок, если первые i элементов разрезать на j кусков; переход перебирает начало последнего куска: d[j][i] = min по t max(d[j−1][t], сумма a[t..i−1]), Θ(k·n²). Если элементы неотрицательны, есть путь короче - двоичный поиск по ответу: при пороге m жадно режем, когда кусок превысил бы m, и проверяем, хватило ли k кусков; O(n · log S). Наивное «резать у среднего» промахивается в трёх случаях из четырёх.

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

Как думаешь, где резать

Книги одной серии - 3, 1, 4, 1, 5, 9, 2, 6 сантиметров толщиной - надо расставить на три полки, не меняя порядок. Полки покупают по самой длинной. Какой должна быть самая длинная полка?

Прежде чем читать дальше, попробуй очевидное: всего 31 сантиметр, на полку в среднем 10.3. Режь, как только набралось больше среднего. Сколько получится? И почему среднее - это нижняя граница, а не ответ?

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

Раздача глав книги трём переводчикам: каждый получает несколько глав подряд, срок сдачи - когда закончит самый медленный. Редактор, режущий «по сто страниц на человека», натыкается на огромную главу, которую резать нельзя, и вся раскладка съезжает. Хороший редактор сначала спрашивает: «а уложимся ли мы в двести страниц на человека?» - и проверить это очень легко: раздавать главы по очереди, пока человек не наберёт двести.

Механизм: динамика и проверка порога

ДИНАМИКА
d[j][i] - наименьший возможный наибольший кусок,
          если первые i элементов разрезаны на j кусков
база:    d[0][0] = 0
переход: последний кусок - a[t..i−1]:
         d[j][i] = min по t ( max( d[j−1][t],  pref[i] − pref[t] ) )
ответ:   d[k][n];  состояний k·n, переход до n - Θ(k·n²)

ПОИСК ПО ОТВЕТУ (все a ≥ 0)
хватит(m): идти слева направо и резать, как только следующий
           элемент сделал бы кусок больше m; кусков ≤ k?
признак монотонен: хватило при m - хватит и при любом большем
двоичный поиск m на [max(a), sum(a)]:  O(n · log sum(a))

Почему жадная проверка верна. При фиксированном пороге m выгодно делать каждый кусок как можно длиннее: если в каком-то решении кусок можно удлинить, не превысив m, следующий от этого только укоротится. Это обменный аргумент: любое решение с не более чем k кусками превращается в жадное, не увеличивая число кусков.

Правило

Линейное разбиение - разрезать последовательность на k отрезков подряд с наименьшим наибольшим отрезком. Динамика по префиксам: d[j][i] = min по t max(d[j−1][t], сумма a[t..i−1]), Θ(k·n²). При неотрицательных элементах быстрее двоичный поиск по ответу: проверка порога m - жадная нарезка самыми длинными кусками, не больше m; монотонность признака даёт O(n · log S). Среднее S/k - нижняя граница, а не ответ.

Разбор: книги на три полки

a = 3 1 4 1 5 9 2 6,  сумма 31,  k = 3

«резать у среднего» (10.3):  3 1 4 1 | 5 | 9 2 6     полки 9, 5, 17
    после второго разреза разрезов не осталось, хвост 17

поиск по ответу, границы [9, 31]:
    хватит(14)?  3 1 4 1 5 =14 | 9 2 =11 | 6     три куска - да
    хватит(13)?  3 1 4 1 =9 | 5 | 9 2 =11 | 6    четыре - нет
    ответ 14:  3 1 4 1 5 | 9 | 2 6   или   3 1 4 1 | 5 9 | 2 6

Наивная нарезка потратила разрез на одинокую пятёрку и оставила хвост в 17. Нижняя граница max(9, 31/3) = 10.3 недостижима: девятка не делится, а соседи у неё такие, что меньше 14 не выходит.

Разбор: пятьсот массивов и сто тысяч элементов

Программа сравнивает динамику с поиском по ответу на пятистах случайных массивах, считает, как часто ошибается «резать у среднего», и решает задачу на ста тысячах элементов, где динамика за k·n² уже не годится.

razbienie.py
import itertools
import random


def dinamika(a, k):                   # d[j][i]: первые i элементов
    n = len(a)                        # на j кусков, лучший наибольший
    pref = list(itertools.accumulate(a, initial=0))
    INF = float("inf")
    d = [[INF] * (n + 1) for _ in range(k + 1)]
    d[0][0] = 0
    for j in range(1, k + 1):
        for i in range(1, n + 1):
            for t in range(j - 1, i):     # последний кусок a[t..i−1]
                c = max(d[j - 1][t], pref[i] - pref[t])
                d[j][i] = min(d[j][i], c)
    return d[k][n]


def po_otvetu(a, k):                  # двоичный поиск по ответу
    def hvatit(m):                    # жадно: резать, когда кусок > m
        kuskov, s = 1, 0
        for x in a:
            if s + x > m:
                kuskov, s = kuskov + 1, 0
            s += x
        return kuskov <= k
    lo, hi = max(a), sum(a)
    while lo < hi:
        m = (lo + hi) // 2
        if hvatit(m):
            hi = m
        else:
            lo = m + 1
    return lo


def po_srednemu(a, k):                # «резать у среднего» - наивно
    cel, s, kuski, tek = sum(a) / k, 0, [], 0
    for x in a:
        if tek + x > cel and len(kuski) < k - 1 and tek > 0:
            kuski.append(tek)
            tek = 0
        tek += x
    return max(kuski + [tek])


rng = random.Random(26)
huzhe = 0
for _ in range(500):
    a = [rng.randint(1, 50) for _ in range(rng.randint(5, 30))]
    k = rng.randint(2, 5)
    d = dinamika(a, k)
    assert d == po_otvetu(a, k)
    huzhe += po_srednemu(a, k) > d
print("500 массивов: динамика и поиск по ответу совпали всегда")
print("«резать у среднего» хуже оптимума в", huzhe, "случаях")
a = [rng.randint(1, 10 ** 6) for _ in range(10 ** 5)]
print("100 000 элементов на 8 кусков:", po_otvetu(a, 8))
print("нижняя граница sum/k:", sum(a) // 8)
↳  выводpython3 razbienie.py
500 массивов: динамика и поиск по ответу совпали всегда
«резать у среднего» хуже оптимума в 383 случаях
100 000 элементов на 8 кусков: 6258082014
нижняя граница sum/k: 6257749680

Два точных метода совпали везде. «Резать у среднего» промахнулось в 383 случаях из 500. На ста тысячах элементов поиск по ответу сделал около тридцати шести проверок по 10⁵ шагов; динамика потребовала бы 8 · 10¹⁰ действий. Ответ на большом массиве лишь на 0.005% выше нижней границы sum/k: когда элементов много и они мелкие относительно суммы, разбиение почти идеально.

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

Первая ошибка - решать жадно «по среднему» и не проверять: ответ правдоподобен и часто неверен. Вторая - применять поиск по ответу к массивам с отрицательными числами: признак «хватит ли k кусков» перестаёт быть монотонным, а жадная нарезка - верной; остаётся динамика. Третья - перепутать задачу с разбиением на два РАВНЫХ по сумме множества без сохранения порядка: это уже сумма подмножеств, и она трудна. Четвёртая - начать двоичный поиск с нуля, а не с наибольшего элемента: при m меньше элемента жадная нарезка зацикливается или врёт.

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

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

Теперь сам

a = 7 2 5 10 8, k = 2. Найди ответ поиском по ответу: какие пороги проверяет двоичный поиск и где режет жадная проверка на ответе?

Ответ. Границы [10, 32]. Середина 21: 7 2 5 = 14, +10 дало бы 24 > 21 - режем; 10 8 = 18 - два куска, хватает, hi = 21. 15: 7 2 5 | 10 | 8 - три, не хватает, lo = 16. 18: 7 2 5 | 10 8 - два, hi = 18. 17: 7 2 5 | 10 | 8 - три, lo = 18. Ответ 18: полки 14 и 18.

Главное

Линейное разбиение режет последовательность на k кусков подряд с наименьшим наибольшим. Динамика по префиксам решает его за Θ(k·n²), а при неотрицательных элементах - двоичный поиск по ответу с жадной нарезкой самыми длинными допустимыми кусками за O(n · log S). «Резать у среднего» ошиблось в 383 случаях из 500: среднее - нижняя граница, а не ответ.

Комментарии

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

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

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