Skip to Content

Envolvente convexa

Introducción

La envolvente convexa (Convex Hull) es el subconjunto de puntos que forma el polígono convexo más pequeño que encierra a todos los puntos del conjunto. Para visualizarlo, imaginar que cada punto es un poste. Después, imaginar qué pasa si se envuelve una soga por fuera de todos los postes y se tira con fuerza infinita, de modo que las conexiones entre cualesquiera dos puntos que quedan sobre el borde de la soga son segmentos. El conjunto de puntos que tocan la soga es la envolvente convexa.

Visualización de la envolvente convexa

Convex Hull Visualization

HechoFuenteNombreDificultadTagsSolución
KattisConvex HullFácilConvexen el módulo

Con Graham Scan

Recursos
FuenteRecursoNotas
WikipediaGraham Scan
VisuAlgoGraham Scan Visualization
UCSDOriginal Graham Scan Paper

Video de YouTube (9rQMLpQn5xQ)

Solución

Recursos
FuenteRecursoNotas
BenqGraham Scan Implementation

El algoritmo de Graham Scan funciona en 3 pasos. Primero, ordena los nn puntos por su ángulo antihorario alrededor de un pivote P0P_0, desempatando por distancia. Este algoritmo usa como P0P_0 el punto más a la izquierda (y el más abajo si hay empate).

Mantenemos una pila que contiene los puntos de modo que se cumple el siguiente invariante: cada tres puntos consecutivos a,b,ca,b,c de la pila forman un giro antihorario. En otras palabras, cc queda a la izquierda de la recta de aa a bb. Esta condición implica que los puntos de la pila forman los vértices de un polígono convexo.

Para empezar a construir la envolvente convexa, elegimos 2 puntos. El pivote (primer punto) y el segundo punto según el ordenamiento inicial. Después de eso, intentamos agregar a la pila cada punto de cand\texttt{cand}.

Denotemos nuestra pila como hull\texttt{hull}, el elemento tope de hull\texttt{hull} como hull[i]\texttt{hull}[i] y cand[j]\texttt{cand}[j] como el jj-ésimo punto candidato ordenado. Antes de agregar cand[j]\texttt{cand}[j] a la pila, chequeamos si

hull[i1]hull[i]cand[j]\texttt{hull}[i-1] \rightarrow \texttt{hull}[i] \rightarrow \texttt{cand}[j]

forma un giro antihorario.

  • Si es así, entonces agregamos cand[j]\texttt{cand}[j] a la pila y el invariante se mantiene. Seguimos con cand[j+1]\texttt{cand}[j+1].
  • Si no, hull[i]\texttt{hull}[i] queda dentro de la envolvente convexa de los demás puntos de la pila junto con cand[j]\texttt{cand}[j], así que hacemos pop de hull[i]\texttt{hull}[i] de la pila y seguimos con cand[j]\texttt{cand}[j].
Ilustración

Illustration of Convex Hull

Ejemplo resuelto

Hallar la envolvente convexa de los puntos (1,2),(2,3),(5,3),(3,2),(2,0),(6,2)(1,2),(2,3),(5,3),(3,2),(2,0),(6,2).

