题单 · 习题解析

洛谷 P1638 逛画展

同一个 n、同一个 m,形状不同能差十倍;★ 读入优化的倍数跨题不变,但「够不够」看绝对时间

原题:洛谷 P1638出自 第 7 章 双指针与滑动窗口 的题单题面本地存档:2026-08-27
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

博览馆正在展出由世上最佳的 m 位画家所画的图画。

游客在购买门票时必须说明两个数字,xy,代表他要看展览中的第 x 幅至第 y 幅画 (包含 x, y)之间的所有图画,而门票的价钱就是一张图画一元。

Sept 希望入场后可以看到所有名师的图画。当然,他想最小化购买门票的价格。

请求出他购买门票时应选择的 x, y,数据保证一定有解。

若存在多组解,输出 x 最小的那组。

输入格式

第一行两个整数 n, m,分别表示博览馆内的图画总数及这些图画是由多少位名师所绘画的。

第二行包含 n 个整数 aᵢ,代表画第 i 幅画的名师的编号。

输出格式

一行两个整数 x, y

说明 / 提示:数据规模与约定

  • 对于 30% 的数据,有 n ≤ 200m ≤ 20
  • 对于 60% 的数据,有 n ≤ 10⁵m ≤ 10³
  • 对于 100% 的数据,有 1 ≤ n ≤ 10⁶1 ≤ aᵢ ≤ m ≤ 2×10³

输入输出样例

输入

12 5
2 5 3 1 3 2 4 1 1 5 4 3

输出

2 7

样例解释:a[2..7] = 5 3 1 3 2 4,五位名师齐全,长度 6。 ⚠ 注意 a[5..10] = 3 2 4 1 1 5 也齐全,也是长度 6 —— 两组解一样短,题目要的是 x 小的那组,所以答案是 2 7 不是 5 10上面那段输出是仓库里的 p1638.cpp 真跑出来的。

1先把题读干净 —— 「多组解取 x 最小」被写进样例里了

要的是最短的、包含全部 m 种编号的区间。这是滑动窗口最经典的一个变形。

而题面那句「若存在多组解,输出 x 最小的那组」不是客套话 —— 样例本身就有两组等长的解2 75 10)。

★ 出题人把坑埋进样例了 —— 这种好事不多

第 5 步那个错版(更新条件写成 <=)在样例上就会输出 5 10。 ⇒ 这道题「样例过了」这个门槛,比大多数题都值钱一点。

