别只知道暴力循环!我从用户名校验功能中领悟到的高效字符集判断法(1684. 统计一致字符串的数目)

别只知道暴力循环!我从用户名校验功能中领悟到的高效字符集判断法 😎

大家好,日常开发中,我们经常会遇到一些看似不起眼,却能成为性能瓶颈的小模块。今天,我想和大家分享一个我亲身经历的故事,关于如何从一个简单的"用户名合法性校验"功能,一步步挖掘出三种不同层次的算法解法,最终get到性能优化的精髓。

我遇到了什么问题

事情是这样的,我正在负责一个新项目的用户注册模块。产品经理(PM)跑过来对我说:"为了系统的安全和统一,我们规定新注册的用户名,只能包含我们指定的一组字符。这组字符未来可能会在后台调整。"

听起来是个小需求嘛,不就是个字符串校验?我当时想。

PM接着补充道:"后台会有个功能,可以批量导入一批用户名,我们需要快速地筛选出所有合法的用户名。"

这个场景就变得有意思了。我需要一个规则集allowed 字符串)去校验一大批 待测字符串(words 数组)。如果每次校验一个单词,都去遍历一次规则集,当单词数量和长度上来后,性能开销会非常大。

这让我想起了 LeetCode 上的这道题,简直就是对我这个场景的完美抽象:

1684. 统计一致字符串的数目

给你一个由不同字符组成的字符串 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树),快速判断文本中是否包含敏感词。

练练手,更熟练

如果你对这类问题产生了兴趣,不妨挑战一下这些"兄弟"题目,它们都能用上类似的思路:

希望我这次从真实项目到算法优化的心路历程能给你带来一些启发。记住,优雅的解决方案,往往源于对问题本质和约束条件的深刻洞察。下次再遇到类似问题,别只知道暴力循环啦!😉

解法对比

特性 解法一 (HashSet) 解法二 (布尔数组) 解法三 (位运算)
核心思想 通用哈希集合查找 数组直接寻址模拟哈希 整数位表示集合
代码简洁度 高,API直观易用 中,逻辑清晰 低,需要理解位运算
执行效率 高效 非常高效(优于HashSet) 极致高效(硬件级优化)
时间复杂度 O(L + S) O(L + S) O(L + S)
空间复杂度 O(L) (可视为O(1)) O(1) (固定26) O(1) (固定1个int)
普适性 ,可用于任何类型元素 ,仅限于键可映射为连续整数 ,仅限于键空间极小
相关推荐
go54631584651 小时前
基于深度学习的食管癌右喉返神经旁淋巴结预测系统研究
图像处理·人工智能·深度学习·神经网络·算法
aramae1 小时前
大话数据结构之<队列>
c语言·开发语言·数据结构·算法
大锦终2 小时前
【算法】前缀和经典例题
算法·leetcode
想变成树袋熊2 小时前
【自用】NLP算法面经(6)
人工智能·算法·自然语言处理
cccc来财2 小时前
Java实现大根堆与小根堆详解
数据结构·算法·leetcode
Coovally AI模型快速验证3 小时前
数据集分享 | 智慧农业实战数据集精选
人工智能·算法·目标检测·机器学习·计算机视觉·目标跟踪·无人机
墨尘游子3 小时前
目标导向的强化学习:问题定义与 HER 算法详解—强化学习(19)
人工智能·python·算法
恣艺4 小时前
LeetCode 854:相似度为 K 的字符串
android·算法·leetcode
予早4 小时前
《代码随想录》刷题记录
算法
满分观察网友z4 小时前
别总想着排序!我在数据看板中悟出的O(N)求第三大数神技(414. 第三大的数)
算法