Usando los pasos de nuestro Graham Scan:

  1. Ordenar los puntos por ángulo antihorario: nuestro punto más a la izquierda es (1,2)(1,2), así que ordenamos todos los puntos por su ángulo antihorario. Si los ángulos coinciden, desempatamos por distancia. Los puntos ordenados resultantes son (2,0),(6,2),(3,2),(5,3),(2,3)(2,0),(6,2),(3,2),(5,3),(2,3) con pivote inicial (1,2)(1,2). Notar que (6,2)(6,2) y (3,2)(3,2) son colineales, pero como (6,2)(6,2) está más lejos desempata y queda antes que (3,2)(3,2).

  2. Mantener la pila:

    Los elementos iniciales de la pila son (1,2),(2,0)(1,2),(2,0). Después, empezando desde cand[2]\texttt{cand}[2], procesamos cada punto.

    Primero, chequeamos si el giro entre (1,2)(1,2), (2,0)(2,0) y (3,2)(3,2) es antihorario, y lo es. Por lo tanto, el punto cand[2]=(3,2)\texttt{cand}[2]=(3,2) se inserta en la pila.

    La pila ahora corresponde a los puntos: (1,2),(2,0),(3,2)(1,2),(2,0),(3,2).

    Después, chequeamos el giro (2,0),(3,2),(6,2)(2,0),(3,2),(6,2), que es horario. Por lo tanto, hacemos pop de la pila hasta que se pueda satisfacer la propiedad antihoraria. En este caso, solo se saca (3,2)(3,2). Después, hacemos push de (6,2)(6, 2) a la pila porque satisface la propiedad antihoraria.

    La pila ahora corresponde a los puntos: (1,2),(2,0),(6,2)(1,2),(2,0),(6,2).

    Después, chequeamos si el giro entre (2,0)(2,0), (6,2)(6,2), (5,3)(5,3) es antihorario, y lo es, así que hacemos push de (5,3)(5,3) a la pila.

    La pila ahora corresponde a los puntos: (1,2),(2,0),(6,2),(5,3)(1,2),(2,0),(6,2),(5,3).

    Después, chequeamos si el giro entre (6,2),(5,3),(2,3)(6,2),(5,3),(2,3) es antihorario, y lo es, así que hacemos push de (2,3)(2,3) a la pila.

    Como ya procesamos todos los puntos, completamos el ciclo y la envolvente convexa está lista.

    La pila queda entonces (1,2),(2,0),(6,2),(5,3),(2,3)(1,2),(2,0),(6,2),(5,3),(2,3), que son los vértices de la envolvente convexa.

Otra visualización de envolvente convexa con más puntos se puede ver acá  (creada con GeoDeb ).

#include <bits/stdc++.h> using namespace std; #define FOR(i, a, b) for (int i = (a); i < (b); i++) #define FORE(i, a, b) for (int i = (a); i <= (b); i++) #define F0R(i, a) for (int i = 0; i < (a); i++) #define trav(a, x) for (auto &a : x) #define f first #define s second #define bk back() #define pb push_back #define sz(x) int((x).size()) #define bg(x) begin(x) #define all(x) bg(x), end(x) int N, Q; const int MX = 2e5 + 5; using T = long long; // or long long const T EPS = 1e-9; // might want to change using P = pair<T, T>; using vP = vector<P>; using Line = pair<P, P>; T sq(T a) { return a * a; } // square T norm(const P &p) { return sq(p.f) + sq(p.s); } // x^2 + y^2 // basic operations P operator-(const P &l, const P &r) { return P(l.f - r.f, l.s - r.s); } T cross(const P &a, const P &b) { return a.f * b.s - a.s * b.f; } // cross product T cross(const P &p, const P &a, const P &b) { // cross product return cross(a - p, b - p); } using vi = vector<int>; using vP = vector<P>; vi hullInd(const vP &v) { int ind = int(min_element(all(v)) - begin(v)); vi cand, hull{ind}; F0R(i, sz(v)) if (v[i] != v[ind]) cand.pb(i); sort(all(cand), [&](int a, int b) { // sort by angle, tiebreak by distance P x = v[a] - v[ind], y = v[b] - v[ind]; T t = cross(x, y); return t != 0 ? t > 0 : norm(x) < norm(y); }); trav(c, cand) { // for every point while (sz(hull) > 1 && cross(v[end(hull)[-2]], v[hull.bk], v[c]) <= 0) { hull.pop_back(); // pop until counterclockwise and size > 1 } hull.pb(c); } return hull; } int main() { ios_base::sync_with_stdio(0); cin.tie(0); for (;;) { int n; cin >> n; if (!n) break; vP pts(n); F0R(i, n) { long long a, b; cin >> a >> b; pts[i] = P(a, b); } vi ret = hullInd(pts); // gets hull indices of convex hull cout << sz(ret) << '\n'; trav(a, ret) { cout << pts[a].first << " " << pts[a].second << '\n'; } } }

Con cadena monótona

