扫描线及其应用

扫描线

一、问题引入

对于给定坐标的几个矩形,如何求取它们覆盖的面积?或者,如何求取它们构成的不规则图形的周长?

image-20220917171944593

最暴力的方式就是将所有矩形的面积加起来,再减去相邻矩形重合的面积。但是这种O(n2)的算法显然没有那么优雅。如何优雅地解决这个问题呢?我们引入扫描线算法。

二、算法描述

image-20220917173701209

想象有一根竖直的线从左向右进行扫描,如图蓝色部分所示:

image-20220917194402351

OI Wiki上是从下往上扫描,本质上是一样的:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MQeZDf7H-1663490250819)(https://pic.leetcode-cn.com/1663294002-IWgpYx-scanning.svg)]

每当扫描到一个矩形的边缘时,前后两条扫描线就可能与原来的不规则图形构成一个规则的矩形。如此一来,不管是不规则图形的面积还是周长,都可以通过这些规则的矩形算出。

假设所有矩形的横坐标都是从0开始,那么,扫描线应该如何移动呢?从0开始逐次加1?当然不是。

我们再看一眼上图,扫描线构成规则矩形时,都是在扫描到原矩形的某一条边缘时。因此,真正有效的扫描线,也就是发生在这些边缘上。所以, 从小到大遍历原矩形的边缘,就是扫描线的正确扫描方式。

扫描线是一种离散化的技巧,将大范围的连续的坐标转化成 2n 个离散的坐标。

那么当扫描线扫描到边缘时,前后两条扫描线构成的规则矩形的宽就是扫描线的间距,可是高如何确定呢?

image-20220917195242288

同一边缘处,可能有多个矩形,矩形之间可以是重叠的,也可以是有"镂空"的。那么一条边缘处的有效线段长度如何求取呢?线段树

当我们获取到边缘处的有效线段长度后,以上图为例:

image-20220917200435088

蓝线下的数字表示该扫描线扫描到的有效线段长度。

左右两条扫描线形成的矩形面积就等于:扫描线间距*有效线段长度。

当我们扫描到一个矩形的左边缘时,我们将其y方向上的线段加入线段树,当扫描到一个矩形的右边缘时,我们将其y方向上的线段从线段树中删除。最后,只需要在遍历边缘时动态查询线段树中有效线段的长度即可。

三、算法实现

LeetCode 850. 矩形面积 II

struct Segtree {
    int cover;
    int length;
    int max_length;
};

class Solution {
public:
    int rectangleArea(vector<vector<int>>& rectangles) {
        int n = rectangles.size();
        for (const auto& rect: rectangles) {
            // 下边界
            hbound.push_back(rect[1]);
            // 上边界
            hbound.push_back(rect[3]);
        }
        sort(hbound.begin(), hbound.end());
        hbound.erase(unique(hbound.begin(), hbound.end()), hbound.end());
        int m = hbound.size();
        // 线段树有 m-1 个叶子节点,对应着 m-1 个会被完整覆盖的线段,需要开辟 ~4m 大小的空间
        tree.resize(m * 4 + 1);
        init(1, 1, m - 1);

        vector<tuple<int, int, int>> sweep;
        for (int i = 0; i < n; ++i) {
            // 左边界
            sweep.emplace_back(rectangles[i][0], i, 1);
            // 右边界
            sweep.emplace_back(rectangles[i][2], i, -1);
        }
        sort(sweep.begin(), sweep.end());

        long long ans = 0;
        for (int i = 0; i < sweep.size(); ++i) {
            int j = i;
            while (j + 1 < sweep.size() && get<0>(sweep[i]) == get<0>(sweep[j + 1])) {
                ++j;
            }
            if (j + 1 == sweep.size()) {
                break;
            }
            // 一次性地处理掉一批横坐标相同的左右边界
            for (int k = i; k <= j; ++k) {
                auto&& [_, idx, diff] = sweep[k];
                // 使用二分查找得到完整覆盖的线段的编号范围
                int left = lower_bound(hbound.begin(), hbound.end(), rectangles[idx][1]) - hbound.begin() + 1;
                int right = lower_bound(hbound.begin(), hbound.end(), rectangles[idx][3]) - hbound.begin();
                update(1, 1, m - 1, left, right, diff);
            }
            ans += static_cast<long long>(tree[1].length) * (get<0>(sweep[j + 1]) - get<0>(sweep[j]));
            i = j;
        }
        return ans % static_cast<int>(1e9 + 7);
    }

    void init(int idx, int l, int r) {
        tree[idx].cover = tree[idx].length = 0;
        if (l == r) {
            tree[idx].max_length = hbound[l] - hbound[l - 1];
            return;
        }
        int mid = (l + r) / 2;
        init(idx * 2, l, mid);
        init(idx * 2 + 1, mid + 1, r);
        tree[idx].max_length = tree[idx * 2].max_length + tree[idx * 2 + 1].max_length;
    }

    void update(int idx, int l, int r, int ul, int ur, int diff) {
        if (l > ur || r < ul) {
            return;
        }
        if (ul <= l && r <= ur) {
            tree[idx].cover += diff;
            pushup(idx, l, r);
            return;
        }
        int mid = (l + r) / 2;
        update(idx * 2, l, mid, ul, ur, diff);
        update(idx * 2 + 1, mid + 1, r, ul, ur, diff);
        pushup(idx, l, r);
    }

    void pushup(int idx, int l, int r) {
        if (tree[idx].cover > 0) {
            tree[idx].length = tree[idx].max_length;
        }
        else if (l == r) {
            tree[idx].length = 0;
        }
        else {
            tree[idx].length = tree[idx * 2].length + tree[idx * 2 + 1].length;
        }
    }

private:
    vector<Segtree> tree;
    vector<int> hbound;
};
 

四、加餐练习

LeetCode 218. 天际线问题

class Solution 
{
public:
    // 观察题目我们可以发现,关键点的横坐标总是落在建筑的左右边缘上。
    // 这样我们可以只考虑每一座建筑的边缘作为横坐标
    // 这样其对应的纵坐标为:包含该横坐标的所有建筑的最大高度。
    // 注:根据观察,建筑的右边缘的高度我们视为0
    // 一种做法就是使用线段树:
    // 遍历所有的建筑边缘,如果最大高度比起前一个发生变化,则为关键点。
    // 第二种扫描线的思路:
    // 我们将所有建筑的左右边缘保存至edges数组并升序排序,从小到大进行"扫描"。
    // 同时,使用堆优化查找某一下标处建筑的最大高度。
    // 具体地:
    // 我们知道,buildings都是非降序排序的,因此,我们维护一个指针idx。
    // 当遍历到边缘edge时,我们将所有buildings[idx][0]==edge的右边缘和高度入队。
    // 即保存buildings[idx][1]和buildings[idx][2],堆顶保存当前扫描到的高度的最大值
    // 同时,将堆顶元素中右边缘在edge处或edge左边的出队。
    // 此时,再查询堆顶,如果非空,则该元素就是edge处的最大高度,否则,最大高度为0。
    vector<vector<int>> getSkyline(vector<vector<int>>& buildings) 
    {
        
        // 将所有建筑的边缘进行升序排序
        vector<int> edges;
        for (auto building : buildings)
        {
            edges.emplace_back(building[0]);
            edges.emplace_back(building[1]);
        }
        sort(edges.begin(), edges.end());

        vector<vector<int>> res;
        priority_queue<pair<int, int>> pq;
        int idx = 0;
        for (auto edge : edges)
        {
            // 边缘与扫描线重合的建筑高度入队
            while (idx < buildings.size() && buildings[idx][0] == edge)
            {
                pq.push({buildings[idx][2], buildings[idx][1]});
                ++idx;
            }

            // 将已经被扫描过的建筑高度出队
            while (!pq.empty() && pq.top().second <= edge)
            {
                pq.pop();
            }
            
            // 避免当前关键点与前一个构成相同高度的水平线
            int maxh = pq.empty() ? 0 : pq.top().first;
            if (res.size() == 0 || res.back()[1] != maxh)
                res.push_back({ edge, maxh });
        }

        return res;
    }
};
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

白龙码~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值