126. 单词接龙 II(这道题)
题目
按字典 wordList 完成从单词 beginWord 到单词 endWord 转化,一个表示此过程的 转换序列 是形式上像 beginWord -> s1 -> s2 -> ... -> sk 这样的单词序列,并满足:
每对相邻的单词之间仅有单个字母不同。 转换过程中的每个单词 si(1 <= i <= k)必须是字典 wordList 中的单词。注意,beginWord 不必是字典 wordList 中的单词。 sk == endWord 给你两个单词 beginWord 和 endWord ,以及一个字典 wordList 。请你找出并返回所有从 beginWord 到 endWord 的 最短转换序列 ,如果不存在这样的转换序列,返回一个空列表。每个序列都应该以单词列表 [beginWord, s1, s2, ..., sk] 的形式返回。
题解
我的coding -_-|| Error; 难过😫不愧大难度的。
const findLadders = (beginWord, endWord, wordList) => {
let ans = [];
const dict = new Set(wordList); //单词字典
if(!dict.has(endWord)) return ans; //没有终点词汇
let q1 = [beginWord];
let q2 = [endWord];
const next = new Map();
let reversed = false, found = false;
while(q1.length) {
let q = [];
while(q1.length){
let w = q1.shift();
for(let i = 0, len = w.length; i < len; i++) {
for(let c = 97; c <= 122; c++){
const s = `${w.slice(0, i)}${String.fromCharCode(c)}${w.slice(i+1)}`;
if(q2.includes(s)){
reversed? next.set(s, next.has(s) ? [... next.get(s), w]: [w]) : next.set(w, next.has(w) ? [... next.get(w), s]: [s]);
found = true;
}
if(dict.has(s)) {
reversed? next.set(s, next.has(s) ? [... next.get(s), w]: [w]) : next.set(w, next.has(w) ? [... next.get(w), s]: [s]);
q.push(s);
}
}
}
}
if(found) break;
if(q.length <= q2.length) {
q1 = q;
} else {
reversed = !reversed;
q1 = q2;
q2 = q;
}
}
if(found) {
let path = [beginWord];
backtracking(beginWord, endWord, next, path, ans);
}
return ans;
}
function backtracking(src, dst, next, path, ans) {
if(src === dst) {
ans.push([...path])
}
if(next.get(src)) {
for(const s of next.get(src)) {
path.push(s);
backtracking(s, dst, next, path, ans);
path.pop();
}
}
}
下面是大神的解析和答案。
解析
这道题用到了三个知识点,bfs、dfs回溯。 第一个知识点 深度优先搜索(depth-first seach,DFS)在搜索到一个新的节点时,立即对该新节点进行遍 历;因此遍历需要用先入后出的栈来实现,也可以通过与栈等价的递归来实现。对于树结构而言, 由于总是对新节点调用遍历,因此看起来是向着“深”的方向前进。 深度优先搜索也可以用来检测环路:记录每个遍历过的节点的父节点,若一个节点被再次遍 历且父节点不同,则说明有环。也可以用拓扑排序判断是否有环路,若最后存 在入度不为零的点,则说明有环。 有时我们可能会需要对已经搜索过的节点进行标记,以防止在遍历时重复搜索某个节点,这 种做法叫做状态记录或记忆化(memoization)。
第二个知识点。
回溯法(backtracking)是优先搜索的一种特殊情况,又称为试探法,常用于需要记录节点状
态的深度优先搜索。通常来说,排列、组合、选择类问题使用回溯法比较方便。
顾名思义,回溯法的核心是回溯。在搜索到某一节点的时候,如果我们发现目前的节点(及
其子节点)并不是需求目标时,我们回退到原来的节点继续搜索,并且把在目前节点修改的状态 还原。这样的好处是我们可以始终只对图的总状态进行修改,而非每次遍历时新建一个图来储存
状态。在具体的写法上,它与普通的深度优先搜索一样,都有 [修改当前节点状态]→[递归子节
点] 的步骤,只是多了回溯的步骤,变成了 [修改当前节点状态]→[递归子节点]→[回改当前节点
状态]。
记住两个小诀窍,一是按引用传状态,二是所有的状态修 改在递归完成后回改。 回溯法修改一般有两种情况,一种是修改最后一位输出,比如排列组合;一种是修改访问标 记,比如矩阵里搜字符串。
第三个知识点。 广度优先搜索(breadth-first search,BFS)不同与深度优先搜索,它是一层层进行遍历的,因 此需要用先入先出的队列而非先入后出的栈进行遍历。由于是按层次进行遍历,广度优先搜索时 按照“广”的方向进行遍历的,也常常用来处理最短路径等问题。
深度优先搜索和广度优先搜索都可以处理可达性问题,即从一个节点开始是否
能达到另一个节点。因为深度优先搜索可以利用递归快速实现,很多人会习惯使用深度优先搜索
刷此类题目。可能产生栈溢出的情况;而用栈实现的深度优先搜索和用队列实现的广度优先搜索在写法上并没有太
大差异,因此使用哪一种搜索方式需要根据实际的功能需求来判断。
回到本题中来。
我们可以把起始字符串、终止字符串、以及单词表里所有的字符串想象成节点。若两个字符
串只有一个字符不同,那么它们相连。因为题目需要输出修改次数最少的所有修改方式,因此我
们可以使用广度优先搜索,求得起始节点到终止节点的最短距离。
在搜索结束后,我们还需要通过回溯法来重建所有可能的路径。
1、两个单词只有一个字母之差,是邻接关系、递进层次关系,输出修改次数最少最短路径关系。
我们需要做的就是通过bfs ,把这些wordList构建开始节点到其他节点的无向图,相邻节点相差一个字符。 如果把这些wordList 进行分层,每个节点记录层级,相邻节点相差一个字符,相差一个层次,就是开始字符到结束字符(结束字符的层次不一定是最深一层)的有向图。
bfs 得到的有向图,再通过dfs回溯出开始结点到找到最终节点的所有可能路径。
2、构建bfs(层次)图需要的状态信息。
- wordMap:记录所有节点,每个节点保存它所有父亲信息,即每个每个节点的键为当前节点信息,值为“父亲们”数组,记录哪些单词变过来。
- levelMap: 记录这个单词的level,根据当前节点的“父亲们”的所在层数,选出满足最短路径的。
- visited: 记录访问过的节点记录访问过的单词,避免将它重复加入到路径中
- queue: 每一层单词的队列,初始放入起点词,
- 我们在 BFS 时要构建这种关系,供 DFS 时使用。
3、bfs步骤
- 找新单词:
遍历当前层的单词逐个出列将它的字母逐个改动成 a 到 z,找出存在于单词表的新单词。 - wordMap存储符合条件的新单词:作为 key 存到 wordMap,值是它的“父单词”,即出列的单词。
- 判断新单词是否是结束字符:如果这个新单词正好是终点词,说明肯定存在有路径可以变到终点词。
- 状态跟新1-visited: 用 visited 表记录访问过的单词,避免将它重复加入到路径中
- 状态更新2-levelMap: 用 levelMap 记录路径上的单词所在的层。
- 状态更新3-queue:将下一层的新单词入列,下次循环全都是下一层的单词
- 层次状态、访问状态和每一层队列的单词都是唯一的, 只有状态记录1 ,23状态才可以更新。
coding
/**
* @param {string} beginWord
* @param {string} endWord
* @param {string[]} wordList
* @return {string[][]}
*/
const findLadders = (beginWord, endWord, wordList) => {
// 建状态记录表
const wordSet = new Set(wordList);
const ans = [];
if(!wordSet.has(endWord)) return ans; // 单词表中没有终点词,返回空数组
const wordMap = new Map();
const levelMap = new Map();
const visited = new Set();
// 初始化状态
visited.add(beginWord)
const queue = [beginWord];
let finished = false;
let level = 0;
levelMap.set(beginWord, 0);
// bfs 操作
while(queue.length) {
level++;
for(let i = 0, levelSize = queue.length; i < levelSize; i++) {
const word = queue.shift();
for(let j = 0, len = word.length; j < len; j++) { // 遍历单词的所有字符
for(let c = 97; c <= 122; c++) { // 遍历26个字母字符
const newWord = `${word.slice(0, j)}${String.fromCharCode(c)}${word.slice(j+1)}`;
if(!wordSet.has(newWord)) continue; / 不是单词表中的单词就忽略
// 存储新单词
if(wordMap.has(newWord))
wordMap.get(newWord).push(word);
else
wordMap.set(newWord, [word]);
if(visited.has(newWord)) continue; // 层次状态、访问状态和每一层队列的单词都是唯一的,该新单词已经访问过就忽略
if(newWord === endWord)
finished = true;
levelMap.set(newWord, level);
queue.push(newWord);
visited.add(newWord);
}
}
}
}
if(!finished) return ans;
// dfs 回溯查找符合条件的路径
const dfs = (path, beginWord, word) => {
if(word === beginWord) { // 当前遍历的word,和起始词相同 找到一条路径
ans.push([beginWord, ...path]);
return;
}
path.unshift(word); // dfs 修改状态 将当前单词加入到path数组的开头
if(wordMap.get(word)){
for(const parent of wordMap.get(word)) { // 遍历“父单词”们
if(levelMap.get(parent) + 1 === levelMap.get(word)) {
// 一个单词可能有很多“邻居单词”可选择,但为了路径最短,
//需要选择满足「当前单词的层 == 邻居单词的层 + 1」的节点,这才是最短路径中的节点
dfs(path, beginWord, parent);
}
}
}
path.shift(); // dfs 回复状态 回溯,撤销选择,将path数组开头的单词弹出
}
dfs([], beginWord, endWord);
return ans;
}