Recursos
FuenteRecursoNotas
CPHMonotone Chain
WikipediaMonotone Chain
BenqMonotone Chain Implementation

Solución

Con el algoritmo de cadena monótona (Monotone Chain), empezamos ordenando los nn puntos dados en orden creciente respecto de sus coordenadas xx. Si dos puntos tienen la misma coordenada xx, entonces miramos la coordenada yy.

Después, calculamos la envolvente convexa en dos partes: la envolvente superior y la envolvente inferior. Primero, observamos que los puntos inicial y final de ambas envolventes (superior e inferior) son los mismos. Son los puntos con menor y mayor valor de xx respectivamente, P0P_0 y Pn1P_{n-1}. Empezamos agregando P0P_0 y P1P_1 a la envolvente. (Notar que P1P_1 no tiene por qué quedar en la envolvente convexa al final). Después, empezando desde P2P_2, iteramos por los puntos ordenados y los agregamos a la envolvente. Denotemos el punto actual que se está agregando como PkP_{k} y el último punto que sigue en la envolvente como PiP_{i}. Al agregar puntos nuevos, queremos asegurarnos de que no hay giro a la derecha entre todos los segmentos de la envolvente, igual que en el algoritmo de Graham Scan de arriba. Para lograrlo, el segmento Pi1PiP_{i-1}P_i siempre debería quedar a la derecha del segmento Pi1PkP_{i-1}P_k. Esto se puede calcular usando un producto cruz:

  • Si (PiPi1)×(PkPi1)<0(P_i - P_{i-1}) \times (P_k - P_{i-1}) < 0, el punto PiP_i queda a la izquierda del segmento Pi1PkP_{i-1}P_k. En este caso, hay que sacar el punto PiP_i de la envolvente y repetir este chequeo.
  • Si (PiPi1)×(PkPi1)=0(P_i - P_{i-1}) \times (P_k - P_{i-1}) = 0, el punto PiP_i queda sobre el segmento Pi1PkP_{i-1}P_k. Si incluir varios puntos colineales depende del enunciado, pero para el problema de arriba también sacamos el punto PiP_i y repetimos el chequeo.
  • En caso contrario, el punto PiP_i queda a la derecha del segmento Pi1PkP_{i-1}P_k. En este caso, podemos agregar PkP_k a la envolvente y procesar el siguiente punto Pk+1P_{k+1} de la lista dada.

Después de procesar todos los puntos, tenemos la envolvente inferior y empezamos a hallar la envolvente superior de la misma forma. Esta vez, agregamos el punto Pn2P_{n-2} a la envolvente e iteramos desde el final de los puntos, Pn3P_{n-3}, hasta el punto inicial P0P_0. (El punto Pn2P_{n-2} tampoco tiene por qué quedar en la envolvente convexa y se puede sacar si provoca un giro a la derecha).

Al final, tenemos todos los puntos de la envolvente convexa en orden antihorario. Para hacerlo en orden horario, solo hay que cambiar la condición de (PiPi1)×(PkPi1)(P_i - P_{i-1}) \times (P_k - P_{i-1}) de mayor que 0 a menor que 0. En ese caso, se halla primero la envolvente superior y después la inferior.

Este algoritmo tarda O(nlogn)\mathcal{O}(n \log n) en ordenar los puntos y O(n)\mathcal{O}(n) en calcular la envolvente, lo que da una complejidad temporal final de O(nlogn)\mathcal{O}(n \log n).

Una animación de cómo funciona:

Ejemplo resuelto

Consideremos el mismo ejemplo del algoritmo de Graham Scan.

Hallar la envolvente convexa de los puntos (1,2),(2,3),(5,3),(3,2),(2,0),(6,2)(1,2),(2,3),(5,3),(3,2),(2,0),(6,2).

