ACM 一个月学习计划 · DAY 06

Day 6 · 数组进阶:桶计数

用「值」当下标 —— 这是后续计数、去重、哈希类题目的共同基础

今日目标:掌握桶思想,能独立完成 4 道配套练习
预计用时:约 3 小时(含做题)  难度:入门 ~ 普及-
题目来源:洛谷 P1554 / P1980 / P2550 / P2911
← 学习计划原文(txt)

⏱今日时间安排

0 - 20 min读懂「桶」是什么,为什么快(第 1-3 节)
20 - 40 min数字拆分模板(第 4 节)——今天的核心工具
40 - 120 min做 4 道题,每题限时 30 分钟
120 - 150 min对照本文的题解,把没做出来的补掉
150 - 180 min写题解笔记 + 做自测题
💡 昨天内容的衔接

Day 5 你学会了「用数组批量存数据」。今天要往前一步:不只把数据存起来,而是把数据本身当成下标来用。

1什么是「桶」

先看一个生活场景

老师要统计全班 50 个同学的考试分数各出现了几次。最笨的办法是:拿第一个分数,数一遍全班有几个和它一样;再拿第二个……要数 50 遍。

聪明的办法是:在桌上摆 101 个盒子,编上号 0 到 100。谁考了多少分,就往对应编号的盒子里放一颗豆子。最后数每个盒子里有几颗豆子就行。

这些盒子,就是「桶」。

🎯 核心思想就一句话

把「值」直接当作数组的下标,用数组元素来记录这个值的信息。

具体长什么样

还是那个分数统计的例子。50 个同学的分数是:

88 95 88 72 95 88 100 72

我们开一个数组 int cnt[101] = {0};,然后:

// 每读到一个分数,就往对应的桶里放一颗
for(int i = 0; i < n; i++){
    int score;  cin >> score;
    cnt[score]++;          // ★ 分数是多少,就加在哪个桶
}

执行完之后,桶里是这样的:

72
2
88
3
95
2
100
1
其余
0

(其他编号的桶里都是 0,说明没人考那个分数)

为什么它快

做法查一次「值 v 出现了几次」需要总复杂度
暴力两重循环把整个数组扫一遍,O(n)O(n²)
桶直接读 cnt[v],O(1)O(n)
✅ 桶的本质是「用空间换时间」

你多开了一个数组(空间),换来的是查询从「扫一遍」变成「直接读」(时间)。这在竞赛里是非常划算的交易。

2桶的两个前提(不满足就不能用)

前提一:值域不能太大

因为桶的个数 = 值域大小。数组能开多大,桶就能装多大。

值的范围需要开多大的数组能不能用桶
0 ~ 100int cnt[101]✅ 随便用
0 ~ 10⁶int cnt[1000005]✅ 可以(约 4MB)
0 ~ 10⁷int cnt[10000005]⚠️ 约 40MB,接近上限
0 ~ 10⁹开不出来❌ 要用 map / 离散化
⚠️ 竞赛内存限制一般是 128MB 或 256MB

一个 int 占 4 字节。开 int cnt[107] 就是 40MB。
开数组前先算一下:值域上限 × 4 字节 = 多少 MB。超了就得换方法。

前提二:值必须是整数(或者能映射成整数)

下标只能是整数。但很多东西都能映射过去:

本来是什么怎么映射成下标
小写字母 'a' ~ 'z'c - 'a' → 0 ~ 25
数字字符 '0' ~ '9'c - '0' → 0 ~ 9
大写字母 'A' ~ 'Z'c - 'A' → 0 ~ 25
所有 ASCII 字符直接 int cnt[128],用字符本身当下标
负数 -100 ~ 100x + 100 → 0 ~ 200
💡 「映射」这个动作,就是后面学离散化的雏形

把「不好当索引的东西」翻译成「好当索引的整数」——这个思路会一直用到后面的坐标压缩、离散化。

3三种最常用的桶(背下来)

模板 A:计数桶 —— 某个值出现了几次

int cnt[100005] = {0};          // ★ 一定要初始化成 0

for(int i = 0; i < n; i++){
    int x;  cin >> x;
    cnt[x]++;                   // 放一颗豆子进 x 号桶
}

