A 题按「半长 + 数位和」统计并按乘法原理合并,B 题以「末尾连续答对数」为状态做线性递推,一次算出一句答案。
OJ: luogu
题目 ID: P9230
难度:普及-
标签:计数数位统计动态规划暴力枚举对拍
创建: 2026-10-02 15:07
更新: 2026-10-03 11:23
形式化题目
本题的输入只有一个字符 A 或 B,要求分别回答两个互相独立的小问。
A. 幸运数:称一个正整数是幸运数,若它的十进制位数
B. 有奖问答:一次答题共
- 答对得
分,答错则分数清零; - 小蓝可以在任意时刻结束答题并领取当前分数对应的奖项;
- 分数到达
分时他必然立刻停止(也就是连续答对 道题后就结束了); - 他最终实际领取的是
分对应的奖项。
求满足上述条件的答题情况数。
暴力解法
思路
两道小题的数据规模都不大,先把“按定义直接数”的做法写出来,作为理解题意和校验正解的基线。
A 题:枚举
B 题:把答题过程看成一棵二叉树,每个节点是状态「已经答了
- 这一题答错:转移到
,分数归零; - 这一题答对:转移到
,但如果 ,说明他必须在这里停止,不能再继续向下扩展。
每当走到一个
这两个暴力解都写在 brute.cpp 里,只用来做小规模理解和互相对拍,不是正解。
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-02 15:07
* update_at: 2026-10-03 11:23
*/
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// A 题:直接枚举 1 ~ 100000000 的每个数,数位求和后比较前后两半,不做任何数学优化。
// B 题:把答题过程看成一次搜索,状态是(已答题数, 当前分数),
// 每层在这一题上做“答对 / 答错”的选择,遇到 70 分就把“此刻停止答题”计入答案。
// 它与 main.cpp 的状态定义完全不同(main 用的是“末尾连续答对数”),
// 因此是一份独立的校验实现;规模只够用来对拍和理解,不适合当作正解。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int ALL_Q = 30; // B 题总题数
const int MAX_SCORE = 90; // 分数只能是 10 的倍数,且 100 分时立即停止
const int WANT_SCORE = 70; // 最终实际获得的 70 分
const int A_LIMIT = 100000000; // A 题题面上界
int digit[16]; // digit[1..len]:当前枚举到的数的各位数字
// A 题暴力:把 x 拆位,位数必须是偶数,且前后两半的数位和相等。
bool is_lucky(int x) {
int len = 0;
int y = x;
while (y > 0) {
len++;
digit[len] = y % 10;
y /= 10;
}
if (len % 2 != 0) {
return false;
}
int half = len / 2;
int front = 0;
int back = 0;
for (int i = len; i > half; i--) { // digit[len]..digit[half+1] 是高位那一半
front += digit[i];
}
for (int i = half; i >= 1; i--) { // digit[half]..digit[1] 是低位那一半
back += digit[i];
}
return front == back;
}
ll solve_a_brute() {
ll cnt = 0;
for (int x = 1; x <= A_LIMIT; x++) {
if (is_lucky(x)) {
cnt++;
}
}
return cnt;
}
ll memo[ALL_Q + 1][MAX_SCORE + 1]; // 记忆化:同一个(已答题数, 当前分数)只算一次
bool visited[ALL_Q + 1][MAX_SCORE + 1];
// 当前已经答了 t 题、手上分数是 score,返回还能产生多少种“以 70 分结束”的答题情况。
// 到达某个状态时分数一定小于 100,否则早就在 100 分处停止、不会继续答题。
ll dfs_answer(int t, int score) {
if (visited[t][score]) {
return memo[t][score];
}
visited[t][score] = true;
ll res = 0;
if (score == WANT_SCORE) {
res += 1; // 此刻正好 70 分,小蓝可以选择在这里结束答题
}
if (t < ALL_Q) {
res += dfs_answer(t + 1, 0); // 这一题答错:分数直接归零
if (score + 10 <= MAX_SCORE) { // 这一题答对:拿到 100 分就必须停止,不能再往后展开
res += dfs_answer(t + 1, score + 10);
}
}
memo[t][score] = res;
return res;
}
ll solve_b_brute() {
memset(visited, 0, sizeof(visited));
memset(memo, 0, sizeof(memo));
return dfs_answer(0, 0);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
char pid;
if (!(cin >> pid)) {
return 0;
}
if (pid == 'A') {
cout << solve_a_brute() << "\n";
} else {
cout << solve_b_brute() << "\n";
}
return 0;
}复杂度
A 题枚举
瓶颈
A 题的瓶颈是
正解
思路
A 题:把“枚举数”换成“枚举数位和”。
幸运数由两部分拼成:前
其中:
表示长度恰为 、首位不为 的数字中数位和为 的个数; 表示长度恰为 、允许首位为 的 填充串中数位和为 的个数。
于是
减掉的正是“首位取
B 题:把“分数”换成“末尾连续答对了几题”。
关键观察是:分数完全由「末尾连续答对题数」
定义
转移就是“第
答错时无论原来连续对了多少题都会清零,所以所有状态都汇总到
每一层的
以长度
| 本层贡献 | ||||||
|---|---|---|---|---|---|---|
| 0 | 1 | 0 | 0 | 0 | 0 | 0 |
| 1 | 1 | 1 | 0 | 0 | 0 | 0 |
| 2 | 2 | 1 | 1 | 0 | 0 | 0 |
| 3 | 4 | 2 | 1 | 1 | 0 | 0 |
| 4 | 8 | 4 | 2 | 1 | 0 | 0 |
| 7 | 64 | 32 | 16 | 8 | 1 | 1 |
可以看到:连续答对数
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-02 15:07
* update_at: 2026-10-03 11:23
*/
// main.cpp:P9230 [蓝桥杯 2023 省 A] 填空问题 的正式提交程序。
// A 题:位数最多 8 位,统计前后半部分数位和相等的数 —— 按“半长 + 数位和”计数相乘。
// B 题:一共 30 题,统计最终得 70 分(连续答对 7 题)的答题情况数 —— 按“末尾连续答对数”分层递推。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int HALF = 4; // 1e8 以内偶数位数最多 8 位,所以前/后半部分最多 4 位
const int MAX_SUM = 36; // 4 位数字的最大数位和是 9*4 = 36
const int MAX_Q = 30; // 答题总数为 30
const int BRUTE_RUN = 10; // 连续答对 10 题就是 100 分,到达时立刻停止
const int WANT_RUN = 7; // 70 分对应的连续答对数
// ways[h][s]:h 位数字(允许前导 0)中数位和为 s 的个数
ll ways[HALF + 1][MAX_SUM + 1];
// 预处理每一位自由取 0~9 的数位和方案数,相当于做 h 次“数位和”背包。
void build_digit_ways() {
ways[0][0] = 1;
for (int h = 1; h <= HALF; h++) {
for (int s = 0; s <= 9 * HALF; s++) {
ll total = 0;
for (int d = 0; d <= 9; d++) {
if (s - d >= 0) {
total += ways[h - 1][s - d];
}
}
ways[h][s] = total;
}
}
}
// A 题答案:长度为 2h 的数,前 h 位的数位和 == 后 h 位的数位和。
// 后半部分允许前导 0(就是普通的 h 位串),前半部分首位不能是 0。
ll solve_a() {
build_digit_ways();
ll ans = 0;
for (int h = 1; h <= HALF; h++) {
for (int s = 0; s <= 9 * h; s++) {
// 前半部分首位不能是 0:先随便填 h 位,再减掉首位为 0 的 h-1 位方案
ll front = ways[h][s] - ways[h - 1][s];
ans += front * ways[h][s];
}
}
return ans;
}
// dp[r]:在当前枚举到的长度下,以“末尾恰好连续答对 r 题”结尾、
// 并且中途从未到达 100 分(即从未连续答对 10 题)的答题序列数。
ll dp[BRUTE_RUN + 1];
// B 题答案:对每个长度 t,恰好结束在 70 分(末尾连续答对 7 题)的序列都要计入。
ll solve_b() {
ll ans = 0;
dp[0] = 1;
for (int t = 1; t <= MAX_Q; t++) {
ll ndp[BRUTE_RUN + 1];
for (int r = 0; r <= BRUTE_RUN; r++) {
ndp[r] = 0;
}
ll all = 0; // 所有长度 t-1 的合法序列,下一题答错后都落到 r = 0
for (int r = 0; r <= BRUTE_RUN; r++) {
all += dp[r];
}
ndp[0] = all;
for (int r = 0; r + 1 < BRUTE_RUN; r++) {
ndp[r + 1] += dp[r]; // 下一题答对,连续答对数加一;到 10 就已经是 100 分,不再延伸
}
for (int r = 0; r <= BRUTE_RUN; r++) {
dp[r] = ndp[r];
}
ans += dp[WANT_RUN];
}
return ans;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
char pid;
if (!(cin >> pid)) {
return 0;
}
if (pid == 'A') {
cout << solve_a() << "\n";
} else {
cout << solve_b() << "\n";
}
return 0;
}复杂度
设 A 题最大位数
总结
这道题的两问看起来毫不相干,但拆开后是同一个思路的两个实例:先找出真正决定答案的那一个量,再把枚举对象换成它。
- A 题的答案是「前
位数位和 = 后 位数位和」的方案数。前一半与后一半互不干扰,于是可以先各自统计“数位和为 的 位数有多少个”(一次数位和背包),再对同一个 做乘法合并。核心是意识到 ,数位和最多 ,状态空间只有几百个,枚举数位和自然远快于枚举数。注意两个半部分边界不同:前半部分首位不能为 (用 扣掉首位为 的情况),后半部分允许有前导 。 - B 题的答案是「结束时正好
分」的方案数。分数完全由末尾连续答对题数决定,所以状态不需要记录具体分数,只需记录 ;答错即 ,答对即 ,且 永远不能到 (那是 分、必须停止)。这样一条 的线性递推就能求出所有长度上停在 分的方案数。
本题的输入只有一个字符,答案固定为 brute.cpp 中保留了两份独立实现(枚举 main.cpp 对拍验证。