Siguiendo los pasos del algoritmo de cadena monótona:

  1. Ordenar los puntos dados primero por sus coordenadas xx y después por sus coordenadas yy. La lista ordenada sería (1,2)(1,2), (2,0)(2,0), (2,3)(2,3), (3,5)(3,5), (5,3)(5,3), (6,2)(6,2)

  2. Hallar la envolvente inferior:

    Primero, agregamos los dos primeros puntos a la envolvente. La lista que representa la envolvente ahora contendría estos dos elementos [(1,2),(2,0)][(1,2), (2,0)]. Empecemos a iterar por el resto de los puntos. Nuestro PkP_k ahora es (2,3)(2,3). El producto cruz, ((2,0)(1,2))×((2,3)(1,2))=(1,2)×(1,1)=(11)(21)=3((2,0) - (1,2)) \times ((2,3) - (1,2)) = (1,-2) \times (1,1) = (1 \cdot 1) - (-2 \cdot 1 ) = 3, es mayor que 0, lo que nos dice que no hay giro a la derecha, y podemos pasar al siguiente punto, (3,5)(3,5). La lista ahora contiene [(1,2),(2,0),(2,3)][(1,2), (2,0), (2,3)].

    El producto cruz ((2,0)(2,3))×((2,0)(3,5))=3((2,0) - (2,3)) \times ((2,0) - (3,5)) = -3 es menor que cero, lo que significa que hay un giro a la derecha de (2,0)(2,3)(2,0)(2,3) a (2,3)(3,5)(2,3)(3,5). Por lo tanto, sacamos el punto (2,3)(2,3) de la envolvente. La envolvente ahora contiene [(1,2),(2,0)][(1,2), (2,0)]. Después testeamos el producto cruz ((1,2)(2,0))×((1,2)(3,5))=7((1,2) - (2,0)) \times ((1,2) - (3,5)) = 7, que es positivo, así que agregamos el punto (3,5)(3,5) a la envolvente. La envolvente ahora contiene [(1,2),(2,0),(3,5)][(1,2), (2,0), (3,5)].

    Seguimos testeando los puntos restantes. Queremos saber si hay un giro a la derecha de (2,0)(3,5)(2,0)(3,5) a (3,5)(5,3)(3,5)(5,3), y lo hay, así que sacamos el punto (3,5)(3,5). Después testeamos los dos segmentos, (1,2)(2,0)(1,2)(2,0) y (2,0)(5,3)(2,0)(5,3). Hay un giro a la izquierda, así que agregamos el punto (5,3)(5,3) y pasamos al siguiente punto. Nuestra lista queda así: [(1,2),(2,0),(5,3)][(1,2), (2,0), (5,3)].

    Después, testeamos los segmentos (2,0)(5,3)(2,0)(5,3) y (5,3)(6,2)(5,3)(6,2). El producto cruz ((5,3)(2,0))×((6,2)(2,0))((5,3) - (2,0)) \times ((6,2) - (2,0)) es negativo, así que hay un giro a la derecha. Sacamos (5,3)(5,3) de la lista y ahora testeamos los segmentos (1,2)(2,0)(1,2)(2,0) y (2,0)(6,2)(2,0)(6,2). Hay un giro a la izquierda, así que agregamos el punto (6,2)(6,2) a la envolvente. La envolvente ahora contiene [(1,2),(2,0),(6,2)][(1,2), (2,0), (6,2)]. Como el punto (6,2)(6,2) es el último elemento de la lista, hallamos la envolvente inferior del conjunto de puntos dado.

  3. Hallar la envolvente superior:

    Primero agregamos el punto (5,3)(5,3) al final de la lista y procesamos el punto (3,5)(3,5). El producto cruz para ((6,2)(5,3))×((6,2)(3,5))((6,2) - (5,3)) \times ((6,2) - (3,5)) es cero, lo que significa que estos tres puntos son colineales. Por lo tanto, sacamos el punto (5,3)(5,3) de la lista. Como no quedan suficientes puntos en la envolvente superior, agregamos el punto (3,5)(3,5) a la lista. La envolvente contiene [(1,2),(2,0),(6,2),(3,5)][(1,2), (2,0), (6,2), (3,5)]. Notar que los puntos [(1,2),(2,0),(6,2)][(1,2), (2,0), (6,2)] pertenecen a la envolvente inferior y el punto (6,2)(6,2) como punto final también pertenece a la envolvente superior.

    Después procesamos el siguiente punto (2,3)(2,3). Los segmentos (6,2)(3,5)(6,2)(3,5) y (3,5)(2,3)(3,5)(2,3) forman un giro a la izquierda, así que podemos agregar el punto (2,3)(2,3) a la envolvente. La lista contiene [(1,2),(2,0),(6,2),(3,5),(2,3)][(1,2), (2,0), (6,2), (3,5), (2,3)].

    El siguiente punto es (2,0)(2,0). Los segmentos (3,5)(2,3)(3,5)(2,3) y (2,3)(2,0)(2,3)(2,0) forman un giro a la izquierda, así que podemos agregar el punto (2,3)(2,3) a la envolvente. La lista contiene [(1,2),(2,0),(6,2),(3,5),(2,3),(2,0)][(1,2), (2,0), (6,2), (3,5), (2,3), (2,0)].

    El siguiente punto es (1,2)(1,2). Hay un giro a la derecha de (2,3)(2,0)(2,3)(2,0) a (2,0)(1,2)(2,0)(1,2), así que sacamos el punto (2,0)(2,0) de la lista. La lista contiene [(1,2),(2,0),(6,2),(3,5),(2,3)][(1,2), (2,0), (6,2), (3,5), (2,3)]. Testeamos de nuevo los segmentos (3,5)(2,3)(3,5)(2,3) y (2,3)(1,2)(2,3)(1,2). Hay un giro a la derecha, así que sacamos el punto (2,3)(2,3). La lista contiene [(1,2),(2,0),(6,2),(3,5)][(1,2), (2,0), (6,2), (3,5)]. Testeamos otra vez los segmentos (6,2)(3,5)(6,2)(3,5) y (3,5)(1,2)(3,5)(1,2). Hay un giro a la izquierda, así que agregamos el punto (1,2)(1,2) a la lista. Llegamos al comienzo de la lista, así que podemos terminar acá. Como (1,2)(1,2) ya se había agregado al calcular la envolvente inferior, sacamos el último elemento, es decir el punto agregado (1,2)(1,2), de la lista. Ahora también tenemos calculada la envolvente superior. El resultado final es [(1,2),(2,0),(6,2),(3,5)][(1,2), (2,0), (6,2), (3,5)]. Estos son los vértices de nuestra envolvente convexa en orden antihorario.