cout << cnt[42] << endl;       // O(1) 查询:42 出现了几次

模板 B:标记桶(bool)—— 某个值出现过没有

bool vis[100005] = {false};

for(int i = 0; i < n; i++){
    int x;  cin >> x;
    vis[x] = true;              // 只记「有没有」,不记次数
}

if(vis[42]) cout << "出现过" << endl;
💡 什么时候用 bool 而不是 int

只要判断「在不在」时用 bool,省 3/4 内存。今天的 P2550 彩票摇奖 就是典型用法。

模板 C:桶 + 找众数 —— 出现次数最多的值

int bestCount = 0, bestValue = -1;
for(int v = 最小可能值; v <= 最大可能值; v++){
    if(cnt[v] > bestCount){     // ★ 用 > 而不是 >=
        bestCount = cnt[v];
        bestValue = v;          // 并列时保留先遇到的(最小的)
    }
}
⚠️ > 和 >= 的区别,今天会考到

题目如果说「出现次数相同时输出最小的那个」,就必须用 >,并且从小到大扫描。
如果用 >=,后面的会把前面的覆盖掉,你就输出了最大的那个。

4数字拆分:把一个整数的各位取出来

今天 4 道题里有 2 道都要用到这个。这是今天最重要的工具,必须练到不用想就能写出来。

原理:反复「取末位 + 丢末位」

🎯 两个运算符就够

x % 10  →  拿到末位数字
x / 10  →  丢掉末位(整数除法自动向下取整)

动手走一遍:以 1357 为例

步骤x 的值x % 10(拿到的末位)x / 10(丢完末位后)
开始1357——
第 1 轮13577135
第 2 轮135513
第 3 轮1331
第 4 轮110
结束0循环条件 x > 0 不成立—

四个数字 7、5、3、1 全部拿到,而且是从低位到高位。

标准模板

while(x > 0){
    int d = x % 10;             // 取末位
    // 这里对 d 做点什么,比如 cnt[d]++
    x /= 10;                    // 丢掉末位
}
🚫 两个必踩的坑

坑 1:不要直接用循环变量 i 去拆位!

for(int i = 1; i <= n; i++){
    while(i > 0){        // ❌ 这样 i 会被改成 0
        ...
        i /= 10;         // ❌ 外层循环直接失控!
    }
}

必须用临时变量:int t = i; 然后拆 t。

坑 2:数字 0 本身。
如果范围里包含 0,while(x > 0) 一次都不会执行,0 就被漏掉了。需要单独特判(今天的 P1554 里 M ≥ 1,用不到,但要知道这件事)。

配套:字符与数字互转

想要什么写法例子
数字字符 → 整数c - '0''7' - '0' = 7
整数 → 数字字符'0' + d'0' + 7 = '7'
小写字母 → 0~25c - 'a''c' - 'a' = 2
判断是不是数字isdigit(c)需 <cctype>
💡 为什么 c - '0' 能行

因为字符 '0'~'9' 在 ASCII 表里是连续排列的(48 ~ 57)。所以 '7' 的编码减 '0' 的编码,正好就是 7。
字母 'a'~'z'(97~122)也是连续的,同理。

5四道例题详解

下面每道题的代码我都实际编译运行过,输出是你看到的真实结果。

P1554 [USACO06DEC] 梦中的统计 Dream Counting B 入门 · 数字拆分 + 桶

题意:Bessie 从 M 数到 N,把每个数都写下来。问数码 0~9 各出现了多少次。

⚠️ 先看数据范围,这题有陷阱

1 ≤ M ≤ N ≤ 2×10⁹,但 0 ≤ N − M ≤ 5×10⁵。

N 看着很大(20 亿)像是不能枚举,但区间长度最多只有 50 万——所以可以放心地一个个枚举!

如果只看到 N ≤ 2×10⁹ 就放弃枚举,那这题就做不出来了。永远要看完整的数据范围。

思路

开一个 10 个元素的桶 cnt[0..9]。对区间里每个数,把它拆成各位数字,每拆出一位就往对应的桶里放一颗。

样例输入
129 137
样例输出
1 10 2 9 1 1 1 1 0 1

含义:0 出现 1 次、1 出现 10 次、2 出现 2 次 …… 8 出现 0 次、9 出现 1 次。

