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

Побудова опуклої оболонки

У цій статті ми розглянемо задачу побудови опуклої оболонки за заданою множиною точок.

Нехай на площині задано NN точок, і наша мета — побудувати опуклу оболонку, тобто найменший опуклий многокутник, що містить усі задані точки.

Ми розглянемо алгоритм обходу Грема (Graham's scan), опублікований 1972 року Гремом, а також алгоритм монотонного ланцюга (Monotone chain), опублікований 1979 року Ендрю. Обидва мають складність O(NlogN)\mathcal{O}(N \log N) і є асимптотично оптимальними (адже доведено, що не існує асимптотично кращого алгоритму), за винятком кількох задач, де йдеться про паралельну або онлайн-обробку.

Коли підходить цей алгоритм?
  • На вході множина точок на площині й потрібен найменший опуклий многокутник, що їх охоплює?
  • Тобі справді потрібна оболонка точок, а не оптимізація ДП виду minj(kjx+bj)\min_j(k_j x + b_j)? (якщо ні → Трюк опуклої оболонки)
  • Усі точки відомі заздалегідь (офлайн), тож достатньо одного проходу за O(NlogN)O(N \log N)?

Алгоритм обходу Грема

Спершу алгоритм знаходить найнижчу точку P0P_0. Якщо є кілька точок з однаковою координатою Y, береться та, що має меншу координату X. Цей крок виконується за O(N)\mathcal{O}(N).

Далі всі інші точки сортуються за полярним кутом за годинниковою стрілкою. Якщо полярний кут між двома або більше точками однаковий, нічию розв'язуємо за відстанню від P0P_0 у порядку зростання.

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

Ми використовуємо стек для зберігання точок, і щойно ми повертаємося до початкової точки P0P_0, алгоритм завершено, і ми повертаємо стек, що містить усі точки опуклої оболонки в порядку за годинниковою стрілкою.

Якщо під час обходу Грема потрібно врахувати колінеарні точки, знадобиться ще один крок після сортування. Потрібно взяти точки, що мають найбільшу полярну відстань від P0P_0 (вони мають бути в кінці відсортованого вектора) і є колінеарними. Точки на цій прямій слід розвернути, щоб ми могли вивести всі колінеарні точки, інакше алгоритм узяв би найближчу точку на цій прямій і зупинився б. Цей крок не слід включати в неколінеарну версію алгоритму, інакше ми не отримаємо найменшу опуклу оболонку.

Реалізація

struct pt {
double x, y;
bool operator == (pt const& t) const {
return x == t.x && y == t.y;
}
};

int orientation(pt a, pt b, pt c) {
double v = a.x*(b.y-c.y)+b.x*(c.y-a.y)+c.x*(a.y-b.y);
if (v < 0) return -1; // за годинниковою стрілкою
if (v > 0) return +1; // проти годинникової стрілки
return 0;
}

bool cw(pt a, pt b, pt c, bool include_collinear) {
int o = orientation(a, b, c);
return o < 0 || (include_collinear && o == 0);
}
bool collinear(pt a, pt b, pt c) { return orientation(a, b, c) == 0; }

void convex_hull(vector<pt>& a, bool include_collinear = false) {
pt p0 = *min_element(a.begin(), a.end(), [](pt a, pt b) {
return make_pair(a.y, a.x) < make_pair(b.y, b.x);
});
sort(a.begin(), a.end(), [&p0](const pt& a, const pt& b) {
int o = orientation(p0, a, b);
if (o == 0)
return (p0.x-a.x)*(p0.x-a.x) + (p0.y-a.y)*(p0.y-a.y)
< (p0.x-b.x)*(p0.x-b.x) + (p0.y-b.y)*(p0.y-b.y);
return o < 0;
});
if (include_collinear) {
int i = (int)a.size()-1;
while (i >= 0 && collinear(p0, a[i], a.back())) i--;
reverse(a.begin()+i+1, a.end());
}

vector<pt> st;
for (int i = 0; i < (int)a.size(); i++) {
while (st.size() > 1 && !cw(st[st.size()-2], st.back(), a[i], include_collinear))
st.pop_back();
st.push_back(a[i]);
}

if (include_collinear == false && st.size() == 2 && st[0] == st[1])
st.pop_back();

a = st;
}

Алгоритм монотонного ланцюга

Спершу алгоритм знаходить найлівішу та найправішу точки A і B. Якщо таких точок кілька, найнижчу серед лівих (з найменшою координатою Y) беремо за A, а найвищу серед правих (з найбільшою координатою Y) беремо за B. Очевидно, що і A, і B мають належати опуклій оболонці, оскільки вони найдальші й не можуть бути всередині жодної прямої, утвореної парою серед заданих точок.

Тепер проведемо пряму через AB. Вона ділить усі інші точки на дві множини, S1 і S2, де S1 містить усі точки над прямою, що сполучає A і B, а S2 містить усі точки під прямою, що з'єднує A і B. Точки, що лежать на прямій, яка з'єднує A і B, можуть належати будь-якій із множин. Точки A і B належать обом множинам. Тепер алгоритм будує верхню множину S1 і нижню множину S2, а потім об'єднує їх, щоб отримати відповідь.

Щоб отримати верхню множину, ми сортуємо всі точки за координатою x. Для кожної точки перевіряємо, чи виконується одне з двох — чи поточна точка є останньою (яку ми визначили як B), чи орієнтація між прямою від A до поточної точки та прямою від поточної точки до B є за годинниковою стрілкою. У цих випадках поточна точка належить верхній множині S1. Перевірити, чи поворот за годинниковою стрілкою, чи проти, можна за допомогою орієнтації.

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

Та сама логіка застосовується до нижньої множини S2. Якщо виконується одне з двох — поточна точка є B, або орієнтація прямих, утворених A і поточною точкою та поточною точкою і B, є проти годинникової стрілки — тоді вона належить S2.

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

Остаточна опукла оболонка отримується з об'єднання верхньої та нижньої опуклих оболонок, утворюючи оболонку за годинниковою стрілкою, а реалізація така.

Якщо вам потрібні колінеарні точки, достатньо перевіряти їх у процедурах cw/ccw. Однак це допускає вироджений випадок, коли всі вхідні точки колінеарні на одній прямій, і алгоритм видав би повторювані точки. Щоб це розв'язати, ми перевіряємо, чи верхня оболонка містить усі точки, і якщо так, ми просто повертаємо точки у зворотному порядку, бо саме це повернула б реалізація Грема в цьому випадку.

Реалізація

struct pt {
double x, y;
};

int orientation(pt a, pt b, pt c) {
double v = a.x*(b.y-c.y)+b.x*(c.y-a.y)+c.x*(a.y-b.y);
if (v < 0) return -1; // за годинниковою стрілкою
if (v > 0) return +1; // проти годинникової стрілки
return 0;
}

bool cw(pt a, pt b, pt c, bool include_collinear) {
int o = orientation(a, b, c);
return o < 0 || (include_collinear && o == 0);
}
bool ccw(pt a, pt b, pt c, bool include_collinear) {
int o = orientation(a, b, c);
return o > 0 || (include_collinear && o == 0);
}

void convex_hull(vector<pt>& a, bool include_collinear = false) {
if (a.size() == 1)
return;

sort(a.begin(), a.end(), [](pt a, pt b) {
return make_pair(a.x, a.y) < make_pair(b.x, b.y);
});
pt p1 = a[0], p2 = a.back();
vector<pt> up, down;
up.push_back(p1);
down.push_back(p1);
for (int i = 1; i < (int)a.size(); i++) {
if (i == a.size() - 1 || cw(p1, a[i], p2, include_collinear)) {
while (up.size() >= 2 && !cw(up[up.size()-2], up[up.size()-1], a[i], include_collinear))
up.pop_back();
up.push_back(a[i]);
}
if (i == a.size() - 1 || ccw(p1, a[i], p2, include_collinear)) {
while (down.size() >= 2 && !ccw(down[down.size()-2], down[down.size()-1], a[i], include_collinear))
down.pop_back();
down.push_back(a[i]);
}
}

if (include_collinear && up.size() == a.size()) {
reverse(a.begin(), a.end());
return;
}
a.clear();
for (int i = 0; i < (int)up.size(); i++)
a.push_back(up[i]);
for (int i = down.size() - 2; i > 0; i--)
a.push_back(down[i]);
}

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

Відеоматеріали