#include <bits/stdc++.h> using namespace std; using pii = pair<int, int>; vector<pii> points; vector<pii> hull; // producto cruz, el área con signo de estos tres puntos int area(pii O, pii P, pii Q) { return (P.first - O.first) * (Q.second - O.second) - (P.second - O.second) * (Q.first - O.first); } void monotone_chain() { // ordenar respecto de las coordenadas x e y sort(points.begin(), points.end()); // dejar los puntos distintos points.erase(unique(points.begin(), points.end()), points.end()); int n = points.size(); // 1 o 2 puntos siempre están en la envolvente convexa if (n < 3) { hull = points; return; } // envolvente inferior for (int i = 0; i < n; i++) { // si con el nuevo punto points[i] se forma un giro a la // derecha, sacamos el último punto de la envolvente y // seguimos testeando while (hull.size() > 1 && area(hull[hull.size() - 2], hull.back(), points[i]) <= 0) hull.pop_back(); // si no, agregar el punto a la envolvente hull.push_back(points[i]); } // envolvente superior, con la misma lógica que la inferior auto lower_hull_length = hull.size(); for (int i = n - 2; i >= 0; i--) { // solo podemos sacar un punto si todavía quedan puntos en la // envolvente superior while (hull.size() > lower_hull_length && area(hull[hull.size() - 2], hull.back(), points[i]) <= 0) hull.pop_back(); hull.push_back(points[i]); } // borrar point[0], que se agregó dos veces hull.pop_back(); } int main() { cin.tie(0)->sync_with_stdio(false); int n; cin >> n; while (n != 0) { points.assign(n, {}); hull = {}; for (auto &p : points) cin >> p.first >> p.second; monotone_chain(); cout << hull.size() << "\n"; for (auto &p : hull) cout << p.first << " " << p.second << "\n"; cin >> n; } return 0; }
import java.io.*; import java.util.*; public class ConvexHull { public static void main(String[] args) throws IOException { BufferedReader in = new BufferedReader(new InputStreamReader(System.in)); int N = Integer.parseInt(in.readLine()); while (N > 0) { // usar un hashset para dejar los puntos distintos Set<V2> input = new HashSet<V2>(N); for (int i = 0; i < N; i++) { StringTokenizer st = new StringTokenizer(in.readLine()); input.add(new V2(Integer.parseInt(st.nextToken()), Integer.parseInt(st.nextToken()))); } V2[] points = new V2[input.size()]; input.toArray(points); var hull = monotoneChain(points); System.out.println(hull.size()); for (V2 p : hull) { System.out.println(String.format("%d %d", p.X, p.Y)); } N = Integer.parseInt(in.readLine()); } in.close(); } // calcular la envolvente convexa de los puntos dados en orden antihorario private static List<V2> monotoneChain(V2[] P) { // para un conjunto con menos de 3 puntos, el conjunto de esos // puntos ya es la envolvente convexa if (P.length < 3) { return Arrays.asList(P); } List<V2> hull = new ArrayList<V2>(); // ordenar los puntos por su valor de x en orden creciente. En // caso de empate, ordenar por sus valores de y Arrays.sort(P); // envolvente inferior for (int i = 0; i < P.length; i++) { /* * si el último punto de hull está a la izquierda del * punto actual, es decir forma un giro a la derecha, * entonces podemos sacar el último punto hasta que solo * queden giros a la izquierda */ while (hull.size() > 1 && sign(hull.get(hull.size() - 2), hull.get(hull.size() - 1), P[i]) <= 0) hull.remove(hull.size() - 1); hull.add(P[i]); } // envolvente superior int k = hull.size(); for (int i = P.length - 2; i >= 0; i--) { while (hull.size() > k && sign(hull.get(hull.size() - 2), hull.get(hull.size() - 1), P[i]) <= 0) { hull.remove(hull.size() - 1); } hull.add(P[i]); } // El primer punto se agregó dos veces, así que lo sacamos hull.remove(hull.size() - 1); return hull; } /* * devuelve -1 si el segmento OP está a la izquierda del segmento OQ; * y 1 en el caso contrario; 0 si O, P y Q son colineales. */ private static int sign(V2 O, V2 P, V2 Q) { int area = area(O, P, Q); if (area < 0) return -1; else if (area > 0) return 1; return 0; } // truco del producto cruz, el área con signo de estos tres puntos private static int area(V2 O, V2 P, V2 Q) { return P.minus(O).cross(Q.minus(O)); } private static class V2 implements Comparable<V2> { public int X; public int Y; public V2(int x, int y) { X = x; Y = y; } public V2 minus(V2 other) { return new V2(X - other.X, Y - other.Y); } // producto "cruz" (wedge) de un vector 2d. si no se está // familiarizado con esto, conviene investigar primero public int cross(V2 o) { return X * o.Y - Y * o.X; } @Override public int compareTo(ConvexHull.V2 o) { int r = X - o.X; if (r == 0) return Y - o.Y; return r; } @Override public boolean equals(Object obj) { if (obj == null || !getClass().equals(obj.getClass())) return false; return ((V2)obj).X == this.X && ((V2)obj).Y == this.Y; } @Override public int hashCode() { int hash = 17; hash = hash * 13 + X; hash = hash * 13 + Y; return hash; } } }

Calibradores rotatorios

HechoFuenteNombreDificultadTagsSolución
KattisRobert HoodFácilConvex

Solución

Problemas

HechoFuenteNombreDificultadTagsSolución
CFWater BalanceFácilConvex
PlatinumBalance BeamNormalConvex
CFGeometers AnonymousNormalConvex, PURS
Old GoldCow CurlingNormalConvex
KattisFence OrthogonalityDifícilConvex
Balkan OI2011 - TimeismoneyDifícilMST, ConvexSolución
ACAGC E - Random PawnMuy difícilConvex