从KMP到动态规划:解决“设计密码”算法题与状态机实战
2026/9/23 11:15:06 网站建设 项目流程

1. 项目概述:从一道经典算法题到现实世界的密码设计

“设计密码”这个标题,乍一看像是要讲如何创建一个强密码来保护账户安全。但在算法和编程竞赛的语境里,它特指LeetCode上那道编号为1052的经典题目。这道题本身是一个动态规划问题,但它背后所蕴含的思维模型,却与我们日常设计系统、处理字符串匹配、乃至构思一个健壮的验证逻辑息息相关。我最初接触这道题时,觉得它就是个纯粹的算法练习,但在实际工作中反复遇到类似“状态机”、“模式匹配”、“避免子串出现”的需求后,才真正体会到它的价值。它教会我们的不是背下一个解法,而是如何将“禁止出现特定模式”这一约束,转化为可计算、可遍历的系统状态,这个思想在软件开发的很多场景下都能用上。

简单来说,这道题是这样的:你需要构造一个长度为N的密码,这个密码只能由小写字母组成。同时,你会得到一个字符串T(我们称之为“禁止串”)。你的目标是,设计出所有可能的密码,并且确保这个密码中不包含禁止串T作为其子串。最终,我们需要输出所有满足条件的密码数量。这听起来像是一个排列组合问题,但字符串T的存在,使得密码字符之间产生了强烈的依赖关系——下一个字符的选择,会受到前面已构造部分是否已经“接近”形成禁止串的影响。这就是动态规划大显身手的地方。

无论你是正在准备技术面试的求职者,还是对字符串处理、状态机设计感兴趣的开发者,理解这个问题都能带来切实的提升。它不仅锻炼你定义状态、推导转移方程的能力,更能让你深刻理解KMP算法中“最长公共前后缀”概念的精妙复用。接下来,我会带你从问题本质出发,一步步拆解思路,给出清晰的实现方案,并分享一些我在调试和优化过程中的实战心得。

2. 核心思路拆解:状态机与动态规划的融合

要解决这个问题,暴力枚举所有长度为N的小写字母字符串(共有26^N种可能),然后逐个检查是否包含子串T,在N稍大时(比如N>10)就完全不可行。我们必须找到更聪明的方法。核心的突破口在于,我们并不关心密码具体是什么,只关心它在构造过程中,与禁止串T的匹配程度。这种“匹配程度”,可以用一个状态来表示。

2.1 为什么是状态机?

想象一下,你正在一个字符一个字符地构造密码。同时,你手里拿着禁止串T,像一把尺子一样,在已经构造好的密码末尾进行比对。这个比对的过程,就是一个状态转移的过程。

我们定义状态j(0 <= j < M,其中M是禁止串T的长度) 表示:当前已构造的密码后缀,与禁止串T的前缀匹配的长度为j。换句话说,如果我们把当前密码的末尾和T的开头对齐,最多能连续匹配上j个字符。

  • 状态j = 0:意味着当前密码的末尾,与T的开头第一个字符都不匹配。这是最“安全”的状态,离形成禁止子串最远。
  • 状态j = k(0 < k < M):意味着当前密码的末尾,已经匹配上了T的前k个字符。这时我们处于“危险”的边缘,下一个字符的选择必须非常小心,否则就可能完成匹配(达到状态M,即形成了禁止子串)。
  • 状态j = M:这意味着我们已经完整匹配了禁止串T,即密码中包含了T作为子串。这个状态是我们需要避免的非法状态

我们的目标就是在构造长度为N的密码过程中,始终避免进入状态M。每一个新添加的字符,都会引起状态的转移。这个转移规则,正是KMP算法中核心的“失配函数”或“next数组”所描述的。

2.2 动态规划状态定义

有了状态机的概念,我们就可以用动态规划来计数了。我们定义:dp[i][j]:表示构造了长度为i的密码,且当前处于状态j(即密码后缀与T的前缀匹配长度为j)的方案数量

其中:

  • i的范围是[0, N],表示密码的当前长度。
  • j的范围是[0, M-1],因为我们禁止达到状态M(一旦达到就说明密码非法,不计入方案)。

初始状态:dp[0][0] = 1。这表示一个长度为0的空密码,它自然匹配T的长度为0,有1种方案(就是空本身)。

最终答案:我们需要所有长度为N,且状态不为M的密码。所以答案是sum(dp[N][j]),其中j0M-1

2.3 状态转移方程推导

