离散化

通俗地讲,“离散化”就是把无穷大的集合中的若干个元素映射为有限集合以便于统计的方法。例如,在很多情况下,问题的范围虽然定义在整数集合 \(\mathbb{Z}\),但是只涉及其中 \(m\) 个有限数值,并且与数值的绝对大小无关(只把这些数值作为代表,或只与它们的相对大小有关)。此时,可以把整数集合 \(\mathbb{Z}\) 中的这 \(m\) 个整数与 \(1 \sim m\) 建立映射关系。如果有一个时间、空间复杂度与数值范围 \(\mathbb{Z}\) 的大小有关的算法,在离散化后,该算法的时间、空间复杂度就降低为与 \(m\) 相关。

具体地说,假设问题涉及 int 范围内的 \(n\) 个整数 \(a_1 \sim a_n\),这 \(n\) 个整数可能有重复,去重以后共有 \(m\) 个整数。要把每个整数 \(a_i\) 用一个 \(1 \sim m\) 之间的整数代替,并且保持大小顺序不变,即如果 \(a_i\) 小于(或等于、大于)\(a_j\),那么代替 \(a_i\) 的整数也小于(或等于、大于)代替 \(a_j\) 的整数。

可以把 \(a\) 数组排序并去掉重复的数值,得到有序数组 \(b_1 \sim b_m\),在 \(b\) 数组的下标 \(i\)\(b_i\) 之间建立映射关系。若要查询 \(i \ (1 \le i \le m)\) 代替的数值,只需直接返回 \(b_i\);若要查询 \(a_j \ (1 \le j \le n)\) 被哪个 \(1 \sim m\) 之间的整数代替,只需在数组 \(b\) 中二分查找 \(a_j\) 的位置即可。

例题:CF670C Cinema

对于每一部电影,需要计算出它能带来的“愉悦”观众数和“满意”观众数,然后根据题目的双重优化目标(愉悦人数优先,满意人数其次)来找到最佳电影。

“愉悦”人数等于懂配音语言的科学家总数,“满意”人数等于懂字幕语言的科学家总数。因此,问题转化为如何高效地查询“有多少科学家懂某种特定的语言”。

语言的 ID 范围特别大(高达 \(10^9\)),无法直接使用一个大数组来存储每种语言的人数。然而,实际出现的不同语言种类最多为 \(n+2m\),远小于 \(10^9\),这样就可以考虑使用离散化处理。

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 200005;
// a: 存储科学家语言的离散化后索引
// tmp: 离散化辅助数组,存储排序和去重后的科学家语言ID
// b: 存储电影配音语言的离散化后索引
// c: 存储电影字幕语言的离散化后索引
// cnt: 语言计数数组,cnt[i]表示掌握第i种语言(离散化后)的科学家数量
// len: 科学家掌握的独立语言的数量
int len, a[N], tmp[N], b[N], c[N], cnt[N];

// 将原始语言ID通过二分查找转换为离散化后的索引
// 如果该语言不存在于科学家的语言列表中,返回0
int val_to_idx(int x) {
    // 在排好序、去重后的tmp数组中二分查找语言x
    int idx = lower_bound(tmp + 1, tmp + len + 1, x) - tmp;
    // 如果找到了完全匹配的x,返回其索引;否则返回0
    return idx <= len && tmp[idx] == x ? idx : 0;
}

int main()
{
    int n;
    scanf("%d", &n);
    // 读入科学家语言,并复制一份到tmp数组用于后续的离散化处理
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
        tmp[i] = a[i];
    }

    // --- 离散化过程 ---
    // 1. 对科学家掌握的语言ID进行排序
    sort(tmp + 1, tmp + n + 1);   
    // 2. 去除重复的语言ID,len记录下独立语言的数量
    len = unique(tmp + 1, tmp + n + 1) - tmp - 1;

    // --- 统计每种语言的科学家数量 ---
    for (int i = 1; i <= n; i++) {
        // 将原始语言ID转换为离散化后的索引
        a[i] = val_to_idx(a[i]);
        // 对该语言的计数器加一
        cnt[a[i]]++;
    }

    int m;
    scanf("%d", &m);
    // 读入电影配音语言并进行离散化
    for (int i = 1; i <= m; i++) {
        scanf("%d", &b[i]);
        b[i] = val_to_idx(b[i]);
    }
    // 读入电影字幕语言并进行离散化
    for (int i = 1; i <= m; i++) {
        scanf("%d", &c[i]);
        c[i] = val_to_idx(c[i]);
    }

    // --- 寻找最优电影 ---
    int ans = 1; // 假设第一部电影是当前最优解
    for (int i = 2; i <= m; i++) {
        // 比较愉悦人数:如果当前电影i的愉悦人数更多
        if (cnt[b[i]] > cnt[b[ans]]) {
            ans = i;
        } 
        // 如果愉悦人数相同,则比较满意人数
        else if (cnt[b[i]] == cnt[b[ans]] && cnt[c[i]] > cnt[c[ans]]) {
            ans = i;
        }
    }

    printf("%d\n", ans);
    return 0;
}

例题: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;
}

习题:P2862 [USACO06JAN] Corral the Cows G

给定 \(N \ (N \le 500)\) 个三叶草的位置 \((X,Y) \ 1 \le X,Y \le 10000\),每个坐标代表一个 \(1 \times 1\) 的区域。求一个最小边长的正方形畜栏,使得该畜栏至少包含 \(C\) 个三叶草。

