LeetCode 290. 单词规律:同构字符串思路迁移,轻松破解映射难题

47 阅读11分钟

在上一篇文章中,我们详细拆解了 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.lengthwords.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(同构字符串的通用版),虽然不会出错,但会造成不必要的空间浪费,面试时会显得对题干约束不敏感——细节决定成败,这一点要注意。

六、总结与拓展:映射类题目,一通百通

通过这两道题的对比的,我们可以提炼出「映射匹配」类题目的核心解题模板,无论后续遇到的是“字符→字符”“字符→单词”,还是“单词→单词”的映射,都可以直接复用这个模板:

  1. 前置判断:确保两个待映射的“序列”长度一致(字符序列长度、单词序列长度等);

  2. 双向映射:维护两个映射容器(Map/数组/对象),分别存储“甲→乙”和“乙→甲”的映射关系;

  3. 遍历校验:逐一遍历两个序列,判断当前映射是否与已有映射冲突,冲突则返回 false;

  4. 效率优化:根据题干约束,用固定长度数组替代 Map(比如字符映射用 26/128 长度数组),降低空间开销,提升执行速度。

回到本题,LeetCode 290 题本质上就是同构字符串的“换皮题”,核心逻辑完全复用,但通过“单词拆分”的细节,考察我们的知识点迁移能力——面试时,面试官很喜欢出这类“变式题”,检验你是否真的理解了知识点,而不是死记硬背代码。

拓展思考:掌握了这个核心模板后,你还可以尝试 LeetCode 205(同构字符串)、LeetCode 290(单词规律)的联动练习,再试试 LeetCode 890. 查找和替换模式(进一步拓展映射场景),感受“一道题通一类题”的刷题效率。