完整代码

#include <iostream>
using namespace std;

int cnt[10] = {0};          // 桶:cnt[d] = 数码 d 出现的次数

void countNumber(long long x){
    if(x == 0){ cnt[0]++; return; }     // 数字 0 本身(本题 M>=1 用不到)
    while(x > 0){
        cnt[x % 10]++;                  // 取末位,丢进对应的桶
        x /= 10;                        // 去掉末位
    }
}

int main(){
    long long M, N;
    cin >> M >> N;
    for(long long i = M; i <= N; i++){  // 区间长度 ≤ 5×10^5,直接枚举
        countNumber(i);
    }
    for(int d = 0; d < 10; d++){
        if(d) cout << " ";
        cout << cnt[d];
    }
    cout << endl;
    return 0;
}
✅ 实测结果

官方样例 129 137 → 输出 1 10 2 9 1 1 1 1 0 1 完全一致

大数测试 1999999999 2000000000 → 输出 9 1 1 0 0 0 0 0 0 9 正确
(1999999999 有一个 1 和九个 9;2000000000 有一个 2 和九个 0)

易错点

  • 用 int 存 M、N 是够的(2×10⁹ < 21亿),但用 long long 更保险。
  • 输出是 10 个数字用空格隔开,注意最后一个后面不要多打空格(用 if(d) cout << " "; 或判断 d < 9)。
  • 拆位必须用临时变量,不能直接改循环变量 i。
P1980 [NOIP 2013 普及组] 计数问题 入门 · 枚举 + 数位统计

题意:在 1 到 n 的所有整数中,数字 x(0 ≤ x ≤ 9)共出现了多少次?

例:1 到 11 中,数字 1 在 1、10、11 里出现,共 4 次。

⚠️ 这题不需要桶

因为只要统计一个数字 x 的次数,用一个变量累加就够了,不需要开 10 个桶。
别看到"计数"就条件反射开桶——先想清楚要统计几个不同的东西。

思路

枚举 1 到 n 的每个数,把每个数拆位,拆出来的每一位和 x 比一下,相等就答案 +1。

样例输入
11 1
样例输出
4

完整代码

#include <iostream>
using namespace std;
int main(){
    int n, x;
    cin >> n >> x;
    int ans = 0;
    for(int i = 1; i <= n; i++){
        int t = i;                      // ★ 用临时变量拆位,别把 i 本身改掉
        while(t > 0){
            if(t % 10 == x) ans++;      // 末位正好是 x,计数 +1
            t /= 10;                    // 去掉末位
        }
    }
    cout << ans << endl;
    return 0;
}
✅ 实测结果

官方样例 11 1 → 4 正确

最大数据 1000000 0 → 488895,耗时仅 20 ms(时限 1000ms,非常宽裕)
复杂度 O(n log n):n = 10⁶,每个数最多 7 位 → 约 7×10⁶ 次操作,完全没问题。

易错点

  • 不要用 i 直接拆位——会用 i /= 10 把外层循环搞坏。必须 int t = i;。
  • x = 0 的情况要能正确处理。因为 i 从 1 开始,不会遇到数字 0 本身,但 10、20、100 里的 0 会被正常统计到。
  • 没必要求 i 有几位再循环——while(t > 0) 自动处理了。
P2550 [AHOI2001] 彩票摇奖 入门 · 标记桶 + 比对

题意:彩票每张 7 个号码(取值 1~33)。给出中奖号码和 n 张彩票,按「猜中几个号码」分别统计特等奖(7个)到六等奖(1个)各有几张。

不看位置,只看号码在不在中奖号码里。

🎯 这题是「标记桶」的教科书用例

如果不用桶,判断每个彩票号是否中奖,要和中奖号码逐个比 → 7×7 = 49 次比对。
用桶:先把中奖号标记进桶,之后每个号 O(1) 查 → 每张彩票只要 7 次。

样例输入
2
23 31 1 14 19 17 18
12 8 9 23 1 16 7
11 7 10 21 2 9 31
样例输出
0 0 0 0 0 1 1

第 1 张:中了 23、1 → 2 个 → 五等奖
第 2 张:中了 31 → 1 个 → 六等奖
输出顺序是「特等 一等 二等 三等 四等 五等 六等」。

