离散化

例题:P3138 [USACO16FEB] Load Balancing S

有一个直接的做法,考虑整个平面里的每一个点 \((X,Y)\),将平面分成四个区域,计算每个区域奶牛的数量:

  • 左下:\(0 < x \le X, \ 0 < y \le Y\)
  • 左上:\(0 < x \le X, \ y > Y\)
  • 右下:\(x > X, \ 0 < y \le Y\)
  • 右上:\(x > X, \ y > Y\)

而四个区域的奶牛数量可以借助二维前缀和来计算。

但是,题目中的坐标范围是 \(10^6\),我们没法开一个 \(10^6 \times 10^6\) 的二维数组来计算,不管是考虑时间效率还是空间效率都不可行。

注意到 \(n \le 1000\),也就是说最多只有 \(1000\) 头奶牛,也就是说坐标点的取值最多只有 \(2000\) 种不同的数据,而实际上我们只需要能够计算出每个区域的奶牛数量,并不关心坐标具体的数值,只要能保持坐标值的相对大小关系即可。我们可以在保持原数值之间相对大小关系不变的情况下将其映射成正整数,也就是给每个可能用到的数值按照大小关系分配一个编号,用此编号来代替原数值进行操作。这个过程就称为离散化。而离散化之后这个二维前缀和数组大小就只有 \(2000 \times 2000\) 级别了,就可以枚举每个点作为分界点进行计算比较了。

离散化的一种做法是将需要离散化的数值放入一个数组,对其排序,当需要知道某个原始值经过离散化之后映射成多少时利用二分查找返回其在有序数组中的位置即可。

#include <cstdio>
#include <algorithm>
using std::sort;
using std::lower_bound;
using std::max;
using std::min;
const int N = 2005; // 每个坐标有两个数值,离散化之后最多2000个点
int x[N], y[N], d[N], cnt, sum[N][N];
int getid(int num) { // 通过二分查找获取离散化之后的值
    return lower_bound(d + 1, d + cnt + 1, num) - d;
}
int main()
{
    int n;
    scanf("%d", &n); cnt = 2 * n;
    for (int i = 1; i <= n; i++) {
        scanf("%d%d", &x[i], &y[i]);
        d[i] = x[i]; d[i + n] = y[i];
    }
    sort(d + 1, d + cnt + 1); // 将涉及到的数据排序以便离散化
    for (int i = 1; i <= n; i++) {
        int xid = getid(x[i]), yid = getid(y[i]);
        sum[xid][yid]++;
    }
    // 离散化后预处理二维前缀和
    for (int i = 1; i <= cnt; i++)
        for (int j = 1; j <= cnt; j++)
            sum[i][j] += sum[i - 1][j] + sum[i][j - 1] - sum[i - 1][j - 1];
    int ans = n;
    for (int i = 1; i <= cnt; i++)
        for (int j = 1; j <= cnt; j++) {
            int m = 0;
            int dl = sum[i][j]; m = max(m, dl); // 左下
            int ul = sum[i][cnt] - dl; m = max(m, ul); // 左上 
            int dr = sum[cnt][j] - dl; m = max(m, dr); // 右下
            int ur = n - dl - ul - dr; m = max(m, ur); // 右上
            ans = min(ans, m);
        }
    printf("%d\n", ans);
    return 0;
}

习题:P6172 [USACO16FEB] Load Balancing P

解题思路

由于本题 \(n\) 达到 \(10^5\),因此二维前缀和的数组就开不下了。注意到这是一个典型的最大值最小化问题,是二分答案的经典应用场景。

一、二分答案

可以不直接求最小值,而是去猜这个 \(m\),然后去验证:是否存在一种栅栏放置方案,使得所有四个区域的奶牛数都不超过 \(m\)

  • 如果存在这样的方案,说明 \(m\) 可能偏大,或者正好是答案,可以尝试一个更小的 \(m\)
  • 如果不存在,说明 \(m\) 太小了,必须放宽条件,尝试一个更大的 \(m\)

这个验证过程满足单调性,因此可以通过二分法高效地找到满足条件的最小的 \(m\)

