LeetCode 127. 单词接龙:两种解法详解(BFS+双向BFS)

0 阅读9分钟

LeetCode中等难度题目「127. 单词接龙」,这道题是BFS(广度优先搜索)的经典应用,核心考察“最短路径”的求解思路,同时还有优化版的双向BFS解法,能大幅提升效率。下面结合题目题意、两种完整代码,一步步拆解思路,帮大家吃透这道题。

一、题目解读

先明确题目核心要求,避免理解偏差:

  • 给定两个单词 beginWord(起始词)、endWord(目标词),以及一个单词字典 wordList;

  • 要求找到从 beginWord 到 endWord 的「最短转换序列」,转换规则有两个:① 相邻单词仅差1个字母;② 除了 beginWord,序列中所有单词都必须在 wordList 中;

  • 返回值是「最短序列的单词数目」,如果不存在这样的序列,返回0。

举个简单例子:beginWord = "hit",endWord = "cog",wordList = ["hot","dot","dog","lot","log","cog"],最短序列是 hit → hot → dot → dog → cog,共5个单词,所以返回5。

核心难点:如何高效找到“相邻单词”(仅差1个字母),以及如何快速搜索到最短路径——BFS天生适合解决“最短路径”问题,因为它是逐层遍历,一旦找到目标,就是最短路径。

二、解法一:常规BFS(邻接表构建)

2.1 思路分析

常规BFS的核心思路的是「先构建邻接表,再逐层遍历」,步骤如下:

  1. 预处理wordList:先找到endWord在wordList中的索引(如果不存在,直接返回0);

  2. 构建邻接表:遍历wordList中每两个单词,判断是否仅差1个字母,若是则建立双向连接(因为A能到B,B也能到A,属于无向图);

  3. 初始化BFS队列:找到所有与beginWord仅差1个字母的单词,作为BFS的第一层(这些单词是beginWord的直接邻居);

  4. 逐层遍历:每遍历一层,单词数目+1,直到找到endWord,返回当前数目;若遍历完所有可能仍未找到,返回0。

2.2 完整代码

function ladderLength_1(beginWord: string, endWord: string, wordList: string[]): number {
  const m = beginWord.length; // 每个单词的长度
  const n = wordList.length;  // 字典中单词的个数
  const adj = new Array(n).fill(0).map(() => new Array()); // 邻接表,存储每个单词的邻居索引
  let endIndex = -1; // endWord在wordList中的索引

  // 1. 遍历wordList,找到endWord的索引,并构建邻接表
  for (let i = 0; i < n; i++) {
    if (wordList[i] === endWord) {
      endIndex = i; // 记录endWord的位置
    }
    // 对比当前单词与后续所有单词,判断是否仅差1个字母
    for (let j = i + 1; j < n; j++) {
      let mutations = 0; // 记录字母差异数
      for (let k = 0; k < m; k++) {
        if (wordList[i][k] !== wordList[j][k]) {
          mutations++;
        }
        if (mutations > 1) { // 差异超过1个,直接跳出,无需继续对比
          break;
        }
      }
      if (mutations === 1) { // 仅差1个字母,建立双向邻接关系
        adj[i].push(j);
        adj[j].push(i);
      }
    }
  }

  // 如果endWord不在wordList中,直接返回0
  if (endIndex === -1) {
    return 0;
  }

  // 2. 初始化BFS:找到所有与beginWord仅差1个字母的单词,加入队列
  const queue: number[] = []; // 队列存储wordList中的索引
  const visited = new Array(n).fill(false); // 标记是否访问过,避免重复遍历
  let num = 1; // 初始单词数目(beginWord本身)

  for (let i = 0; i < n; i++) {
    let mutations = 0;
    for (let k = 0; k < m; k++) {
      if (beginWord[k] !== wordList[i][k]) {
        mutations++;
      }
      if (mutations > 1) {
        break;
      }
    }
    if (mutations === 1) { // 与beginWord仅差1个字母,作为第一层
      queue.push(i);
      visited[i] = true;
    }
  }

  // 3. BFS逐层遍历
  while (queue.length) {
    const sz = queue.length; // 当前层的单词个数
    num++; // 进入下一层,单词数目+1
    for (let i = 0; i < sz; i++) {
      const curr = queue.shift(); // 取出当前单词的索引
      if (curr === undefined) continue;
      if (curr === endIndex) { // 找到目标词,返回当前单词数目
        return num;
      }
      // 遍历当前单词的所有邻居,未访问过的加入队列
      for (const next of adj[curr]) {
        if (visited[next]) {
          continue;
        }
        visited[next] = true;
        queue.push(next);
      }
    }
  }

  // 遍历完所有可能,仍未找到endWord,返回0
  return 0;
};

