每日算法 — 使用java实现天际线问题:扫描线算法与最大堆维护

天际线问题(Skyline Problem)是计算几何与分治算法的经典结合。给定城市中若干建筑物的左右边界和高度,求从远处看去建筑物的轮廓线——即所有建筑物的并集所形成的上边界折线。本文用Java实现基于扫描线(Sweep Line)和最大堆的高效解法。

问题分析

每座建筑物用三元组 $[L, R, H]$ 表示,其中 $L$ 为左边界横坐标,$R$ 为右边界横坐标,$H$ 为高度。天际线由一系列关键点 $(x, h)$ 组成,表示在位置 $x$ 处轮廓线的高度变为 $h$。

关键观察:天际线只在建筑物的左右边界处发生变化。当进入一个建筑物范围时,高度可能上升;当离开最高建筑物时,高度下降为次高建筑物的高度。

算法核心思想

扫描线算法

  1. 事件生成:将每座建筑物的左右边界转换为事件。左边界为”进入事件”(高度为正),右边界为”离开事件”(高度为负或标记删除)
  2. 事件排序:按横坐标排序,同一位置先处理进入事件(高度高的优先)
  3. 最大堆维护:用最大堆维护当前活跃的建筑物高度
  4. 遇到左边界:将高度加入堆
  5. 遇到右边界:从堆中移除对应高度
  6. 每次事件后,如果堆顶高度发生变化,则产生一个天际线关键点

关键细节

由于Java的PriorityQueue是最小堆,需要自定义比较器实现最大堆。更复杂的是,标准的堆不支持高效的任意元素删除,因此需要采用延迟删除策略:使用一个额外的哈希表记录待删除的高度,当堆顶恰好是待删除元素时才真正移除。

Java完整实现

import java.util.*;

/**
 * 天际线问题 - 扫描线算法 + 最大堆实现
 * 给定建筑物集合,求从远处看到的天际线轮廓
 */
public class SkylineProblem {

    /**
     * 建筑物类
     */
    static class Building {
        int left, right, height;
        Building(int l, int r, int h) {
            left = l; right = r; height = h;
        }
    }

    /**
     * 事件类:用于扫描线
     */
    static class Event {
        int x;           // 事件位置
        int height;      // 建筑物高度
        boolean isStart; // true=进入事件, false=离开事件
        int buildingId;  // 建筑物标识

        Event(int x, int h, boolean start, int id) {
            this.x = x;
            this.height = h;
            this.isStart = start;
            this.buildingId = id;
        }
    }

    /**
     * 主解法:扫描线 + 最大堆(延迟删除)
     * @param buildings 建筑物数组,每座 [left, right, height]
     * @return 天际线关键点列表 [x, height]
     */
    public static List<int[]> getSkyline(int[][] buildings) {
        List<int[]> result = new ArrayList<>();
        if (buildings == null || buildings.length == 0) {
            return result;
        }

        // 1. 生成所有事件
        List<Event> events = new ArrayList<>();
        for (int i = 0; i < buildings.length; i++) {
            events.add(new Event(buildings[i][0], buildings[i][2], true, i));
            events.add(new Event(buildings[i][1], buildings[i][2], false, i));
        }

        // 2. 排序事件:按x坐标升序,同一x先处理进入事件(高度降序)
        events.sort((a, b) -> {
            if (a.x != b.x) {
                return Integer.compare(a.x, b.x);
            }
            // 同一位置:进入事件先于离开事件
            if (a.isStart && !b.isStart) {
                return -1;
            }
            if (!a.isStart && b.isStart) {
                return 1;
            }
            // 同为进入事件:高度高的优先
            if (a.isStart) {
                return Integer.compare(b.height, a.height);
            }
            // 同为离开事件:高度低的优先(先移除矮的,避免影响当前最高)
            return Integer.compare(a.height, b.height);
        });

        // 3. 最大堆 + 延迟删除
        // 堆中存储高度,使用延迟删除处理右边界事件
        PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());
        // 记录每个高度出现的次数(处理高度相同的建筑物)
        Map<Integer, Integer> heightCount = new HashMap<>();
        // 延迟删除计数
        Map<Integer, Integer> removedCount = new HashMap<>();

        int prevMaxHeight = 0;
        maxHeap.offer(0); // 地面高度
        heightCount.put(0, 1);

        int i = 0;
        while (i < events.size()) {
            int currX = events.get(i).x;

            // 处理当前x位置的所有事件
            while (i < events.size() && events.get(i).x == currX) {
                Event e = events.get(i);
                if (e.isStart) {
                    // 进入事件:加入堆
                    maxHeap.offer(e.height);
                    heightCount.merge(e.height, 1, Integer::sum);
                } else {
                    // 离开事件:标记延迟删除
                    removedCount.merge(e.height, 1, Integer::sum);
                }
                i++;
            }

            // 清理堆顶已删除的元素
            while (!maxHeap.isEmpty()) {
                int top = maxHeap.peek();
                int rem = removedCount.getOrDefault(top, 0);
                int cnt = heightCount.getOrDefault(top, 0);
                if (rem > 0 && rem >= cnt) {
                    maxHeap.poll();
                    removedCount.remove(top);
                    heightCount.remove(top);
                } else if (rem > 0) {
                    // 部分删除
                    break;
                } else {
                    break;
                }
            }

            // 获取当前最大高度
            int currMaxHeight = maxHeap.isEmpty() ? 0 : maxHeap.peek();

            // 如果高度发生变化,记录关键点
            if (currMaxHeight != prevMaxHeight) {
                result.add(new int[]{currX, currMaxHeight});
                prevMaxHeight = currMaxHeight;
            }
        }