二、坐标离散化

原始坐标值可能很大,但奶牛数量 \(n\) 是有限的,坐标是稀疏的,为了方便处理,进行坐标离散化

  • 把所有奶牛的 \(x\) 坐标和 \(y\) 坐标分别收集起来。
  • 对它们进行排序和去重,然后用它们在排序后的排名(例如 \(1,2,3,\dots\))来代替原始的坐标值。
  • 这样,就把原始坐标映射到了从 \(1\)\(n\) 的整数上,可以直接作为数组的下标使用。

三、如何验证一个 \(m\) 是否可行

这是整个算法的核心,对于一个给定的 \(m\),需要判断是否存在一个十字路口 \((a,b)\) 满足条件。

枚举所有的垂直栅栏,对于每一个垂直栅栏,快速判断是否存在一个与之匹配的水平栅栏

一个垂直栅栏 \(x=a\) 会把平面分成左右两部分,假设把垂直栅栏放在了离散化后 \(x_{rank}\)\(i\)\(i+1\) 的两群奶牛之间。现在,需要找到一条水平栅栏 \(y=b\)(假设在 \(y_{rank} = h\)\(h+1\) 之间),使得四个区域的奶牛数都 \(\le m\)

为了对每一个 \(i\) 都能快速判断,进行预处理,计算四个数组:

  • \(dl_i\):考虑所有 \(x_{rank} \le i\) 的奶牛(左半边),为了让这部分区域的下方\(y_{rank \le h}\))奶牛数 \(\le m\),水平栅栏最高可以放在哪个高度 \(h\)
  • \(dr_i\):考虑所有 \(x_{rank} \ge i\) 的奶牛(右半边),为了让这部分区域的下方\(y_{rank \le h}\))奶牛数 \(\le m\),水平栅栏最高可以放在哪个高度 \(h\)
  • \(ul_i\):考虑所有 \(x_{rank} \le i\) 的奶牛(左半边),为了让这部分区域的上方\(y_{rank \ge h}\))奶牛数 \(\le m\),水平栅栏最低可以放在哪个高度 \(h\)
  • \(ur_i\):考虑所有 \(x_{rank} \le i\) 的奶牛(右半边),为了让这部分区域的上方\(y_{rank \le h}\))奶牛数 \(\le m\),水平栅栏最低可以放在哪个高度 \(h\)

这四个数组都可以通过双指针\(O(n)\) 的时间内计算出来。以左下方区域为例,随着 \(i\) 的右移,会引入一些新的点,可能导致区域内奶牛书超出 \(m\),此时可进一步下调水平线。可以发现,水平线只会往单个方向移动。

四、合并与最终检查

预处理完成后,就可以遍历所有垂直栅栏的位置 \(i\),对于每个 \(i\)

  • 水平栅栏必须低于 \(\min (dl_i, dr_{i+1}) + 1\) 才能满足下方两个区域的要求。
  • 水平栅栏必须高于 \(\max (ul_i, ur_{i+1}) - 1\) 才能满足上方两个区域的要求。

因此,只要存在一个 \(i\),使得 \(\max (ul_i, ur_{i+1}) - 1 \lt h \lt \min (dl_i, dr_{i+1}) + 1\) 是一个有效的不等式,就说明存在一个有效的水平栅栏位置,此时的 \(m\) 值是可行的。如果遍历完 \(i\) 都不满足,则 \(m\) 值不可行。

