# [127] 单词接龙
字典 wordList 中从单词 beginWord 和 endWord 的 转换序列 是一个按下述规格形成的序列 beginWord -> s1 -> s2 -> ... -> sk:
每一对相邻的单词只差一个字母。
对于 1 <= i <= k 时,每个 si 都在 wordList 中。注意, beginWord 不需要在 wordList 中。
sk == endWord
给你两个单词 beginWord 和 endWord 和一个字典 wordList ,返回 从 beginWord 到 endWord 的 最短转换序列中的 单词数目 。如果不存在这样的转换序列,返回 0 。
示例 1:
输入:beginWord = "hit", endWord = "cog", wordList =
["hot","dot","dog","lot","log","cog"]
输出:5
解释:一个最短转换序列是 "hit" -> "hot" -> "dot" -> "dog" -> "cog", 返回它的长度 5。
示例 2:
输入:beginWord = "hit", endWord = "cog", wordList =
["hot","dot","dog","lot","log"]
输出:0
解释:endWord "cog" 不在字典中,所以无法进行转换。
提示:
1 <= beginWord.length <= 10
endWord.length == beginWord.length
1 <= wordList.length <= 5000
wordList[i].length == beginWord.length
beginWord、endWord 和 wordList[i] 由小写英文字母组成
beginWord != endWord
wordList 中的所有字符串 互不相同
# 单向 BFS
这道题目作为一个 BFS 的典型题型,十分的有意思,解出来并不难,重点在于我们该怎么进行优化。
知道本题使用 BFS,那么 BFS 的核心就是队列,我们需要将每一层的节点都放入队列中,然后依次遍历,直到找到目标节点。
那么,对于每一层应该有哪些节点呢?其实,我们可以遍历当前的 wordList,然后将其中只差一个字母的单词都放入队列中,这样就可以得到下一层的节点。
判断两个字符串是否只差一个字母我们可以写一个函数判断。
const nextValidWords: string[] = [];
for (const nextWord of wordList) {
let count = 0;
for (let i = 0; i < curWord.length; i++) {
if (curWord[i] !== nextWord[i]) {
count += 1;
}
if (count > 1) break;
}
if (count === 1) nextValidWords.push(nextWord);
}
接下来,我们可以想想 BFS 的终止条件。由题目可知,当当前遍历的单词等于目标单词时,我们就可以返回当前的层数了。因此我们可以得到最终的解法。
function ladderLength(beginWord: string, endWord: string, wordList: string[]): number {
if (!wordList.includes(endWord)) return 0;
const queue: string[] = [beginWord];
const visited: string = [beginWord];
let step = 1;
while (queue.length > 0) {
let len = queue.length;
while (len--) {
const curWord = queue.shift();
if (curWord === endWord) return step;
// 若已经访问过,则跳过
if (visited.includes(curWord)) continue;
const nextValidWords = findNextValidWords(curWord, wordList);
for (const nextWord of nextValidWords) {
// 将符合要求的单词放入队列中
queue.push(nextWord);
visited.push(nextWord);
}
}
step += 1;
}
return 0;
function findNextValidWords(curWord: string, wordList: Array<string>): string[] {
const nextValidWords: string[] = [];
for (const nextWord of wordList) {
let count = 0;
for (let i = 0; i < curWord.length; i++) {
if (curWord[i] !== nextWord[i]) {
count += 1;
}
if (count > 1) break;
}
if (count === 1) nextValidWords.push(nextWord);
}
return nextValidWords;
}
}
# 优化方案
上面的方法虽然能够顺利 AC,但我们仍然可以想出更优秀的解决办法。
首先我们可以看到,我们在每一次选择中,都需要遍历整个 wordList 和 visited 数组,那么在大型的测试用例中显然运行时间会变长。首先我们可以想到,其实使用 Set 会更加的快速,因为 Set 的查找时间复杂度是 O(1)。我们仅需要维护一个 wordSet,并实时维护,则同样可以起到一样的效果。
再来我们可以注意 findNextValidWords 这个方法,对于每一次选择,我们都需要遍历整个 wordList,这样的时间复杂度实际上是 word.length * wordList.size。
我们可以换一个思路,因为单词是由 a~z 这有限数量的字符组成的,那么对于每一个单词,我们可以将其每一个字母都替换成 26 个字母,然后判断是否在 wordList 中,这样的时间复杂度就是 word.length * 26。在 wordList 较长的情况下仍然有常数级的性能。
因此我们可以根据优化思路,写出更新的解法:
function ladderLength(beginWord: string, endWord: string, wordList: string[]): number {
if (!wordList.includes(endWord)) return 0;
const wordSet = new Set(wordList);
const queue: string[] = [beginWord];
let step = 1;
while (queue.length > 0) {
let len = queue.length;
while (len--) {
const curWord = queue.shift();
if (curWord === endWord) return step;
const nextValidWords = findNextValidWords(curWord, wordSet);
for (const nextWord of nextValidWords) {
queue.push(nextWord);
// 从字典中删除,避免重复访问
wordSet.delete(nextWord);
}
}
step += 1;
}
return 0;
function findNextValidWords(curWord: string, wordSet: Set<string>): string[] {
const nextValidWords: string[] = [];
// 单词集合比对遍历法,适合单词集合个数较少的情况,复杂度 word.length * wordSet.size
// for (const nextWord of wordSet) {
// let count = 0;
// for (let i = 0; i < curWord.length; i++) {
// if (curWord[i] !== nextWord[i]) {
// count += 1;
// }
// if (count > 1) break;
// }
// if (count === 1) nextValidWords.push(nextWord);
// }
// 26个字母遍历法,适合单词集合个数较多的情况,复杂度 word.length * 26
for (let i = 0; i < curWord.length; i++) {
for (let j = 97; j <= 122; j++) {
const nextWord = curWord.slice(0, i) + String.fromCharCode(j) + curWord.slice(i + 1);
if (wordSet.has(nextWord)) {
nextValidWords.push(nextWord);
wordSet.delete(nextWord);
}
}
}
return nextValidWords;
}
}
# 双向 BFS
双向 BFS 是一种优化 BFS 的方法,它的思路是从起点和终点同时开始搜索,当两边有交集的时候,就可以结束搜索。从而大大优化了搜索的时间复杂度。
在本题中,显然起点和重点都是十分明确的,因此我们可以使用双向 BFS 来优化搜索。
使用双向 BFS 的时候,我们需要维护两个队列,分别是从起点开始的队列和从终点开始的队列。我们每次都从两个队列中选择一个较小的方向进行 BFS,这样可以减少遍历次数。
终止条件有两个,当两个队列中有一个为空时,说明这条路一定不会有交集,因此可以直接返回 0。如果遍历过程中某一时刻,从一侧中遍历到的单词恰好已经被另一侧遍历过了,表明两个队列有交集,此时说明找到了最短路径,可以直接返回 step。
function ladderLength(beginWord: string, endWord: string, wordList: string[]): number {
if (!wordList.includes(endWord)) return 0;
// 双向 BFS
let queue1: string[] = [beginWord];
let visited1 = new Set<string>();
visited1.add(beginWord);
let queue2: string[] = [endWord];
let visited2 = new Set<string>();
visited2.add(endWord);
let step = 1;
const wordSet = new Set<string>(wordList);
while (queue1.length > 0 && queue2.length > 0) {
// 永远选择更小的当前步骤选项集合进行 BFS,可以减少遍历次数
if (queue1.length > queue2.length) {
const tempQueue = queue1;
queue1 = queue2;
queue2 = tempQueue;
const temp = visited1;
visited1 = visited2;
visited2 = temp;
}
let len = queue1.length;
while (len--) {
const curWord = queue1.shift();
// 如果此时从一侧中遍历到的单词恰好已经被另一侧遍历过了,说明此时找到了最短路径
if (visited2.has(curWord)) return step;
const nextValidWords = findNextValidWords(curWord, wordSet);
for (const nextWord of nextValidWords) {
if (!visited1.has(nextWord)) {
queue1.push(nextWord);
visited1.add(nextWord);
}
}
}
step += 1;
}
return 0;
function findNextValidWords(curWord: string, wordSet: Set<string>): string[] {
const nextValidWords: string[] = [];
// 26个字母遍历法,适合单词集合个数较多的情况,复杂度 word.length * 26
for (let i = 0; i < curWord.length; i++) {
for (let j = 97; j <= 122; j++) {
const nextWord = curWord.slice(0, i) + String.fromCharCode(j) + curWord.slice(i + 1);
if (wordSet.has(nextWord)) {
nextValidWords.push(nextWord);
wordSet.delete(nextWord);
}
}
}
return nextValidWords;
}
}