2.3 优缺点分析

优点:思路直观,容易理解,核心是“构建邻接表+常规BFS”,符合大多数人对“最短路径”的认知;

缺点:效率较低。构建邻接表时,需要两两对比wordList中的单词,时间复杂度为O(n²×m)(n是wordList长度,m是单词长度);当wordList较长时(比如n=1000),两两对比会非常耗时。

三、解法二:双向BFS(优化版,高效)

为了解决常规BFS效率低的问题,我们引入「双向BFS」——从beginWord和endWord同时开始BFS,当两个BFS的遍历范围相遇时,就找到了最短路径。这样可以大幅减少遍历的层数和节点数,效率提升明显。

同时,优化“相邻单词”的查找方式:不提前构建邻接表,而是通过「虚拟节点」快速匹配相邻单词(比如将“hot”转化为“ot”“ht”“ho*”,所有能转化为同一虚拟节点的单词,都是相邻单词)。

3.1 思路分析

  1. 创建一个映射(wordId),将每个单词(包括虚拟节点)映射到唯一的id,方便后续处理;

  2. 定义一个添加单词的函数(addWord):将单词及其所有虚拟节点加入wordId和边表(edge),建立单词与虚拟节点的双向连接;

  3. 将wordList中的所有单词和beginWord加入映射和边表,判断endWord是否存在(不存在则返回0);

  4. 初始化两个BFS队列(分别从beginWord和endWord开始),以及两个距离数组(记录每个节点到起始点/目标点的距离);

  5. 双向BFS遍历:每次遍历一层,判断两个队列的遍历范围是否有交集(即某个节点同时被两个BFS访问到),若有则计算最短路径长度并返回;

  6. 若其中一个队列为空,说明没有交集,返回0。

关键理解:虚拟节点的作用——比如“hot”和“dot”,都能转化为“*ot”,所以它们通过虚拟节点“*ot”建立连接,无需两两对比就能找到相邻单词,时间复杂度优化为O(n×m²)(n是单词数,m是单词长度)。

3.2 完整代码