这是最精妙的部分。假设我们已经计算好了dp[i][j],即长度为i、状态为j的方案数。现在我们要添加第i+1个字符(可以是26个小写字母中的任意一个),新的状态会变成多少?

设我们添加的字符为c。我们需要计算,在原有匹配长度为j的基础上,加上字符c后,新的匹配长度next_j是多少。这个过程,完全就是KMP算法中匹配过程的一个步骤。

我们可以模拟这个匹配过程:

  1. 如果c == T[j],那么匹配长度可以直接增加1,即next_j = j + 1
  2. 如果c != T[j],那么我们就不能直接匹配了。这时我们需要利用KMP的思想,回溯到一个更短的匹配位置。这个位置就是KMP的next数组(或者称为“部分匹配表”)里定义的值。我们记k = next[j](这里的next[j]表示当在T的第j位失配时,应该跳转到T的哪个位置继续尝试匹配)。然后我们再看c是否等于T[k],重复这个过程,直到匹配成功或者回到0。

注意:这里有一个关键的实现技巧。我们可以预处理一个二维数组auto[m][26]auto[j][c]表示当前状态为j时,下一个输入字符是c时,将会转移到的下一个状态next_j。这个预处理过程就是一次对KMP状态机的构建。有了它,我们在动态规划转移时,就可以在O(1)时间内通过查表得到next_j,而不需要在每次转移时都模拟KMP的跳转过程,极大提升了效率。

因此,状态转移方程可以描述为: 对于每个状态dp[i][j],对于26个可能的字符c(用0-25表示):

  1. 查表得到新状态next_j = auto[j][c]
  2. 如果next_j < M(即没有形成完整的禁止串),那么我们就可以进行转移:dp[i+1][next_j] += dp[i][j]

如果next_j == M,则意味着添加字符c后形成了禁止串,这个转移路径是无效的,我们直接舍弃。

2.4 复杂度分析

  • 时间复杂度:预处理auto数组需要 O(M * 26)。动态规划过程需要遍历i(0~N),j(0~M-1) 和 26个字符,所以是 O(N * M * 26)。由于M通常远小于N,且26是常数,所以可以认为是 O(N * M)。
  • 空间复杂度:dp数组是 O(N * M),但我们可以发现,dp[i+1]只依赖于dp[i],因此可以使用滚动数组优化到 O(M)。auto数组是 O(M * 26)。

3. 完整实现与代码详解

理解了核心思想后,我们来看具体的代码实现。我会用Python作为示例语言,因为它表达清晰,易于理解。代码将包含详细的注释,并分为几个关键步骤。

3.1 步骤一:构建KMP的next数组

next数组(为避免与Python关键字冲突,常命名为faillps)是KMP算法的核心。next[j]表示字符串T的前缀T[0:j](长度为j)中,最长的相等真前缀和真后缀的长度。

def build_kmp_next(pattern: str): """构建KMP算法的next数组(有时称为lps数组)。""" m = len(pattern) next_arr = [0] * m # next[0] 始终为0 j = 0 # 指向前缀的末尾 for i in range(1, m): # i指向后缀的末尾 # 当字符不匹配时,利用已经计算好的next数组回退j while j > 0 and pattern[i] != pattern[j]: j = next_arr[j - 1] # 如果字符匹配,则最长公共前后缀长度增加 if pattern[i] == pattern[j]: j += 1 next_arr[i] = j return next_arr

实操心得:构建next数组时,循环变量i从1开始,因为长度为1的子串没有真前缀和真后缀。内层的while循环是理解的关键,它体现了“利用已知信息避免重复匹配”的KMP思想。务必亲手模拟一下这个过程,比如对模式串"ababc"计算next数组,结果是[0, 0, 1, 2, 0]

3.2 步骤二:预处理状态转移表auto

这是将KMP思想融入动态规划的关键一步。auto[j][c]定义了状态机。

def build_automaton(pattern: str): """构建状态自动机。返回自动机转移表auto。""" m = len(pattern) next_arr = build_kmp_next(pattern) # auto[j][c]:状态j下,遇到字符c(映射为0-25)时转移到的下一个状态 auto = [[0] * 26 for _ in range(m)] for state in range(m): # 当前状态 for c_idx in range(26): # 尝试所有可能的字符 c = chr(ord('a') + c_idx) if state < m and c == pattern[state]: # 如果字符匹配,状态前进 new_state = state + 1 else: # 如果不匹配,则回退到next[state-1]的状态,并继续尝试匹配字符c # 注意处理state=0的情况 new_state = state while new_state > 0 and c != pattern[new_state]: new_state = next_arr[new_state - 1] if c == pattern[new_state]: new_state += 1 # 如果循环结束仍不匹配,new_state就是0 auto[state][c_idx] = new_state return auto

