算法2026年9月5日· 约 17 分钟

P5324 [BJOI2019] 删数 题解

#线段树#区间覆盖#数列处理
Twitter 微博

P5324 [BJOI2019] 删数 题解

P5324 [BJOI2019] 删数 题解

核心:从题意性质转换 线段树技巧优化#

1. 问题重述#

给定长度为 n 的数列。定义一次删数操作:若当前数列长度为 k,则删掉所有值等于 k 的数。若经过若干次操作后数列为空,则称该数列可删空。
m 次修改,每次修改为单点修改(将某个位置的值改为 x)或整体加减(所有数同时 +1 或 -1)。每次修改后,询问至少还需要修改几个数(任意修改,不限值域),才能使当前数列可删空。


2. 核心转化:删空与区间覆盖的等价性#

2.1 删空过程的本质#

cnt[i] 表示数列中值为 i 的元素个数。当数列长度为 k 时,如果 cnt[k] > 0,则一次操作会删除这 cnt[k] 个数,长度变为 k - cnt[k];若 cnt[k] = 0,则无法继续,数列不能删空。
因此,可删空的充要条件是:存在一条从 n0 的递减路径
n = p₁ > p₂ > … > p_t = 0,使得对于每个 j,都有 cnt[pⱼ] = pⱼ - pⱼ₊₁
也就是说,在长度为 pⱼ 时,值为 pⱼ 的数的个数恰好等于要减少的长度,使得长度能精确跳到下一个目标值。

2.2 转化为区间覆盖#

对于每个数值 i1 ≤ i ≤ n),若 cnt[i] > 0,则它对应一次“跳跃”:从 i 跳到 i - cnt[i]。这个跳跃覆盖了长度区间 (i - cnt[i], i]。用闭区间表示为
[i - cnt[i] + 1, i],长度为 cnt[i]

关键结论
数列可删�� 当且仅当 所有这些区间恰好不重叠且无缝地覆盖 [1, n]

证明

  • 必要性(可删空 ⇒ 区间完美覆盖):
    若数列可删空,则存在上述路径 p₁, …, p_t。对于每个 pⱼ,其对应的区间是 [pⱼ₊₁ + 1, pⱼ](因为 pⱼ - cnt[pⱼ] = pⱼ₊₁)。这些区间按右端点从大到小排列,正好首尾相接,拼接成 [1, n]。而所有 cnt[i] > 0i 中,只有这些 pⱼ 会被使用,其余 i 的区间不存在(因为 cnt[i]=0),所以总覆盖恰好是 [1, n] 且无重叠。

  • 充分性(区间完美覆盖 ⇒ 可删空):
    若这些区间恰好覆盖 [1, n],则将这些区间按右端点从小到大排序,右端点序列即为 r₁ < r₂ < … < r_t = n(因为要覆盖到 n)。每个区间的左端点为 lⱼ = rⱼ - cnt[rⱼ] + 1,由于区间无缝拼接,必有 lⱼ = rⱼ₋₁ + 1,即 cnt[rⱼ] = rⱼ - rⱼ₋₁。从 n 开始,依次以 rⱼ 为当前长度执行删除,每次恰好删掉 cnt[rⱼ] 个等于 rⱼ 的数,长度变为 rⱼ₋₁,最终到达 0。因此可删空


3. 动态维护#

我们需要在 m 次修改后快速计算答案。修改有两种:

  • 单点修改:将某个 a[p] 改为 xx[1, n] 内)。
  • 整体加减:所有元素同时 +1 或 -1(可能使某些值超出 [1, n])。

如果直接维护 cnt 数组和覆盖情况,每次整体加减都要更新所有元素的“值”,这会超时。因此必须设计更高效的方法。

3.1 引入全局偏移量 start#

我们并不真的修改每个元素,而是维护一个全局偏移量 start,使得实际值 = 存储值 - start(等价地,存储值 = 实际值 + start)。
初始令 start = BASE(一个足够大的常数,如 150000),这样所有实际值在 [1, n] 的元素,其存储值都在 [BASE+1, BASE+n] 范围内,便于作为数组下标。

  • 当整体加 1 时,所有实际值都 +1。若我们保持存储值不变,则需要让 start 减 1(因为实际值 = 存储值 - start,start 减小则实际值增大)。
  • 当整体减 1 时,start 加 1。

这样,整体加减只需 O(1) 修改 start,而不需要遍历数组。

3.2 覆盖数组与查询窗口#

我们在存储值轴上维护一个覆盖数组 cover[pos],表示位置 pos(存储值)被多少个区间覆盖。
当前数列的实际值域是 [1, n],对应的存储值范围是 [start+1, start+n],我们称这个区间为查询窗口,右端点记为 up = start + n
每次询问,我们只需要求出该窗口内 cover[pos] == 0 的位置个数即可。

