第十七届蓝桥杯C/C++软件赛B组算法题讲解

个人主页:
wengqidaifeng

✨ 永远在路上,永远向前走

个人专栏:
数据结构
C语言
嵌入式小白启动!
重要OJ算法题详解
蓝桥杯备战


文章目录

  • 前言
  • 一.青春常数
  • [二. 双碳战略](#二. 双碳战略)
  • [三. 循环右移](#三. 循环右移)
  • [四. 蓝桥竞技](#四. 蓝桥竞技)
  • [五. LQ聚合](#五. LQ聚合)
  • [六. 应急布线](#六. 应急布线)
  • [七. 理想温度](#七. 理想温度)
  • [八. 足球训练](#八. 足球训练)
  • 总结

前言

第十七届蓝桥杯省赛于 4 月 11 日结束,我参加的是 C/C++ 大学 B 组。四小时的比赛节奏紧凑,从结果填空到程序设计,每道题都需要在有限时间内完成理解、建模与编码,对心态和基本功都是不小的考验。

赛后我花了一些时间重新梳理了全部题目,把自己当时的思路和更优的解法做了对比复盘。本文将按照题号顺序,逐一分享我对每道题的理解与实现,希望能给大家提供一些实用的参考,也欢迎在评论区一同讨论交流。(感谢苯环yeVegetable大佬!)


省一线: 30-35

一.青春常数

这道题是该卷最简单的,只是需要注意一些细节,比如:x可以取到0,且N是一个奇数,所以应当是N/2取整后+1.
坑点: 1. 代码模拟时数据类型没开成long long int.

2.如果用电脑自带计算器,用标准模式会四舍五入.

二. 双碳战略

该题是这套考题最难的一道题。乍一看很有可能找不到思路,没有思路对于填空题,我们可以通过打表找到一些规律来看看能不能推导。

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
//打表
void solve(){
		int n = 2026;
		vector<int> s(n);//0 0 0 0... 2026个0
		//定义数组表示初始状态
		//跑暴力打表 用bfs,一个数组就表示一个状态
		queue<vector<int>> q;//用队列存储每个状态
		map<vector<int>, int> d;//存该状态对应的最短路
		q.push(s);
		d[s] = 0;
		//bfs
		while(q.size()){
				auto u = q.front();
				q.pop();
				for(int i = 0; i < n; i++){
					auto v = u;
					//第奇数次
					if(d[u] % 2 == 0){
							for(int j = i; j < n; j++){
									//右边翻转
									v[j] ^= 1;
							}
					}else{
							for(int j = 0; j <= i; j++){
									//左边翻转
									v[j] ^= 1;
							}								
					}
}
		//下一个状态v
		if(!d.count(v)){
				//第一次搜到这个状态
				q.push(v);
				d[v] = d[u] + i;//更新
		}
		}
		int ans = 0;
		//枚举一遍所有状态
		for(auto & [v, dist] : d){
		//把dist和v都输出
			 for(auto & x : v){
			 	cerr << x << " "; 
			 }
			 cerr << endl;
			 cerr << "步数:" << dist << endl;
			 ans += dist;
		}
		cout << ans << endl;
}

通过输出以上代码,从小到大测试几组数据,将会发现数据呈现杨辉三角的规律(算有几个0,几个1,几个2,几个...),即是组合数如下

cpp 复制代码
sigma C(n, i) * i;

演示一下求组合数

cpp 复制代码
int n = 2026;
cin >> n;
vector<int> f(n + 1, 1), g(n + 1, 1);//预处理阶乘和阶乘导数
for(int i = 1; i <+ n; i++){
	f[i] = f[i - 1] * i % MOD;//阶乘
	g[i] = g[i - 1] * ksm(i, MOD - 2, MOD) % MOD;//1/i逆元快速幂一下(通过费马小定理)
	//C(n, m) = n! / (m!*(n-m)!)
	//f[n] * g[m] * g[n-m]
	int ans = 0;
	for(int i = 0; i <= n; i++){
		int C = =f[n] * g[i] % MOD * g[n - i] % MOD;
		ans = (ans + C * i % MOD) % MOD;
	}
	cout << ans << endl;
}

三. 循环右移


这道题也属于本张考卷最简单的题目之一。循环右移要完全相同,自己可以举例子演示一下,要么数组中每个元素都相同,那么都向右移也自然和原本相同。还有一种可能就是数组中只有一个元素,那么你怎么移,那最后肯定是相同的。

cpp 复制代码
void solve(){
	int n,x,y;
	cin >> n >> x >> y;

	cout << max(OLL, y - x + 1) << endl;
}

signed main(){
		int T = 1;
		cin >> T;
		while(T--){
		 		solve();
		}
		return 0;
}

四. 蓝桥竞技


该题是一道结论题,首先读题可以知道要想满足条件,那么首先总选手的数量必须是5的倍数才行,不然肯定会有选手组队失败。即是如果能成功的话,那么战队数肯定是总选手数量除以5。

再接着,一个位置比如上单对抗路有5个人,但是总共组的队发现是3支,这就肯定不行了,因为这样的话有的队伍就肯定会有超过一个以上的上单选手,所以任何单个位置的选手数量ai是不能超过组队数量的。

这两个必要条件加起来就是一个充要条件。

cpp 复制代码
void solve(){
		int n;
		cin >> n;
		int mx = 0, sum = 0;
		for(int i = 0, x; i < n; i++){
				cin >> x;
				sum += x;
				mx = max(mx, x);
		}

		if(sum % 5 || (sum / 5 < mx)){
				cout << "F" << endl;
		} else {
				cout << "T" << endl;
		}
}

五. LQ聚合

这道题对于新手确实有点难度。首先要明确该序列肯定有某个位置分界点(枚举出来),前半部分全改成L,后半部分全改成Q会是最优的。

暴力做法:对于30%样例,?个数小于10,一共只会产生2的10次方种可能,每种可O(n)乘以1e5,且不会超时,可以得到30%的分。

正解:运用前缀和+乘法原理

假设分界点为i,[1, i]的?改成L,[i+1, n]?改成Q。

二元组数量组成贡献:

  1. 左边'?'X右边'?'
  2. 左边'?'X右边'Q'
  3. 左边'L'X右边'?'
  4. 左边'L'X左边'?'
  5. 右边'?'X右边'Q'
  6. 原本的'L'和原本的'Q'
    2和3是对称的,4和5是对称的
    前三个是乘法原理,4和5是前缀和后缀预处理(因为需要知道每个区间中L.Q.?分别的个数)
    6直接算
cpp 复制代码
void solve(){
		int n;
		string s;
		cin >> n >> s;
		s = " " + s;
		vector<int> L(n + 1), Q(n + 1), X(n + 1);
		vector<int> sq(n + 1);//统计左边的Q乘上左边的?
		//贡献6为规定的
		int sun = 0;//贡献6
		for(int i = 1; i <= n; i++){
				L[i] = L[i - 1];
				Q[i] = Q[i - 1];
				X[i] = X[i - 1];
				//预处理三个前缀
				sq[i] = sq[i - 1];//
				if(s[i] == 'L'){
					L[i]++;
				}else if(s[i] == 'Q'){
					Q[i]++;
					sq[i] += X[i];
					sum += L[i];
				} else {
				X[i]++;
				}
		}

		vector<int> sl(n + 2);//统计右半部分的L乘以右边的?
		//倒着预处理一遍sl
		for(int i = n; i > 0; i--){
			sl[i] = sl[i + 1];
			if(s[i] == 'L'){
				sl[i] += (X[n] - X[i]);
			}
		}

		//枚举
		int ans = 0;
		for(int i = 0; i <= n; i++){
			int add = X[i] * (X[n] - X[i]);//贡献1
			add += X[i] * (Q[n] - Q[i]);//贡献2
			add += L[i] * (X[n] - X[i]);//贡献3
			add += sq[i] + sl[i + 1];//贡献4和5
			ans = max(ans, add);
			}

		cout << ans + sum << endl;
}

六. 应急布线


(dfs,并查集均可以)由题目中"接入这种应急跳线数量最多的那一台机器,其接入的跳线数降到最低。"可以知道这不就是常说的求最大值最小化嘛,可以想到二分思想。

第一问:补几条线也就是补几条边:连通块个数-1即可

第二问:最小化补边产生的度数的最大值

这些连通块肯定能连成一条链,所以补边最大值不会超过2,只可能是0.1.2其中一个,最大最小这些说法只是用来迷惑的。

则可以有这样的逻辑。if(能0) 则0;else if(能1)1;else 2;

如果是0,那就是一条边都不加,那就只有一种可能----原图就是联通的。连通块个数为1。

即if(连通块个数 == 1)

cout << " 0 0" << endl;

到目前为止,所讨论的均与连通块有关,所以我们想办法将这个连通块维护起来,这就要用到并查集或者bfs建图。

如何判断能不能为1,自己画图观察,要加跳线与其他连通块连接的只能作为两端的元素,连一条边,那么这条边的两个点就不能再连单独的点了,不然增加跳线数就会变成2了。还能连的点数量为非单点数-2Xcnt-1,与单点个数比较,大于等于单点个数就是可以为1的。

cpp 复制代码
void solve(){
		int n,m;
		cin >> n >> m;
		vector<int> fa(n + 1), sz(n + 1);
		for(int i = 1; i <= n; i++){
			 fa[i] = i;
			 sz[i] = 1;
		}

		auto find = [&](auto && self, int x) -> int {
		return x == fa[x] ? x : fa[x] = self(self, fa[x]);
		}

		auto merge = [&](int u, int v) -> bool{
				u = find(find, u);
				v = find(find, v);
				if(u == v) return 0;
				sz[u] += sz[v];
				fa[v] = u;
				return 1;
				}

		int cnt = n;
		for(int i = 0, u, v; i < m; i++){
		   cin >> u >> v;
		   cnt -= merge(u, v);
		}

		cout << cnt - 1 << " ";//cnt就是连通块个数
		if(cnt == 1){//原图连通->答案是0
		   cout << 0 << endl;
		} else if(cnt == 2){//只有两个连通块->答案是1
					cout << 1 << endl;
		} else {
				int s = 2, c = 0;
				for(int i = 1; i <= n; i++){
						if(i != find(find, i)) continue;
						if(sz[i] == 1){
								c++;
						} else {
								s += sz[i] - 2;
						}
				}
				cout << (1LL + (c > s)) << endl;//压缩代码
		}
}

七. 理想温度

这道题题意非常直观:我们有一次机会给某个区间 [l, r] 统一加上一个整数 k,问最多能让多少个位置的 A[i] 变成 B[i]

思路分析

首先可以想到,原本就相等的那些传感器,即使不加操作也已经达标,它们本身就贡献了基础的答案。而对于那些不相等的传感器,我们需要通过一次区间加法来同时"修正"其中的一部分。

注意到每个位置的差值 d[i] = B[i] - A[i]。我们选定一个区间 [l, r] 和一个补偿值 k,对于区间内的每个位置,相当于要求 d[i] == k。也就是说,在同一个区间内,所有被修正的位置,它们的差值 d[i] 必须全部相等

因此问题转化为:在数组 d 中,找到一个连续子区间,使得该子区间内某个数值出现的次数尽可能多(当然,原本就相等的那些位置,无论 k 取什么值,它们始终达标,也可以视作 d[i] = 0 的位置天然达标)。但注意我们只能选择一个固定的 k,所以对于一个区间,我们能达标的数量就是该区间内众数出现的次数 ,而这个众数对应的值就是我们选择的 k

进一步抽象 :对于任意一个目标值 v,我们把所有 d[i] == v 的位置标记为 1,其余位置标记为 -1(因为操作区间是连续的,包含一个不相等的数就会浪费一次区间名额,但实际上不能简单用 -1,因为区间外的达标数不会减少)。更准确的做法是:枚举每一个可能的 k 值,考虑所有 d[i] == k 的位置,我们想要找到一个连续区间,包含尽可能多的这些位置。这可以转化为经典的 最大子段和 问题。

实现思路

  1. 先计算出差值数组 d[i] = B[i] - A[i]
  2. 统计原本就相等的位置个数,记为 base
  3. 对于所有出现过的 k(即 d[i] 的所有不同取值),单独处理:
    • 创建一个临时数组,如果 d[i] == k,则该位置贡献为 1;如果 d[i] == 0,该位置贡献为 0(因为无论选不选它,它都已经在 base 中了,不增加也不减少);如果 d[i] 为其他值,则贡献为 -1(因为如果将它纳入区间,它不会变成相等,反而会占用区间名额)。
    • 对该临时数组求最大子段和 ,得到的值记为 gain
    • 那么选择这个 k 的最优答案为 base + gain
  4. 对所有 k 取最大值即可。

坑点

  • 注意 d[i] 的取值范围很大,需要用 map 或离散化来存储每个 k 对应的位置。
  • 最大子段和可以直接贪心扫一遍,时间复杂度 O(n)。
  • 由于每个位置只会被它的 d[i] 值遍历一次,总复杂度为 O(n)。

参考代码

cpp 复制代码
void solve() {
    int n;
    cin >> n;
    vector<long long> A(n), B(n);
    for (int i = 0; i < n; i++) cin >> A[i];
    for (int i = 0; i < n; i++) cin >> B[i];
    
    int base = 0;
    map<long long, vector<int>> pos;
    for (int i = 0; i < n; i++) {
        long long d = B[i] - A[i];
        if (d == 0) base++;
        else pos[d].push_back(i);
    }
    
    int ans = base;
    for (auto &[k, v] : pos) {
        // 由于只需要处理这些位置,我们可以在原数组上直接做类最大子段和
        // 用 map 存每个位置的值,然后遍历连续段
        // 简便做法:将 v 中的下标排序,然后考虑相邻下标的间隙
        // 如果间隙中全是 d != k 且 d != 0 的位置,它们会带来负贡献
        // 因此我们可以将整个数组压缩成由 v 中位置构成的段,段间间隙的负贡献为 -(间隙长度)
        // 注意间隙中可能混有 d==0 的位置,它们贡献为 0,所以间隙的负贡献只计算 d!=k 且 d!=0 的位置数
        
        // 为了准确计算,直接对原数组扫一遍,遇到 d==k 就 +1,遇到 d==0 就 +0,否则 -1,求最大子段和
        int sum = 0, max_gain = 0;
        for (int i = 0; i < n; i++) {
            long long d = B[i] - A[i];
            if (d == k) sum++;
            else if (d != 0) sum--;
            if (sum < 0) sum = 0;
            max_gain = max(max_gain, sum);
        }
        ans = max(ans, base + max_gain);
    }
    cout << ans << endl;
}

八. 足球训练

这道题是本场考试的压轴题,难度较高。题目要求将 m 天分配给 n 个队员,每人分配 k_i 天,使得最终实力值乘积最大。

初步分析

每个队员的实力增长是一个线性函数 a_i + k_i * b_i。如果没有取模,这是一个经典的资源分配问题。由于乘积的形式,取对数后可以转化为求和的最大化问题,但对于整数分配和取模要求,需要更精细的分析。

关键观察

  1. 如果我们给某个队员多训练 1 天,他的实力会从 a_i + k * b_i 变为 a_i + (k+1) * b_i,增长的倍数(a_i + (k+1)*b_i) / (a_i + k*b_i)
  2. 为了让总乘积最大,每次分配 1 天时,应该选择增长倍数最大的那个队员。
  3. 这个贪心策略是正确的,因为函数 f(k) = a + k*b 是线性函数,其边际增长倍数 (a + (k+1)*b) / (a + k*b) 随着 k 的增加而递减。因此我们可以用一个大根堆,每次取出增长倍数最大的队员,给他加一天,然后重新计算他的增长倍数再放回堆中。

但是 m 最大可达 1e9,显然不能一天一天模拟。

优化:批量分配

注意到对于同一个队员,随着 k 增加,他的边际增长倍数单调递减。当某个队员的边际增长倍数下降到与堆顶下一个队员的边际增长倍数相同时,我们可以批量地给这些队员分配天数,直到他们的边际增长倍数再次分出高低。

更系统的做法:对于每个队员,我们可以计算出他训练 k 天时的边际增长倍数 r_i(k) = (a_i + (k+1)*b_i) / (a_i + k*b_i)。我们需要将 m 天分配出去,使得最终所有队员的边际增长倍数尽可能均衡(不能差太多)。实际上可以二分一个阈值倍数 R,只要某个队员的边际增长倍数 ≥ R,我们就给他分配天数,直到他的边际增长倍数 < R。然后统计总天数是否达到 m,并调整剩余零头。

由于需要高精度比较,我们可以用分数形式 (a + (k+1)*b) / (a + k*b) 比较,或者转化为乘法:对于两个队员,比较 (a_i + k_i*b_i) * b_j(a_j + k_j*b_j) * b_i 的大小。

实现细节

  1. 对于每个队员,初始 k_i = 0
  2. 二分一个阈值 R(用一个分数或高精度实数表示),判断在总天数不超过 m 的情况下,能否让所有队员的边际倍数都不低于 R
  3. 具体判断函数:对于每个队员,我们可以通过解方程求出在给定阈值 R 下最多能分配的天数 k,使得 (a + (k+1)*b) / (a + k*b) >= R。这个不等式可以转化为关于 k 的线性不等式,直接计算最大整数 k
  4. 二分找到最大可行天数对应的阈值,然后补齐剩余的零散天数(此时用堆模拟即可,因为剩余天数已经很少)。

复杂度 :二分次数约为 O(log(值域)),每次判断需要 O(n),总复杂度 O(n log V),可以接受。

参考代码

cpp 复制代码
const int MOD = 998244353;

void solve() {
    int n, m;
    cin >> n >> m;
    vector<long long> a(n), b(n);
    for (int i = 0; i < n; i++) {
        cin >> a[i] >> b[i];
    }

    // 二分阈值:边际倍数 >= R 才给天数
    // 用分数表示 R = p / q
    // 由于要最大化,我们二分一个 double 阈值或者用整数二分
    // 这里采用 double 二分,注意精度
    double L = 1.0, R = 2e5; // 最大边际倍数不会超过 (a+b)/a,a>=1, b<=1e5,所以上限约1e5+1
    for (int _ = 0; _ < 60; _++) {
        double mid = (L + R) / 2;
        long long tot = 0;
        for (int i = 0; i < n; i++) {
            // 求解 (a_i + (k+1)*b_i) / (a_i + k*b_i) >= mid
            // 推导:a_i + (k+1)*b_i >= mid * (a_i + k*b_i)
            // a_i + k*b_i + b_i >= mid*a_i + mid*k*b_i
            // k*b_i*(1 - mid) >= mid*a_i - a_i - b_i
            // k <= (a_i*(mid-1) - b_i) / (b_i*(1-mid))  注意符号变化
            // 更简单的做法:直接用 mid 计算可分配的最大 k
            if (mid <= 1.0) {
                tot += m; // 不可能比1小,因为至少增长倍数为 (a+b)/a >= 1
                continue;
            }
            // 避免除零,b_i > 0
            long long k = (a[i] * (mid - 1) - b[i]) / (b[i] * (1 - mid)); // 这个推导可能有问题,实际可以直接解不等式
            // 正确推导:
            // (a + (k+1)b) >= mid * (a + k*b)
            // a + kb + b >= mid*a + mid*kb
            // kb(1 - mid) >= a(mid - 1) - b
            // 由于 mid > 1,1-mid < 0,两边同除以负数要变号:
            // k <= (a(mid-1) - b) / (b*(mid-1))  注意分母正负
            // 化简:k <= a/b - 1/(mid-1)
            // 可以直接用这个公式计算最大整数 k
            if (mid > 1.0) {
                double max_k = a[i] * 1.0 / b[i] - 1.0 / (mid - 1.0);
                if (max_k < 0) k = -1;
                else k = (long long)floor(max_k);
            }
            if (k < 0) k = -1;
            tot += k + 1; // 实际训练天数
            if (tot > m) break;
        }
        if (tot >= m) L = mid;
        else R = mid;
    }

    // 此时 L 是最大阈值,按照这个阈值分配天数,可能还有剩余几天
    // 用优先队列精确分配剩余天数
    priority_queue<pair<double, int>> pq; // 存边际增长倍数和队员编号
    vector<long long> k(n, 0);
    long long tot_days = 0;
    for (int i = 0; i < n; i++) {
        double max_k = a[i] * 1.0 / b[i] - 1.0 / (L - 1.0);
        if (max_k < 0) k[i] = 0;
        else k[i] = (long long)floor(max_k) + 1;
        if (k[i] < 0) k[i] = 0;
        tot_days += k[i];
        if (k[i] < m) { // 可能还能继续训练
            double gain = (a[i] + (k[i] + 1) * b[i]) * 1.0 / (a[i] + k[i] * b[i]);
            pq.push({gain, i});
        }
    }

    // 补足剩余天数
    long long remain = m - tot_days;
    while (remain > 0 && !pq.empty()) {
        auto [gain, i] = pq.top(); pq.pop();
        k[i]++;
        remain--;
        if (k[i] < m) {
            double new_gain = (a[i] + (k[i] + 1) * b[i]) * 1.0 / (a[i] + k[i] * b[i]);
            pq.push({new_gain, i});
        }
    }

    // 计算最终答案
    long long ans = 1;
    for (int i = 0; i < n; i++) {
        long long val = (a[i] + k[i] % MOD * (b[i] % MOD)) % MOD;
        ans = ans * val % MOD;
    }
    cout << ans << endl;
}

:上面代码中的二分和公式推导是核心,实际实现时需要注意浮点精度,可以用分数类或高精度整数比较来避免误差。另外,由于 m 很大,在二分时计算 k 的上限也要注意范围限制,但 k 不会超过 m


总结

第十七届蓝桥杯省赛 C/C++ B 组的题目整体难度中等偏上,既考察了基础算法能力(如差分、贪心、二分),也考验了选手对复杂问题的抽象与建模能力(如重构树、杨辉三角规律)。填空题中的"双碳战略"极具挑战性,需要从打表找规律入手;编程大题部分,"理想温度"和"足球训练"分别从差分数组和贪心二分角度考察了思维深度。

回顾整个比赛,我认为以下几点很重要:

  1. 稳住心态:遇到不会的题先跳过,把能拿的分拿稳,比如"青春常数"和"循环右移"这种送分题一定不能丢。
  2. 善于打表找规律:对于没有直接思路的填空题,先写暴力程序输出小数据,观察规律往往能柳暗花明。
  3. 模型转换能力:像"理想温度"转换为最大子段和,"足球训练"转换为边际倍数贪心,都需要将原问题映射到经典模型上,这需要平时的积累。
  4. 代码细节 :数据类型(开 long long)、边界条件、取模运算等细节往往是决定成败的关键。

希望这篇题解能对大家有所帮助。算法之路漫漫,与诸君共勉!也欢迎在评论区留下你的思路或疑问,一起交流进步。(前6题是我看yeVegetable大佬的讲解视频总结的)

相关推荐
道剑剑非道2 小时前
【C++ 仿 MFC 反射系统】
开发语言·c++·mfc
cui_ruicheng2 小时前
Linux IO入门(一):从C语言IO到文件描述符
linux·运维·c语言
网域小星球3 小时前
C 语言从 0 入门(二十二)|内存四区:栈、堆、全局、常量区深度解析
c语言·开发语言
晓纪同学3 小时前
EffctiveC++_第三章_资源管理
开发语言·c++·算法
蚊子码农3 小时前
每日一题--C语言指针与内存泄漏:一道小问题的深度复盘
c语言·开发语言
Fanfanaas3 小时前
Linux 系统编程 进程篇(一)
linux·运维·服务器·c语言·开发语言·网络·学习
沐雪轻挽萤3 小时前
6. C++17新特性-编译期 if 语句 (if constexpr)
开发语言·c++
水云桐程序员3 小时前
C语言编程基础,输入与输出
c语言·开发语言·算法
jolimark3 小时前
微软不支持C开发Win32原因剖析,及C语言在系统开发中的优势
c语言·微软·mfc·系统开发·win32