Перейти до основного вмісту

Мінімальне охоплююче коло

Розглянемо таку задачу:

інформація

Library Checker - Minimum Enclosing Circle

Вам задано n105n \leq 10^5 точок pi=(xi,yi)p_i=(x_i, y_i).

Для кожної точки pip_i визначте, чи лежить вона на колі мінімального охоплюючого кола множини {p1,,pn}\{p_1,\dots,p_n\}.

Тут під мінімальним охоплюючим колом (MEC) ми розуміємо коло з найменшим можливим радіусом, яке містить усі nn точок — усередині кола або на його межі. Ця задача має простий рандомізований розв'язок, який на перший погляд виглядає так, ніби працює за O(n3)O(n^3), але насправді виконується за очікуваний час O(n)O(n).

Коли підходить цей алгоритм?
  • Потрібне коло мінімального радіуса, що містить усі задані точки (всередині або на межі)?
  • Тобі потрібна охоплююча фігура у вигляді кола, а не опуклого многокутника? (якщо ні → Побудова опуклої оболонки)
  • Прийнятний рандомізований алгоритм з очікуваним лінійним часом O(n)O(n)?

Щоб краще зрозуміти міркування нижче, нам варто одразу зауважити, що розв'язок задачі єдиний:

Чому MEC єдине?

Розглянемо таку конструкцію. Нехай rr — радіус MEC. Навколо кожної з точок p1,,pnp_1,\dots,p_n намалюємо коло радіуса rr. Геометрично центри кіл, що мають радіус rr і покривають усі точки p1,,pnp_1,\dots,p_n, утворюють перетин усіх nn кіл.

Тепер, якщо цей перетин — лише одна точка, це вже доводить, що MEC єдине. Інакше перетин — це фігура ненульової площі, тож ми можемо трохи зменшити rr і все одно мати непорожній перетин, що суперечить припущенню про те, що rr був мінімально можливим радіусом охоплюючого кола.

За схожою логікою ми можемо також показати єдиність MEC, якщо додатково вимагати, щоб воно проходило через задану конкретну точку pip_i або через дві точки pip_i і pjp_j (воно теж єдине, бо його радіус однозначно його визначає).

Інакше можна також припустити, що існують два MEC, і тоді зауважити, що їхній перетин (який уже містить точки p1,,pnp_1,\dots,p_n) повинен мати менший діаметр, ніж початкові кола, а отже, його можна покрити меншим колом.

Алгоритм Вельцля

Для стислості позначимо через mec(p1,,pn)\operatorname{mec}(p_1,\dots,p_n) мінімальне охоплююче коло множини {p1,,pn}\{p_1,\dots,p_n\}, і нехай Pi={p1,,pi}P_i = \{p_1,\dots,p_i\}.

Алгоритм, який спершу запропонував Вельцль 1991 року, виглядає так:

  1. Застосуємо випадкову перестановку до вхідної послідовності точок.
  2. Підтримуємо поточного кандидата на MEC CC, починаючи з C=mec(p1,p2)C = \operatorname{mec}(p_1, p_2).
  3. Проходимо по i=3..ni=3..n і перевіряємо, чи piCp_i \in C.
    1. Якщо piCp_i \in C, це означає, що CC є MEC множини PiP_i.
    2. Інакше присвоюємо C=mec(pi,p1)C = \operatorname{mec}(p_i, p_1) і проходимо по j=2..ij=2..i, перевіряючи, чи pjCp_j \in C.
      1. Якщо pjCp_j \in C, то CC є MEC множини PjP_j серед кіл, що проходять через pip_i.
      2. Інакше присвоюємо C=mec(pi,pj)C=\operatorname{mec}(p_i, p_j) і проходимо по k=1..jk=1..j, перевіряючи, чи pkCp_k \in C.
        1. Якщо pkCp_k \in C, то CC є MEC множини PkP_k серед кіл, що проходять через pip_i і pjp_j.
        2. Інакше C=mec(pi,pj,pk)C=\operatorname{mec}(p_i,p_j,p_k) є MEC множини PkP_k серед кіл, що проходять через pip_i і pjp_j.

Ми бачимо, що кожен рівень вкладеності тут має інваріант, який треба підтримувати (а саме те, що CC є MEC серед кіл, які додатково проходять ще й через задані 00, 11 або 22 точки), і щоразу, коли внутрішній цикл завершується, його інваріант стає еквівалентним інваріанту поточної ітерації батьківського циклу. Це, своєю чергою, забезпечує коректність алгоритму в цілому.

Опускаючи поки що деякі технічні деталі, увесь алгоритм можна реалізувати на C++ так:

