欢迎光临
我们一直在努力

利用SQL求解城市天际线问题| LeetCode 218题

“城市天际线(The Skyline Problem)”是计算机科学中极其经典的一道算法题(LeetCode 218题,难度:Hard),也是扫描线算法(Sweep Line) 最纯粹、最惊艳的应用场景。

它完美展示了如何将“静态的二维几何重叠问题”,通过扫描线降维成“一维的动态状态流转问题”。


一、 问题详细描述

假设你是一名城市规划师,站在城市的正前方(二维平面的侧面)观察一排建筑物。你需要画出这些建筑物在天空背景下形成的外轮廓线(天际线)。

输入:一个列表 buildings,其中每个元素是一个三元组 [left, right, height]:

  • left:建筑物左边缘的 X 坐标。

  • right:建筑物右边缘的 X 坐标。

  • height:建筑物的高度。

  • 假设所有建筑物都建在高度为 0 的绝对平坦的地面上,且都是完美的矩形。

输出:一个列表 skyline,包含一系列坐标点 [x, y]。

  • [x, y] 表示天际线在水平位置 x 处,高度发生了改变,新的高度为 y。

  • 输出的最后一个点必须是天际线的终点,高度永远为 0。

  • 核心约束(去重与合并):输出中不能包含连续的、相同高度的水平线段。只有当高度发生“转折(变化)”时,才记录一个点。


二、 测试数据

我们构造 5 栋建筑物,包含了重叠、包含、相邻、间隙、高度跌落等所有复杂边缘场景。

buildings = [
[2, 9, 10], # B1: 较宽,中等高度
[3, 7, 15], # B2: 较窄,很高,被 B1 和 B3 夹在中间
[5, 12, 12], # B3: 较宽,中等高度,与 B1、B2 都有重叠
[15, 20, 10], # B4: 独立建筑,与前面有间隙 (Gap)
[19, 24, 8] # B5: 与 B4 尾部重叠,但比 B4 矮
]

扫描线推演(X轴从左向右扫):

  • X=2: 碰到 B1 左边缘,高度从 0 升到 10。 (记录点 [2, 10])

  • X=3: 碰到 B2 左边缘,高度从 10 升到 15。 (记录点 [3, 15])

  • X=5: 碰到 B3 左边缘,但 B3 高度(12) < 当前最大高度(15),被遮挡,不记录。

  • X=7: 碰到 B2 右边缘(高楼离开),高度从 15 跌落。此时扫描线上还有 B1(10) 和 B3(12),最高的是 B3,所以高度跌落到 12。 (记录点 [7, 12])

  • X=9: B1 离开,但 B3(12) 还在,最高仍是 12,无变化,不记录。

  • X=12: B3 离开,扫描线上无建筑,高度跌落到 0。 (记录点 [12, 0])

  • X=15: 碰到 B4,高度从 0 升到 10。 (记录点 [15, 10])

  • X=19: 碰到 B5,但 B5(8) < B4(10),被遮挡,不记录。

  • X=20: B4 离开,高度跌落到 B5 的高度 8。 (记录点 [20, 8])

  • X=24: B5 离开,高度跌落到 0。 (记录点 [24, 0])

正确输出:[[2, 10], [3, 15], [7, 12], [12, 0], [15, 10], [20, 8], [24, 0]]


三、 核心解法:扫描线 + 最大堆

在天际线问题中,我们需要知道的是当前扫描线上所有活跃建筑的 “最大高度”。

因此,我们需要将 SUM() 替换为一个能动态维护最大值的数据结构:

  • 在 C++ 中是 std::multiset。

  • 在 Python 中是 heapq(优先队列/最大堆)。

  • 在 Java 中是 TreeMap 或 PriorityQueue。