        return result;
    }

    /**
     * 分治解法:归并两个天际线
     */
    public static List<int[]> getSkylineDivideConquer(int[][] buildings) {
        if (buildings == null || buildings.length == 0) {
            return new ArrayList<>();
        }
        return mergeSkyline(buildings, 0, buildings.length - 1);
    }

    private static List<int[]> mergeSkyline(int[][] buildings, int left, int right) {
        if (left == right) {
            List<int[]> res = new ArrayList<>();
            res.add(new int[]{buildings[left][0], buildings[left][2]});
            res.add(new int[]{buildings[left][1], 0});
            return res;
        }

        int mid = left + (right - left) / 2;
        List<int[]> leftSkyline = mergeSkyline(buildings, left, mid);
        List<int[]> rightSkyline = mergeSkyline(buildings, mid + 1, right);

        return mergeTwoSkylines(leftSkyline, rightSkyline);
    }

    private static List<int[]> mergeTwoSkylines(List<int[]> left, List<int[]> right) {
        List<int[]> result = new ArrayList<>();
        int i = 0, j = 0;
        int h1 = 0, h2 = 0; // 当前左右天际线的高度

        while (i < left.size() && j < right.size()) {
            int x, maxH;
            if (left.get(i)[0] < right.get(j)[0]) {
                x = left.get(i)[0];
                h1 = left.get(i)[1];
                maxH = Math.max(h1, h2);
                i++;
            } else if (left.get(i)[0] > right.get(j)[0]) {
                x = right.get(j)[0];
                h2 = right.get(j)[1];
                maxH = Math.max(h1, h2);
                j++;
            } else {
                x = left.get(i)[0];
                h1 = left.get(i)[1];
                h2 = right.get(j)[1];
                maxH = Math.max(h1, h2);
                i++;
                j++;
            }

            // 避免连续相同高度的点
            if (result.isEmpty() || result.get(result.size() - 1)[1] != maxH) {
                result.add(new int[]{x, maxH});
            }
        }

        while (i < left.size()) {
            result.add(left.get(i++));
        }
        while (j < right.size()) {
            result.add(right.get(j++));
        }

        // 合并相同高度的相邻点
        List<int[]> cleaned = new ArrayList<>();
        for (int[] point : result) {
            if (cleaned.isEmpty() || cleaned.get(cleaned.size() - 1)[1] != point[1]) {
                cleaned.add(point);
            }
        }

        return cleaned;
    }

    /**
     * 打印天际线
     */
    public static void printSkyline(List<int[]> skyline) {
        System.out.println("天际线关键点:");
        for (int[] point : skyline) {
            System.out.printf("  x=%d, height=%d%n", point[0], point[1]);
        }

        // 文本可视化
        System.out.println("\n文本可视化(简化):");
        int maxH = skyline.stream().mapToInt(p -> p[1]).max().orElse(0);
        for (int h = maxH; h > 0; h--) {
            System.out.printf("H=%2d ", h);
            for (int[] point : skyline) {
                if (point[1] >= h) {
                    System.out.print("██");
                } else {
                    System.out.print("  ");
                }
            }
            System.out.println();
        }
    }

    public static void main(String[] args) {
        int[][] buildings = {
            {2, 9, 10},
            {3, 7, 15},
            {5, 12, 12},
            {15, 20, 10},
            {19, 24, 8}
        };

        System.out.println("=== 扫描线算法 ===");
        List<int[]> skyline1 = getSkyline(buildings);
        printSkyline(skyline1);

        System.out.println("\n=== 分治算法 ===");
        List<int[]> skyline2 = getSkylineDivideConquer(buildings);
        printSkyline(skyline2);

        // 验证两种方法结果一致
        boolean match = skyline1.size() == skyline2.size();
        if (match) {
            for (int i = 0; i < skyline1.size(); i++) {
                if (skyline1.get(i)[0] != skyline2.get(i)[0] ||
                    skyline1.get(i)[1] != skyline2.get(i)[1]) {
                    match = false;
                    break;
                }
            }
        }
        System.out.println("\n两种算法结果一致: " + match);
    }
}

时间复杂度分析

扫描线算法

  • 事件生成:$O(n)$
  • 事件排序:$O(n \log n)$
  • 扫描处理:每个事件涉及堆操作 $O(\log n)$,共 $2n$ 个事件,总计 $O(n \log n)$
  • 总时间复杂度:$O(n \log n)$
  • 空间复杂度:$O(n)$,存储事件和堆

分治算法

  • 递归关系:$T(n) = 2T(n/2) + O(n)$
  • 总时间复杂度:$O(n \log n)$
  • 空间复杂度:$O(n \log n)$,递归栈和中间结果

两种算法时间复杂度相同,但扫描线算法的常数因子更小,且空间效率更优。

算法变体

实际问题中可能需要考虑:
– 最大矩形:在直方图中找到最大矩形面积(可用单调栈优化到 $O(n)$)
– 雨水收集:计算建筑物间的积水总量(双指针法 $O(n)$)
– 二维天际线:扩展到三维空间的建筑并集体积计算