题单 · 习题解析

洛谷 P1972 [SDOI2009] HH 的项链

★★★ 「种类数」不可加 ⇒ 关键一步是**每种贝壳只在「最后出现的位置」记 1**,于是它变成区间求和;⚠ 而那个位置跟着 r 变 ⇒ **询问按右端点排序、离线回答**(`add(pre, −1)` **不可回退**,排序不是为了快);★ 暴力在 20% 那一档 0.01 秒 ⇒ 稳拿 20 分,10⁵ 就要 8.63 秒;★★ 「忘了那句 −1」输出的**恒等于区间长度**(1500 轮全成立)⇒ 触发 ≡ 抓获,五档一个不差;★★★ 而顺手照抄题面那个「aᵢ ≤ 10⁶」配上十来个数的 n(比值 10⁻⁵)**同时把两个 bug 打成 0**,两个 0 都能证 ⇒ [生成器该照抄题面的比值](/sol/p1020/);⚠ 官方样例的三个询问 r = 2→5→6 **恰好升序** ⇒ 挡不住「不排序」;★ 题面点名的 20 MB 读入量完要打个折:默认 cin 端到端 0.69 秒 / 时限 2 秒,**够,但只剩 2.9 倍余量**

原题:洛谷 P1972出自 第 38 章 树状数组 的题单题面本地存档:2026-08-31
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1972,日期见页头。两边不一致时信原站。

题目描述

HH 有一串由各种漂亮的贝壳组成的项链。HH 相信不同的贝壳会带来好运,所以每次散步完后, 他都会随意取出一段贝壳,思考它们所表达的含义。HH 不断地收集新的贝壳,因此,他的项链变得越来越长。

有一天,他突然提出了一个问题:某一段贝壳中,包含了多少种不同的贝壳? 这个问题很难回答……因为项链实在是太长了。于是,他只好求助睿智的你,来解决这个问题。

输入格式

第一行一个正整数 n,表示项链长度。

第二行 n 个正整数 aᵢ,表示项链中第 i 个贝壳的种类。

第三行一个整数 m,表示 HH 询问的个数。

接下来 m 行,每行两个整数 lr,表示询问的区间。

输出格式

输出 m 行,每行一个整数,依次表示询问对应的答案。

数据范围

对于 20% 的数据,1 ≤ n, m ≤ 5000

对于 40% 的数据,1 ≤ n, m ≤ 10⁵

对于 60% 的数据,1 ≤ n, m ≤ 5 × 10⁵

对于 100% 的数据,1 ≤ n, m, aᵢ ≤ 10⁶1 ≤ l ≤ r ≤ n

本题可能需要较快的读入方式,最大数据点读入数据约 20 MB。

时限 2 秒,内存 512 MB。

输入输出样例

输入

6
1 2 3 4 3 5
3
1 2
3 5
2 6

输出

2
2
4

项链是 1 2 3 4 3 5[1,2] 里是 1 22 种; [3,5] 里是 3 4 32 种(那两个 3 只算一种); [2,6] 里是 2 3 4 3 54 种。

1第一反应:每问一次就数一遍

p1972Brute.cpp第 ① 版:时间戳去重,一次一扫 —— ★ 20 分
// P1972 第 ① 版:**每问一次就数一遍** —— 拿一个「时间戳」数组当去重的桶
//
// ★ 它值多少分,题面自己分好了档:
// · 「对于 20% 的数据,1 ≤ n, m ≤ 5000」 ⇒ 2.5×10⁷ 次,本机约 0.01 秒 ⇒ ★ 稳拿 20 分;
// · 「对于 40% 的数据,1 ≤ n, m ≤ 10⁵」 ⇒ 10¹⁰ 次 ⇒ 挂;
// · 顶格 n = m = 10⁶ ⇒ 10¹² 次 ⇒ 想都别想。
//
// ★ vis 用「这次询问的编号」当标记,就不用每次 memset 一遍 10⁶ 个格子了
// (不然光清零就 10¹² 次)—— 这个小技巧本身值 n/(r−l+1) 倍。
#include <bits/stdc++.h>
using namespace std;
const int MAXV = 1000006;
int vis[MAXV];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<int> a(n + 1);
for (int i = 1; i <= n; i++) cin >> a[i];
int m;
cin >> m;
string out;
for (int q = 1; q <= m; q++) {
int l, r;
cin >> l >> r;
int cnt = 0;
for (int i = l; i <= r; i++)
if (vis[a[i]] != q) { vis[a[i]] = q; cnt++; } // ★ 时间戳去重,不用清零
out += to_string(cnt);
out += '\n';
}
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 题面把分档写得很细,顺手乘一遍就知道它值多少
那一档 最坏工作量 本机实测
20%:n, m ≤ 5000 2.5 × 10⁷ 0.01 秒 20 分
40%:n, m ≤ 10⁵ 10¹⁰ 8.63 秒
100%:n, m ≤ 10⁶ 10¹² n·m 外推约 860 秒

