ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

蓝桥杯国赛C++ B组真题深度解析:递推、DP与贡献度思维实战

蓝桥杯国赛C++ B组真题深度解析:递推、DP与贡献度思维实战 1. 项目背景与核心价值最近在整理历年蓝桥杯国赛的真题时我又翻出了2020年C B组的那套题。这套题在当年引起了不小的讨论它不像一些年份的题目那样追求极致的算法复杂度而是在基础算法和编程思维上设置了非常巧妙的“陷阱”和“弯道”。很多选手包括一些平时刷题不少的同学都在这里栽了跟头。我之所以想重新梳理这套题是因为它非常典型地体现了蓝桥杯国赛从“考你会不会”到“考你熟不熟、细不细、活不活”的转变趋势。对于正在备赛的同学来说吃透这套题的价值远大于盲目刷十套新题。它更像是一面镜子能照出你在编程基本功、逻辑严谨性、边界条件处理以及时间/空间复杂度权衡上的真实水平。今天我就以一名多次参与竞赛辅导的“老司机”视角带大家深度复盘2020年蓝桥杯国赛C B组的几道核心题目不仅讲“怎么做”更要讲清楚“为什么这么做”以及“当时容易怎么错”。2. 试题一平面分割递推与空间思维这道题是当年国赛的第一道编程大题题目描述大致是有20条圆和20条直线这些圆和直线两两相交且没有三条线或圆交于同一点问它们最多能把平面分割成多少部分。很多同学一看到“最多”再看到圆和直线混合就有点发懵容易陷入复杂的几何分类讨论。其实这是一道经典的“递推”问题考察的是将复杂问题分解为已知模型的能力。2.1 问题拆解从简单到复杂我们不应该直接思考20圆20线这个复杂场景。正确的思路是建立模型只有直线n条直线两两相交且无三线共点最多能将平面分割成多少部分这是一个经典公式L(n) n*(n1)/2 1。推导思路是第k条直线最多可以与前面的k-1条直线相交产生k-1个新交点这条直线被这些交点分割成k段每一段都会将其穿过的原有区域一分为二即新增k个区域。所以区域数递推公式为f(k) f(k-1) k初始f(0)1求和后即得上述公式。只有圆m个圆两两相交且无三圆共点最多能将平面分割成多少部分公式为C(m) m*(m-1) 2。推导思路类似第k个圆最多可以与前面k-1个圆相交每个圆产生两个交点所以第k个圆上最多有2*(k-1)个交点这些交点把这个圆分割成2*(k-1)段圆弧每一段圆弧都会将其穿过的原有区域一分为二即新增2*(k-1)个区域。递推公式g(k) g(k-1) 2*(k-1)初始g(0)1求和后即得公式。注意这里的关键是理解“新增区域”的来源。直线或圆上的每一段“新产生的”弧或线段如果它穿过了某个已有的区域就会把这个区域分成两块。而“最多”的情况就是确保每一段新弧都穿过一个独立的已有区域。2.2 混合情况的分析与递推现在考虑混合情况。我们不能简单地将L(20)和C(20)相加因为直线和圆之间也会相交产生新的分割。我们需要思考增量。假设我们已经有了a条直线和b个圆它们已经将平面分割成了F(a, b)个部分。现在我们加入第a1条直线。这条新直线会和已有的a条直线各交于1点产生a个交点。 同时它也会和已有的b个圆各交于2点产生2b个交点。 所以这条新直线上总共有a 2b个交点。这些交点把这条新直线分成了(a 2b 1)段两端也算区间。每一段如果它穿过一个已有的区域就会把这个区域一分为二从而增加1个区域。在“最多”的假设下这(a 2b 1)段中的每一段都穿过了不同的已有区域。因此新增一条直线带来的区域增量是(a 2b 1)。同理如果我们加入第b1个圆。 这个新圆会和已有的a条直线各交于2点产生2a个交点。 也会和已有的b个圆各交于2点产生2b个交点。 所以这个新圆上总共有2a 2b个交点。这些交点把这个新圆分成了(2a 2b)段圆弧。同样在“最多”的假设下每一段弧都穿过一个不同的已有区域。因此新增一个圆带来的区域增量是(2a 2b)。2.3 计算过程与代码实现有了递推关系我们就可以从零开始模拟依次添加20条直线和20个圆的过程。初始平面为1部分。我们可以选择任意添加顺序因为最终结果与顺序无关在“最多”的假设下。一种简单的实现方式是先加完所有直线再加所有圆但需要注意在加圆时直线数量a已经是20了。更清晰的方法是使用双重循环或直接基于公式计算。这里给出模拟递推的C代码#include iostream using namespace std; int main() { long long parts 1; // 初始平面 int lines 20, circles 20; // 先添加20条直线 for (int a 0; a lines; a) { // 添加第a1条直线时已存在a条直线0个圆 parts (a 2*0 1); // 增量 a 1 } // 此时已有20条直线再添加20个圆 for (int b 0; b circles; b) { // 添加第b1个圆时已存在20条直线b个圆 parts (2*20 2*b); // 增量 40 2b } cout parts endl; return 0; }计算一下 添加直线parts 1 (12...20) 1 210 211添加圆parts 211 (404244...78) 211 (4078)*20/2 211 1180 1391所以最终答案是1391。2.4 易错点与心得混淆“最多”与“任意”题目条件是“最多”这意味着我们必须假设所有交点都产生且交点不重合。计算增量时必须用当前已有的直线和圆数量来计算最大可能交点数。如果当成任意情况计算就会出错。增量公式记错直线增量是(a 2b 1)圆增量是(2a 2b)。这里的系数直线与圆相交产生2个点和常数项直线两端非常关键。一个常见的错误是忘记1或者把圆的增量误写成2(ab)1。数据类型溢出最终结果1391虽然不大但在递推过程中部分中间结果可能超过int范围如果规模更大。使用long long是更安全的竞赛习惯。实战心得遇到这种“平面分割”问题第一步永远是退回到最简单模型只有直线、只有圆推导或回忆其公式。第二步思考新增元素带来的“切割段数”这个段数就是区域增量。这比直接死记硬背混合公式要可靠得多。3. 试题二数字三角形动态规划与路径回溯这道题是经典数字三角形的变种。题目通常给定一个数字三角形从顶部走到底部每次只能走到下一行相邻的两个位置求经过数字之和的最大值。但国赛的题目往往会增加限制条件比如“向左下走的次数和向右下走的次数相差不能超过1”。3.1 经典DP解法回顾如果没有额外限制这就是一个最基础的动态规划问题。 设dp[i][j]表示从顶部走到第i行第j列从0或1开始计数所能获得的最大和。 状态转移方程为dp[i][j] max(dp[i-1][j-1], dp[i-1][j]) triangle[i][j]。 最终答案就是最后一行dp中的最大值。3.2 限制条件的分析与转化“向左下走的次数和向右下走的次数相差不能超过1”这个条件限制了路径的终点。想象一个高度为n的三角形。从顶点到底边一共需要走n-1步。 设向左下走了L步向右下走了R步则有L R n - 1。 条件要求|L - R| 1。解这个方程如果n-1是偶数则L R (n-1)/2。这意味着路径终点一定是底行的最中间那个数如果底行有奇数个数不对需要更精确。如果n-1是奇数则L和R相差1。比如L R1或R L1。这意味着终点会偏向一边。其实这描述的是在多层决策后左右步数的平衡性。有一个更直观的几何理解将向左下走视为坐标-1向右下走视为坐标1假设水平方向。从顶点(0,0)出发走n-1步后横坐标x的范围是[-(n-1), n-1]且步数差L-R就是-x。条件|L-R|1即|x|1。所以合法的终点对应的列索引假设顶点列索引为0的绝对值不能超过1。对于底边有n个数的三角形其列索引范围是[0, n-1]。我们需要将上述理论坐标映射到实际的数组索引上。通常我们这样构建三角形第i行有i1个数索引j从0到i。从(i, j)可以走到(i1, j)左下和(i1, j1)右下。那么从(0,0)出发走到(i, j)的位置向右下走的次数就是j向左下走的次数就是i - j。因为每向右下一次列索引1。 所以L i - j,R j。条件|L - R| 1即|i - 2*j| 1。当走到最后一行i n-1时条件变为| (n-1) - 2*j | 1。 我们需要找出所有满足这个条件的列索引j然后取dp[n-1][j]的最大值。3.3 算法实现与细节#include iostream #include vector #include algorithm using namespace std; int main() { int n; cin n; vectorvectorint triangle(n, vectorint(n, 0)); vectorvectorint dp(n, vectorint(n, 0)); // 读入数据只使用左下三角部分 for (int i 0; i n; i) { for (int j 0; j i; j) { cin triangle[i][j]; } } // 初始化DP dp[0][0] triangle[0][0]; // 状态转移 for (int i 1; i n; i) { for (int j 0; j i; j) { dp[i][j] triangle[i][j]; if (j 0) { // 最左边只能从上一行同列下来即从右上角下来但此处是左下走法 dp[i][j] dp[i-1][j]; } else if (j i) { // 最右边只能从上一行前一列下来即从左下下来 dp[i][j] dp[i-1][j-1]; } else { dp[i][j] max(dp[i-1][j-1], dp[i-1][j]); } } } // 根据限制条件找出合法终点 int ans 0; for (int j 0; j n; j) { if (abs((n-1) - 2*j) 1) { // 核心判断条件 ans max(ans, dp[n-1][j]); } } cout ans endl; return 0; }3.4 易错点与心得对限制条件的错误理解最常见的错误是忽略了这个条件或者错误地认为它限制了每一步的选择。实际上它只约束了整条路径的宏观形态最终体现在终点位置的选择上。DP过程本身不受影响。终点列索引的计算推导|i - 2*j| 1这个条件是解题关键。直接去枚举L和R的组合也可以但不如这个公式简洁高效。务必理解其推导过程。边界处理在DP循环中对每一行的第一个元素(j0)和最后一个元素(ji)要单独处理因为它们只有一个来源。输入数据的存储题目通常给的是三角形数据用二维数组存储时注意未使用的部分右上三角可以置0或不处理但要确保DP时不会越界访问。实战心得遇到带限制条件的DP先思考这个条件影响了DP的哪个部分状态定义、转移方程、初始条件、答案提取。像本题它只影响“答案提取”阶段那么DP的核心部分就不用变。这是一种非常重要的解题技巧——分离关注点。4. 试题三子串分值贡献度思维这道题是字符串处理中非常考验思维的一道题。题目定义了一个字符串S的“分值”为其所有非空子串的“唯一字符个数”之和。对于一个子串它的“唯一字符个数”是指在这个子串中只出现一次的字符的个数。要求计算给定字符串S的分值。例如字符串aba其所有非空子串有a(1),ab(2),aba(1),b(1),ba(2),a(1)。分值为 121121 8。暴力枚举所有子串是 O(n²) 的复杂度对于每个子串统计唯一字符又是 O(n)总复杂度 O(n³)对于 n 可能达到 10^5 的数据范围完全不可行。4.1 贡献度思维换个角度思考我们不能着眼于“每个子串有多少个唯一字符”而应该着眼于每个字符在多少个子串中能成为“唯一字符”。对于字符串中的第i个字符S[i]假设索引从0开始我们考虑它在哪些子串里是唯一的。假设在S[i]的左边离它最近的与它相同的字符位置是left如果没有则left -1。 在S[i]的右边离它最近的与它相同的字符位置是right如果没有则right nn为字符串长度。那么对于S[i]而言它要想在一个子串中是唯一的这个子串必须包含S[i]但不能包含S[left]和S[right]。这意味着这个子串的起始位置必须在(left, i]这个左开右闭区间内选择结束位置必须在[i, right)这个左闭右开区间内选择。起始位置有(i - left)种选择从left1到i。结束位置有(right - i)种选择从i到right-1。根据乘法原理S[i]能作为唯一字符出现的子串数量就是(i - left) * (right - i)。字符串 S 的分值就是将所有字符的这个贡献值加起来sum( (i - left[i]) * (right[i] - i) )其中left[i]和right[i]分别表示字符S[i]左右两边最近相同字符的位置。4.2 如何高效计算 left 和 right 数组我们需要对字符串中每种字符快速找到每个位置左右两边最近的出现位置。一种高效的方法是预处理每个字符出现的位置列表。对于字符ch假设它出现的位置数组是pos[ch] [p1, p2, p3, ..., pk]。 那么对于位置p2来说它左边的最近相同字符位置是p1。它右边的最近相同字符位置是p3。 对于位置p1左边没有相同字符left -1右边最近是p2。 对于位置pk左边最近是p_{k-1}右边没有相同字符right n。我们可以遍历字符串一次记录每个字符上一次出现的位置从而得到left数组。 然后再逆序遍历字符串记录每个字符下一次出现的位置从而得到right数组。4.3 算法实现#include iostream #include string #include vector using namespace std; int main() { string s; cin s; int n s.length(); vectorint left(n, -1), right(n, n); vectorint last_pos(26, -1); // 假设字符串只包含小写字母 // 计算 left 数组记录每个字符上一次出现的位置 for (int i 0; i n; i) { int idx s[i] - a; if (last_pos[idx] ! -1) { left[i] last_pos[idx]; } last_pos[idx] i; // 更新该字符最后出现的位置 } // 重置 last_pos用于计算 right 数组 fill(last_pos.begin(), last_pos.end(), n); // 计算 right 数组记录每个字符下一次出现的位置 for (int i n - 1; i 0; --i) { int idx s[i] - a; if (last_pos[idx] ! n) { right[i] last_pos[idx]; } last_pos[idx] i; // 更新该字符最后出现的位置从右向左 } // 计算总贡献值 long long ans 0; // 注意用 long long结果可能很大 for (int i 0; i n; i) { ans (long long)(i - left[i]) * (right[i] - i); } cout ans endl; return 0; }4.4 易错点与心得思维定式最容易犯的错误就是陷入“枚举子串”的暴力思维。竞赛中看到“所有子串的XX之和”要立刻条件反射地想到“贡献度”思维——计算每个元素对总答案的贡献。边界处理left数组的初始值应为-1right数组的初始值应为n。这代表了“左边/右边没有相同字符”的边界情况。处理不当会导致贡献值计算错误。数据类型溢出贡献值(i-left)*(right-i)可能很大两个int相乘可能溢出需要转换为long long再进行累加。这是竞赛中非常常见的坑。字符集范围示例代码假设了字符串只有小写字母。如果字符集更大如ASCII全部字符last_pos数组的大小应调整为128或256。如果字符集未知或很大可以使用unordered_mapchar, int来记录位置。实战心得“贡献度”是处理子串、子数组类求和问题的利器。类似的题目还有“子串中不同字符个数之和”、“子数组最小值之和”等。核心思路都是不从整体看部分而从部分每个元素看它影响了哪些整体子串/子数组。5. 试题四荒岛探测计算几何与积分思想这道题是当年国赛的压轴题之一综合性很强。题目描述了一个椭圆探测器信号范围和一个三角形荒岛区域要求计算椭圆与三角形重合部分的面积。这本质是一个计算几何问题但直接求任意多边形与椭圆的交集面积非常复杂。5.1 问题转化暴力法的局限与优化方向最直接的想法是蒙特卡洛方法在三角形和椭圆的外接矩形内随机撒大量点统计落在交集内的点的比例乘以矩形面积得到近似面积。但这种方法精度低、速度慢且竞赛中通常要求精确解或高精度解。另一种思路是多边形裁剪用椭圆曲线去裁剪三角形多边形。但椭圆是二次曲线裁剪算法如Sutherland-Hodgman通常针对直线裁剪处理曲线边界非常麻烦。本题的突破口在于题目可能给出的特殊条件回忆真题椭圆的焦点在x轴上且三角形的一条边与x轴平行。这个条件极大地简化了问题。5.2 基于定积分的面积计算如果椭圆是标准的x^2/a^2 y^2/b^2 1且三角形底边在x轴上假设为从x1到x2的一条线段那么椭圆与三角形重叠的部分可以看作是一个曲边梯形的面积减去或加上几个三角形面积。具体步骤坐标变换将椭圆平移旋转使其标准方程成立同时相应变换三角形的顶点坐标。如果椭圆焦点在x轴且长轴与x轴平行那么可能只需要平移。确定积分区间找出三角形与椭圆在x轴方向上的重叠区间[L, R]。这可以通过求解三角形三条边与椭圆的交点x坐标得到。计算曲边梯形面积在重叠区间[L, R]内椭圆的上半部分曲线方程为y b * sqrt(1 - x^2/a^2)。我们需要计算的是在这个区间内椭圆曲线、x轴、以及三角形的斜边所围成的区域面积。这需要根据三角形斜边在椭圆上方还是下方对面积进行加减。如果三角形的斜边在区间内在椭圆上方那么重叠部分面积 ∫(椭圆上曲线) dx - ∫(三角形斜边) dx在公共区间内。如果三角形的斜边在椭圆下方那么重叠部分就是椭圆曲线到x轴的面积但还需要考虑三角形是否完全在椭圆内情况更复杂。实际上更通用的方法是计算在x处椭圆曲线y值y_ellipse(x)与三角形在该x处对应的y值y_triangle(x)的最小值因为底部是x轴取两者中靠下的那条线作为上边界不对。我们需要的是椭圆与三角形公共部分的面积。可以转化为在x处从x轴到min(y_ellipse(x), y_triangle(x))的积分但前提是y_triangle(x)在这个x处有定义即x在三角形投影内。分段积分由于三角形的边是直线其表达式在x的某些区间内会发生变化例如穿过三角形顶点。因此需要根据三角形与椭圆在x方向上的交点将积分区间[L, R]进一步细分为若干个子区间在每个子区间内y_triangle(x)由一条固定的直线方程描述。数值积分最终的积分表达式∫ sqrt(1 - x^2/a^2) dx或∫ min( sqrt(1 - x^2/a^2), kxb) dx可能没有初等函数形式的原函数。在竞赛中通常允许使用自适应辛普森积分等数值方法来计算定积分达到要求的精度即可。5.3 自适应辛普森积分法自适应辛普森积分是计算给定区间[l, r]上函数f(x)的定积分的有效数值方法。#include iostream #include cmath #include iomanip using namespace std; double a, b; // 椭圆参数 // 假设三角形由三条直线描述这里简化我们只关心在某个x处三角形的高度y_tri(x) // 实际代码中需要根据三角形顶点坐标求出每条边的直线方程并实现一个函数给定x返回三角形在该x处的y值可能有多值取与椭圆比较的相关值 double f(double x) { // 这是被积函数。例如如果我们求的是椭圆上半部分与x轴之间的面积那么 if (x -a || x a) return 0; // x超出椭圆范围 double y_ellipse b * sqrt(1 - x*x/(a*a)); double y_triangle ...; // 根据三角形方程计算这里需要具体实现 // 假设我们要求椭圆与三角形重叠部分在x处的“高度”是两者中较小的一个因为从x轴向上看 // 但更准确地说是求 min(y_ellipse, y_triangle) 从 x轴 到 该值的积分如果该值0。 // 实际上重叠部分在x处的垂直截线长度是 min(y_ellipse, max(y_triangle, 0))情况复杂。 // 这里仅以椭圆面积为例 return y_ellipse; } // 辛普森公式 double simpson(double l, double r) { double mid (l r) / 2; return (r - l) * (f(l) 4*f(mid) f(r)) / 6; } // 自适应辛普森递归计算 double asr(double l, double r, double eps, double whole_area) { double mid (l r) / 2; double left_area simpson(l, mid); double right_area simpson(mid, r); if (fabs(left_area right_area - whole_area) 15 * eps) { return left_area right_area (left_area right_area - whole_area) / 15; } return asr(l, mid, eps/2, left_area) asr(mid, r, eps/2, right_area); } // 主函数调用 double calculate_area(double l, double r, double eps) { return asr(l, r, eps, simpson(l, r)); }5.4 易错点与心得几何情况分析不全这是本题最大的难点。椭圆和三角形的位置关系有多种包含、相交、相离。相交又分为三角形顶点在椭圆内、边穿过椭圆等多种情况。必须对所有情况进行分类讨论或者设计一个能处理所有情况的通用积分函数如计算在x处椭圆与三角形区域的垂直重叠长度。积分函数定义错误被积函数f(x)不是简单的椭圆y值。它表示在横坐标x处椭圆与三角形公共部分的垂直高度。如果三角形在该x处不存在x不在三角形水平投影内则高度为0如果存在则高度为min(y_ellipse(x), y_triangle_upper(x)) - max(0, y_triangle_lower(x))其中y_triangle_upper和y_triangle_lower是三角形在x处的上下边界对于与x轴平行的底边下边界可能是0。这需要根据三角形具体形状仔细推导。数值积分精度自适应辛普森积分的精度参数eps需要设置合理太小会超时太大会精度不足。通常对于输出浮点数的题目eps设为1e-6或1e-7是安全的。坐标变换如果椭圆不是标准位置必须先通过平移和旋转将椭圆变换到标准方程x^2/a^2 y^2/b^2 1同时对三角形的所有顶点进行相同的变换。这是计算几何中的常规操作但涉及矩阵运算容易出错。实战心得国赛出现这种题往往不期望选手写出完美解决所有情况的代码。更常见的考察点是1) 能否将问题转化为积分模型2) 能否正确实现数值积分3) 能否处理一种或几种特定的、简化后的情况如三角形底边在x轴。在考场上如果时间有限应优先保证核心算法如自适应辛普森积分的正确实现并对简单情况如三角形完全在椭圆内或底边在x轴上的直角三角形进行准确计算拿到部分分数。
返回列表