日期统计用贪心找最靠左落点判断子序列,01 串的熵用单调性二分定位峰值附近的两个候选,两问都只需常数级计算。
OJ: luogu
题目 ID: P9239
难度:普及-
标签:枚举贪心二分数学思维
创建: 2026-10-03 11:29
更新: 2026-10-03 11:33
形式化题目
输入一个大写字母,表示要回答下面哪一问。
试题 A(日期统计):给定一个长度为
形如 yyyymmdd,且表示的日期属于 2023 年;的 位数字按顺序作为 的子序列出现(即存在下标 ,使得 等于 的第 位数字)。
试题 B(01 串的熵):设长度
已知
暴力解法
思路
两问的规模都不大,可以先把“题目字面要求”直接翻译成程序,作为正确性基线。
- 试题 A:枚举 2023 年的每一天,得到 365 个日期串。对每个日期串,把
位数字的落点当作一串决策,递归枚举所有可能的下标组合:第 位在当前起点之后任选一个等于该位数字的位置,递归匹配下一位。只要存在一组下标走到底,这个日期就合法。 - 试题 B:
只有 种可能,逐个代入熵公式算出 ,记录与目标值差距最小的 。
暴力不利用任何结构,A 题枚举组合、B 题线性扫描,正确性一眼可见。
代码
/**
* 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-03 11:29
* update_at: 2026-10-03 11:29
*/
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// A 题枚举每个日期串的所有可能匹配位置组合;B 题扫描全部 c0 取值取最近的一个。
// 两道小问的规模都是固定的,暴力只用来独立验证正解的贪心/单调性结论。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 105;
// 试题 A 给定的 100 个数字。
int a[MAXN];
// 当前正在匹配的 8 位日期串。
string target_date;
// 递归的层数:正在决定日期串第几位的落点。
// 返回是否找到一组下标,使日期串成为 a[] 的子序列。
// brute 不使用“最靠左落点最优”的贪心结论,而是把所有下标组合都试一遍。
bool dfs_A(int digit, int start) {
if (digit == 8) {
return true;
}
int want = target_date[digit] - '0';
for (int i = start; i < 100; i++) {
if (a[i] == want) {
if (dfs_A(digit + 1, i + 1)) {
return true;
}
}
}
return false;
}
// 每个月的天数,2023 不是闰年。
int month_day[13] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
// 试题 A:枚举 2023 年的每一天,用 dfs_A 判断这一天能否被拼出来。
int solve_A() {
int ans = 0;
for (int m = 1; m <= 12; m++) {
for (int d = 1; d <= month_day[m]; d++) {
char buf[16];
snprintf(buf, sizeof(buf), "2023%02d%02d", m, d);
target_date = buf;
if (dfs_A(0, 0)) {
ans++;
}
}
}
return ans;
}
// 试题 B:暴力枚举 0 的个数 c0,直接用熵公式算出 H(c0),
// 记录与题目给定熵值差距最小的那个 c0。
// 不利用 H(c0) 的单调性,也不提前退出,遍历全部 n/2 + 1 个取值。
int solve_B() {
const int n = 23333333;
const double target = 11625907.5798;
double best_diff = 1e18;
int best_c0 = -1;
for (int c0 = 0; c0 <= n / 2; c0++) {
int c1 = n - c0;
double p0 = (double)c0 / n;
double p1 = (double)c1 / n;
double h = 0.0;
// c0 或 c1 为 0 时该项占比为 0,熵的贡献记 0,不对 log2(0) 求值。
if (c0 > 0) {
h -= (double)c0 * p0 * log2(p0);
}
if (c1 > 0) {
h -= (double)c1 * p1 * log2(p1);
}
double diff = fabs(h - target);
if (diff < best_diff) {
best_diff = diff;
best_c0 = c0;
}
}
return best_c0;
}
int main() {
int given[100] = {
5, 6, 8, 6, 9, 1, 6, 1, 2, 4, 9, 1, 9, 8, 2, 3, 6, 4, 7, 7,
5, 9, 5, 0, 3, 8, 7, 5, 8, 1, 5, 8, 6, 1, 8, 3, 0, 3, 7, 9,
2, 7, 0, 5, 8, 8, 5, 7, 0, 9, 9, 1, 9, 4, 4, 6, 8, 6, 3, 3,
8, 5, 1, 6, 3, 4, 6, 7, 0, 7, 8, 2, 7, 6, 8, 9, 5, 6, 5, 6,
1, 4, 0, 1, 0, 0, 9, 4, 8, 0, 9, 1, 2, 8, 5, 0, 2, 5, 3, 3,
};
for (int i = 0; i < 100; i++) {
a[i] = given[i];
}
char type;
if (!(cin >> type)) {
return 0;
}
if (type == 'A') {
printf("%d\n", solve_A());
} else if (type == 'B') {
printf("%d\n", solve_B());
}
return 0;
}复杂度
试题 A 对每个日期做一次
瓶颈
暴力能算出答案,但没有回答“为什么是这两个数”:
- A 题枚举下标组合是指数级的,数据稍大就不可行,而且它没有解释子序列匹配的判定为什么可以更快。
- B 题虽然能在
内算完,但要遍历 个取值,且完全没有利用 的单调结构——这正是正解要回答的问题。
正解
思路
试题 A:贪心取最靠左落点。
判断一个日期串能否作为子序列出现,有一个标准结论:让每一位尽量取靠左的位置,不会让后面的匹配变难。设 pos[k] 表示贪心匹配完前
其中
正确性来自交换论证:若存在任意一组可行下标
有了判定后,枚举日期本身而不是枚举子序列:遍历 2023 年的 365 天(2023 不是闰年,2 月 28 天),每个日期天然只被检查一次,所以相同日期不可能重复计数,无需额外去重。每一天的判定只需扫一遍长度为
试题 B:利用
把熵写成只含
由最大熵原理,
于是“与目标值最近”的 below),再比较 below 与 below + 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-03 11:29
* update_at: 2026-10-03 11:29
*/
// main.cpp:P9239 填空问题正解。
// 两道小问的输入都只是题面写死的常量(A 题的 100 个数字、B 题的 n 和熵值),
// 所以这里把“答案是怎么算出来的”完整写出来,而不是直接 printf 两个数字。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 105;
// 试题 A 给定的 100 个数字,每个都在 0..9 之间。
int a[MAXN];
// pos[L] = 贪心匹配日期串前 L 位后,第 L 位落在 a[] 中的最小下标。
// pos[0] 用 -1 表示“还没有匹配任何字符”。
int pos[10];
// 每个月的天数,2023 不是闰年,2 月按 28 天算。
int month_day[13] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
const int LEN_B = 23333333; // 试题 B 的 01 串长度 n
const double ENTROPY_B = 11625907.5798; // 试题 B 给定的信息熵
// 判断 8 位日期串 d 能否作为 a[] 的子序列出现。
// 思路:第 L 位取在 pos[L-1] 之后最靠左的位置,不会让后面的匹配变难。
bool can_form(const string & d) {
for (int L = 0; L <= 8; L++) {
pos[L] = 1000000;
}
pos[0] = -1;
for (int L = 1; L <= 8; L++) {
int want = d[L - 1] - '0';
// 从上一个落点的右边开始,找第一个等于 want 的数字。
for (int i = pos[L - 1] + 1; i < 100; i++) {
if (a[i] == want) {
pos[L] = i;
break;
}
}
if (pos[L] == 1000000) {
return false;
}
}
return true;
}
// 试题 A:枚举 2023 年的 365 个日期,逐个判断是不是 a[] 的子序列。
// 直接枚举日期本身,同一个日期天然只被统计一次。
int solve_a() {
int ans = 0;
for (int m = 1; m <= 12; m++) {
for (int d = 1; d <= month_day[m]; d++) {
char buf[16];
snprintf(buf, sizeof(buf), "2023%02d%02d", m, d);
string date = buf;
if (can_form(date)) {
ans++;
}
}
}
return ans;
}
// 计算 0 出现 c0 次、1 出现 c1 = n - c0 次时的信息熵:
// H = -c0 * p0 * log2(p0) - c1 * p1 * log2(p1),其中 p0 = c0/n,p1 = c1/n。
// 某一类出现 0 次时规定它贡献 0(不取 log2(0))。
double entropy_of(int c0, int n) {
int c1 = n - c0;
double p0 = (double)c0 / n;
double p1 = (double)c1 / n;
double h = 0.0;
if (c0 > 0) {
h -= (double)c0 * p0 * log2(p0);
}
if (c1 > 0) {
h -= (double)c1 * p1 * log2(p1);
}
return h;
}
// 试题 B:H(c0) 关于 c0 在 [0, n/2] 上单调递增(峰值在 c0 = n/2),
// 所以“离目标熵最近”的 c0 一定在 H 穿过目标值的那个位置附近。
// 先二分出最大的、满足 H(c0) <= 目标值的 c0,再和它的右邻居比一比距离即可。
int solve_b() {
int n = LEN_B;
double target = ENTROPY_B;
// 二分:H(0) = 0 <= target,且 H(n/2) 远大于 target,区间内一定有解。
int left = 0, right = n / 2;
int below = 0;
while (left <= right) {
int mid = left + (right - left) / 2;
if (entropy_of(mid, n) <= target) {
below = mid; // mid 还在目标值下方,可能还能更大
left = mid + 1;
} else {
right = mid - 1;
}
}
int best = below;
int next = below + 1;
if (next <= n / 2) {
double diff_best = fabs(entropy_of(best, n) - target);
double diff_next = fabs(entropy_of(next, n) - target);
if (diff_next < diff_best) {
best = next;
}
}
return best;
}
int main() {
char type;
if (!(cin >> type)) {
return 0;
}
// 题面写死的 100 个数字。
int given[100] = {
5, 6, 8, 6, 9, 1, 6, 1, 2, 4, 9, 1, 9, 8, 2, 3, 6, 4, 7, 7,
5, 9, 5, 0, 3, 8, 7, 5, 8, 1, 5, 8, 6, 1, 8, 3, 0, 3, 7, 9,
2, 7, 0, 5, 8, 8, 5, 7, 0, 9, 9, 1, 9, 4, 4, 6, 8, 6, 3, 3,
8, 5, 1, 6, 3, 4, 6, 7, 0, 7, 8, 2, 7, 6, 8, 9, 5, 6, 5, 6,
1, 4, 0, 1, 0, 0, 9, 4, 8, 0, 9, 1, 2, 8, 5, 0, 2, 5, 3, 3,
};
for (int i = 0; i < 100; i++) {
a[i] = given[i];
}
if (type == 'A') {
printf("%d\n", solve_a());
} else if (type == 'B') {
printf("%d\n", solve_b());
}
return 0;
}复杂度
试题 A:日期数
总结
这道题的两问看似无关,但都在考同一件事:先用结构把搜索空间压小,再在压缩后的空间里枚举或二分。
- 子序列匹配的贪心性质把“枚举所有下标组合”压成了“每层只取最靠左的落点”,正确性用交换论证保证。
- 熵函数的对称性
把搜索区间砍半,单调性又把线性扫描压成了二分。
两个结论都值得记住:子序列存在性判定用贪心取最左是最常用的子序列技巧;先找穿越点、再比较相邻候选是处理“给定浮点目标求最近整数”这类填空问题的通用写法,因为浮点误差使“恰好等于”几乎不可能发生。