关于算法竞赛中的离散化Trick

算法竞赛中的离散化操作

Overview

什么是离散化?为什么要用离散化?

问题:给出一个长度为n的整数数组,求出这个数组的逆序对[1]。其中,\(1<=n<=5×10^5\)\(1<= a[i] <= 10^9\)

对于 $ a[i]$ 比较小的情况,我们可以直接开一个权值树状数组来解决这个问题;反之,由于 $ a[i]$ 最大是 \(10^9\),假设我们直接开一个\(10^9\)的树状数组,必然会炸空间。

其实我们没有必要建立那么大的一个树状数组,因为实际上涉及到的数据只有\(n_{max}=5×10^5\)个,并且我们并不需要考虑数据具体的值,而是他们之间相对的大小关系,因此,我们可以建立一种映射,让数据从小到大,分别对应 1~n,就像下面这样。

[999 99999 114514 123456] ->
[1	 2	   3	  4]

这样,我们就只需要开一个\(O(n)\)的树状数组,这个是可接受的。

如何实现离散化?

总结来说,离散化的步骤就是以下几点:

  1. 将原数组赋值给离散化数组
  2. 对离散化数组进行排序、去重
  3. 需要用到原数组的数据时,用二分查找在离散化数组里面找

而根据问题的不同,实现上略微有些差别。下文将结合不同的类型和问题进行讲解。

Insight

单点离散化

对于只涉及单点操作的问题,我们根据之前所说进行离散化即可。具体的实现代码可以参考:

auto get_disc = [&](vector<int> &v) {
	auto disc = v;
    sort(disc.begin(), disc.end());
    disc.erase(unique(disc.begin(), disc.end()), disc.end());
    // 如果原数组的值不重要 可以用下面这句话方便操作
    // for (auto &x : v) x = lower_bound(disc.begin(), disc.end(), x) - disc.begin() 
    return disc;
};

值得一提的是,由于树状数组并不支持0-based下标,需要额外进行操作:

  1. disc数组里面插入一个值域中的极小值,让这个值映射到0位置。
  2. 在查找的时候,用lower_bound() + 1,进行偏移

接下来我们来看几个具体的问题来看这个Trick的实现。


洛谷 P1908 逆序对

题目描述:给出一个长度为n的整数数组,求出这个数组的逆序对。其中,$ 1<=n<=5×10^5 \(,\) 1<= a[i] <= 10^9 $

题目解析:这个题目就是一开始给出的例题,也是值域树状数组最经典的运用。我们需要把给出的数组进行离散化,然后在遍历整个数组的时候用树状数组来统计后面几个小或者前面几个大

代码实现:

void solve() {
  int n;
  cin >> n;
  vector<int> a(n);
  for (int i = 0; i < n; i++) {
    cin >> a[i];
  }

  auto get_disc = [&](vector<int> &v) {
    auto disc = v;
    sort(disc.begin(), disc.end());
    disc.erase(unique(disc.begin(), disc.end()), disc.end());
    for (int &x : v) x = lower_bound(disc.begin(), disc.end(), x) - disc.begin() + 1;
    return disc;
  };

  auto disc = get_disc(a);
  int m = disc.size();

  BIT<int> Fenwick(m);

  i64 ans = 0;
  // 从前往后走,每次看前面几个小,用i-(比我小)找到(比我大)
  // for (int i = 0; i < n; i++) {
  //   ans += i - Fenwick.query(a[i]);
  //   Fenwick.update(a[i], 1);
  // }
    
  // 从后往前走,每次看后面几个小
  for (int i = n - 1; i >= 0; i--) {
    ans += Fenwick.query(a[i] - 1);
    Fenwick.update(a[i], 1);
  }
  cout << ans << '\n';
}

洛谷 P1966 火柴排队

题目描述:给两个长度为 \(n\) 的数组\(a\)\(b\),每次可以交换相邻两个元素,问:当\(min(\Sigma_{i=1}^{n}(a_i-b_i)^2)\)成立时,最小的操作次数(要求结果对于\(10^8-3\)取模)。

