刷LeetCode的时候第一次碰到“单词规律”(Word Pattern)这道题,我下意识觉得“这不就是做个映射吗”,结果真写起来才发现里面全是细节。pattern = "abba",s = "dog cat cat dog",输出true;换成"dog cat cat fish",马上就是false;再换成"dog dog dog dog",还是false。这三个Case跑下来,一个比一个刁钻,一个比一个能暴露问题。
这道题本质上是一个模式匹配问题,但它的“模式”是抽象字符而不是具体字符串。它考的不只是哈希表怎么用,而是你在处理映射关系时有没有建立“双射”的直觉。这几年我在面试里也经常拿它当热身题,发现能一次写对的人不到一半,大多数人都栽在只维护了单向映射上。所以这篇博文我想完整拆一下这道题:从题目本身开始,讲清双射的核心难点,再给出可直接照着抄的C++实现,最后把它放进模式匹配的知识网络里,聊聊KMP、同构字符串、回溯剪枝这些关联内容。无论你是在准备算法面试、刷LeetCode,还是单纯对模式匹配的底层逻辑感兴趣,这篇都能给你一些不一样的视角。
1. 题目本身与核心考点拆解
1.1 题目到底在说什么
题目原文很短:给定一个模式串pattern,比如"abba",再给一个字符串s,比如"dog cat cat dog",要求判断s是否满足pattern所表达的规律。所谓满足,指的是pattern里的每个字母都唯一映射到s里的一个单词,同时s里的每个单词也唯一映射回pattern里的一个字母,而且位置顺序完全一致。
这个“同时”两个字,就是全题的核心。
我见过很多人对题目的第一反应是:把pattern的字符当key,把s的单词当value,遍历一遍塞进哈希表就完事。这个思路没有错,但它只实现了一半。举个最容易翻车的例子:pattern = "abba",s = "dog dog dog dog"。按单向映射,a对应dog,b也对应dog,结束。程序遍历完没发现任何冲突,返回true。但正确答案是false,因为a和b是两个不同字母,它们对应的单词不能相同。这就是所谓“双向唯一对应”的数学结构——双射(bijection)。
读题最关键的收获是:这题不是考你会不会用哈希表,而是考验你有没有识别出“字符到单词的映射必须是双射”这个条件。
1.2 为什么“单向映射”看似合理其实是陷阱
很多刷题资料会把这道题归类为“哈希表”简单题,导致大家把注意力全放在“怎么建map”上,忽略了数学本质。单向映射对应的数学概念是“函数”,即每个输入有唯一输出,但允许多个输入对应同一个输出。而双射要求的是“一一对应”,既不允许一个key对应多个value,也不允许多个key对应同一个value。
那道题的s = "dog dog dog dog",如果你只查“pattern字符是否出现过”,会发现a、b都没出现过,于是一路畅通地建立映射,最终返回true。但如果你反过来再查一遍“这个单词是否已经被别的字符占用”,就会发现dog已经被a占用,b再来时就会冲突,这才符合真实答案。
所以我在给新手讲这道题的时候,一定会先用一个生活场景去类比:学号和姓名之间的关系。一个学号对应一个学生姓名,这是确定的功能;但如果两个学号同时指向“张三”,你还能分出谁是张三吗?单词规律要求的就是“学号-姓名”严格一一对应,任何重复都是非法。
1.3 解题思路演变:从暴力枚举到双哈希
如果完全没有数据结构基础,遇到这道题会怎么做?最原始的做法是暴力枚举:先把s按空格拆成单词数组,然后针对pattern里的每个位置,检查当前字母之前有没有出现过,出现过就比对当前单词和之前记录是否相等,没出现过就把当前单词加入记录并继续。这个流程本质上就是模拟手工判断,时间复杂度O(n^2)也不奇怪。
再往上走一步就是哈希表优化。用哈希表把“当前字母出现过”和“对应的单词是什么”缓存下来,这样每次查找都是O(1),整道题变成一趟遍历。这里有一个从暴力到优化的关键思维:暴力算法在“当前字母出现过”时仍然需要扫描之前的映射记录,而哈希表直接把历史记录组织成了可直接寻址的结构。
但光有哈希还不够,因为前面那个“dog dog dog dog”的反例告诉我们,值冲突也需要检查。于是自然的演化就是双哈希:一张表存字符到单词的映射,另一张表存单词到字符的映射,或者一张映射表加一个集合记录已占用单词。这就是双射在工程上的落地。从暴力枚举到双哈希,这也是网络上关于“单词规律”最常见的讨论路径。
2. 核心难点解析:双射与冲突检测
2.1 学习映射关系时的方向性陷阱
先看一张典型的错误过程表,我模拟了很多人写代码时的心理活动:
| 步骤 | 读取内容 | 单向映射视角 | 双射视角 |
|---|---|---|---|
| pattern[0]=a, 单词dog | a → dog,未出现,记录 | a → dog,dog → a,双向记录 | |
| pattern[1]=b, 单词dog | b → dog,b没出现过,记录 | b → dog,但dog已被a占用,冲突 | |
| 最终判断 | true | false |
这张表清晰说明:只要少了反向检查,错误结果就是必然的。代码里体现出来就是“用map<char, string>保存单向映射,同时用set 保存已经映射过的单词”。很多人会问,那为什么不能用map<string, char>只存方向?因为只存反向一样会漏掉“一个字符映射到两个不同单词”的情况,比如pattern = "abba",s = "dog cat cat fish"。a第一次记录dog,第二次遇到位置3的fish,再看map<char,string>,a已经有了dog,发现值不一致,才返回false。所以正向检查负责“字符不能一对多”,反向检查负责“单词不能多对一”,缺一不可。
2.2 双射的数学直觉:从身份证号到学号
为了让“双射”不再抽象,我最常用的一组类比是:身份证号与人名。身份证号和姓名不是双射,因为同名的人太多了,你拿到一个姓名没法唯一定位到具体的人。而学号和学生姓名在正常教学管理里就应该是双射:一个学号只能对应一个学生,一个学生也只能拥有一个学号。
放在题目场景里,pattern里的字母就像学号,s里的单词就像学生姓名。系统正常运转的前提就是两者一一对应。如果出现两个学号对应同一个姓名,或者一个学号对应两个姓名,系统就会乱套。单词规律算法要做的,就是给出一个自动化检测“学籍档案是否规范”的方案。
这个类比的好处是:当你纠结“到底要不要存反向映射”时,只要想想“我能不能把一个姓名唯一反查到学号”就知道了。如果你不支持反向查,那前面说的“dog被两个字符共占”的情况就根本发现不了。
2.3 一个哈希表还是两个?代码设计的取舍
我见过不少解法只用一个unordered_map<char, string>,再配一个unordered_set 来做值占用检查。也有解法直接用两个unordered_map:一个存char到string,另一个存string到char。两种都能AC,但在可读性和健壮性上有区别。
用map + set的写法,主循环只需要一次find操作加一次set的insert操作,代码量最少。缺点是当你需要“根据单词反查字符”做某些扩展时,set帮不上忙,信息不完整。用双map的写法,虽然每次要维护两个数据结构,但逻辑对称,读起来一目了然,而且后续如果要支持“给定单词输出对应字符”之类的查询,完全不需要重构。
我个人在实际刷题和面试手写环节更推荐双map。原因很简单:面试脚本不只看你对不对,还看沟通成本。你写一个char→string的map,面试官第一反应就是“单向映射能过吗”,你需要额外解释“我还用set做了反向占用检查”,解释成本其实更高。而你直接写双向哈希,面试官一眼就能看出你理解了双射。这个选择很微妙,但它直接影响你在面试中的表达效率。
3. C++ 实现细节与工程化写法
3.1 字符串分割:istringstream 是最省心的选择
这道题第一步就是把s按空格拆成单词,而C++标准库不像Python有现成的split,于是很多人会自己写循环遍历空格切割。自己切不是不行,但连续空格、首尾空格、换行符这些边界情况都要处理,非常容易出bug。
现实中我推荐直接用istringstream。它天然支持空白字符分割,遇到连续多个空格也能自动跳过,省去大量边界判断。比如"dog cat cat dog"这种带多个空格的串,istringstream会稳定地依次读出dog、cat、cat、dog,行为符合题意。
有一点得提醒:istringstream处理的是空白字符,包括空格、制表符、换行符。如果你只需要按空格切,这个行为一般没问题;但如果题目明确说“只按空格分开”,而输入里混入制表符,istringstream会把它们也当成分隔符,这可能需要额外注意。好在LeetCode这类平台上的测试输入通常都规规矩矩。
3.2 一份可以直接照着写的C++代码
下面是双map版本的完整实现,我在关键位置加了注释:
class Solution { public: bool wordPattern(string pattern, string s) { // 先把 s 按空白分割成单词 vector<string> words; istringstream iss(s); string word; while (iss >> word) { words.push_back(word); } // 长度不一致直接 false,这是一个快速剪枝 if (pattern.size() != words.size()) { return false; } // 双向哈希表 unordered_map<char, string> p2s; unordered_map<string, char> s2p; for (int i = 0; i < pattern.size(); ++i) { char c = pattern[i]; const string& w = words[i]; // 正向检查:字符是否已经映射到别的单词 if (p2s.count(c) && p2s[c] != w) { return false; } // 反向检查:单词是否已经被别的字符占用 if (s2p.count(w) && s2p[w] != c) { return false; } // 建立双向映射 p2s[c] = w; s2p[w] = c; } return true; } };这份代码的核心逻辑就四点:分割、长度剪枝、正向查冲突、反向查冲突。所有判断都在一趟循环里完成,时间复杂度O(n),空间复杂度O(m),其中n是单词数量,m是不同单词数量。
我还见过一种更省空间的写法:把map换成数组加set。因为pattern里只有小写字母,可以用int p2s[26]存字符上次映射的单词编号,用unordered_set 记录已占用的单词编号。这样能省掉字符串哈希的开销,但对一般面试场景来说收益不大,反而增加理解成本。
3.3 边界条件与输入健壮性
边界条件是最容易被忽视的部分,也是测试用例最容易踩到的坑。我总结了一下需要重点验证的场景:
- pattern为空、s为空:长度检查会先兜住,通常返回true,因为空模式匹配空串。
- pattern为空、s非空:长度不匹配,返回false。
- pattern = "a",s = "dog":单字符单单词,应当返回true。
- pattern = "a",s = "dog cat":长度不匹配,直接false。
- s包含多个连续空格:istringstream自动处理,分割结果没有空字符串。
- 单词数量特别大时:unordered_map查找均摊O(1),并发冲突概率低,性能稳定。
有一点我吃过亏:如果题目在s里混入了非ASCII字符或者全角空格,istringstream的默认行为可能跟你预期不一致。更稳妥的做法是在读入前对s做trim,去掉首尾空白,但内嵌连续空格交给istringstream即可。
4. 从单词规律看模式匹配家族的延续
4.1 KMP 算法与单词规律的对话
很多人看到“模式匹配”这个关键词,第一反应是KMP算法。KMP解决的是:给定一个文本串T和一个模式串P,找出P在T中出现的位置。它处理的是“字面对齐”:P = "abc",那么T中必须连续出现"abc"才算匹配。而单词规律处理的是“结构同构”:pattern = "abba",它对应的不一定是具体的"dog cat cat dog",你也可以用"cat dog dog cat"来满足它,重点在结构而不是字面内容。
这两种匹配视角本质上是不同层级的问题。KMP关心“字节是否完全一致”,单词规律关心“类别之间的对应关系是否一致”。我在实际工作中很少把这两者混为一谈,但刷题时把它们放在一起对比,对理解很有帮助。KMP的next数组本质上是在优化暴力枚举的无效回溯,而单词规律的双哈希是在优化“历史映射关系的查询”。一个面向“串的重复结构”,一个面向“映射关系的合法性”,它们共同构成了模式匹配的两种基本坐标系。
4.2 同构字符串与单词规律的家族关系
LeetCode上有一道几乎“换汤不换药”的题:205. 同构字符串(Isomorphic Strings)。它判断两个字符串s和t是否同构,比如s = "egg",t = "add",返回true。这道题跟单词规律的区别仅仅在于把“单词”换成了“字符”,核心还是双射,代码几乎可以平移:用两个map或者一个map加set做双向检查。我把这类题归为“双射家族”:
| 题目 | 输入形式 | 数据结构 | 核心考点 |
|---|---|---|---|
| 290. 单词规律 | pattern字符串 + 单词串 | char→string + string→char | 字符串分割 + 双向映射 |
| 205. 同构字符串 | 两个字符串 | char→char + char→char | 双向映射 |
| 291. 单词规律II | pattern字符串 + 单词串(单词长度不固定) | 哈希 + 回溯递归 | 回溯 + 剪枝 + 映射 |
其中291题是进阶版:pattern的每个字符可以匹配一段长度不定的连续子串,此时双射仍然成立,但你不能遍历一遍就出结果,而是要枚举所有可能的切分方式,用回溯递归去尝试。它的剪枝操作就是“检查当前映射是否已经冲突”,如果没有冲突就继续深搜。这时你会发现,单词规律II已经把“双射检查”和“递归枚举”结合在了一起,复杂度一下子从线性变成指数级,但也正因为有双射约束,剪枝效率才足够高。
4.3 一个简单的模式匹配题如何考出深度
面试官特别喜欢从这种简单题往外扩。我复盘过一轮真实面试,面试官就从单词规律开始,一路追问出三个问题:
第一问:如果pattern里不只小写字母,还有数字和特殊字符,怎么办?答案是直接换用unordered_map,不要用数组。这个考察点其实是数据结构的泛化能力。
第二问:如果单词长度不固定,你怎么解?这就是291题的场景。很多候选人能写出回溯框架,但忘了回溯时要撤销双向映射,导致状态污染。这恰恰是双射思想在递归场景里的应用。
第三问:如果要求返回所有满足pattern的单词划分而不是只判断存在性,怎么做?这就要把回溯改成收集所有合法路径,剪枝条件同样是双向映射。能走到这一步的候选人,通常已经能够灵活地把“双射”从判断工具变成搜索约束。
从一道简单题延伸到一整棵知识树,这是我觉得模式匹配系列最好玩的地方。它不像动态规划那样需要很强的递推直觉,但同样能把“数据结构选型”“边界处理”“递归剪枝”这些基本功串起来。
5. 常见问题、性能数据与调试经验
5.1 典型错误一:只用一个哈希表导致误判
这是我在面试中见过最多的问题,没有之一。错误代码通常长这样:
unordered_map<char, string> mp; for (int i = 0; i < pattern.size(); ++i) { if (mp.count(pattern[i]) && mp[pattern[i]] != words[i]) { return false; } mp[pattern[i]] = words[i]; } return true;这段代码在pattern = "abba",s = "dog dog dog dog"时会返回true,而预期结果是false。原因就是它完全没有检查“单词是否已经映射给别的字符”。我把这个Case单独写到测试用例里,作为代码提交前的自检标准。建议每个写这道题的人都把“abba / dog dog dog dog”和“abba / dog cat cat fish”这两个反向Case永远留在测试列表里,它们一个抓反向冲突,一个抓正向冲突。
5.2 典型错误二:分割字符串时出现空串
如果你不用istringstream而自己写split,常见的坑是:当s以空格开头,或者有两个连续空格时,会解析出空字符串。空字符串一旦进入words数组,长度检查可能仍然通过,但映射关系会错乱。比如pattern = "ab",s = " dog cat",开头有个空格,自己写的分割逻辑可能得到["", "dog", "cat"],长度变成3,直接判false,但题目预期是true。
这个问题在C++里最容易用istringstream规避。如果你必须手写分割,记得在循环里跳过连续空白:
int i = 0; while (i < s.size()) { while (i < s.size() && isspace(s[i])) ++i; int start = i; while (i < s.size() && !isspace(s[i])) ++i; if (start < i) words.push_back(s.substr(start, i - start)); }这段代码能正确识别空串边界,但明显更啰嗦。工程上能用标准库解决的就别自己造轮子,这是我反复强调的一个原则。
5.3 性能数据与调试技巧
单词规律这道题在LeetCode上我实测最差情况耗时大概在0ms到8ms之间,内存消耗约6.5MB到8.5MB,取决于map的数量。用双map实现,面对上千个单词的输入规模完全没有压力,毕竟就是一次线性扫描加若干次哈希读写。
调试这道题有个很实用的技巧:写一个打印映射关系的辅助函数,在每次更新映射后把当前状态打印出来。比如输入是"dog cat cat dog",你会看到每一步的map内容变化,一旦出现双向冲突,一眼就能定位是哪个方向的检查漏了。
我日常刷题时还会多写几组自定义用例,覆盖单字符、单词数量不匹配、模式长度超长、同一单词出现在不同pattern字符下、以及同一pattern字符对应不同单词等场景。把这些用例固化成一份测试清单,每次提交前跑一遍,比任何复盘都管用。
最后再说一个扩展心得:如果你觉得双map写法在工程里太重,可以用“map + set”的组合,但一定要把set的作用写在注释里。我踩过几次坑之后发现,代码写出来给别人看,最重要的不是省那两个变量,而是把双射这个约束表达得足够明显。LeetCode上优秀答案那么多,区别往往不在算法复杂度,而在谁能在30秒内让读代码的人理解你的全部设计意图。这道题恰恰是练习这种表达能力的绝佳素材。