整个算法的时间复杂度为 \(O(n \log n)\),主要瓶颈在于排序和二分。

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 100005;
struct Point {
    int x, y;
};
Point p[N]; // 存储奶牛的坐标
// xcnt: 独立的x坐标数量,ycnt: 独立的y坐标数量,n: 奶牛总数
// cnt: 临时计数器,用于check函数中计算某条线上的奶牛数
int sum[N], xcnt, ycnt, n, cnt[N], dlh[N], drh[N], ulh[N], urh[N];
bool cmp_x(const Point& p1, const Point& p2) { // 比较函数,用于按x坐标排序
    return p1.x != p2.x ? p1.x < p2.x : p1.y < p2.y;
}
bool cmp_y(const Point& p1, const Point& p2) { // 比较函数,用于按y坐标排序
    return p1.y != p2.y ? p1.y < p2.y : p1.x < p2.x;
}
bool check(int m) { // 检查函数,判断是否存在一种划分方式,使得四个区域的奶牛数都 <= m
    // 通过四次扫描,计算出四个关键的辅助数组
    for (int i = 1; i <= ycnt; i++) cnt[i] = 0; // 重置计数器
    int cur = 0, idx = 1, h = ycnt; // cur: 当前区域牛数,idx: 点指针,h: 水平线位置
    for (int i = 1; i <= xcnt; i++) { // 从左到右扫描垂直线
        while (idx <= n && p[idx].x == i) { // 处理当前x坐标上的所有点
            cnt[p[idx].y]++; // 对应y坐标的点数+1
            if (p[idx].y <= h) cur++; // 如果点在当前水平线下方,计入
            idx++;
        }
        while (cur > m) { // 如果左下区域牛数超标
            cur -= cnt[h]; h--; // 从总数中减去最上面一行的牛,并将水平线下移
        }
        dlh[i] = h; // 记录下对于x<=i,合法的最高水平线位置
    }
    for (int i = 1; i <= ycnt; i++) cnt[i] = 0;
    cur = 0; idx = n; h = ycnt;
    for (int i = xcnt; i >= 1; i--) { // 从右到左扫描
        while (idx > 0 && p[idx].x == i) {
            cnt[p[idx].y]++;
            if (p[idx].y <= h) cur++;
            idx--;
        }
        while (cur > m) {
            cur -= cnt[h]; h--;
        }
        drh[i] = h; // 记录下对于x>=i,合法的最高水平线位置
    }
    for (int i = 1; i <= ycnt; i++) cnt[i] = 0;
    cur = 0; idx = 1; h = 1; // h从1开始向上扫描
    for (int i = 1; i <= xcnt; i++) { // 从左到右
        while (idx <= n && p[idx].x == i) {
            cnt[p[idx].y]++;
            if (p[idx].y >= h) cur++; // 如果点在当前水平线上方,计入
            idx++;
        }
        while (cur > m) { // 如果左上区域牛数超标
            cur -= cnt[h]; h++; // 从总数中减去最下面一行的牛,并将水平线向上移
        }
        ulh[i] = h; // 记录下对于x<=i,合法的最低水平线位置
    }
    for (int i = 1; i <= ycnt; i++) cnt[i] = 0;
    cur = 0; idx = n; h = 1;
    for (int i = xcnt; i >= 1; i--) { // 从右到左
        while (idx > 0 && p[idx].x == i) {
            cnt[p[idx].y]++;
            if (p[idx].y >= h) cur++;
            idx--;
        }
        while (cur > m) {
            cur -= cnt[h]; h++;
        }
        urh[i] = h; // 记录下对于x>=i,合法的最低水平线位置
    }
    // 遍历所有可能的垂直分割线
    for (int i = 1; i <= xcnt; i++) {
        int dh = min(dlh[i], drh[i + 1]);
        int uh = max(ulh[i], urh[i + 1]);
        // uh - 1 < h < dh + 1(因为牛都在原始的奇数点,线都在原始的偶数点)
        if (uh - 1 < dh + 1) return true; // 找到了一个可行的划分方案
    }
    return false; // 遍历完所有垂直线都找不到方案
}
int main()
{
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%d%d", &p[i].x, &p[i].y);
    // x坐标离散化
    sort(p + 1, p + n + 1, cmp_x);
    int pre = 0;
    for (int i = 1; i <= n; i++) {
        if (p[i].x != pre) {
            xcnt++; pre = p[i].x; sum[xcnt] = sum[xcnt - 1];
        }
        // 用排名替换原始坐标
        p[i].x = xcnt; sum[xcnt]++;
    }
    // y坐标离散化
    sort(p + 1, p + n + 1, cmp_y);
    pre = 0;
    for (int i = 1; i <= n; i++) {
        if (p[i].y != pre) {
            ycnt++; pre = p[i].y;
        }
        p[i].y = ycnt;
    }
    sort(p + 1, p + n + 1, cmp_x); // 按x坐标排好序,为check函数做准备
    // 二分答案
    int l = 1, r = n, ans = n; // 答案范围是 [1, n]
    while (l <= r) {
        int mid = (l + r) / 2; // 猜一个答案mid
        if (check(mid)) { // 如果mid可行
            r = mid - 1; ans = mid; // 更新最优解,尝试更小的答案
        } else {
            l = mid + 1; // mid太小了,需要更大的
        }
    }
    printf("%d\n", ans);
    return 0;
}