其中,\(1<=n<=10^5\)\(0<=a_i,b_i<2^{31}\),且\(a,b\)均不包含重复元素。

题目解析:一个很朴素的贪心是,对于每一个位置,都让a最大的减去b最大的,以此类推。可以使用局部扰动法进行证明:

假设\(a_1>a_2\)\(b_1>b_2\),即证:\((a_1 - b_1)^2 + (a_2 - b_2)^2 < (a_1-b_2)^2 + (a_2 - b_1)^2\)

对不等式两边展开、约项、提项,原式等价于:\(a_1(b_1-b_2) > a_2(b_1-b_2)\)

由于\(b_1-b_2>0\)\(a_1>a_2\),上式恒成立,可以说明我们的贪心策略是正确的。

由于我们只关心数据的相对大小关系,那么我们可以将两个数组看作两个排列,用一个样例来解释就是:

a:[1 3 4 2] ->
  [1 3 4 2]
b:[1 7 2 4] ->
  [1 4 2 3]

现在问题转化成:给你两个排列,每次操作交换相邻元素,现把其中一个排列变为另一个排列,求出最小的操作次数。

我们还是用上述的样例来解释做法:

a:[1 3 4 2]
b:[1 4 2 3]
每个数字应该前往的位置
a->b:[1 4 2 3]
当[1 4 2 3]这个排列变为[1 2 3 4]时,我们就完成了操作
每次只能交换两个相邻元素,那么就是求[1 4 2 3]这个排列的逆序对

至此,我们就把问题转化成了第一个例题。

代码实现:

constexpr int MOD = 1E8 - 3;

void solve() {
  int n;
  cin >> n;
  vector<int> a(n), b(n);
  for (int i = 0; i < n; i++) {
    cin >> a[i];
  }
  for (int i = 0; i < n; i++) {
    cin >> b[i];
  }
    
  // 离散化标准操作
  auto get_disc = [&](vector<int> &v) {
      auto disc = v;
      sort(disc.begin(), disc.end());
      disc.erase(unique(disc.begin(), disc.end()), disc.end());
      for (int &x : v) x = lower_bound(disc.begin(), disc.end(), x) - disc.begin();
      return disc;
  };

  auto disca = get_disc(a);
  auto discb = get_disc(b);

  // 因为题目说了没有重复元素,我们可以确定disc一定是0~n-1的排列,开n个桶和树状数组
  vector<int> bucket(n);
  BIT<int> Fenwick(n);

  // 找到目标位置
  for (int i = 0; i < n; i++) {
    bucket[b[i]] = i;
  }

  i64 ans = 0;

  for (int i = n - 1; i >= 0; i--) {
    int target = bucket[a[i]];    // 每次先找到a[i]应该去哪里
    ans += Fenwick.query(target); // 用树状数组查一下:后面几个小
    Fenwick.update(target + 1, 1); // 前面都是0-based,这里记得+1转化一下
    if (ans >= MOD) ans -= MOD;
  }

  cout << ans << '\n';
}

区间离散化

对于涉及到区间操作的问题,我们需要使用的就是区间的离散化。

区间离散化的关键思想是,我把整个要操作的区间划分成若干个小区间,不重不漏,每一个区间的权值当前区间的长度,或者说是离散化数组中,两个相邻数字的差值

简单来说,区间离散化涉及到的Trick有:

  1. 对于操作的区间[l, r]->[l, r + 1),即闭区间左闭右开区间
  2. 往往需要在离散化数组中,进行补点,来表示整个区间/补充需要统计的区间

由于这种Trick不结合具体题目会很难理解,下面选取了四个相关的题目进行讲解。


AT_ABC_435E 区间染色

