状态压缩与前缀和的魔力:破解LeetCode 1371元音之谜
哈喽,各位算法爱好者们!今天我们要一起探索一道非常有趣的 LeetCode 题目——第 1371 题:“每个元音包含偶数次的最长子字符串”。这道题不仅能考察我们对字符串处理的熟悉程度,更是巧妙地融合了位运算中的“状态压缩”和经典的“前缀和”思想。别担心,我会像你的老朋友一样,用最通俗易懂的方式,带你一步步揭开它的神秘面纱!
1. 邂逅难题:LeetCode 1371 初体验
想象一下,你拿到一个神秘的密码本(一个字符串 s
),你的任务是找出其中最长的一段连续密码(子串),这段密码有一个奇特的规则:里面所有的元音字母 ‘a’, ‘e’, ‘i’, ‘o’, ‘u’ 都不多不少,正好出现了偶数次(0次也被认为是偶数次哦!)。
题目简介:
- 名称: LeetCode 1371. 每个元音包含偶数次的最长子字符串 (Find the Longest Substring Containing Vowels in Even Counts)
- 难度等级: 中等 (Medium)
- 题型分类: 字符串 (String), 位运算 (Bit Manipulation), 前缀和 (Prefix Sum), 哈希表 (Hash Table)
是不是感觉有点意思?初看之下,可能会觉得要枚举所有子串再逐个检查,但那样效率可不高。别急,我们先来分析一下这类题目的“蛛丝马迹”。
2. 洞察先机:题型特征与识别技巧
很多时候,解题的钥匙就藏在题目的描述中。对于这类问题,我们如何才能快速识别并找到突破口呢?
- 输入输出特征: 输入通常是一个序列(如字符串、数组),输出是满足特定条件的子序列的某个属性(如长度、数量、是否存在)。
- 关键词:
- “最长/最短子串/子数组”:这通常暗示着可能需要遍历或者用动态规划、滑动窗口、前缀和等技巧。
- “出现次数都是偶数”:这是本题的核心!当看到“奇偶性”相关,尤其是涉及多种独立元素的奇偶性时,你的“算法雷达”就应该“哔哔哔”作响,提示你——位运算可能要登场了!
- 识题技巧与触发条件:
- “奇偶性” -> 位运算 (XOR): 对于单个元素,奇数次出现和偶数次出现是两种状态。如果用
1
代表奇数次,0
代表偶数次,那么每遇到一次该元素,状态就会翻转 (0 -> 1
,1 -> 0
)。这完美契合了 XOR 运算的特性 (state XOR 1
)。 - “多种元素的独立奇偶性” -> 状态压缩: 当我们关心多个元素(比如这里的5个元音)各自的奇偶状态时,可以用一个整数的二进制位来“压缩”这些状态。每个元音对应一个 bit,这个整数就代表了当前所有元音的整体奇偶性格局。
- “子串/子数组的累积性质” + “查找特定状态” -> 前缀状态 + 哈希表: 如果一个子串的性质可以由其端点的前缀性质推导出来(比如子数组和等于
prefix_sum[j] - prefix_sum[i]
),并且我们要找满足特定性质的子串,那么“前缀状态”结合“哈希表”往往是优化良方。哈希表可以帮我们快速找到之前出现过的、能构成目标状态的前缀。
- “奇偶性” -> 位运算 (XOR): 对于单个元素,奇数次出现和偶数次出现是两种状态。如果用
总结一下本题的“触发公式”:“元音偶数次” (多元素奇偶性) => 位运算状态压缩;“最长子串” + “特定状态” => 前缀状态 + 哈希表。
3. 庖丁解牛:LeetCode 1371 解题思路详解
有了上面的分析,我们就像拿到了地图的冒险家,开始正式探索解题路径。
第一站:暴力破解法(Brute Force)—— 能行但不够酷
最直观的想法是什么?当然是把所有可能的子串都找出来,然后一个个检查它们是否满足“所有元音偶数次”的条件。
- 枚举所有子串的起始位置
i
和结束位置j
。 - 对于每个子串
s[i...j]
,遍历它,统计 ‘a’, ‘e’, ‘i’, ‘o’, ‘u’ 的出现次数。 - 检查这五个计数是否都为偶数。
- 如果是,更新记录到的最大长度。
这种方法简单粗暴,但时间复杂度太高了。枚举子串 O(N²),每次检查又可能 O(N),总共 O(N³),对于稍长一点的字符串就会超时。就像大海捞针,效率太低啦!
第二站:优化思路 —— 引入状态压缩与前缀思想
暴力法的问题在于重复计算和查找效率低下。我们需要更聪明的方法。
核心洞察1:用“状态”表示元音奇偶性
我们只关心元音的出现次数是奇数还是偶数。这可以用一个5位的二进制数来表示,每一位对应一个元音:
- 第0位: ‘a’ 的奇偶性 (0为偶,1为奇)
- 第1位: ‘e’ 的奇偶性
- 第2位: ‘i’ 的奇偶性
- 第3位: ‘o’ 的奇偶性
- 第4位: ‘u’ 的奇偶性
例如,状态 00000
(二进制) 表示所有元音都是偶数次(这是我们的目标状态!)。状态 00001
表示 ‘a’ 是奇数次,其他元音是偶数次。
当遇到一个元音字符时,我们就将对应状态位进行翻转(XOR 1)。比如当前 ‘a’ 是偶数次 (状态位为0),遇到一个 ‘a’,它就变成奇数次 (状态位变为1)。再遇到一个 ‘a’,又变回偶数次 (状态位回到0)。
核心洞察2:前缀状态与子串状态的关系
定义 mask[k]
为字符串前缀 s[0...k]
(即从第一个字符到第 k+1
个字符) 中元音的奇偶性状态。
那么,子串 s[i+1...j]
(从第 i+2
个字符到第 j+1
个字符) 的元音奇偶性状态是什么呢?
它等于 mask[j] XOR mask[i]
。
为什么是 XOR?想象一下,mask[j]
记录了从头到 j
的累积奇偶变化,mask[i]
记录了从头到 i
的累积奇偶变化。那么从 i+1
到 j
这一段路程所产生的奇偶变化,就是用 mask[j]
“抵消”掉 mask[i]
的影响。对于奇偶性这种非加即减的状态,XOR 正好能完美实现这种“抵消”并获得区间效果。
我们的目标是找到一个子串,其元音奇偶性状态为 00000
。
所以,我们希望 mask[j] XOR mask[i] == 00000
。
这意味着什么?这意味着 mask[j] == mask[i]
!
柳暗花明! 问题转化为:找到两个索引 i
和 j
(i < j
),使得它们对应的前缀状态 mask[j]
和 mask[i]
相同。这样的 j - i
(或者说,子串的长度 (j+1) - (i+1) = j-i
,这里索引处理要小心) 就是一个候选答案。我们要找的是最大的这个差值。
这就像我们给每个元音发一个小旗子,红色代表奇数次,蓝色代表偶数次。我们一路走过字符串,每遇到一个元音,就让它对应的小旗子变色。我们想找一段路,走完之后所有旗子都是蓝色。如果我们在某个点 j
看到的旗子组合,和之前某个点 i
看到的旗子组合完全一样,那么从 i
到 j
这段路程,肯定让每面旗子都变色了偶数次(可能变过来又变回去),最终回到了原来的颜色组合。这意味着 i
和 j
之间的那段路程本身,所有元音都出现了偶数次!
第三站:最终算法 —— 状态压缩 + 前缀状态 + 哈希表
现在,思路已经清晰了:
- 维护一个当前元音奇偶性的状态
current_mask
(一个5位整数)。 - 遍历字符串,每遇到一个字符,如果是元音,就更新
current_mask
(对应位 XOR 1)。 - 我们需要快速知道当前的
current_mask
之前是否出现过。如果出现过,在哪个最早的位置出现的?这时,哈希表(或者一个大小为 32 的数组,因为状态最多只有 2^5=32 种)就派上用场了! - 哈希表
seen
用来存储:{状态: 首次出现该状态时的索引}
。
算法步骤:
- 初始化
current_mask = 0
(空字符串,所有元音0次,即偶数次)。 - 初始化哈希表
seen = {0: -1}
。键是状态,值是该状态首次出现的索引。0: -1
非常关键,它表示在处理任何字符之前(可以理解为索引-1处),状态是0。这使得从字符串开头到某个位置j
的子串(如果其mask
为0)也能被正确计算长度j - (-1) = j + 1
。 - 初始化最大长度
max_len = 0
。 - 遍历字符串
s
的每个字符s[idx]
(从idx = 0
到n-1
):
a. 判断s[idx]
是否是元音。如果是,根据是哪个元音,更新current_mask
:
* ‘a’:current_mask ^= (1 << 0)
* ‘e’:current_mask ^= (1 << 1)
* ‘i’:current_mask ^= (1 << 2)
* ‘o’:current_mask ^= (1 << 3)
* ‘u’:current_mask ^= (1 << 4)
b. 检查current_mask
是否在seen
哈希表中:
* 如果存在seen[current_mask]
:这意味着当前状态current_mask
在之前的seen[current_mask]
索引处也出现过。那么,从seen[current_mask] + 1
到idx
的这个子串s[seen[current_mask]+1 ... idx]
就满足所有元音偶数次。其长度为idx - seen[current_mask]
。我们更新max_len = max(max_len, idx - seen[current_mask])
。
* 如果不存在:说明这是状态current_mask
第一次出现(在当前遍历过程中),记录下来:seen[current_mask] = idx
。 - 遍历结束后,
max_len
就是答案。
复杂度分析:
- 时间复杂度: O(N),因为我们只遍历字符串一次。哈希表操作(插入和查找)平均情况下是 O(1)。
- 空间复杂度: O(1),因为状态最多只有 2^5 = 32 种。所以哈希表(或数组)的大小是常数。
常见错误点与思维误区:
- 忘记
seen = {0: -1}
的妙用:没有这个初始化,就无法正确处理那些从字符串最开始就满足条件的子串。 - 索引计算:长度是
当前索引 - 首次出现相同状态的索引
。 - 位运算不熟练:确保你知道如何用位掩码和XOR操作来更新特定位。
4. 举一反三:解法通用技巧总结
这道题的解法其实蕴含了一类问题的通用解决模式。
-
“状态压缩 + 前缀状态 + 哈希表”三件套:
- 识别与定义状态: 题目中是否有可以量化或编码的“状态”?(如奇偶性、特定组合等)
- 状态压缩: 如果状态由多个独立的小单元组成,且每个单元状态不多,考虑用一个整数的位来紧凑表示。
- 前缀思想: 子序列的问题,通常可以转化为对“前缀信息”(如前缀和、前缀状态、前缀积)的运用。思考
区间信息 = f(前缀信息_j, 前缀信息_i)
。 - 目标关系转换: 将“区间信息 == 目标值” 转换为 “前缀信息_j 和 前缀信息_i 满足某种关系”(如相等、差值为定值等)。
- 哈希表加速查找: 使用哈希表存储出现过的前缀信息及其位置,以便快速找到满足关系的那个“前缀信息_i”。
-
关键词触发的思维路径:
- “奇数/偶数次” + “多种独立元素” => 高度警惕 位运算状态压缩 (XOR)。
- “子串/子数组” + “累积性质 (和、异或和、状态等)” => 联想 前缀和/前缀状态。
- 当需要找到满足
状态_j ⊙ 状态_i = 目标
(⊙代表某种运算)的(i, j)
对时 => 哈希表 登场,存储状态_k
及其对应索引k
。
5. 触类旁通:相似题目练练手
掌握了上面的技巧,我们来看看还有哪些题目可以用类似的思路解决:
-
LeetCode 1915. Number of Wonderful Substrings
- 题目简介: 一个“美妙”字符串,定义为其中最多只有一个字母出现奇数次。给定一个由前十个小写字母(‘a’ 到 ‘j’)组成的字符串
word
,返回美妙非空子字符串的数量。 - 相似之处与技巧应用:
- 状态压缩: 与 1371 题用5位二进制表示5个元音的奇偶性类似,此题用一个10位二进制数(
mask
)来表示 ‘a’ 到 ‘j’ 这10个字符出现的奇偶性。每一位对应一个字符,0表示偶数次,1表示奇数次。 - 前缀状态: 遍历字符串,实时维护从字符串开头到当前位置的
current_mask
。 - 目标转换: 一个子串
word[i..j]
是美妙的,意味着其字符奇偶性掩码 (prefix_mask[j] XOR prefix_mask[i-1]
) 要么是0
(所有字符都是偶数次),要么是只有一个 bit 为1(只有一个字符是奇数次,即掩码是2的幂)。 - 哈希表: 使用哈希表(或大小为 2^10 的数组)
seen_masks
来存储每个prefix_mask
出现的次数。 - 解题逻辑: 当遍历到索引
j
,得到current_prefix_mask
时:- 我们查找
seen_masks[current_prefix_mask]
。这对应了prefix_mask[j] XOR prefix_mask[i-1] == 0
的情况,即子串所有字符偶数次。 - 我们再依次尝试10种可能(
k
从 0 到 9),查找seen_masks[current_prefix_mask XOR (1 << k)]
。这对应了prefix_mask[j] XOR prefix_mask[i-1] == (1 << k)
的情况,即子串只有一个字符是奇数次。
将这些查找到的次数累加起来,就是以当前字符结尾的美妙子串数量。
- 我们查找
- 状态压缩: 与 1371 题用5位二进制表示5个元音的奇偶性类似,此题用一个10位二进制数(
- 细微差别: 1371 要求所有元音都是偶数次(目标状态为0),而此题允许目标状态为0或2的幂。1371求最长,此题求数量。
- 题目简介: 一个“美妙”字符串,定义为其中最多只有一个字母出现奇数次。给定一个由前十个小写字母(‘a’ 到 ‘j’)组成的字符串
-
LeetCode 1442. Count Triplets That Can Form Two Arrays of Equal XOR
- 题目简介: 给定一个整数数组
arr
,找到满足0 <= i < j <= k < arr.length
且a == b
的三元组(i, j, k)
的数目,其中a = arr[i] ^ arr[i+1] ^ ... ^ arr[j-1]
且b = arr[j] ^ arr[j+1] ^ ... ^ arr[k]
。 - 相似之处与技巧应用:
- 前缀XOR: 这是解决区间XOR和问题的经典技巧。令
prefix_xor[x]
为arr[0] ^ ... ^ arr[x-1]
(prefix_xor[0] = 0
)。 - 条件转换:
a == b
等价于a ^ b == 0
。
a = prefix_xor[j] ^ prefix_xor[i]
b = prefix_xor[k+1] ^ prefix_xor[j]
所以(prefix_xor[j] ^ prefix_xor[i]) ^ (prefix_xor[k+1] ^ prefix_xor[j]) == 0
。
由于X ^ X = 0
,上式简化为prefix_xor[i] ^ prefix_xor[k+1] == 0
,即prefix_xor[i] == prefix_xor[k+1]
。 - 核心模式: 问题转化为找到所有满足
prefix_xor[i] == prefix_xor[k+1]
的(i, k)
对。这与 1371 题中寻找mask[j] == mask[i]
的模式完全一致! - 计数: 如果我们找到了这样的
i
和k
(令idx1 = i
,idx2 = k+1
,且idx1 < idx2
),那么对于这个确定的i
和k
,j
可以取i+1, i+2, ..., k
。共有k - i
个选择。遍历所有可能的i
和k
,累加k-i
。
- 前缀XOR: 这是解决区间XOR和问题的经典技巧。令
- 细微差别: 这里的“状态”是单一的XOR和,而不是像1371那样由多个独立奇偶性组成的复合状态。但“前缀状态相等则区间状态为目标(0)”的原理是通用的。此题求满足条件的三元组数量。
- 题目简介: 给定一个整数数组
这些题目虽然具体场景和目标有所不同,但它们都巧妙地运用了“状态表示/压缩”、“前缀信息(和/XOR/状态)”以及“哈希表(或数组)优化查找”的核心思想,值得我们深入体会。
结语
LeetCode 1371 是一道非常棒的题目,它像一位魔术师,巧妙地将位运算、前缀和、哈希表这些基本功组合在一起,变幻出令人拍案叫绝的解法。希望通过今天的讲解,你不仅学会了这道题,更能体会到这些算法思想的精髓和乐趣。记住,学习算法就像升级打怪,多思考,多总结,你也能成为解决复杂问题的“超级英雄”!
下次再遇到类似“奇偶性”、“子串状态”的问题,希望你能自信地亮出“状态压缩”和“前缀和”这两把利器!加油!