注意事项:在计算auto表时,内层循环对每个状态state和每个字符c都模拟了KMP匹配过程。虽然看起来是三重循环(state, c_idx, 可能的while回退),但平摊分析下来,每个字符c对于每个state的匹配过程与KMP算法本身复杂度一致,总复杂度仍是 O(M * 26)。这个预处理是值得的,它让后续的DP转移变得极其简单高效。

3.3 步骤三:动态规划计数

有了自动机,动态规划的过程就非常直观了。

def design_password_count(N: int, forbidden: str) -> int: """计算长度为N且不包含子串forbidden的密码总数。""" MOD = 10**9 + 7 # 通常题目要求对结果取模,防止溢出 m = len(forbidden) if m == 0: # 如果禁止串为空,那么任何密码都包含空串,方案数为0(除非题目特别定义) # 根据常见题意,通常认为空串是任何字符串的子串,所以返回0。 # 但有些题目可能规定N>=1,且空串不算。这里按返回0处理,具体需看题。 return 0 if N == 0: # 长度为0的密码只有空串,只要禁止串不是空串,它就合法。 return 1 if m > 0 else 0 auto = build_automaton(forbidden) # dp[j] 表示当前长度下,处于状态j的方案数。使用滚动数组。 dp = [0] * m dp[0] = 1 # 初始状态:长度为0,匹配长度为0,有1种方案(空密码) for i in range(N): # 构造密码的每一位 new_dp = [0] * m for state in range(m): # 遍历所有当前可能的状态 if dp[state] == 0: continue current_count = dp[state] # 尝试添加26个可能的字符 for c_idx in range(26): next_state = auto[state][c_idx] if next_state < m: # 如果新状态没有形成完整禁止串 new_dp[next_state] = (new_dp[next_state] + current_count) % MOD # 如果 next_state == m,则丢弃这个转移 dp = new_dp # 滚动到下一层 # 最终,所有长度为N且状态j < m 的方案都是合法的 result = sum(dp) % MOD return result

代码细节解析

  1. 取模:由于方案数可能巨大(26^N),题目通常要求对10^9+7取模。我们在每次加法后立即取模,避免中间结果溢出。
  2. 滚动数组:注意dpnew_dp的用法。dp代表长度为i时的状态方案数,new_dp代表长度为i+1时的状态方案数。每一轮迭代后,用new_dp覆盖dp。这节省了大量空间。
  3. 状态转移:最内层循环,对于当前状态statedp[state]种方案,每一种都可以通过添加26个字符中的任意一个,转移到新的状态next_state。只要next_state不等于m(即没有匹配完禁止串),我们就将方案数累加到new_dp[next_state]中。
  4. 边界处理:对N=0forbidden为空串的情况进行了处理。这是良好的编程习惯,能避免 corner case 错误。

3.4 步骤四:测试与验证

写完代码一定要测试。我们可以用一些简单例子来验证。

# 测试用例 if __name__ == "__main__": # 例1:N=2, forbidden="ab" # 所有2位小写字母串共26^2=676个。包含"ab"的串有:以"ab"开头的26个,以"a"结尾且第二位是'b'的26个,但"ab"本身重复计算了一次。所以包含"ab"的有26+26-1=51个。 # 那么不包含"ab"的就有676-51=625个。 print(design_password_count(2, "ab")) # 应输出 625 # 例2:N=3, forbidden="aa" # 总数为26^3=17576。 # 包含"aa"的串计算较复杂,可以用我们的函数验证。 print(design_password_count(3, "aa")) # 可以手动计算或信任程序 # 例3:N=1, forbidden="z" # 长度为1的密码有26个,包含"z"的只有"z"本身。所以合法密码有25个。 print(design_password_count(1, "z")) # 应输出 25 # 例4:N=10, forbidden="leetcode" # 这是一个较长的禁止串,手动计算不可能,用于测试程序效率。 print(design_password_count(10, "leetcode"))

调试技巧:对于复杂的动态规划,如果结果不对,可以尝试打印出auto转移表,或者在小规模N(如1,2,3)时,打印出每一轮迭代后的dp数组,与手工推导的结果进行比对。这是定位逻辑错误最有效的方法。

4. 从算法到应用:思维模型的延伸

解出这道题本身很有成就感,但它的价值远不止于此。这种“在构造序列时避免出现某个模式”的模型,在软件开发中有着广泛的应用场景。

