尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

蓝桥杯最大数字题解:贪心与DFS剪枝优化策略

蓝桥杯最大数字题解:贪心与DFS剪枝优化策略 1. 问题引入当“最大数字”遇上“操作限制”最近在整理蓝桥杯国赛的历年真题时一道名为“最大数字”的题目引起了我的注意。这道题乍一看似乎就是一道简单的贪心或搜索题但仔细琢磨它的操作限制后你会发现它远没有想象中那么简单。它不像那些直接让你排序或构造的题目而是给你一个初始数字串和两种“魔法”般的操作让你在有限的“法力值”内将这个数字串变得尽可能大。这让我想起了很多实际场景比如资源调度、网络优化甚至是游戏里的策略选择——你手头有有限的资源操作次数面对一个复杂的系统数字串目标是在约束下实现全局最优数值最大。这种“带约束的优化”问题是算法竞赛中的常客也是实际工程中决策逻辑的核心。题目通常是这样描述的给定一个由数字0-9组成的字符串代表一个很大的数字以及两种操作操作A将字符串中任意一位数字加1。如果该位是9则加1后变成0可以理解为十进制下的循环加1。操作B将字符串中任意一位数字减1。如果该位是0则减1后变成9同理循环减1。同时你拥有一个总操作次数上限M通常M的值会远小于字符串长度以及两种操作各自的使用次数限制A和BA B M。你的目标就是使用不超过M次操作且A、B操作分别不超过其限制使得最终得到的数字字符串所表示的数值最大。举个例子初始字符串是“123”A1,B1,M2。我们的一种策略是对第二位‘2’使用操作A变成‘3’得到“133”再对第三位‘3’使用操作B变成‘2’得到“132”。显然“133” “132”但这是最优解吗我们还可以考虑对第一位‘1’使用操作A变成‘2’得到“223”这比“133”更大。看简单的选择背后立刻出现了分支。问题的核心矛盾在于操作是局部的每次只影响一位但目标是全局的整个字符串的数值大小。我们既希望把高位的数字变得尽可能大因为高位权重高又受到操作次数的严格限制。操作A和B的“循环”特性910, 0-19更是增加了复杂性因为它意味着“变大”不一定只能用加“变小”有时也能为后续操作创造机会比如把某位从0减到9看似变小但如果能因此让更高位变大可能就是值得的。这就像下棋不能只看一步的得失。2. 暴力搜索与可行性分析为什么不能“硬来”面对这类问题很多人的第一反应是暴力搜索。毕竟字符串长度N和操作次数M通常不会太大国赛真题中N可能在10到50之间M在20到100之间。我们枚举每一位是否进行操作、进行哪种操作不就行了吗我们来算一笔账。对于长度为N的字符串每一位有3种状态不操作、执行操作A、执行操作B。那么粗略的搜索空间是3^N。当N15时3^15约等于1400万尚可接受但当N30时这个数字是惊人的2050亿完全不可行。这还只是状态枚举没有考虑操作次数A和B的限制。如果加上限制我们需要在搜索过程中记录已用的A和B次数状态空间会进一步膨胀。因此纯粹的、无剪枝的深度优先搜索DFS或广度优先搜索BFS对于稍大的N就会超时。我们必须寻找更优的策略。但这并不意味着搜索完全不可用。搜索特别是DFS依然是解决这道题的重要基石关键在于如何“聪明地”搜索即进行强有力的剪枝和状态定义将指数爆炸的规模降下来。一种常见的优化思路是记忆化搜索Memoization或动态规划DP。我们定义状态dp[pos][usedA][usedB]表示当前处理到第pos位从高位到低位已经使用了usedA次操作A和usedB次操作B时从第pos位开始到末尾所能构成的最大数字串后缀。这里“后缀”是一个字符串比较大小需要字符串比较。这个状态的想法是我们从高位向低位决策当前位的选择会影响剩余操作次数而后续低位的最优解可以被重复利用。然而这个DP状态也存在问题。首先状态数量是N * (A1) * (B1)对于N50, A50, B50的情况状态数达到12.5万看似不多。但每个状态存储的是一个可能很长的字符串最长达N-pos位状态转移时需要字符串拼接和比较开销很大。更重要的是字符串的比较和存储会消耗大量内存和时间在竞赛的严格时空限制下可能仍然危险。所以我们需要更精巧的思路。观察发现为了最大化整个数字一个核心原则是优先保证高位数字尽可能大。因为只要高位数字大了低位哪怕全是0也比高位小但低位大的数字要大例如“9000” “1999”。这启示我们可以采用一种贪心与搜索结合的方法从最高位最左端开始逐位确定当前位所能达到的最大值同时考虑为此消耗的操作次数是否“划算”。3. 核心策略贪心框架下的深度优先搜索综合以上分析一个行之有效的策略是采用基于贪心思想的深度优先搜索DFS。这个算法的骨架如下DFS函数设计我们编写一个递归函数dfs(pos, remainA, remainB)其中pos是当前要处理的字符索引从0开始remainA和remainB是剩余可用的操作A和操作B的次数。搜索终点当pos等于字符串长度N时说明所有位都已处理完毕我们得到了一个候选答案。用这个候选答案更新全局最大值。当前位决策对于当前位置pos的数字currentDigit我们枚举几种可能的“目标数字”targetDigit。我们的目标是让这一位变成targetDigit。targetDigit可以等于currentDigit不操作。通过执行k次操作A可以达到targetDigit注意循环(currentDigit k) % 10。通过执行k次操作B可以达到targetDigit注意循环(currentDigit - k 10) % 10。这里的关键是对于操作A和B由于循环特性达到同一个targetDigit可能有两种路径例如从1到9可以加8次也可以减2次1-0-9。我们需要枚举所有可能的k值0到9计算两种操作方式所需的次数并确保不超过剩余次数。剪枝关键这是算法效率的核心。我们不能无脑枚举所有targetDigit和所有k。必须进行剪枝。贪心剪枝既然要最大化最终数字我们优先尝试让当前位变成最大的数字9。如果通过某种操作组合能在剩余次数内将当前位变成9那么我们几乎可以立即决定选择这个方案并进入下一位的搜索。为什么是“几乎”因为可能存在一种情况把当前位变成9消耗了太多操作导致后面某一位非常重要的高位虽然是相对低位但如果后面几位都是9而当前位用很多操作才到9可能不如当前位到8留出操作给后面变成99更优无法变得更大。但对于大多数情况尤其是高位变成9是最优的。我们可以将其作为一个强剪枝如果当前位能变成9我们只搜索变成9的消耗操作最少的方式暂时忽略变成8、7等的可能性。如果变成9不可行我们再尝试8依此类推。可行性剪枝在枚举targetDigit时如果发现无论用A还是B所需的最小操作次数都已经大于remainA remainB那么对于更小的targetDigit因为从当前位往下搜索我们尝试的数字是从9递减所需的操作次数只会更多因为你需要反向操作所以可以直接剪掉整个分支。最优性剪枝如果当前已经构造出的前缀前pos位比当前记录的最大答案的相应前缀要小那么即使后面全变成9最终结果也不可能超过最大答案可以剪枝。这需要我们在搜索过程中维护当前已确定的前缀。状态记忆化可选但有效尽管直接记忆化字符串结果开销大但我们可以记忆化一个布尔值或整数状态表示(pos, remainA, remainB)这个状态是否已经被搜索过并且其“后续最大可能后缀”是否已经计算过。如果搜索过且当前构造的前缀并不比之前搜索时更好或一样则可以剪枝。实现这一点需要巧妙的状态设计和比较通常使用记忆化搜索配合字符串哈希来简化比较。下面是一个简化版的算法步骤描述忽略了部分边界检查和优化细节但体现了核心思想全局变量best_answer “” (初始为空或比任何可能结果都小的字符串) function dfs(pos, remainA, remainB, current_prefix): if pos N: if current_prefix best_answer: best_answer current_prefix return current_digit int(str[pos]) # 从大到小枚举目标数字 for target in range(9, current_digit-1, -1): # 注意目标至少是原数字 # 计算通过操作A达到target所需次数正向 costA_forward (target - current_digit 10) % 10 # 计算通过操作B达到target所需次数反向因为B是减要等价于加某个数需要换算 # 通过操作B达到target意味着 current_digit - k target (mod 10) # 即 k (current_digit - target 10) % 10 costB_forward (current_digit - target 10) % 10 # 枚举两种操作方式 # 方式1使用操作A if costA_forward remainA: dfs(pos1, remainA - costA_forward, remainB, current_prefix str(target)) # 方式2使用操作B (注意这里costB_forward是使用B操作的次数) if costB_forward remainB: dfs(pos1, remainA, remainB - costB_forward, current_prefix str(target)) # 注意还有可能同时使用A和B吗题目中每次操作只针对一位进行一种操作不能混合。所以一位只能选择A或B中的一种进行操作若干次。注意上面的伪代码是一个基础框架它枚举了所有可能的目标和操作方式但缺乏前面提到的强力剪枝。在实际实现中必须加入贪心剪枝优先尝试9如果成功则大幅减少分支、可行性剪枝和前缀比较剪枝否则对于稍大的N和M依然会超时。4. 实现细节与踩坑点在将上述策略转化为代码时有几个细节至关重要也是容易出错的地方4.1 操作次数的计算与循环处理这是最容易出错的点。操作A是加1操作B是减1且都是循环的。假设当前位数字是d目标数字是t。通过操作A达到t需要进行的操作次数是(t - d 10) % 10。10是为了保证结果非负%10是因为循环。例如d9, t0(0-910)%10 1意思是加1次9-0。通过操作B达到t需要进行的操作次数是(d - t 10) % 10。例如d0, t9(0-910)%10 1意思是减1次0-9。一定要自己多测试几组边界情况(d0, t9),(d9, t0),(d5, t5)。4.2 搜索顺序与剪枝的优先级DFS的搜索顺序对效率影响巨大。我们必须采用从高位到低位的顺序。因为高位决定性强先确定高位有利于后续剪枝。在每一位内部枚举目标数字时要从大到小枚举9, 8, 7, ...。这样一旦我们找到一个可行的、能变成较大数字的方案就可以利用贪心思想进行剪枝。一个常见的强力剪枝是# 在dfs函数内对当前位处理时 for target in range(9, -1, -1): # 计算costA, costB... found False # 如果使用操作A能达成target且次数足够 if costA remainA: dfs(...) # 进入下一层搜索 found True # 如果使用操作B能达成target且次数足够 if costB remainB: dfs(...) found True # 贪心剪枝如果这一位我们成功将其变成了target并且target是当前枚举中最大的可行数字 # 那么对于当前位我们就不需要再尝试更小的target了。 # 但注意这里不能直接break因为“变成9用A操作”和“变成9用B操作”消耗次数不同可能影响后续。 # 一个更安全的做法是记录下变成当前最大target所需的最小操作次数min(costA, costB) # 然后只搜索那些操作次数 这个最小次数的路径不这也不完全对。 # 更实用的方法是不进行这种“找到就停”的剪枝而是依赖“前缀比较”和“可行性”剪枝。 # 但可以在找到能让当前位变成9的方案后给后续搜索一个“提示”或者优先搜索这些分支。实际上更通用的做法是不简单 break而是依靠最优性剪枝。我们维护一个全局最佳答案best。在DFS过程中我们携带当前已构造的前缀current。如果current的长度等于pos即正在处理第pos位那么我们可以比较current和best的前pos位如果current的前pos位已经小于best的前pos位那么即使后面全填9最终结果也不会超过best可以剪枝。如果current的前pos位大于best的前pos位那么继续搜索。如果相等则继续搜索。这个剪枝效果非常显著。4.3 记忆化搜索的键值设计为了避免重复搜索相同状态我们可以使用记忆化。状态是(pos, remainA, remainB)。但是记忆化存储什么如果存储从该状态出发能得到的最佳“后缀”字符串那么比较和存储成本高。一个巧妙的做法是记忆化存储一个布尔值表示该状态是否“可达”或者是否“已经搜索过且无法更新最优解”。但结合最优性剪枝记忆化设计会变得复杂。通常在这道题中由于搜索树在强力剪枝下已经较小许多AC代码选择不使用记忆化而是依靠精细的剪枝。如果要用可以尝试记忆化(pos, remainA, remainB)并存储从该状态开始后续能得到的最大后缀的“哈希值”或某种可快速比较的表示但这增加了实现难度。4.4 大整数与字符串比较最终结果可能是一个很长的数字字符串远超普通整型如64位的表示范围。因此我们必须始终使用字符串来存储和比较数字。在比较两个数字字符串大小时先比较长度长度长的更大。长度相等时直接进行字典序比较从最高位开始逐字符比较。Python等语言中字符串可以直接比较字典序这正好符合我们的需求。在DFS中传递和拼接字符串会产生开销。可以使用字符列表list of chars来构建当前数字在递归到最后posN时再将其转换为字符串与best比较这样可以减少中间生成的字符串对象。5. 代码实现与实例解析下面给出一个Python的实现示例它包含了上述讨论的核心策略高位优先DFS、贪心枚举目标数字、最优性前缀剪枝。为了清晰暂时未加入记忆化。import sys sys.setrecursionlimit(1000000) def solve(): # 假设输入读取这里用示例数据 # 格式第一行字符串第二行 A B M # 例如 123 \n 1 1 2 s input().strip() A, B, M map(int, input().split()) N len(s) digits list(map(int, s)) # 转换为数字列表 best [0] * N # 初始最佳答案全0 def dfs(pos, remainA, remainB, current): pos: 当前处理位置 remainA: 剩余操作A次数 remainB: 剩余操作B次数 current: 当前已确定的前缀字符列表 nonlocal best # 最优性剪枝比较当前前缀和已知最佳答案的前缀 if pos 0: # 将current和best都转换为字符串比较前pos位 cur_prefix .join(current[:pos]) best_prefix .join(best[:pos]) if cur_prefix best_prefix: return # 当前前缀已更差剪枝 # 如果当前前缀已经大于最佳前缀我们继续搜索因为可能找到更好的 # 如果相等也继续搜索 if pos N: # 找到一个完整解 candidate .join(current) best_candidate .join(best) # 比较长度和字典序 if (len(candidate) len(best_candidate)) or (len(candidate) len(best_candidate) and candidate best_candidate): best[:] current[:] # 更新最佳答案 return d digits[pos] # 从大到小枚举目标数字 for target in range(9, -1, -1): # 计算通过操作A达到target需要的次数 costA (target - d 10) % 10 # 计算通过操作B达到target需要的次数 costB (d - target 10) % 10 # 尝试使用操作A if costA remainA: current.append(str(target)) dfs(pos 1, remainA - costA, remainB, current) current.pop() # 尝试使用操作B if costB remainB and costB ! costA: # 避免和A重复当targetd时两者cost都为0 current.append(str(target)) dfs(pos 1, remainA, remainB - costB, current) current.pop() # 注意这里没有对找到大数字就break因为消耗操作次数不同会影响后续。 # 剪枝主要依靠前面的前缀比较。 dfs(0, A, B, []) print(.join(best)) if __name__ __main__: solve()实例解析以输入s123, A1, B1, M2为例。初始best000。dfs(pos0, remainA1, remainB1, current[])。处理第一位d1。枚举target从9到1。target9:costA(9-110)%108,costB(1-910)%102。均超过剩余次数跳过。target8:costA7,costB3跳过。...target2:costA1,costB9。costA1 remainA1成立。递归进入dfs(pos1, remainA0, remainB1, current[2])。此时前缀2大于best前缀0继续。处理第二位d2remainA0, remainB1。尝试target9:costA7,costB3。costA超了costB31超了。...target2:costA0,costB0。costA0可行递归得到22继续下一位...target1:costA9,costB1。costB1 remainB1可行递归得到21...最终在pos3时会得到诸如222,221,212等候选更新best。target1:costA0,costB0。两者都可行分别搜索。这是不操作的情况会探索原始路径。搜索树会遍历所有可能组合。由于有前缀剪枝很多分支会被提前剪掉。最终程序会找到最大值222对第一位用A加1对第二位用A加1不对A只有1次。实际上最优解是222对第一位用A变成2对第二位用A没有A了。等等我们只有A1,B1。222需要两次操作A。所以不可能。让我们重新计算。可能解133(第一位不动第二位A1第三位不动)223(第一位A1第二位不动第三位不动)132(第一位不动第二位不动第三位B-1)229(第一位A1第二位A1A不够)...实际上枚举后最大的是229需要两次A第一位1-2第二位2-3不对是2-9需要7A次数不够和一次B我们只有A1,B1,M2。229需要 1-2 (A:1), 2-2 (0), 3-9 (B:? 3减到9需要减4次循环3-2-1-0-9需要4次B)B次数不够。最终通过程序计算s123, A1, B1, M2的最大值应该是133操作第二位A1或223操作第一位A1。显然223 133。所以答案是223。这个例子说明了手动枚举的复杂性也体现了算法的重要性。6. 性能优化与进阶思考上述DFS代码在较小规模数据上可行但对于极限数据N50, M100可能仍然会超时因为最坏情况下分支很多。我们需要进一步优化6.1 强化贪心剪枝在每一位如果我们发现可以通过某种操作且消耗次数在允许范围内将当前位变成9那么我们是否应该只搜索变成9的方案而忽略变成8、7等的方案这需要证明其正确性。在某些情况下这可能不是绝对正确的因为将当前位变成9可能消耗较多操作导致后面更重要的位虽然位权低但如果后面能连续多位变成9总和可能更大无法提升。但在竞赛实践中对于蓝桥杯这类题目的数据范围“当前位能变9则必变9”作为一个贪心选择配合后续的搜索往往是能够通过所有测试数据的。这是一个基于经验和对出题数据风格的判断。我们可以实现一个“激进贪心”版本在DFS中如果当前位可以变成9用A或B则只尝试变成9的消耗次数最少的那种操作方式然后进入下一位。如果不行再尝试8以此类推。这能极大减少分支。6.2 状态压缩与记忆化我们可以用一个三维数组dp[pos][a][b]来记忆化。但存储整个后缀字符串不现实。我们可以换一种思路存储一个布尔值表示从状态(pos, a, b)出发是否可能达到当前已知的最佳答案或更好。但这需要和全局best联动实现起来较复杂。更常见的是使用DFS 剪枝而不依赖复杂的记忆化。6.3 迭代加深与可行性预估可以预估一下从当前位置pos开始剩余的数字位即使全部变成9所能得到的最好可能后缀是什么。如果当前前缀加上这个“最好可能后缀”构成的字符串仍然不大于当前全局最优解best那么就可以剪枝。这个“最好可能后缀”可以通过假设剩余每一位都使用最少操作0或1次变成9来估算但这只是一个乐观估计用于剪枝。6.4 转换为动态规划DP理论上这道题可以用DP解。定义dp[i][j][k]为处理完前i位使用了j次操作A和k次操作B时所能得到的前i位的最大字符串。状态转移时我们枚举第i位变成的数字t以及使用的操作类型从dp[i-1][j-costA][k]或dp[i-1][j][k-costB]转移过来并选择能使最终字符串最大的方案。但是和之前说的一样字符串的比较和存储是瓶颈。如果N、A、B在50左右状态数约12.5万每个状态存字符串内存和时间的压力都很大。除非题目限制非常小否则DP不是首选。在实际的蓝桥杯国赛环境中通常数据会经过精心设计使得带剪枝的DFS能够在规定时间内通过。因此掌握DFS剪枝的技巧是解决此类问题的关键。7. 总结与实战建议“最大数字”这道题是一个经典的带资源约束的字符串构造问题。它综合考察了选手的以下几个能力问题建模能力将操作抽象为循环加/减理解操作对整体数值的影响。搜索算法基础深度优先搜索DFS是解决组合优化问题的基本武器。剪枝优化技巧这是本题的核心考点。如何设计有效的剪枝策略最优性剪枝、可行性剪枝、贪心剪枝来减少搜索空间直接决定了算法能否在时限内运行。细节处理能力包括循环操作次数的正确计算、大数字的字符串处理与比较、递归边界的控制等。在实战中我建议按照以下步骤思考先想暴力明确搜索空间是什么每一位的操作选择状态参数是什么位置、剩余A、剩余B。再想剪枝最优性剪枝维护当前已构造的前缀和全局最优解及时剪掉不可能更优的分支。贪心引导优先尝试让高位变成更大的数字特别是9。可行性剪枝如果剩余操作次数连把当前位变成可能的最大数字都做不到或者连把后面所有位都变成9乐观估计都做不到就剪枝。后写代码从清晰的DFS框架开始逐步加入剪枝逻辑。务必注意操作次数的循环计算。最后测试用一些小数据包括边界情况如全9、全0、操作次数为0等和题目给的样例验证正确性。如果超时再分析是否还有更强的剪枝可以添加。这道题的价值在于它训练的不是死记硬背模板而是在理解问题本质的基础上灵活运用搜索和优化技巧的能力。这种能力在解决许多实际编程和算法问题时都至关重要。当你成功AC的那一刻你会对“搜索”和“剪枝”有更深的理解——它们不仅是算法更是一种在约束条件下寻找最优解的系统性思维方式。
返回列表