解题思路

坐标范围是 \(1\)\(10000\),如果直接开 \(10000 \times 10000\) 的二维数组计算前缀和,内存占用约为 400MB,会超过题目限制。

考虑到三叶草的数量 \(N\) 只有 \(500\),可以使用离散化技术。这样,就将网格大小从 \(10000 \times 10000\) 压缩到了 \(N \times N\),可以安全地使用二维数组。

构建一个 \(N \times N\) 的二维数组 \(S_{i,j}\),表示离散化网格中 \((1,1)\)\((i,j)\) 矩形区域内三叶草的总数。利用前缀和,可以在 \(O(1)\) 时间内查询任意离散化矩形区域内的三叶草数量。

畜栏的边长 \(L\) 具有单调性,如果边长 \(L\) 满足条件,那么边长 \(L+1\) 也一定满足。因此可以对边长进行二分,二分范围是 \([1,10000]\)

如何检查边长 $L$ 是否可行?

需要在离散化后的网格中找到一个矩形区域,满足该区域对应的原坐标跨度不超过 \(L\) 并且该区域内包含的三叶草数量 \(\gt C\)

可以使用双指针来优化枚举过程。枚举离散化后 \(X\) 轴的起始下标,找到满足原距离限制的最大结束下标。对于固定的 \(X\) 轴范围,枚举离散化 \(Y\) 轴的起始下标,找到满足原距离限制的最大结束下标。计算离散化矩形内的三叶草数量,只要任何一个这样的矩形内的三叶草数量,若 \(\gt C\),即边长 \(L\) 可行。

总时间复杂度为 \(O(N^2 \log N)\)

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;

const int N = 505;
// x[], y[] 存储输入的三叶草坐标
// xc[], yc[] 用于存储离散化后的坐标值
// sum[][] 二维前缀和数组
int c, x[N], y[N], xc[N], nx, yc[N], ny, sum[N][N];

// 辅助函数:二分查找离散化后的下标(1-based)
int id(const int c[], int len, int val) {
    return lower_bound(c, c + len, val) - c + 1;
}

// 检查函数:是否存在边长为 len 的正方形包含至少 C 个三叶草
bool check(int len) {
    // 题目中坐标代表 1x1 区域。
    // 如果要覆盖点 x1 到 x2 的区域,实际所需的边长是 x2 - x1 + 1。
    // 反之,如果有边长 len,那么能够覆盖的最大坐标差(点跨度)是 len - 1。
    int diff = len - 1;
    
    // 使用双指针优化检查过程
    // 枚举 x 轴方向的起始离散化坐标索引 i
    int j = 1;
    for (int i = 1; i <= nx; i++) {
        // 找到满足 xc[j-1] - xc[i-1] <= diff 的最大 j
        // 即找到在 x 方向上能够被长度 len 覆盖的最远右边界
        while (j <= nx && xc[j - 1] - xc[i - 1] <= diff) j++;
        int vj = j - 1; // vj 是满足条件的右边界索引
        
        // 内层循环枚举 y 轴方向,同样使用双指针
        int q = 1;
        for (int p = 1; p <= ny; p++) {
            // 找到满足 yc[q-1] - yc[p-1] <= diff 的最大 q
            while (q <= ny && yc[q - 1] - yc[p - 1] <= diff) q++;
            int vq = q - 1; // vq 是满足条件的上边界索引
            
            // 利用二维前缀和计算矩形区域 [i, vj] x [p, vq] 内的三叶草数量
            int cnt = sum[vj][vq] - sum[i - 1][vq] - sum[vj][p - 1] + sum[i - 1][p - 1];
            
            // 如果数量满足要求,则当前边长 len 可行
            if (cnt >= c) return true;
        }
    }
    return false;
}

int main()
{
    int n; scanf("%d%d", &c, &n);
    for (int i = 0; i < n; i++) {
        scanf("%d%d", &x[i], &y[i]);
        xc[i] = x[i]; yc[i] = y[i];
    }
    
    // 离散化 x 坐标
    sort(xc, xc + n);
    nx = unique(xc, xc + n) - xc;
    
    // 离散化 y 坐标
    sort(yc, yc + n);
    ny = unique(yc, yc + n) - yc;
    
    // 构建离散化后的网格,标记三叶草位置
    for (int i = 0; i < n; i++) {
        sum[id(xc, nx, x[i])][id(yc, ny, y[i])]++;
    }
    
    // 计算二维前缀和
    // sum[i][j] 表示离散化坐标区域 [1, i] x [1, j] 内的三叶草总数
    for (int i = 1; i <= nx; i++) {
        for (int j = 1; j <= ny; j++) {
            sum[i][j] += sum[i - 1][j] + sum[i][j - 1] - sum[i - 1][j - 1];
        }
    }
    
    // 二分查找最小边长
    // 边长范围是 1 到 10000
    int low = 1, high = 10000, ans = 10000;
    while (low <= high) {
        int mid = low + (high - low) / 2;
        if (check(mid)) {
            ans = mid; high = mid - 1;
        } else {
            low = mid + 1;
        }
    }
    printf("%d\n", ans);
    return 0;
}
posted @ 2024-08-11 13:39  RonChen  阅读(131)  评论(0)    收藏  举报