例题:P1884 [USACO12FEB] Overplanting S

这道题要求计算 \(n\) 个可能重叠的矩形所覆盖的总面积。直接计算每个矩形的面积然后相加,会因为重叠部分被重复计算而导致结果错误。使用容斥原理对于 \(n\) 较大的情况会变得极其复杂。

解决此类问题的经典高效算法是扫描线算法

一、核心思想:化整为零,化二为一

扫描线算法的核心思想,是将一个二维的面积问题,转化为一系列更容易处理的一维长度问题。

  1. 想象一条扫描线:想象一条垂直于 \(x\) 轴的直线,从左到右匀速地扫过整个平面。
  2. 离散化事件:只有当扫描线遇到矩形的左边界右边界时,被覆盖的区域才会发生变化。因此,可以将这些矩形的垂直边界的 \(x\) 坐标看作一个个“事件点”。
  3. 计算条带面积:在任意两个相邻的事件点 \(x_i\)\(x_{i+1}\) 之间,形成了一个狭长的垂直条带。在这个条带内部,没有任何矩形的边界,因此扫描线上被矩形覆盖的总有效高度是恒定的。
  4. 累加面积:这个条带的面积就等于 \((条带宽度) \times (有效高度)\),即 \((x_{i+1}-x_i) \times (有效高度)\)。只需要计算出每个条带的面积,然后将它们全部累加起来,就能得到最终的总面积。

二、关键挑战:如何高效计算“有效高度”

现在,问题转化为了:在每个条带内,如何快速计算出扫描线上被覆盖的区段的总长度(即“有效高度”)?

