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,则无法继续,数列不能删空。
因此,可删空的充要条件是:存在一条从 n 到 0 的递减路径
n = p₁ > p₂ > ... > p_t = 0,使得对于每个 j,都有 cnt[pⱼ] = pⱼ - pⱼ₊₁。
也就是说,在长度为 pⱼ 时,值为 pⱼ 的数的个数恰好等于要减少的长度,使得长度能精确跳到下一个目标值。
2.2 转化为区间覆盖
对于每个数值 i(1 ≤ 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] > 0的i中,只有这些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]改为x(x在[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 + x(x 是输入的实际值)。
我们需要更新 cnt 数组,并调整覆盖数组:
-
删除旧值的影响 :
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,该区间完全在窗口右边,不影响当前答案。 -
更新
cnt[old]--,a[p] = new_val,cnt[new_val]++。 -
添加新值的影响 :
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 += v。cnt_mn保持不变,因为最小值整体移动,其出现次数不变。 - 查询时,若当前节点区间被完全包含,则 0 的个数为:若
mn == 0,则返回cnt_mn,否则返回 0。 - 合并左右孩子时,
mn = min(左.mn, 右.mn),cnt_mn为左右中mn值等于该最小值的个数之和。
这样,区间加和查询都变得非常简单,且不需要关心每个位置的具体值,只需维护两个整数和懒标记。
5. 复杂度分析
- 线段树大小约为
3 * BASE(约 45 万),建树 O(N)。 - 每次
change或query都是 O(log N)。 - 每次修改涉及常数次线段树操作,因此总复杂度 O((n+m) log n),可轻松通过 150000 的数据。
AC code
cpp
#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 (0);
cin >> n >> m;
build (1, 1, N - 3);
for (int i = 1; i <= n; i ++) {
int x; cin >> x;
a[i] = st + x;
cnt[a[i]] ++;
}
for (int i = st + 1; i <= st + n; i ++) {
if (cnt[i]) {
int L = i - cnt[i] + 1;
int R = i;
change (1, L, R, 1);
}
}
while (m --) {
int p, x; cin >> p >> x;
if (p == 0) {
int up = st + n;
if (x == 1) {
if (cnt[up] > 0) {
int L = up - cnt[up] + 1;
change (1, L, up, -1);
}
st --;
}
else {
st ++;
int new_up = st + n;
if (cnt[new_up] > 0) {
int L = new_up - cnt[new_up] + 1;
change (1, L, new_up, 1);
}
}
}
else {
int old = a[p];
int new_val = st + x;
if (old <= st + n) {
int L = old - cnt[old] + 1;
change (1, L, L, -1);
}
cnt[old] --;
a[p] = new_val;
cnt[new_val] ++;
if (new_val <= st + n) {
int L = new_val - cnt[new_val] + 1;
change (1, L, L, 1);
}
}
cout << query (1, st + 1, st + n) << '\n';
}
return 0;
}
6. 关键点
- 题意转化 :将删数条件转化为区间覆盖的充要条件,答案即为未覆盖位置数。(核心性质推导)
- 整体偏移
start:用 O(1) 时间处理整体加减,避免遍历数组。 - 边界处理:整体加减时,只需处理窗口右边界的一个区间,保证覆盖数组的正确性。
- 线段树
mn与cnt_mn:利用覆盖数非负的特性,用最小值和最小值个数巧妙求 0 的个数,区间加只需修改mn和add。 - 单点修改 :只修改左端点,因为
cnt变化 1 只影响区间长度 1 的边界。