别只知道暴力循环!我从用户名校验功能中领悟到的高效字符集判断法(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)
普适性 ,可用于任何类型元素 ,仅限于键可映射为连续整数 ,仅限于键空间极小
相关推荐
江畔柳前堤5 小时前
大语言模型分布式训练:从并行策略到万卡工程的系统梳理
人工智能·分布式·深度学习·算法·目标检测·机器学习·语言模型
Forever Nore6 小时前
LeetCode 4 寻找两个正序数组的中位数 - 二分
算法·leetcode
罗西的思考8 小时前
【OpenClaw具身硬件】MiniClaw 阅读笔记---(1)基础
人工智能·算法·机器学习
蛋先生DX9 小时前
大模型参数存储格式揭秘:BF不是男朋友
深度学习·算法·llm
爱跳舞的烤冷面9 小时前
自学嵌入式第22天(数据结构——哈希)
数据结构·算法·哈希算法
猎嘤一号10 小时前
博弈论(Game Theory)的理论、算法与工程
人工智能·算法·安全·博弈论
月光船幽幽10 小时前
影子模式下保护 logits 不被修改
人工智能·python·算法
马拉AI10 小时前
腾讯开源 Agent 记忆系统,AI“换对话就忘”的问题有了新解法(附安装使用教程)
人工智能·算法·开源·科研
地平线开发者11 小时前
征程6工具链模型X86推理方式说明
算法
地平线开发者12 小时前
【征程6】校准量化中HistogramObserver解析
算法