算法执行步骤(标准 4 步法变体):

  • 事件拆解:将每个建筑 [L, R, H] 拆为两个事件:[L, -H] (进入,用负数表示左边缘以便排序) 和 [R, H] (离开,用正数表示右边缘)。

  • 事件排序:按 X 坐标升序排列。

    (核心避坑点:如果 X 相同,必须先处理左边缘,且左边缘中较高的先处理;右边缘中较矮的先处理。使用负数高度可以完美利用默认升序排序解决此冲突!)

  • 状态扫描(最大堆):维护一个包含当前所有“活跃建筑高度”的最大堆。遇到左边缘,高度入堆;遇到右边缘,高度出堆。

  • 转折提取:每次堆顶元素(当前最大高度)发生变化时,记录 [X, 新最大高度]。


  • 四、 高级 SQL 实现 (降维打击)

    如果在数据仓库(如 Hive, Spark SQL, PostgreSQL)中,我们需要对城市里几百万栋建筑计算天际线,写 Python UDF 太慢。我们可以完全利用 SQL 的窗口函数和扫描线思想来解决!

    SQL 解题思路:

  • 行转列:把建筑拆成 x 和 h 的事件点。

  • 网格化(离散化):因为 SQL 没有“堆”这种内存数据结构,我们需要找出所有发生高度变化的关键 X 坐标(即所有建筑的左右边缘)。

  • 区间覆盖判断:对于每一个关键 X 坐标,找出所有跨越这个 X 坐标的建筑,取它们的 MAX(height)。

  • 去重合并:利用 LAG() 剔除连续相同的高度。

  • WITH
    — 原始数据
    buildings AS (
    SELECT * FROM (VALUES
    (1, 2, 9, 10),
    (2, 3, 7, 15),
    (3, 5, 12, 12),
    (4, 15, 20, 10),
    (5, 19, 24, 8)
    ) AS t(id, left_x, right_x, height)
    ),

    — 步骤 1:提取所有关键的 X 坐标(扫描线会停留的所有“事件点”)
    critical_x AS (
    SELECT left_x AS x FROM buildings
    UNION
    SELECT right_x AS x FROM buildings
    ),

    — 步骤 2:对于每一个关键 X 坐标,找出覆盖它的所有建筑,并求出最大高度
    — 注意:建筑覆盖的条件是 left_x <= x AND x < right_x (左闭右开区间,避免右边缘被重复计算)
    x_max_height AS (
    SELECT
    c.x,
    COALESCE(MAX(b.height), 0) AS max_h
    FROM critical_x c
    LEFT JOIN buildings b
    ON b.left_x <= c.x AND c.x < b.right_x
    GROUP BY c.x
    ),

    — 步骤 3:利用 LAG() 获取上一个 X 坐标的高度,用于判断是否发生“转折”
    height_changes AS (
    SELECT
    x,
    max_h,
    LAG(max_h) OVER (ORDER BY x) AS prev_max_h
    FROM x_max_height
    )

    — 步骤 4:过滤出高度发生变化的转折点(即天际线输出)
    SELECT
    x,
    max_h AS y
    FROM height_changes
    WHERE prev_max_h IS NULL — 第一个点
    OR max_h != prev_max_h — 高度发生变化的点
    ORDER BY x;

    SQL 中间过程:

    c.x (扫描线位置)

    覆盖该点的建筑(left <= x < right)

    MAX(height) (当前天际线高度)

    LAG (上一个高度)

    是否记录转折点?

    2

    B1(10)

    10

    NULL

    ✅ [2, 10]

    3

    B1(10), B2(15)

    15

    10

    ✅ [3, 15]

    5

    B1(10), B2(15), B3(12)

    15

    15

    ❌ (无变化)

    7

    B1(10), B3(12)

    12

    15

    ✅ [7, 12]

    9

    B3(12)

    12

    12

    ❌ (无变化)

    12

    0

    12

    ✅ [12, 0]

    15

    B4(10)

    10

    0

    ✅ [15, 10]

    19

    B4(10), B5(8)

    10

    10

    ❌ (无变化)

    20

    B5(8)

    8

    10

    ✅ [20, 8]

    24

    0

    8

    ✅ [24, 0]

    (SQL 解法虽然时间复杂度是O(N^2) ,但在 MPP 数据库(如 ClickHouse, Spark)中,利用分布式 JOIN 和聚合,处理百万级区间的天际线计算依然非常高效,且代码极具可读性。)

    五、 总结:天际线问题带来的思维升华

    无论是用 Python 的最大堆,还是用 SQL 的左连接+分组聚合,城市天际线问题都向我们展示了扫描线算法的终极奥义:

  • 打破实体的边界:不要盯着“一栋栋楼”看,要把楼拆碎,变成时间/空间轴上的“事件”。

  • 维护“活跃状态池”:扫描线本身没有记忆,你必须用一个数据结构(堆、Multiset、或者 SQL 的 JOIN 匹配)来记录“当前扫描线穿透了哪些实体”。

  • 只关注“状态突变”:海量的事件点中,90% 都是无效的(被遮挡的、高度没变的)。真正的业务价值(天际线的转折、系统的报警、用户的流失)只发生在状态池的极值(MAX)发生翻转的那一瞬间。

  • 往期精彩

    某制造业面试题:LOT历史日志设备空值数据补全问题

    面试问:建模中粒度设计不当会造成什么影响?

    SQL数据分析实战:电商新品高流量低转化问题

    SQL数据分析:购物篮分析

    游戏数仓 : 用户行为属性大表整合【宽表设计】

    SQL数据分析实战:物流轨迹行为区间划分

    SQL数据分析实战:用户WiFi行为区间划分

    数据治理:数据波动如何校验?

    SQL库存库龄分析实战:库存周期重算问题

    在团队中,如何推动一项新技术落地 ?

    赞(0)
    未经允许不得转载:171主机测评 » 利用SQL求解城市天际线问题| LeetCode 218题
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址