Мінімальне охоплююче коло
Розглянемо таку задачу:
Library Checker - Minimum Enclosing Circle
Вам задано точок .
Для кожної точки визначте, чи лежить вона на колі мінімального охоплюючого кола множини .
Тут під мінімальним охоплюючим колом (MEC) ми розуміємо коло з найменшим можливим радіусом, яке містить усі точок — усередині кола або на його межі. Ця задача має простий рандомізований розв'язок, який на перший погляд виглядає так, ніби працює за , але насправді виконується за очікуваний час .
- Потрібне коло мінімального радіуса, що містить усі задані точки (всередині або на межі)?
- Тобі потрібна охоплююча фігура у вигляді кола, а не опуклого многокутника? (якщо ні → Побудова опуклої оболонки)
- Прийнятний рандомізований алгоритм з очікуваним лінійним часом ?
Щоб краще зрозуміти міркування нижче, нам варто одразу зауважити, що розв'язок задачі єдиний:
Чому MEC єдине?
Розглянемо таку конструкцію. Нехай — радіус MEC. Навколо кожної з точок намалюємо коло радіуса . Геометрично центри кіл, що мають радіус і покривають усі точки , утворюють перетин усіх кіл.
Тепер, якщо цей перетин — лише одна точка, це вже доводить, що MEC єдине. Інакше перетин — це фігура ненульової площі, тож ми можемо трохи зменшити і все одно мати непорожній перетин, що суперечить припущенню про те, що був мінімально можливим радіусом охоплюючого кола.
За схожою логікою ми можемо також показати єдиність MEC, якщо додатково вимагати, щоб воно проходило через задану конкретну точку або через дві точки і (воно теж єдине, бо його радіус однозначно його визначає).
Інакше можна також припустити, що існують два MEC, і тоді зауважити, що їхній перетин (який уже містить точки ) повинен мати менший діаметр, ніж початкові кола, а отже, його можна покрити меншим колом.
Алгоритм Вельцля
Для стислості позначимо через мінімальне охоплююче коло множини , і нехай .
Алгоритм, який спершу запропонував Вельцль 1991 року, виглядає так:
- Застосуємо випадкову перестановку до вхідної послідовності точок.
- Підтримуємо поточного кандидата на MEC , починаючи з .
- Проходимо по і перевіряємо, чи .
- Якщо , це означає, що є MEC множини .
- Інакше присвоюємо і проходимо по , перевіряючи, чи .
- Якщо , то є MEC множини серед кіл, що проходять через .
- Інакше присвоюємо і проходимо по , перевіряючи, чи .
- Якщо , то є MEC множини серед кіл, що проходять через і .
- Інакше є MEC множини серед кіл, що проходять через і .
Ми бачимо, що кожен рівень вкладеності тут має інваріант, який треба підтримувати (а саме те, що є MEC серед кіл, які додатково проходять ще й через задані , або точки), і щоразу, коли внутрішній цикл завершується, його інваріант стає еквівалентним інваріанту поточної ітерації батьківського циклу. Це, своєю чергою, забезпечує коректність алгоритму в цілому.
Опускаючи поки що деякі технічні деталі, увесь алгоритм можна реалізувати на C++ так:
- C++
- Python
- TypeScript
- Go
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;
}
# point — це ...
# mec представляється 2 або 3 точками на його колі
def inside(C, p):
return ...
# Виберіть якийсь хороший генератор випадковості для перемішування
rng = random.Random(...)
def enclosing_circle(p):
n = len(p)
rng.shuffle(p)
C = (p[0], p[1])
for i in range(n):
if not inside(C, p[i]):
C = (p[i], p[0])
for j in range(i):
if not inside(C, p[j]):
C = (p[i], p[j])
for k in range(j):
if not inside(C, p[k]):
C = (p[i], p[j], p[k])
return C
// Point — це ...
// Mec представляється масивом із 2 або 3 точок на його колі
type Mec = Point[];
function inside(C: Mec, p: Point): boolean {
return ...;
}
// Виберіть якийсь хороший генератор випадковості для перемішування
const rnd = makeRng(...);
function enclosingCircle(p: Point[]): Mec {
const n = p.length;
shuffle(p, rnd);
let C: Mec = [p[0], p[1]];
for (let i = 0; i < n; i++) {
if (!inside(C, p[i])) {
C = [p[i], p[0]];
for (let j = 0; j < i; j++) {
if (!inside(C, p[j])) {
C = [p[i], p[j]];
for (let k = 0; k < j; k++) {
if (!inside(C, p[k])) {
C = [p[i], p[j], p[k]];
}
}
}
}
}
}
return C;
}
// point — це ...
// mec представляється зрізом із 2 або 3 точок на його колі
type mec = []point
func inside(C mec, p point) bool {
return ...
}
// Виберіть якийсь хороший генератор випадковості для перемішування
func enclosingCircle(p []point, rng *rand.Rand) mec {
n := len(p)
rng.Shuffle(n, func(i, j int) { p[i], p[j] = p[j], p[i] })
C := mec{p[0], p[1]}
for i := 0; i < n; i++ {
if !inside(C, p[i]) {
C = mec{p[i], p[0]}
for j := 0; j < i; j++ {
if !inside(C, p[j]) {
C = mec{p[i], p[j]}
for k := 0; k < j; k++ {
if !inside(C, p[k]) {
C = mec{p[i], p[j], p[k]}
}
}
}
}
}
}
return C
}
Тепер можна сподіватися, що перевірку того, чи точка лежить усередині MEC з або точок, можна виконати за (це ми обговоримо пізніше). Але навіть тоді наведений вище алгоритм виглядає так, ніби він займатиме у найгіршому випадку — лише через усі ці вкладені цикли. То чому ж ми стверджували, що очікуваний час роботи лінійний? Розберімося!
Аналіз складності
Для найвнутрішнішого циклу (по ) очевидно, що його очікуваний час роботи становить операцій. А що з циклом по ?
Він запускає наступний цикл лише тоді, коли лежить на межі MEC множини , яке також проходить через точку , і видалення ще більше зменшило б коло. Серед усіх точок може бути щонайбільше точки з такою властивістю, бо якщо на межі є більш ніж точки з , це означає, що після видалення будь-якої з них на межі все одно залишиться щонайменше точки, чого достатньо, щоб однозначно визначити коло.
Іншими словами, після початкового випадкового перемішування ймовірність того, що однією з щонайбільше двох невдалих точок виявиться , не перевищує . Підсумувавши це по всіх від до , отримуємо очікуваний час роботи
Точно так само ми тепер можемо довести, що найзовнішніший цикл має очікуваний час роботи .
Перевірка того, чи точка лежить у MEC з 2 або 3 точок
Розберімося тепер з деталями реалізації point і mec. У цій задачі виявляється особливо зручним використовувати std::complex як клас для точок:
- C++
- Python
- TypeScript
- Go
using ftype = int64_t;
using point = complex<ftype>;
# У Python вбудований тип complex уже представляє двовимірну точку x + yi
# (його координати — числа з рухомою комою, але для цілих координат
# проміжні обчислення з порядком O(A^4) лишаються точними).
Point = complex
// У TypeScript комплексних чисел немає, тому реалізуємо точку як пару (x, y)
// і власні операції множення/спряження нижче.
type Point = { x: number; y: number };
// У Go вбудований тип complex128 уже представляє двовимірну точку x + yi
type point = complex128
Нагадаємо, що комплексне число — це число виду , де і . У C++ таке комплексне число представляється двовимірною точкою . Комплексні числа вже реалізують базові покомпонентні лінійні операції (додавання, множення на дійсне число), але також їх множення й ділення несуть певний геометричний зміст.
Не вдаючись надто в деталі, відзначимо найважливішу для цієї конкретної задачі властивість: множення двох комплексних чисел додає їхні полярні кути (відраховані від проти годинникової стрілки), а взяття спряженого числа (тобто перетворення на ) множить полярний кут на . Це дозволяє нам сформулювати дуже прості критерії того, чи лежить точка усередині MEC з або конкретних точок.
MEC з 2 точок
Для точок і їхнє MEC — це просто коло з центром у і радіусом , іншими словами, коло, яке має як діаметр. Щоб перевірити, чи лежить усередині цього кола, нам просто треба перевірити, що кут між і не є гострим.
Внутрішні кути тупі, зовнішні кути гострі, а кути на колі прямі
Еквівалентно, нам треба перевірити, що
не має додатної дійсної координати (що відповідає точкам із полярним кутом між і ).
MEC з 3 точок
Додавання до трикутника перетворить його на чотирикутник. Розглянемо такий вираз:
У вписаному чотирикутнику, якщо і лежать з одного боку від , то кути рівні й у сумі дадуть , якщо підсумовувати їх зі знаком (тобто додатний, якщо проти годинникової стрілки, і від'ємний, якщо за годинниковою). Відповідно, якщо і лежать з протилежних боків, кути в сумі дадуть .
Суміжні вписані кути однакові, протилежні кути доповнюються до 180 градусів
У термінах комплексних чисел можна зауважити, що — це полярний кут числа , а — це полярний кут числа . Отже, можна зробити висновок, що — це полярний кут числа
Якщо кут дорівнює або , це означає, що уявна частина дорівнює ; інакше ми можемо визначити, чи лежить усередині або зовні охоплюючого кола трикутника , перевіривши знак уявної частини . Додатна уявна частина відповідає додатним кутам, а від'ємна уявна частина — від'ємним кутам.
Але яка з них означає, що лежить усередині, а яка — що зовні кола? Як ми вже зауважили, коли усередині кола, це загалом збільшує модуль , тоді як коли воно зовні кола — зменшує. Тож маємо такі 4 випадки:
- , з того самого боку від , що й . Тоді , і для точок усередині кола.
- , з того самого боку від , що й . Тоді , і для точок усередині кола.
- , з протилежного боку від відносно . Тоді , і для точок усередині кола.
- , з протилежного боку від відносно . Тоді , і для точок усередині кола.
Іншими словами, якщо додатний, точки всередині кола матимуть , інакше вони матимуть , за умови, що ми нормалізуємо кути в межах від до . Це, своєю чергою, можна перевірити за знаками уявних частин і .
Зауваження: оскільки для отримання ми перемножуємо чотири комплексні числа, проміжні коефіцієнти можуть бути аж порядку , де — найбільший за модулем координат у вхідних даних. З хорошого боку, якщо вхідні дані цілочислові, обидві перевірки вище можна виконати повністю в цілих числах.
Реалізація
Тепер, щоб реально реалізувати перевірку, нам спершу слід вирішити, як представляти MEC. Оскільки наші критерії працюють безпосередньо з точками, природний та ефективний спосіб зробити це — сказати, що MEC безпосередньо представляється парою або трійкою точок, що його визначають:
- C++
- Python
- TypeScript
- Go
using mec = variant<
array<point, 2>,
array<point, 3>
>;
# mec — це просто кортеж із 2 або 3 точок; розрізняємо випадки за len()
# (наприклад, (a, b) або (a, b, c))
// mec — це масив із 2 або 3 точок; розрізняємо випадки за C.length
type Mec = Point[];
// mec — це зріз із 2 або 3 точок; розрізняємо випадки за len(C)
type mec = []point
Тепер ми можемо використати std::visit, щоб ефективно опрацювати обидва випадки відповідно до наведених вище критеріїв:
- C++
- Python
- TypeScript
- Go
/* 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;
}
# I < 0, якщо z всередині C; I > 0, якщо z зовні C; I = 0, якщо z на колі C
def indicator(C, z):
a, b = C[0], C[1]
I0 = (b - z) * (a - z).conjugate()
if len(C) == 2:
return I0.real
c = C[2]
I2 = (a - c) * (b - c).conjugate()
I1 = I0 * I2
return -I1.imag if I2.imag < 0 else I1.imag
def inside(C, p):
return indicator(C, p) <= 0
// Операції над комплексними числами (множення додає полярні кути,
// спряження множить кут на -1)
const sub = (a: Point, b: Point): Point => ({ x: a.x - b.x, y: a.y - b.y });
const mul = (a: Point, b: Point): Point => ({
x: a.x * b.x - a.y * b.y,
y: a.x * b.y + a.y * b.x,
});
const conj = (a: Point): Point => ({ x: a.x, y: -a.y });
// I < 0, якщо z всередині C; I > 0, якщо z зовні C; I = 0, якщо z на колі C
function indicator(C: Mec, z: Point): number {
const a = C[0], b = C[1];
const I0 = mul(sub(b, z), conj(sub(a, z)));
if (C.length === 2) {
return I0.x;
}
const c = C[2];
const I2 = mul(sub(a, c), conj(sub(b, c)));
const I1 = mul(I0, I2);
return I2.y < 0 ? -I1.y : I1.y;
}
const inside = (C: Mec, p: Point): boolean => indicator(C, p) <= 0;
// I < 0, якщо z всередині C; I > 0, якщо z зовні C; I = 0, якщо z на колі C
func indicator(C mec, z point) float64 {
a, b := C[0], C[1]
I0 := (b - z) * cmplx.Conj(a-z)
if len(C) == 2 {
return real(I0)
}
c := C[2]
I2 := (a - c) * cmplx.Conj(b-c)
I1 := I0 * I2
if imag(I2) < 0 {
return -imag(I1)
}
return imag(I1)
}
func inside(C mec, p point) bool {
return indicator(C, p) <= 0
}
Тепер ми можемо нарешті переконатися, що все працює, надіславши розв'язок задачі до Library Checker: #308668.