← 目录 第 6 / 60 节 全部 60 节

06 · 桶计数:用值当下标

把数组下标从“位置”换成“值”:边读入边完成统计、去重,扫一遍桶就是升序输出,不需要 sort。同时算清桶的代价 —— 值域 V 就是它的天花板,也是判断“该不该用桶”的唯一标准。
⭐ 已学范围入门预计 55 分钟桶计数排序离散化思想
本节出现的新函数 / 新语法(先看懂再用)

第 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” 而不是 “++”。

新函数拆解 · 桶数组 int cnt[V + 1];
int cnt[V + 1]; // V = 值域上界,值 ∈ [0, V]
组成含义 / 格式要求
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]++
++cnt[x]; // 等价于 cnt[x] = cnt[x] + 1;
组成含义 / 格式要求
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
cnt[x] = 1; // 只记“出现过”,丢掉了次数
组成含义 / 格式要求
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[] 判是否已计过答案,就是两个桶。

例题 · 明明的随机数洛谷 P1059普及-去洛谷做这题 ↗

明明用计算机生成了 n 个 1 到 1000 之间的随机整数,其中有重复。他要先把重复的数去掉、每种只留一个,再把这些数从小到大排好,按排好的顺序给同学编号。

输入格式:第一行一个整数 n(1 ≤ n ≤ 100);第二行 n 个用空格隔开的整数,每个都在 1 到 1000 之间。

输出格式:第一行一个整数 M,表示不相同的数的个数;第二行 M 个整数,从小到大用空格隔开。

一组小数据: 输入: 6 20 40 20 99 40 20 输出: 3 20 40 99
例题怎么想:把“排序 + 去重”换成“按下标找人”

题目要两件事:去重、升序。常规做法是读进数组 → 排序 → 相邻比较去重,三步,还得先会写排序。

换个角度:值的可能取值只有 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) 的一次扫描。这就是以空间换时间的字面意思。

完整代码(P1059)
#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 MB128 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

压缩和离散化的共同点:先用一个单调映射把大值域换成小下标,之后所有操作都在小下标上做。区别只在离散化是先看数据再定映射,压缩是直接按公式算。

这一节最容易犯的 6 个错,附症状

桶开小了:值最大 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 —— 一个字符之差,桶就从去重变成了计数排序。

数学补给 · 桶是一张函数表:O(n + V) 是怎么算出来的

定义 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 = 10001100扫桶比数据多花 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 节(离散化)会接上。

举一反三 · 想清楚这些再往下走
  1. 如果题目改成“输出每个数各出现了几次”,桶法要改哪一行?如果要“按出现次数从多到少输出”,桶还能直接用吗?
  2. 值域是 1..10⁹、n 只有 10⁵,桶开不出来。你能不能想个办法,让真正用到的下标不超过 n 个?(这通向第 13 节的离散化)
  3. 桶天然有序。如果题目要求“按输入顺序输出去重结果”,桶的哪一步会失效?你会怎么补这一刀?
  4. 计数排序和 sort 比,什么时候更快?当元素是字符串 “abc”“abd” 时,桶还能用吗?为什么?
  5. 多组数据、每组 10⁵ 个数、V = 10⁶,共 10 组。桶的清零成本是多少?会不会把 O(n+V) 拖成 O(T·V)?有什么办法少清零?
  6. 桶里存的如果是“该值第一次出现的下标”,能回答哪些新问题?试着用它求“每个值最早在哪一位出现”。
自测:能全部回答再进下一节
挑战题 · 珠心算测验
洛谷 P2141普及-去洛谷做这题 ↗

老师随机生成一个正整数集合,集合里的数互不相同。她要求学生回答:其中有多少个数,恰好等于集合中另外两个(不同的)数之和。

输入格式:第一行一个整数 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 格,数据超题面时宁可漏算,也不能越界写坏内存。
这题最容易错的地方
  • 枚举写成 i <= j,允许同一个数用两次,把 {2,4} 里的 4 判成 2 + 2,答案偏大。
  • 没有 used 桶去重,同一个和由多组数凑出时重复计数。
  • 桶只开到 10001(只按值域开),而和能到 20000,越界写坏内存 → WA 或 RE。
  • 把条件顺序写错(先标记再判断 have[s]),或把 ++ans 写进内层每一轮,答案会成倍变大。
延伸阅读 / 参考