这本身是一个“一维线段求并集长度”的问题。如果每次都暴力计算会很慢。这里采用离散化区间覆盖计数的方法。

  1. \(y\) 坐标离散化
    • 虽然矩形的 \(y\) 坐标可能很大,但所有矩形的上下边界最多只有 \(2n\) 个不同的 \(y\) 坐标。
    • 将所有矩形的 \(y_1\)\(y_2\) 坐标收集起来,进行排序和去重。这样,就得到了一系列有序的、离散的 \(y\) 坐标,例如 \(y'_1, y'_2, \dots, y'_m\)
    • 这些离散的 \(y\) 坐标将 \(y\) 轴划分成了 \(m-1\) 个最基本的、不可再分的元区间 \(y'_j, y'_{j+1}\)
  2. 区间覆盖计数
    • 创建一个计数数组 \(cnt\),其中 \(cnt_j\) 用来记录第 \(j\) 个元区间 \([y'_j, y'_{j+1}]\) 被多少个当前活跃的矩形所覆盖
    • 当扫描线遇到一个矩形的左边界时,就将该矩形所覆盖的所有元区间的 \(cnt\) 值加 \(1\)
    • 当扫描线遇到一个矩形的右边界时,就将对应元区间的 \(cnt\) 值减 \(1\)
  3. 计算有效高度
    • 在任意时刻,想知道总的有效高度,只需要遍历所有的元区间。如果一个元区间 \([y'_j, y'_{j+1}]\)\(cnt_j\) 大于 \(0\),说明它被至少一个矩形覆盖,是有效部分。
    • 将所有 \(cnt_j \gt 0\) 的元区间的长度 \(y'_{j+1}-y'_{j}\) 相加,就得到了当前扫描线上的总有效高度。

三、算法整体流程

  1. 拆分矩形为扫描线:将每个矩形 \((x_1,y_1,x_2,y_2)\) 拆分成两条垂直的扫描线事件。
    • 一条在 \(x_1\) 处的入边,代表一个矩形开始覆盖,标记为 \(+1\)
    • 一条在 \(x_2\) 处的出边,代表一个矩形结束覆盖,标记为 \(-1\)
    • 每条线都包含其 \(x\) 坐标和 \(y\) 区间 \([y_1, y_2]\)
  2. \(y\) 坐标离散化:收集所有矩形的上下 \(y\) 坐标,排序去重,建立 \(y\) 坐标到其在离散数组中索引的映射。
  3. 排序扫描线:将所有的扫描线事件按 \(x\) 坐标从小到大排序。
  4. 执行扫描
    • 从左到右遍历排好序的扫描线事件。
    • 对于第 \(i\) 条扫描线和第 \(i+1\) 条扫描线之间的条带,计算其面积。此时的“有效高度”由处理完第 \(i\) 条线之后的 \(cnt\) 数组状态决定。
    • 将计算出的条带面积累加到总答案中。
    • 处理第 \(i\) 条扫描线事件:根据是入边还是出边,更新其 \(y\) 区间所对应的元区间的 \(cnt\) 值(加 \(1\) 或减 \(1\))。
    • 重复此过程,直到所有扫描线事件处理完毕。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
typedef long long LL;
const int N = 2005; // 最多1000个矩形,产生2000条扫描线
// 定义扫描线结构体
struct Line {
    int x;      // 扫描线的x坐标
    int y1, y2; // 扫描线覆盖的y区间
    int flag;   // 标记是入边(1)还是出边(-1)
    bool operator<(const Line& other) const { // 重载小于号,用于排序
        if (x != other.x) return x < other.x;
        if (y1 != other.y1) return y1 < other.y1;
        if (y2 != other.y2) return y2 < other.y2;
        return flag < other.flag;
    }
};
Line l[N];  // 存储所有扫描线
int y[N];   // 存储所有y坐标用于离散化
int ycnt;   // 离散化后不同y坐标的数量
int cnt[N]; // 计数数组,cnt[j]表示元区间被覆盖的次数
// 将原始y坐标映射到离散化后的索引
int y2id(int num) {
    // 使用二分查找找到num在离散化数组y中的位置
    return lower_bound(y + 1, y + ycnt + 1, num) - y;
}
int main()
{
    int n; scanf("%d", &n);
    // 拆分矩形为扫描线事件
    for (int i = 1; i <= n; i++) {
        int x1, y1, x2, y2; scanf("%d%d%d%d", &x1, &y1, &x2, &y2);
        l[i] = {x1, y2, y1, 1}; l[i + n] = {x2, y2, y1, -1}; // 创建入边和出边
        y[i] = y1; y[i + n] = y2; // 收集所有y坐标用于离散化
    }
    n *= 2; // 总共有2n条扫描线
    sort(l + 1, l + n + 1); 
    sort(y + 1, y + n + 1);
    ycnt = unique(y + 1, y + n + 1) - y - 1;
    LL ans = 0; // 存储总面积
    // 执行扫描
    // 遍历n-1个条带,第i个条带是 l[i].x 到 l[i+1].x 之间
    for (int i = 1; i < n; i++) {
        // 处理第i条扫描线事件,更新cnt数组
        int a = y2id(l[i].y1), b = y2id(l[i].y2);
        for (int j = a; j < b; j++) cnt[j] += l[i].flag; // 对这条线覆盖的所有元区间进行计数更新
        // 计算当前条带的有效高度
        int len = 0; 
        for (int j = 1; j < ycnt; j++) 
            if (cnt[j] > 0) len += y[j + 1] - y[j]; // 如果元区间被覆盖次数大于0,累加其长度
        ans += 1ll * len * (l[i + 1].x - l[i].x); // 计算条带面积并累加到总面积,宽度是 l[i+1].x - l[i].x
    }
    printf("%lld\n", ans);
    return 0;
}
posted @ 2024-08-11 13:39  RonChen  阅读(89)  评论(0)    收藏  举报