完整代码

#include <iostream>
using namespace std;
int main(){
    int n;
    cin >> n;

    bool win[34] = {false};             // 桶:win[k] 标记号码 k 是不是中奖号
    for(int i = 0; i < 7; i++){
        int t; cin >> t;
        win[t] = true;
    }

    int result[8] = {0};                // result[k] = 中了 k 个号码的彩票张数
    for(int i = 0; i < n; i++){
        int hit = 0;
        for(int j = 0; j < 7; j++){
            int t; cin >> t;
            if(win[t]) hit++;           // O(1) 查桶
        }
        if(hit >= 1) result[hit]++;
    }

    // 题目要求:先输出特等奖(中7个),再依次一等奖(6个)…六等奖(1个)
    for(int k = 7; k >= 1; k--){
        if(k < 7) cout << " ";
        cout << result[k];
    }
    cout << endl;
    return 0;
}
✅ 实测结果

官方样例 → 输出 0 0 0 0 0 1 1 完全一致

易错点

  • 输出顺序是倒着的!题目要「特等奖、一等奖……六等奖」,也就是中 7 个的先输出。
    所以循环要写 for(k = 7; k >= 1; k--),不是从小到大。
  • 桶开 bool win[34] 就够(号码最大 33),下标 0 用不上也无所谓,多开一个更安全。
  • 中 0 个号码的彩票不计入任何奖项,所以 result[0] 根本用不上,加个 if(hit >= 1) 更清楚。
P2911 [USACO08OCT] Bovine Bones G 入门 · 桶计数找众数

题意:三个骰子分别有 S₁、S₂、S₃ 个面(面上是 1 到 Sᵢ)。掷出所有组合,哪个「三个点数之和」出现得最频繁?如果有并列,输出最小的那个和。

🚨 更正:原计划里我对数据范围的描述写错了

我在 Day 6 的原文里写的是「P2911 三个骰子和的范围是 3~18,桶要开到 20 以上」——这是错的,那是默认三个骰子都是 6 面的情况。

正确范围:2 ≤ S₁ ≤ 20,2 ≤ S₂ ≤ 20,2 ≤ S₃ ≤ 40(注意第三个骰子上限是 40,不是 20)。

所以和的范围是 3 ~ 80(1+1+1 到 20+20+40),桶至少要开到 81,建议开 85 留余量。

样例输入
3 2 3
样例输出
5

题目提示里给了全部 18 种组合:和 5 与和 6 各出现 5 次并列最多,取小的 → 5。

思路

三重循环枚举所有点数组合(最多 20×20×40 = 16000 种,非常小),把每个「和」丢进对应的桶。然后从小到大扫一遍桶,找出现次数最多的那个和。

完整代码

#include <iostream>
using namespace std;
int main(){
    int s1, s2, s3;
    cin >> s1 >> s2 >> s3;

    // 和的范围:最小 1+1+1 = 3,最大 20+20+40 = 80  ->  数组开到 85
    int cnt[85] = {0};

    // 三重循环枚举所有点数组合,把每个和丢进对应的桶
    for(int a = 1; a <= s1; a++)
        for(int b = 1; b <= s2; b++)
            for(int c = 1; c <= s3; c++)
                cnt[a + b + c]++;       // ★ 和是多少,就加在哪个桶

    int bestCount = 0, bestSum = 0;
    for(int s = 3; s <= 80; s++){       // 从小到大扫描
        if(cnt[s] > bestCount){         // ★ 用 > 而不是 >=
            bestCount = cnt[s];         //   并列时保留先遇到的(最小的和)
            bestSum = s;
        }
    }
    cout << bestSum << endl;
    return 0;
}
🎯 为什么这里 > 不能写成 >=

题目要求「并列时输出最小的和」。从小到大扫描时:

· 用 > :只有严格更大才更新 → 第一个达到最大值的和会被保留 → 输出最小的 ✅
· 用 >=:相等也更新 → 后面的会把前面的覆盖掉 → 输出最大的 ❌

✅ 实测结果

官方样例 3 2 3 → 5 正确

