ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

蓝桥杯国赛题解:贪心与DFS在“最大数字”问题中的博弈与融合

2026/8/27 5:47:59 拓冰建站 浏览量
蓝桥杯国赛题解:贪心与DFS在“最大数字”问题中的博弈与融合 1. 项目概述从一道国赛题看“贪心”与“搜索”的博弈去年备赛蓝桥杯国赛刷到这道“最大数字”时我印象很深。题目初看有点唬人像是要你写个复杂的数字处理程序但核心其实是一场在“操作次数”限制下的“数字改造”游戏。给你一个起始数字串允许两种操作一是将某一位数字加1消耗一次操作次数二是将某一位数字减1消耗一次操作次数但都有上限——加不能超过9减不能低于0。你的目标是在总操作次数有限的前提下通过一系列加减让最终的数字串在字典序上尽可能大。字典序最大简单说就是希望数字串从左到右比较时尽可能让靠前的数字变大。比如“195”和“189”第一位都是1第二位9比8大所以“195”更大。这直接引出了我们的核心矛盾操作次数是宝贵的你是应该把资源“梭哈”在开头的高位上赌一个大的开局还是应该精打细算为后面可能出现的“瓶颈位”留有余地这道题之所以被放在国赛就是因为它完美地融合了贪心策略的直观和深度搜索的严谨你需要在这两者之间找到平衡甚至结合。今天我就结合自己AC这道题的思路把贪心和DFS深度优先搜索两种主流解法的内核、取舍以及一些容易踩的坑掰开揉碎了讲清楚。2. 题目核心逻辑与两种解题哲学解析2.1 问题重述与形式化定义我们先把题目用更严谨的语言描述一遍这是写出正确代码的第一步。输入一个字符串num表示初始的N位数字N最大可达 17即用long long都可能溢出必须用字符串处理。两个整数A和B分别表示“加1”操作和“减1”操作的最大可用次数。操作规则操作1加选择数字串中的某一位将其数字加1。如果该位数字是9则不能进行此操作。每执行一次消耗一个A。操作2减选择数字串中的某一位将其数字减1。如果该位数字是0则不能进行此操作。每执行一次消耗一个B。目标 在消耗不超过A次加操作和B次减操作的前提下通过对num的各个数位进行若干次可以是零次操作得到一个新的数字串。在所有可能的结果中找出字典序最大的那个。字典序比较对于两个长度相同的数字串S和T从左到右比较找到第一个不同的位置i。如果S[i] T[i]则S的字典序大于T。这直接决定了我们的优化方向优先保证高位数字尽可能大。2.2 贪心策略局部最优的全局冒险贪心的思想很直接既然高位更重要那我就从左到右从最高位到最低位处理每一个数字。对于当前位digit我优先考虑能否把它变得尽可能大最好是9如果不行再考虑其他选择。具体到每一步面对当前位数字d我们有几个选择理想情况使用加操作把d加到 9。这需要消耗9 - d次加操作。如果加操作次数不够那就把所有的A都加给当前位让它变成d A当然不能超过9。使用减操作等等减操作是让数字变小这怎么符合“变大”的目标这里有个关键技巧减操作不是用来直接优化当前位的而是为进位服务。比如当前位是d我可以通过减操作把它变成0然后想象前一位如果存在加了1即进位。但在字典序比较中我们是从左往右比的当前位一旦变成0即使后面进位了当前位也比原来小了除非… 除非我们是在递归搜索中考虑这种“先减后进”的路径。在纯从左到右的贪心中我们通常不会在当前位主动使用减操作因为这会立即降低当前位的值可能得不偿失。所以一个最简单的贪心算法伪代码如下初始化剩余加次数 ra A, 剩余减次数 rb B 结果字符串 res for 每一位数字 d in num: // 尝试用尽所有加操作提升当前位 need_add min(9 - d, ra) // 本次能加的次数 new_digit d need_add ra - need_add res.append(new_digit) // 最后返回 res这种策略非常快时间复杂度是 O(N)。但它有一个致命问题短视。它把所有的加操作资源都“挥霍”在了前面几位可能使得后面某一位原本只需要几次加操作就能变成9却因为资源耗尽而无法提升。例如初始数字1234A5,B0。贪心法处理第一位1-9 (用8次但A只有5所以只能加到6)结果变成6234。然而存在更好的解1294A用在第1位2次第3位3次。贪心法没有找到这个解因为它没有为后面的“3”预留资源。注意这个简单的贪心在B0即没有减操作时问题尤其明显。它证明了在操作次数有限且不能“借贷”的情况下局部最优无法保证全局最优。2.3 深度优先搜索DFS暴力枚举下的智慧剪枝既然贪心可能出错最稳妥的办法就是枚举所有可能的操作序列。但直接枚举指数级爆炸。DFS在这里扮演的角色是一种系统性的、带剪枝的枚举。我们定义DFS函数dfs(pos, ra, rb, current)pos: 当前处理到数字串的第几位从0开始。ra,rb: 剩余的加操作和减操作次数。current: 当前已构建的结果数字串或数组。搜索过程递归终点当pos num.length()说明所有位都处理完了我们用current更新全局最大答案。当前位决策对于位置pos的原始数字d我们面临几种选择不操作保留d。加操作尝试加k次 (1 k min(9-d, ra))将数字变为dk。减操作尝试减k次 (1 k min(d, rb))将数字变为d-k。每一种选择都对应一个分支我们递归调用dfs(pos1, new_ra, new_rb, current new_digit)。为什么DFS可行因为位数N 17操作次数A和B通常也不会太大题目会控制总搜索空间。但最坏情况下每一层都有10种可能数字0-9深度17这是10^17不可接受。因此剪枝至关重要。核心剪枝策略可行性剪枝如果ra 0或rb 0直接返回。最优性剪枝启发式这是加速的关键。我们可以估算从当前状态pos开始后续位理论上能达到的最大潜力。一个常用的、宽松的上界是假设后面所有位都能通过加操作变成9。那么当前已构建的字符串current加上后面(N-pos)个字符‘9’就构成了一个可能的最大字符串。如果这个“可能最大字符串”的字典序小于或等于我们当前已记录的全局最优解best那么当前分支无论怎么走都不可能超越best可以果断剪掉。potential_max current 9 * (N - pos) if potential_max best: return这个剪枝能极大地减少搜索空间。DFS方法一定能找到最优解但它的效率依赖于剪枝效果和数据规模。在蓝桥杯的比赛环境中这道题的数据通常会让设计良好的DFS在时限内通过。2.4 贪心与DFS的结合一种更优的实践纯贪心快但可能错纯DFS稳但可能慢。有没有折中方案在实践中我采用了一种贪心开局 DFS 收尾的策略效果很好。思路如下高位确定性决策对于非常靠前的高位比如前K位我们采用带前瞻的贪心。例如在处理第i位时我们不仅看把它加到9需要多少还粗略估算一下后面几位如果缺少资源会损失多大。但实现起来比较复杂。更实用的结合方案直接用DFS但在DFS决策时融入贪心思想作为搜索顺序的优化。即在每一层每个位置尝试分支时优先尝试“提升当前位最多”的选择。比如优先尝试“加到9”的分支然后尝试“加到8”以此类推最后尝试“不操作”和“减操作”。这样好的解高位大的解会更快被搜索到一旦我们找到一个很好的best后续的剪枝就会更早、更猛烈从而整体提升搜索效率。这其实是一种“优先搜索更优分支”的启发式策略在DFS框架中很容易实现往往能显著提升性能。3. 代码实现与关键细节剖析这里我给出一个经过优化和详细注释的DFS解法C实现。它包含了上述的剪枝和搜索顺序优化。3.1 数据结构与全局变量定义#include iostream #include string #include algorithm using namespace std; string num; // 初始数字字符串 int A, B; // 总操作次数 string best; // 存储当前找到的字典序最大的结果 int N; // 数字字符串的长度使用string类型存储数字方便按位访问和拼接。best初始化为空串在DFS过程中不断更新。注意空串的字典序比任何非空串都小这是一个安全的初始值。3.2 DFS函数实现这是整个算法的核心。/** * 深度优先搜索函数 * param pos 当前处理到的位置0-indexed * param ra 剩余的加操作次数 * param rb 剩余的减操作次数 * param current 当前已构建的结果字符串 */ void dfs(int pos, int ra, int rb, string current) { // 递归基所有位置处理完毕 if (pos N) { if (current best) { best current; } return; } int original_digit num[pos] - 0; // 当前位的原始数字 char original_char num[pos]; // --- 剪枝1: 计算理论上界进行最优性剪枝 --- // 构造一个“理想”字符串current 后面全部填9 string potential_max current; potential_max.append(N - pos, 9); // 添加 (N-pos) 个 9 // 如果理想情况都不如当前最优解直接返回 if (!best.empty() potential_max best) { return; // 注意字符串比较就是字典序比较 } // --- 剪枝结束 --- // 尝试当前位的各种可能数字 new_digit (从大到小尝试贪心优化搜索顺序) for (int new_digit 9; new_digit 0; --new_digit) { int diff new_digit - original_digit; int need_add 0, need_sub 0; // 计算达成 new_digit 所需的操作次数 if (diff 0) { // 需要加操作 need_add diff; need_sub 0; // 检查操作是否可行加操作次数足够且加后不超过9其实由diff保证 if (need_add ra) continue; if (original_digit need_add 9) continue; // 安全校验 } else if (diff 0) { // 需要减操作 need_add 0; need_sub -diff; // diff是负数取绝对值 // 检查操作是否可行减操作次数足够且减后不小于0 if (need_sub rb) continue; if (original_digit - need_sub 0) continue; // 安全校验 } else { // diff 0, 不需要操作 need_add 0; need_sub 0; } // 操作可行更新状态并递归 current.push_back(0 new_digit); // 选择该数字 dfs(pos 1, ra - need_add, rb - need_sub, current); current.pop_back(); // 回溯恢复状态 } }关键点解析搜索顺序for (int new_digit 9; new_digit 0; --new_digit)从9到0尝试这体现了贪心思想。优先尝试让当前位变成9这样更容易快速找到一个高位很大的best从而激活强有力的剪枝。操作消耗计算通过diff判断需要加还是减并计算具体次数。逻辑清晰避免了复杂的条件分支。可行性检查在递归前检查need_add ra和need_sub rb以及操作后数字是否在0-9范围内。这是基本的可行性剪枝。回溯current.push_back和current.pop_back()是经典的回溯法操作确保递归返回后状态恢复以便尝试下一个分支。3.3 主函数与初始化int main() { // 假设输入格式为第一行字符串num第二行两个整数A B // 例如 // 1234 // 5 0 cin num A B; N num.length(); best ; // 初始化为空比所有结果都小 string current ; dfs(0, A, B, current); cout best endl; return 0; }注意在实际比赛中输入可能没有这么简单可能需要处理多组测试数据或者数字串中间有空格。这里为了聚焦算法核心做了简化。实际编码时务必仔细阅读题目输入格式说明。3.4 关于“减操作”意义的再思考你可能疑惑在追求最大字典序时减操作似乎总是有害的并非如此。考虑这个例子num 100, A 0, B 1。没有加操作只有一次减操作。如果不使用减操作结果是100。如果对第二位0使用减操作不行0不能再减。如果对第一位1使用减操作变成000更差。正确的用法对第一位1使用减操作它变成0但这意味着我们“放弃”了这一位希望它向更高位虚拟的借位不在这个三位数里第一位就是最高位没有更高位了。所以在这个特例中减操作无用。那么减操作用在哪考虑一个更复杂的场景它与“进位”和“资源转移”的深层策略相关。假设我们有A1, B1数字是19。一种策略是对第二位9用减操作变成8消耗B省下来的操作或者配合A用来做别的但似乎对提升字典序没帮助。实际上在这道题的标准解读和测试数据中减操作的主要作用往往体现在DFS的搜索空间中作为一种“以备不时之需”的灵活手段。例如当前位是5加操作只剩1次只能变成6但减操作很多。虽然直接减当前位不好但也许存在另一条路径在之前的某一位使用了减操作从而“节省”出加操作给后面用不操作类型是独立的。A和B不能互换。因此一个更准确的理解是减操作扩大了搜索空间。在某些非常特定的、看似违反直觉的路径上它可能组合出最优解。例如初始数字50A4,B1。最优解可能是94。怎么来的一种路径第一位5-9 (用4次A)第二位0-4 (需要4次A但A已用尽)。这不行。换条路先对第一位用1次B变成4消耗B然后对第二位用4次A变成4消耗A。得到44比50差。看来也不是。这个例子想说明单纯为了用减操作而用通常不会更好。在绝大多数情况下最优解不会主动对某一位使用使其值变小的减操作除非它能通过一种复杂的连锁反应比如结合后续位的进位最终使高位变大——而在这道题不允许直接进位操作只影响本位的规则下这几乎不可能。所以许多AC代码在实现时甚至会选择不主动搜索“减操作”分支只搜索“不操作”和“加操作”分支同样能通过所有测试点。这揭示了出题人的数据可能没有考察那种需要减操作的极端情况或者说那种情况下的最优解可以通过不加不减达到。但这是一种风险严谨的DFS应该包含减操作分支以确保正确性。4. 性能优化与测试技巧4.1 剪枝策略的威力前面提到的potential_max剪枝是性能的关键。我们通过一个例子感受一下 设best 9876543210当前current 9876,pos4,N10。 那么potential_max 9876 999999 9876999999。 比较9876999999和9876543210由于第四位是’6‘对’6‘第五位是’9‘对’5‘所以potential_max best这个分支不能剪因为有可能超越当前最优解。 如果best 9876999999那么potential_max best整个分支可以剪掉因为后面就算全填9最多也只能平局而我们是求最大平局不需要更新。这节省了大量无谓的搜索。4.2 输入边界与数据处理大数处理数字串长度可达17位远超long long(约19位十进制) 的表示范围。必须用字符串或数组来处理每一位数字绝不能转换成整数。这是本题的第一个陷阱。操作次数范围A和B的范围需要关注。如果它们很大比如几百那么DFS的搜索深度虽然固定N17但每一层的选择0-9都会被认为是可行的因为资源充足到可以把任意位变成任意数。这会导致搜索树非常庞大。这时贪心算法的正确性反而得到了保证因为资源无限相对每位最多9次操作的需求最优策略就是简单粗暴地把每一位都加到9。所以对于很大的A和B可以直接输出一个由’9‘组成的字符串。这是一个有效的特判优化。if (A 9 * N B 0) { // 一个简单的上界判断 cout string(N, 9) endl; return 0; }前导零问题题目要求输出的是数字串通常允许前导零。但字典序比较中00123是小于123的因为第一位’0‘’1‘。我们的算法生成的结果可能包含前导零这是符合题目规则的。如果题目要求输出一个数值最大的整数则需要去除前导零但本题明确是字典序所以保持字符串原样即可。4.3 测试用例设计自己设计测试用例是调试和验证算法正确性的重要环节。针对此题应考虑以下几类小型随机测试写一个暴力枚举所有操作序列的程序对于非常小的N和A,B与你的DFS结果对比确保基础正确。贪心陷阱测试例如前面提到的num1234, A5, B0确保你的程序能找到1294而不是6234。边界测试N1的各种情况。A0或B0的情况。num全为’9‘或全为’0‘的情况。A和B非常大的情况测试特判优化。减操作相关测试尽管可能不影响最终AC但可以测试包含减操作的复杂案例确保算法逻辑的完备性。例如num111, A0, B3最优解应该是000使用3次减操作但字典序上111比000大所以最优解是不使用任何操作保持111。这测试了算法不会“画蛇添足”。5. 常见错误与调试心得整数溢出与类型错误在计算need_add 9 - d时如果d是char类型直接做减法会得到ASCII码的差值而不是数字差值。务必先int d num[pos] - 0。字符串比较的误区best初始化为空串。在C中string的比较运算符已经重载为字典序比较。空串是合法的初始最小值。如果初始化为0当结果也是0时可能没问题但如果结果是(理论上不会出现) 就会出错。统一用空串最安全。DFS状态回溯不完整这是回溯法的经典错误。在递归调用前后必须成对地修改和恢复状态。对于current字符串使用push_back和pop_back。如果使用全局数组则需要手动回溯该位置的值。剪枝条件写反potential_max best时剪枝。一定要清楚当“理论上界”都不如当前最优解时才剪枝。如果写成可能会剪掉那些能产生和当前最优解一样好的分支而题目要求输出最大的那个如果存在多个输出任何一个通常都可接受但严格来说剪掉等号可能丢失一些等效解不过不影响找到一个最优解。忽略操作次数为零的情况在DFS的for循环中一定要包含diff 0即new_digit original_digit的情况这代表“不进行操作”这是一个非常重要的选择。递归深度与栈溢出N最大为17递归深度最大也为17这在任何平台的栈空间下都是安全的无需担心。输出格式务必输出字符串本身不要转换成整数输出否则前导零会丢失也可能导致大数溢出。调试心得当你的程序对样例通过但提交错误时首先尝试自己构造一些小的、手算能知道答案的测试用例。特别是针对贪心算法会出错的用例。然后在DFS函数入口打印状态如pos, ra, rb, current观察搜索路径。看看它是否探索了你认为应该探索的分支又在何时被剪枝。对于“最大数字”这类问题一个有效的调试方法是先写一个不加任何剪枝的暴力DFS确保它能对小数据得到正确结果以此作为“标程”然后再逐步给你的优化DFS加上剪枝并对比两者的结果是否一致。这道“最大数字”题从表面看是字符串处理内核却是搜索与贪心的策略选择。它考察的不仅仅是对DFS和回溯的编码能力更是对问题策略的分析和优化能力。在比赛时如果时间紧张实现一个带简单剪枝的DFS是相对稳妥的选择。如果时间充裕可以先尝试贪心用几组极端数据验证其正确性如果不放心再上DFS。毕竟一个正确但稍慢的算法远比一个快速但错误的算法得分高。