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

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. 关键点

  1. 题意转化 :将删数条件转化为区间覆盖的充要条件,答案即为未覆盖位置数。(核心性质推导)
  2. 整体偏移 start:用 O(1) 时间处理整体加减,避免遍历数组。
  3. 边界处理:整体加减时,只需处理窗口右边界的一个区间,保证覆盖数组的正确性。
  4. 线段树 mncnt_mn :利用覆盖数非负的特性,用最小值和最小值个数巧妙求 0 的个数,区间加只需修改 mnadd
  5. 单点修改 :只修改左端点,因为 cnt 变化 1 只影响区间长度 1 的边界。
相关推荐
Tisfy18 小时前
LeetCode 3876.构造奇偶一致的数组 II:三种情况分类讨论(其实还是脑筋急转弯)
算法·leetcode·题解·脑筋急转弯
lvwangshu18 小时前
P7668 [JOI 2018 Final] 团子制作 / Dango Maker 题解
动态规划·题解·贪心·性质题·思维好题
lvwangshu2 天前
P6534 [COCI 2015/2016 #1] UZASTOPNI 等差树列 题解
动态规划·图论·题解·性质题
Tisfy3 天前
LeetCode 2058.找出临界点之间的最小和最大距离:遍历+遇到极值则更新(这种题谁空间复杂度不是O(1)啊)
linux·数据库·leetcode·链表·题解·模拟·遍历
Tisfy8 天前
LeetCode 1927.求和游戏:抵消+看最值
java·leetcode·游戏·题解·博弈论
Tisfy11 天前
LeetCode 1386.安排电影院座位:哈希表+位运算
算法·leetcode·散列表·题解·哈希表
闻缺陷则喜何志丹11 天前
【计算几何 第10章】更多几何数据结构:截窗
数据结构·数学·线段树·计算几何·截窗·优先查找树·区间树
nike0good15 天前
April Fools Day Contest 2026 题解
算法·题解·愚人节比赛
Tisfy17 天前
LeetCode 3471.找出最大的几近缺失整数:三种情况判断
算法·leetcode·题解·分类讨论