I. Giới thiệu
Trong hình học tính toán, việc xét các điểm trên một mặt phẳng là bài toán thường xuyên xảy ra. Tuy nhiên, với dữ liệu lớn trong các bài toán Lập trình thi đấu, việc quét qua tất cả mọi điểm trên mặt phẳng dường như là bất khả thi, chính vì thế ta sẽ chỉ xét tới một số điểm cần thiết mà thôi.
Thuật toán đường quét (Sweep line algorithm) được xây dựng dựa trên tư tưởng nói trên. Đây là một thuật toán mạnh trong hình học tính toán, ý tưởng của nó rất đơn giản: Sử dụng một đường thẳng dọc và "quét" qua mặt phẳng.
Trước khi đọc bài viết này, độc giả cần nắm vững các kiến thức sau:
- Lý thuyết khoảng cách: Với hai điểm và trên mặt phẳng ta có hai khái niệm:
- Khoảng cách Euclid giữa hai điểm và là khoảng cách thu được khi tính bằng công thức Pythagoras:
- Khoảng cách Mahattan giữa hai điểm và là khoảng cách thu được nếu như ta chỉ được đi dọc hoặc ngang từ điểm này qua điểm kia:
- Khoảng cách Euclid giữa hai điểm và là khoảng cách thu được khi tính bằng công thức Pythagoras:
- Các kĩ thuật bổ trợ: Kĩ thuật Hai con trỏ, Kĩ thuật Rời rạc hóa (Nén số).
- Các cấu trúc dữ liệu bổ trợ: Cây phân đoạn, Cây chỉ số nhị phân.
- Thuật toán Bao lồi.
Những kiến thức nói trên sẽ được sử dụng để lọc ra và xử lý các điểm cần quan tâm trong quá trình quét trên mặt phẳng. Vì thế, các bạn hãy đọc kĩ tất cả các bài viết về các chủ đề trên trước khi nghiên cứu bài viết này.
Bây giờ, chúng ta sẽ cùng phân tích ý tưởng của Thuật toán đường quét thông qua những bài toán kinh điển trên mặt phẳng!
II. Các bài toán minh họa
1. Bài toán tìm cặp điểm gần nhất
Đề bài
Cho điểm trên mặt phẳng tọa độ . Khoảng cách giữa hai điểm trên mặt phẳng này được định nghĩa là khoảng cách Euclid.
Yêu cầu: Tìm cặp điểm có khoảng cách Euclid gần nhau nhất?
Input:
- Dòng đầu tiên chứa số nguyên dương - số lượng điểm.
- Trên dòng tiếp theo, mỗi dòng chứa hai số nguyên - tọa độ điểm thứ .
Ràng buộc:
- .
- .
Output:
- Đưa ra chỉ số và của cặp điểm gần nhau nhất và khoảng cách giữa chúng, làm tròn tới chữ số sau dấu chấm thập phân.
Sample Input:
5 0 0
0 1
100 45
2 3
9 9
Sample Output:
1 2 1.000000
Ý tưởng
Chắc chắn ý tưởng xét mọi cặp điểm sẽ gây ra TLE. Tuy nhiên, thuật toán đường quét sẽ giúp giảm độ phức tạp xuống chỉ còn .
Đầu tiên ta sắp xếp lại các điểm theo thứ tự hoành độ tăng dần. Lần lượt duyệt qua từng điểm trong danh sách đã sắp xếp (tưởng tượng như có một đường thẳng dọc đang "quét" qua các điểm), sau đó ta sẽ tìm ra một số điểm "đáng quan tâm" để xét khoảng cách với điểm đang duyệt, sao cho chi phí xét các điểm đáng quan tâm này không vượt quá . Nhờ thế, thuật toán sẽ được cải tiến độ phức tạp về .
Giả sử ta đã xử lý xong điểm, và khoảng cách ngắn nhất hiện có là . Như vậy từ điểm thứ trở đi, ta sẽ chỉ cần xem xét các điểm có khoảng cách tới nó nhỏ hơn . Gọi điểm đang xét là . Ta có bổ đề dưới đây:
Bổ đề 1:
Tại mỗi bước trong thuật toán, để tìm ra một cặp điểm có khoảng cách bé hơn ta chỉ cần quan tâm tới tối đa điểm khác.
Chứng minh:
Từ điểm ta vẽ hình vuông xung quanh, mỗi hình vuông có cạnh đúng bằng (điểm màu xanh là điểm ):
Dễ thấy các điểm từ tới đều nằm về bên trái của điểm đang xét, do ta đã sắp xếp các điểm tăng dần theo hoành độ. Nhận xét rằng, từ ta không cần quan tâm tới các điềm nằm ngoài hình vuông trên, do khi đó khoảng cách giữa chúng sẽ lớn hơn .
Với hình vuông vừa vẽ, do là khoảng cách ngắn nhất giữa hai điểm bất kì trong điểm đầu tiên, nên trong mỗi hình vuông, ta chỉ có tối đa điểm có thể ghép với để tạo ra một cặp điểm có khoảng cách tốt hơn. Do đó, ta chỉ cần xét tối đa điểm trong hình vuông để cập nhật kết quả.
Từ bổ đề ta nhận thấy cần duy trì một danh sách các điểm có hoành độ chênh lệch không quá so với điểm đang xét. Do giảm dần qua các bước của thuật toán, nên việc này có thể thực hiện bằng kĩ thuật hai con trỏ. Đồng thời, tại mỗi bước ta cần tìm các điểm có tung độ lân cận với điểm đang xét. Do đó các điểm trong cần được sắp xếp theo thứ tự tung độ tăng dần. Cấu trúc dữ liệu phù hợp ở đây sẽ là std::set
.
Thuật toán cụ thể sẽ trải qua các bước như sau:
- Sắp xếp danh sách các điểm theo hoành độ tăng dần.
- Lần lượt duyệt qua các điểm trong danh sách, đồng thời duy trì một
std::set
. - Gọi là khoảng cách nhỏ nhất đã có, tại mỗi bước xét điểm ta thực hiện:
- Loại bỏ khỏi các điểm có chênh lệch hoành độ so với điểm đang xét lớn hơn .
- Tìm các điểm trong có chênh lệch tung độ không quá tính khoảng cách giữa các điểm này với và cập nhật .
- Thêm điểm đang xét vào .
Cài đặt
#include <bits/stdc++.h>
#define int long long using namespace std; struct Point
{ int x, y; int id; bool operator < (const Point& other) { if (x != other.x) return x < other.x; return y < other.y; }
}; struct cmp
{ bool operator()(const Point& a, const Point& b) { if (a.y != b.y) return a.y < b.y; return a.x < b.x; }
}; int sqr_distance(Point a, Point b)
{ return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y);
} void solution(int n, vector < Point >& p)
{ sort(p.begin() + 1, p.end()); // Ban đầu giả sử kết quả tốt nhất là khoảng cách giữa hai điểm đầu tiên. int sqr_d = sqr_distance(p[1], p[2]); int id1 = 1, id2 = 2; set < Point, cmp > s; for (int i = 1; i <= n; ++i) { int x = p[i].x, y = p[i].y, id = p[i].id; int d = sqrt(sqr_d); Point cur = {-1000001, y - d, id}; while (true) { // Sử dụng hàm upper_bound của set, tìm ra điểm có hoành độ nhỏ nhất // thỏa mãn tung độ >= (y - d). auto it = s.upper_bound(cur) ; if (it == s.end()) break; // Nếu điểm đầu tiên có tung độ >= (y - d) mà lại // lớn hơn hẳn cả y + d thì dừng luôn. cur = *it; if (cur.y > y + d) break; // Xóa điểm này nếu như nó có hoành độ bé hơn x - d. // Trong set dần dần sẽ chỉ còn lại các điểm có hoành độ >= x - d // và tung độ nằm trong đoạn [y - d, y + d]. if (cur.x < x - d) { s.erase(it); continue; } if (sqr_distance(p[i], cur) < sqr_d) { sqr_d = sqr_distance(p[i], cur); id1 = id; id2 = cur.id; } } s.insert(p[i]); } if (id1 > id2) swap(id1, id2); cout << id1 << ' ' << id2 << ' '; cout << fixed << setprecision(6) << sqrt(sqr_d);
} signed main()
{ ios_base::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; vector < Point > p(n + 1); for (int i = 1; i <= n; ++i) { cin >> p[i].x >> p[i].y; p[i].id = i; } solution(n, p); return 0;
}
Đánh giá
Ta nhận thấy, mỗi điểm được thêm vào và xóa khỏi set
không quá lần, do đó tổng chi phí cho các thao tác thêm và xóa điểm là .
Chi phí sắp xếp các điểm ban đầu là .
Tại mỗi bước, chi phí tìm kiếm là và có tối đa điểm cần xét.
Như vậy, độ phức tạp tổng quát là:
- Độ phức tạp thời gian: .
- Độ phức tạp không gian: .
2. Bài toán tìm tổng diện tích phủ bởi các hình chữ nhật
Đề bài
Trên mặt phẳng tọa độ vẽ hình chữ nhật có cạnh song song với các trục tọa độ.
Yêu cầu: Tính tổng diện tích trên mặt phẳng bị che phủ bởi hình chữ nhật này?
Input:
- Dòng đầu tiên chứa số nguyên dương .
- Trên dòng tiếp theo, mỗi dòng chứa bốn số nguyên biểu thị tọa độ góc trái dưới và góc phải trên của một hình chữ nhật.
Ràng buộc:
- .
- .
Output:
- In ra diện tích bị phủ bởi hình chữ nhật.
Sample Input:
2
10 10 20 20
15 15 25 30
Sample Output:
225
Ý tưởng
Tư tưởng chung của thuật toán đường quét là sử dụng một đường thẳng dọc quét qua mặt phẳng, và bài toán này cũng không ngoại lệ.
Thay vì xử lý từng hình chữ nhật, thì ta sẽ tách mỗi hình chữ nhật thành hai "sự kiện": Một sự kiện biểu thị cạnh bên trái, một sự kiện biểu thị cạnh bên phải của hình chữ nhật. Lúc này, thực chất ta đang lưu lại các đoạn thẳng theo chiều dọc với trạng thái là - Đoạn thẳng có hoành độ kéo dài từ tung độ tới .
Đồng thời, ta duy trì một tập chứa các hình chữ nhật mà đường quét đang cắt qua.
Ta biết được những hình chữ nhật nào đang bị cắt bởi đường quét của chúng ta (màu đỏ). Để tìm tổng diện tích được bao phủ, ta sẽ tìm diện tích từng phần bị bao phủ giữa mỗi cặp hai đoạn thẳng liền nhau và tính tổng của chúng (trước đó các đoạn thẳng đã được lưu lại và sắp xếp tăng dần theo hoành độ). Để tìm phần diện tích được bao phủ giữa hai đoạn thẳng liền nhau, ta cần biết tổng độ dài phần đường quét đi qua chúng (nét liền màu xanh trong hình trên). Nhân độ dài này với khoảng cách giữa hai đoạn thẳng liền nhau, ta được diện tích của phần hình chữ nhật giữa hai "sự kiện" đó.
Vấn đề đặt ra là làm thế nào để tìm tổng độ dài của các phần "nét liền màu xanh" như trên hình. Hãy nhớ rằng tại mỗi bước ta duy trì một tập các hình chữ nhật mà đường thẳng quét cắt qua. Hiển nhiên ta thấy tổng độ dài của phần "nét liền màu xanh" chính là hợp của tất cả các hình chữ nhật trong tập tại mỗi bước.
Thuật toán đơn giản nhất để tính hợp của tất cả các hình chữ nhật trong tập chính là duyệt qua mọi hình chữ nhật. Khi đó, độ phức tạp tổng quát của thuật toán là : Ta duyệt qua sự kiện, và với mỗi sự kiện lại duyệt qua hình chữ nhật mà đường quét đang cắt qua (bao gồm cả các thao tác thêm hình chữ nhật mới - xóa hình chữ nhật cũ mà đường quét không còn cắt nữa trong tập ):
Để tối ưu thuật toán, ta sẽ cần dùng tới CTDL Segment Tree: Ta dựng cây phân đoạn với đoạn thẳng con này là các nút lá. Tại mỗi nút trong cây phân đoạn ta sẽ lưu hai giá trị để trả lời cho hai câu hỏi:
- Tổng độ dài các phần được ít nhất một hình chữ nhật phủ trong đoạn con mà nút quản lý là bao nhiêu?
- Hiện có bao nhiêu hình chữ nhật đang phủ đoạn con mà nút quản lý?
- Segment Tree luôn luôn chỉ quản lý các hình chữ nhật mà đường quét đang cắt qua, nên ở mỗi bước kết quả sẽ nằm ở gốc của cây.
Cụ thể như sau: Khi xét cạnh thứ có hai trường hợp:
- Nếu nó là cạnh bên trái của một hình chữ nhật, thì số lượng hình chữ nhật mà đường quét cắt qua sẽ tăng thêm ngược lại thì giảm đi . Ta sẽ cập nhật giá trị tương ứng trên đoạn tọa độ (do công thức sử dụng để tính độ dài phần nét liền phủ từ tọa độ tới tọa độ là nên phải chuyển thành để thu được kết quả đúng).
- Tính toán kết quả: Tổng độ dài của các phần được ít nhất một hình chữ nhật phủ trong tất cả các đoạn thẳng đang được Segment Tree quản lý sẽ luôn luôn nằm ở nút gốc của Segment Tree.
Trong trường hợp tọa độ của các đỉnh lớn hơn nữa ( chẳng hạn), ta kết hợp thêm kĩ thuật nén số để có thể sử dụng Segment Tree.
Cài đặt
#include <bits/stdc++.h> using namespace std; const int maxv = 30000; struct Seg
{ int x, y1, y2; int type; bool operator < (const Seg& other) { return x < other.x; }
}; struct SegmentTree
{ vector < pair < int, int > > s; SegmentTree(int n) { s.resize(4 * n + 1); for (int i = 0; i <= 4 * n; ++i) s[i] = {0, 0}; } void update(int id, int l, int r, int u, int v, int val) { if (l > v || r < u) return; if (l >= u && r <= v) { s[id].second += val; if (s[id].second != 0) s[id].first = r - l + 1; else if (l != r) s[id].first = s[id * 2].first + s[id * 2 + 1].first; else s[id].first = 0; return; } int mid = (l + r) >> 1; update(id * 2, l, mid, u, v, val); update(id * 2 + 1, mid + 1, r, u, v, val); if (s[id].second != 0) s[id].first = r - l + 1; else s[id].first = s[id * 2].first + s[id * 2 + 1].first; }
}; void solution(int n, vector < Seg >& segments)
{ sort(segments.begin(), segments.end()); SegmentTree st(maxv); int res = 0; for (int i = 0; i < (int) segments.size() - 1; ++i) { st.update(1, 0, maxv, segments[i].y1, segments[i].y2 - 1, segments[i].type); res += (segments[i + 1].x - segments[i].x) * st.s[1].first; } cout << res;
} signed main()
{ ios_base::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; vector < Seg > segments; for (int i = 1; i <= n; ++i) { int x1, y1, x2, y2; cin >> x1 >> y1 >> x2 >> y2; // Cạnh bên trái, khi đường quét cắt vào sẽ làm tăng số HCN trong danh sách. segments.push_back({x1, y1, y2, 1}); // Cạnh bên phải, khi đường quét cắt vào sẽ làm giảm số HCN trong danh sách. segments.push_back({x2, y1, y2, -1}); } solution(n, segments); return 0;
}
Đánh giá
Độ phức tạp thời gian tổng quát của thuật toán là do các thao tác thêm - xóa hình chữ nhật và tìm tổng độ dài của các phần "nét liền màu xanh" đã được tối ưu xuống với Segment Tree.
Độ phức tạp không gian tổng quát của thuật toán là .
III. Bài tập tự luyện
Đường quét là một thuật toán rất mạnh trong lập trình thi đấu, các bạn nên dành thêm thời gian để tự luyện thêm dạng bài này nếu như muốn xử lý tốt các bài tập hình học trong kì thi.
Dưới đây là một số bài tập hay của dạng này:
- TopCoder - CultureGrow.
- TopCoder - BoxUnion.
- TopCoder - PowerSupply.
- TopCoder - ConvexPolygons.
- SPOJ - CEPC08B.
- USACO - Square Overlap.
- Codeforces - 1402B.
- CSA - The Sprawl.