★ 这一版里唯一值得学的是那个 vis 用「询问的编号」当标记 —— 不然每次询问都要把 10⁶ 个格子清一遍零,光清零就 10¹² 次。

2★★★ 关键一步:把「有多少种」改写成「有多少个 1」

★★★ 种类数不可加 —— 所以先把它换成一个可加的量

前缀和/树状数组能做的事只有一件:把一段东西加起来。 可「种类数」不可加[1,3] 有 2 种、[4,6] 有 2 种,合起来可能是 2 也可能是 4。

★ 换一个问法:每种贝壳只让它「最后出现的那个位置」记 1,别的位置记 0。 那么对固定的右端点 r

[l, r] 里的种类数 = 位置 l..r 上这些 1 的个数

—— 因为每种在 [l, r] 里出现过的贝壳,恰好有一个「在 [l, r] 内最后出现的位置」。 ⇒ 种类数变成了区间求和,而区间求和正是树状数组的看家本领。

⚠ 但「最后出现的位置」是跟着 r 变的 ⇒ 得让 r 从小到大走一遍: 扫到 i 时,若 a[i] 上次在 pre 出现过就 add(pre, −1),再 add(i, +1); 然后回答所有右端点正好是 i 的询问。

⇒ ★ 把所有询问按右端点排序、离线回答 —— 每个位置至多被 add 两次, 总共 O((n + m) log n)

