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

# теория · шаг 4 из 7

Случайный порядок вставки: Делоне в ожидании и наименьшая окружность

Коротко

Многие геометрические структуры строят вставкой по одному в случайном порядке: держат ответ для первых i объектов и чинят его, когда приходит (i + 1)-й. В худшем порядке починка дорогая каждый раз, но при случайном порядке её цену считает обратный анализ: посмотри на шаг с конца - удали из i объектов случайный - и спроси, с какой вероятностью ответ изменится. Для наименьшей охватывающей окружности ответ держат три точки, вероятность ≤ 3 / i, и всё строится за линейное ожидаемое время. Для Делоне новая точка в среднем касается меньше шести рёбер - отсюда n · log n в ожидании. Та же схема решает и линейное программирование на плоскости.

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

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

Нужна наименьшая окружность, внутри которой все точки, - например, где поставить вышку, чтобы покрыть сорок домов наименьшим радиусом. Алгоритм простой: держим окружность для уже обработанных точек; новая точка внутри - ничего не делаем; снаружи - строим окружность заново, зная, что новая точка лежит на её границе.

Ответь себе до чтения: что будет, если подать точки от центра наружу - тогда новая точка почти всегда снаружи? А если в случайном порядке? Ответ: на сорока точках 3942 проверки против 203 - в двадцать раз больше, и при удвоении n второе число растёт почти впятеро - быстрее квадрата.

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

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

Механизм: починка, обратный анализ и цена

Инкрементное построение. Ответ A(i) для первых i объектов. Объект i + 1 либо не нарушает ответ - шаг почти бесплатный, - либо нарушает, и тогда ответ строят заново, но для задачи поменьше: известно, что новый объект войдёт в ответ особым образом. Для окружности это «новая точка на границе», и задача с одной точкой на границе решается тем же приёмом с двумя точками на границе, а с двумя - перебором третьей.

Обратный анализ. Сколько стоит шаг i в случайном порядке? Удобно смотреть назад: первые i точек - это случайное множество, а последняя вставленная - случайная точка из него. Шаг дорогой, только если эта точка была нужна ответу - если без неё окружность меньше. Окружность опирается на две или три точки, значит, вероятность ≤ 3 / i. Дорогой шаг - это починка на i точках. Её цена в ожидании O(i) - по тому же доводу на уровень ниже: внутри починки одна опорная точка уже известна, и её собственные дорогие шаги так же редки. В худшем случае починка дороже, но в ожидании цена шага 3/i · O(i) = O(1), а всех n шагов - O(n).

Окружность держат три точки. Убери любую из восьми других - окружность не изменится. Последняя вставленная точка - случайная из одиннадцати: она меняет круг с вероятностью не больше 3 / 11. Так и устроен обратный анализ: смотрим, что теряется, если убрать случайную точку.
Одиннадцать точек и их наименьшая окружность. Выделенные три её держат, остальные восемь можно удалить по одной - окружность не изменится. Поэтому последний шаг случайной вставки дорогой с вероятностью не больше 3/11.

Делоне. Сначала добавляют три далёкие вершины - огромный треугольник, накрывающий все точки, - чтобы каждая новая точка оказалась внутри, а не за оболочкой; в конце их убирают вместе с рёбрами. Вставка точки: найти треугольник, где она лежит, разрезать его на три (точка ровно на ребре режет два треугольника на четыре) и переворачивать рёбра, ставшие нелегальными, - все они вокруг новой точки. Сколько переворотов? Столько, сколько рёбер у новой точки после вставки, минус три. Обратный анализ: последняя точка - случайная вершина Делоне из i точек, а сумма степеней всех вершин равна удвоенному числу рёбер, ≤ 2(3i − 6). Средняя степень меньше шести, ожидаемое число переворотов на вставку - O(1). Поиск треугольника идёт по истории: все треугольники, когда-либо существовавшие, хранятся со ссылками на те, что их заменили, и точку ведут от первого огромного треугольника вниз по ссылкам. Путь в ожидании O(log n). Итого O(n · log n) (Гибас, Кнут, Шарир, 1992).

Линейное программирование на плоскости - та же схема (Зайдель, 1991): ограничения вставляются в случайном порядке, нарушенное ограничение проходит через новый оптимум, и задача становится одномерной. Ожидаемое время O(n) при фиксированной размерности.

Правило

