多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

数位DP实战:C++解蓝桥杯“修改数位”问题与通用框架

数位DP实战:C++解蓝桥杯“修改数位”问题与通用框架 1. 项目概述从“修改数位”看竞赛题的思维转换最近在带学生刷信奥和蓝桥杯的题目遇到一道挺有意思的题P12289 “[蓝桥杯 2024 国 Java A] 修改数位”。虽然原题标注是Java A组但核心算法思想是语言无关的用C来实现和讲解对于理解数位相关的动态规划数位DP是绝佳的练习。这道题的本质是考察我们如何通过最少的操作将一个数字的某些数位进行修改使其满足特定条件通常是变成另一个数的倍数或自身具有某种性质。很多同学一看到“修改”、“数位”、“最少次数”就发怵觉得要枚举所有修改方式复杂度爆炸。其实不然这类题目有非常清晰的套路和状态定义方式。今天我就结合这道题把数位DP中“修改数位”这类问题的通用解法以及用C实现的细节、踩过的坑给大家掰开揉碎了讲清楚。无论你是正在备战信奥赛、蓝桥杯的选手还是对算法感兴趣的C开发者这篇都能让你获得可以直接“抄作业”的解题框架。2. 核心思路拆解为什么是数位DP拿到“修改数位”的题目第一步不是急着写代码而是判断它属于哪一类问题。暴力搜索所有数位修改组合对于位数n每个数位有10种可能0-9复杂度是O(10^n)n超过10就不可行了。动态规划DP是降低复杂度的不二法门。而“数位DP”特指那些与数字的每一位相关的DP问题通常用于求解在某个区间[L, R]内满足特定条件的数字个数或者求满足条件的最小数字等。对于“修改数位”问题我们可以将其转化为给定一个初始数字A我们允许修改其中某些位每次修改将一个数位变成0-9中的任意数字求使得新数字B满足条件C的最少修改次数。这里的条件C在竞赛题中常见的有“B是M的倍数”、“B的各位数字之和是S”、“B是回文数”等等。P12289这道题根据其题号归属和常见考法条件C极大概率是“B是某个给定整数K的倍数”。我们就以这个为条件来展开分析这个思路具有普适性。那么DP状态如何设计这是最关键的一步。经过大量题目训练可以总结出一个核心状态定义dp[pos][mod][isLimit][isLead]让我们逐一拆解pos当前正在处理从高位到低位的第几位0-index或1-index均可。这代表了DP的阶段。mod当前已经处理完的前几位数字组成的数对目标模数K取余的结果。这是解决“倍数”条件的关键。我们并不需要记录完整的数字那样状态爆炸只需要记录余数因为判断一个数是否是K的倍数只取决于它除以K的余数是否为0。isLimit布尔值表示当前位是否受到原始数字A对应位上限的限制。如果isLimit为真那么当前位最多只能填到A[pos]原始数字的那一位否则可以填0-9。这是数位DP处理“上界”的经典技巧。isLead布尔值表示当前位之前是否全是前导零。这对于处理“允许修改为0”以及统计有效数字位数很重要。例如数字“00123”实际是123前两位的0是前导零不应被视为有效修改位如果题目允许修改为前导零且不计代价或另有规则。有了状态决策是什么决策就是给当前位pos选择一个数字digit。如果digit等于原始数字A[pos]那么相当于没有修改这一位。如果digit不等于原始数字A[pos]那么相当于进行了一次修改操作我们需要在转移时增加1的代价修改次数。选择digit后我们需要计算新的余数new_mod (old_mod * 10 digit) % K。同时需要更新isLimit和isLead标志。最终我们的目标是什么是当pos处理完所有位即到达末尾时mod 0满足倍数条件的所有方案中修改次数的最小值。所以dp数组存储的值就是“最小修改次数”。初始化时可以将dp数组设为无穷大如INT_MAX或1e9dp[0][0][true][true] 0表示开始处理第0位当前余数为0受上限限制且处于前导零状态此时的修改次数为0。这个框架就是解决此类问题的“万能钥匙”。不同的题目只是条件C和代价计算方式不同状态定义和转移思想是相通的。3. 算法实现细节与C编码要点理解了框架我们来看看用C实现时有哪些需要特别注意的细节。我将结合一个假设的题目描述来实现代码给定一个正整数A以字符串形式输入长度100和一个模数KK1000求至少修改A的多少个数位每位可改为0-9可以得到一个能被K整除的正整数B。允许B有前导零但B至少有一位非零即不能是纯0。输出最小修改次数。3.1 状态定义与初始化首先我们定义DP数组。由于位数可能较多100位状态有pos(100),mod(1000),isLimit(2),isLead(2)总状态数约100 * 1000 * 2 * 2 400,000在时间和空间上都是可行的。我们可以使用记忆化搜索DFS Memoization来实现这样代码更清晰。#include iostream #include string #include vector #include cstring #include algorithm #include climits using namespace std; string A; // 原始数字字符串 int K; // 模数 int n; // 数字长度 // dp[pos][mod][isLimit][isLead] 存储最小修改次数 // 使用vector或数组这里用数组更高效。将isLimit和isLead二维展开为一维。 int dp[105][1005][2][2]; // 记忆化搜索函数 // pos: 当前处理位0-index // mod: 当前余数 // isLimit: 当前位是否受原始数字限制 // isLead: 当前位之前是否都是前导零 int dfs(int pos, int mod, bool isLimit, bool isLead) { // 递归边界所有位处理完毕 if (pos n) { // 需要满足1. 是K的倍数 (mod 0) 2. 整个数字非全零即至少有一位非前导零 // 如果一直是前导零isLead为true说明构造的数字是0不符合“正整数”要求返回无穷大 if (mod 0 !isLead) { return 0; // 成功构造一个数且修改次数为当前累积值在递归中累加 } else { return INT_MAX / 2; // 返回一个较大值表示无效状态 } } // 记忆化如果已经计算过直接返回 int mem dp[pos][mod][isLimit][isLead]; if (mem ! -1) return mem; int res INT_MAX / 2; // 初始化为无穷大 int up isLimit ? A[pos] - 0 : 9; // 当前位能填的最大数字 for (int digit 0; digit up; digit) { // 计算新的isLead状态如果之前是前导零且当前位也是0则新的isLead为true bool new_isLead isLead (digit 0); // 计算新的isLimit状态如果之前受限制且当前位填到了上限则新的isLimit为true bool new_isLimit isLimit (digit up); // 计算新的余数 int new_mod (mod * 10 digit) % K; // 计算本次决策的代价如果当前位与原数字不同则代价为1修改一次 int cost (digit (A[pos] - 0)) ? 0 : 1; // 递归求解子问题 int sub_res dfs(pos 1, new_mod, new_isLimit, new_isLead); // 如果子问题有解则更新当前结果 if (sub_res ! INT_MAX / 2) { res min(res, cost sub_res); } } mem res; // 记忆化 return res; } int main() { cin A K; n A.length(); // 初始化dp数组为-1表示未计算 memset(dp, -1, sizeof(dp)); int ans dfs(0, 0, true, true); // 从第0位开始余数0受限制处于前导零状态 if (ans INT_MAX / 2) { // 根据题目要求可能输出-1或特定值这里假设总有解 cout No solution found (should not happen for this problem) endl; } else { cout ans endl; } return 0; }3.2 关键点解析与避坑指南上面的代码框架虽然清晰但有几个极易出错的地方我结合自己的调试经验重点说一下前导零isLead的处理这是数位DP最容易混淆的点。isLead为真意味着到目前为止构造的数字部分全是0。它影响两点递归边界如果递归到最后一位isLead还是true说明我们构造的数字是000...0即0。如果题目要求结果必须是正整数通常如此那么这个状态就是非法的应该返回一个极大值表示无解。这就是边界条件中if (mod 0 !isLead)的判断依据。状态转移new_isLead isLead (digit 0)。只有之前全是前导零并且当前位也填了0新的状态才是前导零状态。一旦某一位填了非零数字isLead就变为false并且之后永远为false。上限限制isLimit的处理isLimit为真表示当前构造的数字前缀和原始数字A的前缀完全相同。此时当前位能填的数字上限up是A[pos]。如果当前位填的数digit小于up那么后续位就不再受原始数字的限制new_isLimit false可以自由填0-9。这个技巧保证了我们枚举的是所有小于等于原始数字A的数这是数位DP处理区间问题的核心。DP值的含义与初始化dp数组存储的是从当前状态出发完成后续所有位的填充后能得到有效解所需的最少修改次数。初始化时所有值设为-1未计算。在递归边界我们返回的是到达此状态所需的累积修改次数。注意这个次数是在递归过程中从叶子节点边界向根节点起点累加回来的。在记忆化时我们存储的是res即当前状态下的最优解最小修改次数。无效状态的处理使用INT_MAX / 2来表示无效状态无解而不是INT_MAX。这是因为我们在转移时会做cost sub_res的加法运算如果sub_res是INT_MAX再加一个cost可能导致整数溢出变成负数影响min()比较。用INT_MAX / 2可以提供一个足够大的“无穷大”值避免溢出。模运算的正确性新的余数计算new_mod (mod * 10 digit) % K是标准做法。它模拟了在数字末尾添加一位digit的过程。无论mod和digit多大最后都要对K取模保证状态数量可控。4. 针对P12289题目的可能变体与扩展思考虽然我们基于一个通用的“倍数”条件实现了代码但实际的P12289题目可能有细微的变化。根据蓝桥杯国赛A组的难度可能的变体包括变体1修改代价不同。不是每次修改代价都为1。可能将某一位从x改成y的代价是|x-y|或者是一个给定的代价矩阵cost[x][y]。应对方法这很简单只需要修改转移时的cost计算方式。将固定的cost (digit orig_digit) ? 0 : 1替换为cost cost_matrix[orig_digit][digit]即可。DP框架完全不变。变体2目标条件不是“倍数”而是“数字和”或其他性质。例如求最少修改次数使得新数字B的各位数字之和为S。应对方法修改DP状态。将状态中的mod余数替换为sum当前数字和。状态变为dp[pos][sum][isLimit][isLead]。递归边界判断sum S。注意sum的范围是[0, 9*n]需要评估状态数是否可接受n100时sum900可以接受。变体3不允许结果有前导零且结果必须严格大于0。这是更常见的要求。我们的代码已经通过!isLead条件排除了全零的情况。如果不允许前导零意味着数字的第一位最高位不能是0。这需要在递归过程中控制当isLead为真且pos0或通过其他方式判断是最高位时digit不能从0开始循环而要从1开始。应对方法在dfs函数开头的循环中增加一个判断int start 0; if (isLead pos 0) { start 1; // 最高位不能是0 } for (int digit start; digit up; digit) { // ... }同时边界条件中!isLead的判断可以保留以确保数字非零。变体4求具体的修改方案而不仅仅是次数。这是更进阶的要求。DP只能求出最优值要输出方案需要记录“决策”。应对方法在记忆化搜索的同时用另一个数组choice[pos][mod][isLimit][isLead]记录在最优解下当前位选择的是哪个数字digit。在递归函数返回最优值的同时记录下取得这个最优值的digit。最后从起始状态开始根据choice数组一步步回溯就能输出每一位修改成了什么数字。5. 调试技巧与常见问题排查在实际编码和调试这类数位DP问题时我总结了几条非常实用的技巧从小数据开始测试不要一上来就用100位的大数测试。先用A”123”, K3这样的小例子手动推算答案比如最少修改几次能让123的倍数能被3整除123本身就能被3整除所以答案是0。用cout在递归函数中打印pos, mod, isLimit, isLead, digit, cost等关键变量观察状态转移是否符合预期。测试边界情况A”0”, K1。0能被任何非零数整除吗通常题目要求正整数所以0可能不合法。我们的代码中isLead最终为true会被边界条件排除返回无穷大。需要确认题目是否允许结果为0。A”100”, K10。目标是10的倍数即末位为0。原始数字末位已经是0所以最少修改0次。测试代码是否能得出0。A”999”, K1。任何整数都是1的倍数所以最少修改0次。测试K1时模运算是否正确任何数模1都为0。检查状态是否重复计算记忆化搜索的核心是避免重复计算。可以在dp数组被赋值的地方加一个计数器看看总共计算了多少个状态。对于n100, K1000状态数最多40万如果计算次数远大于这个数说明记忆化可能没生效检查dp数组初始化是否为-1和记忆化返回的逻辑。警惕整数溢出和无效状态如前所述用INT_MAX/2代替INT_MAX。在递归返回时确保无效状态返回一个足够大的值且这个值加上cost后不会溢出变成很小的负数。理解“最少修改次数”的初始值在dfs函数开头res初始化为INT_MAX/2。这意味着如果所有digit的选择都导向无效子问题返回INT_MAX/2那么res最终也会是INT_MAX/2表示从当前状态出发无解。这是正确的逻辑。这道“修改数位”的题目是连接基础模拟与高阶动态规划的一座桥梁。它要求你不仅会写代码更要理解如何将一个问题抽象成状态并设计出高效的状态转移方程。把上面这个框架吃透举一反三遇到类似的“数位”、“修改”、“最少代价”问题你就能迅速抓住要害而不是在暴力搜索的泥潭里挣扎。编程竞赛的魅力就在于这种从具体问题中抽象出通用模型的能力锻炼。
返回列表