p1972.cpp★ 这一版就能 AC(顶格端到端 0.24 秒 / 时限 2 秒)
// P1972 [SDOI2009] HH 的项链 —— ★ 这一版就能 AC(询问离线 + 树状数组)
//
// ============ ★★★ 关键一步:把「有多少种」改写成「有多少个 1」 ============
//
// 直接问「[l, r] 里有多少种贝壳」没法用前缀和 —— 种类数**不可加**
// ([1,3] 有 2 种、[4,6] 有 2 种,合起来可能是 2 也可能是 4)。
//
// ★ 换一个问法:**每种贝壳只让它「最后出现的那个位置」记 1,别的位置记 0**。
// 那么对固定的右端点 r,[l, r] 里的种类数 = 位置 l..r 上这些 1 的**个数** ——
// 因为每种在 [l, r] 里出现的贝壳,恰好有一个「在 [l, r] 内最后出现的位置」。
// ⇒ 种类数变成了**区间求和**,而区间求和正是树状数组的看家本领。
//
// ⚠ 但「最后出现的位置」是跟着 r 变的 ⇒ 得让 r 从小到大走一遍。
// ⇒ **把所有询问按右端点排序,离线回答**:
// 扫到 i 时:如果 a[i] 上次在 pre 出现过,就 add(pre, −1);再 add(i, +1);
// 然后回答所有 r = i 的询问:sum(r) − sum(l − 1)。
//
// ============ ⚠ 为什么非离线不可 ============
// 那个 add(pre, −1) 是**不可回退**的:一旦把 pre 位置的 1 抹掉,
// 就再也答不了「右端点小于 i」的询问了(p1972Online.cpp 演示这件事,第 ④ 步)。
// ⇒ **排序不是为了快,是为了让每个位置只被抹一次、而且抹得不早不晚。**
//
// 复杂度 O((n + m) log n);每个位置至多被 add 两次 ⇒ 总共 2n 次 add。
//
// ⚠ 读入:题面自己写着「本题可能需要较快的读入方式,最大数据点读入数据约 20MB」
// —— 第 ⑥ 步量了三种读法,默认 cin 在顶格上要 2.6 秒(时限 2 秒)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000006;
int c[MAXN], a[MAXN], pre[MAXN], ans[MAXN];
int n, m;
inline int lowbit(int i) { return i & -i; }
void add(int p, int v) { for (int i = p; i <= n; i += lowbit(i)) c[i] += v; }
int sum(int r) { int s = 0; for (int i = r; i > 0; i -= lowbit(i)) s += c[i]; return s; }
/* 手写快读(题面点名要它) */
static char ibuf[1 << 22];
static size_t ipos = 0, ilen = 0;
static inline int gc() {
if (ipos == ilen) { ilen = fread(ibuf, 1, sizeof(ibuf), stdin); ipos = 0; if (!ilen) return EOF; }
return ibuf[ipos++];
}
static inline int readInt() {
int ch = gc(), x = 0;
while (ch != EOF && (ch < '0' || ch > '9')) ch = gc();
for (; ch >= '0' && ch <= '9'; ch = gc()) x = x * 10 + (ch - '0');
return x;
}
struct Q { int l, r, id; };
int main() {
n = readInt();
for (int i = 1; i <= n; i++) a[i] = readInt();
m = readInt();
vector<Q> qs(m);
for (int i = 0; i < m; i++) { qs[i].l = readInt(); qs[i].r = readInt(); qs[i].id = i; }
// ★ 按右端点升序 —— 这一句就是「离线」两个字
sort(qs.begin(), qs.end(), [](const Q& x, const Q& y) { return x.r < y.r; });
int j = 0;
for (int i = 1; i <= n; i++) {
if (pre[a[i]]) add(pre[a[i]], -1); // ⚠ 把这种贝壳上一次的那个 1 抹掉
add(i, 1);
pre[a[i]] = i;
while (j < m && qs[j].r == i) { ans[qs[j].id] = sum(qs[j].r) - sum(qs[j].l - 1); j++; }
}
string out;
for (int i = 0; i < m; i++) { out += to_string(ans[i]); out += '\n'; }
fwrite(out.data(), 1, out.size(), stdout);
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

3⚠ 为什么非离线不可:那句 −1 是不可回退的

p1972Online.cpp✗ 不排序,来一个询问就把指针推到 r(⚠ 官方样例放过了它)
★★ 「离线」不是为了快,是为了让那次抹除发生得不早不晚

想法听着很顺:「维护一个指针 p,来一个询问就把 p 推到 r,然后 sum(r) − sum(l−1)」。 ⚠ 它漏掉的是:add(pre, −1) 不可回退 —— 一旦某个位置的 1 被后来的同色贝壳抹掉了,再问一个右端点更小的区间, 那个位置就凭空少了一个 1 ⇒ 答案偏小。

★ 触发条件写得出来:询问的右端点不是升序,而且回退跨过了某次抹除。而这两层差得很远:

300 轮 ① 询问的 r 不是升序 ② 它真被抓
档 0:顺手(颜色 1~10⁶) 298 0
档 1:颜色 1~3 298 294
档 2:颜色 1~n(题面的比值) 298 272
★ 档 4:询问按 r 升序给它 0 0

⇒ ⚠ 第一层几乎没有区分度(四档里三档都是 298)—— B3637 那条的又一次现场:第一层写得越「显然」,越要提防它不区分。 ⇒ ★ 而档 4 那个 0 是能证的:按右端点升序喂给它,它做的事和正解一模一样。

官方样例挡不住它,而原因说得出来:样例的三个询问是 (1,2) (3,5) (2,6), 右端点 2 → 5 → 6 恰好升序 ⇒ 那个 bug 根本没机会出场。 (第 31 章 P1347 那条「程序在坑出现之前就退出了」的近亲:这次是坑在数据里没被摆出来。)

4✗ 忘了那句 −1:它输出的恒等于区间长度

p1972NoDel.cpp✗ 少了 add(pre, −1)(官方样例当场打死:2 3 5)
★★★ 说清楚它算了什么:r − l + 1,五档 1500 轮一次不差

少了那一句,每个位置都记着 1 ⇒ 它输出的恒等于区间长度 r − l + 1 (度量程序逐组验过:五个档 1500 轮全成立)。

⇒ 于是它的触发条件是「被问的区间里真的有重复的贝壳」, 而这一层和「被抓的轮数」一个不差

300 轮 ① 区间里真的有重复 ② 它真被抓
⚠ 档 0:顺手 —— 颜色 1~10⁶(照抄题面的绝对值域 0 0
档 1:颜色 1~3 299 299
★ 档 2:颜色 1~n(照抄题面的比值 290 290
档 3:询问端点专挑 l=1 / r=n / l=r 297 297
档 4:询问按 r 升序 290 290

★★★ 第一行才是这一页最值钱的一格:顺手照抄题面那个「10⁶」,这个 bug 一次都抓不到。 道理很浅:题面的 naᵢ 都是 10⁶(比值 1.0), 而顺手写的生成器 n 只有十来个、值域照抄 10⁶ ⇒ 比值 10⁻⁵ ⇒ 一串贝壳全不一样, 「重复」这件事根本没发生。 ⇒ ★★ 生成器该照抄题面的比值,而不是绝对规模 —— 这是它的第 N 次现场。

⚠⚠ 而同一档还把另一个 bug 也打成了 0(上一步那张表的档 0): 没有重复 ⇒ 一次抹除都不会发生 ⇒ 「不排序」那个错法也一起对了。 ⇒ ★★★ 一个顺手写的档位,能同时让两个毫不相干的 bug 隐身,而两个 0 都能证。

5⚠ 题面点名的那件事:20 MB 的读入

p1972Cin.cpp对照:算法和正解一样,只把快读换成默认 cin
★ 题面说「可能需要较快的读入方式」—— 量完之后要打个折

顶格 n = m = 10⁶ ⇒ 要读 3 × 10⁶ 个整数。 ★ 我们照题面造的那份顶格数据是 20 666 979 字节(19.7 MB),和题面说的「约 20 MB」对得上。

本机实测(A 机 · WSL2 · Linux 6.18 / 8 线程 / 7 GB,2026-08-31,独占):

只把那 20 MB 读进来 毫秒
cin(默认,同步开着) 457
cin + ios::sync_with_stdio(false) 85
★ 手写 fread 快读 25

端到端(/usr/bin/time,跑三次取稳定值):快读版 0.24 秒、默认 cin 版 0.69 秒,时限 2 秒。

⇒ ⚠ 所以老实说:这道题上「默认 cin」也过得去 —— 题面那句提醒不是及格线, 但它也不是空话:余量只剩 2.9 倍,而评测机通常比本机慢。 ⇒ ★ 和第 6 章 P2367 对一下就知道差在哪:那道题要读 2 × 10⁷ 个数、时限 1 秒 ⇒ 连 scanf 都不够。倍数跨题几乎不变,变的是绝对时间,而分数线在绝对时间上。

6★ 对拍:五个档位各 300 轮

p1972Gen.cpp(八个档位)数据生成器
p1972Count.cpp度量程序(本页的数字都出自它)
300 轮 ① 每问一次数一遍 ✗ 忘了 add(pre, −1) ✗ 不排序边读边答
⚠ 档 0:顺手(颜色 1~10⁶) 0 0 0
档 1:颜色 1~3 0 299 294
★ 档 2:颜色 1~n(题面的比值 1.0) 0 290 272
档 3:询问端点专挑边上 0 297 253
★ 档 4:询问按 r 升序 0 290 0

★ 档 4 那一列同时是两个自检:它把「不排序」打成 0(能证), 而同一档里另一个 bug 还是 290 ⇒ 那一档的代码确实在跑

7一页纸

★ 暴力值多少分 题面 20% 档 n, m ≤ 50000.01 秒,稳拿 20 分;10⁵ 就要 8.63 秒
★★★ 关键一步 「种类数」不可加 ⇒ 每种只在「最后出现的位置」记 1 ⇒ 变成区间求和
★★ 为什么要离线 add(pre, −1) 不可回退 ⇒ 询问必须按右端点升序处理;排序不是为了快
⚠ 官方样例挡不住「不排序」 样例三个询问的 r 是 2 → 5 → 6,恰好升序 ⇒ 那个 bug 没机会出场
★★ 忘了那句 −1 它输出的恒等于区间长度(1500 轮全成立)⇒ 触发条件 ≡ 抓获数,五档一个不差
★★★ 顺手写的档位有多毒 颜色照抄题面的 10⁶n 只有十来个 ⇒ 比值 10⁻⁵ ⇒ 两个 bug 同时变成 0
⚠ 读入那笔账 顶格 19.7 MB:默认 cin 457 ms / 关同步 85 / 快读 25;端到端 0.69 vs 0.24 秒(时限 2 秒)