目录
这两个题目(HDU 5057)是一个经典的带修改区间查询问题。我们需要在一个数组上进行单点修改,并查询一个区间内有多少个数字的第
由于数据范围
下面我将分别详细介绍这两种方法,并提供带有快读优化的 C++ 代码。
预备知识:如何获取数字的第 D 位
题目中规定第 1 位是最低位(个位)。数字
由于
long long powers[11];
void precompute_powers() {
powers[1] = 1;
for (int i = 2; i <= 10; ++i) powers[i] = powers[i - 1] * 10;
}
// 获取 val 的第 D 位数字
inline int get_digit(long long val, int D) {
return (val / powers[D]) % 10;
}
方法一:树状数组 (BIT)
树状数组擅长处理单点修改和区间前缀和查询。这里的查询条件是“第
核心思想:
我们可以建立多个树状数组。具体来说,对于每一个可能的位数 tree[D][P] 为一个树状数组。如果原数组 tree[D][P] 的位置
这样,查询区间 tree[D][P] 上查询区间 query(tree[D][P], R) - query(tree[D][P], L-1)。
复杂度分析:
- 空间复杂度:我们需要
个大小为 的树状数组。总空间约为 字节 MB,是可以接受的。 - 时间复杂度:
- 初始化:遍历
个数,对每个数更新 10 个 BIT,总共 。 - 修改 (S X Y):修改一个数涉及到在 10 个 BIT 中减去旧值的贡献,并加上新值的贡献。总共
。 - 查询 (Q L R D P):只需要在指定的一个 BIT 上进行两次查询。总共
。 - 总时间复杂度为
,常数较大但足以通过。
- 初始化:遍历
特征数组思想
你的理解非常准确,完全正确!
这正是这个解法的核心思想。你已经抓住了问题的本质。
让我用更正式一点的语言把你的想法再梳理一遍,加深印象:
1. 核心思想:特征数组 (Indicator Array)
你提到的“桶”或者那个 [1 1 1 0 0 0] 的数组,在算法中通常被称为特征数组或者指示器数组。
对于题目中的每一个特定的查询要求——“第
数组
- 如果
的第 位恰好是 ,那么 。 - 如果
的第 位不是 ,那么 。
举个例子:
假设原数组
符合 不符 符合 不符 符合
那么,概念上的特征数组 [1, 0, 1, 0, 1]。
2. 问题的转化
题目要求查询:在区间
转化到特征数组
即:
因为满足条件的记为 1,不满足的记为 0,所以它们的和就是满足条件的个数。
3. BIT 的角色:动态维护区间和
现在问题变成了我们最熟悉的形式:单点修改,区间求和。
如果数组
但题目有修改操作(S X Y):将
- 如果用原始数组维护
,修改是 ,但区间求和是 ,太慢。 - 如果用前缀和数组维护
,区间求和是 ,但修改是 ,太慢。
这时候,树状数组 (BIT) 就登场了。它正是为了平衡这两者而生的数据结构。
你所说的 tree[D][P],实际上就是基于特征数组
- 当我们在
的第 位上观察到数字 时,我们在概念上把 设为了 1。在代码里,我们就执行 bit_update(tree[D][P], X, 1)。 - 当我们查询区间
时,我们实际上是在求特征数组的区间和,代码里就是 bit_query(tree[D][P], R) - bit_query(tree[D][P], L - 1)。
总结
你的想法非常透彻。
这个解法就是把一个复杂的复合条件查询,通过特征化(变成 0/1 数组)转化为标准的区间求和问题,然后因为需要支持动态修改,所以引入了树状数组来维护这个求和过程。
C++ 代码实现 (BIT):
#include <iostream>
#include <vector>
#include <cstdio>
#include <cstring>
using namespace std;
// --- 快读快写模板 ---
inline int read() {
int x = 0, f = 1; char ch = getchar();
while (ch < '0' || ch > '9') { if (ch == '-') f = -1; ch = getchar(); }
while (ch >= '0' && ch <= '9') { x = x * 10 + ch - '0'; ch = getchar(); }
return x * f;
}
inline long long readll() {
long long x = 0, f = 1; char ch = getchar();
while (ch < '0' || ch > '9') { if (ch == '-') f = -1; ch = getchar(); }
while (ch >= '0' && ch <= '9') { x = x * 10 + ch - '0'; ch = getchar(); }
return x * f;
}
inline void write(int x) {
if (x > 9) write(x / 10);
putchar(x % 10 + '0');
}
// --------------------
const int MAXN = 100005;
long long a[MAXN]; // 原数组
int n, m;
int tree[11][10][MAXN]; // 100个树状数组:tree[D][P][idx]
long long powers[11]; // 预存10的幂
void precompute_powers() {
powers[1] = 1;
for (int i = 2; i <= 10; ++i) powers[i] = powers[i - 1] * 10;
}
inline int get_digit(long long val, int D) {
return (val / powers[D]) % 10;
}
inline int lowbit(int x) { return x & -x; }
// 在指定的 BIT 上进行更新
void bit_update(int d, int p, int idx, int val) {
for (; idx <= n; idx += lowbit(idx)) {
tree[d][p][idx] += val;
}
}
// 在指定的 BIT 上进行前缀和查询
int bit_query(int d, int p, int idx) {
int sum = 0;
for (; idx > 0; idx -= lowbit(idx)) {
sum += tree[d][p][idx];
}
return sum;
}
void solve() {
n = read(); m = read();
memset(tree, 0, sizeof(tree)); // 多组数据,切记清空
for (int i = 1; i <= n; ++i) {
a[i] = readll();
// 初始化 BIT,对每个位置的每一位进行记录
for (int d = 1; d <= 10; ++d) {
bit_update(d, get_digit(a[i], d), i, 1);
}
}
char type[2];
for (int k = 0; k < m; ++k) {
scanf("%s", type);
if (type[0] == 'S') { // 修改操作
int x = read();
long long y = readll();
long long old_val = a[x];
// 1. 移除旧值的贡献
for (int d = 1; d <= 10; ++d) {
bit_update(d, get_digit(old_val, d), x, -1);
}
a[x] = y; // 更新原数组
// 2. 添加新值的贡献
for (int d = 1; d <= 10; ++d) {
bit_update(d, get_digit(y, d), x, 1);
}
} else { // 查询操作
int l = read(), r = read(), d = read(), p = read();
// 利用区间减法原理查询
int ans = bit_query(d, p, r) - bit_query(d, p, l - 1);
write(ans);
putchar('\n');
}
}
}
int main() {
precompute_powers(); // 预处理
int t = read();
while (t--) {
solve();
}
return 0;
}
方法二:分块 (Square Root Decomposition)
分块是一种通用性很强的方法,特别适合处理一些树状数组或线段树不好维护的复杂区间信息。
核心思想:
本质: 区间信息是什么
ask(l,r,d,p), 可以用这个来统计区间信息,关键字d,p,,任何一个区间的信息可以使用长度为100的数组存下. 使用分块来进行暴力优化
将数组 cnt[block_id][D][P],表示在第 block_id 个块中,第
- 修改 (S X Y):找到
所在的块,更新该块的 cnt数组。先减去旧值在各个位上的计数,然后更新 ,再加上新值 在各个位上的计数。复杂度 。 - 查询 (Q L R D P):查询区间
可能跨越多个块。 - 对于两端不完整的块(散块),直接暴力遍历原数组
进行统计。复杂度 。 - 对于中间完整的块,直接累加预处理好的
cnt[block_id][D][P]。复杂度。
- 对于两端不完整的块(散块),直接暴力遍历原数组
复杂度分析:
- 空间复杂度:
cnt数组大小约为,非常小。 - 时间复杂度:
- 初始化:
。 - 修改:
(常数为 10)。 - 查询:
。 - 总时间复杂度为
。
- 初始化:
通常来说,
C++ 代码实现 (分块):
#include <bits/stdc++.h>
using namespace std;
const int maxn = 2e5+5;
typedef long long ll;
int n,m;
ll a[maxn]; // 原数组,存储每个数字的值
long long powers[11]; // 用于存储 10 的幂次方,powers[i] = 10^(i-1)
// 预处理 10 的幂次方
void precompute_powers() {
powers[1] = 1;
for (int i = 2; i <= 10; ++i) powers[i] = powers[i - 1] * 10;
}
// 获取数字 val 的第 D 位数字(个位是第1位)
inline int get_digit(long long val, int D) {
return (val / powers[D]) % 10;
}
// ---- block 分块结构体
struct Block {
ll block; // 块的大小,通常取 sqrt(n)
ll t; // 块的总数量
ll pos[maxn]; // pos[i] 表示原数组第 i 个元素所属的块编号
ll st[maxn]; // st[i] 表示第 i 个块的起始下标
ll ed[maxn]; // ed[i] 表示第 i 个块的结束下标
ll addflag[maxn]; // 懒标记,这个题目里暂时没用到
// cnt[i][d][p] 表示第 i 个块中,所有数字的第 d 位上,数字 p 出现的次数
ll cnt[2005][11][10];
// 初始化分块结构
void init(){
memset(cnt,0,sizeof(cnt)); // 清空统计数组,因为有多组数据
block = sqrt(n); // 计算块大小
t = n / block; // 计算完整块的数量
if( n % block) t++; // 如果有剩余元素,块数量加 1
// 确定每个块的起始和结束下标
for(ll i = 1;i <= t ;++i ) // i: 1->t
{
st[i] = (i-1) * block + 1;
ed[i] = i * block;
}
ed[t] = n; // 修正最后一个块的结尾下标为 n
// 确定每个元素所属的块编号
for(ll i = 1;i <= n ;++i ) // i: 1->n
{
pos[i] = (i-1) / block + 1;
}
// 初始化 cnt 数组,统计每个块内的信息
for(ll i = 1;i <= t ;++i ) // i: 1->t
{
for(ll j = st[i];j <= ed[i] ;++j ) // j: 1->n
{
ll num = a[j]; // 使用 long long 防止溢出
// 遍历数字 num 的每一位,更新 cnt 数组
for(int d = 1;d <= 10;d ++) cnt[i][d][ get_digit(num, d)]++;
}
}
}
// 单点修改操作:将 a[L] 的值修改为 val
// 这里 R 其实没用到,因为是单点修改,调用时传入 update(x, x, y)
void update(ll L,ll R,ll val) {
int idx = L; // 要修改的元素的下标
int bidx = pos[L]; // 该元素所属的块编号
// 1. 移除旧值的贡献
// 遍历旧值 a[idx] 的每一位,在对应的 cnt 统计中减 1
for(int d = 1;d <= 10;d++)
cnt[bidx][d][get_digit(a[idx], d)]--;
// 2. 更新原数组
a[idx] = val;
// 3. 添加新值的贡献
// 遍历新值 val 的每一位,在对应的 cnt 统计中加 1
for(int d = 1;d <= 10;d++)
cnt[bidx][d][get_digit(val, d)]++;
}
// 区间查询操作:查询区间 [L, R] 内,第 d 位是 val 的数字有多少个
long long query(ll L,ll R,int d,int val) {
long long ret = 0;
ll p = pos[L], q = pos[R]; // 计算区间两端所属的块编号
if( p == q) {
// 情况 1:查询区间在同一个块内
// 直接暴力遍历区间 [L, R] 进行统计
for(ll i = L;i <= R;i++) {
if( get_digit(a[i], d) == val)
ret++;
}
}
else {
// 情况 2:查询区间跨越多个块
// 统计中间完整的块
for(ll i = p+1;i<=q-1;i++) {
ret += cnt[i][d][val]; // 直接累加预处理好的统计信息
}
// 统计左侧不完整的散块
for(ll i = L; i <= ed[p] ;i++) {
if( get_digit(a[i], d) == val)
ret++;
}
// 统计右侧不完整的散块
for(ll i = st[q];i <= R;i++) {
if( get_digit(a[i], d) == val)
ret++;
}
}
return ret;
}
} myblock;
int main () {
// 优化 I/O 操作,提高读写速度
std::ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);
precompute_powers(); // 程序开始时预处理 10 的幂次方
int T;
std::cin >> T; // 读取测试用例数量
while (T--) {
std::cin >> n >> m; // 读取数组大小和操作次数
for(int i = 1;i <= n ;++i ) std::cin >> a[i]; // 读取原数组
myblock.init(); // 初始化分块结构
for(int i = 1;i <= m ;++i ) // i: 1->m 处理 m 次操作
{
char opt;
std::cin >> opt; // 读取操作类型
if( opt == 'Q') { // 查询操作
int l,r,d,p;
std::cin >> l >> r >> d >> p; // 读取查询参数
cout << myblock.query(l, r,d,p) << "\n"; // 输出查询结果
}
else { // 修改操作
int x,y;
std::cin >> x >> y; // 读取修改参数
myblock.update(x,x,y); // 执行单点修改
}
}
}
return 0;
}
总结
- 树状数组做法更加巧妙,利用了问题的特性(位数和数字范围小)将问题转化为多个简单的区间求和问题,理论复杂度更优。
- 分块做法更加直观通用,是处理此类区间问题的“万金油”,虽然理论复杂度稍高,但常数小,实现也相对简单。
两种方法结合快读快写都能通过此题。你可以根据自己的喜好选择。