1. 从“握手问题”到“小球反弹”:开局两道送分题,你真的拿稳了吗?
每年蓝桥杯省赛,前几道题往往被大家戏称为“签到题”,但今年的“握手问题”和“小球反弹”可不仅仅是送分那么简单。它们更像是一个温柔的陷阱,考的就是你的基础是否扎实,心态是否平稳。很多同学一看题目简单,上来就写,结果要么是公式推错,要么是精度丢失,白白丢了本应稳稳到手的分数。
先说说A题的握手问题。这题本质上是一个简单的组合数学问题。题目描述了一个会议场景,有若干人,每两人之间握手一次,问总共握手次数。这其实就是从n个人中选2个人的组合数,公式是 n*(n-1)/2。听起来是不是小学奥数水平?但考场上的压力和环境,很容易让人犯低级错误。我见过有同学直接用 n*(n-1) 忘了除以2,也见过有人用循环累加,结果因为n比较大导致超时。其实这里考察的就是你能不能把生活场景抽象成数学模型,并且选择最高效的计算方式。对于这种题,我的建议是:读完题,先在草稿纸上把公式写出来,确认无误后再敲代码。代码就几行,但心态稳了,这分就拿稳了。
B题的小球反弹,难度稍微提升了一点,开始涉及到物理模拟和数学优化。题目给了一个矩形区域和小球的初始速度,问小球再次回到起点所经过的总路程。最直接的想法当然是模拟——让小球一步步跑,碰到边界就反弹,直到回到起点。但这里有个大坑:直接模拟大概率会超时。因为矩形的尺寸和小球速度可能使得循环次数极其巨大。这道题的精髓在于,你要跳出模拟的思维,用数学方法把问题转化。
关键思路是:将小球的运动分解为水平和垂直两个方向。在每个方向上,小球可以看作是在做独立的“一维运动”。小球要回到起点(包括位置和速度方向都复原),需要它在水平方向和垂直方向都恰好完成了整数个来回。设水平方向周期为 Tx,垂直方向周期为 Ty,那么小球整体回到起点的时间,就是 Tx 和 Ty 的最小公倍数(LCM)的2倍(因为一次来回)。而总路程就是时间乘以合速度的大小。这里又涉及到分数的最小公倍数计算,需要用到最大公约数(GCD)进行通分。原始文章里给出的代码核心就是这个思路,先分别计算两个方向上的“半周期”,求其最小公倍数得到总时间,最后计算路程。
#include<bits/stdc++.h>
using namespace std;
// 求最大公约数
int gcd(int a, int b) {
return b == 0 ? a : gcd(b, a % b);
}
int main() {
int dx = 15, dy = 17; // 速度分量
int x = 343720, y = 233333; // 矩形半长/半宽?这里需要根据题目理解具体含义
// 计算两个方向上的“半周期”比例
int p = dy * x, q = dx * y;
int g = gcd(p, q);
p /= g, q /= g;
// 计算总时间(最小公倍数的2倍)
int t = 2 * p * x / dx; // 注意公式推导,这里用p和x、dx的关系
// 计算总路程
double ans = t * sqrt(dx*dx + dy*dy);
printf("%.2lf\n", ans);
return 0;
}
敲黑板!这里最容易出错的有两点:一是对题目中给出的 x 和 y 的理解,它们到底是矩形的长宽,还是半长半宽?这直接影响到周期公式。二是最后计算路程时,速度要用合速度,而不是某个方向的分速度。很多同学算对了时间,却在最后一步用了 dx 或 dy,功亏一篑。所以,对于这种数理题,一定要在编码前彻底完成纸笔推导,把每个变量的物理意义标清楚。
2. “好数”与“R格式”:字符串处理与高精度运算的经典组合
从C题开始,题目的综合性就加强了。C题“好数”是一个典型的枚举+数位判断问题。题目定义了一种“好数”:对于一个奇数,如果它的各位数字中,奇数位(从个位开始数,个位是第1位)是奇数,偶数位是偶数,那么这个数就是好数。给你一个上限N,问从1到N有多少个好数。
最朴素的思路就是暴力枚举1到N的每一个奇数(因为偶数肯定不是好数),然后对每个数逐位判断。时间复杂度是 O(N * log(N))。当N达到10^5甚至更大时,很多同学就开始担心会不会超时。但实测下来,对于省赛的数据规模,这个复杂度是完全可行的。这里考察的是你对时间复杂度的估算能力和敢于暴力求解的勇气。当然,暴力也有技巧,比如只枚举奇数,在循环内部用 while 循环和取模运算快速进行数位分离与判断。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
int main(){
ll n; cin >> n;
ll count = 0; // 注意,从1开始,1算不算?需要看题目定义
for(ll i = 1; i <= n; i++) {
if(i % 2 == 0) continue; // 偶数跳过
ll cur = i;
ll pos = 1; // 当前位置,从个位(第1位)开始
bool isGood = true;
while(cur) {
ll digit = cur % 10;
// 判断:奇数位应为奇数,偶数位应为偶数
if( (pos % 2 == 1 && digit % 2 == 0) || (pos % 2 == 0 && digit % 2 == 1) ) {
isGood = false;
break;
}
pos++;
cur /= 10;
}
if(isGood) count++;
}
cout << count << '\n';
return 0;
}
需要注意的细节是:数位是从右向左(从低位到高位)数的。个位是第1位(奇数位),十位是第2位(偶数位),以此类推。循环中的 pos 变量就是用来记录当前是第几位的。另外,数字1只有个位,个位是1(奇数),且是奇数位,所以1通常是好数(除非题目特别说明)。这些边界条件一定要在编码时考虑清楚。
D题“R格式”是一道非常好的高精度运算题。题目要求将一个可能包含小数的数字 d 乘以 2^n,然后对结果进行四舍五入到整数。n 可以很大(比如几十),直接使用 double 进行浮点数计算肯定会溢出或精度丢失。所以,我们必须用高精度来模拟整数和定点数的乘法。
核心思路是:把输入的数字 d 当作字符串处理,去掉小数点和小数点后的部分,得到一个整数序列(可以用 vector<int> 存储,低位在前)。然后对这个序列进行 n 次“乘以2”的操作,这本质上就是高精度乘低精度。每次乘法从低位到高位,当前位乘以2加上进位,然后更新进位。全部乘完后,就得到了 d * 2^n 的精确整数结果(但此时它包含了原来小数部分放大后的结果)。
接下来是最容易出错的一步:四舍五入。原始文章里给出了一个90分代码和一个满分代码,区别就在于四舍五入的处理。90分代码只考虑了小数点后第一位是否>=5,但这是错误的。例如,对于数字 89.98,乘以 2^n 放大后,小数部分 .98 对应的整数部分是98。四舍五入时,98 >= 50,所以应该向整数部分进1,整数部分89变成90。如果只判断最后一位(8),就会漏掉这次进位。正确的做法是:从小数部分的最低位(即我们存储的整数的前几位)开始,依次向前(向高位)判断是否需要进位。如果某一位 >=5,则向它的前一位进1。这个进位可能会像多米诺骨牌一样一直传递到整数部分的最高位。
// ... 高精度乘法部分省略 ...
// 假设 res1 中存储了放大后的整数,idx 记录了原小数点的位置(从低位开始数)
// 需要进行四舍五入
reverse(res1.begin(), res1.end()); // 为了方便处理,转为高位在前
int last_digit = -1;
// 移除原小数部分(idx位),并记录最后一位的值用于判断
while(idx--) {
last_digit = res1.back();
res1.pop_back();
}
// 如果原小数部分的最后一位 >=5,则需要进位
if(last_digit >= 5) {
reverse(res1.begin(), res1.end()); // 转为低位在前方便进位
int carry = 1; // 进位1
for(int i = 0; i < res1.size(); i++) {
res1[i] += carry;
carry = res1[i] / 10;
res1[i] %= 10;
}
if(carry) {
res1.push_back(carry);
}
reverse(res1.begin(), res1.end()); // 转回高位在前输出
}
// 输出结果
for(auto x : res1) cout << x;
这道题综合考察了字符串处理、高精度运算和细致的逻辑思维。我建议大家在平时练习时,一定要自己动手实现一遍高精度的加、减、乘,理解其核心的“按位运算与进位处理”模式,这样在考场上才能灵活应对。
3. “宝石组合”与“数字接龙”:算法思维与DFS搜索的实战
E题“宝石组合”是一道很有意思的题目,它看起来像是一道数学题,但最终落脚点却是一个巧妙的算法优化。题目给了n个正整数,要你找出其中三个数,使得它们的某个特征值S最大。这个S经过推导(如原始文章中的验证代码所示),其实就是这三个数的最大公约数(GCD)。所以问题转化为:从n个数中选三个数,使得它们的最大公约数尽可能大。
最暴力的方法是三重循环枚举所有三元组,计算GCD,记录最大值。时间复杂度是 O(n^3),当n=10^5时显然不可行。这里就需要我们进行算法优化。一个常见的技巧是:枚举答案。既然我们要找最大的GCD,那么我们可以从可能的最大值(即数组中最大的数)开始,向下枚举每一个可能的GCD值 g,然后检查数组中是否有至少三个数是 g 的倍数。如果有,那么 g 就是一个合法的三元组的GCD,并且由于我们是逆序枚举,第一个找到的 g 就是最大的。
如何高效地检查呢?我们可以预处理一个“倍数桶”。对于数组中的每个数 a[i],我们找出它的所有约数(只需要枚举到 sqrt(a[i])),然后把这个数加入到它每个约数对应的列表中。这样,对于任意一个数 g,tong[g] 这个列表里存储的就是原数组中所有能被 g 整除的数。之后,我们从大到小枚举 g,只要 tong[g].size() >= 3,我们就取这个列表里最小的三个数(因为题目要求字典序最小)输出即可。如果枚举完都没有找到,说明最大公约数就是1,直接输出排序后最小的三个数。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
int main(){
ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
ll n; cin >> n;
vector<ll> a(n);
for(ll i=0; i<n; i++) cin >> a[i];
sort(a.begin(), a.end());
unordered_map<ll, vector<ll>> bucket; // 约数 -> 拥有该约数的数列表
// 预处理倍数桶
for(ll num : a) {
for(ll d=1; d*d <= num; d++) {
if(num % d == 0) {
bucket[d].push_back(num);
if(d != num/d) { // 避免重复添加平方数的情况
bucket[num/d].push_back(num);
}
}
}
}
// 从大到小枚举可能的gcd
bool found = false;
for(ll g = a.back(); g >= 1; g--) {
if(bucket.count(g) && bucket[g].size() >= 3) {
// 输出该桶中最小的三个数
cout << bucket[g][0] << " " << bucket[g][1] << " " << bucket[g][2];
found = true;
break;
}
}
if(!found) { // 所有数互质,gcd最大为1
cout << a[0] << " " << a[1] << " " << a[2];
}
return 0;
}
这个解法的时间复杂度主要在于预处理部分,每个数找约数是 O(sqrt(a_i)),总体可以接受。它巧妙地将一个求三元组的问题,转化为了基于约数统计的枚举问题,是蓝桥杯中常见的“正难则反”思维。
F题“数字接龙”是一个标准的深度优先搜索(DFS)题,但加上了不少限制条件,使得它比普通的迷宫搜索要复杂。题目给定一个n*n的网格,每个格子有一个0到k-1的数字。你需要从(1,1)走到(n,n),每一步可以走八个方向(上下左右加斜角),但必须满足:1. 走的格子数字必须是 (当前步数 % k);2. 路径不能交叉或重复访问;3. 路径的字典序最小(方向用0-7的数字表示)。
这种求字典序最小的路径,通常的DFS策略是:按方向编号从小到大进行搜索。这样,第一个搜索到的完整路径,自然就是字典序最小的。但这里有一个巨大的优化点:一旦找到一条合法路径,就应该立即结束整个搜索过程,因为继续搜索只会找到字典序相同或更大的路径。这就是原始文章代码中 bool ac 变量的作用,它是一个全局标志位,一旦置为 true,所有递归分支都直接返回。
另一个难点是路径不能交叉的判断。题目中说的“不能交叉”并不是简单的不能走回头路(这由 st 访问数组保证了),而是特指一种情况:不能“绕过”一个已经访问过的格子形成交叉。原始文章代码中那几个 if(i==1 && ...) 的条件判断,就是为了防止这种交叉。例如,方向1是右上角(-1,1),如果当前格子的左边(x-1,y)和上边(x,y+1)都已经被访问过,那么从当前格子走到右上角格子,就会和之前访问过的路径形成一个“包围”式的交叉,这是不允许的。对于四个斜角方向,都需要进行类似的判断。
int dx[] = {-1, -1, 0, 1, 1, 1, 0, -1};
int dy[] = {0, 1, 1, 1, 0, -1, -1, -1};
// ... 在dfs循环中 ...
for(int i=0; i<8; ++i){
int nx = x + dx[i];
int ny = y + dy[i];
// ... 边界、访问、数字匹配判断 ...
// 防止路径交叉的判断
if(i==1 && st[x-1][y]==1 && st[x][y+1]==1) continue; // 右上方向
if(i==3 && st[x+1][y]==1 && st[x][y+1]==1) continue; // 右下方向
if(i==5 && st[x+1][y]==1 && st[x][y-1]==1) continue; // 左下方向
if(i==7 && st[x-1][y]==1 && st[x][y-1]==1) continue; // 左上方向
// ... 递归搜索 ...
}
写这类DFS题目,一定要把状态标记(st数组)和回溯(pop_back, st[nx][ny]=false)写对。递归前标记和压入路径,递归后恢复状态,这是标准模板。同时,要仔细理解题目中所有限制条件,并转化成代码中的剪枝语句,这样才能避免超时。
4. “爬山”与“拔河”:贪心陷阱与区间和的巧妙处理
G题“爬山”是本届比赛争议比较大的一道题。题目有n座山,每座山有一个高度。你有两种操作:1. 使用一次“魔法A”,使一座山的高度变为原来的平方根(向下取整)。2. 使用一次“魔法B”,使一座山的高度变为原来的一半(向下取整)。你各有P次和Q次使用机会,问如何操作能使所有山的总高度最小。
很多同学的第一反应,也是原始文章中提到的“优先队列贪心”法:每次选择当前最高的山,看是除以2下降得多,还是开平方根下降得多,就选哪种操作。这个贪心策略听起来很合理,而且能过大部分样例,甚至能拿到92分的高分。但是,它并不是完全正确的。贪心策略在这里失效的原因在于,操作之间不是独立的。对一座山连续使用两种不同的操作,其效果可能与分别对两座山使用不同操作的效果不同。例如,一座非常高的山,先开平方根再除以2,减少的高度可能远大于对两座不同的山分别进行操作。
原始文章提到了“得用dp”,这指向了正确的方向。这是一个资源分配型的动态规划问题。我们可以定义状态 dp[i][p][q] 表示考虑前i座山,使用了p次A操作和q次B操作时,这些山的最小总高度。但状态空间太大(n, P, Q都可能达到2000),无法直接实现。更可行的思路是,对于每一座山,我们可以预处理出使用不同次数(a次A和b次B,满足a+b <= P+Q)操作后,这座山能达到的最小高度。由于操作顺序不影响最终结果(因为除2和开方都是可交换的),我们可以枚举a和b。然后,问题就变成了一个“分组背包”问题:有n组物品(n座山),每组物品里有若干种选择(不同的操作组合及其最终高度),每组必须选一个物品,总操作次数有限制,求最小总高度。
// 思路伪代码,并非AC代码
// 1. 预处理每座山的状态
for 每座山的高度 h {
for a from 0 to P {
for b from 0 to Q {
if (a+b > P+Q) continue;
height[a][b] = 对h进行a次开方和b次除2后的结果;
}
}
}
// 2. 分组背包DP
vector<vector<ll>> dp(P+1, vector<ll>(Q+1, INF));
dp[0][0] = 0;
for 每座山 i {
vector<vector<ll>> new_dp = dp; // 滚动数组优化
for p_used from 0 to P {
for q_used from 0 to Q {
if(dp[p_used][q_used] == INF) continue;
for a from 0 to (P - p_used) {
for b from 0 to (Q - q_used) {
new_dp[p_used+a][q_used+b] = min(new_dp[p_used+a][q_used+b],
dp[p_used][q_used] + height[i][a][b]);
}
}
}
}
dp = move(new_dp);
}
// 答案就是 dp[P][Q]
这个DP的复杂度是 O(n * P^2 * Q^2),仍然很高,需要根据数据范围进行优化,比如注意到操作次数很多时,山的高度会很快降到1,之后操作无效,可以提前终止枚举。这道题在考场上想拿满分非常难,它考察的是选手对贪心算法局限性的认识,以及将复杂问题转化为经典模型(背包DP)的能力。
H题“拔河”则是一道关于区间和的题目。给定一个序列,要求将其划分为两个连续的、不相交的子区间,使得两个区间和的总和之差最小。注意,是分成两个连续的区间,而不是任意挑两段。这意味着我们只需要找一个分割点 k,左边区间是 [1, k],右边区间是 [k+1, n],然后计算两边和的差值。但题目要求的是两个区间,并没有说必须是前后分割,也可以是中间两段,只要它们连续且不相交。所以更一般的理解是:找两个不相交的连续子数组,使得它们各自的和的差值最小。
暴力枚举所有可能的区间对是 O(n^4) 的,显然不行。一个优化思路是:先预处理前缀和 s[i],那么任意连续区间 [l, r] 的和就是 s[r] - s[l-1]。我们可以枚举所有可能的区间和以及它的起始位置,存储在一个数组里。然后按区间和的大小排序。排序后,差值最小的两个区间和,很可能在排序后的数组中相邻(但不一定,因为区间可能重叠)。所以我们只需要检查排序后相邻的区间对,如果它们对应的区间不相交,就用它们的差值更新答案。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
int main(){
ll n; cin >> n;
vector<ll> a(n+1), s(n+1, 0);
for(int i=1; i<=n; i++) {
cin >> a[i];
s[i] = s[i-1] + a[i];
}
vector<pair<ll, ll>> v; // {区间和, 区间左端点}
for(int l=1; l<=n; l++) {
for(int r=l; r<=n; r++) {
v.push_back({s[r] - s[l-1], l});
}
}
sort(v.begin(), v.end());
ll ans = LLONG_MAX;
for(int i=0; i < v.size()-1; i++) {
// 检查v[i]和v[i+1]对应的区间是否重叠
// 我们需要知道区间的右端点。已知左端点l和区间和sum,但无法直接得到r。
// 一个近似方法是:如果两个区间和相差很大,或者左端点相距很远,它们很可能不重叠。
// 更稳妥但低效的方法是记录右端点。
// 更好的做法:存储三元组 {区间和, 左端点, 右端点}
}
cout << ans << endl;
return 0;
}
原始文章中的代码尝试通过区间和除以左端点值来估算右端点,这种方法并不严谨,在某些情况下会出错。更正确的做法是在生成所有区间时,同时记录右端点 r。然后在排序后检查相邻区间对 (l1, r1) 和 (l2, r2) 是否满足 r1 < l2 或 r2 < l1(即不相交)。这样做的复杂度是 O(n^2 log n),对于 n=1000 左右的数据是可行的。如果n更大,可能需要更优的解法,例如使用二分查找或者双指针。这道题考察的是对前缀和的灵活运用,以及通过排序来优化最值搜索的技巧。
1119



被折叠的 条评论
为什么被折叠?