function ladderLength_2(beginWord: string, endWord: string, wordList: string[]): number {
  const wordId = new Map(); // 单词 -> 唯一id的映射
  const edge: number[][] = []; // 边表,存储每个节点(单词/虚拟节点)的邻居
  let nodeNum = 0; // 节点总数(单词数 + 虚拟节点数)

  // 新增单词(含虚拟节点)到映射和边表
  const addWord = (word: string) => {
    if (!wordId.has(word)) {
      wordId.set(word, nodeNum++); // 分配唯一id
      edge.push([]); // 为该节点初始化边表
    }
    const id_1 = wordId.get(word); // 当前单词的id
    const array = word.split(''); // 将单词拆分为字符数组
    const length = array.length;
    // 生成该单词的所有虚拟节点(替换每个位置为*)
    for (let i = 0; i < length; i++) {
      const tmp = array[i]; // 保存当前位置的字符
      array[i] = '*';
      const newWord = array.join(''); // 虚拟节点(如hot -> *ot、h*t、ho*)
      // 将虚拟节点加入映射和边表
      if (!wordId.has(newWord)) {
        wordId.set(newWord, nodeNum++);
        edge.push([]);
      }
      const id_2 = wordId.get(newWord);
      // 建立单词与虚拟节点的双向连接
      edge[id_1].push(id_2);
      edge[id_2].push(id_1);
      array[i] = tmp; // 恢复原字符,继续生成下一个虚拟节点
    }
  }

  // 1. 将wordList中的所有单词加入映射和边表
  for (const word of wordList) {
    addWord(word);
  }
  // 2. 将beginWord加入映射和边表(beginWord可能不在wordList中)
  addWord(beginWord);
  // 3. 判断endWord是否存在,不存在则返回0
  if (!wordId.has(endWord)) {
    return 0;
  }

  // 初始化双向BFS的距离数组和队列
  let disBegin: number[] = new Array<number>(nodeNum).fill(Infinity); // beginWord到各节点的距离
  const beginId = wordId.get(beginWord);
  disBegin[beginId] = 0; // 起始点距离为0
  const queBegin = [beginId]; // 从beginWord开始的队列

  let disEnd: number[] = new Array<number>(nodeNum).fill(Infinity); // endWord到各节点的距离
  const endId = wordId.get(endWord);
  disEnd[endId] = 0; // 目标点距离为0
  const queEnd = [endId]; // 从endWord开始的队列

  // 双向BFS遍历
  while (queBegin.length && queEnd.length) {
    // 遍历begin端的当前层
    let queBeginLen = queBegin.length;
    for (let i = 0; i< queBeginLen; ++i) {
      const nodeBegin = queBegin.shift()!;
      // 若当前节点已被end端访问过,说明相遇,计算最短路径
      if (disEnd[nodeBegin] !== Infinity) {
        // 距离之和除以2(因为虚拟节点占了一半距离)+1(起始词本身)
        return (disBegin[nodeBegin] + disEnd[nodeBegin]) / 2 + 1;
      }
      // 遍历当前节点的所有邻居,未访问过的加入队列
      for (const it of edge[nodeBegin]) {
        if (disBegin[it] === Infinity) {
          disBegin[it] = disBegin[nodeBegin] + 1;
          queBegin.push(it);
        }
      }
    }

    // 遍历end端的当前层
    let queEndLen = queEnd.length;
    for (let i = 0; i < queEndLen; ++i) {
      const nodeEnd = queEnd.shift()!;
      // 若当前节点已被begin端访问过,说明相遇,计算最短路径
      if (disBegin[nodeEnd] !== Infinity) {
        return (disBegin[nodeEnd] + disEnd[nodeEnd]) / 2 + 1;
      }
      // 遍历当前节点的所有邻居,未访问过的加入队列
      for (const it of edge[nodeEnd]) {
        if (disEnd[it] === Infinity) {
          disEnd[it] = disEnd[nodeEnd] + 1;
          queEnd.push(it);
        }
      }
    }
  }

  // 两个队列有一个为空,说明没有交集,返回0
  return 0;
};

3.3 优缺点分析

优点:效率高。① 虚拟节点替代邻接表,避免两两对比单词,时间复杂度优化;② 双向BFS减少遍历层数,比如常规BFS需要遍历10层,双向BFS可能只需要各遍历5层就相遇;

缺点:思路相对复杂,需要理解“虚拟节点”和“双向遍历相遇”的逻辑,对新手不太友好。

四、两种解法对比总结

解法核心思路时间复杂度空间复杂度适用场景
常规BFS(解法一)构建邻接表,从beginWord单向BFSO(n²×m)O(n²)(邻接表占用空间)wordList长度较小(n≤100)
双向BFS(解法二)虚拟节点+双向BFS,两端相遇即最短路径O(n×m²)O(n×m²)(虚拟节点占用空间)wordList长度较大(n≥1000)

五、关键注意点

  • beginWord不需要在wordList中,但endWord必须在wordList中(否则直接返回0);

  • 相邻单词的判断:必须是“仅差1个字母”,多一个、少一个都不行;

  • 双向BFS中,距离计算需要除以2:因为虚拟节点的存在,begin到相遇点的距离 + end到相遇点的距离,包含了虚拟节点的层数,实际单词层数是其一半,再加上起始词本身(+1);

  • BFS中必须标记“已访问”,避免重复遍历同一单词,导致死循环和效率低下。

六、总结

「单词接龙」的核心是“最短路径”,BFS是最优思路,而双向BFS是常规BFS的高效优化。新手可以先掌握解法一,理解BFS逐层遍历的逻辑;熟练后再学习解法二,掌握“虚拟节点”和“双向遍历”的技巧,应对更大规模的测试用例。

建议大家把两种代码都敲一遍,对比运行效率,感受优化的核心点——其实很多算法题的优化,都是从“减少遍历次数”“优化查找方式”入手,这道题就是很好的例子。