扫描线(Sweep Line)算法是计算几何领域的经典技巧,通过将二维问题降维到一维来处理。本文以”会议室调度”和”矩形面积并”两个经典场景为例,深入讲解扫描线算法的核心思想与Java实现。
算法核心思想
扫描线算法的本质是利用事件驱动的思想:想象一条垂直的扫描线从左向右移动,遇到矩形的左边界时”激活”该矩形,遇到右边界时”关闭”该矩形。通过维护当前被激活的区间集合,我们可以在每个事件点快速计算所需的信息。
核心步骤:
1. 事件提取:将所有边界转化为带有方向的事件(进入/离开)
2. 事件排序:按坐标排序,决定扫描线的移动顺序
3. 状态维护:使用线段树或有序集合维护当前激活区间
4. 区间聚合:计算当前激活区间的总长度或重叠情况
场景一:会议室调度(区间最大重叠数)
问题描述
给定一组会议时间区间,求同一时刻最多需要多少个会议室。
算法思路
将每个会议的开始时间和结束时间视为事件:
– 开始时间:会议室需求+1
– 结束时间:会议室需求-1
按时间顺序扫描所有事件,维护当前需要的会议室数量,取最大值即可。
Java实现
import java.util.*;
/**
* 会议室调度问题:计算同一时刻最多需要的会议室数量
* 使用扫描线算法,时间复杂度 O(n log n)
*/
public class MeetingRoomScheduler {
/**
* 会议事件:时间点和变化量(+1表示开始,-1表示结束)
*/
static class Event implements Comparable<Event> {
int time; // 事件发生时间
int delta; // 会议室数量变化(+1 或 -1)
Event(int time, int delta) {
this.time = time;
this.delta = delta;
}
@Override
public int compareTo(Event other) {
// 时间相同时,结束事件优先处理(先释放再占用)
if (this.time != other.time) {
return this.time - other.time;
}
return this.delta - other.delta; // -1(结束)在 +1(开始)之前
}
}
/**
* 计算最少需要的会议室数量
* @param intervals 会议时间区间数组,intervals[i] = [开始时间, 结束时间]
* @return 同时进行的最多会议数(即最少会议室数)
*/
public int minMeetingRooms(int[][] intervals) {
if (intervals == null || intervals.length == 0) {
return 0;
}
List<Event> events = new ArrayList<>();
// 步骤1:提取所有事件
for (int[] interval : intervals) {
events.add(new Event(interval[0], 1)); // 会议开始
events.add(new Event(interval[1], -1)); // 会议结束
}
// 步骤2:按时间排序事件
Collections.sort(events);
// 步骤3:扫描线遍历,维护当前会议室使用数量
int currentRooms = 0;
int maxRooms = 0;
for (Event event : events) {
currentRooms += event.delta;
maxRooms = Math.max(maxRooms, currentRooms);
}
return maxRooms;
}
/**
* 扩展功能:返回具体每个会议室安排了哪些会议
*/
public List<List<int[]>> scheduleMeetings(int[][] intervals) {
if (intervals == null || intervals.length == 0) {
return new ArrayList<>();
}
// 按开始时间排序会议
Arrays.sort(intervals, (a, b) -> a[0] - b[0]);
// 优先队列:按结束时间排序,存储每个会议室的最后结束时间
PriorityQueue<int[]> rooms = new PriorityQueue<>(Comparator.comparingInt(a -> a[0]));
// 记录每个会议分配到哪个会议室
int[] assignment = new int[intervals.length];
for (int i = 0; i < intervals.length; i++) {
int[] meeting = intervals[i];
// 检查是否有会议室已空闲(结束时间 <= 当前会议开始时间)
if (!rooms.isEmpty() && rooms.peek()[0] <= meeting[0]) {
int[] room = rooms.poll();
assignment[i] = room[1]; // 复用该会议室
rooms.offer(new int[]{meeting[1], room[1]});
} else {
// 需要新会议室
assignment[i] = rooms.size();
rooms.offer(new int[]{meeting[1], rooms.size()});
}
}
// 按会议室分组输出
List<List<int[]>> result = new ArrayList<>();
for (int i = 0; i < rooms.size(); i++) {
result.add(new ArrayList<>());
}
for (int i = 0; i < intervals.length; i++) {
result.get(assignment[i]).add(intervals[i]);
}
return result;
}
public static void main(String[] args) {
MeetingRoomScheduler scheduler = new MeetingRoomScheduler();
// 测试用例
int[][] intervals = {
{0, 30}, {5, 10}, {15, 20}
};
System.out.println("=== 会议室调度问题 ===");
System.out.println("会议区间: " + Arrays.deepToString(intervals));
System.out.println("最少需要会议室: " + scheduler.minMeetingRooms(intervals));
// 更复杂的测试
int[][] intervals2 = {
{1, 5}, {2, 6}, {3, 7}, {8, 10}, {9, 12}
};
System.out.println("\\n会议区间: " + Arrays.deepToString(intervals2));
System.out.println("最少需要会议室: " + scheduler.minMeetingRooms(intervals2));
// 打印具体安排
System.out.println("\\n=== 会议室具体安排 ===");
List<List<int[]>> schedule = scheduler.scheduleMeetings(intervals2);
for (int i = 0; i < schedule.size(); i++) {
System.out.println("会议室 " + (i + 1) + ": " + schedule.get(i));
}
}
}
场景二:矩形面积并
问题描述
给定多个轴对齐矩形(由左下角和右上角坐标定义),求它们覆盖的总面积(重叠部分只算一次)。
算法思路
- 离散化Y坐标:收集所有矩形的上下边界Y坐标,排序去重
- 事件提取:每个矩形的左右边作为事件,记录Y区间范围
- 线段树维护:用线段树维护当前Y轴上被覆盖的长度
- 扫描计算:从左到右扫描,相邻X事件之间的面积 = 覆盖长度 × X间距
Java实现
import java.util.*;
/**
* 矩形面积并问题:计算多个矩形覆盖的总面积
* 使用扫描线 + 线段树 + 坐标离散化,时间复杂度 O(n log n)
*/
public class RectangleAreaUnion {
/**
* 垂直边事件
*/
static class Edge implements Comparable<Edge> {
int x; // 边的X坐标
int y1, y2; // Y区间范围
int type; // +1 左边(进入),-1 右边(离开)
Edge(int x, int y1, int y2, int type) {
this.x = x;
this.y1 = y1;
this.y2 = y2;
this.type = type;
}
@Override
public int compareTo(Edge other) {
return this.x - other.x;
}
}
/**
* 线段树节点:维护区间覆盖信息
*/
static class SegTree {
int[] cover; // 区间被完全覆盖的次数
int[] length; // 区间内实际被覆盖的长度
int[] ys; // 离散化后的Y坐标数组
SegTree(int[] ys) {
this.ys = ys;
int n = ys.length;
// 需要4倍空间,叶子节点代表区间 [ys[i], ys[i+1])
cover = new int[n * 4];
length = new int[n * 4];
}
/**
* 更新区间 [y1, y2) 的覆盖次数
*/
void update(int node, int l, int r, int y1, int y2, int val) {
if (y1 >= ys[r] || y2 <= ys[l]) {
return; // 无交集
}
if (y1 <= ys[l] && ys[r] <= y2) {
// 完全覆盖
cover[node] += val;
} else {
int mid = (l + r) >> 1;
update(node << 1, l, mid, y1, y2, val);
update(node << 1 | 1, mid, r, y1, y2, val);
}
// 更新当前节点的覆盖长度
if (cover[node] > 0) {
// 被完全覆盖,长度为整个区间
length[node] = ys[r] - ys[l];
} else if (l + 1 == r) {
// 叶子节点且未被覆盖
length[node] = 0;
} else {
// 合并子节点信息
length[node] = length[node << 1] + length[node << 1 | 1];
}
}
}
/**
* 计算矩形面积并
* @param rectangles 每个矩形为 [x1, y1, x2, y2],表示左下角(x1,y1)到右上角(x2,y2)
* @return 总面积
*/
public int rectangleArea(int[][] rectangles) {
if (rectangles == null || rectangles.length == 0) {
return 0;
}
final int MOD = 1_000_000_007;
List<Edge> edges = new ArrayList<>();
Set<Integer> ySet = new TreeSet<>();
// 步骤1:提取所有边事件和Y坐标
for (int[] rect : rectangles) {
int x1 = rect[0], y1 = rect[1], x2 = rect[2], y2 = rect[3];
edges.add(new Edge(x1, y1, y2, 1));
edges.add(new Edge(x2, y1, y2, -1));
ySet.add(y1);
ySet.add(y2);
}
// 步骤2:离散化Y坐标
int[] ys = new int[ySet.size()];
int idx = 0;
for (int y : ySet) {
ys[idx++] = y;
}
// 步骤3:按X排序事件
Collections.sort(edges);
// 步骤4:构建线段树并扫描
SegTree segTree = new SegTree(ys);
long area = 0;
long prevX = edges.get(0).x;
for (Edge edge : edges) {
long x = edge.x;
// 计算前一个X区间形成的面积
area += segTree.length[1] * (x - prevX);
area %= MOD;
// 更新当前边对应的Y区间
segTree.update(1, 0, ys.length - 1, edge.y1, edge.y2, edge.type);
prevX = x;
}
return (int) area;
}
public static void main(String[] args) {
RectangleAreaUnion solver = new RectangleAreaUnion();
// 测试用例1:两个重叠矩形
int[][] rectangles1 = {
{0, 0, 2, 2},
{1, 1, 3, 3}
};
System.out.println("=== 矩形面积并 ===");
System.out.println("矩形列表: " + Arrays.deepToString(rectangles1));
System.out.println("总面积: " + solver.rectangleArea(rectangles1)); // 预期: 7
// 测试用例2:多个矩形
int[][] rectangles2 = {
{0, 0, 2, 2},
{1, 0, 2, 3},
{1, 0, 3, 1}
};
System.out.println("\\n矩形列表: " + Arrays.deepToString(rectangles2));
System.out.println("总面积: " + solver.rectangleArea(rectangles2)); // 预期: 6
}
}
复杂度分析
| 场景 | 时间复杂度 | 空间复杂度 | 关键数据结构 |
|---|---|---|---|
| 会议室调度 | O(n log n) | O(n) | 排序 + 优先队列 |
| 矩形面积并 | O(n log n) | O(n) | 排序 + 线段树 + 离散化 |
扫描线算法的适用场景
扫描线算法特别适用于以下类型的问题:
– 区间统计:最大重叠数、区间覆盖长度
– 几何计算:矩形面积/周长并、最近点对(一维扫描)
– 事件模拟:时间轴上的状态变化追踪
– 碰撞检测:在一维投影上快速筛选候选碰撞对
掌握扫描线算法的核心在于理解“降维”思想——将复杂的二维/多维问题,通过在某一维度上的有序扫描,转化为可维护的一维区间问题。