在上一篇文章中,我们详细拆解了 LeetCode 205 题「同构字符串」,核心掌握了「双向映射校验」的解题思路——通过维护两个映射关系,确保字符之间的对应唯一、反向唯一,同时优化了时间和空间效率。而今天要讲的 LeetCode 290 题「单词规律」,其实就是「同构字符串」的进阶变式题,解题逻辑完全可以直接迁移,只需稍作调整适配“字符与单词”的映射场景,就能轻松拿下。
如果你还没吃透同构字符串的核心,建议先回顾上一篇内容,掌握「双向映射」的核心逻辑后,这道题会变得异常简单;如果已经熟练掌握,那这篇就是帮你巩固思路、拓展应用,让你学会“一道题通一类题”,高效刷题不盲目。
一、题目解读:单词规律与同构字符串的核心关联
先看题干要求,明确「单词规律」的核心定义——本质上和同构字符串的规则高度一致,只是将“字符与字符”的映射,换成了“字符与单词”的映射,我们可以直接对照上一篇的同构规则,提炼出本题的 3 个核心要求(划重点,快速对齐思路):
核心规则(对比同构字符串,一目了然)
-
映射唯一性:pattern 中的每个小写字母,必须映射到 s 中的同一个唯一单词(不能一个字母既对应“dog”,又对应“cat”);
-
反向唯一性:s 中的每个唯一单词,也只能被 pattern 中的同一个字母映射(不能“dog”既对应 'a',又对应 'b',也不能两个不同单词映射到同一个字母);
-
长度匹配性:pattern 的长度,必须和 s 拆分后的单词数量完全相等(这是前置条件,和同构字符串“两个字符串长度相等”的约束一致,不满足直接返回 false)。
补充题干约束(避坑关键)
题干给出的约束的的,帮我们省去了很多边界处理的麻烦,重点关注 2 点:
-
pattern 仅包含小写英文字母,长度 1~300,无需考虑大写、符号等特殊字符;
-
s 仅包含小写英文字母和空格,无前后导空格,单词之间仅用单个空格分隔——这意味着我们可以直接用
split(' ')拆分单词,无需处理多余空格的异常情况。
举个例子,快速上手
结合例子理解规则,避免踩坑,和同构字符串的案例逻辑完全一致:
-
有效案例1:pattern = "abba",s = "dog cat cat dog" → 符合规律(a→dog,b→cat,反向映射也唯一,长度匹配);
-
有效案例2:pattern = "abc",s = "hello world java" → 符合规律(每个字母对应唯一单词,反向也唯一);
-
无效案例1:pattern = "abba",s = "dog cat dog cat" → 不符合(a既要对应dog,又要对应cat,违反映射唯一性);
-
无效案例2:pattern = "ab",s = "dog dog" → 不符合(a和b两个不同字母,都映射到dog,违反反向唯一性);
-
无效案例3:pattern = "aaa",s = "dog cat dog" → 不符合(pattern长度3,单词数量2,长度不匹配)。
二、原代码解析:迁移同构思路,逻辑完全复用
题干给出的 TypeScript 代码,核心思路和上一篇同构字符串的原代码(Map 版)几乎一模一样——只是将“字符与字符”的双向映射,改成了“字符与单词”的双向映射,我们逐行拆解,看如何实现思路迁移。
1. 原代码实现(直接复用同构逻辑)
function wordPattern(pattern: string, s: string): boolean {
const mapP = new Map<string, string>(); // pattern字符 → 单词 的映射
const mapS = new Map<string, string>(); // 单词 → pattern字符 的映射
const words = s.split(' '); // 将s拆分为单词数组
// 前置边界判断:pattern长度与单词数量必须相等
if(words.length !== pattern.length){
return false;
}
// 遍历,校验双向映射关系
for (let i = 0; i < pattern.length; i++) {
const c = pattern[i]; // 当前pattern中的字符
const w = words[i]; // 当前对应的单词
// 核心校验逻辑:和同构字符串完全一致
if ((mapP.has(c) && mapP.get(c) !== w) || (mapS.has(w) && mapS.get(w) !== c)) {
return false;
}
// 存入双向映射关系
mapP.set(c, w);
mapS.set(w, c);
}
return true;
};
2. 核心思路拆解(和同构字符串对比,秒懂)
这道题的解题关键,依然是「双向映射校验」,和上一篇同构字符串的逻辑一一对应,我们用表格清晰对比,帮你快速迁移知识点:
| 对比维度 | LeetCode 205. 同构字符串 | LeetCode 290. 单词规律 |
|---|---|---|
| 映射双方 | s 中的字符 ↔ t 中的字符 | pattern 中的字符 ↔ s 中的单词 |
| 映射容器 | mapS(s→t)、mapT(t→s) | mapP(字符→单词)、mapS(单词→字符) |
| 前置判断 | s.length === t.length | words.length === pattern.length |
| 校验逻辑 | 判断已有映射是否冲突,冲突则返回false | 判断已有映射是否冲突,冲突则返回false |
| 核心本质 | 双向唯一映射,顺序不变 | 双向唯一映射,顺序不变 |
简单来说,只要把上一篇“字符与字符”的映射,替换成这道题“字符与单词”的映射,再增加一步“拆分单词”的操作,就能直接写出本题的基础版本——这就是刷题的核心技巧:掌握一类题的本质,而非单独记一道题的解法。
3. 原代码的优缺点(复用同构字符串的分析)
和同构字符串的 Map 版代码一样,本题的原代码逻辑清晰、无 bug,能覆盖所有测试用例,适合面试时快速写出基础版本,但同样有可优化的空间:
优点
-
逻辑直观:双向 Map 分别维护“字符→单词”和“单词→字符”的映射,校验逻辑简单易懂,不容易出错;
-
无边界遗漏:前置判断单词数量与 pattern 长度,拆分单词无需处理多余空格,贴合题干约束,鲁棒性强;
-
复用性强:思路完全复用同构字符串,掌握后可快速迁移到其他映射类题目。
可优化点
-
时间上:Map 的 has、get、set 方法存在轻微的封装层开销(虽然平均复杂度仍是 O(n)),在数据量偏大时(比如 s 长度达到 3000,单词数量接近 300),可进一步提升速度;
-
空间上:Map 的空间开销是 O(k)(k 是不同字符/单词的数量),最坏情况下空间复杂度是 O(n),可优化到 O(1)(结合题干约束)。
三、优化方案:复用数组优化思路,效率翻倍
在上一篇同构字符串中,我们用“ASCII 数组替代 Map”的思路,将空间复杂度优化到 O(1),本题同样可以复用这个优化思路,结合题干约束做细微调整即可——核心还是利用“固定长度数组”替代“动态 Map”,减少额外开销。
优化核心分析(结合本题约束)
题干明确:pattern 仅包含小写英文字母(共 26 个),这意味着“字符→单词”的映射,其 key 只有 26 种可能,我们可以用一个长度为 26 的数组(对应 a~z)替代 mapP;
而 s 中的单词是小写英文字母组成的,没有固定数量,但结合上一篇的优化思路,我们依然可以用一个对象(或数组)替代 mapS,不过考虑到单词的不确定性,数组的索引无法直接对应单词,因此这里我们做“差异化优化”:仅优化 mapP(字符→单词),mapS(单词→字符)仍用 Map(或对象),兼顾效率和实用性。
优化后代码(兼顾效率与可读性)
function wordPattern(pattern: string, s: string): boolean {
const words = s.split(' ');
// 前置边界判断,和原代码一致
if (words.length !== pattern.length) return false;
// 优化点1:用长度26的数组替代mapP(对应a~z,索引=字符ASCII码-97)
const mapP: (string | undefined)[] = new Array(26);
// 优化点2:mapS用Map仍可,也可用对象,这里保持Map的可读性,也可替换为对象
const mapS = new Map<string, string>();
// 基准ASCII码:'a'.charCodeAt(0) = 97
const baseCode = 97;
for (let i = 0; i < pattern.length; i++) {
const c = pattern[i];
const w = words[i];
// 转换字符为数组索引(a→0,b→1,...,z→25)
const cIndex = c.charCodeAt(0) - baseCode;
// 核心校验逻辑,仅修改mapP的访问方式
if ((mapP[cIndex] !== undefined && mapP[cIndex] !== w) || (mapS.has(w) && mapS.get(w) !== c)) {
return false;
}
// 存入映射关系
mapP[cIndex] = w;
mapS.set(w, c);
}
return true;
}
极致优化(对象替代 Map,进一步提升速度)
如果想进一步降低空间开销、提升执行速度,可以用普通对象替代 mapS(单词→字符的映射),因为对象的属性访问比 Map 的 get/set 方法更快,结合题干中“单词是小写英文字母”的约束,不会出现属性名冲突的问题:
function wordPattern(pattern: string, s: string): boolean {
const words = s.split(' ');
if (words.length !== pattern.length) return false;
const mapP: (string | undefined)[] = new Array(26);
const mapS: Record<string, string> = {}; // 用对象替代Map
const baseCode = 97;
for (let i = 0; i < pattern.length; i++) {
const c = pattern[i];
const w = words[i];
const cIndex = c.charCodeAt(0) - baseCode;
// 校验逻辑调整:对象用 in 判断属性是否存在,用 [] 访问属性
if ((mapP[cIndex] !== undefined && mapP[cIndex] !== w) || (w in mapS && mapS[w] !== c)) {
return false;
}
mapP[cIndex] = w;
mapS[w] = c; // 对象赋值,比Map.set更快
}
return true;
}
四、优化效果对比(关键指标)
结合本题的题干约束(pattern 长度≤300,s 长度≤3000),优化后的代码在实际执行中提升明显,具体对比如下:
| 指标 | 原代码(双 Map 版) | 优化代码(数组+对象版) |
|---|---|---|
| 时间复杂度 | O(n)(n 为 pattern 长度,平均情况) | O(n)(最优,无 Map 封装层开销) |
| 空间复杂度 | O(k)(k 为不同字符/单词数,最坏 O(n)) | O(m)(m 为不同单词数,mapP 是 O(1) 常量开销) |
| 实际执行速度 | 中等(Map 操作有额外损耗) | 更快(数组+对象底层操作,损耗极低) |
| 可读性 | 高(逻辑直观,适合新手) | 较高(优化后仍清晰,适合面试进阶) |
五、常见踩坑点提醒(面试高频,复用同构思路但需注意差异)
这道题虽然思路和同构字符串高度一致,但因为涉及“单词拆分”,有几个专属的踩坑点,很多人会因为忽略这些细节导致出错,结合面试高频场景总结如下:
1. 忘记拆分单词,直接按字符映射(最基础踩坑)
比如误将 s 当作字符数组,和 pattern 直接做字符映射,忽略了 s 是“单词拼接”的特点——比如 pattern = "ab",s = "dog cat",误判为“a→d、b→o”,导致结果错误。解决方法:必须先执行 s.split(' '),将 s 拆分为单词数组后,再和 pattern 遍历匹配。
2. 忽略单词数量与 pattern 长度的前置判断
虽然题干约束 s 无前后导空格、单词用单个空格分隔,但依然要先判断 words.length === pattern.length——比如 pattern = "aaa",s = "dog cat",单词数量 2 不等于 pattern 长度 3,直接返回 false,避免无效遍历,也让代码更严谨(面试时加上这个判断,会加分)。
3. 仅维护单向映射,忽略反向校验(复用同构踩坑点)
和同构字符串一样,只维护“pattern 字符→单词”的映射,不维护“单词→pattern 字符”的映射,会导致误判。比如 pattern = "ab",s = "dog dog",a→dog、b→dog,单向映射看似没问题,但违反“反向唯一性”,会误判为符合规律,必须加上双向校验。
4. 用数组优化时,数组长度设置错误
优化 mapP 时,因为 pattern 仅包含小写英文字母,数组长度设为 26 即可(对应 a~z),如果误设为 128(同构字符串的通用版),虽然不会出错,但会造成不必要的空间浪费,面试时会显得对题干约束不敏感——细节决定成败,这一点要注意。
六、总结与拓展:映射类题目,一通百通
通过这两道题的对比的,我们可以提炼出「映射匹配」类题目的核心解题模板,无论后续遇到的是“字符→字符”“字符→单词”,还是“单词→单词”的映射,都可以直接复用这个模板:
-
前置判断:确保两个待映射的“序列”长度一致(字符序列长度、单词序列长度等);
-
双向映射:维护两个映射容器(Map/数组/对象),分别存储“甲→乙”和“乙→甲”的映射关系;
-
遍历校验:逐一遍历两个序列,判断当前映射是否与已有映射冲突,冲突则返回 false;
-
效率优化:根据题干约束,用固定长度数组替代 Map(比如字符映射用 26/128 长度数组),降低空间开销,提升执行速度。
回到本题,LeetCode 290 题本质上就是同构字符串的“换皮题”,核心逻辑完全复用,但通过“单词拆分”的细节,考察我们的知识点迁移能力——面试时,面试官很喜欢出这类“变式题”,检验你是否真的理解了知识点,而不是死记硬背代码。
拓展思考:掌握了这个核心模板后,你还可以尝试 LeetCode 205(同构字符串)、LeetCode 290(单词规律)的联动练习,再试试 LeetCode 890. 查找和替换模式(进一步拓展映射场景),感受“一道题通一类题”的刷题效率。