Рандомизированное инкрементное построение: перемешай вход, вставляй по одному, чини ответ при нарушении. Цену считает обратный анализ: вероятность, что последний объект был нужен ответу, умножить на цену починки. Наименьшая окружность - O(n) в ожидании (на неё опираются ≤ 3 точки), Делоне - O(n · log n) в ожидании (средняя степень < 6), линейное программирование на плоскости - O(n).

Разбор: от центра наружу против случайного

Окружность хранится своими опорными точками - двумя (диаметр) или тремя, - и проверка «точка внутри» точная: скалярное произведение для диаметра, определитель из прошлых уроков для трёх точек. Корней и центров не считаем вовсе.

import random
schet = [0]

def povorot(a, b, c):
    return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])

def v_kruge(a, b, c, d):
    m = []
    for p in [a, b, c]:
        dx = p[0] - d[0]
        dy = p[1] - d[1]
        m.append([dx, dy, dx * dx + dy * dy])
    return (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
            - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
            + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]))

def vnutri(krug, p):
    # круг задан двумя точками диаметра или тремя точками на окружности;
    # граница считается внутренней; всё в целых
    schet[0] = schet[0] + 1
    if len(krug) == 2:
        a, b = krug
        dx = (p[0] - a[0]) * (p[0] - b[0])
        return dx + (p[1] - a[1]) * (p[1] - b[1]) <= 0
    a, b, c = krug
    if povorot(a, b, c) < 0:
        b, c = c, b
    return v_kruge(a, b, c, p) >= 0

def s_dvumya(P, q1, q2):
    krug = [q1, q2]
    for p in P:
        if not vnutri(krug, p):
            krug = [q1, q2, p]
    return krug

def s_odnoj(P, q):
    krug = [q, P[0]]
    for j in range(1, len(P)):
        if not vnutri(krug, P[j]):
            krug = s_dvumya(P[:j], P[j], q)
    return krug

pereschetov = [0]

def minimalnyj(P):
    krug = [P[0], P[1]]
    for i in range(2, len(P)):
        if not vnutri(krug, P[i]):
            pereschetov[0] = pereschetov[0] + 1
            krug = s_odnoj(P[:i], P[i])
    return krug
def nabor(n):
    tochki = []
    for i in range(n):
        x = random.randint(-1000, 1000)
        tochki.append([x, random.randint(-1000, 1000)])
    return tochki

def ot_centra(tochki):
    # неудачный порядок: от центра наружу, точка часто вне круга
    pary = []
    for p in tochki:
        pary.append([p[0] * p[0] + p[1] * p[1], p])
    pary.sort()
    otvet = []
    for para in pary:
        otvet.append(para[1])
    return otvet

random.seed(12)
for n in [20, 40]:
    tochki = nabor(n)
    random.shuffle(tochki)
    schet[0] = 0
    krug = minimalnyj(tochki)
    s1 = schet[0]
    schet[0] = 0
    drugoj = minimalnyj(ot_centra(tochki))
    print(n, s1, schet[0], sorted(krug) == sorted(drugoj))
print(krug)
20 103 816 True
40 203 3942 True
[[-831, 981], [486, -894]]

На двадцати точках случайный порядок потратил 103 проверки, порядок «от центра наружу» - 816; на сорока - 203 против 3942. Удвоение n удвоило первое число и увеличило второе почти в пять раз. Окружность в обоих случаях одна и та же - алгоритм верен при любом порядке, порядок влияет только на цену. Для сорока точек она опирается на две точки: это диаметр от (−831, 981) до (486, −894). Точка p внутри круга с диаметром ab, когда угол a p b не острый, - это и проверяет скалярное произведение (a − p) · (b − p) ≤ 0. Точек на входе нужно хотя бы две - minimalnyj начинает с P[0] и P[1]; для одной точки ответ - круг нулевого радиуса.

Почему «от центра наружу» так плох: новая точка дальше всех прежних от начала координат и почти всегда вне прежнего круга (не всегда: сортировка по расстоянию до центра этого не гарантирует), и почти каждый раз запускается полная починка - n дорогих шагов вместо 3 ln n. А внутри починки происходит то же самое ещё раз.

Разбор: обратный анализ в числах

Шесть случайных перестановок для 50 и 100 точек. Считаем проверки на точку и число дорогих шагов верхнего уровня - сравниваем со суммой 3/3 + 3/4 + … + 3/n из обратного анализа.

import random
schet = [0]

