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

资讯详情

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

数位DP精解:从二进制问题到通用框架,掌握记忆化搜索与递推

数位DP精解:从二进制问题到通用框架,掌握记忆化搜索与递推 1. 项目概述从一道蓝桥杯国赛题看数位DP的精髓最近在复盘蓝桥杯国赛真题特别是C B组cb组的题目时一道关于“二进制问题”的题目让我印象深刻。它不像传统的动态规划那样直接而是将问题巧妙地嵌套在“数位”的框架下考察对二进制数位特性的深度理解以及动态规划的高级应用——数位DP。很多朋友初次接触数位DP时会觉得它概念抽象状态设计复杂尤其是面对“记忆化搜索”和“递推”两种实现思路时容易混淆。这道题恰好是一个绝佳的切入点它不要求你处理十进制而是更纯粹的二进制反而能让我们剥离表象聚焦于数位DP的核心思想如何优雅地统计在特定数位限制下满足某种性质的数的个数。简单来说这道题通常会给定一个范围[L, R]和一个目标值K要求你找出该范围内其二进制表示中“1”的个数恰好为K的所有整数的数量。例如L1,R10,K2那么我们需要找出1到10之间哪些数在二进制下恰好有2个‘1’。1(1), 2(10), 3(11), 4(100)... 其中3(11)和5(101)、6(110)、9(1001)、10(1010)都满足条件。手动枚举在小范围尚可但当R大到10^18甚至更大时暴力遍历无异于天方夜谭。这时数位DP就派上了用场。它之所以适合所有希望深入算法竞赛尤其是蓝桥杯、ACM的选手是因为它融合了多个关键知识点位运算、动态规划、深度优先搜索以及对问题模型的转化能力。理解它不仅能解决这一道题更能为你打开解决“数字计数问题”、“数字和问题”、“特定模式数统计”等一大类问题的大门。接下来我将以这道“二进制问题”为例彻底拆解数位DP的两种核心实现路径——记忆化搜索DFS with Memoization和递推Iterative DP并附上详细的思路推导、代码实现以及我踩过的那些坑。2. 核心思路拆解为什么是数位DP在直接跳进代码之前我们必须先想清楚为什么普通的动态规划DP搞不定这个问题普通的DP比如经典的背包问题状态定义通常基于物品序号和容量是线性的、顺序的。但我们现在面对的是一个“数”它的每一位在二进制中就是每一个bit都有两种选择0或1并且高位的选择会直接影响低位的取值范围比如是否达到“上限”这种“位与位之间的约束”和“上限限制”是普通一维或二维DP难以直接刻画的。数位DP的精妙之处在于它把对一个庞大数字集合的统计转化为了对一个“数位树”的遍历。想象一下我们从最高位开始逐位确定这个数字的每一位。每走到一位我们面临几个关键问题当前位可以填什么这取决于之前的高位是否已经“脱离”了原始数字R的限制。如果之前某一位填的数已经小于R的对应位那么当前位可以自由选择0或1在二进制下如果之前每一位都和R的对应位相等那么当前位就不能超过R的当前位这就是“上限限制”。我们需要记录什么为了最终统计“1”的个数我们显然需要记录到目前为止已经填了多少个‘1’。此外为了处理上述的“上限限制”我们必须知道当前构造的数字前缀是否“紧贴”着上限R这个状态通常被称为limit或tight。如何避免重复计算这是动态规划的核心。如果两个不同的数字前缀它们“已使用的1的个数”相同并且它们相对于上限R的状态是否贴限也相同那么它们后续低位可以形成的、满足条件的数字数量是完全一样的这个“后续可能性”与具体的前缀数字无关只与当前处理到的位置 已使用的1的个数 是否贴限这个状态有关。这就是我们进行记忆化缓存的基础。基于这个思路我们有两种方式来实现这个“数位树”的遍历和统计记忆化搜索和递推。记忆化搜索更符合人类“尝试-探索”的思维直观易懂而递推则更体现动态规划“自底向上”的严谨性有时在空间优化上更有优势。下面我们分别深入。2.1 记忆化搜索像走迷宫一样探索所有可能记忆化搜索本质上是深度优先搜索DFS加上一个备忘录。我们写一个递归函数dfs(pos, cnt, limit)它的含义是从第pos位开始通常从最高位向最低位处理在已经使用了cnt个‘1’的情况下且当前是否受到上限limit的约束继续向下构造数字最终能得到的、满足“总‘1’个数为K”的数字有多少个。这里有几个关键参数解析pos(位置)当前正在处理哪一位。我们从最高位比如二进制下第m位开始递归地向低位pos-1探索当pos -1或pos 0取决于实现时表示所有位都处理完了。cnt(计数)从最高位到当前位的前一位为止我们已经在这个数字中放置了多少个‘1’。limit(限制)这是一个布尔值。如果为true表示当前构造的数字前缀其每一位都和上限R的对应位完全相同那么当前位能填的最大数字就是R在这一位的值二进制下是0或1。如果为limit为false表示之前的高位已经有某一位填的数小于R的对应位了那么当前位就“解放”了可以自由填0或1在进制范围内。递归的流程如下边界条件如果pos 0所有位处理完毕我们检查cnt是否等于目标K。如果相等说明找到一条合法路径返回1否则返回0。查备忘录如果当前状态(pos, cnt, limit)已经被计算过直接返回缓存的结果。这是记忆化搜索提升效率的关键避免了指数级的重复递归。确定当前位上限根据limit参数和R在当前位pos的值记为up确定当前位能填的数字范围。如果limit为真则最大值为up否则为1二进制最大位值。枚举与递归从0到maxDigit枚举当前位可以填的数字i。然后计算新的状态参数传递给下一层递归new_cnt cnt (i 1)如果当前位填了1计数加1。new_limit limit (i up)新的限制状态。只有当之前是贴限的limit为真并且当前位填的数字等于上限值i up时下一位才会继续贴限否则下一位就自由了limit为假。汇总与缓存将所有枚举分支的递归结果相加得到当前状态(pos, cnt, limit)下的总方案数存入备忘录通常是一个多维数组dp[pos][cnt][limit]然后返回这个结果。最后我们调用dfs(最高位, 0, true)就能得到在[0, R]区间内满足条件的数的个数。要求[L, R]区间只需计算solve(R) - solve(L-1)即可这是处理区间问题的常用技巧。注意记忆化搜索的备忘录dp数组其维度limit通常只开2true/false。但这里有一个极易出错的关键点dp[pos][cnt]这个状态只有在limit false的时候才可以被记忆和复用因为当limit true时当前状态受到特定上限R的严格约束不同前缀即使pos和cnt相同后续的可能性是不同的。而limit false时意味着已经“自由”后续低位的选择完全不受原数R的影响此时(pos, cnt)这个状态就具有了唯一确定性可以安全缓存。在实际代码中我们常常将dp数组定义为dp[pos][cnt]仅在limit false时进行记忆化读取和存储。2.2 递推自底向上严谨构建状态转移如果说记忆化搜索是“从顶向下”的分解那么递推就是“从底向上”的合成。递推的思路更直接地体现了动态规划的状态转移方程。我们定义dp[pos][cnt][s]其中s表示当前是否处于“贴限”状态通常用0表示自由1表示贴限。它的含义是处理完前pos位从高位向低位处理使用了cnt个‘1’且当前贴限状态为s时可能的数字构造方案数。这里“处理完前 pos 位”可能有点绕更常见的理解是我们有一个长度为len的数字二进制串dp[i][j][0/1]表示我们正在处理第i位从0开始作为最低位或最高位均可但需要统一已经处理完了i位或即将处理第i位取决于初始化使用了j个‘1’且状态为0/1时的方案数。递推的典型步骤如下以从高位向低位递推为例初始化通常设置一个虚拟的起点。例如可以设dp[0][0][1] 1表示还没有开始处理任何位时数字为空使用了0个‘1’并且处于“贴限”状态因为还没开始默认是和上限对齐的。状态转移我们遍历每一位i从0到len-1代表从最高位到最低位遍历当前可能已经使用的‘1’的个数j遍历当前的状态s(0或1)。确定当前位i的上限值up如果s 1贴限则up等于R的第i位否则up 1。枚举当前位填的数字d(0 到up)。计算新的状态new_j j (d 1)new_s (s 1) (d up)// 注意这里的up是动态的当s0时up1new_s必然为0。将当前状态dp[i][j][s]的方案数累加到下一个状态dp[i1][new_j][new_s]上。即dp[i1][new_j][new_s] dp[i][j][s]。获取结果在递推完成后dp[len][K][0] dp[len][K][1]就代表了所有处理完len位恰好使用K个‘1’的数字方案总数也就是[0, R]区间内的答案。递推法的优势在于其循环结构清晰有时更容易理解状态之间的依赖关系并且可以方便地进行空间优化例如滚动数组。但它的缺点是需要仔细处理边界和初始化对于复杂的状态设计循环嵌套层数可能较多代码不如记忆化搜索直观。3. 记忆化搜索实现详解与避坑指南理论说再多不如一行代码。我们以记忆化搜索为例实现蓝桥杯这道二进制数位DP问题。假设函数solve(x)用于计算[0, x]区间内二进制表示中‘1’的个数为K的数字数量。#include bits/stdc.h using namespace std; using ll long long; // 全局变量目标K上限数字R的二进制位数组digits记忆化数组dp int K; vectorint digits; // 存储R的二进制位digits[0]是最高位 ll dp[70][70]; // dp[pos][cnt] 维度根据数据范围设定70对于10^18的二进制位(约60位)足够 // 记忆化搜索函数 // pos: 当前处理位从最高位向最低位初始为0 // cnt: 当前已使用的‘1’的个数 // limit: 当前是否贴限 ll dfs(int pos, int cnt, bool limit) { // 1. 递归边界所有位处理完毕 if (pos digits.size()) { return cnt K ? 1 : 0; } // 2. 记忆化读取关键仅在非限制状态下记忆化 if (!limit dp[pos][cnt] ! -1) { return dp[pos][cnt]; } // 3. 确定当前位可填数字的上限 int up limit ? digits[pos] : 1; ll res 0; // 4. 枚举当前位所有可能的选择 for (int d 0; d up; d) { int new_cnt cnt (d 1); // 如果已经使用的‘1’超过K后续无论如何都不可能满足条件可以剪枝 if (new_cnt K) continue; bool new_limit limit (d up); res dfs(pos 1, new_cnt, new_limit); } // 5. 记忆化存储关键仅在非限制状态下存储 if (!limit) { dp[pos][cnt] res; } return res; } // 主计算函数将数字x转化为数位数组并启动DFS ll solve(ll x) { if (x 0) return 0; // 处理边界 digits.clear(); // 将x转化为二进制位数组最高位在前 while (x) { digits.push_back(x 1); // 获取最低位 x 1; } reverse(digits.begin(), digits.end()); // 反转使digits[0]为最高位 // 如果x为0digits为空需要特殊处理或者直接认为0的二进制表示就是0 if (digits.empty()) { digits.push_back(0); } // 初始化记忆化数组为-1表示未计算 memset(dp, -1, sizeof(dp)); // 从最高位(0)开始已用‘1’数为0初始状态为贴限(true) return dfs(0, 0, true); } int main() { ll L, R; cin L R K; // 利用前缀和思想计算区间[L, R]的结果 ll ans solve(R) - solve(L - 1); cout ans endl; return 0; }代码逐段解析与避坑点数位提取(solve(ll x)函数内)我们通过x 1和x 1循环获取x的每一个二进制位注意这里得到的是从低位到高位的顺序。为了符合我们“从高到低”处理的习惯必须进行reverse操作。这是第一个容易出错的地方务必检查digits[0]是否是最高位。记忆化数组dp的定义与初始化dp[pos][cnt]的大小需要根据数据范围估算。long long类型的R最大约10^18其二进制位数不超过64位所以pos维度开70足够安全。cnt维度最多也不会超过位数同样开70。初始化值为-1这是为了区分“该状态未计算过”和“该状态计算结果为0”这两种情况。记忆化的条件if (!limit)这是整个记忆化搜索的灵魂也是最容易混淆的地方。为什么limit true时不能记忆化考虑两个不同的前缀A和B它们都处理到了第pos位都使用了cnt个‘1’。如果A是贴限的即A的前缀完全等于R的前pos位而B是不贴限的B的前缀已经小于R的前pos位。那么对于A它下一位能填的数字受限于R[pos]对于B它下一位可以自由填0或1。它们后续的可能性完全不同因此(pos, cnt, true)这个状态是与具体的R值绑定的不具有通用性不能缓存。只有“自由”状态(pos, cnt, false)才是通用的可以被复用。剪枝优化在枚举当前位d时我们计算了new_cnt cnt (d 1)。如果new_cnt K意味着即使后面所有位都填0‘1’的总数也必然超过K这条路径不可能成功。此时直接continue跳过该分支的递归这是一个有效的可行性剪枝能提升效率。处理数字0当x0时while(x)循环不会执行digits为空。我们需要特殊处理可以手动加入一个0代表二进制数“0”。同时在DFS的边界条件中当pos digits.size()时我们判断cnt K。对于x0digits[0]DFS会处理一位0然后到达边界。此时若K0则0这个数被计入若K0则不计入。逻辑是正确的。前缀和思想solve(R) - solve(L-1)是处理闭区间[L, R]的经典方法。注意L可能为0或1L-1可能为负数需要在solve函数开头进行判断负数直接返回0。4. 递推实现详解与状态转移剖析为了更全面地理解我们也给出递推版本的实现。我们将数字的二进制位存储于数组a[]中a[1]为最高位a[len]为最低位这种下标方式在循环时更自然。#include bits/stdc.h using namespace std; using ll long long; ll dp[70][70][2]; // dp[i][j][s]: 处理完前i位从高到低用了j个1贴限状态为s的方案数 int a[70]; // 存储数字的二进制位a[1]是最高位 int len; // 二进制位数 ll solve(ll x) { if (x 0) return 0; // 1. 数位分解 len 0; while (x) { a[len] x 1; x 1; } if (len 0) { // x为0 a[len] 0; } // 此时a[1]是最高位a[len]是最低位符合我们递推从高位开始的习惯 // 2. 初始化DP数组 memset(dp, 0, sizeof(dp)); // 初始状态尚未处理任何位时数字为空用了0个1处于贴限状态因为要和原数对齐 dp[0][0][1] 1; // 3. 状态转移从高位向低位递推 for (int i 0; i len; i) { // i表示已经处理完的位数从0到len-1 for (int j 0; j K; j) { // 已使用的‘1’的个数 for (int s 0; s 1; s) { // 贴限状态0-自由1-贴限 if (dp[i][j][s] 0) continue; // 当前状态不可达跳过 int up (s 1) ? a[i1] : 1; // 当前要处理的第i1位的上限 for (int d 0; d up; d) { int new_j j (d 1); if (new_j K) continue; // 剪枝 int new_s (s 1) (d up); dp[i1][new_j][new_s] dp[i][j][s]; } } } } // 4. 统计结果 // 处理完所有len位后使用了恰好K个‘1’的所有方案无论最后是否贴限 return dp[len][K][0] dp[len][K][1]; } int main() { ll L, R; cin L R K; ll ans solve(R) - solve(L - 1); cout ans endl; return 0; }递推版本的关键点解析状态定义再审视dp[i][j][s]表示“已经处理完前i位即决定了最高位到第i位的值使用了j个‘1’且贴限状态为s的方案数”。这里i从0开始dp[0][...][...]是初始状态。巧妙的初始化dp[0][0][1] 1是递推的起点。它表示一个“虚拟”的第0位处理完毕的状态数字为空没有‘1’并且由于还没开始我们认为它和原数R是“对齐”的贴限状态为1。这个初始化保证了后续递推的合法性。状态转移的核心最外层的i循环遍历“已处理位数”。对于每一个已存在的状态(i, j, s)我们去决策第i1位填什么数字d。up的计算如果当前状态是贴限的(s1)那么第i1位不能超过原数R的第i1位 (a[i1])否则可以自由填到1。new_s的计算新的贴限状态只有当旧状态是贴限的(s1)并且当前填的数字达到了允许的上限(d up)时新状态才继续贴限(new_s1)。否则新状态变为自由(new_s0)。这个逻辑和记忆化搜索中完全一致。累加将当前状态dp[i][j][s]的方案数加到新状态dp[i1][new_j][new_s]上。这体现了动态规划的“状态转移”。结果汇总最终我们处理完了所有len位。满足条件的数字就是那些使用了恰好K个‘1’的数字无论它最终是贴限状态(s1)还是自由状态(s0)都是合法的。所以答案是两者之和。实操心得选择记忆化搜索还是递推对于初学者我强烈推荐从记忆化搜索入手。它的思维模式更符合“尝试所有可能性”的直觉代码结构递归也更清晰更容易处理复杂的条件判断比如前导零问题在十进制数位DP中很常见。递推法虽然效率上可能常数更优且易于进行空间优化如使用滚动数组但其多重循环和状态转移的下标处理更容易出错尤其是在处理“前导零”这种特殊状态时。在竞赛中绝大多数情况下记忆化搜索的代码编写速度、可读性和正确率都更高。先熟练掌握记忆化搜索再在有必要时学习递推的优化技巧是更稳妥的学习路径。5. 从二进制到通用数位DP的扩展与常见问题掌握了二进制这个特例我们就能触类旁通解决更一般的数位DP问题比如十进制下求含有特定数字、数字和、能被某数整除的数的个数等。其核心框架是不变的变化的只是“状态”的定义。通用数位DP记忆化搜索框架ll dfs(int pos, int state, bool limit, bool lead) { // lead: 前导零标志 if (pos -1) return check(state); // 根据最终状态判断是否合法 if (!limit !lead dp[pos][state] ! -1) return dp[pos][state]; int up limit ? digits[pos] : 9; // 十进制上限是9 ll res 0; for (int d 0; d up; d) { // 根据具体问题更新状态new_state int new_state update_state(state, d, lead); res dfs(pos-1, new_state, limit (dup), lead (d0)); } if (!limit !lead) dp[pos][state] res; return res; }与二进制版本相比主要增加了lead前导零参数。这是因为在十进制中数字“0”本身和数字中间的“0”意义不同。比如统计数字“1”出现的次数数字“101”包含两个‘1’而“001”或“0”则不是我们关心的。lead参数帮助我们区分这种情况。针对蓝桥杯“二进制问题”的扩展思考问题变体一统计‘1’的个数在某个区间[A, B]内的数量。比如求二进制中‘1’的个数在[K1, K2]之间的数有多少个。这很简单我们只需要修改DFS的边界条件将cnt K的判断改为cnt K1 cnt K2。相应地记忆化数组的维度cnt需要开到最大可能位数。问题变体二求第N个满足条件的数。这是数位DP的经典应用——“数位DP二分查找”。我们可以用solve(x)求出[0, x]区间内满足条件的数的个数这个函数是单调的。那么我们可以二分查找一个数mid使得solve(mid) N且solve(mid-1) N那么第N个数就是mid。这要求solve(x)函数高效而我们的数位DP正好满足。问题变体三二进制下‘1’的个数为质数。这只需要在状态中增加一个“当前已使用‘1’的个数”cnt然后在边界判断cnt是否为质数即可。判断质数可以预处理一个素数表。常见问题与排查技巧实录结果总是0或明显偏小检查数位提取确认digits数组的顺序是否正确最高位在前。一个简单的测试方法是输入一个小的数如5(二进制101)打印digits数组看是否为[1,0,1]。检查DFS边界条件pos的终止条件是什么是pos -1还是pos digits.size()cnt K的判断是否写在了正确的边界里检查记忆化条件这是最可能出错的地方确认你的dp数组只在!limit时进行读取和存储。可以尝试暂时去掉记忆化注释掉if(!limit)的判断和存储如果结果正确了那问题就出在这里。结果偏大或溢出检查区间计算solve(R) - solve(L-1)中的L-1是否可能为负数确保solve函数对负数输入返回0。检查数据类型结果是否可能超过int范围对于大的R(如10^18)结果可能很大务必使用long long。检查DP数组初始化dp数组是否用-1正确初始化如果初始化为0会导致所有未计算过的状态都被误认为结果是0从而漏算。程序运行超时确认记忆化生效在DFS开头打印状态(pos, cnt, limit)观察是否有大量重复计算。确保!limit的条件判断正确。进行可行性剪枝如我们代码中的if (new_cnt K) continue;。在问题允许时尽早剪掉不可能到达终点的分支。优化状态设计有时状态可以合并或简化。例如如果问题只关心奇偶性那么cnt可以只存0或1而不是具体数值。处理前导零针对十进制或其他进制在通用框架中引入lead参数。它的含义是到目前为止构造的数字是否全是前导零。在枚举当前位数字d时如果lead d0那么新的状态new_lead仍然为真并且通常不更新其他状态比如不计数、不计算和等因为前导零不构成有效数字的一部分。只有当!(lead d0)时即当前位是第一个非零数字或者之前已经有非零数字了我们才将其视为有效位更新相应的状态如cnt,sum等。记忆化时状态(pos, state)只有在!limit !lead时才是通用的可以缓存。因为含有前导零的状态其后续可能性与不含前导零的状态是不同的例如数字长度不同。数位DP是一个“套路”很深的专题一旦掌握了其核心思想——状态定义位置、计数、限制、前导零和记忆化搜索的框架大部分题目都能迎刃而解。这道蓝桥杯的二进制问题抛开了十进制前导零的干扰直指数位DP最本质的状态转移是绝佳的练手题。我建议在理解上述代码后自己默写几遍然后尝试用同样的框架去解决LeetCode或洛谷上的经典数位DP问题如“数字1的个数”、“不含连续1的非负整数”等你会发现自己对动态规划和搜索的理解又深了一个层次。
返回列表