想象你是一位专门破解古董保险箱的锁匠。保险箱的密码不是一串能直接读出的数字,而是四个独立的转轮,每个转轮从 0 转到 9。现在委托人说道:密码一定在 0000 到 2024 之间,而且密码中不能出现数字 7,请数出一共有多少组合法密码。如果按常规思路,你会从 0000 开始一个个拧到 2024,两千多次尝试,虽然可行,但若上限变成 10^18,这种暴力试锁将耗尽你的余生。 数位DP的思路完全不同:把那个巨大的范围丢到一边,只盯着“第一位能放几”这个问题。拨动千位转轮,如果拨到 0 或 1,后面三位完全可以自由发挥;如果拨到 2,因为和上界的首位 2 贴死了,百位就只能在 0 到 0 之间选。就这样,一位一位地把限制传下去,同时把已经算过的“剩余几位 + 是否贴死”的结果记在小本本上。整个过程的计算量不再随数字大小线性增长,而只和“位数”与“状态数”有关。 这就是数位DP:对数字的每一位做动态规划。它解决的核心问题,是形如“统计区间 [L,R] 内满足某数位条件的数字个数”的计数题,特别适合那些区间极大、条件与数字十进制表示有关的场景。你不再需要把每个数字都生成出来,而是像拨保险箱轮盘一样,逐位枚举并累加可能性。
数位DP是算法工程师处理海量区间计数、数据校验和合规统计的利器,掌握它等于为数字类问题装上了“光学瞄准镜”。
目录
可视化演示
展示数位DP核心机制:整数按位分解、limit上界约束与分支剪枝、记忆化搜索缓存复用、差分转化区间计数。支持步进切换四种模式。
本节配有交互动效(进入视口后自动加载,约 3KB)。
'+captions.decomp+'
数位DP概述:像破译保险箱一样逐位破解
想象你是一个专门破解古董保险箱的锁匠。保险箱的密码不是一串能直接读出的数字,而是四个独立的转轮,每个转轮从 0 转到 9。现在委托人说道:密码一定在 0000 到 2024 之间,而且密码中不能出现数字 7,请数出一共有多少组合法密码。如果按常规思路,你会从 0000 开始一个个拧到 2024,两千多次尝试,虽然可行,但若上限变成 10^18,这种暴力试锁将耗尽你的余生。
数位DP的思路完全不同:把那个巨大的范围丢到一边,只盯着“第一位能放几”这个问题。拨动千位转轮,如果拨到 0 或 1,后面三位完全可以自由发挥;如果拨到 2,因为和上界的首位 2 贴死了,百位就只能在 0 到 0 之间选。就这样,一位一位地把限制传下去,同时把已经算过的“剩余几位 + 是否贴死”的结果记在小本本上。整个过程的计算量不再随数字大小线性增长,而只和“位数”与“状态数”有关。
这就是数位DP:对数字的每一位做动态规划。它解决的核心问题,是形如“统计区间 [L,R] 内满足某数位条件的数字个数”的计数题,特别适合那些区间极大、条件与数字十进制表示有关的场景。你不再需要把每个数字都生成出来,而是像拨保险箱轮盘一样,逐位枚举并累加可能性。
那么,第一位应该如何拆开?贴死与自由之间如何切换?小本本上又要记哪些东西?接下来的每一节,都把这套“保险箱破解术”的零件逐一拆给你看。
按位拆解:把密码锁拆成一排独立的转轮
保险箱虽然是一整串数字,但锁匠只关心每个转轮的位置。同样,数位DP的第一步,是给数字“拆位”。比如密码上界 2024,拆开后就是千位 2、百位 0、十位 2、个位 4。这样,一个整数就变成了一张“转轮清单”。
这个表达式叫位权展开:每一位数字乘以它的十进制位权,再把所有项加起来,就还原了原来的整数。拆位之后,枚举一个数就变成了依次决定每一个转轮的数字。由于我们总是从高位往低位拨,前面已经拨下的数字就构成了“前缀”。前缀和上界前缀完全相等时,当前位就要小心;前缀一旦比上界小,后面就可以随便拨。
从数学角度看,记第 k 位数字为 d_k,则一个 m 位数 n 可以写为:
拆位是所有数位DP代码的第一步。一旦把数字变成数组 digits,剩下的过程就像把保险箱的每一颗螺丝卸下来,无论是上界限制还是统计逻辑,都能在这个数组上按顺序执行。
- 求和符号的含义\sum_{k=0}^{m} 表示让 k 从 0 取到 m,把每一项都加起来。k 是“位权指数”,个位是 0,十位是 1,百位是 2。
- 第 k 位数字d_k 表示整数 n 从右往左数第 k 位的数字,范围是 0~9。它是拆位得到的原子信息。
- 十进制位权10^k 是这一位的“重量”。比如 k=3 时位权是 1000,所以千位上的 2 贡献 2×1000。
- 求和还原把所有位贡献加起来,就是整数 n 本身。这个公式是我们按位枚举时“拼回去”的依据。
2024 = 2 × 10³ + 0 × 10² + 2 × 10 + 4状态定义:三个变量描述“我现在在哪”
当锁匠拨到第三个转轮时,心里需要知道三件事:还剩几个转轮要拨?目前的前缀是否和上界完全一样?之前已经拨出的数字里有没有出现过某个特殊数字?这三件事,正好对应数位DP的三个核心状态。
首先是位置 pos,它表示当前正在处理数字的哪一位,也可以理解为“还剩多少位可以自由决策”。其次是 tight,它是一个布尔值,表示到目前为止,我们选择的前缀是否与上界的前缀“贴死”了。如果贴死,当前位只能取到上界对应位;如果之前任何一位已经选得比上界小,tight 就变成 false,后面不再受限。最后是 mask,它是一个位掩码,用于记录已经出现过的数字集合,通常在处理“每个数字最多出现一次”这类题目时使用。
这三个状态组合起来,就构成了一个子问题的完整描述:
状态定义是整个数位DP的地基。如果你能说出“此刻我在第几位、受不受上界限制、之前满足什么性质”,那么你已经把一个复杂的计数问题,拆分成了许多个可以独立求解的小问题。
- dp[pos][tight][mask] 的含义它表示“当前还有 pos 位要填,前缀是否贴死上界为 tight,已用数字集合为 mask”时,后续可以产生的合法方案数。
- limit 的意义limit 是当前位可选数字的上限。若 tight 为真,limit = digits[pos];否则 limit = 9。
- 枚举当前位在 0 到 limit 之间枚举当前位数字 d,并累加所有子问题的答案。求和符号把每个可选数字的结果汇总。
- 新的 tight 与 mask如果当前 tight 为真且 d 恰好等于 limit(也就是仍然和上界贴死),下一层 tight 为真;否则为假。mask' 是在 mask 基础上加入数字 d 的新集合。
约束处理:贴死时不能越线,前导零不能算数
继续用保险箱的例子:如果前两位已经拨成了 20,恰好和上界 2024 的前两位一致,那么第三位转轮只能在 0 到 2 之间转;如果拨成 19,第三位就可以从 0 转到 9。这就是 tight 带来的上界约束。翻译成代码就是:如果当前处于贴死状态,当前位的枚举上界不是 9,而是上界在这一位的数字。
另一类约束来自“前导零”。在数字 007 中,前两位的 0 只是占位符,它们不应该被当作真正的数字参与统计。比如统计“数字 0 出现了多少次”时,007 这个数只能贡献一次 0(在个位),而不能因为十进制表示有两位 0 就贡献两次。因此,我们通常还要维护一个 started(是否已经开始)状态:只有已经出现了非零位,之后的 0 才算作有效数字;否则 0 只是前导零,不能计入统计。
这两个约束看似繁琐,却正是数位DP能够在超大区间上精确计数的关键。限上界,保证我们统计的每个数字都不越界;处理前导零,保证计数规则和十进制表示的真实含义一致。如果把这两个约束写进状态,任何子问题都能正确地被描述。
记忆化搜索:小本本让破解速度起飞
锁匠拨锁时很快发现:无论千位拨成 0 还是 1,只要后面的状态相同——都是“还剩三位、tight=false、没有特殊数字记录”,那后续所有分支的方案数其实完全一样。既然如此,为什么不把第一次算出的结果记在小本本上,后面直接抄答案?
这就是记忆化搜索。我们用递归函数 dfs(pos, tight, mask, started) 表示从当前状态出发的合法方案数。函数内部枚举当前位所有可选数字,并递归求解下一层;当一个状态第一次算完,就把结果存入缓存数组,下次遇到相同状态直接返回。这样,原本可能在搜索树里爆炸的计算量,被压缩到状态数的规模。
数位DP的复杂度因此变得非常优雅:假设数字有 L 位,状态中可能记录的数字集合有 2^m 种,每个状态要枚举 10 个数字,总时间复杂度大约是:
写记忆化搜索时有个小技巧:当 tight=false 时,子问题的答案和上界无关,所以可以放心缓存;而 tight=true 的分支沿着上界的路径一路走下来,路径数很少,缓存收益不大。把缓存数组开成 dp[pos][tight][mask] 的维度,就能轻松实现秒出答案。
- L 是数字位数例如 2024 有 4 位,L=4。它决定了枚举的层数,也是 DP 表的第一维大小。
- 2^m 是 mask 状态数如果 mask 要记录哪些数字已经出现过,那么 m 种数字就有 2^m 种可能组合。这是状态空间的主要来源。
- 乘以 10每一层都要枚举 0~9 共 10 种数字选择,这是转移的常数因子。
- 总复杂度这三个因子相乘,就是数位DP在记忆化后的渐进时间。实际实现中,tight 为 true 的分支很少,所以常数还会更小。
区间差分:要一段区间,先算两个前缀
委托人真正想统计的是 [L,R] 之间的合法数字数量,但数位DP最顺手的对象却是形如 [0,n] 的前缀区间。原因是 [0,n] 天然带一个明确的上界 n,让拆位枚举有了一个“最高限制”;而任意 [L,R] 有两个边界,会让状态图立刻复杂起来。那怎么办?用减法。
假设我们已经有一个函数 F(n),能算出从 0 到 n 有多少个合法数字。那么区间 [L,R] 的答案就是 [0,R] 的答案减去 [0,L-1] 的答案。这就像想知道书架上从第 L 本到第 R 本之间有多少本书,先数从开头到第 R 本,再减去从开头到第 L-1 本,剩下的正好是目标区间。
差分思想几乎是所有区间统计题的通用套路。它把“区间”这个二维的困难问题,转化成两个“前缀”一维的简单问题。在数位DP中尤其重要,因为你只需要把一个“上界如何限制状态”的逻辑写好,然后调用两次、做一次减法,就拿到了任意区间的答案。
- F 是前缀计数函数F(n) 表示 [0,n] 中满足条件的数字个数,它是数位DP直接计算的单元。
- F(R) 覆盖了太多[0,R] 中包含了所有 [0,L-1] 的数字,也包含我们要的 [L,R]。
- 减去 F(L-1)把左侧多余的部分去掉,剩下正好是区间内的数量。
- 边界处理若 L=0,则 L-1=-1 不该调用 F,直接返回 F(R)。更安全的做法是令 F(-1)=0。
经典应用:从数字计数到数位性质统计
数位DP的价值不在于它本身花哨,而在于它像一把万能钥匙,能打开一大批看似不同、实则同构的数字统计问题。最常见的应用是统计区间内某个数字的出现次数。比如“0 到 2024 中数字 7 一共出现多少次”,你可以把每一位单独拿出来看:千位是 7 的数有多少个?百位是 7 的数有多少个?……每个位独立统计后相加,正是按位枚举的天然优势。
第二类应用是“不含特定数字”的计数。比如文章开头的不含 7 的密码数,状态里只要有一层限制,当前位枚举时跳过被禁止的数字即可。第三类是更复杂的数位性质数:每个数字的数位和满足某个模数、数字从左到右递增、或者每个数字最多出现一次(需要 mask 状态)。这些条件五花八门,但都能通过扩展状态和转移规则,嵌入到同一个递归框架里。
在实际工作中,这类问题经常以“数据校验”的面孔出现。例如在一批 ID 中统计不包含数字 4 的 ID 数量,在订单号段中统计可用的优惠码个数。只要条件的定义落在“数位”这个粒度上,数位DP几乎总能给出比逐条扫描高效得多的解法。它不关心数字本身的业务含义,只关心十进制表示的形状,这正是它强大的地方。
实现技巧:让破解流程更稳更快
最后,我们把整个保险箱破解流程整理成一套可落地的操作指南。第一步,将整数 n 按十进制拆位,得到从高位到低位的 digits 数组。这一步看似简单,但要记住:若 n 是 0,数组是 [0] 而不是空数组;拆位顺序务必从最高位开始,因为状态转移依赖于“前缀与上界比较”的方向。
第二步,写递归函数 dfs(pos, tight, started, ……),在函数开头判断是否已经遍历完所有位,是则返回 1 或 0;然后根据 tight 计算 limit,枚举当前位。第三步,使用记忆化:当 tight=false 时,把结果存入缓存;这样同一个“自由状态”只需计算一次。实现时可以用一个多维数组,也可以用一个以 (pos, tight, mask) 为键的哈希表。
调试时有一个屡试不爽的方法:先写一个暴力函数检查小范围(比如 0 到 10000)的结果,再与数位DP的结果对比。如果小范围都对,再放开到大范围。边界情况尤其要测试 L=0、R=0、R 极大、n=10^k 这类值。只要把边界剪好、缓存键设计对,数位DP就能成为你处理数字区间统计问题的一把锋利刮刀。
AI 应用场景
号码资源合规统计
阶段:数据预处理与特征统计
解决的问题:在业务系统生成大量 ID 或优惠码时,需要快速统计某个号段内不包含敏感数字(如 4、7)的可用数量。传统做法是逐条扫描数据库,当号段规模达到亿级时速度不可接受。数位DP把统计转换为按位计数的解析计算,毫秒级返回精确结果。
具体案例:某电商平台准备在用户 ID 区间 [100000000, 999999999] 内发放一批免单券,规则要求 ID 中不能出现数字 4。算法工程师用数位DP写出 count_not_contain(L, R, 4),在半小时内完成迁移计算,避免了对 9 亿条 ID 的逐条过滤。
日志ID区间审计
阶段:模型训练数据清洗
解决的问题:AI 训练日志中需要保留满足特定数位性质的样本标识,例如“ID 中各数字不重复”的样本用于异常检测。传统 SQL 需要使用正则逐个匹配,效率低且难以扩展到 10^15 级 ID。数位DP可支持任意数位条件的高效统计。
具体案例:安全团队想统计一个月内产生的日志 ID 中“每位数字都不相同”的 ID 占比。后端工程师用数位DP实现了 count_unique_digit(L, R),仅需一次解析计算就知道总数,从而评估异常样本占比是否异常。
密码强度合规评估
阶段:策略搜索与规则计数
解决的问题:设计密码规则时,需要计算“某个区间内有多少个密码满足数位和等于 K”之类的约束数量。人工列举组合非常麻烦,数位DP可以将其建模为状态含“当前数位和”的计数问题,快速评估规则空间大小。
具体案例:金融 App 制定新密码策略,限制 6~10 位数字密码的各位数字之和必须为 20~30。产品经理需要知道可用密码总量。算法工程师套用数位DP,状态中维护数位和,五分钟算出可用密码约为多少亿个,顺利通过合规评估。
Python 演示
class DigitDP: def __init__(self, n, forbidden=None): self.digits = [] self.forbidden = forbidden if forbidden else set() n = max(n, 0) if n == 0: self.digits = [0] else: while n: self.digits.append(n % 10) n //= 10 self.digits.reverse() self.L = len(self.digits) self.memo = {} def count_not_contain(self): return self._dfs_not(0, True, False) def _dfs_not(self, pos, tight, started): if pos == self.L: return 1 if started else 0 key = (pos, tight, started) if key in self.memo: return self.memo[key] limit = self.digits[pos] if tight else 9 res = 0 for d in range(limit + 1): if d in self.forbidden: continue res += self._dfs_not(pos + 1, tight and (d == limit), started or d != 0) if not tight: self.memo[key] = res return res def main(): L, R = 1, 2024 dp = DigitDP(R, forbidden={7}) total_R = dp.count_not_contain() dp2 = DigitDP(L - 1, forbidden={7}) total_L = dp2.count_not_contain() ans = total_R - total_L print("区间 [1, 2024] 内不含数字 7 的整数个数:", ans) if __name__ == "__main__": main()
self.digits.append(n % 10)逐次取下一位数字,完成拆位;最后reverse()让它从高位到低位排列。self.L = len(self.digits)位数 L 是 DFS 的终止条件,也是状态空间的长度。_dfs_not(pos, tight, started)递归函数,返回从当前状态出发的合法方案数。limit = self.digits[pos] if tight else 9这就是 tight 约束的代码化身:贴死时取上界位,否则放开到 9。started or d != 0处理前导零:只有出现非零位后,之后的 0 才是有效数字。if not tight: self.memo[key] = res只有不受上界限制的分支才缓存,因为 tight 分支沿上界路径走,复用价值低。total_R - total_L区间差分的落地:两个前缀相减得到目标区间答案。
常见误区
误区写数位DP时,我习惯把所有参数 `(pos, tight, started)` 都作为 key 缓存,结果发现有些测试用例答案不对,但程序又很快,这应该是记忆化的魔法失灵了。
正解问题往往不在于“缓存了 tight”,而在于缓存时没有区分 tight 对结果的影响。当 tight=True 时,limit 取决于上界 digits[pos],同样的 (pos, True, started) 在不同上界下对应的 limit 可能不同,如果你直接用裸的 (pos, tight, started) 缓存,跨上界复用就会产生错误。正确的做法是只在 tight=False 时缓存,因为此时 limit 恒为 9,状态完全确定;tight=True 的路径很少,不缓存也没关系。
为何常见:初学者刚接触“记忆化减少重复计算”时,很容易把“能缓存”理解成“所有状态都能缓存”,忽略了 tight 分支隐含的上界依赖。
误区统计数字 0 的个数时,我直接在每个位置都允许填 0,结果算出来的 0 多了一大堆,明明区间只有几万个数字。
正解问题出在把前导零也当作真正的 0 了。数字 0017 在十进制表示中其实是 17,它的两个前导 0 不应该计入“0 出现次数”。处理方法是增加 started 状态:只有当 started=True 时,填 0 才计入;否则 0 只是占位符。统计数字 0 是这类题目里最容易翻车的陷阱,许多教材都会专门强调。
为何常见:人的直觉会把“digit=0“和”这个位置填了数字 0”画等号,但在数的标准表示里,最高位之前的 0 并不存在,这种表示层与计数层的偏差很容易被忽略。
误区我写区间统计时都套用 `F(R) - F(L-1)`,某次 L=0 程序直接崩溃或者返回奇怪结果,我才发现 F(-1) 没有定义。
正解公式 ans(L,R)=F(R)-F(L-1) 在 L=0 时需要特殊处理。因为 F(-1) 没有意义,不能让拆位函数处理负数。通常做法是:如果 L==0,直接返回 F(R);或者在 F 内部判断 n<0 时返回 0。边界防御是工程实现的重要一环,尤其在算法题里,L 可能为 0 而 R 很大。
为何常见:差分公式本身优美简洁,初学者容易只记公式、不记边界条件,等跑测试样例才发现负数会导致无限递归。
误区数位DP听起来就是关于“数位”的,所以应该只能处理十进制的数字吧?想统计二进制 0 的个数,感觉没法用。
正解数位DP的核心是按位枚举,而“位”天然属于任意进制。二进制、八进制、十六进制都可以做,只要把拆位换成对应进制的 n % base,枚举范围从 0~9 改成 0~base-1,上界 digits 也对应调整即可。很多竞赛题会考察 K 进制下的数位统计,原理完全一样。
为何常见:生活里最常见的进制是十进制,入门教程也总拿十进制举例,导致学习者把“十进制”误当成数位DP的固有属性,而没有注意到它只是一层外壳。
面试题
Q1. 数位DP最擅长解决下面哪一类问题?
- A.区间内满足数位条件的数字计数← 正确答案
- B.数组中的最大连续子段和
- C.图中的最短路径
- D.字符串的最长回文子串
答案:A。数位DP按数字的每一位做动态规划,核心应用就是统计 [L,R] 内满足特定数位条件的整数个数。最大子段和属于线性DP,最短路属于图论,最长回文子串属于字符串DP,都不是数位DP的典型场景。
Q2. 在数位DP状态中,tight 为 true 时,当前位的可枚举上界是什么?
- A.9
- B.恒为 0
- C.上界数字的当前位 digits[pos]← 正确答案
- D.当前已选前缀的数字之和
答案:C。tight 表示前缀已与上界贴死,为了不越界,当前位只能选到上界在这一位的数字 digits[pos]。如果 tight 为 false,说明前缀已经小于上界,当前位才能放开到 9。选项 C 正确。
Q3. 数位DP中,为了处理“每个数字最多出现一次”这类题目,通常需要哪些状态信息?
- A.pos:当前处理到第几位← 正确答案
- B.tight:当前是否受上界限制← 正确答案
- C.mask:已经使用过的数字集合← 正确答案
- D.当前数字的总和
答案:ABC。每个数字最多出现一次意味着要判断某个数字是否用过,需要 mask 记录已用集合,同时需要 pos 表示枚举进度,tight 保证不越界。数字总和是另外一类题目(如数位和)的状态,与“不重复”无关。
Q4. 计算区间 [L,R] 内合法数字个数,如果已经实现了前缀统计函数 F(n),正确的差分公式是?
- A.F(R) + F(L)
- B.F(R) - F(L-1)← 正确答案
- C.F(R) - F(L)
- D.F(L) - F(R)
答案:B。前缀函数 F(n) 统计 [0,n],所以 [L,R] 等于 [0,R] 去掉 [0,L-1],即 F(R)-F(L-1)。选择 F(R)-F(L) 会错误地漏掉 L 本身。
Q5. 在统计“数字 0 在 [0,n] 中出现的总次数”时,哪些处理是必要的?
- A.增加 started 状态区分前导零← 正确答案
- B.只在 started=true 时把填 0 计入答案← 正确答案
- C.把前导 0 也当作正常数字统计
- D.特殊处理 n=0 的边界← 正确答案
答案:ABD。前导 0 不是数字 0 的一部分,所以必须用 started 状态区分,并在未开始(started=false)时跳过前导零的计数;n=0 时要单独返回 1(因为 0 本身含一个 0)。选项 C 会导致多计无数个占位零。
Q6. 记忆化搜索中,为什么通常只对 tight=false 的状态做缓存?
- A.因为 tight=true 的状态一定会超时
- B.因为 tight=true 的状态依赖具体上界,跨上界复用会出错← 正确答案
- C.因为 tight=true 的状态数量太多,缓存放不下
- D.因为 tight=false 的状态不需要计算
答案:B。当 tight=true 时,limit 由上界 digits[pos] 决定,同样的 pos 在不同上界下含义不同,缓存后如果复用到别的上界就会出错。tight=false 时 limit 恒为 9,状态完全确定,缓存安全且能大幅提速。
Q7. 数位DP算法的时间复杂度通常与下面哪个因素无关?
- A.数字的位数 L
- B.状态中 mask 的规模
- C.数字本身的大小 n← 正确答案
- D.枚举当前位的 10 种数字
答案:C。数位DP的复杂度主要取决于位数 L、状态数量和每层枚举的数字种数,而不是数字 n 的绝对大小。这正是它能处理 10^18 大范围的原因。
Q8. 关于数位DP的前导零处理,下列说法正确的有?
- A.前导零只影响数字 0 的计数,不影响其他数字
- B.started 为 false 时填 0 不应更新 mask← 正确答案
- C.前导零的 0 不应被计入答案← 正确答案
- D.数字 0007 与 7 在数位统计中应视为同一个数← 正确答案
答案:BCD。前导零不仅影响数字 0 的计数,也可能影响“每位数字不重复”等题目的 mask 状态,所以 B 正确;前导零不是真实位,不应计入答案,C 正确;0007 的标准十进制表示就是 7,必须视为同一个数,D 正确。A 太绝对,错误。
Q9. 某数位DP问题要求统计 [0,n] 中“数位和为 K”的数字个数,应在状态中额外记录什么?
- A.当前数字的最大值
- B.当前已选数字之和← 正确答案
- C.当前最小的数字
- D.当前数字的奇偶个数差
答案:B。要判断最终数位和是否为 K,每填一位就得把当前的和传递下去,所以状态中要维护“当前已选数字之和”。选项 A、C、D 与数位和无关。
Q10. 对于数字 n 有 L 位、状态记录一个 m 位数字集合 mask 的数位DP,记忆化后的时间复杂度近似为?
- A.O(L × m)
- B.O(10^L)
- C.O(L × 2^m × 10)← 正确答案
- D.O(L × m × 10)
答案:C。状态数是 L × 2^m(pos 有 L 种,mask 有 2^m 种),每个状态转移枚举 10 个数字,所以总复杂度为 O(L × 2^m × 10)。选项 B 是暴力枚举,A、D 都低估了 mask 状态空间。