0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1972,日期见页头。两边不一致时信原站。
题目描述
HH 有一串由各种漂亮的贝壳组成的项链。HH 相信不同的贝壳会带来好运,所以每次散步完后, 他都会随意取出一段贝壳,思考它们所表达的含义。HH 不断地收集新的贝壳,因此,他的项链变得越来越长。
有一天,他突然提出了一个问题:某一段贝壳中,包含了多少种不同的贝壳? 这个问题很难回答……因为项链实在是太长了。于是,他只好求助睿智的你,来解决这个问题。
输入格式
第一行一个正整数 n,表示项链长度。
第二行 n 个正整数 aᵢ,表示项链中第 i 个贝壳的种类。
第三行一个整数 m,表示 HH 询问的个数。
接下来 m 行,每行两个整数 l、r,表示询问的区间。
输出格式
输出 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 2 ⇒ 2 种;
[3,5] 里是 3 4 3 ⇒ 2 种(那两个 3 只算一种);
[2,6] 里是 2 3 4 3 5 ⇒ 4 种。
1第一反应:每问一次就数一遍
// 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;}点「运行 ▶」看结果
| 那一档 | 最坏工作量 | 本机实测 | 分 |
|---|---|---|---|
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 [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;}点「运行 ▶」看结果
3⚠ 为什么非离线不可:那句 −1 是不可回退的
想法听着很顺:「维护一个指针 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:它输出的恒等于区间长度
少了那一句,每个位置都记着 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 一次都抓不到。
道理很浅:题面的 n 和 aᵢ 都是 10⁶(比值 1.0),
而顺手写的生成器 n 只有十来个、值域照抄 10⁶ ⇒ 比值 10⁻⁵ ⇒ 一串贝壳全不一样,
「重复」这件事根本没发生。
⇒ ★★ 生成器该照抄题面的比值,而不是绝对规模 —— 这是它的第 N 次现场。
⚠⚠ 而同一档还把另一个 bug 也打成了 0(上一步那张表的档 0): 没有重复 ⇒ 一次抹除都不会发生 ⇒ 「不排序」那个错法也一起对了。 ⇒ ★★★ 一个顺手写的档位,能同时让两个毫不相干的 bug 隐身,而两个 0 都能证。
5⚠ 题面点名的那件事:20 MB 的读入
顶格 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 轮
| 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 ≤ 5000 ⇒ 0.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 秒) |