用「值」当下标 —— 这是后续计数、去重、哈希类题目的共同基础
| 0 - 20 min | 读懂「桶」是什么,为什么快(第 1-3 节) |
| 20 - 40 min | 数字拆分模板(第 4 节)——今天的核心工具 |
| 40 - 120 min | 做 4 道题,每题限时 30 分钟 |
| 120 - 150 min | 对照本文的题解,把没做出来的补掉 |
| 150 - 180 min | 写题解笔记 + 做自测题 |
Day 5 你学会了「用数组批量存数据」。今天要往前一步:不只把数据存起来,而是把数据本身当成下标来用。
老师要统计全班 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]++; // ★ 分数是多少,就加在哪个桶 }
执行完之后,桶里是这样的:
(其他编号的桶里都是 0,说明没人考那个分数)
| 做法 | 查一次「值 v 出现了几次」需要 | 总复杂度 |
|---|---|---|
| 暴力两重循环 | 把整个数组扫一遍,O(n) | O(n²) |
| 桶 | 直接读 cnt[v],O(1) | O(n) |
你多开了一个数组(空间),换来的是查询从「扫一遍」变成「直接读」(时间)。这在竞赛里是非常划算的交易。
因为桶的个数 = 值域大小。数组能开多大,桶就能装多大。
| 值的范围 | 需要开多大的数组 | 能不能用桶 |
|---|---|---|
| 0 ~ 100 | int cnt[101] | ✅ 随便用 |
| 0 ~ 10⁶ | int cnt[1000005] | ✅ 可以(约 4MB) |
| 0 ~ 10⁷ | int cnt[10000005] | ⚠️ 约 40MB,接近上限 |
| 0 ~ 10⁹ | 开不出来 | ❌ 要用 map / 离散化 |
一个 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 ~ 100 | x + 100 → 0 ~ 200 |
把「不好当索引的东西」翻译成「好当索引的整数」——这个思路会一直用到后面的坐标压缩、离散化。
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 出现了几次
bool vis[100005] = {false};
for(int i = 0; i < n; i++){
int x; cin >> x;
vis[x] = true; // 只记「有没有」,不记次数
}
if(vis[42]) cout << "出现过" << endl;
只要判断「在不在」时用 bool,省 3/4 内存。今天的 P2550 彩票摇奖 就是典型用法。
int bestCount = 0, bestValue = -1;
for(int v = 最小可能值; v <= 最大可能值; v++){
if(cnt[v] > bestCount){ // ★ 用 > 而不是 >=
bestCount = cnt[v];
bestValue = v; // 并列时保留先遇到的(最小的)
}
}
> 和 >= 的区别,今天会考到
题目如果说「出现次数相同时输出最小的那个」,就必须用 >,并且从小到大扫描。
如果用 >=,后面的会把前面的覆盖掉,你就输出了最大的那个。
今天 4 道题里有 2 道都要用到这个。这是今天最重要的工具,必须练到不用想就能写出来。
x % 10 → 拿到末位数字
x / 10 → 丢掉末位(整数除法自动向下取整)
| 步骤 | x 的值 | x % 10(拿到的末位) | x / 10(丢完末位后) |
|---|---|---|---|
| 开始 | 1357 | — | — |
| 第 1 轮 | 1357 | 7 | 135 |
| 第 2 轮 | 135 | 5 | 13 |
| 第 3 轮 | 13 | 3 | 1 |
| 第 4 轮 | 1 | 1 | 0 |
| 结束 | 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~25 | c - 'a' | 'c' - 'a' = 2 |
| 判断是不是数字 | isdigit(c) | 需 <cctype> |
c - '0' 能行
因为字符 '0'~'9' 在 ASCII 表里是连续排列的(48 ~ 57)。所以 '7' 的编码减 '0' 的编码,正好就是 7。
字母 'a'~'z'(97~122)也是连续的,同理。
下面每道题的代码我都实际编译运行过,输出是你看到的真实结果。
题意: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 更保险。if(d) cout << " "; 或判断 d < 9)。题意:在 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;。i 有几位再循环——while(t > 0) 自动处理了。题意:彩票每张 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 完全一致
for(k = 7; k >= 1; k--),不是从小到大。bool win[34] 就够(号码最大 33),下标 0 用不上也无所谓,多开一个更安全。result[0] 根本用不上,加个 if(hit >= 1) 更清楚。题意:三个骰子分别有 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 合理)
> 写成 >=:会输出最大的和而不是最小的。<= s1 不是 < s1——骰子面数是包含 Sᵢ 的。| # | 错误 | 后果 / 怎么避免 |
|---|---|---|
| 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。 |
题目说:输入的每个数 a 满足 1 ≤ a ≤ 5000,要用桶统计每个数出现几次。
答:int cnt[5005] = {0}; —— 数组下标从 0 开始,要能存到 5000,所以至少 5001 个元素,开 5005 留余量。
数字 20304 拆位后,各是哪几个数字?
答:2、0、3、0、4。
过程:20304 % 10 = 4 → 2030 % 10 = 0 → 203 % 10 = 3 → 20 % 10 = 0 → 2 % 10 = 2 → 0 结束。
注意中间那个 0 也要统计,不能因为「0 没意义」就跳过。
某题要求:输出出现次数最多的数;若并列,输出较大的那个。扫描方向应该怎样?用 > 还是 >=?
答:从小到大扫描,用 >=(相等也更新)→ 后面遇到的(更大的)会覆盖前面,最终留下最大的。
或者:从大到小扫描 + 用 >。两种都行,关键是「扫描方向」和「比较符号」要配套。
不用桶,写一段代码判断整数 n(n ≥ 1)里是否含有数字 7。
bool hasSeven(int n){
while(n > 0){
if(n % 10 == 7) return true;
n /= 10;
}
return false;
}
★ 这里可以放心地直接改 n,因为它只是个参数副本,不影响调用方。
【桶】
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 的 P5731 蛇形方阵、P5732 杨辉三角都会用到二维数组的坐标操作。
另外 Day 7 是第一个复盘日,记得把这周的错题重做一遍。