4.1 场景一:输入验证与过滤

假设你在设计一个论坛系统的用户昵称注册规则。要求昵称不能包含某个敏感词汇T。如果只是简单地在注册时检查昵称是否包含T,那么用户可能会使用“T的变体”来绕过,比如在中间插入空格、特殊符号(如s e n s i t i v e)。一个更鲁棒的方法是,在客户端或服务端进行实时校验,当用户输入每个字符时,判断当前已输入的内容是否“接近”敏感词。这本质上就是一个在线状态机匹配问题。我们的auto状态机可以很好地集成到输入框的onChange事件处理中,一旦状态达到M(匹配完成),就立即提示用户输入了违规内容。

实操心得:在实际应用中,敏感词库可能很大。我们可以为每个敏感词单独构建一个状态机,然后并行运行(如Aho-Corasick自动机,正是多模式匹配的扩展)。或者,将所有敏感词构建成一棵Trie树,其失败指针(fail pointer)的构建思想与KMP的next数组如出一辙。

4.2 场景二:生成安全的随机标识符

有时我们需要生成一批唯一的、随机的标识符(如订单号、优惠券码),但希望这些标识符中绝对不出现某些令人误解或不当的字符组合(例如,不希望出现“IL1”、“O0”这样易混淆的序列,或者公司禁止的内部代码)。我们可以利用类似的DP思想进行“受约束的随机生成”:

  1. 定义状态(当前已生成序列的后缀与所有禁止模式的匹配情况)。
  2. 随机选择下一个字符时,只从那些不会导致状态转移到“非法状态”(即完整匹配任一禁止模式)的字符集合中挑选。
  3. 这样可以保证生成的任何标识符都是“干净”的。

这种方法比“先生成,后过滤”要高效得多,尤其当禁止模式较多或标识符长度较长时,可以避免大量无效的生成和比对。

4.3 场景三:编译原理与词法分析

在编写编译器或解释器的词法分析器(Lexer)时,需要将源代码字符串切分成一个个记号(Token),如标识符、关键字、数字、运算符等。识别每个记号的过程,就是一个模式匹配的过程。正则表达式引擎在底层实现时,常常会将正则表达式转换为一个非确定有限状态自动机(NFA),再确定化为DFA。对于关键字这种固定的字符串模式,其匹配过程完全可以看作是我们这里讨论的“单模式匹配状态机”。理解KMP和这种DP状态机,有助于理解更复杂的自动机原理。

5. 常见问题与优化策略实录

在实际编码和面试中,围绕这个问题会遇到一些典型问题。这里我总结一下。

5.1 问题一:如何输出具体的密码,而不仅仅是计数?

原题通常只要求计数,因为方案数可能天文数字。但如果面试官追问(或者题目变体要求),如何输出所有方案?这时深度优先搜索(DFS)结合状态机是更合适的方法。我们用DFS递归地构建密码,同时维护当前状态state。在每一层,我们遍历所有不会导致state转移到M的字符c,然后以新状态auto[state][c]进入下一层递归。当密码长度达到N时,就将当前路径加入结果列表。

注意事项:此方法仅适用于N非常小的情况(比如N<10),因为方案数是指数增长的。一定要和面试官确认需求,或者只要求输出前K个方案。

5.2 问题二:如果密码字符集很大(比如包含大小写和数字),怎么办?

我们的代码中,字符集大小26是一个常数。如果字符集扩大到62(a-z, A-Z, 0-9),甚至更大,算法复杂度依然是 O(N * M * C),其中C是字符集大小。预处理auto表的时间复杂度变为 O(M * C)。只要C不是特别大(比如上万),算法仍然是可行的。只需要修改代码中字符循环的范围和字符到索引的映射关系即可。

优化策略:如果字符集极大(例如Unicode所有字符),预处理的auto表将变得稀疏且巨大,内存可能无法承受。此时有两种思路:

  1. 使用字典存储转移:将auto从二维数组改为字典数组。auto[state]是一个字典,只存储实际可能发生的转移(即那些在模式串T中出现的字符,或者通过失败指针转移后能匹配的字符)。对于其他字符,其转移目标通常是0或某个通过失败指针回退到的状态。这可以节省大量空间。
  2. 在转移时实时计算:放弃预处理auto表,在DP转移的每一步,当需要计算next_state时,都实时运行一次KMP匹配过程。这样空间复杂度降到最低,但每次转移的时间成本从O(1)升到O(M)(因为可能回退)。总复杂度变为 O(N * M^2),在M不大时也可接受。