def povorot(a, b, c):
    return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])

def v_kruge(a, b, c, d):
    m = []
    for p in [a, b, c]:
        dx = p[0] - d[0]
        dy = p[1] - d[1]
        m.append([dx, dy, dx * dx + dy * dy])
    return (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
            - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
            + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]))

def vnutri(krug, p):
    # круг задан двумя точками диаметра или тремя точками на окружности;
    # граница считается внутренней; всё в целых
    schet[0] = schet[0] + 1
    if len(krug) == 2:
        a, b = krug
        dx = (p[0] - a[0]) * (p[0] - b[0])
        return dx + (p[1] - a[1]) * (p[1] - b[1]) <= 0
    a, b, c = krug
    if povorot(a, b, c) < 0:
        b, c = c, b
    return v_kruge(a, b, c, p) >= 0

def s_dvumya(P, q1, q2):
    krug = [q1, q2]
    for p in P:
        if not vnutri(krug, p):
            krug = [q1, q2, p]
    return krug

def s_odnoj(P, q):
    krug = [q, P[0]]
    for j in range(1, len(P)):
        if not vnutri(krug, P[j]):
            krug = s_dvumya(P[:j], P[j], q)
    return krug

pereschetov = [0]

def minimalnyj(P):
    krug = [P[0], P[1]]
    for i in range(2, len(P)):
        if not vnutri(krug, P[i]):
            pereschetov[0] = pereschetov[0] + 1
            krug = s_odnoj(P[:i], P[i])
    return krug
random.seed(13)
for n in [50, 100]:
    tochki = []
    for i in range(n):
        x = random.randint(-1000, 1000)
        tochki.append([x, random.randint(-1000, 1000)])
    harm = 0
    for i in range(3, n + 1):
        harm = harm + 3 / i
    schet[0] = 0
    pereschetov[0] = 0
    for t in range(6):
        random.shuffle(tochki)
        minimalnyj(tochki)
    print(n, round(schet[0] / 6 / n, 2), round(pereschetov[0] / 6, 2),
          round(harm, 2))
50 5.33 7.17 9.0
100 5.76 8.33 11.06

Проверок на точку 5,3 и 5,8 - число почти не меняется при удвоении n: время линейно. Дорогих шагов в среднем 7,2 и 8,3, а оценка сверху - 9,0 и 11,1. Оценка выше среднего, потому что «не больше трёх опорных точек» - иногда их две, и тогда вероятность 2 / i. Оценка растёт как логарифм, 3 · ln n плюс константа, а среднее держится под ней.

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

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

Вход «как пришёл». Данные часто приходят упорядоченными - по времени, по координате, по обходу контура. Это как раз худшие порядки для инкрементных алгоритмов. Перемешивание стоит O(n) и снимает проблему.

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

Окружность через центр и радиус во float. Точка на границе - то внутри, то снаружи, и починки запускаются лишний раз или пропускаются. Опорные точки и определитель решают это точно.

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

Ограничивающие окружности и сферы в играх и физических движках - алгоритм Вельцля (1991) в той же схеме. Где поставить вышку или склад, чтобы покрыть всех наименьшим радиусом. Построение Делоне в CGAL и других библиотеках - вставка с перемешиванием; на практике порядок ещё и упорядочивают по кривой Гильберта внутри случайных пачек (так называемый смещённый случайный порядок, BRIO), чтобы соседние вставки трогали соседние места в памяти, - случайность сохраняет гарантию, упорядоченность - кеш.

Теперь сам

  1. Точек 1000, окружность всегда опирается на три точки. Сколько в среднем дорогих шагов верхнего уровня?
  2. Почему в Делоне ожидаемое число переворотов на вставку не больше примерно трёх?
  3. Можно ли в алгоритме окружности вместо перемешивания один раз отсортировать точки по x?

Ответ. 1. 3 · (1/3 + 1/4 + … + 1/1000) ≈ 3 · (ln 1000 − 0,92) ≈ 18.

  1. У новой точки после вставки в среднем меньше шести рёбер, три из них появились при разрезании треугольника, остальные - каждый переворот добавляет одно ребро. Значит, переворотов в среднем меньше 6 − 3 = 3.

  2. Нельзя: порядок по x - фиксированный, и для него есть плохой вход (например, точки на дуге, где каждая следующая по x выходит за прежний круг). Гарантия держится именно на случайности.

Главное

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

Комментарии

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

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

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