动态规划去重计数:从本质上升序列问题解析状态设计与去重逻辑

📅 发布时间:2026/8/29 3:13:10
动态规划去重计数:从本质上升序列问题解析状态设计与去重逻辑 1. 项目概述从一道国赛真题看动态规划的深度应用最近在整理历年蓝桥杯国赛的经典题目第十一届那道“本质上升序列”让我印象尤为深刻。这题乍一看像是普通的动态规划DP问题但它的“本质”二字以及序列本身的特性让解题思路变得非常微妙。很多朋友在初次接触时要么会漏算要么会重复计算最后发现答案总对不上。这道题考察的不仅仅是递推公式的书写更是对“状态定义”和“去重逻辑”的深刻理解。它非常适合用来检验和提升对动态规划中“子问题独立性”和“计数不重不漏”这两个核心原则的掌握程度。无论你是正在备赛的选手还是想巩固DP思想的开发者通过深度拆解这道题都能获得远超题目本身的收获。简单来说题目会给你一个字符串比如 “lanqiao”你需要找出其中所有“本质不同”的上升子序列的数量。这里的“上升”指的是子序列中每个字符的ASCII码严格递增即后一个字符大于前一个字符。“本质不同”则意味着即使两个子序列由原字符串中不同位置的字符组成但只要它们最终形成的字符串一模一样就只能算作一个。正是这个“本质不同”的条件让问题从简单的枚举跳升到了需要精巧状态设计的层面。接下来我将结合我的解题和教学经验带你一步步拆解这道题的思维过程、实现细节以及那些容易踩进去的坑。2. 问题核心解析与动态规划状态设计2.1 理解“本质不同”与暴力枚举的困境我们先明确一下概念。对于一个字符串比如abcba它的一个上升子序列需要满足字符顺序取自原串且索引递增同时字符本身严格递增。例如ab是一个有效的上升子序列取自索引0的‘a’和索引1的‘b’。ac也是索引0的‘a’和索引2的‘c’。那么“本质不同”是什么意思呢考虑子序列ab。在原串abcba中实际上可以找到两个索引组合都能形成ab索引 (0, 1) - ‘a’ ‘b’索引 (0, 4) - ‘a’ ‘b’注意索引4是最后一个‘a’后面的‘b’如果我们简单地枚举所有可能的索引组合“ab”会被计算两次。但题目要求“本质不同”所以它只能被计为1次。这就是暴力DFS或组合枚举会直接超时且结果错误的原因。我们需要一种方法在计数的过程中自动为每个“唯一的字符串”只计数一次而不管它在原串中有多少种形成方式。2.2 动态规划的思维转向以字符结尾进行归类动态规划擅长处理这类“计数”且需要“去重”的问题关键在于如何定义“状态”。一个常见的切入点是考虑以某个特定字符结尾的上升子序列。为什么这么想因为对于一个确定的结尾字符比如 ‘c’所有以它结尾的上升子序列其最后一个字符都是 ‘c’。那么要形成一个新的以 ‘c’ 结尾的序列我们只需要找到在 ‘c’ 之前、且字符小于 ‘c’ 的那些字符然后把 ‘c’ 接在它们形成的各种序列后面即可。同时单独的 ‘c’ 本身也是一个合法的子序列。但这里依然有“本质不同”的陷阱。假设原串是“acbc”我们想计算以最后一个 ‘c’索引3结尾的本质不同上升子序列数量。小于 ‘c’ 的字符有 ‘a’ 和 ‘b’。对于 ‘a’索引0以 ‘a’ 结尾的本质不同序列集合假设我们已知是{“a”}。对于 ‘b’索引2以 ‘b’ 结尾的本质不同序列集合是{“b”}。那么将 ‘c’ 接在后面我们会得到{“ac” “bc”}。再加上{“c”}本身。然而“ac”这个序列是否可能通过其他路径比如索引1的 ‘c’已经形成过了呢如果之前计算以索引1的 ‘c’ 结尾的序列时也已经从 ‘a’ 生成了“ac”那么这里就会重复。这说明仅仅定义“以字符结尾”还不够因为同一个字符可能在字符串中出现多次。我们必须将状态定义得更加精确dp[i] 表示以原字符串中第 i 个位置索引 i的字符结尾的、本质不同的上升子序列的数量。这个定义的精妙之处在于它将序列的“结尾”精确到了具体的某个字符出现的位置从而为后续的去重逻辑打下了基础。2.3 状态转移方程的推导与去重关键定义了dp[i]我们如何计算它呢根据子序列的定义和“上升”的要求要形成以s[i]结尾的子序列我们可以单独自己s[i]本身就是一个长度为1的子序列。所以初始至少为1。接在前面某个序列之后找到所有在i之前的索引j(0 j i)满足s[j] s[i]。那么所有以s[j]结尾的本质不同子序列后面加上s[i]都能形成一个新的以s[i]结尾的子序列。因此一个初步的转移方程是dp[i] 1 sum(dp[j])对于所有j i且s[j] s[i]。但是这个方程有严重问题它会导致重复计数违反了“本质不同”。考虑字符串“abab”dp[0](以第一个 ‘a’ 结尾): 只有“a”dp[0]1。dp[1](以第一个 ‘b’ 结尾): 它可以从j0(‘a’ ‘b’) 转移。dp[1] 1 dp[0] 1 1 2。序列集合是{“b” “ab”}。正确。dp[2](以第二个 ‘a’ 结尾): 没有j满足s[j] ‘a’(因为 ‘a’ 是最小的)所以dp[2] 1。序列是{“a”}。注意这里出现了两个位置索引0和2都能形成“a”。dp[3](以第二个 ‘b’ 结尾): 满足s[j] ‘b’的j有 0(‘a’), 2(‘a’)。按照公式dp[3] 1 dp[0] dp[2] 1 1 1 3。这意味着我们得到了3个以第二个 ‘b’ 结尾的本质不同序列。让我们手动列举一下以第二个 ‘b’ (索引3) 结尾的所有本质不同上升子序列“b”(自身)“ab”(从索引0的 ‘a’ 接上)“ab”(从索引2的 ‘a’ 接上) ——看这里重复了实际上“ab”这个序列无论你是通过 (索引0, 索引3) 还是 (索引2, 索引3) 得到的它都是同一个“本质”的序列。所以正确的结果应该是2个{“b” “ab”}。问题出在哪里出在我们把dp[0]和dp[2]直接加起来了。dp[0]代表了所有以第一个 ‘a’ 结尾的序列集合{“a”}dp[2]代表了所有以第二个 ‘a’ 结尾的序列集合{“a”}。当我们要用这些序列后面加上 ‘b’ 来生成新序列时从第一个集合和第二个集合都会生成“a” “b” “ab”。由于“a”在两个集合中是“本质相同”的都是字符串“a”所以它们生成的“ab”也是本质相同的应该被去重。所以正确的转移逻辑不是简单相加dp[j]而是应该只加上那些能产生“新序列”的贡献。更准确地说对于当前字符s[i]我们关心的是所有结尾字符小于s[i]的、本质不同的序列。当我们在不同的位置j和k遇到相同的字符比如都是 ‘a’时以它们结尾的序列集合可能存在大量重复。解决方案是在累加贡献时对于相同的字符我们只取最后一次出现时的dp值作为贡献或者更准确地说在向前遍历j时我们需要维护一个针对字符的贡献值并不断更新。一种清晰且高效的做法是初始化一个长度为26如果只有小写字母的数组lastlast[ch]表示字符ch在当前遍历过程中最后一次出现时以其结尾的本质不同序列总数。初始化为0。从左到右遍历字符串的每个位置i字符为c s[i]。计算dp[i]它等于 1自身加上所有比字符c小的字符ch对应的last[ch]之和。因为last[ch]记录了到当前位置之前所有以ch结尾的本质不同序列总数并且由于我们只取“最后一次”的统计它自动完成了对相同字符结尾序列的去重。更新last[c]为dp[i]。这意味着对于后面更大的字符它们如果要接在c后面将以dp[i]这个最新的、包含所有以当前这个c结尾的序列总数作为基础。核心原理理解为什么更新last[c]为dp[i]能去重因为dp[i]已经包含了所有以当前位置的c结尾的序列。当后面再遇到相同的字符c’在另一个位置时我们会用dp[i’]覆盖掉last[c]。这样对于更后面更大的字符它们累加last[c]时使用的是最新的、最全的以c结尾的序列集合而不会重复累加历史上旧位置产生的、可能已经重复过的序列集合。这保证了对于每个字符值我们始终只将其最新的、完整的贡献传递给未来。2.4 最终答案的获取遍历完整个字符串后我们得到了每个位置i的dp[i]它表示以s[i]结尾的本质不同上升子序列的数量。那么整个字符串的所有本质不同上升子序列总数就是所有dp[i]的和吗并不是。因为dp[i]包含了所有以s[i]结尾的序列。而整个字符串的上升子序列可以以任何字符结尾。所以答案确实是所有dp[i]的累加和。但是等等这里需要再思考一下去重。不同的dp[i]之间会不会有重复的序列例如序列“a”既在以第一个 ‘a’ 结尾的集合里也在以第二个 ‘a’ 结尾的集合里。这个顾虑是合理的但我们在计算最终答案时不能直接求和dp[i]。因为dp[i]的定义是“以第 i 个位置的字符结尾”如果字符串有重复字符直接求和就会把同一个“本质序列”重复计算多次每次出现在不同结尾位置都算一次。正确的答案是所有last[ch]的和其中ch遍历所有可能的小写字母。因为在遍历结束后last[ch]中存储的正是以字符ch结尾的、最新的、也是最全的本质不同序列总数。这个值已经涵盖了所有位置上的贡献并且对于每个字符ch只计数了一次完美符合“本质不同”的要求。所以最终答案 sum(last[0..25])。3. 算法实现与代码逐行解析理解了核心原理后我们来看代码实现。这里提供Python版本的实现因为它清晰易懂。算法的时间复杂度是 O(n * 26)对于长度n的字符串和固定26个字母可以认为是 O(n)空间复杂度是 O(26)。def count_distinct_increasing_subsequences(s: str) - int: 计算字符串 s 中本质不同的上升子序列的个数。 上升子序列原字符串中抽取的一个子序列其字符严格递增按ASCII码。 本质不同即使子序列在原串中抽取的位置不同只要形成的字符串相同就算同一个。 MOD 10**9 7 # 蓝桥杯常见要求结果可能很大需要取模 # last 数组记录每个字符‘a’到‘z’最后一次出现时以其结尾的本质不同序列数 last [0] * 26 for char in s: idx ord(char) - ord(a) # 将字符映射到 0-25 的索引 # 计算以当前字符 char 结尾的新序列数量 dp # 1. 它自己单独作为一个序列 dp 1 # 2. 它可以接在所有比它小的字符结尾的序列后面 for smaller_idx in range(idx): dp (dp last[smaller_idx]) % MOD # 更新 last 数组对于当前字符其最新的总序列数就是 dp last[idx] (last[idx] dp) % MOD # 最终答案是所有字符对应的序列数之和 total 0 for count in last: total (total count) % MOD return total # 示例 if __name__ __main__: test_str lanqiao result count_distinct_increasing_subsequences(test_str) print(f字符串 {test_str} 的本质不同上升子序列个数为: {result})代码逐行解析MOD 10**9 7蓝桥杯竞赛中答案往往非常大需要取模以避免整数溢出。这是一个常见的质数模数。last [0] * 26初始化贡献数组。last[i]对应字符chr(ord(‘a’)i)。初始时每个字符结尾的序列数都为0。for char in s:遍历输入字符串的每一个字符。idx ord(char) - ord(‘a’)将当前字符转换为0到25的索引方便数组操作。dp 1初始化当前状态dp。这个1代表序列[char]即仅包含当前字符本身的序列。for smaller_idx in range(idx):遍历所有比当前字符小的字符索引。range(idx)生成 0 到 idx-1正好对应了所有ASCII码小于char的字符。dp (dp last[smaller_idx]) % MOD这是状态转移的核心。last[smaller_idx]存储了到当前位置之前所有以字符chr(ord(‘a’)smaller_idx)结尾的本质不同序列总数。将所有这些小字符的序列数加起来就得到了“当前字符可以接在后面的所有基础序列”的数量。加上自身的1就得到了以当前字符结尾的、新的本质不同序列总数dp。last[idx] (last[idx] dp) % MOD关键更新步骤。将计算出的dp加到last[idx]上。注意这里是“加等于”而不是直接赋值。为什么考虑字符串“aa”。第一个 ‘a’idx0dp1last[0]从0变为1。第二个 ‘a’idx0计算dp。此时smaller_idx循环range(0)为空所以dp1。然后执行last[0] (1 1) % MOD 2。这表示以 ‘a’ 结尾的本质不同序列总数为2。它们分别是第一个 ‘a’ 形成的“a”第二个 ‘a’ 形成的“a”。但由于“本质不同”这两个“a”是同一个序列所以last[0] 2是错误的正确答案应该是1。啊哈这里发现了一个关键错误。我们之前的逻辑last[idx] (last[idx] dp) % MOD会导致重复累加相同本质的序列。对于相同的字符我们不应该将新旧位置的dp简单相加因为新位置产生的序列可能和旧位置的序列“本质相同”。修正逻辑实际上对于当前字符chardp计算的是以“当前位置”的这个char结尾的、全新的本质不同序列数。而last[idx]应该表示的是到目前位置为止所有以字符char结尾的“本质不同”序列的总数。当我们在一个新位置遇到相同的字符char时新计算出的dp中包含了一些与之前last[idx]中“本质相同”的序列比如单独的char本身。因此不能简单相加。正确的更新方式是last[idx] dp。但这就够了吗让我们用“abab”来验证修正后的逻辑。初始化last [0]*26字符 ‘a’ (索引0):idx0,dp1,last[0] 1。 (序列:{“a”})字符 ‘b’ (索引1):idx1,dp 1 last[0] 2,last[1] 2。 (序列:{“b” “ab”})字符 ‘a’ (索引2):idx0,dp 1(因为range(0)为空)last[0] 1。这里将之前的1覆盖了。(序列:{“a”}但注意这个集合和第一步的集合本质上是同一个{“a”}我们只是用新值覆盖了旧值)字符 ‘b’ (索引3):idx1,dp 1 last[0] 1 1 2,last[1] 2。 (序列:{“b” “ab”})遍历结束计算总和sum(last) last[0] last[1] 1 2 3。但我们手动枚举“abab”的所有本质不同上升子序列“a”“b”“ab”。只有3个。结果正确修正后的核心理解last[ch]始终维护着到当前遍历位置为止以字符ch结尾的、所有“本质不同”序列的最新、最全的集合所对应的计数。当在位置i遇到字符c时我们计算出的dp代表了“必须使用位置i的这个字符c作为结尾”所能形成的全新序列集合。而这个集合与之前last[c]所代表的集合使用更早出现的字符c结尾的序列集合之间的关系是新集合包含了旧集合中的所有序列因为可以用当前位置的c替换旧序列中结尾的c而形成相同字符串并且还多了一个单独的c如果旧集合里没有的话但实际上旧集合里肯定有。但更重要的是对于“本质不同”的计数我们只关心最终的字符串。由于新集合能生成的所有字符串旧集合也都能生成通过替换结尾字符为更早的c所以新集合并没有贡献新的“本质不同”的字符串。因此last[c]的值不应该改变等等这个推论似乎与“abab”的例子矛盾。在“abab”中第二个 ‘a’ 出现时我们将其last[‘a’]从1更新为1覆盖看起来没变。但关键是第二个 ‘b’ 出现时它计算dp用到的last[‘a’]是1这是正确的。如果last[‘a’]始终保持为第一个 ‘a’ 出现时的值1结果也一样。让我们审视一个更复杂的例子“acbc”来验证last[idx] dp这个更新规则。‘a’: idx0, dp1, last[0]1。 {“a”}‘c’: idx2, dp1last[0]last[1]? last[1]初始为0所以 dp112, last[2]2。 {“c” “ac”}‘b’: idx1, dp1last[0]112, last[1]2。 {“b” “ab”}‘c’: idx2, dp1last[0]last[1]1124,last[2]4。 这里更新了 last[2]。最终last [1 2 4 0 ...]总和7。 手动枚举“acbc”的本质不同上升子序列长度1: a b c长度2: ab ac bc长度3: abc 总共7个。正确。在这个例子中第二个 ‘c’ 出现时last[2]被更新为4。这4个序列是“c”(新c自身)“ac”(a新c)“bc”(b新c)“abc”(ab新c)。而第一个 ‘c’ 形成的序列是{“c” “ac”}。可以看到新集合包含了旧集合并新增了“bc”和“abc”。所以更新last[2]是必要的它反映了以 ‘c’ 结尾的序列集合的扩充。那么last[idx] dp和last[idx] last[idx] dp的区别到底是什么关键在于dp的含义。在我们修正后的、正确的算法中dp代表的是以当前位置的字符结尾的、全新的序列数量但这个“全新”是相对于“必须以这个位置的字符为结尾”而言的。当我们将last[idx]更新为dp时我们实际上是在说“从现在开始以字符ch结尾的所有可能序列就是当前这个位置产生的这些序列。” 这隐含了一个假设后面出现的相同字符所形成的序列集合完全包含了之前位置形成的集合。这个假设成立吗在“上升子序列”的语境下这个假设是成立的。因为对于任何一个以之前位置j的字符c结尾的序列我们可以把结尾字符替换成现在位置i的字符c从而得到一个以i结尾的、字符串完全相同的序列。所以以当前位置i的c结尾的序列集合确实包含了所有以之前位置j的c结尾的序列所能形成的所有字符串。因此我们只需要保留最新的、最全的这个计数dp即可。last[idx] dp这个操作实际上是用新的集合覆盖了旧的集合因为新集合在“字符串集合”的意义上是旧集合的超集。而last[idx] last[idx] dp的错误在于它把新旧集合的计数简单相加相当于把同一个字符串集合重复计数了因为新旧集合包含大量相同字符串。所以最终正确的状态转移和更新逻辑如下dp 1 sum(last[0..idx-1])。这计算了以当前字符结尾的、所有可能的本质不同序列数。sum(last[0..idx-1])代表了所有以更小字符结尾的序列总数这些序列后面加上当前字符都能形成新序列。last[idx] dp。用这个新的、更全的计数覆盖旧值。最终答案 sum(last[0..25])。让我们把修正后的逻辑应用到“aa”上第一个 ‘a’: idx0 dp1 last[0]1。第二个 ‘a’: idx0 dp1 (因为 sum(last[0..-1]) 为空) last[0]1。 总和1。正确因为只有“a”这一个本质序列。应用到“abab”‘a’: dp1 last[0]1。‘b’: dp1last[0]2 last[1]2。‘a’: dp1 last[0]1。‘b’: dp1last[0]2 last[1]2。 总和 last[0]last[1]123。正确。因此我们得到了最终正确的代码实现def count_distinct_increasing_subsequences(s: str) - int: MOD 10**9 7 last [0] * 26 # 记录每个字符结尾的序列总数 for char in s: idx ord(char) - ord(a) # 计算以当前字符结尾的新序列数 # 1. 当前字符本身 dp 1 # 2. 接在所有比它小的字符结尾的序列后面 for i in range(idx): dp (dp last[i]) % MOD # 关键更新当前字符的计数为dp覆盖而非累加 last[idx] dp # 总和即为答案 total 0 for count in last: total (total count) % MOD return total4. 深入讨论边界条件、复杂度与扩展4.1 空序列是否计入这是一个常见的疑问。题目通常的表述是“本质上升序列”在没有特别说明的情况下默认不包括空序列。因为空序列通常不被认为是一个“序列”或“子序列”。在我们的算法中dp从1开始计数代表字符自身最后求和也没有加入空序列的计数所以符合一般要求。如果题目明确要求包含空序列只需在最终答案上加1即可。4.2 算法复杂度分析时间复杂度O(n * 26)其中n是字符串长度。因为对于每个字符我们需要遍历所有比它小的字符最多25个来累加last值。由于字母表大小固定为26所以也可以认为是 O(n)。空间复杂度O(26)即last数组的大小常数级别。这个效率对于蓝桥杯竞赛中字符串长度达到 10^5 数量级也是完全可行的。4.3 从“本质上升序列”到一般“本质不同子序列”计数这道题的思路可以扩展。如果去掉“上升”条件即统计一个字符串所有“本质不同”的子序列不要求字符递增该如何做状态定义可以类似设dp[i]表示以字符串前i个字符中以s[i]结尾的本质不同子序列数。但转移时需要从所有j i的dp[j]转移过来只要s[j] ! s[i]以避免重复。实际上通用解法的核心依然是去重。一种高效的方法是使用一个数组last[256]记录每个字符上次出现时的总贡献。遍历字符串记total为当前所有本质不同子序列数含空序列。遇到字符c时以c结尾的新序列数new total 1total是所有旧序列后面加c1是序列[c]自身。但需要减去上一次字符c出现时所贡献的序列数因为那些序列后面加上当前的c会与上次形成的序列重复。所以new total - last[c] 1。然后更新total total new并更新last[c] new或last[c] last[c] new取决于定义。最终答案通常是total包含空序列。这体现了同一种去重思想对于相同字符只考虑最新一次出现带来的新增贡献。4.4 蓝桥杯真题实战与调试技巧在蓝桥杯比赛中遇到此类题目建议按以下步骤进行仔细审题明确“上升”的定义通常是严格递增ASCII码“本质不同”的定义。确认是否包含空序列。小规模验证像我们上面做的那样用手工或简单程序枚举小例子如“a”“ab”“aa”“aba”“abc”的结果用来验证算法逻辑。设计状态与转移紧扣“以特定位置或字符结尾”来定义状态并仔细推演转移方程特别是去重逻辑。代码实现与取模由于答案可能巨大务必在每次加法运算后取模包括内部累加和最终求和。测试用例边界用例空字符串如果允许、单字符字符串、所有字符相同的字符串如“aaaa”、严格递增的字符串如“abcdef”、随机字符串。验证对于短字符串可以用DFS暴力枚举所有子序列并去重与你的DP结果对比。一个常见的调试技巧是在代码中打印出每一步的dp值和last数组与手工计算的过程对比能快速定位逻辑错误。5. 常见错误与避坑指南在解这道题以及类似DP计数问题时以下几个坑点需要特别注意状态定义模糊导致重复计数这是最核心的错误。如果简单地定义dp[i]为以i结尾的子序列数并在转移时累加所有s[j] s[i]的dp[j]就会在字符串有重复字符时重复计数。必须理解并应用last数组来维护“以某个字符值结尾”的最新总数而不是“以某个位置结尾”的简单累加。更新last数组的逻辑错误正如我们深入讨论的是last[idx] dp还是last[idx] dp结论是last[idx] dp。因为dp已经包含了之前所有以更小字符结尾的序列接上当前字符所能形成的所有新序列以及当前字符自身。它代表了以“当前这个字符”结尾的完整集合。覆盖操作实现了去重。忽略取模运算蓝桥杯的答案往往很大需要取模。务必在每一次加法操作后立即取模包括内层循环累加dp时和最终求和时。否则中间结果可能溢出导致错误。错误理解“上升”“上升”通常指严格递增 ()而不是非递减 ()。仔细看题目的描述一般是“严格递增”。初始化错误last数组初始化为0是正确的因为开始时没有任何字符结尾的序列。dp的初始值1代表了序列只包含当前字符本身。循环范围错误内层循环for smaller_idx in range(idx):是遍历所有比当前字符小的字符。如果写成for smaller_idx in range(i)i是字符索引位置就完全错了因为last数组是按字符索引的不是位置索引。最终求和对象错误答案应该是sum(last)而不是sum(dp)。dp是每个位置结尾的序列数对于重复字符不同位置的dp值可能对应相同的本质序列直接求和会重复。而last数组已经对每个字符值进行了去重存储的就是以该字符结尾的本质不同序列总数。为了更直观我将常见错误和正确做法总结如下表错误点错误表现或代码后果正确做法状态转移重复计数dp[i] 1 sum(dp[j] for ji if s[j]s[i])然后ans sum(dp)字符串有重复字符时答案偏大使用last数组按字符去重dp 1 sum(last[0:idx])last[idx] dplast更新错误last[idx] dp对相同字符结尾的序列重复累加last[idx] dp(覆盖更新)未取模或取模不当只在最后结果取模或忘记取模中间结果溢出得到错误答案在所有加法运算后立即取模如dp (dp last[smaller]) % MOD“上升”条件误用使用进行判断将非递减序列也算入答案偏大使用进行严格递增判断最终答案求和错误ans sum(dp)重复字符导致本质序列被多次计数ans sum(last)内层循环对象错误for j in range(i):(i为位置索引)逻辑完全错误last数组索引越界或结果无意义for k in range(idx):(idx为字符索引0-25)掌握这些要点你就能稳健地解决“本质上升序列”及其变种问题。这道题的价值不仅在于其本身更在于它提供了一种处理“本质不同”计数问题的经典DP思路即通过维护“以某个特征值如字符结尾”的最新状态来避免重复。这种思想在字符串计数、序列分析等场景中非常有用。