struct point {...};

// Представляється 2 або 3 точками на його колі
struct mec {...};

bool inside(mec const& C, point p) {
return ...;
}

// Виберіть якийсь хороший генератор випадковості для перемішування
mt19937_64 gen(...);
mec enclosing_circle(vector<point> &p) {
int n = p.size();
ranges::shuffle(p, gen);
auto C = mec{p[0], p[1]};
for(int i = 0; i < n; i++) {
if(!inside(C, p[i])) {
C = mec{p[i], p[0]};
for(int j = 0; j < i; j++) {
if(!inside(C, p[j])) {
C = mec{p[i], p[j]};
for(int k = 0; k < j; k++) {
if(!inside(C, p[k])) {
C = mec{p[i], p[j], p[k]};
}
}
}
}
}
}
return C;
}

Тепер можна сподіватися, що перевірку того, чи точка pip_i лежить усередині MEC з 22 або 33 точок, можна виконати за O(1)O(1) (це ми обговоримо пізніше). Але навіть тоді наведений вище алгоритм виглядає так, ніби він займатиме O(n3)O(n^3) у найгіршому випадку — лише через усі ці вкладені цикли. То чому ж ми стверджували, що очікуваний час роботи лінійний? Розберімося!

Аналіз складності

Для найвнутрішнішого циклу (по kk) очевидно, що його очікуваний час роботи становить O(j)O(j) операцій. А що з циклом по jj?

Він запускає наступний цикл лише тоді, коли pjp_j лежить на межі MEC множини PjP_j, яке також проходить через точку ii, і видалення pjp_j ще більше зменшило б коло. Серед усіх точок PjP_j може бути щонайбільше 22 точки з такою властивістю, бо якщо на межі є більш ніж 22 точки з PjP_j, це означає, що після видалення будь-якої з них на межі все одно залишиться щонайменше 33 точки, чого достатньо, щоб однозначно визначити коло.

Іншими словами, після початкового випадкового перемішування ймовірність того, що однією з щонайбільше двох невдалих точок виявиться pjp_j, не перевищує 2j\frac{2}{j}. Підсумувавши це по всіх jj від 11 до ii, отримуємо очікуваний час роботи

j=1i2jO(j)=O(i).\sum\limits_{j=1}^i \frac{2}{j} \cdot O(j) = O(i).

Точно так само ми тепер можемо довести, що найзовнішніший цикл має очікуваний час роботи O(n)O(n).

Перевірка того, чи точка лежить у MEC з 2 або 3 точок

Розберімося тепер з деталями реалізації point і mec. У цій задачі виявляється особливо зручним використовувати std::complex як клас для точок:

using ftype = int64_t;
using point = complex<ftype>;

Нагадаємо, що комплексне число — це число виду x+yix+yi, де i2=1i^2=-1 і x,yRx, y \in \mathbb R. У C++ таке комплексне число представляється двовимірною точкою (x,y)(x, y). Комплексні числа вже реалізують базові покомпонентні лінійні операції (додавання, множення на дійсне число), але також їх множення й ділення несуть певний геометричний зміст.

Не вдаючись надто в деталі, відзначимо найважливішу для цієї конкретної задачі властивість: множення двох комплексних чисел додає їхні полярні кути (відраховані від OxOx проти годинникової стрілки), а взяття спряженого числа (тобто перетворення z=x+yiz=x+yi на z=xyi\overline{z} = x-yi) множить полярний кут на 1-1. Це дозволяє нам сформулювати дуже прості критерії того, чи лежить точка zz усередині MEC з 22 або 33 конкретних точок.

MEC з 2 точок

Для 22 точок aa і bb їхнє MEC — це просто коло з центром у a+b2\frac{a+b}{2} і радіусом ab2\frac{|a-b|}{2}, іншими словами, коло, яке має abab як діаметр. Щоб перевірити, чи лежить zz усередині цього кола, нам просто треба перевірити, що кут між zaza і zbzb не є гострим.


Внутрішні кути тупі, зовнішні кути гострі, а кути на колі прямі

Еквівалентно, нам треба перевірити, що

I0=(bz)(az)I_0=(b-z)\overline{(a-z)}

не має додатної дійсної координати (що відповідає точкам із полярним кутом між 90-90^\circ і 9090^\circ).

MEC з 3 точок

Додавання zz до трикутника abcabc перетворить його на чотирикутник. Розглянемо такий вираз:

azb+bca\angle azb + \angle bca