5.3 问题三:如何处理多个禁止串?

这是更现实的场景。LeetCode上也有类似题目(如“包含所有单词的最小子串”的某种变体)。此时,单模式的状态机就不够用了。需要升级到多模式匹配自动机,即著名的Aho-Corasick (AC) 自动机

AC自动机可以看作是KMP算法在多模式上的扩展。它首先将所有禁止串构建成一棵Trie树,然后为每个节点构建失败指针(fail pointer),其含义与KMP的next数组类似:当当前字符匹配失败时,跳转到失败指针所指的节点继续尝试。这样,我们在构造密码时,状态就是AC自动机上的节点。动态规划的定义变为:dp[i][node]:构造了长度为i的密码,当前走到AC自动机的node节点。 转移时,对于每个字符c,我们从node节点出发,沿着自动机的边(或失败指针)走到下一个节点next_node。如果next_node及其所有通过失败指针链能到达的节点中,任何一个是某个禁止串的终点(即被打上“终止标记”),那么这条转移路径就是非法的。

核心区别:状态从一维的匹配长度j,变成了Trie树节点。非法状态不再是某个固定的M,而是任何带有“终止标记”的节点(表示匹配了至少一个禁止串)。AC自动机的构建和DP转移比单模式复杂,但核心思想一脉相承。

5.4 问题四:大数取模的细节

题目通常要求结果对10^9+7取模。这里有几个坑:

  1. 加法后立即取模:如代码所示,new_dp[next_state] = (new_dp[next_state] + current_count) % MOD。避免中间和溢出。
  2. 使用滚动数组时的初始化:每一轮开始,new_dp必须初始化为全零。
  3. 最终求和取模result = sum(dp) % MOD。虽然dp中每个元素都已经取过模,但它们的和可能仍然超过MOD,所以最终求和后需要再次取模。

一个更隐蔽的坑是,如果题目要求计算的是“方案数对MOD取模的结果”,那么在整个计算过程中,包括预处理,都不应进行任何可能破坏同余性质的操作(如除法)。我们的算法只涉及加法和乘法(常数26可以看作加法),所以是安全的。

6. 举一反三:相关题目与练习建议

掌握“设计密码”这道题后,你可以尝试解决一系列相关的、难度递进的题目,来巩固和深化理解:

  1. LeetCode 1397. 找到所有好字符串:这是“设计密码”的升级版。密码需要满足:1) 长度固定为n;2) 由特定字符集组成;3) 不包含任何“邪恶”子串(多个禁止串);4) 字典序位于两个给定字符串之间。这需要结合AC自动机(处理多模式禁止)、数位DP(处理上下界约束)和动态规划。是道Hard题,但思路是相通的。
  2. LeetCode 467. 环绕字符串中唯一的子字符串:这道题关注的是字符串本身的性质,但其中“状态”的定义和转移的思想也有异曲同工之妙。它要求我们找到字符串p在无限环绕字符串"...zabcde...xyzabcde..."中,作为子串出现的唯一字符串数量。解题时,我们同样需要记录以某个字符结尾的、满足环绕条件的最长子串长度,这可以看作是一种状态。
  3. HDU 2457 DNA repair:一道经典的AC自动机+DP问题。给定一个DNA字符串(由AGCT组成),其中包含一些“致病模式串”(禁止串)。问最少需要修改原字符串中的多少个字符(每个字符可以改成AGCT中的任意一个),才能使其不包含任何致病模式串。dp[i][node]可以定义为处理到原串第i个字符、走到AC自动机node节点时的最小修改次数。
  4. POJ 1625 Censored!:同样是AC自动机+DP,要求计算长度为N的、由给定字母表组成的、不包含任何禁止单词的字符串总数。几乎是“设计密码”的多模式版本。

练习建议:建议的刷题顺序是:先彻底理解并能手撕KMP算法(LeetCode 28),然后搞定设计密码(LeetCode 1052思路题),接着学习AC自动机模板,最后挑战HDU 2457LeetCode 1397。每做一道题,都要问自己:状态是什么?如何转移?边界条件是什么?如何优化空间?只有通过反复练习和思考,这种基于状态机的动态规划模型才能内化成你自己的解题武器。

这道题的精髓不在于记忆模板,而在于理解“将字符串匹配过程抽象为状态转移”这一核心思想。当你再遇到需要处理“序列约束”、“模式避免”的问题时,不妨想想:能不能设计一个状态机?状态如何定义?转移如何发生?想明白了这些,问题就解决了一大半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询