蓝桥杯国赛B组题解:贪心、动态规划与图论实战复盘 📅 发布时间:2026/8/28 18:13:17 👁 浏览次数: 1. 赛题回顾与整体策略复盘第十三届蓝桥杯国赛B组的题目可以说是对选手算法功底、思维缜密度和临场调试能力的一次全面检验。比赛结束已经有一段时间但那些在赛场上与时间赛跑、与逻辑搏斗的场景依然历历在目。这份题解与其说是标准答案的罗列不如说是我个人解题思路的一次完整复盘其中包含了当时的选择、犹豫、踩过的坑以及最终突围的路径。希望能给后来者尤其是准备冲击国赛奖项的同学们提供一个真实的、可供参考的视角。国赛的题目风格向来以“思维深度”和“代码实现细节”著称B组题目更是如此。它不会用极其复杂的算法模板来吓唬你但往往在题目描述中埋下关键约束在数据范围上暗示算法复杂度在样例中隐藏边界情况。很多题目一眼看去似乎有思路但真正动手实现时才会发现各种“陷阱”。因此我的整体策略是“先通览定优先级保正确再优化细读题防陷阱”。具体来说就是先用10-15分钟快速浏览所有题目对难度和类型有个大致判断优先解决描述清晰、思路明确的“签到题”和“套路题”建立信心并确保基础分。对于需要深思的题目先写出保证正确性的朴素解法哪怕是暴力搜索确保能拿到部分分再思考如何优化到满分。最关键的一点是题目中的每一个字、每一个数据范围、每一个样例解释都可能包含重要信息必须逐字逐句理解。接下来我将挑选本届国赛B组中几道具有代表性、易错或思维难度较高的题目进行详细的拆解。我不会简单地贴出AC代码而是会重点还原**“我当时是怎么想的”、“遇到了什么问题”以及“为什么最终采用这个方案”**。我们主要讨论的题型会涉及动态规划、贪心思维、模拟与搜索以及一些需要特别注意的数学技巧。2. “最优清零方案”中的贪心抉择与边界处理这道题是典型的“贪心模拟”题题目大意是给定一个正整数数组和一个操作次数k每次操作可以选择一个大于0的连续子数组将其每个元素减1。问在k次操作内最多能将多少个元素变为0。很多同学的第一反应可能是这简单每次都选整个数组减1直到k次用完或数组全为零。但仔细一想就发现不对如果数组是[100, 1]k5按这种策略5次操作后数组变为[95, 0]只有1个零。但如果我们只对第二个元素操作1次就能让它变零剩下4次操作对第一个元素毫无帮助最终结果还是1个零。这显然不是最优的。2.1 核心贪心策略的推导正确的贪心策略是优先让那些值小的元素变成0。因为操作是针对连续子数组的减少一个小的数可能会“顺带”减少旁边较大的数但反之则不行。为了让尽可能多的数变零我们应该集中火力“消灭”当前数组中最小的正数。基于这个思想一个直接的实现方案是将数组元素及其索引放入最小堆优先队列。每次从堆顶取出当前最小值min_val及其索引idx。如果min_val为0说明它已经被清零跳过。否则我们需要进行min_val次操作才能将nums[idx]减到0。但是我们总共只有k次操作。因此实际可以执行的操作次数ops min(min_val, k)。执行这ops次操作这意味着我们需要选择一个包含idx的连续子数组将其每个元素减去ops。为了最大化收益这个子数组应该尽可能长吗不对为了不影响其他可能更小的数它们可能在未来被清零我们应该选择以idx为中心向左右扩展到值不小于nums[idx]的边界。但这样实现复杂且可能不是最优。这里是一个关键的思维转换点我们不必真的模拟对连续子数组的减法操作。因为目标是计数“有多少个数变成了0”我们只需要关心每个数独自被减了多少。而“对连续子数组减1”这个操作可以理解为你拥有k次“减1”的机会每次可以分配给任意一个元素但分配时必须同时给该元素所在某个连续区间内的所有元素都分配一次。 这等价于每个元素nums[i]最终被减去的次数dec[i]必须满足存在一种划分使得dec数组是由若干个“平台”值相同的连续段构成的且每个“平台”的高度即减去的次数不超过该平台内原始元素的最小值否则该最小值会变成负数。2.2 一种清晰的实现思路排序后差分计数我们可以换一种角度思考。假设最终有x个数变成了0。那么这x个数一定是原始数组中最小的x个数排序后。为什么因为如果你让一个较大的数变成了0而一个较小的数却没有那你完全可以把用于清零大数的操作用来清零那个更小的数从而可能让更多的数变成0因为清零小数需要的操作次数更少。所以算法可以这样设计将数组nums升序排序。设我们已经让前i个数nums[0]到nums[i-1]变成了0现在考虑让第i个数nums[i]注意是排序后的也变成0。让nums[i]变成0需要多少额外的操作次数不是nums[i]次因为前面的i个数在清零过程中可能已经“顺带”帮nums[i]减掉了一些值。具体来说在排序数组中为了让前j个数清零我们至少需要进行nums[j-1]次全局性的“减1”操作因为每次操作至少覆盖第j-1个数。实际上让前i个数清零所需的最小操作次数等于nums[i-1]排序后吗让我们仔细计算。更精确的模型是考虑排序后的数组a[0] a[1] ... a[n-1]。为了将a[0]清零我们需要进行a[0]次操作。这些操作每次都会覆盖a[0]及之后的所有元素。此时a[1]的新值为a[1] - a[0]。为了再将a[1]清零我们需要额外的(a[1] - a[0])次操作这些操作会覆盖a[1]及之后的元素。同理将a[2]清零需要额外的(a[2] - a[1])次操作...因此让前m个数即a[0]到a[m-1]全部清零所需的最小操作次数为total_ops(m) a[0] (a[1]-a[0]) (a[2]-a[1]) ... (a[m-1]-a[m-2]) a[m-1]这个结果非常简洁让排序后前m个数清零的最小操作次数就是第m小的数本身a[m-1]。这是因为我们的操作是连续区间的减法可以等价为从最小值开始“一层一层”地剥掉整个数组的“外壳”。因此我们只需要找到最大的m使得a[m-1] k即可。答案就是m。 注意边界m可以从1到n。我们遍历排序后的数组找到第一个下标i0-indexed使得a[i] k那么i就是最大能清零的数量。因为a[i]是让前i1个数清零所需的最小操作次数如果它大于k我们就无法清零前i1个数。2.3 代码实现与踩坑点def max_zero_count(nums, k): nums.sort() for i, val in enumerate(nums): if val k: return i # 前i个数可以清零 # 如果val k说明我们可以清零它继续看下一个 # 注意我们不需要显式地扣除k因为判断条件valk已经隐含了操作次数是否足够。 # 这里的k是总操作次数而val是清零前i1个数所需的总次数。 # 如果循环结束都没有找到val k说明所有数都可以清零 return len(nums)踩坑点误解操作对象最易错的点是认为每次操作是任选一个数减1而不是连续子数组。这会导致贪心策略完全错误。忽略排序的必然性必须想通“清零的必是最小的若干个”这一关键结论否则会陷入复杂的区间选择模拟。复杂度担忧有同学可能会想排序是不是O(nlogn)的有没有O(n)的线性算法在国赛环境下n通常不超过10^5或10^6O(nlogn)完全可接受。关键是先做出正确的解法。边界条件当k非常大足以清零所有数时循环会遍历完整个数组最后返回len(nums)这是正确的。这道题很好地考察了将复杂操作转化为数学模型的能力。它提醒我们面对操作规则抽象的题目不妨思考其等价形式或最终效果往往能发现更简洁的规律。3. “技能升级”的动态规划状态设计与优化这道题是经典的“资源分配”型动态规划问题带有一些变种。题目通常描述为有n个技能每个技能有初始等级L_i升级到下一级需要花费C_i点资源但升级后所有技能或特定技能会获得收益B_i。你拥有总资源M问如何分配资源升级技能使得总收益最大。3.1 暴力搜索与DP的初步思考最直观的想法是搜索每个技能可以升级0次、1次、2次...直到资源不够。但资源M和升级花费C_i可能很大搜索空间是指数级的不可行。既然每个技能可以升级多次这很像完全背包问题资源M是背包容量每个技能升级一次是一个“物品”花费为C_i价值为B_i且每个物品可以无限次选取。但这里有一个关键不同收益B_i可能不是恒定的。题目中常说“每次升级获得的收益可能不同”或者“收益取决于该技能的当前等级”。这就变成了“依赖当前状态的完全背包”。3.2 状态定义的陷阱与突破如果收益B_i是固定的那么状态定义很简单dp[j]表示使用恰好j点资源能获得的最大收益。转移方程为dp[j] max(dp[j], dp[j - C_i] B_i)for all i, j C_i。但如果收益依赖于该技能已升级的次数即当前等级我们就必须知道每个技能具体升级了多少次。这迫使我们将状态扩展到二维甚至更高维dp[i][j]表示考虑前i个技能使用恰好j点资源时所能获得的最大收益。但这样我们仍然不知道每个技能的具体升级次数无法计算依赖等级的收益。一个常见的错误状态设计是dp[i][j][k]表示考虑前i个技能使用j资源且第i个技能升级了k次的最大收益。这个状态维度过高i * j * k无法承受。正确的思路是改变“物品”的定义。我们不再把“升级一次技能i”当作一个物品而是把**“将技能i从初始等级升级到目标等级t”** 当作一个物品。这样对于技能i我们预先计算出升级到等级1, 2, 3, ... , t_max所需的总花费total_cost[i][t]和获得的总收益total_gain[i][t]。这里t_max受资源M和每次升级花费C_i的限制。那么问题就转化为了一个分组背包问题有n个组技能每个组里有多个物品升级到不同等级t的方案每个物品有花费总资源和价值总收益。每组最多只能选择一个物品因为一个技能只能有一种最终的升级结果。我们要求在总资源不超过M的情况下选出物品使得总收益最大。3.3 状态转移与实现细节设cost[i][t]为将技能i升级t次从初始等级升到等级L_i t所需的总花费。 设gain[i][t]为将技能i升级t次获得的总收益。 其中 t 的范围是 0 到 T_iT_i 是满足cost[i][t] M的最大t因为资源有限。状态定义dp[i][j]表示考虑前i个技能恰好使用j点资源时能获得的最大收益。初始化dp[0][0]0其余为负无穷因为“恰好使用”需要精确初始化。状态转移对于每个技能i (1 to n): 对于每个可能的资源j (M down to 0): // 注意这里是01背包的倒序循环因为每组内物品互斥 对于技能i的每个升级方案t (0 to T_i): 如果 j cost[i][t]: dp[i][j] max(dp[i][j], dp[i-1][j - cost[i][t]] gain[i][t])最终答案是max(dp[n][j])for j in 0..M。3.4 空间优化与预处理技巧由于状态转移只依赖于前一层i-1我们可以用滚动数组优化空间到一维dp[M1]。但注意因为每组内是01背包只能选一个方案所以资源j的循环必须是从大到小以确保每个技能只被选择一次一个升级方案。预处理cost[i][t]和gain[i][t]时需要根据题目给出的升级规则计算。例如如果每次升级花费固定为C_i收益为B_i * (当前等级)那么cost[i][t] t * C_i gain[i][t] B_i * (L_i (L_i 1) ... (L_i t - 1)) B_i * (t * L_i t*(t-1)/2)这是一个等差数列求和。务必仔细推导公式这是容易出错的地方。3.5 一个实战中的教训数据范围与整数溢出在国赛环境中资源M、收益B_i、等级L_i都可能很大10^9量级。在计算gain[i][t]时中间结果t * L_i和t*(t-1)/2都可能超过32位整数范围。必须使用64位长整型C中的long long, Python中的int进行计算和存储DP值。我在初次提交时就因为用了int导致WAWrong Answer调试了很久才意识到是溢出问题。这是一个非常经典的坑点尤其是在涉及乘积和求和的问题中。此外如果M很大比如10^5而单个技能升级花费C_i很小比如1那么T_i可能会很大10^5导致内层循环t的迭代次数很多可能达到O(n * M * avg_T)在最坏情况下会超时。这时需要进一步优化例如注意到cost[i][t]和gain[i][t]通常是凸函数收益递增速度可能放缓可以使用单调队列优化完全背包或者利用贪心性质。但在本届国赛B组的数据范围内通常分组背包的O(n * M * avg_T)复杂度经过精心实现是可以接受的avg_T不会太大。4. “解密游戏”的模拟与哈希映射技巧这是一道模拟题但模拟的规则可能比较复杂通常涉及字符串变换、状态转移、寻找循环节或者最短步骤。题目可能给出一个加密规则要求你将一个初始字符串通过一系列操作转换为目标字符串或者找到多少种初始字符串经过若干轮加密后能得到给定的结果。4.1 问题抽象与核心挑战这类问题的核心挑战在于直接模拟可能时间爆炸。如果操作步数非常多比如10^9步我们不可能一步步模拟。此时必须寻找规律比如操作是否具有周期性循环节或者操作是否可以用某种数学变换如置换的幂来快速计算。首先我们需要将操作抽象化。最常见的操作是“替换”和“置换”。替换根据当前字符串的某些特征按照规则生成新字符串。例如每个字符变成其在字母表中的后一位‘a’-‘b’, ‘z’-‘a’。置换一个更特殊的替换可以看作是一个映射函数f(c)将字符c映射到另一个字符。整个字符串的变换就是每个字符独立应用f。如果f是双射一一对应那么这就是一个置换。多个置换的复合仍然是置换。4.2 利用置换群理论加速模拟如果操作是置换那么问题就简化了。一个长度为n的置换其幂运算连续应用多次有很强的规律性。每个字符在置换的多次作用下会形成一个循环。例如映射规则是a-b, b-c, c-a。那么字符a、b、c形成了一个长度为3的循环。关键技巧寻找循环节并利用模运算。找出每个字符所在的循环从字符c开始不断应用置换f直到回到c记录经过的字符序列这就是一个循环。计算快速幂题目要求应用置换k次即求f^k(c)。我们只需要找到c所在循环的长度L那么f^k(c) f^(k mod L)(c)。因为应用L次后字符回到原位。对整个字符串操作对字符串中的每个字符分别计算f^k(char)然后组合成新字符串即可。时间复杂度从O(k*n)降为O(n * L_max)其中L_max是最大循环长度通常很小不超过字符集大小比如26。4.3 当操作不是简单置换时如果操作规则更复杂比如当前字符的变换依赖于相邻字符或者整个字符串作为一个整体进行重排如反转、循环移位那么情况就复杂了。此时我们需要将整个字符串的状态视为一个节点操作视为状态之间的边问题就变成了在图状态空间中寻找路径。对于这种问题常见的解题框架是状态表示与哈希将字符串编码成一个可以快速比较和哈希的值如整数哈希、元组。这是模拟搜索的基础。BFS/DFS搜索如果步数不多求最短步数可以用BFS求所有可能路径可以用DFS。处理巨大步数k如果k很大状态空间也可能很大比如字符串长度有10位每位有26种可能状态总数是26^10无法遍历。此时必须挖掘题目中的特殊性质寻找循环节从初始状态开始模拟记录每一步的状态。一旦某个状态重复出现就找到了循环节。假设从第i步开始进入循环循环节长度为L。那么第k步的状态就等于第i ((k - i) mod L)步的状态。这样我们只需要模拟到发现循环节为止步数最多为状态总数通常可接受。利用数学性质有些操作如“反转”是二阶的做两次回到原状“循环左移n位”的周期与字符串长度有关。可以分析出操作的复合结果从而直接计算出k次操作后的状态。4.4 实战案例分析与调试心得我曾遇到一道题规则是给定一个数字字符串每次操作将每个数字d替换为(d * 2) mod 10。问操作k次后的字符串。这看起来像是每个数字独立变换映射关系是0-0, 1-2, 2-4, 3-6, 4-8, 5-0, 6-2, 7-4, 8-6, 9-8。这不是一个置换因为0,2,4,6,8都有多个原像但每个数字的变换仍然是独立的且只依赖于自身。对于这种“独立同分布”的变换我们依然可以预处理每个数字在操作k次后的结果。由于模10运算每个数字的变换序列必然会出现循环。我们可以对0-9每个数字模拟它多次变换找到循环节。例如数字1的变换序列1-2-4-8-6-2-... 从第二步开始进入循环2-4-8-6长度为4。那么对于操作次数k如果k0就是1本身如果k1是2如果k2结果就是循环序列中第((k-2) mod 4)个元素从2开始算。在代码实现时我建议单独写一个函数char transform(char c, long long k)它根据预处理的循环节信息返回字符c经过k次操作后的结果。主函数只需要遍历字符串对每个字符调用此函数即可。这样逻辑清晰不易出错。调试这类题的关键从小数据开始用手算或写一个暴力模拟程序验证你的快速算法在小k比如k100时是否正确。注意k0的情况这是一个常见的边界条件务必检查。注意循环节的起点循环不一定从初始状态开始。可能有一段“尾巴”才进入循环。在记录循环节时要用字典map记录每个状态第一次出现的步数当状态第二次出现时循环起点就是第一次出现的步数。大整数处理k可能非常大10^18所以记录步数、做模运算都要用64位整数。5. “最优布线”问题中的并查集与克鲁斯卡尔算法变体这是一道图论题通常描述为有n个节点给出连接某些节点对的花费或者距离要求以最小的总花费连接所有节点即构建一棵生成树但可能有一些额外的约束条件比如某些节点必须直接连接或者连接的总边数有限制。5.1 经典模型与算法选择如果没有额外约束这就是经典的最小生成树MST问题。对于稠密图可以用Prim算法对于边数不是特别多的稀疏图通常用Kruskal算法更直观易写。Kruskal算法的核心是将所有边按权值从小到大排序然后依次考虑每条边如果这条边的两个端点目前不在同一个连通分量中就加入这条边合并两个分量直到所有点连通或已加入n-1条边。在国赛环境中我强烈推荐使用Kruskal算法因为它思路简单代码模板化程度高不易写错。实现的关键是并查集Disjoint Set Union, DSU。5.2 并查集模板的编写与优化并查集需要实现find查找根节点和union合并两个集合操作。一个高效的模板如下使用路径压缩和按秩合并class DSU: def __init__(self, n): self.parent list(range(n)) self.rank [0] * n # 秩用于按秩合并 def find(self, x): # 路径压缩 if self.parent[x] ! x: self.parent[x] self.find(self.parent[x]) return self.parent[x] def union(self, x, y): rx, ry self.find(x), self.find(y) if rx ry: return False # 已经在同一集合无需合并 # 按秩合并 if self.rank[rx] self.rank[ry]: self.parent[rx] ry elif self.rank[rx] self.rank[ry]: self.parent[ry] rx else: self.parent[ry] rx self.rank[rx] 1 return True # 成功合并5.3 处理额外约束必选边与限定额外边题目常见的变体是有必选边某些边必须被包含在最终的生成树中。限定额外边数量在必选边的基础上最多只能再选k条边求最小总花费。策略对于必选边在运行Kruskal算法之前先将这些边加入并查集执行union操作并将它们的权值计入总花费。同时记录已经连接的边数cnt。对于限定额外边数量假设必选边有m条那么我们还剩下n-1-m条边需要从剩余的边中选取。但题目可能限制最多再选k条k可能小于n-1-m。这意味着最终的生成树可能不是连接所有点的树而是一个森林并且这个森林由(n - (m k))个连通块组成因为总边数 m k。算法调整预处理必选边合并端点更新总花费和已用边数used m。将剩余的所有边非必选边按权值从小到大排序。遍历排序后的边如果边的两端点已连通跳过。否则加入这条边union总花费加上边权used 1。如果used m k达到了最大允许边数此时不一定所有点都连通。算法应该继续吗不我们应该停止。因为继续加边会超过限制。但此时可能还有多个连通块题目要求可能就是在边数限制下使得所有连通块的总边权最小。这实际上是在构建一个“最小生成森林”其中森林的连通块数量是固定的n - (mk)。因此循环终止条件有两个一是used n - 1所有点连通形成一棵树二是used m k达到了最大边数限制形成森林。满足任一条件即可退出。循环结束后检查是否满足题目的连通性要求如果题目要求必须全部连通则需要used n-1如果允许森林则当前状态即答案。5.4 边界情况与调试要点图不连通时的处理如果即使加入所有可选边used也无法达到n-1说明图本身不连通。此时如果题目要求必须连通则无解如果允许森林则当前结果就是最小生成森林。边权相等时的处理Kruskal算法在边权相等时不同的排序顺序可能产生不同的生成树但总权值相同。如果题目有特殊要求如字典序最小等需要在排序时增加第二关键字。数据范围与数据类型总花费可能很大需要用64位整数。节点编号通常从0或1开始注意并查集初始化的大小。必选边可能形成环题目给出的必选边集合本身可能包含环。这在逻辑上应该是无效输入但有时需要代码鲁棒性。可以在加入必选边时检查如果union返回False即两端点已连通说明这条必选边会形成环根据题意可能直接判定无解。在我实际解题时曾因为忽略了“达到最大边数即可停止”这个条件而是一直循环到所有边遍历完导致在允许森林的情况下多加了边总花费不是最小。这个教训提醒我对于变形的最小生成树问题一定要重新审视算法的终止条件它可能不再是“边数等于n-1”。6. 考场策略与时间管理心得最后结合本届比赛的个人体验分享几点关于应试策略的心得这或许比解出某一道题更重要。6.1 读题与规划阶段开赛前30分钟通读所有题目不要立刻扎进第一道题。花10-15分钟快速浏览所有题目对每道题的题型、难度、数据范围有个初步判断。用笔简单标记哪些是一眼有思路的“签到题”哪些是熟悉的经典模型题哪些是看起来需要长时间思考的“难题”。制定答题顺序优先解决“签到题”和思路清晰的题。这能快速得分建立信心并缓解紧张情绪。将最难的、需要大量推导的题放在最后。仔细阅读输入输出格式和样例特别是样例解释它往往揭示了题目的核心逻辑和边界情况。有时候题目描述可能有点绕但样例一看就懂。6.2 编码与调试阶段比赛中期先写暴力再优化对于不确定正确性的算法或者一时想不到最优解的题先写一个保证正确性的朴素解法比如暴力搜索、简单模拟。这能确保拿到基础分部分分并且这个暴力程序可以作为后续优化算法的对拍器。模块化编程将常用的功能封装成函数如并查集、快速幂、读入优化等。这不仅能减少重复代码降低出错率也能让主逻辑更清晰。每写一段简单测试不要等全部写完再测试。写完一个功能模块比如读入、核心算法、输出就用样例或自己构造的小数据测试一下。这样能尽早发现逻辑错误。善用打印调试在怀疑的地方打印中间变量如循环索引、关键状态值。国赛环境通常允许标准输出调试完后记得注释掉或删除调试输出。6.3 应对卡题与检查阶段最后1小时一道题卡住超过30分钟如果毫无头绪果断放弃去检查其他已完成的题目或者尝试其他未做的题。死磕一道题是时间管理的大忌。可能在你做其他题的时候会对卡住的题产生新的灵感。最后务必留出检查时间至少留出20-30分钟进行整体检查。重新阅读题目确保没有理解错题意特别是“不大于”、“至少”、“恰好”这些关键词。检查边界条件数组大小是否足够索引是否从0开始整数运算会溢出吗输入数据范围的最小值、最大值、为零的情况是否考虑对拍如果时间允许用之前写的暴力程序或者思路不同的另一个程序对拍随机生成的数据。这是发现隐蔽错误的最有效手段。检查输入输出特别是多组数据输入时是否正确地初始化了所有全局变量输出格式是否完全符合要求空格、换行6.4 心态调整国赛压力大题目难出现“崩盘”感是正常的。我当时的策略是不追求AK所有题满分而是确保每道题都拿到力所能及的分数。即使一道题不会最优解也要写出能拿部分分的代码。一道题的部分分可能是30分、50分多道题的部分分累加起来往往比死磕一道题拿满分更有性价比。记住稳定的发挥比灵光一现更重要。把该拿的分都拿到你就已经战胜了很多人。