边界 2 2 2 → 4 正确
(三骰各 1~2,和 3/4/5/6 的次数分别是 1/3/3/1,4 和 5 并列 → 取小的 4)

最大面数 20 20 40 → 41 合理
(和范围 3~80,众数在正中附近,41 合理)

易错点

  • 桶开太小:只开 20 会越界。记住 S₃ 上限是 40,最大和 80。
  • 把 > 写成 >=:会输出最大的和而不是最小的。
  • 三重循环的边界是 <= s1 不是 < s1——骰子面数是包含 Sᵢ 的。

6今日常见错误清单

#错误后果 / 怎么避免
1桶的数组开太小(值域算错)越界 → 玄学 WA 或 RE。
开之前先算:最大值是多少?再 +5 留余量。
2桶忘记初始化成 0局部数组不初始化是随机值。
int cnt[100005] = {0}; 或开在全局(全局自动为 0)。
3用循环变量 i 直接拆位外层循环被 i /= 10 破坏。
必须 int t = i;。
4找众数时用了 >=并列时会输出最大而不是最小的那个。
5输出格式错(多余空格 / 顺序反了)P2550 的输出顺序是「特等到六等」,不是从小到大。
6只看到 N ≤ 2×10⁹ 就放弃枚举P1554 还给了「区间长度 ≤ 5×10⁵」——要把数据范围看全。
7下标从 1 开始但数组只开了 n越界(就是昨天 P5724 的那个坑)。
规则:1 开始 → 数组开 n+5。

7自测(做完再进入 Day 7)

第 1 题:桶最少要开多大?

题目说:输入的每个数 a 满足 1 ≤ a ≤ 5000,要用桶统计每个数出现几次。

答:int cnt[5005] = {0}; —— 数组下标从 0 开始,要能存到 5000,所以至少 5001 个元素,开 5005 留余量。

第 2 题:手算拆位

数字 20304 拆位后,各是哪几个数字?

答:2、0、3、0、4。
过程:20304 % 10 = 4 → 2030 % 10 = 0 → 203 % 10 = 3 → 20 % 10 = 0 → 2 % 10 = 2 → 0 结束。
注意中间那个 0 也要统计,不能因为「0 没意义」就跳过。

第 3 题:找众数的边界

某题要求:输出出现次数最多的数;若并列,输出较大的那个。扫描方向应该怎样?用 > 还是 >=?

答:从小到大扫描,用 >=(相等也更新)→ 后面遇到的(更大的)会覆盖前面,最终留下最大的。
或者:从大到小扫描 + 用 >。两种都行,关键是「扫描方向」和「比较符号」要配套。

第 4 题(手写代码)

不用桶,写一段代码判断整数 n(n ≥ 1)里是否含有数字 7。

bool hasSeven(int n){
    while(n > 0){
        if(n % 10 == 7) return true;
        n /= 10;
    }
    return false;
}

★ 这里可以放心地直接改 n,因为它只是个参数副本,不影响调用方。

8今日速查卡

【桶】
  int cnt[N] = {0};        // 计数桶,N = 值域上限 + 5
  bool vis[N] = {false};   // 标记桶,只记「有没有」
  cnt[x]++;                // 放一颗豆子进 x 号桶

【拆位】
  int t = x;               // ★ 一定要用临时变量
  while(t > 0){
      int d = t % 10;      // 取末位
      t /= 10;             // 丢末位
  }

【字符映射】
  c - '0'    →  数字字符转整数
  '0' + d    →  整数转数字字符
  c - 'a'    →  小写字母转 0~25

【找众数】
  for(v = 最小; v <= 最大; v++)
      if(cnt[v] > best) { best = cnt[v]; ans = v; }
  // 从小扫 + 用 >  → 并列时取最小的

【防越界的铁律】
  数组大小 = 值域上限 + 5
  下标从 0 开始写循环用 i < n
  下标从 1 开始数组至少开 n + 5

→明天预告 · Day 7

二维数组 + 第一周复盘

会用到的今天已经埋下伏笔:桶的思想在二维里就是「行和列都当坐标」, 而 Day 7 的 P5731 蛇形方阵、P5732 杨辉三角都会用到二维数组的坐标操作。

另外 Day 7 是第一个复盘日,记得把这周的错题重做一遍。