线段树就是维护这个 cover 数组的。

3.3 整体加减时的边界处理#

整体加减时,start 改变,查询窗口整体平移。同时,由于所有数值也整体平移,所有覆盖区间也整体平移。
但我们并不移动线段树中的区间,而是通过调整窗口边界处的覆盖来保持正确性。

情况一:整体加 1(x = 1
此时实际值全部 +1,因此所有区间整体右移一位。
在移动前,查询窗口右端点为 up = start + n。原来在 up 这个存储值位置可能有覆盖(因为数值为 up - start = n 的区间可能覆盖到它),但整体右移后,该覆盖会移动到 up+1,而新窗口的右端点变为 up-1(因为 start 减小了 1),所以该覆盖不再影响新窗口。
因此,我们要cnt[up] 对应的旧区间 [up - cnt[up] + 1, up] 整体减 1(从覆盖数组中移除),然后再执行 start--

情况二:整体减 1(x = -1
此时所有区间整体左移一位。
我们先执行 start++,此时新窗口右端点为 up = start + n(这个 up 是更新后的值)。原来在 up+1 位置的覆盖(因为整体左移,原本覆盖在 up+1 的区间现在移到 up)现在进入了新窗口。
所以,在 start++ 之后,我们要将 cnt[up] 对应的新区间 [up - cnt[up] + 1, up] 整体加 1(添加覆盖)。

这样,每次整体加减只需修改一个区间(或者先减后移、先移后加),复杂度 O(log N)。

3.4 单点修改#

单点修改 a[p] 从旧值 old(存储值)变为新值 new_val = start + xx 是输入的实际值)。

我们需要更新 cnt 数组,并调整覆盖数组:

  1. 删除旧值的影响
    cnt[old] 减少 1,所以其区间长度减 1,左端点右移一位。原来的左端点是 old - cnt[old] + 1(使用修改前的 cnt[old]),新的左端点是 old - (cnt[old]-1) + 1 = old - cnt[old] + 2
    因此,只需在原来的左端点位置 old - cnt[old] + 1 处将覆盖数减 1(相当于移除了该点覆盖)。
    但只有 old ≤ up 时才需要操作,因为若 old > up,该区间完全在窗口右边,不影响当前答案。

  2. 更新 cnt[old]--a[p] = new_valcnt[new_val]++

  3. 添加新值的影响
    cnt[new_val] 增加 1,区间长度增 1,左端点左移一位。新左端点为 new_val - cnt[new_val] + 1(使用修改后的 cnt[new_val])。
    在该位置将覆盖数加 1。同样,只有 new_val ≤ up 时才操作。

注意:这里对窗口外的修���并不影响当前答案,但 cnt 数组和 a[p] 必须更新,因为未来 start 变化可能使这些值进入窗口。


4. 线段树的巧妙设计#

我们需要支持两种操作:区间加(对 cover 数组进行 ±1)和查询区间内 0 的个数。

4.1 为什么不直接维护 0 的个数?#

如果直接维护 cnt0(区间内 0 的个数),那么区间加 1 时,原来为 0 的位置变成 1(cnt0 减少),原来为 -1 的位置变成 0(但覆盖数不会为负,所以不会有 -1)。然而区间加 1 还可能使某些 1 变成 2,不影响 cnt0,但无法快速更新——我们需要知道区间内 0 的个数如何变化,但区间加操作会改变每个位置的覆盖值,且每个位置变化后是否成为 0 取决于其原值,这在区间操作中无法快速合并。

4.2 利用最小值与最小值个数#

由于覆盖数始终 ≥ 0(我们保证操作合法,不会出现负数),值为 0 的位置就是整个区间内最小值的位置。因此,我们只需维护每个区间的最小值 mn 以及最小值出现的次数 cnt_mn

  • 区间加 v 时,整个区间的最小值也加 v,所以 mn += v,同时更新懒标记 add += vcnt_mn 保持不变,因为最小值整体移动,其出现次数不变。
  • 查询时,若当前节点区间被完全包含,则 0 的个数为:若 mn == 0,则返回 cnt_mn,否则返回 0。
  • 合并左右孩子时,mn = min(左.mn, 右.mn)cnt_mn 为左右中 mn 值等于该最小值的个数之和。

这样,区间加和查询都变得非常简单,且不需要关心每个位置的具体值,只需维护两个整数和懒标记。


5. 复杂度分析#

  • 线段树大小约为 3 * BASE(约 45 万),建树 O(N)。
  • 每次 changequery 都是 O(log N)。
  • 每次修改涉及常数次线段树操作,因此总复杂度 O((n+m) log n),可轻松通过 150000 的数据。

AC code#

#include <bits/stdc++.h>
using namespace std;

using ll = long long;

const int BASE = 150000; const int N = 450005;

int n, m; int st = BASE; int a[N];
int cnt[N];

struct node { int l, r; int mn;
int cnt_mn;
int add;
}; node t[N * 4];

void pushup(int p) { t[p].mn = min(t[p * 2].mn, t[p * 2 + 1].mn); t[p].cnt_mn = 0; if (t[p].mn == t[p * 2].mn) t[p].cnt_mn += t[p * 2].cnt_mn; if (t[p].mn == t[p * 2 + 1].mn) t[p].cnt_mn += t[p * 2 + 1].cnt_mn; }

void build (int p, int l, int r) { t[p].l = l, t[p].r = r; t[p].add = 0; if (l == r) { t[p].mn = 0; t[p].cnt_mn = 1; return; } int mid = (l + r) / 2; build(p * 2, l, mid); build(p * 2 + 1, mid + 1, r); pushup(p); }

void spread (int p) { if (t[p].add) { t[p * 2].mn += t[p].add; t[p * 2].add += t[p].add; t[p * 2 + 1].mn += t[p].add; t[p * 2 + 1].add += t[p].add; t[p].add = 0; } }

void change(int p, int l, int r, int v) { if (t[p].l >= l && t[p].r <= r) { t[p].mn += v; t[p].add += v; return; } spread(p); int mid = (t[p].l + t[p].r) / 2; if (l <= mid) change(p * 2, l, r, v); if (r > mid) change(p * 2 + 1, l, r, v); pushup(p); }

ll query(int p, int l, int r) { if (t[p].l >= l && t[p].r <= r) { if (t[p].mn == 0) return t[p].cnt_mn; else return 0; } spread(p); int mid = (t[p].l + t[p].r) / 2; ll res = 0; if (l <= mid) res += query(p * 2, l, r); if (r > mid) res += query(p * 2 + 1, l, r); return res; }

int main() { freopen ("delete.in", "r", stdin); freopen ("delete.out", "w", stdout);

ios::sync_with_stdio (false);
cin.tie (class="hljs-number">0);

cin &gt;&gt; n &gt;&gt; m;

build (class="hljs-number">1, class="hljs-number">1, N - class="hljs-number">3);

for (int i = class="hljs-number">1; i &lt;= n; i ++) {
	int x; cin &gt;&gt; x;
	a[i] = st + x;
	cnt[a[i]] ++;
}

for (int i = st + class="hljs-number">1; i &lt;= st + n; i ++) {
	if (cnt[i]) {
		int L = i - cnt[i] + class="hljs-number">1;
		int R = i;
		change (class="hljs-number">1, L, R, class="hljs-number">1);
	}
}

while (m --) {
	int p, x; cin &gt;&gt; p &gt;&gt; x;

	if (p == class="hljs-number">0) {             
		int up = st + n;

		if (x == class="hljs-number">1) {          
			if (cnt[up] &gt; class="hljs-number">0) {
				int L = up - cnt[up] + class="hljs-number">1;
				change (class="hljs-number">1, L, up, -class="hljs-number">1);
			}

			st --;
		} 
		else {                      
			st ++;
			int new_up = st + n;

			if (cnt[new_up] &gt; class="hljs-number">0) {
				int L = new_up - cnt[new_up] + class="hljs-number">1;
				change (class="hljs-number">1, L, new_up, class="hljs-number">1);
			}
		}
	} 
	else {                     
		int old = a[p];
		int new_val = st + x;

		if (old &lt;= st + n) {
			int L = old - cnt[old] + class="hljs-number">1;
			change (class="hljs-number">1, L, L, -class="hljs-number">1);
		}

		cnt[old] --;
		a[p] = new_val;
		cnt[new_val] ++;

		if (new_val &lt;= st + n) {
			int L = new_val - cnt[new_val] + class="hljs-number">1;
			change (class="hljs-number">1, L, L, class="hljs-number">1);
		}
	}

	cout &lt;&lt; query (class="hljs-number">1, st + class="hljs-number">1, st + n) &lt;&lt; class="hljs-string">'\n';
}

return class="hljs-number">0;

}

6. 关键点#

  1. 题意转化:将删数条件转化为区间覆盖的充要条件,答案即为未覆盖位置数。(核心性质推导)
  2. 整体偏移 start:用 O(1) 时间处理整体加减,避免遍历数组。
  3. 边界处理:整体加减时,只需处理窗口右边界的一个区间,保证覆盖数组的正确性。
  4. 线段树 mncnt_mn:利用覆盖数非负的特性,用最小值和最小值个数巧妙求 0 的个数,区间加只需修改 mnadd
  5. 单点修改:只修改左端点,因为 cnt 变化 1 只影响区间长度 1 的边界。

原文链接:https://www.cnblogs.com/lvwangshuOI/p/22854124

评论

© 2026 松岛川树