别只知道暴力循环!我从用户名校验功能中领悟到的高效字符集判断法 😎
大家好,日常开发中,我们经常会遇到一些看似不起眼,却能成为性能瓶颈的小模块。今天,我想和大家分享一个我亲身经历的故事,关于如何从一个简单的"用户名合法性校验"功能,一步步挖掘出三种不同层次的算法解法,最终get到性能优化的精髓。
我遇到了什么问题
事情是这样的,我正在负责一个新项目的用户注册模块。产品经理(PM)跑过来对我说:"为了系统的安全和统一,我们规定新注册的用户名,只能包含我们指定的一组字符。这组字符未来可能会在后台调整。"
听起来是个小需求嘛,不就是个字符串校验?我当时想。
PM接着补充道:"后台会有个功能,可以批量导入一批用户名,我们需要快速地筛选出所有合法的用户名。"
这个场景就变得有意思了。我需要一个规则集 (allowed
字符串)去校验一大批 待测字符串(words
数组)。如果每次校验一个单词,都去遍历一次规则集,当单词数量和长度上来后,性能开销会非常大。
这让我想起了 LeetCode 上的这道题,简直就是对我这个场景的完美抽象:
给你一个由不同字符组成的字符串
allowed
和一个字符串数组words
。如果一个字符串的每一个字符都在allowed
中,就称这个字符串是 一致字符串 。请你返回words
数组中 一致字符串 的数目。
在动手编码前,我们先来仔细品味一下题目的"提示":
1 <= words.length <= 10^4
,1 <= words[i].length <= 10
: 单词数量不少,但每个单词都不长。这告诉我们,算法的效率重点应该放在如何快速判断"单个字符"是否合法上,而不是纠结于单词长度。1 <= allowed.length <= 26
,allowed 中的字符 互不相同
,只包含小写英文字母
: 这是最重要的三条信息! 它把问题从一个通用的字符串匹配,降维到了一个固定大小(26个字母)的集合问题上。这是我们施展优化"魔法"的关键。
好了,背景介绍完毕,让我们看看我是如何用三套"组合拳"来解决这个问题的。
我是如何用算法"组合拳"解决的
解法一:哈希集合(HashSet),万能的"存在性"检查器
这是我脑海里冒出的第一个想法,也是最符合直觉的解法。要反复检查一个字符在不在 allowed
里,如果每次都用 String.contains()
,效率太低。更好的办法是把 allowed
的字符先存到一个查询飞快的数据结构里。HashSet
就是为此而生的!
先把所有"合法"的字符都请进一个 HashSet
,它就像一个VIP俱乐部的门禁系统。然后,对于每一个待校验的用户名,我们检查它的每个字符是不是这个俱乐部的会员。
java
import java.util.HashSet;
import java.util.Set;
/*
* 思路:使用哈希集合。将 allowed 字符串的字符存入 HashSet,以实现 O(1) 的快速查找。
* 然后遍历每个单词,检查其所有字符是否都在 HashSet 中。
* 时间复杂度:O(L + S),其中 L 是 allowed 的长度,S 是 words 中所有字符的总数。
* 空间复杂度:O(L),由于 L<=26,可视为 O(1)。
*/
class Solution {
public int countConsistentStrings(String allowed, String[] words) {
// 1. 预处理,构建"VIP俱乐部"
// 为什么用 HashSet?因为它提供了平均时间复杂度为 O(1) 的 contains() 方法,
// 对于需要频繁检查"某元素是否存在于集合中"的场景,它是最理想的选择。
// 比起 List 的 O(L) 查找,简直是降维打击。
Set<Character> allowedSet = new HashSet<>();
for (char c : allowed.toCharArray()) {
allowedSet.add(c);
}
int consistentCount = 0;
// 2. 遍历所有待校验的用户名
for (String word : words) {
boolean isConsistent = true;
// 3. 检查用户名的每个字符
for (char c : word.toCharArray()) {
// 如果在"VIP俱乐部"里查不到这个字符,说明是非法用户,直接标记并"拉黑"
if (!allowedSet.contains(c)) {
isConsistent = false;
break; // 重要的优化:一旦发现不合法的,立即停止对当前单词的检查!
}
}
// 如果这个用户名所有字符都通过了检查,计数器加一
if (isConsistent) {
consistentCount++;
}
}
return consistentCount;
}
}
复杂度
时间复杂度:O(L + S)。L 是 allowed
的长度,S 是 words
中所有字符的总数。构建 HashSet
的时间是 O(L)。之后遍历所有单词的所有字符,总字符数为 S,每次查询是 O(1),所以检查部分是 O(S)。总计 O(L + S)。
空间复杂度:O(L)。HashSet
需要存储 allowed
中的字符。因为题目限制 L <= 26
,所以空间复杂度可以认为是常数级别的 O(1)。
这个解法又稳又准,代码清晰,足以应对大多数场景。但当我看到提示里的"26个小写字母"时,一个念头闪过:我真的需要 HashSet
这个"重型武器"吗?有没有更轻量、更快的办法?😉
解法二:布尔数组,小范围数据的"降维打击"
HashSet
很好,但它内部有哈希计算、冲突处理等机制,有一定开销。既然我们已经知道所有字符都在'a'到'z'这个小范围内,就可以用一个简单的数组来创建一个更快的"门禁系统"。
我们可以创建一个长度为26的布尔数组,每个位置对应一个字母。array[0]
代表 'a',array[1]
代表 'b',以此类推。如果 allowed
里有 'c',我们就把 array[2]
设为 true
。检查时,只需要看对应位置是不是 true
就行了,这是最纯粹的 O(1) 操作!
java
/*
* 思路:使用布尔数组模拟哈希。利用字符集限定在26个小写字母的特性,
* 创建一个长度为26的布尔数组作为查找表,比 HashSet 更高效。
* 时间复杂度:O(L + S),但常数时间更小。
* 空间复杂度:O(1),数组大小固定为26。
*/
class Solution {
public int countConsistentStrings(String allowed, String[] words) {
// 1. 预处理,构建一个超快的"字母通行证"
// 为什么用 boolean 数组?因为当键的范围已知且很小时(26个字母),
// 数组提供了最快的直接寻址访问(真正的 O(1)),避免了 HashSet 的哈希计算和潜在冲突开销。
// c - 'a' 这个操作是精髓,它能巧妙地把字符映射到 0-25 的索引上。
boolean[] isAllowed = new boolean[26];
for (char c : allowed.toCharArray()) {
isAllowed[c - 'a'] = true;
}
int consistentCount = 0;
outerLoop: // 使用标签可以更清晰地跳出外层循环,直接检查下一个单词
for (String word : words) {
for (char c : word.toCharArray()) {
// 如果"通行证"上这个字母没盖章,说明不合法
if (!isAllowed[c - 'a']) {
continue outerLoop; // 直接跳到下一个单词的检查
}
}
// 如果一个单词的内层循环能正常走完,说明它是合法的
consistentCount++;
}
return consistentCount;
}
}
复杂度
时间复杂度:O(L + S)。分析同上。但由于数组访问是直接内存寻址,它的实际运行速度通常会比 HashSet
快。
空间复杂度:O(1)。isAllowed
数组的大小是固定的26,不随输入规模变化,是纯粹的常数空间。
这个解法是我当时"恍然大悟"的瞬间!它让我明白了,充分利用题目给定的约束条件,是算法优化的不二法门。这个方法已经非常高效了,但我还想挑战一下自己,有没有办法把空间再压榨一下?
解法三:位运算(Bitmask),高手的极简主义
布尔数组 [true, true, false, ...]
本质上不就是一串二进制 110...
吗?既然如此,我为什么不直接用一个整数来表示这26个状态位呢?一个 int
类型有32位,足够放下26个字母的"通行证"了!
这就是位运算的魅力。我们可以用一个整数的二进制位来代表集合。第0位是1,代表'a'合法;第1位是1,代表'b'合法......
- 添加字符 :用"按位或
|
"操作。 - 检查字符 :用"按位与
&
"操作。
java
/*
* 思路:位运算。将 allowed 字符串表示为一个位掩码(bitmask),然后检查
* 每个单词的每个字符对应的位是否在该掩码中为1。这是空间和时间效率都极高的做法。
* 时间复杂度:O(L + S)。
* 空间复杂度:O(1)。
*/
class Solution {
public int countConsistentStrings(String allowed, String[] words) {
// 1. 预处理,构建一个整数版的"通行证"
// 为什么用 int 做位掩码?因为它紧凑、高效。26个字母的状态刚好可以用一个32位整数表示。
// `1 << (c - 'a')` 生成一个只有 c 对应位是 1 的数字(例如 c='b', 结果是二进制的10)。
// `|=` (按位或赋值) 将这个位设置为1,而不影响其他位。
int allowedMask = 0;
for (char c : allowed.toCharArray()) {
allowedMask |= (1 << (c - 'a'));
}
int consistentCount = 0;
outerLoop:
for (String word : words) {
for (char c : word.toCharArray()) {
// 检查字符 c 的"通行证"位是否为1
// `(allowedMask & (1 << (c - 'a')))` 会提取出 c 对应的位。
// 如果结果是 0,说明 allowedMask 在这一位上是0,即字符不合法。
if ((allowedMask & (1 << (c - 'a'))) == 0) {
continue outerLoop; // 验证失败,检查下一个单词
}
}
consistentCount++;
}
return consistentCount;
}
}
复杂度
时间复杂度:O(L + S)。分析同上。位运算直接在CPU层面执行,通常是所有解法中实际运行最快的。
空间复杂度:O(1)。只用了一个额外的 int
变量,空间利用达到了极致。
这个解法虽然代码看起来有点"黑话",但它展示了对计算机底层工作原理的深刻理解。在追求极致性能的场景下,位运算是你的屠龙宝刀!
举一反三:这个思想还能用在哪?
这个"预处理查询集"的思想在开发中无处不在:
- API网关:校验请求参数是否在允许的枚举值范围内。
- 文件上传 :检查上传文件的后缀名是否在白名单中(如
.jpg
,.png
,.gif
)。 - 敏感词过滤:将敏感词库预处理成高效的查找结构(如Trie树),快速判断文本中是否包含敏感词。
练练手,更熟练
如果你对这类问题产生了兴趣,不妨挑战一下这些"兄弟"题目,它们都能用上类似的思路:
- 771. 宝石与石头:本题的简化版,绝佳的入门练习。
- 242. 有效的字母异位词:同样使用数组作为哈希表来统计字符频率。
- 389. 找不同:位运算解法在此题中大放异彩。
希望我这次从真实项目到算法优化的心路历程能给你带来一些启发。记住,优雅的解决方案,往往源于对问题本质和约束条件的深刻洞察。下次再遇到类似问题,别只知道暴力循环啦!😉
解法对比
特性 | 解法一 (HashSet) | 解法二 (布尔数组) | 解法三 (位运算) |
---|---|---|---|
核心思想 | 通用哈希集合查找 | 数组直接寻址模拟哈希 | 整数位表示集合 |
代码简洁度 | 高,API直观易用 | 中,逻辑清晰 | 低,需要理解位运算 |
执行效率 | 高效 | 非常高效(优于HashSet) | 极致高效(硬件级优化) |
时间复杂度 | O(L + S) | O(L + S) | O(L + S) |
空间复杂度 | O(L) (可视为O(1)) | O(1) (固定26) | O(1) (固定1个int) |
普适性 | 高,可用于任何类型元素 | 低,仅限于键可映射为连续整数 | 低,仅限于键空间极小 |