题目描述:现有区间\([1, n]\)(可以认为每一个数字对应一个格子),一开始全是白色。进行\(q\)次操作,每次操作给出区间的端点[l, r],并把这段区间涂成黑色。问:每次涂色之后,还有多少白色的格子?其中,\(1\leq N\leq 10^9\)\(1\leq Q\leq 2\times 10^5\)\(1\leq L_i\leq R_i\leq N\)

题目解析:由于我们\(N\)非常大,直接用数据结构进行操作肯定会炸。所以我们需要使用区间离散化。

我们来看一个具体的例子:

n=10, q=5
[3 5]
[8 9]
[5 8]
[2 9]
[6 6]

假设我们按照之前的方式进行离散化,那么我们将会得到:

disc:[2, 3, 5, 6, 8, 9] ->
	 [1, 2, 3, 4, 5, 6]

我们会发现

  1. 这样每个区间端点有可能会重合,导致区间端点在统计贡献的时候,被重复计算(会不会重复计算,需要根据具体题目具体分析,这里想说的是这种方式存在BUG的隐患
  2. 我们在开数据结构的时候,如果只开离散化数组的大小,没有办法覆盖两边的区间(以上述为例,没法覆盖[1, 2][9, 10]),会导致在统计答案的时候有BUG。

为了解决第一个问题,我们通常把区间 \([L, R]\) 统一转化为 左闭右开区间 \([L, R+1)\)

针对上面的例子,我们这样处理:

  1. 收集端点:对每一个操作 \([L_i, R_i]\),我们将 \(L_i\)\(R_i + 1\) 存入离散化数组。
    • 例如 [3, 5] 变为 [3, 6)
    • [8, 9] 变为 [8, 10)
    • [5, 8] 变为 [5, 9)
    • ...以此类推。
  2. 去重排序:得到 disc = {2, 3, 5, 6, 7, 8, 9, 10}
  3. 定义区间块:离散化数组中相邻的两个数 \(disc[i]\)\(disc[i+1]\) 构成一个左闭右开的物理区间 \([disc[i], disc[i+1])\)
    • 权值:这个区间的长度就是 \(disc[i+1] - disc[i]\)
    • 对应映射:这个物理区间对应离散化数组中的索引 \(i\)
映射表展示:
索引 i 离散化区间 [disc[i],disc[i+1]) 对应格子数 (权值)
1 \([2, 3)\) \(3 - 2 = 1\)
2 \([3, 5)\) \(5 - 3 = 2\)
3 \([5, 6)\) \(6 - 5 = 1\)
4 \([6, 7)\) \(7 - 6 = 1\)
5 \([7, 8)\) \(8 - 7 = 1\)
6 \([8, 9)\) \(9 - 8 = 1\)
7 $$[9, 10)$$ $$10 - 9 = 1$$

这样处理的好处:

现在,原题中的“染色”操作就变成了线段树或珂朵莉树上的标准区间修改。

  • 比如要把 \([3, 5]\) 染色,等价于把 \([3, 6)\) 染色。
  • 在离散化后的数组中,找到 \(3\) 的索引是 \(2\)\(6\) 的索引是 \(4\)
  • 我们直接修改离散化索引区间 \([2, 3]\)(即索引 \(2\)\(3\) 对应的块)。
最终计算:

每次染色后,白格子的数量 = \(N - \sum(\text{被覆盖区间的权值})\)。由于 \(N\) 很大,我们只需维护线段树中被覆盖的权值总和即可。

为了解决第二个问题,我们只需要补上两边的点1和n+1,就可以表示整个区间了。

于是我们可以得到最后的思路:先对给出的操作进行区间离散化,同时补上两边的点,用线段树维护区间推平,每次查询整个区间的大小。

代码实现:

struct Tag {
  int v = 0;
  void apply(const Tag &rhs) {
    v |= rhs.v;
  }
};

struct Info {
  int v = 1;
  Info operator+(const Info &rhs) {
    return {v + rhs.v};
  }
  void apply(const Tag &tag) {
    if (tag.v == 0) return;
    v = 0;
  }
};

void solve() {
  int n, q;
  cin >> n >> q;
  // 先离线一下操作 进行预处理
  vector<pair<int, int>> ops(q);

  // 记得补点
  vector<int> disc = {1, n + 1};
  for (auto &[l, r] : ops) {
    cin >> l >> r;
    disc.push_back(l);
    disc.push_back(r + 1);
  }

  // 离散化常规操作
  sort(disc.begin(), disc.end());
  disc.erase(unique(disc.begin(), disc.end()), disc.end());
  for (auto &[l, r] : ops) {
    l = lower_bound(disc.begin(), disc.end(), l) - disc.begin();
    r = lower_bound(disc.begin(), disc.end(), r + 1) - disc.begin();  // 这里用r + 1离散化坐标  
  }

  // 现在,整个大的区间已经被划分成了m个小的左闭右开区间
  // 我们线段树只需要维护着m个小区间就行,你可以把每一个小区间想象成一个新的点
  // 我们使用init数组来初始化,每一个区间的长度就是离散化中两个数字的差值(因为是左闭右开)
  int m = disc.size() - 1;
  vector<Info> init(m);
  for (int i = 0; i < m; i++) {
    init[i].v = disc[i + 1] - disc[i];
  }

  // 线段树模板
  LazySegmentTree<Info, Tag> seg(init);

  for (auto &[l, r] : ops) {
    // 每次推l, r这个区间
    // Tag: 1表示推平了 0表示没推平
    seg.rangeApply(l, r, {1});
    // 查询整个范围的权值
    cout << seg.rangeQuery(0, seg.n).v << '\n';
  }
}

我的线段树模板外部接口是0-based左闭右开,因此我这里直接使用[l, r]就行了,使用不同接口的模板可能会在实现上有一些茶别。


Leetcode 699.掉落的方块

题目描述:在二维平面上的 x 轴上,放置着一些方块。

给你一个二维整数数组 positions ,其中 positions[i] = [lefti, sideLengthi] 表示:第 i 个方块边长为 sideLengthi ,其左侧边与 x 轴上坐标点 lefti 对齐。

每个方块都从一个比目前所有的落地方块更高的高度掉落而下。方块沿 y 轴负方向下落,直到着陆到 另一个正方形的顶边 或者是 x 轴上 。一个方块仅仅是擦过另一个方块的左侧边或右侧边不算着陆。一旦着陆,它就会固定在原地,无法移动。

在每个方块掉落后,你必须记录目前所有已经落稳的 方块堆叠的最高高度

返回一个整数数组 ans ,其中 ans[i] 表示在第 i 块方块掉落后堆叠的最高高度。

  • \[1 <= positions.length <= 1000 \]

  • \[1 <= left[i] <= 10^8 \]

  • \[1 <= sideLength[i] <= 10^6 \]

示例 1:

输入:positions = [[1,2],[2,3],[6,1]]
输出:[2,5,5]
解释:
第 1 个方块掉落后,最高的堆叠由方块 1 组成,堆叠的最高高度为 2 。
第 2 个方块掉落后,最高的堆叠由方块 1 和 2 组成,堆叠的最高高度为 5 。
第 3 个方块掉落后,最高的堆叠仍然由方块 1 和 2 组成,堆叠的最高高度为 5 。
因此,返回 [2, 5, 5] 作为答案。

示意图

题目解析:我们把每个方块对应的x轴的范围看作是一个区间,类似于上一题的思路,只需要进行区间覆盖,同时维护一下最大高度。由于\(left_{max} = 10^8\),显然需要我们进行区间的离散化。

题目给出的数据是position[i] = {left, len},那么我们可以通过right - left + 1 = len来求出right。需要注意的是,我们之前将[l, r] -> [l, r + 1)是建立在我们把每个位置看作是一个方块的下标,而不是某一个刻度的坐标(如下图)

示意图

那么,我们现在[l, r]的区间(数轴意义上),就可以映射成[l, l +len)的区间(块意义上),这样更符合我们的操作。

我们需要用线段树维护Info:当前区间的最大高度mxTag:change表示是否被别的方块叠在底下,update维护现在应该变成多高

代码实现:

struct Tag {
    bool change = 0;
    int update = 0;
    void apply(const Tag &rhs) {
        // 如果没有被叠加,直接返回
        if (rhs.change == 0) return;
        // 被改变了,合并时要上传“已被改变”
        // 并且,要保留一下被更新成了什么值
        change = rhs.change;
        update = rhs.update;
    }
};

struct Info {
    int mx = 0;
    Info operator+(const Info &rhs) {
        return {max(mx, rhs.mx)};
    }
    void apply(const Tag &tag) {
        // 如果没被叠加,直接返回
        if (tag.change == 0) return;
        // 否则,使用更新的数据
        mx = tag.update;
    }
};

class Solution {
public:
    vector<int> fallingSquares(vector<vector<int>>& positions) {
        // ans统计数据
        vector<int> ans;
        // 记得补一下两边的点,这里我用INT_MAX来表示无穷,也可以用maxv + 1来表示
        vector<int> disc = {0, INT_MAX};
        for (auto &v : positions) {
            // 维护区间[l, len)
            int l = v[0], len = v[1];
            disc.push_back(l);
            disc.push_back(l + len);
        }

        // 排序去重
        ranges::sort(disc);
        disc.erase(unique(disc.begin(), disc.end()), disc.end());

        // 由于这里答案的统计是直接从mx获得
        // 我们不关心块之间的权值贡献
        // 所以不需要用init数组来初始化
        int m = disc.size() - 1;
        LazySegmentTree<Info, Tag> seg(m);

        for (auto &v : positions) {
            int L = v[0], len = v[1], R = L + len;
            int l = lower_bound(disc.begin(), disc.end(), L) - disc.begin();
            int r = lower_bound(disc.begin(), disc.end(), R) - disc.begin();

            seg.rangeApply(l, r, {true, seg.rangeQuery(l, r).mx + len});
            ans.push_back(seg.rangeQuery(0, seg.n).mx);
        }

        return ans;
    }
};

洛谷 P3740 贴海报

题目描述:现在给一个长度为n的区间[1, n],有m次操作,每次操作给出区间[l, r],表示这一段用新的一种海报覆盖,现在问:进行了所有的操作时候,还能看到的海报种类。

注意:这道题目洛谷的数据范围是比较弱的,如果不使用离散化也是可以过的,POJ有一个数据更强的测试Case,但是我不怎么会写老版本的CPP,就倒闭了(

我们尝试使用线段树进行维护,每个区间维护有没有空位,这样在我们从后往前遍历的时候,只需要查询一下这个区域有没有空位,就知道遍历到的这种海报是否漏出来。懒标记维护是否覆盖,每次只需要去维护一下覆盖就行。

代码实现:

struct Tag {
  // 懒标记 维护区间是否被覆盖
  // 一开始空的时候 都是没有覆盖
  bool cover = false;
  void apply(const Tag &rhs) {
    cover |= rhs.cover;
  }
};

struct Info {
  // 节点信息 维护是否有空位置
  // 默认构造一个空的Info的空值是False
  bool has = false;
  // 维护区间合并时Info的合并
  Info operator+(const Info &rhs) {
    return {has || rhs.has};
  }
  // 如果 tag 说已经覆盖 那么没有空位
  void apply(const Tag &tag) {
    if (tag.cover) {
      has = false;
    }
  }

};

void solve() {
  int n, m;
  cin >> n >> m;
  vector<pair<int, int>> ops(m);
  vector<int> disc;
  // 经典离散化
  for (auto &[l, r] : ops) {
    cin >> l >> r;
    // 维护[l, r + 1)
    disc.push_back(l);
    disc.push_back(r + 1);
  }

  sort(disc.begin(), disc.end());
  disc.erase(unique(disc.begin(), disc.end()), disc.end());

  for (auto &[l, r] : ops) {
    l = lower_bound(disc.begin(), disc.end(), l) - disc.begin();
    r = lower_bound(disc.begin(), disc.end(), r + 1) - disc.begin();
  }
    
  // 我喜欢线段树结构体写空值,然后在初始化的时候用init显式初始化
  // 虽然经常在这里WA 得调一会儿 QWQ
  vector<Info> init(disc.size() - 1);
  for (int i = 0, SIZE = init.size(); i < SIZE; i++) {
    init[i].has = true;
  }

  LazySegmentTree<Info, Tag> seg(init);
  i64 ans = 0;

  // 倒着看每个海报的覆盖情况
  for (int i = m - 1; i >= 0; i--) {
    auto &[l, r] = ops[i];
    // 如果[l, r]有空位,那么统计一下答案,然后覆盖掉
    if (seg.rangeQuery(l, r).has) {
      ans++;
      seg.rangeApply(l, r, {true});
    }
    // 如果原本没有空位,我们也没必要再覆盖一遍
  }

  cout << ans << '\n';
}

如果用另一种方式思考,我们维护的信息稍微变一下呢?维护每个节点的信息Info:id如果区间颜色统一,id的值就是海报的编号,反之是0,懒标记Tag:表示该区间被哪张海报完全覆盖,最后统计答案的时候,我们把所有的标记从下往上逐层下方,然后遍历所有的叶子节点,看一下有多少个海报的ID出现过就行了。

但是这个时候就出现了一个问题。因为我们区间离散化的时候,是把区间的端点搞进去离散化,那么如果我现在放置海报的操作如下:

[1, 8]
[1, 3]
[5, 8]

那么理论上我统计答案的时候,应该是需要统计出三种的,但是因为离散化之后3和5这两个点之间是少了一个缝隙的,那么我统计就会错过中间的这一段。

这就引入了我们的第二个技巧:补点。如果我们发现离散化的两个点实际坐标不相邻,那么我们可以补上一个点,来表示这里有一个缝隙。

另外,在这种补点的做法里面,不需要我们使用[l, r + 1)来进行离散化。

代码实现:

// Tag 代表懒惰标记:这里表示该区间被哪张海报完全覆盖
struct Tag {
  int id = 0;  // 0 表示没有覆盖标记,正数表示海报编号
  void apply(const Tag &rhs) {
    if (rhs.id != 0) {
      id = rhs.id;
    }
  }
};

// Info 代表节点信息id,如果区间不统一 就是0或-1
struct Info {
  int id = 0;  
  Info operator+(const Info &rhs) {
    if (id == rhs.id) return {id};
    return {-1};
  }
  void apply(const Tag& v) {
    if (v.id != 0) {
      id = v.id;
    }
  }
};

void solve() {
  int n, m;
  cin >> n >> m;

  vector<pair<int, int>> ops(m);
  vector<int> disc;
  for (auto &[l, r] : ops) {
    // 这里我们存的时候直接存 L,R就好 后文会解释
    cin >> l >> r;
    disc.push_back(l);
    disc.push_back(r);
  }

  sort(disc.begin(), disc.end());
  disc.erase(unique(disc.begin(), disc.end()), disc.end());

  // 存完了之后还需要补一些点,这里我们新开一个离散化数组和权值数组
  // 在这道题中,这个权值是用不到的,但是在其他题目更通用
  // 默认先开1位是为了放入左端点,可以认为是负无穷
  vector<int> pos(1), w(1);

  for (int i = 0, SIZE = disc.size(); i < SIZE; i++) {
    // 如果有前一个点 并且前一个点并不相邻 那么需要补点
    if (i && disc[i] - disc[i - 1] != 1) {
      pos.push_back(disc[i - 1] + 1);
      w.push_back(disc[i] - disc[i - 1] - 1);
    }
    // 不需要补就正常放
    pos.push_back(disc[i]);
    w.push_back(1);
  }

  // 直接开一个空的就行,也可以初始化,笔者写到这里太累了偷懒
  LazySegmentTree<Info, Tag> seg(pos.size());

  for (int i = 0; i < m; i++) {
    auto &[l, r] = ops[i];
    l = lower_bound(pos.begin(), pos.end(), l) - pos.begin();
    r = lower_bound(pos.begin(), pos.end(), r) - pos.begin();
    // 这里用r + 1是因为我的线段树模板是左闭右开区间,希望不会造成误解
    // i + 1是因为id=0我们认为是空 避免一下
    seg.rangeApply(l, r + 1, {i + 1});
  }

  i64 ans = 0;
  vector<char> vis(m + 1, false);

  // 把所有的节点放到叶子节点 或者已经推平了的节点
  auto count_ans = [&](this auto &&self, int p, int l, int r) {
    if (seg.info[p].id > 0) {
      if (!vis[seg.info[p].id]) {
        vis[seg.info[p].id] = true;
        ans++;
      }
      return;
    }
    if (r - l == 1) return;

    // 不断往下找
    int m = (l + r) / 2;
    seg.push(p);
    self(2 * p, l, m);
    self(2 * p + 1, m, r);
  };
  count_ans(1, 0, pos.size());

  cout << ans << '\n';
}

这里很容易搞晕的是,为啥一下子用区间[l, r + 1),一下子用补点,笔者也不敢说对这个地方完完全全弄明白了,目前我认为原因如下:

  1. 对于统计答案方式不同的题目/解法,需要使用不同的离散化。法一(区间法)使用的统计方法是,遍历所有的海报,然后用线段树区间查询。这个时候,线段树本身并不会对答案产生贡献,他只是用来辅助答案的统计。法二(补点法)的统计方法比较特殊,需要落到每个叶子节点/被推平的节点,也就意味着我们需要用线段树统计答案,如果我们还是粗暴的用两个点代表一个区间,反而会导致统计答案有误。
  2. 这两种线段树的结构有一个区别——叶子节点表示的内容不一样。法一(区间法)中,线段树的叶节点实际上是所有的分界点。本质是将不同区间的操作,转化成了划分点之间的操作;法二(补点)进行离散化的最后,线段树的叶节点实际上表示的是一个区间,你可以认为,法二实际上就是通过分界点把整个区间划分了一下,叶节点那一层就是所有被划分出来的区间块
  3. 两种离散化的思想不一样。法一(区间法)本质是用离散化在解决区间覆盖问题,离散化的结果是为了区间操作而服务。法二(补点)思想上是把对区间的操作转化成对点的操作,你可以认为,法二的思想是,用区间的两个端点来表示这个区间,如果这两个点都被盖住了,就需要一个新的点来表示这个区间。

我们再用一道题目来加深一下这个理解。


洛谷 P2448 无尽的生命

双倍经验:CF 540E

题目描述:有一个按升序排列的无限正整数序列 p={1,2,3,…}。现进行n次操作,每次操作[x, y]会交换这两个位置上的数,求:进行了所有的操作之后,这个序列的逆序对。其中\(1<=x, y<=10^9\)\(1<=n<=10^5\)

题目解析:统计逆序对,那么考虑树状数组,而显然我们是需要离散化的,不然树状数组肯定MLE。根据上一题的经验,先想一下怎么统计答案。手玩一下会发现,交换的最远的位置之后的序列不会产生贡献,交换的最近的位置之前的序列也不会产生贡献,答案只会产生在交换的两个位置以及中间那一段序列。我们不难发现,主要的操作发生在点与点之间,并且我们需要用数据结构统计答案,那么不难想到使用补点来进行离散化。

代码实现:

void solve() {
  int n;
  cin >> n;

  vector<pair<int, int>> ops(n);
  vector<int> disc;
  for (auto &[x, y] : ops) {
    // 先全部放进来
    cin >> x >> y;
    disc.push_back(x);
    disc.push_back(y);
  }

  sort(disc.begin(), disc.end());
  disc.erase(unique(disc.begin(), disc.end()), disc.end());
  
  // 初始化也可以这么做
  // w表示每个点的点权:离散化数组到下一个点的距离
  vector<int> pos = {0}, w = {0};

  for (int i = 0, SIZE = disc.size(); i < SIZE; i++) {
    if (i && disc[i] - disc[i - 1] != 1) {
      // 维护区间[disc[i - 1] + 1, disc[i])
      pos.push_back(disc[i - 1] + 1);
      w.push_back(disc[i] - disc[i - 1] - 1);
    }
    pos.push_back(disc[i]);
    w.push_back(1);
  }

  int m = pos.size();
  vector<int> a(m);
  iota(a.begin(), a.end(), 0);
  for (auto &[x, y] : ops) {
    // 模拟这个交换
    x = lower_bound(pos.begin(), pos.end(), x) - pos.begin();
    y = lower_bound(pos.begin(), pos.end(), y) - pos.begin();
    swap(a[x], a[y]);
  }

  BIT<int> Fenwick(m);
  i64 ans = 0;

  for (int i = m - 1; i >= 0; i--) {
    // 统计答案的时候 从后往前查询a[i]之后有几个小 然后乘一下对应的点权
    // 你可以认为 a[i]是这一块区间的代表 w[i]是这个区间产生的贡献
    ans += 1LL * w[i] * Fenwick.query(a[i]);
    Fenwick.update(a[i], w[i]);
  }
  
  cout << ans << '\n';
}

总结

离散化本质上是一种空间换时间(或空间压缩)的策略。它通过舍弃数值的“绝对大小”,仅保留“相对次序”,将无限或巨大的值域映射到有限且紧凑的下标空间。

在算法竞赛中,选择哪种离散化方式通常由贡献的计算方式决定:

1. 离散化方案对比

类型 核心思想 适用场景 关键点
单点离散化 \(x \to \text{rank}(x)\) 逆序对、简单权值树状数组 std::unique + std::lower_bound
区间离散化 (左闭右开) \([L, R] \to [L, R+1)\) 区间覆盖、矩形面积并 (扫描线) 统一终点,将物理段转化为离散点。题目关心“长度/量级”,用左闭右开,把区间看成段。
补点离散化 在不相邻的端点间插入“中间段” 贴海报、统计未覆盖空隙 判断 \(disc[i] - disc[i-1] > 1\)。题目关心“存在性/相对顺序”,用补点法,把区间还原成点集。

2. 避坑指南

  • 1-based 问题: 树状数组通常从 1 开始,离散化映射后记得统一 +1 偏移。
  • 边界问题: 在进行区间操作时,务必考虑是否需要补充 1MAX_V 作为边界,以防查询越界或逻辑不完整。
  • 权重处理: 在“补点法”中,每一个离散点代表的不再是单一数值,而是一个权值块。在统计答案(如逆序对、区间和)时,必须乘上该块的长度 \(w[i]\)

3.怎么选择

取决于离散化的内容以及要做什么,一共有四种情况

  1. 离散化对象是点,离散化后仅比较大小,朴素的离散化
  2. 离散化对象是点,离散化后需要算区间,将点的的权值设置为1,区间转化为点,长度当做权值
  3. 离散化的对象是区间,区间的端点不计数,比如区间[1,3]统计的内容是[1-2]、[2-3],朴素的离散化
  4. 离散化的内容是区间,区间的端点本身就代表一格,比如区间[1,3],统计的内容是[1]、[2]、[3],这样就需要采用[l,r+1)的离散化

  1. 逆序对:满足 i < j 且 A[i] > A[j] 的有序下标对 (i, j) ↩︎

posted @ 2025-12-17 20:48  Tangzy0121  阅读(51)  评论(0)    收藏  举报