У вписаному чотирикутнику, якщо cc і zz лежать з одного боку від abab, то кути рівні й у сумі дадуть 00^\circ, якщо підсумовувати їх зі знаком (тобто додатний, якщо проти годинникової стрілки, і від'ємний, якщо за годинниковою). Відповідно, якщо cc і zz лежать з протилежних боків, кути в сумі дадуть 180180^\circ.


Суміжні вписані кути однакові, протилежні кути доповнюються до 180 градусів

У термінах комплексних чисел можна зауважити, що azb\angle azb — це полярний кут числа (bz)(az)(b-z)\overline{(a-z)}, а bca\angle bca — це полярний кут числа (ac)(bc)(a-c)\overline{(b-c)}. Отже, можна зробити висновок, що azb+bca\angle azb + \angle bca — це полярний кут числа

I1=(bz)(az)(ac)(bc)I_1 = (b-z) \overline{(a-z)} (a-c) \overline{(b-c)}

Якщо кут дорівнює 00^\circ або 180180^\circ, це означає, що уявна частина I1I_1 дорівнює 00; інакше ми можемо визначити, чи лежить zz усередині або зовні охоплюючого кола трикутника abcabc, перевіривши знак уявної частини I1I_1. Додатна уявна частина відповідає додатним кутам, а від'ємна уявна частина — від'ємним кутам.

Але яка з них означає, що zz лежить усередині, а яка — що зовні кола? Як ми вже зауважили, коли zz усередині кола, це загалом збільшує модуль azb\angle azb, тоді як коли воно зовні кола — зменшує. Тож маємо такі 4 випадки:

  1. bca>0\angle bca > 0^\circ, cc з того самого боку від abab, що й zz. Тоді azb<0\angle azb < 0^\circ, і azb+bca<0\angle azb + \angle bca < 0^\circ для точок усередині кола.
  2. bca<0\angle bca < 0^\circ, cc з того самого боку від abab, що й zz. Тоді azb>0\angle azb > 0^\circ, і azb+bca>0\angle azb + \angle bca > 0^\circ для точок усередині кола.
  3. bca>0\angle bca > 0^\circ, cc з протилежного боку від abab відносно zz. Тоді azb>0\angle azb > 0^\circ, і azb+bca>180\angle azb + \angle bca > 180^\circ для точок усередині кола.
  4. bca<0\angle bca < 0^\circ, cc з протилежного боку від abab відносно zz. Тоді azb<0\angle azb < 0^\circ, і azb+bca<180\angle azb + \angle bca < 180^\circ для точок усередині кола.

Іншими словами, якщо bca\angle bca додатний, точки всередині кола матимуть azb+bca<0\angle azb + \angle bca < 0^\circ, інакше вони матимуть azb+bca>0\angle azb + \angle bca > 0^\circ, за умови, що ми нормалізуємо кути в межах від 180-180^\circ до 180180^\circ. Це, своєю чергою, можна перевірити за знаками уявних частин I2=(ac)(bc)I_2=(a-c)\overline{(b-c)} і I1=I0I2I_1 = I_0 I_2.

Зауваження: оскільки для отримання I1I_1 ми перемножуємо чотири комплексні числа, проміжні коефіцієнти можуть бути аж порядку O(A4)O(A^4), де AA — найбільший за модулем координат у вхідних даних. З хорошого боку, якщо вхідні дані цілочислові, обидві перевірки вище можна виконати повністю в цілих числах.

Реалізація

Тепер, щоб реально реалізувати перевірку, нам спершу слід вирішити, як представляти MEC. Оскільки наші критерії працюють безпосередньо з точками, природний та ефективний спосіб зробити це — сказати, що MEC безпосередньо представляється парою або трійкою точок, що його визначають:

using mec = variant<
array<point, 2>,
array<point, 3>
>;

Тепер ми можемо використати std::visit, щоб ефективно опрацювати обидва випадки відповідно до наведених вище критеріїв:

/* I < 0, якщо z всередині C,
I > 0, якщо z зовні C,
I = 0, якщо z на колі C */
ftype indicator(mec const& C, point z) {
return visit([&](auto &&C) {
point a = C[0], b = C[1];
point I0 = (b - z) * conj(a - z);
if constexpr (size(C) == 2) {
return real(I0);
} else {
point c = C[2];
point I2 = (a - c) * conj(b - c);
point I1 = I0 * I2;
return imag(I2) < 0 ? -imag(I1) : imag(I1);
}
}, C);
}

bool inside(mec const& C, point p) {
return indicator(C, p) <= 0;
}

Тепер ми можемо нарешті переконатися, що все працює, надіславши розв'язок задачі до Library Checker: #308668.

Задачі для практики