0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1638,日期见页头。两边不一致时信原站。
题目描述
博览馆正在展出由世上最佳的 m 位画家所画的图画。
游客在购买门票时必须说明两个数字,x 和 y,代表他要看展览中的第 x 幅至第 y 幅画
(包含 x, y)之间的所有图画,而门票的价钱就是一张图画一元。
Sept 希望入场后可以看到所有名师的图画。当然,他想最小化购买门票的价格。
请求出他购买门票时应选择的 x, y,数据保证一定有解。
若存在多组解,输出 x 最小的那组。
输入格式
第一行两个整数 n, m,分别表示博览馆内的图画总数及这些图画是由多少位名师所绘画的。
第二行包含 n 个整数 aᵢ,代表画第 i 幅画的名师的编号。
输出格式
一行两个整数 x, y。
说明 / 提示:数据规模与约定
- 对于
30%的数据,有n ≤ 200,m ≤ 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 7 和 5 10)。
第 5 步那个错版(更新条件写成 <=)在样例上就会输出 5 10。
⇒ 这道题「样例过了」这个门槛,比大多数题都值钱一点。
⚠ 但另一个错版(第 6 步,收缩用 if 不用 while)在样例上是对的 ——
所以「样例过了就交」照样会挂。两个坑一明一暗,正好凑一对。
2第 ① 版:枚举左端点,往右扫到齐全
// 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;}点「运行 ▶」看结果
它是对的,而且题面把它的分数标出来了:30% 的数据 n ≤ 200 —— 稳过。
本机实测(B 机:原生 Ubuntu 7.0.0-30 / i5-10210U 8 线程 / 18 GB,2026-08-27,时限 1 秒;
⚠ 不是独占(机器上同时开着浏览器,load average 约 3)—— 秒数只当量级看,倍数才是重点):
| 档位 | 形状 | 暴力 |
|---|---|---|
30%:n = 200、m = 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 |
最坏 | 按 n² 外推约 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 逛画展 —— 能 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;}点「运行 ▶」看结果
★ 双指针的前提在这里长这样:r 往右只会让 kinds 变大或不变,l 往右只会让它变小或不变
—— 两个指针都不用回退,一共走 2n 步。满数据 0.06 秒。
n = 20 000、m = 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 ≤ 30、m ≤ 8 就经常凑不齐。
不这么做的话,一大半轮次的输入是没有答案的 —— 那些轮你的两个程序会「一致地输出垃圾」, 看着 300/300 全绿,其实什么都没验。 ⇒ 第 5 章 P1618 那条「先造出有答案的数据」在这里是硬需求,不是优化。
5★ 第 ③ 版(错的):更新条件写成 <= —— 它在样例上就露馅
// 演示错误写法:更新最优用了 <= 而不是 <//// 题面那句「**若存在多组解,输出 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;}点「运行 ▶」看结果
r 是从左往右扫的,所以后出现的等长解,左端点一定更大。写成 <= 就等于「取 x 最大的那组」。
★ 它露头的充要条件是「存在两组以上等长最优解」。
对拍档位 1 专门造了 1 2 … m 1 2 … m … 这种形状 ——
那时每一个长度为 m 的窗口都是最优解,一共 n − m + 1 组。
300 轮里抓到:档位 0(随机)147 · 档位 1(周期串)291 · 档位 2(左边扎堆)99。
6★ 第 ④ 版(错的):收缩用了 if 不是 while —— 样例上看不出来
// 演示错误写法:收缩左端用了 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;}点「运行 ▶」看结果
窗口右端吃进一个数之后,左边可能一口气多出好几个多余的数,一次只扔一个是不够的。
它输出的区间仍然包含全部 m 种,只是没那么短。
⇒ 你没法靠「检查一下答案对不对」发现它,只能对拍。
★ 它露头要求左边连续堆着两个以上多余的数 —— 值域越小、重复越密越容易撞上。 所以对拍档位 2 专门用 2~3 种画家把序列铺满,再零星撒进其余种类。
| 300 轮 | 档位 0(随机) | 档位 1(1 2 … m 周期串) |
档位 2(左边扎堆) |
|---|---|---|---|
p1638Le(<=) |
147 | ★ 291 | 99 |
p1638If(if) |
83 | ★★★ 0 | 28 |
那个 0 不是运气差,是结构上不可能:周期串里每个值恰好每 m 个位置出现一次,
所以右端每吃进一个重复的数,左边正好只多出一个多余的数 ——
if 和 while 在这种数据上逐字节等价。
⇒ 档位 1 是为 <= 那个 bug 量身造的(造等长最优解),
而它同时把 if 那个 bug 的触发条件(左边连续堆多余的数)恰好消灭干净了。
★ 这就是「一个档位打天下」为什么行不通: 你为某个 bug 精心构造的形状,往往正是另一个 bug 的盲区。 (第 52 章那条「顺手写的生成器有两种漏法」的升级版 —— 这次漏的不是顺手写的生成器,是精心写的那个。)
7★★★ 第二关:n 到 10⁶ 了,读入还来得及吗?
// 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 倍 | ✓ |
| 读几个整数 | 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 轮,三档
// 数据生成器(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;}点「运行 ▶」看结果
// 大数据生成器(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 / 99 和 83 / 0 / 28 —— 那个 0 见上一步那张表。
9四个版本并排
// 换一把尺子:两种写法各碰了多少下(外加一笔「随机数据够不够齐全」的账)//// 用法:./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(样例看不出来) |
- ★★★ 同一个
n、同一个m,形状不同能差十倍。 暴力在60%那档上随机形状 0.57 秒过、最坏形状 6.16 秒挂 —— 「我在顶格数据上测过了」证明不了你拿得到那一档的分。 - ★★★ 为一个 bug 造的数据,可能正是另一个 bug 的盲区。
档位 1(周期串)把
<=那个 bug 抓了 291 / 300, 却让if那个 bug 变成精确的 0(周期串上if和while逐字节等价)。 ⚠ 顺带:两个坑一明一暗 ——<=那个样例就露馅,if那个样例上完全正确。 - ★★★ 读入优化的经验值要连规模一起说。 四种读法的倍数跨题几乎不变
(11.9 / 4.0 / 4.9 / 1.0),但「够不够」看的是绝对时间:
同样的四张牌,
2×10⁷个数时连scanf都不够,10⁶个数时默认cin都够。