아래의 문제를 보자.
D5 선분 교차 4: https://www.acmicpc.net/problem/20150
평면 상에 개의 선분이 있을 때, 이 중 서로 교차하는 것이 있는지 확인하는 문제다.
나이브하게 생각하면 모든 선분들의 쌍을 비교하는 방식으로, 의 시간복잡도로 풀 수 있겠지만, 위 문제와 같이 이 수 십만이 되면 당연히 쓸 수 없게 돼버린다.
샤모스-호이 알고리즘은 평면 상에 개의 선분이 있을 때, 이 중 서로 교차하는 것이 있는지를 의 시간에 판별하는 알고리즘이다.
한 직선 위에 개의 구간이 있고, 이 중 서로 겹치는 구간이 있는지를 물어보는 문제를 생각해보자. 이 문제는 각 구간의 쌍들을 비교하는 방식으로 에 해결할 수 있지만, 각 구간을 적절하게 정렬한다면, 의 시간으로, 훨씬 더 빠르게 풀 수도 있다.
각 구간이 과 같은 형식으로 주어진다고 해보자(단 이고 ). 이 구간들을 왼쪽 끝점()을 기준으로 오름차순으로, 만약 왼쪽 끝점이 같다면 오른쪽 끝점을 기준으로 오름차순으로 정렬하자.
현재 구간을 담을 수 있는 어떤 자료구조가 있다고 하자. 이제 맨 왼쪽에서 오른쪽으로 훑으면서, 왼쪽 끝점이 나오면 자료구조에 해당 구간을 넣고, 오른쪽 끝점이 나오면 해당 구간을 자료구조에서 뺀다. 만약 아직 자료구조에 어떤 구간이 담겨 있는데 새로운 왼쪽 끝점을 만나게 되면 두 구간이 겹친다는 의미다.
개의 끝점들을 정렬하는 데 의 시간이 걸리고, 정렬 후 개의 점을 모두 훑어보는 데에는 의 시간이, 자료구조에 구간을 넣고 빼는 데에는 의 시간이 걸리니, 시간복잡도는 이다.
(파란 선분들은 보기 쉽게 나타낸 것으로, 사실 모두 한 직선 위의 구간들이다.)
위에서 1차원 직선 상 구간들을 정렬함으로써 의 시간복잡도를 으로 줄인 것처럼, 원래 문제였던 2차원 평면에서도 주어진 직선들을 어떻게 잘 정렬하면 의 시간복잡도를 으로 줄일 수 있다. 다만 앞서와 같이 단순히 양 끝점을 사전순으로 정렬하는 것으로는 문제를 풀 수 없고, 직선들에 다른 방법으로 순서를 매길 새로운 정렬 기준이 필요하다.
이제 2차원 평면에 개의 선분들이 주어진다고 하자. 단, 일단은 다음을 가정한다.
평면 상에 있는, 두 선분 에 대해 다음과 같은 관계를 정의하자.
평면 상의 두 선분 에 대해, 두 선분 모두와 교차하는 수직선 가 존재하면, 두 선분은 에서 비교가능(comparable)하다.
비교가능한 두 선분 와 그때의 수직선 에 대해, 와 의 교점의 좌표가 와 의 교점의 좌표보다 크거나 같다면 는 에서 보다 위에 있다고 하며, 다음과 같이 표기한다.
어떤 수직선 에 대해, 와 교차하는 모든 선분들은 서로 비교가능하고, 는 추이성을 만족한다.
아래와 같이 선분들이 주어졌다고 하자.
각 선분의 관계를 보면 는 와 에서 비교가능하고, 다. 는 어떤 선분과도 비교가능하지 않다. 가 추이성을 만족하니, 이고 이면 임도 확인할 수 있다.
이제 맨 왼쪽에서부터 오른쪽으로 훑어 나가보자.
아래는 어떤 선분이 위에 있고 어떤 선분이 아래에 있는지를 나타낸다.
여기서 관찰할 수 있는 중요한 두 가지가 있다. 첫 번째는 교차하는 두 선분의 경우, 교차점을 기준으로 그 순서가 서로 바뀐다는 것이고, 두 번째는 두 선분이 서로 교차한다면 반드시 두 선분이 순서상 서로 연속적(consecutive)으로 나타나는 수직선 가 존재한다는 점이다(세 선분이 한 점에서 만나는 경우는 없다고 가정하는 경우).
선분 를 하나 더 추가하면 더 잘 보인다.
이제 우리가 해야할 일이 명확해지는데, 각 구간마다 위-아래 순서를 유지하면서 선분을 넣고 뺄 수 있는 자료구조를 마련하는 일이다.
다만 문제가 하나 있는데, 위와 같이 선분들의 순서가 바뀌는 지점을 알아내려면 각 교점들의 위치도 미리 알고 있어야 한다는 점이다. 교점들의 개수는 개가 되니, 개 선분의 교차 여부를 시간에 알아내고자 하는 목표에는 전혀 도움이 되지 않는다.
다행히 위의 두 번째, 두 선분이 서로 교차하면 반드시 두 선분이 순서상 서로 연속적으로 나타나는 가 존재한다는 점을 이용하면 각 교점들의 위치들을 모두 알지 못하더라도 개의 선분 중 교차하는 것이 있는지의 여부를 빠르게 알 수 있다.
각 선분들의 위-아래 순서를 유지하면서 선분을 넣고 뺄 수 있는 자료구조 가 있다고 하자. 단 를 통해 다음의 연산들을 수행할 수 있어야 한다.
i. INSERT(s, T): 선분 s를 T에 삽입한다.
ii. DELETE(s, T): T에서 선분 s를 삭제한다.
iii. ABOVE(s, T): T에 담긴 선분들 중 s 바로 위에 있는 선분을 반환한다.
iv. BELOW(s, T): T의 선분들 중 s 바로 아래에 있는 선분을 반환한다.
직선 상에서 개 구간을 관리할 때와 마찬가지로, 개의 끝점들을 왼쪽에서 오른쪽으로 정렬한 후, 맨 왼쪽에서 오른쪽으로 스위핑을 하면서 왼쪽 끝점을 만나면 에 해당 선분을 삽입하고, 오른쪽 끝점을 만나면 에서 해당 선분을 제거한다.
선분을 삽입하거나 제거할 때마다, 새롭게 순서상 연속이 되는 선분의 쌍이 생기게 되는데, 이 선분들이 서로 교차하는지를 확인하면 된다.
SHAMOS-HOEY ALGORITHM (M.I.Shamos and D.Hoey, 1976를 일부 수정)
SORT the endpoints lexicographically on x and y so that POINT[1] is leftmost and POINT[2N] is rightmosts.
FOR I := UNTIL 2N DO BEGIN
P := POINT[I] // Let S be the segment of which P is an endpoint.
IF P is the left endpoint of S THEN BEGIN
INSERT(S, T);
A := ABOVE(S, T);
B := BELOW(S, T);
IF A intersects S THEN RETURN TRUE;
IF B intersects S THEN RETURN TRUE;
END;
ELSE (P is the right endpoint of S) BEGIN
A := ABOVE(S, T);
B := BELOW(S, T);
IF A intersects B THEN RETURN FALSE;
DELETE(S, T);
END;
END;
RETURN FALSE;
왼쪽 끝점을 만나면 해당 선분 를 에 넣는다. 이때 는 바로 위에 있는 선분과 순서상 연속이 되고, 바로 아래에 있는 선분과도 순서상 연속이 된다. 위아래의 각 선분과 교차하는지를 확인하고, 만약 교차한다면 즉시 TRUE
를 반환한다.
반대로, 오른쪽 끝점을 만나면 해당 선분 를 에서 빼야한다. 를 빼면 의 바로 위에 있던 선분과 바로 아래에 있던 선분이 서로 순서상 연속하게 되므로, 그 두 선분이 서로 교차하는지를 확인한다. 교차한다면 즉시 TRUE
를 반환하면 되고, 그렇지 않으면 를 에서 제거한 후 계속한다.
위 pseudo-code는 선분 교차가 감지되면 TRUE
를 반환하기 때문에, 만약 TRUE
가 반환됐다면 개 선분들 중 교차하는 것이 적어도 하나 있음은 보장된다. 그런데 실제로는 선분 교차가 일어나지만 이를 찾아내지 못하는 경우가 있을 수도 있지 않을까?
2차원 평면상 개의 선분들 중 서로 교차하는 것이 있다면, 위 알고리즘은 반드시
TRUE
를 반환한다. (즉, 교차하는 선분이 있음에도 이를 찾아내지 못하는 경우는 없다.)
여러 개의 교차점들 중 가장 왼쪽에 있는 것, 만약 가장 왼쪽에 있는 것들이 여러 개라면 그 중 좌표가 가장 작은 점을 라고 하자.
의 왼쪽 반평면에는 교차점이 없고 스위핑이 맨 왼쪽에서 오른쪽으로 이루어지므로, 까지 에 담긴 선분들의 순서는 선분의 삽입 및 삭제가 일어날 때에만 바뀌게 된다. 다시 말해 이전까지는 선분 교차로 인해 두 선분의 순서가 서로 바뀌는 일은 일어나지 않는다.
에서 교차하는 두 선분을 라고 하자. 두 선분이 교차한다면, 두 선분이 순서상 연속적으로 나타나게 되는 수직선 가 존재한다. 그 수직선들 중 가장 왼쪽에 있는 것을 라고 해보자. 에서 선분 순서상 연속적이게 되는 경우는 두 가지 밖에 없다.
2번은 pseudo-code의 IF
문에서, 1번은 ELSE
문에서 캐치하게 되고, 때문에 위 알고리즘은 (교차점이 존재한다면) 점 이전에, 적어도 점 에 도달하는 시점에는 TRUE
를 반환하게 된다.
위 코드는
TRUE, FALSE
를 반환하게 변경했지만, 원래 코드에서는 교차하는 선분쌍을 반환하게 했다. 다만 이때 반환되는 선분쌍의 교점이 꼭 가장 왼쪽에 있는 교점은 아닐 수 있음에 주의.
논의에 앞서 아래의 두 가지를 가정했었다.
만약 수직인 선분이 인풋으로 주어지거나, 세 선분이 한 점에서 만나는 경우도 존재한다면 처리해야할까?
수직인 선분이 있는 경우, 이것과 교차하는 선분들, 혹은 같은 값을 가지는 다른 수직 선분과 이 선분을 어떻게 비교해야할지 문제가 생긴다.
수직 선분을 어떻게 따로 처리해주는 것도 가능하지만, 가장 간단한 방법은 축을 적절히 회전시켜 수직 선분이 존재하지 않도록 만드는 것이다.
보다 구체적으로는, 일단은 모든 선분의 기울기를 구한 후, 선분의 기울기와도 같지 않은 임의의 실수 를 고르고, 를 새로운 축으로 두면 된다.
구체적으로는, 가 새로운 축이므로 새 축은 그에 수직인 가 된다. 일 때, 좌표계를 만큼 회전시키자.
이때
이므로 이를 대입하면 다음을 얻을 수 있다.
단 선분 교차 4 문제와 같이 모든 점들이 정수로 주어지는 경우, 정수 를 찾고 를 곱해, 로 두더라도 선분 교차 여부를 판정하는 데에는 문제가 없다(부동소수점 오차가 없어지는 대신 오버플로우가 발생할 수 있음에 주의).
점 에서 셋 이상의 선분들이 교차하게 되는 경우는 어떨까? 에서 교차하는 여러 선분들 중, 직전의 어떤 점에서 순서상 연속하는 임의의 두 선분을 골라, 각각 선분 라 한다면, 위에서의 논의를 그대로 적용할 수 있다.
#include <iostream>
#include <vector>
#include <algorithm>
#include <set>
using namespace std;
#define fastio cin.tie(NULL); cout.tie(NULL); ios_base::sync_with_stdio(false)
#define all(v) v.begin(), v.end()
typedef __int128 ll;
typedef pair<ll, ll> Point;
Point operator-(const Point& p1, const Point& p2) {
return Point(p1.first - p2.first, p1.second - p2.second);
}
ll operator*(const Point& p1, const Point& p2) {
return p1.first * p2.second - p1.second * p2.first;
}
ll K, cur;
struct Segment {
Point s, e;
int idx;
Segment(Point _s, Point _e, int _idx) {
s = Point(K * _s.first - _s.second, _s.first + K * _s.second);
e = Point(K * _e.first - _e.second, _e.first + K * _e.second);
idx = _idx;
if (s > e) swap(s, e);
}
bool operator<(const Segment& rhs) const {
auto &[x1, y1] = s;
auto &[x2, y2] = e;
auto &[x3, y3] = rhs.s;
auto &[x4, y4] = rhs.e;
ll eval1 = (y2 - y1) * (x4- x3) * (cur- x1) + y1 * (x2- x1) * (x4- x3);
ll eval2 = (y4 - y3)* (x2- x1) * (cur- x3) + y3 * (x2- x1) * (x4- x3);
if (eval1 != eval2) return eval1 < eval2;
return idx < rhs.idx;
}
};
struct Event {
ll x;
ll y;
int val;
int idx;
Event(ll _x, ll _y, int _val, int _idx) : x(_x), y(_y), val(_val), idx(_idx) {}
bool operator<(const Event& e) {
return tie(x, val, y, idx) < tie(e.x, e.val, e.y, e.idx);
}
};
int ccw(Point& a, Point& b, Point& c) {
ll ret = (b - a) * (c - b);
return ret > 0 ? 1 : ret ? -1 : 0;
}
bool intersect(const Segment& seg1, const Segment& seg2){
Point p1 = seg1.s, p2 = seg1.e;
Point p3 = seg2.s, p4 = seg2.e;
int p1p2 = ccw(p1, p2, p3) * ccw(p1, p2, p4);
int p3p4 = ccw(p3, p4, p1) * ccw(p3, p4, p2);
if (p1p2 == 0 && p3p4 == 0) {
if (p1 > p2) swap(p1, p2);
if (p3 > p4) swap(p3, p4);
return (p2 >= p3 && p4 >= p1);
}
return p1p2 <= 0 && p3p4 <= 0;
}
vector<Event> events;
vector<Segment> segments;
multiset<Segment> T;
bool shamos_hoey() {
for (auto &[s, e, idx] : segments) {
events.emplace_back(s.first, s.second, 0, idx);
events.emplace_back(e.first, e.second, 1, idx);
}
sort(all(events));
for (auto &[x, y, val, idx] : events) {
cur = x;
if (!val) {//insert
auto it = T.insert(segments[idx]);
auto nxt = next(it);
auto prv = prev(it);
if (nxt != T.end() && intersect(*nxt, *it)) return true;
if (it != T.begin() && intersect(*prv, *it)) return true;
}
else {//erase
auto it = T.lower_bound(segments[idx]);
auto nxt = next(it);
auto prv = prev(it);
if (it != T.begin() && nxt != T.end() && intersect(*prv, *nxt)) return true;
T.erase(it);
}
}
return false;
}
int main() {
fastio;
//N
int N;
cin >> N;
vector<pair<Point, Point>> points;
set<ll> slopes;
for (int i = 0; i < N; i++) {
int x1, y1, x2, y2;
cin >> x1 >> y1 >> x2 >> y2;
points.emplace_back(Point(x1, y1), Point(x2, y2));
if (x1 != x2) slopes.insert((y2 - y1) / (x2 - x1));
}
while (slopes.find(K) != slopes.end()) K++;
for (int i = 0; i < N; i++) {
segments.emplace_back(points[i].first, points[i].second, i);
}
cout << shamos_hoey();
}
main()
slopes
에 넣어준다. 굳이 실수 자료형을 쓰지 않아도 적절한 를 찾을 수 있다.slopes
에서 를 찾을 수 있는지 확인하면서 의 값을 계속 늘려간다. 어떠한 선분의 기울기와도 일치하지 않는 가 나타난다면, 가 새로운 축이 된다.multiset<Segment> T
에 들어가 정렬된 채로 관리될 것이기 때문에, 정렬을 위해 연산자 <
를 오버로딩해준다.cur
에서의 좌표다. 즉 두 선분 에 대해 이면 다. cur
에서의 좌표를 구해 실수형 변수에 넣어 비교해도 이 문제에서는 문제 없다.cur
에서 같은 좌표를 가지는 경우가 있을 수 있으므로, 이때는 선분의 인덱스를 기준으로 정렬한다(어차피 바로 걸러지긴 한다).x, y
는 선분 끝점의 좌표이고, val
이 0이면 삽입, 1이면 삭제한다.val
을 다음으로 둬야 한다. shamos_hoey()
cur
은 현재의 sweep line이고, 그 아래는 앞의 pseudo-code와 동일하다.multiset
의 경우 insert
가 iterator
를 반환하기 때문에, 새로이 삽입한 선분의 위 아래에 어떤 선분이 있는지 바로 확인할 수 있다.__int128
자료형을 사용한다.Point
(pair<__int128, __int128>
)를 사용한다. 선분 교차 판정을 위해 연산자 *
를 두 벡터의 외적을 구하도록, -
는 두 벡터의 차를 구하도록 오버로딩한다.intersect()
와 ccw()
에서.
- M. I. Shamos and D. Hoey, "Geometric intersection problems," 17th Annual Symposium on Foundations of Computer Science (sfcs 1976), Houston, TX, USA, 1976, pp. 208-215, doi: 10.1109/SFCS.1976.16.) (https://ieeexplore.ieee.org/document/4567905)