第 4 节的数组,下标是位置:a[0] 是第 0 个读进来的数,a[i] 是第 i 个。这一节换一种用法:让下标等于值本身。cnt[x] 读作“值等于 x 的元素有几个”。数组还是那个 int[],变的只是下标的含义。
先看这段骨架。本节剩下的内容,都是在解释它为什么成立、什么时候不成立:
int cnt[1001]; // 值域 0..1000,所以要 1001 个桶
for (int i = 0; i < n; ++i) {
int x;
cin >> x;
++cnt[x]; // 值 x 出现一次,就让第 x 号桶加一
}
for (int x = 0; x <= 1000; ++x) // 按下标从小到大扫一遍
for (int k = 0; k < cnt[x]; ++k)
cout << x << " "; // 输出自动升序,一次 sort 都没调用这几行同时干完三件事:统计(每个值出现几次)、去重(同一个桶只算一次)、排序(下标从小到大就是值的从小到大)。代价是开 1001 个格子。
| 普通数组 a[i] | 桶 cnt[x] | |
|---|---|---|
| 下标含义 | 第 i 个位置 | 值 x 本身 |
| 格子内容 | 那个位置上的数 | 值 x 出现的次数(或“是否出现”) |
| 大小由谁决定 | 元素个数 n | 值域上界 V |
| 读入后能做什么 | 按原顺序重放 | 统计、去重、升序输出、O(1) 判断存在 |
| 总复杂度 | O(n) | O(n + V) |
本节没有新的标准库函数。要拆的是数组的新用法:格子怎么开、cnt[x]++ 到底做了什么、什么时候该用 “= 1” 而不是 “++”。
| 组成 | 含义 / 格式要求 |
|---|---|
cnt | 数组名。它存的不是“数据”,而是“每个值出现的次数”,所以叫桶(bucket)。 |
V + 1 | 桶的个数等于值域大小。值能取到 0..V 共 V+1 种,下标就必须有 0..V,所以要开 V+1 个。 |
int | 计数器用 int 够:个数最多 n 个,远小于 2.1×10⁹。只有当桶里要存“权值之和”时才换 long long。 |
全局 / static | 写在 main 外面时全部分配在静态区,系统自动清零。写在 main 里面是随机值,必须先清零。 |
空间代价 | 4 × (V+1) 字节。V = 1000 是 4 KB;V = 10⁶ 是 4 MB;V = 10⁷ 是 40 MB,这已是天花板。 |
int cnt[1005]; // 全局:值域 1..1000,全自动清零
int main() {
int n; cin >> n;
for (int i = 0; i < n; ++i) {
int x; cin >> x;
++cnt[x]; // 值 x 出现一次,第 x 号桶加一
}
cout << cnt[7] << "\n"; // 值 7 出现了几次
return 0;
}桶的大小由值的范围决定,和 n 毫无关系。n 只有 10,值到 10⁶,照样要开 10⁶+1 个。
桶开在 main 里又忘了清零,是“答案莫名多出一堆数”的头号原因。
值里有负数时不能直接当下标:先整体加一个偏移量搬进非负区间。
| 组成 | 含义 / 格式要求 |
|---|---|
cnt[x] | 先算出下标 x 对应的地址(cnt + x * 4),再取那一格。x 必须落在 [0, 数组长度) 内,没有越界检查。 |
++ | 自增:把那一格的值加一。单独成一句时,++cnt[x] 与 cnt[x]++ 效果完全相同。 |
语义 | “值等于 x 的元素又出现了一个”。它同时完成了定位与计数,中间没有循环、没有比较。 |
O(1) | 一次 ++ 只做“算地址 + 加一”,与桶多大、数据多少都无关。这是桶能打败排序的根本原因。 |
三种变体 | cnt[x] = 1 只记“出现过”;++cnt[x] 记“几次”;cnt[x] += w 记“权值和”;--cnt[x] 表示“消耗一个”。 |
int cnt[10] = {0};
cnt[3]++; cnt[3]++; cnt[9]++; // 值 3 出现 2 次,值 9 出现 1 次
cout << cnt[3] << " " << cnt[9]; // 2 1
int x = 3;
if (cnt[x] > 0) --cnt[x]; // “消耗”一个 3:配对、匹配类题目的常用写法别写成 cnt[x++]:那是“用 x 当下标,然后把 x 加一”,完全另一回事。
x 是 char 时会被当作 ASCII 码当下标,所以 cnt['a'] 和 cnt[97] 是同一个桶。
cnt[x]++ 不检查 x 是否合法。越界一格不会报错,只会悄悄改掉相邻内存。
| 组成 | 含义 / 格式要求 |
|---|---|
cnt[x] = 1 | 赋值而不是自增。同一个 x 读入十次,格子仍然是 1 —— 去重是赋值本身带来的。 |
读法 | if (cnt[x]) 表示 x 出现过;if (!cnt[x]) 表示没出现过。任意非 0 都算真。 |
与 ++ 的区别 | 标记法丢掉了次数信息。要次数就用 ++,要存在性就用 = 1,别两个混着用。 |
bool 数组 | bool vis[N + 1]; 每格 1 字节,比 int 省 3/4。值域 10⁷ 时 int 要 40 MB,bool 只要 10 MB。 |
int vis[1005]; // 0 = 没出现,1 = 出现过
vis[42] = 1; // 标记值 42 存在
if (vis[42]) cout << "有 42\n";
if (!vis[7]) cout << "没有 7\n";需要“出现过几次”时用标记桶会丢信息;需要“存在性”时用计数桶只是白花力气。先想清楚要哪一个。
同一道题里两种桶可以同时用:本节挑战题里 have[] 判存在、used[] 判是否已计过答案,就是两个桶。
明明用计算机生成了 n 个 1 到 1000 之间的随机整数,其中有重复。他要先把重复的数去掉、每种只留一个,再把这些数从小到大排好,按排好的顺序给同学编号。
输入格式:第一行一个整数 n(1 ≤ n ≤ 100);第二行 n 个用空格隔开的整数,每个都在 1 到 1000 之间。
输出格式:第一行一个整数 M,表示不相同的数的个数;第二行 M 个整数,从小到大用空格隔开。
题目要两件事:去重、升序。常规做法是读进数组 → 排序 → 相邻比较去重,三步,还得先会写排序。
换个角度:值的可能取值只有 1 到 1000,一共 1000 种。既然只有 1000 种,就为每一种准备一个格子,格子的编号直接等于值。读到一个 x,就去第 x 号格子做个记号。
cnt[20] = 1; // 读到 20,给 20 号格子打勾
cnt[40] = 1;
cnt[20] = 1; // 又读到一个 20:本来就是 1,再写一次还是 1 —— 去重是免费的读完以后从 1 号格子扫到 1000 号格子,打了勾的就把编号输出。格子编号天然从小到大,所以输出天然升序 —— 排序也是免费的。
| 代码片段 | 含义与理由 |
|---|---|
for (int x = 1; x <= 1000; ++x) | 这里枚举的是“可能的值”,不是“已读入的数”。下标的顺序替我们做了比较。 |
if (cnt[x]) | 非 0 即真:这个值出现过。0 就是没出现。 |
cout << x | 输出的是下标 x,不是 cnt[x]。桶里存的是次数,我们要的是值 —— 这是新手最容易写反的一行。 |
cnt[x] = 1 | 赋值导致重复读入不改变结果,所以不需要额外的去重步骤。 |
代价写在明面上:多花 1001 个 int(4 KB),把 O(n log n) 的排序换成了 O(n + 1000) 的一次扫描。这就是以空间换时间的字面意思。
#include <bits/stdc++.h>
using namespace std;
// cnt[x] = 1 表示“值 x 出现过”,0 表示没出现。
// 值域是 1..1000,下标最多用到 1000;开 1005 是手滑余量。
// 放在 main 外面:全局数组由系统清零,省掉一次手动清零,也不占栈空间。
int cnt[1005];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
for (int i = 0; i < n; ++i) {
int x;
cin >> x;
cnt[x] = 1; // 用 = 1 而不是 ++cnt[x]:只要“出现过”,不要次数
}
int m = 0; // 题目要求先输出个数,所以先数一遍
for (int x = 1; x <= 1000; ++x)
if (cnt[x]) ++m;
cout << m << "\n";
bool first = true; // 控制空格:第一个数前面不输出空格,行末就不会多出空格
for (int x = 1; x <= 1000; ++x)
if (cnt[x]) {
if (!first) cout << " ";
first = false;
cout << x;
}
cout << "\n";
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
int cnt[1005]; | 桶的大小由值域决定:最大下标 1000,至少 1001 个。放全局是为了自动清零,同时避开 8 MB 的栈限制。 |
cnt[x] = 1; | 值当下标。这一步只做一次地址计算 cnt + x*4,与 n、与 V 都无关,是 O(1)。 |
for (int i = 0; i < n; ++i) | 读入阶段和普通数组一模一样,区别只是“存进第 x 格”而不是“存进第 i 格”。 |
for (int x = 1; x <= 1000; ++x) | 固定 1000 次,与 n 无关。n = 3 也要扫 1000 次 —— 这就是桶的 V 成本。 |
if (cnt[x]) ++m; | cnt[x] 为 1 表示出现过,计数加一。非 0 即真,不用写 == 1。 |
bool first = true; | 第一个输出前不加空格,避免行首/行末多空格被严格比对判 WA。 |
if (!first) cout << " "; | 除第一个数外,每个数前面补一个空格,等价于“用空格分隔”。 |
return 0; | 正常结束。桶法不需要 sort,也就没有 cmp、没有稳定性问题。 |
把 cnt[x] = 1 换成 ++cnt[x],桶里存的就是次数。这时扫一遍桶就能按升序输出全部元素,包括重复的 —— 这个算法有名字:计数排序。
// 输入 n 个数(值 1..1000,允许重复),升序全部输出
int cnt[1005];
int main() {
int n; cin >> n;
for (int i = 0; i < n; ++i) { int x; cin >> x; ++cnt[x]; }
for (int x = 1; x <= 1000; ++x)
for (int k = 1; k <= cnt[x]; ++k) // 出现几次就打印几次
cout << x << " ";
cout << "\n";
}计数排序不是比较排序:它从不比较两个元素的大小,所以 O(n log n) 的比较次数下界对它不成立,它只受值域 V 限制。这也是它快的原因,也是它碰不了“值域巨大”和“字符串、结构体”的原因。
| 需求 | 桶里存什么 | 写法 |
|---|---|---|
| 有多少种不同的值 | 0/1 标记 | cnt[x] = 1 |
| 每个值出现几次 | 次数 | ++cnt[x] |
| 升序输出全部元素 | 次数 | ++cnt[x] 后按次数打印 |
| 判断某个值在不在 | 0/1 标记 | if (cnt[x]) |
桶唯一的门槛是 V。V 开不出数组,桶法就不能直接用。先记住这张判断表:
| 值域上界 V | 桶的大小(int) | 能不能用 |
|---|---|---|
| 10³ | 4 KB | 放心用 |
| 10⁶ | 4 MB | 放心用 |
| 10⁷ | 40 MB | 128 MB 限制下勉强,别再开第二个大数组 |
| 10⁸ | 400 MB | 别想,换方法 |
| 10⁹ | 4 GB | 绝对开不出来 |
V 太大时有两条现在就能用的出路,还有三条以后会学的:
// 值域是 -50..50 的温度:整体平移 50,下标变成 0..100
int cnt[101];
for (int i = 0; i < n; ++i) {
int t; cin >> t;
++cnt[t + 50]; // -50 -> 0, 0 -> 50, 50 -> 100
}
cout << cnt[50] << "\n"; // 温度 0 对应下标 50压缩和离散化的共同点:先用一个单调映射把大值域换成小下标,之后所有操作都在小下标上做。区别只在离散化是先看数据再定映射,压缩是直接按公式算。
桶开小了:值最大 1000 却写 int cnt[1000];,cnt[1000] 已经越界一格。症状:本地 AC、提交 RE;或答案莫名错一位(写坏了相邻变量,编译器一声不响)。
忘了清零:桶写在 main 里、又是多组数据,第二轮在第一轮结果上继续累加。症状:WA 且答案偏大,第一组数据永远是对的。
看错值域:题面写 ai ≤ 10⁹,还坚持开桶。症状:编译通过,一运行就崩或直接 MLE;若能开出的桶太小又硬要扫完 10⁹ 个下标,则是 TLE。
在 main 里开大数组:int cnt[1000005]; 要 4 MB 栈空间,栈一般只有 8 MB。症状:RE(SIGSEGV),换台机器结果还不一样。挪到全局即可。
用桶输出“原顺序”:桶只记得哪些值出现过,不记得谁先出现。症状:顺序全乱的 WA。
负数直接当下标:++cnt[-3]。症状:RE,或悄悄改坏别的内存,且只在含负数的数据上错。
关掉页面,把上面 P1059 的完整代码手打一遍并提交。
再试一组数据:第一行 8,第二行 5 5 3 9 3 3 1 9。去重后应是 1 3 5 9,第一行个数应为 4。若你的第一行输出 8,说明你数的是元素个数而不是桶。
改一改:把 cnt[x] = 1 改成 ++cnt[x],输出那段改成“值 x 打印 cnt[x] 次”,同一组数据应输出 1 3 3 3 5 5 9 9 —— 一个字符之差,桶就从去重变成了计数排序。
定义 f(v) = 值 v 在输入中出现的次数。桶数组就是把 f 在 v = 0, 1, …, V 上的取值全部存下来。所以桶计数的本质是打表:输入还没来,就先为每个可能的输入准备好了答案。
时间账:读入 n 个数,每个做一次 ++cnt[x],共 n 次 O(1);最后从 0 扫到 V,共 V+1 次。总时间 O(n + V) —— 只和“值域多宽”有关,和“值多大”无关。
| V 与 n 的关系 | 总操作数 | 结论 |
|---|---|---|
| n = 10⁵,V = 10⁵ | ≈ 2×10⁵ | 比 sort 的 n log n ≈ 1.7×10⁶ 快近一个量级 |
| n = 100,V = 1000 | 1100 | 扫桶比数据多花 10 倍,仍可忽略 |
| n = 10⁵,V = 10⁹ | ≈ 10⁹ | 必 TLE;空间 4 GB 也开不出来 |
空间账:桶占 4(V+1) 字节。口诀是 V ≤ 10⁷、且 V 不比 n 大太多,才用桶。V ≫ n 时,正确工具是排序或离散化,不是桶。
再往前一步就是前缀和:令 S(x) = cnt[0] + cnt[1] + … + cnt[x],S(x) 就是“值 ≤ x 的元素个数”。第 k 小的数,就是第一个满足 S(x) ≥ k 的 x。桶 + 前缀和 = 不排序也能求第 k 小,这条线索在第 18 节(前缀和)和第 13 节(离散化)会接上。
a[i] 与 cnt[x] 的区别,并写出“读 n 个数进桶”的三行代码。老师随机生成一个正整数集合,集合里的数互不相同。她要求学生回答:其中有多少个数,恰好等于集合中另外两个(不同的)数之和。
输入格式:第一行一个整数 n(3 ≤ n ≤ 100);第二行 n 个用空格隔开的正整数,互不相同,每个不超过 10000。
输出格式:一行一个整数,表示满足条件的数的个数。
先想暴力:枚举“和”,再枚举两个加数,是三层循环。能不能只枚举两个加数,让“和”自己算出来?
题目问的是有多少个数满足条件,不是有多少对。同一个和可能被好几组数凑出来。
两个加数必须是集合里不同的数:集合里只有一个 2 时,不存在 2 + 2 = 4。
第一步把集合搬进桶:have[v] = 1 表示 v 在集合里。此后“某个值在不在”只需 O(1)。
第二步枚举两个不同的数 a[i]、a[j](规定 i < j),算出 s = a[i] + a[j]。若 have[s] 为 1,说明 s 是集合里的数、且能被两个不同的数凑出来。
第三步解决重复:{1,2,3,4,5} 里 5 = 1+4 = 2+3,会被枚举到两次,但题目只要一个 5。所以要再开一个 used[s] 桶,第一次算到时置 1,之后再遇到就跳过。
值域:每个数 ≤ 10000,两数之和 ≤ 20000,所以 have、used 都开 20005。
复杂度 O(n² + V) = 10⁴ + 2×10⁴ 级别,n ≤ 100 时瞬间出解。
#include <bits/stdc++.h>
using namespace std;
int a[105];
int have[20005]; // have[v] = 1 表示值 v 在集合里
int used[20005]; // used[v] = 1 表示值 v 已经算过一次答案
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
for (int i = 0; i < n; ++i) {
cin >> a[i];
have[a[i]] = 1; // 存在性标记:值当下标
}
int ans = 0;
for (int i = 0; i < n; ++i)
for (int j = i + 1; j < n; ++j) { // j 从 i+1 开始,两个加数必定不同
int s = a[i] + a[j];
if (s <= 20000 && have[s] && !used[s]) {
used[s] = 1; // 同一个和只记一次
++ans;
}
}
cout << ans << "\n";
return 0;
}| 片段 | 作用 |
|---|---|
have[a[i]] = 1; | 值当下标,把 10000 个可能的取值压成一个可 O(1) 查询的表。用 = 1 而不是 ++,因为只问存在性。 |
for (int j = i + 1; ...) | j 从 i+1 起,两个加数来自不同位置,天然排除了 a[i] + a[i]。 |
int s = a[i] + a[j]; | 和的最大值是 10000 + 10000 = 20000,这就是桶要开 20005 的原因。 |
have[s] | O(1) 判断 s 是不是集合里的数 —— 这是本题用桶、而不是再套一层循环的全部理由。 |
!used[s] | 同一和去重。没有它,{1,2,3,4,5} 里的 5 会被 1+4 和 2+3 各算一次。 |
used[s] = 1; ++ans; | 先标记再计数,顺序不能反:反过来每一对都会被算上。 |
s <= 20000 | 防御性检查。桶只有 20005 格,数据超题面时宁可漏算,也不能越界写坏内存。 |