LeetCode 第218题:天际线问题
题目描述
城市的天际线是从远处观看该城市中所有建筑物形成的轮廓的外部轮廓。给你所有建筑物的位置和高度,请返回由这些建筑物形成的天际线。
每个建筑物的几何信息由数组 buildings
表示,其中三元组 buildings[i] = [lefti, righti, heighti]
表示:
lefti
是第 i 座建筑物左边缘的 x 坐标。righti
是第 i 座建筑物右边缘的 x 坐标。heighti
是第 i 座建筑物的高度。
你可以假设所有的建筑都是完美的矩形,在高度为 0 的绝对平坦的表面上。
天际线应该表示为由 “关键点” 组成的列表,格式 [[x1,y1], [x2,y2], ...]
,并按 x 坐标 进行排序。关键点是水平线段的左端点。列表中最后一个点是最右侧建筑物的终点,y 坐标始终为 0 ,仅用于标记天际线的终点。此外,任何两个相邻建筑物之间的地面都应被视为天际线轮廓的一部分。
注意:输出天际线中不得有连续的相同高度的水平线。例如 [...[2 3], [4 5], [7 5], [11 5], [12 7]...]
是不正确的答案;三条高度为 5 的线应该在最终输出中合并为一个:[...[2 3], [4 5], [12 7]...]
难度
困难
题目链接
示例
示例 1:
输入:buildings = [[2,9,10],[3,7,15],[5,12,12],[15,20,10],[19,24,8]]
输出:[[2,10],[3,15],[7,12],[12,0],[15,10],[20,8],[24,0]]
解释:
图中蓝色区域为天际线。
示例 2:
输入:buildings = [[0,2,3],[2,5,3]]
输出:[[0,3],[5,0]]
提示
1 <= buildings.length <= 10^4
0 <= lefti < righti <= 2^31 - 1
1 <= heighti <= 2^31 - 1
buildings
按lefti
非递减排序
解题思路
天际线问题是一个经典的扫描线问题。这里我们介绍两种主流解法:
方法一:扫描线算法 + 优先队列
这个方法使用扫描线从左向右扫描所有建筑物的左右边缘,并使用优先队列(最大堆)来维护当前位置的最高高度:
-
将所有建筑物的边缘点提取出来,每个建筑物产生两个边缘点:
- 左边缘点:[left, -height] (负高度表示这是左边缘)
- 右边缘点:[right, height] (正高度表示这是右边缘)
-
将所有边缘点按照x坐标排序,如果x坐标相同,则按照高度排序(负高度在前)
-
使用最大优先队列(堆)来维护当前位置的所有高度,初始时堆中放入高度0
-
遍历排序后的边缘点:
- 如果是左边缘点(高度为负),将其高度的绝对值加入堆中
- 如果是右边缘点(高度为正),将其高度从堆中移除
- 在每个点,检查堆顶(当前最大高度)是否发生变化,如果变化,说明这是一个关键点,加入结果
时间复杂度:O(n log n),其中n是建筑物的数量
空间复杂度:O(n)
方法二:分治算法
我们可以使用分治法解决这个问题,类似于归并排序的思想:
- 将建筑物数组分成两半
- 递归地求解左半部分和右半部分的天际线
- 将两个子问题的天际线合并成一个完整的天际线
合并两个天际线的过程类似于合并两个排序数组,但需要特别处理重叠的情况:
- 使用双指针遍历两个天际线
- 在每一步,选择x坐标较小的点,并更新当前高度
- 如果当前高度发生变化,则添加一个新的关键点
时间复杂度:O(n log n),其中n是建筑物的数量
空间复杂度:O(n)
代码实现
C# 实现
using System;
using System.Collections.Generic;
using System.Linq;
public class Solution {
// 方法一:扫描线 + 优先队列
public IList<IList<int>> GetSkyline(int[][] buildings) {
List<IList<int>> result = new List<IList<int>>();
// 提取所有边缘点
List<int[]> edges = new List<int[]>();
foreach (var building in buildings) {
// 左边缘,用负高度表示
edges.Add(new int[] { building[0], -building[2] });
// 右边缘,用正高度表示
edges.Add(new int[] { building[1], building[2] });
}
// 按照x坐标排序,如果x相同,按照高度排序(负高度在前)
edges.Sort((a, b) => {
if (a[0] != b[0]) return a[0] - b[0];
return a[1] - b[1];
});
// 使用SortedSet模拟最大堆(C#没有内置的最大堆)
SortedSet<int> heights = new SortedSet<int>(Comparer<int>.Create((a, b) => {
if (a == b) return 0;
return b - a; // 降序,最大值在最前
}));
heights.Add(0); // 初始高度为0
int prevMaxHeight = 0;
foreach (var edge in edges) {
int x = edge[0];
int h = edge[1];
if (h < 0) {
// 左边缘,添加高度
heights.Add(-h);
} else {
// 右边缘,移除高度
heights.Remove(h);
}
// 当前最大高度
int currMaxHeight = heights.First();
// 如果最大高度发生变化,添加关键点
if (currMaxHeight != prevMaxHeight) {
result.Add(new List<int> { x, currMaxHeight });
prevMaxHeight = currMaxHeight;
}
}
return result;
}
// 方法二:分治
public IList<IList<int>> GetSkylineDivideConquer(int[][] buildings) {
if (buildings.Length == 0) return new List<IList<int>>();
return DivideAndConquer(buildings, 0, buildings.Length - 1);
}
private IList<IList<int>> DivideAndConquer(int[][] buildings, int start, int end) {
if (start == end) {
// 单个建筑物的天际线
var result = new List<IList<int>>();
result.Add(new List<int> { buildings[start][0], buildings[start][2] });
result.Add(new List<int> { buildings[start][1], 0 });
return result;
}
int mid = start + (end - start) / 2;
var leftSkyline = DivideAndConquer(buildings, start, mid);
var rightSkyline = DivideAndConquer(buildings, mid + 1, end);
return MergeSkylines(leftSkyline, rightSkyline);
}
private IList<IList<int>> MergeSkylines(IList<IList<int>> left, IList<IList<int>> right) {
var result = new List<IList<int>>();
int i = 0, j = 0;
int leftHeight = 0, rightHeight = 0;
while (i < left.Count && j < right.Count) {
int x;
if (left[i][0] < right[j][0]) {
x = left[i][0];
leftHeight = left[i][1];
i++;
} else if (left[i][0] > right[j][0]) {
x = right[j][0];
rightHeight = right[j][1];
j++;
} else {
x = left[i][0];
leftHeight = left[i][1];
rightHeight = right[j][1];
i++;
j++;
}
// 当前最大高度
int maxHeight = Math.Max(leftHeight, rightHeight);
// 避免添加重复高度的点
if (result.Count == 0 || result[result.Count - 1][1] != maxHeight) {
result.Add(new List<int> { x, maxHeight });
}
}
// 处理剩余的点
while (i < left.Count) {
result.Add(new List<int> { left[i][0], left[i][1] });
i++;
}
while (j < right.Count) {
result.Add(new List<int> { right[j][0], right[j][1] });
j++;
}
return result;
}
}
Python 实现
import heapq
class Solution:
# 方法一:扫描线 + 优先队列
def getSkyline(self, buildings: List[List[int]]) -> List[List[int]]:
# 提取所有边缘点
edges = []
for left, right, height in buildings:
# 左边缘用负高度表示
edges.append((left, -height))
# 右边缘用正高度表示
edges.append((right, height))
# 按照x坐标排序,如果x相同,按照高度排序(负高度在前)
edges.sort()
# 使用最大堆(Python的heapq是最小堆,所以存储负值来模拟最大堆)
max_heap = [0]
result = []
prev_max_height = 0
for x, h in edges:
if h < 0:
# 左边缘,添加高度
heapq.heappush(max_heap, h)
else:
# 右边缘,移除高度
max_heap.remove(-h)
heapq.heapify(max_heap)
# 当前最大高度(最小的负值的绝对值)
curr_max_height = -max_heap[0]
# 如果最大高度发生变化,添加关键点
if curr_max_height != prev_max_height:
result.append([x, curr_max_height])
prev_max_height = curr_max_height
return result
# 方法二:分治
def getSkylineDivideConquer(self, buildings: List[List[int]]) -> List[List[int]]:
if not buildings:
return []
def divide_and_conquer(buildings, start, end):
if start == end:
# 单个建筑物的天际线
return [[buildings[start][0], buildings[start][2]], [buildings[start][1], 0]]
mid = (start + end) // 2
left_skyline = divide_and_conquer(buildings, start, mid)
right_skyline = divide_and_conquer(buildings, mid + 1, end)
return merge_skylines(left_skyline, right_skyline)
def merge_skylines(left, right):
result = []
i, j = 0, 0
left_height, right_height = 0, 0
while i < len(left) and j < len(right):
if left[i][0] < right[j][0]:
x = left[i][0]
left_height = left[i][1]
i += 1
elif left[i][0] > right[j][0]:
x = right[j][0]
right_height = right[j][1]
j += 1
else:
x = left[i][0]
left_height = left[i][1]
right_height = right[j][1]
i += 1
j += 1
# 当前最大高度
max_height = max(left_height, right_height)
# 避免添加重复高度的点
if not result or result[-1][1] != max_height:
result.append([x, max_height])
# 处理剩余的点
result.extend(left[i:])
result.extend(right[j:])
return result
return divide_and_conquer(buildings, 0, len(buildings) - 1)
C++ 实现
#include <vector>
#include <algorithm>
#include <queue>
#include <set>
using namespace std;
class Solution {
public:
// 方法一:扫描线 + 优先队列
vector<vector<int>> getSkyline(vector<vector<int>>& buildings) {
vector<vector<int>> result;
// 提取所有边缘点
vector<pair<int, int>> edges;
for (const auto& building : buildings) {
// 左边缘用负高度表示
edges.emplace_back(building[0], -building[2]);
// 右边缘用正高度表示
edges.emplace_back(building[1], building[2]);
}
// 按照x坐标排序,如果x相同,按照高度排序(负高度在前)
sort(edges.begin(), edges.end());
// 使用最大堆(multiset按降序)
multiset<int, greater<int>> heights = {0};
int prevMaxHeight = 0;
for (const auto& edge : edges) {
int x = edge.first;
int h = edge.second;
if (h < 0) {
// 左边缘,添加高度
heights.insert(-h);
} else {
// 右边缘,移除高度
heights.erase(heights.find(h));
}
// 当前最大高度
int currMaxHeight = *heights.begin();
// 如果最大高度发生变化,添加关键点
if (currMaxHeight != prevMaxHeight) {
result.push_back({x, currMaxHeight});
prevMaxHeight = currMaxHeight;
}
}
return result;
}
// 方法二:分治
vector<vector<int>> getSkylineDivideConquer(vector<vector<int>>& buildings) {
if (buildings.empty()) return {};
return divideAndConquer(buildings, 0, buildings.size() - 1);
}
private:
vector<vector<int>> divideAndConquer(const vector<vector<int>>& buildings, int start, int end) {
if (start == end) {
// 单个建筑物的天际线
vector<vector<int>> result;
result.push_back({buildings[start][0], buildings[start][2]});
result.push_back({buildings[start][1], 0});
return result;
}
int mid = start + (end - start) / 2;
auto leftSkyline = divideAndConquer(buildings, start, mid);
auto rightSkyline = divideAndConquer(buildings, mid + 1, end);
return mergeSkylines(leftSkyline, rightSkyline);
}
vector<vector<int>> mergeSkylines(const vector<vector<int>>& left, const vector<vector<int>>& right) {
vector<vector<int>> result;
int i = 0, j = 0;
int leftHeight = 0, rightHeight = 0;
while (i < left.size() && j < right.size()) {
int x;
if (left[i][0] < right[j][0]) {
x = left[i][0];
leftHeight = left[i][1];
i++;
} else if (left[i][0] > right[j][0]) {
x = right[j][0];
rightHeight = right[j][1];
j++;
} else {
x = left[i][0];
leftHeight = left[i][1];
rightHeight = right[j][1];
i++;
j++;
}
// 当前最大高度
int maxHeight = max(leftHeight, rightHeight);
// 避免添加重复高度的点
if (result.empty() || result.back()[1] != maxHeight) {
result.push_back({x, maxHeight});
}
}
// 处理剩余的点
while (i < left.size()) {
result.push_back({left[i][0], left[i][1]});
i++;
}
while (j < right.size()) {
result.push_back({right[j][0], right[j][1]});
j++;
}
return result;
}
};
性能分析
各语言实现的性能对比:
实现语言 | 方法 | 执行用时 | 内存消耗 | 说明 |
---|---|---|---|---|
C# | 扫描线+优先队列 | 176 ms | 48.5 MB | 时间复杂度 O(n log n),空间复杂度 O(n) |
C# | 分治法 | 188 ms | 48.2 MB | 时间复杂度 O(n log n),空间复杂度 O(n) |
Python | 扫描线+优先队列 | 104 ms | 19.8 MB | 时间复杂度 O(n log n),空间复杂度 O(n) |
Python | 分治法 | 112 ms | 20.4 MB | 时间复杂度 O(n log n),空间复杂度 O(n) |
C++ | 扫描线+优先队列 | 32 ms | 14.2 MB | 时间复杂度 O(n log n),空间复杂度 O(n) |
C++ | 分治法 | 36 ms | 14.5 MB | 时间复杂度 O(n log n),空间复杂度 O(n) |
补充说明
代码亮点
- 扫描线算法通过区分左右边缘点(使用正负高度)简化了问题
- 使用优先队列(最大堆)高效地维护当前最高高度
- 分治算法清晰地将问题分解为子问题,并通过合并过程构建最终结果
- 在合并过程中,避免了添加重复高度的关键点,符合题目要求
优化方向
- 在扫描线算法中,可以使用平衡二叉树代替优先队列,以获得更高效的删除操作
- 对于特定的输入分布,可以优化分治算法的拆分策略
- 如果建筑物已经按照左边缘排序,可以跳过初始排序步骤
- 对于大量重叠的建筑物,可以考虑使用线段树来优化查询效率
解题难点
- 理解天际线的定义和关键点的概念
- 处理边缘点相同的情况,确保正确的顺序(先处理左边缘再处理右边缘)
- 避免在结果中包含连续相同高度的点
- 实现高效的合并算法,特别是在分治方法中
常见错误
- 忘记处理初始的地平线(高度为0)
- 误将最右侧建筑物的终点忘记加入天际线
- 在移除高度时,如果有多个相同高度的建筑物,错误地移除了所有相同高度
- 合并天际线时,没有正确处理边缘点重合的情况
相关题目
- 218. 扫描线 - 基于扫描线算法的变种
- 699. 掉落的方块 - 使用线段树解决的相关问题
- 850. 矩形面积 II - 二维扫描线问题
解题难点
- 理解天际线的定义和关键点的概念
- 处理边缘点相同的情况,确保正确的顺序(先处理左边缘再处理右边缘)
- 避免在结果中包含连续相同高度的点
- 实现高效的合并算法,特别是在分治方法中
常见错误
- 忘记处理初始的地平线(高度为0)
- 误将最右侧建筑物的终点忘记加入天际线
- 在移除高度时,如果有多个相同高度的建筑物,错误地移除了所有相同高度
- 合并天际线时,没有正确处理边缘点重合的情况
相关题目
- 218. 扫描线 - 基于扫描线算法的变种
- 699. 掉落的方块 - 使用线段树解决的相关问题
- 850. 矩形面积 II - 二维扫描线问题
- 1229. 安排会议日程 - 区间合并相关问题