⚠ 但另一个错版(第 6 步,收缩用 if 不用 while在样例上是对的 —— 所以「样例过了就交」照样会挂。两个坑一明一暗,正好凑一对。

2第 ① 版:枚举左端点,往右扫到齐全

p1638Brute.cpp第 ① 版:枚举 x,向右扫到五种齐全
// P1638 的第 ① 版:枚举左端点,往右一直扫到「五种画家都齐了」
//
// 这是最直接的想法,也确实是对的。★ 题面把它的分数标出来了:
// 「对于 30% 的数据,n <= 200,m <= 20」—— 这一档它稳过,**考场上是实打实的 30 分**。
// 「对于 60% 的数据,n <= 1e5,m <= 1e3」—— 这一档就悬了。
//
// ⚠ 它每换一个左端点都要把 cnt 清一遍(O(m)),再往右扫到齐全(平均 m·ln m 步)。
// 满数据 n = 1e6、m = 2000 时大约 1.6×10¹⁰ 次 —— 没有任何机会。
//
// ★ 它慢在哪很值得说清楚:**左端点右移一格,右边那一大段是白扫的** ——
// 上一轮已经知道 [x, y] 齐全了,这一轮从 x+1 开始又从头数了一遍。
// 滑动窗口就是把这段重复的账省掉。
#include <bits/stdc++.h>
using namespace std;
static int a[1000005];
static int cnt[2005];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> a[i];
int best = INT_MAX, bx = 1, by = n;
for (int x = 1; x <= n; x++) {
memset(cnt, 0, sizeof(int) * (size_t)(m + 1));
int kinds = 0;
for (int y = x; y <= n; y++) {
if (cnt[a[y]]++ == 0) kinds++;
if (kinds == m) {
if (y - x + 1 < best) { best = y - x + 1; bx = x; by = y; }
break; // 再往右只会更长
}
}
}
cout << bx << ' ' << by << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它是对的,而且题面把它的分数标出来了30% 的数据 n ≤ 200 —— 稳过。

★★★ 但 60% 那一档能不能拿到,取决于数据长什么样

本机实测(B 机:原生 Ubuntu 7.0.0-30 / i5-10210U 8 线程 / 18 GB,2026-08-27,时限 1 秒; ⚠ 不是独占(机器上同时开着浏览器,load average 约 3)—— 秒数只当量级看,倍数才是重点):

档位 形状 暴力
30%n = 200m = 20 随机 0.00 秒
60%n = 10⁵m = 10³ 随机 0.57 秒 ✓(擦着过)
60%n = 10⁵m = 10³ 最坏:前面全是画家 1,2..m 挤在最后 6.16 秒
100%n = 10⁶m = 2000 最坏 外推约 620 秒

★★★ 中间那两行是同一个 n、同一个 m只有形状不同,一个过一个不过

第 4 章 P1731 那条「数据范围顶格 ≠ 最坏」的又一次现场, 而且这次是反着用的:「我在顶格数据上测过了」并不能证明你能拿到那一档的分, 因为你测的多半是随机形状,而出题人不一定给你随机形状。

它慢在哪也说得很清楚:左端点右移一格,右边那一大段是白扫的 —— 上一轮已经知道 [x, y] 齐全,这一轮从 x+1 又从头数了一遍。滑动窗口就是把这笔重复的账省掉。

3第 ② 版:滑动窗口 + 计数数组(正解)

r 往右吃一个数     cnt[a[r]]++ ;从 0 变 1 就 kinds++
左端能缩就缩       cnt[a[l]] > 1 说明 a[l] 是多余的,扔掉
缩不动了           如果 kinds == m,这就是「以 r 结尾」的最短合法区间
p1638.cpp第 ② 版:滑动窗口(能 AC)
// P1638 逛画展 —— 能 AC 的那一版:滑动窗口 + 计数数组
//
// 要的是「包含全部 m 种画家、长度最短」的区间,多组解取左端点最小的那组。
//
// 窗口 [l, r]:`cnt[v]` 是窗口里画家 v 出现了几次,`kinds` 是窗口里有几种画家。
// r 每往右吃一个数 -> cnt[a[r]]++,如果它从 0 变 1,kinds++
// 然后把左端**能缩就缩**:cnt[a[l]] > 1 说明 a[l] 是多余的(窗口里还有一个),扔掉它
// 缩到不能再缩之后,如果 kinds == m,这就是「以 r 为右端点」的最短合法区间
//
// ★ 双指针的前提在这里长这样:r 往右走只会让 kinds 变大或不变,
// l 往右走只会让 kinds 变小或不变 —— **两个指针都不用回退**,总共走 2n 步。
//
// ⚠ 两个细节,各值一条测试点:
// ① 收缩要用 **while** 不是 if —— 一次可能要扔掉好几个多余的数(见 p1638If.cpp)
// ② 更新最优要用 **严格小于** —— 题面说「若存在多组解,输出 x 最小的那组」,
// 而 r 是从左往右扫的,写成 <= 就会被后面等长的解顶掉(见 p1638Le.cpp)
#include <bits/stdc++.h>
using namespace std;
static int a[1000005];
static int cnt[2005];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> a[i];
int l = 1, kinds = 0, best = INT_MAX, bx = 1, by = n;
for (int r = 1; r <= n; r++) {
if (cnt[a[r]]++ == 0) kinds++;
while (cnt[a[l]] > 1) { cnt[a[l]]--; l++; } // ① while,不是 if
if (kinds == m && r - l + 1 < best) { // ② 严格小于
best = r - l + 1; bx = l; by = r;
}
}
cout << bx << ' ' << by << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

★ 双指针的前提在这里长这样:r 往右只会让 kinds 变大或不变,l 往右只会让它变小或不变 —— 两个指针都不用回退,一共走 2n 步。满数据 0.06 秒

★ 换尺子:两种写法各碰了多少下

n = 20 000m = 200 的随机数据(暴力在这个规模上还跑得完):

版本 碰的下数 秒表
① 枚举左端点 25 791 263 19.6 ms
② 滑动窗口 39 037 0.1 ms
★ 差 660 倍

⚠ 暴力那个数里包含每换一个左端点都要把 cnt 清一遍O(m) —— 这笔开销在 m 大的时候比扫描本身还贵,很容易被漏掉。

4⚠ 一个不起眼的账:随机造的数据,常常「无解」

题面写着「数据保证一定有解」。可你自己造对拍数据的时候,这条保证要你自己实现

随机撒 aᵢ ∈ [1, m],要凑齐 m 种平均需要多少个数?这是集邮问题

m x (1 + 1/2 + ... + 1/m)  ~  m ln m

m = 200 时是 1176 个 —— 满数据 n = 10⁶ 远远够用, 可对拍常用的 n ≤ 30m ≤ 8经常凑不齐

⚠ 所以生成器必须先把 1..m 各放一个到随机位置,再随机填其余

不这么做的话,一大半轮次的输入是没有答案的 —— 那些轮你的两个程序会「一致地输出垃圾」, 看着 300/300 全绿,其实什么都没验。 ⇒ 第 5 章 P1618 那条「先造出有答案的数据」在这里是硬需求,不是优化。

5★ 第 ③ 版(错的):更新条件写成 <= —— 它在样例上就露馅

p1638Le.cpp演示错误写法:if (len <= best)
样例上它输出 5 10 —— 长度一样是 6,但 x 大了。
// 演示错误写法:更新最优用了 <= 而不是 <
//
// 题面那句「**若存在多组解,输出 x 最小的那组**」就落在这一个字符上。
//
// 右端点 r 是从左往右扫的,所以**后来出现的等长解,左端点一定更大**。
// 写成 <= 就会被后面那个顶掉 ⇒ 输出的是 **x 最大**的那组。
//
// ★ 它露头的条件很硬:**必须存在两组以上等长的最优解**。
// 随机数据上这不常见;对拍档位 1 专门造了「1..m 反复循环」这种形状 ——
// 那时每一个长度为 m 的窗口都是最优解,一共 n − m + 1 组。
#include <bits/stdc++.h>
using namespace std;
static int a[1000005];
static int cnt[2005];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> a[i];
int l = 1, kinds = 0, best = INT_MAX, bx = 1, by = n;
for (int r = 1; r <= n; r++) {
if (cnt[a[r]]++ == 0) kinds++;
while (cnt[a[l]] > 1) { cnt[a[l]]--; l++; }
if (kinds == m && r - l + 1 <= best) { best = r - l + 1; bx = l; by = r; } // ← <=
}
cout << bx << ' ' << by << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

r 是从左往右扫的,所以后出现的等长解,左端点一定更大。写成 <= 就等于「取 x 最大的那组」。

★ 它露头的充要条件是「存在两组以上等长最优解」。 对拍档位 1 专门造了 1 2 … m 1 2 … m … 这种形状 —— 那时每一个长度为 m 的窗口都是最优解,一共 n − m + 1 组。

300 轮里抓到:档位 0(随机)147 · 档位 1(周期串)291 · 档位 2(左边扎堆)99

6★ 第 ④ 版(错的):收缩用了 if 不是 while —— 样例上看不出来

p1638If.cpp演示错误写法:if 代替 while,一次只扔一个
样例上它输出 2 7,和正解一模一样。
// 演示错误写法:收缩左端用了 if 而不是 while
//
// 和正解只差一个词。窗口右端吃进一个数之后,左边可能**一口气**多出好几个多余的数,
// 一次只扔一个是不够的。
//
// ⚠ 它给出的区间**仍然包含全部 m 种**(所以肉眼看着「对」),只是**不够短**。
// ⇒ 这类 bug 靠「答案看起来合理」是发现不了的,只能对拍。
// ★ 它露头的条件:窗口左边要**连续出现两个以上多余的数** —— 值域越小越容易撞上。
#include <bits/stdc++.h>
using namespace std;
static int a[1000005];
static int cnt[2005];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> a[i];
int l = 1, kinds = 0, best = INT_MAX, bx = 1, by = n;
for (int r = 1; r <= n; r++) {
if (cnt[a[r]]++ == 0) kinds++;
if (cnt[a[l]] > 1) { cnt[a[l]]--; l++; } // ← if:一次只扔一个
if (kinds == m && r - l + 1 < best) { best = r - l + 1; bx = l; by = r; }
}
cout << bx << ' ' << by << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

窗口右端吃进一个数之后,左边可能一口气多出好几个多余的数,一次只扔一个是不够的。

★★ 它给出的区间「看起来完全合理」—— 这才是麻烦的地方

它输出的区间仍然包含全部 m,只是没那么短。 ⇒ 你没法靠「检查一下答案对不对」发现它,只能对拍。

★ 它露头要求左边连续堆着两个以上多余的数 —— 值域越小、重复越密越容易撞上。 所以对拍档位 2 专门用 2~3 种画家把序列铺满,再零星撒进其余种类。

★★★ 两个错版摆在一起看:为一个 bug 造的档位,对另一个 bug 是「精确的 0」
300 轮 档位 0(随机) 档位 1(1 2 … m 周期串) 档位 2(左边扎堆)
p1638Le<= 147 291 99
p1638Ifif 83 ★★★ 0 28

那个 0 不是运气差,是结构上不可能:周期串里每个值恰好每 m 个位置出现一次, 所以右端每吃进一个重复的数,左边正好只多出一个多余的数 —— ifwhile 在这种数据上逐字节等价

⇒ 档位 1 是为 <= 那个 bug 量身造的(造等长最优解), 而它同时把 if 那个 bug 的触发条件(左边连续堆多余的数)恰好消灭干净了

★ 这就是「一个档位打天下」为什么行不通: 你为某个 bug 精心构造的形状,往往正是另一个 bug 的盲区。 (第 52 章那条「顺手写的生成器有两种漏法」的升级版 —— 这次漏的不是顺手写的生成器,是精心写的那个。)

7★★★ 第二关:n 到 10⁶ 了,读入还来得及吗?

p1638Read.cpp四种读法跑同一个滑动窗口
它自己造满数据,不用喂输入。四趟的答案必须一模一样。
// P1638 的第二关:n 到 10⁶ 了,读入还来得及吗?
//
// 用法:./p1638Read [n] [m] [csv] 默认 n = 1000000、m = 2000(就是满数据)
// ★ 它自己造一份 P1638 形状的输入写进临时文件,再 freopen 回 stdin 读四遍 ——
// 不需要喂输入。四遍的**答案必须一模一样**(csv 里的 same 就是钉这件事的)。
//
// 四种读法(第 45 章第 11 步解释过它们的区别,这里量的是**这道题上的**代价):
// ① cin(默认,同步开着) ② cin + sync_with_stdio(false)
// ③ scanf ④ 手写快读(fread 整块读进来自己拼数字)
//
// ⚠ 顺序不能换:关掉同步之后 cin 会自己预读一大块,之后再 freopen 换文件,
// cin 缓冲里剩的就是上一份文件的残渣 ⇒ 「同步开着」那一趟必须排在最前面
// (这条坑是第 45 章 read.cpp 里踩过的,照抄它的顺序)。
//
// ★★ 这道题和第 6 章 [P2367] 是一对:那道题要读 2×10⁷ 个整数,**连 scanf 都不够**;
// 这道题只读 10⁶ 个 —— 结论会不会一样,正文第 7 步摆了两张表并排看。
#include <bits/stdc++.h>
#include <chrono>
using namespace std;
using namespace std::chrono;
static char path[64];
static int a[1000005];
static int cnt[2005];
static void makeData(int n, int m) {
snprintf(path, sizeof(path), "/tmp/p1638-bench-%d.txt", (int)getpid());
FILE* f = fopen(path, "w");
if (!f) { fprintf(stderr, "写不出临时文件 %s\n", path); exit(1); }
mt19937 rng(20260827u);
vector<int> v(n);
for (int i = 0; i < n; i++) v[i] = 1 + (int)(rng() % (unsigned)m);
vector<int> pos(n);
for (int i = 0; i < n; i++) pos[i] = i;
shuffle(pos.begin(), pos.end(), rng);
for (int c = 1; c <= m; c++) v[pos[c - 1]] = c; // 保证 m 种齐全
fprintf(f, "%d %d\n", n, m);
for (int i = 0; i < n; i++) fprintf(f, "%d%c", v[i], i + 1 == n ? '\n' : ' ');
fclose(f);
}
static void reopen() {
if (!freopen(path, "r", stdin)) { fprintf(stderr, "读不回临时文件\n"); exit(1); }
}
/* 滑动窗口那几行 —— 四趟共用,所以四趟之间的差别只可能来自读入 */
static long long solve(int n, int m) {
memset(cnt, 0, sizeof(int) * (size_t)(m + 1));
int l = 1, kinds = 0, best = INT_MAX, bx = 1, by = n;
for (int r = 1; r <= n; r++) {
if (cnt[a[r]]++ == 0) kinds++;
while (cnt[a[l]] > 1) { cnt[a[l]]--; l++; }
if (kinds == m && r - l + 1 < best) { best = r - l + 1; bx = l; by = r; }
}
return (long long)bx * 1000000000LL + by;
}
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 readIntFast() {
int c = gc(), x = 0;
while (c != EOF && (c < '0' || c > '9')) c = gc();
for (; c >= '0' && c <= '9'; c = gc()) x = x * 10 + (c - '0');
return x;
}
int main(int argc, char** argv) {
int n = (argc > 1) ? atoi(argv[1]) : 1000000;
int m = (argc > 2) ? atoi(argv[2]) : 2000;
bool csv = (argc > 3 && string(argv[3]) == "csv");
makeData(n, m);
double ms[4];
long long ans[4];
/* ① cin(默认,同步开着)—— 必须排第一趟 */
{ reopen();
auto t0 = steady_clock::now();
int nn, mm; cin >> nn >> mm;
for (int i = 1; i <= nn; i++) cin >> a[i];
ans[0] = solve(nn, mm);
ms[0] = duration<double, milli>(steady_clock::now() - t0).count(); }
/* ② cin + sync_with_stdio(false) */
{ reopen();
auto t0 = steady_clock::now();
ios::sync_with_stdio(false); cin.tie(nullptr);
int nn, mm; cin >> nn >> mm;
for (int i = 1; i <= nn; i++) cin >> a[i];
ans[1] = solve(nn, mm);
ms[1] = duration<double, milli>(steady_clock::now() - t0).count(); }
/* ③ scanf */
{ reopen();
auto t0 = steady_clock::now();
int nn = 0, mm = 0; if (scanf("%d %d", &nn, &mm) != 2) { nn = mm = 0; }
for (int i = 1; i <= nn; i++) { if (scanf("%d", &a[i]) != 1) break; }
ans[2] = solve(nn, mm);
ms[2] = duration<double, milli>(steady_clock::now() - t0).count(); }
/* ④ 手写快读 */
{ reopen(); ipos = ilen = 0;
auto t0 = steady_clock::now();
int nn = readIntFast(), mm = readIntFast();
for (int i = 1; i <= nn; i++) a[i] = readIntFast();
ans[3] = solve(nn, mm);
ms[3] = duration<double, milli>(steady_clock::now() - t0).count(); }
remove(path);
bool same = (ans[0] == ans[1] && ans[1] == ans[2] && ans[2] == ans[3]);
if (csv) {
const char* key[4] = { "cin", "nosync", "scanf", "fast" };
for (int k = 0; k < 4; k++) printf("%s,%.1f\n", key[k], ms[k]);
printf("same,%d\nx,%lld\ny,%lld\n", same ? 1 : 0, ans[0] / 1000000000LL, ans[0] % 1000000000LL);
return 0;
}
const char* name[4] = { "cin(默认,同步开着)", "cin + sync_with_stdio(false)",
"scanf", "手写快读(fread)" };
/* ⚠ 中文是双宽的,%-30s 按字节数补空格会补歪 —— 照第 45 章 read.cpp 那套按显示宽度补 */
auto disp = [](const string& t) {
int w = 0;
for (unsigned char c : t) { if ((c & 0xC0) == 0x80) continue; w += (c < 0x80) ? 1 : 2; }
return w;
};
auto padR = [&](const string& t, int w) { return t + string(max(0, w - disp(t)), ' '); };
double best = *min_element(ms, ms + 4);
printf("n = %d, m = %d(读 %d 个整数),四种读法跑同一个滑动窗口:\n\n", n, m, n);
for (int k = 0; k < 4; k++)
printf(" %s %8.1f 毫秒 慢 %4.1f 倍\n", padR(name[k], 30).c_str(), ms[k], ms[k] / best);
printf("\n四趟的答案%s(都是 %lld %lld)\n", same ? "完全一致" : "居然不一致!",
ans[0] / 1000000000LL, ans[0] % 1000000000LL);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

本机实测(同机同日,n = 10⁶m = 2000,读 100 万个整数,时限 1 秒; ⚠ 不是独占(机器上同时开着浏览器,load average 约 3)—— 秒数只当量级看,倍数才是重点):

读法 毫秒 比最快的慢 过得去吗
cin(默认,同步开着) 161.8 11.9 倍 ★ ✓
cin + sync_with_stdio(false) 54.3 4.0 倍
scanf 66.8 4.9 倍
手写快读(fread 13.6 1.0 倍
★★★ 和第 6 章 P2367 并排看 —— 倍数几乎一样,结论完全相反
读几个整数 cin 关同步 scanf 快读 结论
P2367 2×10⁷ 8139.7 ms 2676.6 3029.1 682.3 scanf 都不够
本题 10⁶ 161.8 ms 54.3 66.8 13.6 默认 cin 都够
倍数 11.9 / 11.9 4.0 / 3.9 4.9 / 4.4 1.0 / 1.0 几乎一模一样

★★★ 四种读法的相对快慢是稳定的(两道题的倍数几乎重合), 但「够不够用」比的是绝对时间,而绝对时间跟着读入量走。

⇒ 所以「读入量到 10⁶ 就该关同步,到 10⁷ 就该上快读」这条经验值要这么用: 它说的是从哪儿开始该操心,不是「不到就一定安全」。 这道题正好卡在 10⁶:默认 cin 花掉了 16% 的时限 —— 过是过了,但你要是在窗口里再多做点别的事,它就是压垮骆驼的那根稻草。

8★ 对拍:900 轮,三档

p1638Gen.cpp生成器:三个档位
参数是「种子 档位」。档位 1 是 1..m 反复循环(造等长最优解),档位 2 是左边扎堆重复。
// 数据生成器(P1638 对拍用):`./p1638Gen <seed> [level]`
//
// level 0(默认)随机:n <= 30、m <= 8,**1..m 各先放一个到随机位置**(保证有解)
// level 1 **多组等长最优**:1..m 反复循环(1 2 … m 1 2 … m …),
// 于是每一个长度为 m 的窗口都是最优解,一共 n − m + 1 组
// level 2 **左边扎堆重复**:只用 2~3 种画家把序列铺满,再零星撒进其余种类
//
// ★ 三个档位各盯一个错版:
// level 1 是给 p1638Le(`<=` 顶掉了 x 更小的解)造的 —— 没有等长解它就抓不到;
// level 2 是给 p1638If(一次只缩一格)造的 —— 要左边**连续**堆着好几个多余的数才露头;
// level 0 是兜底。
//
// ⚠ 题面「数据保证一定有解」,所以三个档位都必须自己保证 m 种齐全。
// 随机撒 a_i ∈ [1, m] 是不够的:凑齐 m 种平均要 m·ln m 个数(集邮问题),
// n <= 30、m = 8 时经常凑不齐。⇒ 第 5 章 P1618 那条「先造出有答案的数据」。
#include <bits/stdc++.h>
using namespace std;
static mt19937 rng;
static int ri(int lo, int hi) { return lo + (int)(rng() % (unsigned)(hi - lo + 1)); }
int main(int argc, char** argv) {
unsigned seed = (argc > 1) ? (unsigned)atoi(argv[1]) : 1;
int level = (argc > 2) ? atoi(argv[2]) : 0;
rng.seed(seed);
int m = ri(1, 8);
int n = ri(m, 30);
vector<int> a(n);
if (level == 1) {
for (int i = 0; i < n; i++) a[i] = i % m + 1; // 1 2 … m 1 2 … m …
} else if (level == 2) {
int few = min(m, ri(2, 3));
for (int i = 0; i < n; i++) a[i] = ri(1, few); // 先用 2~3 种铺满
vector<int> pos(n);
for (int i = 0; i < n; i++) pos[i] = i;
shuffle(pos.begin(), pos.end(), rng);
for (int v = 1; v <= m; v++) a[pos[v - 1]] = v; // 再保证 m 种齐全
} else {
for (int i = 0; i < n; i++) a[i] = ri(1, m);
vector<int> pos(n);
for (int i = 0; i < n; i++) pos[i] = i;
shuffle(pos.begin(), pos.end(), rng);
for (int v = 1; v <= m; v++) a[pos[v - 1]] = v;
}
printf("%d %d\n", n, m);
for (int i = 0; i < n; i++) printf("%d%c", a[i], i == n - 1 ? '\n' : ' ');
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
p1638GenBig.cpp大数据生成器:随机 vs 最坏形状
参数是「n m 档位」。档位 1 是最坏形状:前面全是画家 1,2..m 挤在最后。
// 大数据生成器(P1638 计时用):`./p1638GenBig [n] [m] [level]`
//
// level 0(默认)随机 a_i ∈ [1, m],并保证 1..m 各出现至少一次
// level 1 **最坏形状**:前 n−m 个位置全是画家 1,最后 m 个位置才把 2..m 亮出来
// ⇒ 暴力的每一个左端点都要一路扫到接近末尾
//
// ★ 两个档位差得很远:档位 0 的最短窗口大约 m·ln m(随机),
// 档位 1 的最短窗口是 m,但**暴力**要扫的距离几乎是整个数组 ——
// 「量上限」要用档位 1(第 5 章 P1563 那条:**要量上限就把旋钮拧到头**)。
#include <bits/stdc++.h>
using namespace std;
static mt19937 rng;
int main(int argc, char** argv) {
int n = (argc > 1) ? atoi(argv[1]) : 1000000;
int m = (argc > 2) ? atoi(argv[2]) : 2000;
int level = (argc > 3) ? atoi(argv[3]) : 0;
rng.seed(12345);
vector<int> a(n);
if (level == 1) {
for (int i = 0; i < n; i++) a[i] = 1;
for (int v = 2; v <= m; v++) a[n - m + v - 1] = v;
} else {
for (int i = 0; i < n; i++) a[i] = 1 + (int)(rng() % (unsigned)m);
vector<int> pos(n);
for (int i = 0; i < n; i++) pos[i] = i;
shuffle(pos.begin(), pos.end(), rng);
for (int v = 1; v <= m; v++) a[pos[v - 1]] = v;
}
printf("%d %d\n", n, m);
for (int i = 0; i < n; i++) printf("%d%c", a[i], i == n - 1 ? '\n' : ' ');
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

check:viz 每档 300 轮,暴力当标准答案:900 轮里滑动窗口和暴力逐字节相同。 两个错版被抓到的轮数是 147 / 291 / 9983 / 0 / 28 —— 那个 0 见上一步那张表。

9四个版本并排

p1638Count.cpp尺子 + 集邮问题那笔账
参数是「n m」。暴力在这个规模上还跑得完,所以两把尺子都能摆出来。
// 换一把尺子:两种写法各碰了多少下(外加一笔「随机数据够不够齐全」的账)
//
// 用法:./p1638Count <n> <m> 人话版(带秒表)
// ./p1638Count <n> <m> csv 只打 `键,值`,给 check:viz 用
//
// ① 尺子:暴力每换一个左端点都要重新往右数一遍;滑动窗口两个指针各走一趟,一共 2n 步。
// ⇒ 这一比是「重复的账」到底有多少。
//
// ② 顺带一笔常被忽略的账:**随机造 a_i ∈ [1, m] 的数据,n 要多大才几乎必然 m 种齐全?**
// 这是「集邮问题」:期望要 m·(1 + 1/2 + … + 1/m) ≈ m·ln m 个数。
// m = 2000 时约 16 400 —— 满数据 n = 10⁶ 远远够;
// 但对拍用的小数据(n <= 30、m <= 8)就常常凑不齐 ⇒ **生成器必须自己保证有解**。
// (第 5 章 P1618 那条:「先造出有答案的数据」。)
#include <bits/stdc++.h>
using namespace std;
static double now_ms() {
timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return t.tv_sec * 1000.0 + t.tv_nsec / 1e6;
}
int main(int argc, char** argv) {
int n = (argc > 1) ? atoi(argv[1]) : 20000;
int m = (argc > 2) ? atoi(argv[2]) : 200;
bool csv = (argc > 3 && string(argv[3]) == "csv");
/* 造一份「保证有解」的随机数据:先把 1..m 各放一个到随机位置,其余随机填 */
mt19937 rng(20260827u);
vector<int> a(n + 1);
for (int i = 1; i <= n; i++) a[i] = 1 + (int)(rng() % (unsigned)m);
{
vector<int> pos(n);
for (int i = 0; i < n; i++) pos[i] = i + 1;
shuffle(pos.begin(), pos.end(), rng);
for (int v = 1; v <= m; v++) a[pos[v - 1]] = v;
}
vector<int> cnt(m + 1, 0);
long long bruteIter = 0;
double t0 = now_ms();
int bBest = INT_MAX, bx = 1, by = n;
for (int x = 1; x <= n; x++) {
fill(cnt.begin(), cnt.end(), 0);
bruteIter += m + 1; // 清 cnt 也是实打实的开销
int kinds = 0;
for (int y = x; y <= n; y++) {
bruteIter++;
if (cnt[a[y]]++ == 0) kinds++;
if (kinds == m) { if (y - x + 1 < bBest) { bBest = y - x + 1; bx = x; by = y; } break; }
}
}
double tBrute = now_ms() - t0;
fill(cnt.begin(), cnt.end(), 0);
long long winIter = 0;
t0 = now_ms();
int wBest = INT_MAX, wx = 1, wy = n;
{
int l = 1, kinds = 0;
for (int r = 1; r <= n; r++) {
winIter++;
if (cnt[a[r]]++ == 0) kinds++;
while (cnt[a[l]] > 1) { winIter++; cnt[a[l]]--; l++; }
if (kinds == m && r - l + 1 < wBest) { wBest = r - l + 1; wx = l; wy = r; }
}
}
double tWin = now_ms() - t0;
/* 集邮问题:随机取 a_i ∈ [1, m] 时,凑齐 m 种平均要几个数 */
double coupon = 0;
for (int i = 1; i <= m; i++) coupon += 1.0 / i;
coupon *= m;
if (csv) {
printf("n,%d\nm,%d\nbrute,%lld\nwin,%lld\nratio,%lld\n"
"same,%d\nbest,%d\nx,%d\ny,%d\ncoupon,%d\n",
n, m, bruteIter, winIter, bruteIter / winIter,
(bBest == wBest && bx == wx && by == wy) ? 1 : 0,
wBest, wx, wy, (int)(coupon + 0.5));
} else {
printf("n = %d, m = %d(随机数据,1..m 各保证出现一次)\n\n", n, m);
printf("(1) 枚举左端点 %12lld 次 %8.1f ms\n", bruteIter, tBrute);
printf("(2) 滑动窗口 %12lld 次 %8.1f ms\n", winIter, tWin);
printf("=> 差 %lld 倍\n\n", bruteIter / winIter);
printf("两版答案%s:%d %d(长度 %d)\n\n",
(bBest == wBest && bx == wx && by == wy) ? "一致" : "不一致!", wx, wy, wBest);
printf("集邮问题:随机取 a_i in [1, %d],凑齐 %d 种平均要 %d 个数\n", m, m, (int)(coupon + 0.5));
printf(" => 满数据 n = 1e6 远远够用;但对拍的小数据必须由生成器保证有解\n");
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
版本 想法 复杂度 满数据 分数
p1638Brute 枚举左端点 O(n × 窗口长) 最坏外推 620 秒 30 分(60 分那档看形状)
p1638 滑动窗口 O(n) 0.06 秒 100 分
p1638Le ② 的 < 写成 <= O(n) 0.06 秒 WA(样例就挂)
p1638If ② 的 while 写成 if O(n) 0.06 秒 WA(样例看不出来)
这一页记住三句话
  1. ★★★ 同一个 n、同一个 m,形状不同能差十倍。 暴力在 60% 那档上随机形状 0.57 秒过、最坏形状 6.16 秒挂 —— 「我在顶格数据上测过了」证明不了你拿得到那一档的分。
  2. ★★★ 为一个 bug 造的数据,可能正是另一个 bug 的盲区。 档位 1(周期串)把 <= 那个 bug 抓了 291 / 300, 却让 if 那个 bug 变成精确的 0(周期串上 ifwhile 逐字节等价)。 ⚠ 顺带:两个坑一明一暗 —— <= 那个样例就露馅,if 那个样例上完全正确。
  3. ★★★ 读入优化的经验值要连规模一起说。 四种读法的倍数跨题几乎不变 (11.9 / 4.0 / 4.9 / 1.0),但「够不够」看的是绝对时间: 同样的四张牌,2×10⁷ 个数时连 scanf 都不够,10⁶ 个数时默认 cin 都够。