随缘刷题 —— Leetcode127单词接龙
随缘刷题 —— Leetcode127单词接龙
题目描述
字典 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 <= 10endWord.length == beginWord.length1 <= wordList.length <= 5000wordList[i].length == beginWord.lengthbeginWord、endWord和wordList[i]由小写英文字母组成beginWord != endWordwordList中的所有字符串 互不相同
https://leetcode.cn/problems/word-ladder/description/
思路
一个很直接的思路就是把 wordList 中的单词,连同 beginWord 一起,按照可以相互转换的关系连接起来,构成一个无权图。注意,endWord 必须在 wordList 中;如果它不在字典里,就应该返回 0,不能通过额外加入终点来构造转换序列。然后结合题目要求的“最短转换序列”长度,自然就能想到使用广度优先搜索了。
关键在于实现细节。要构建图,我们可以选择为每个节点维护一个列表,存放与这个节点直接相连的所有节点。这里使用哈希表维护从节点(字符串)到序号(整型)的映射关系,方便通过编号访问邻接表。这之后的广度优先搜索就使用常规的队列来实现。
按照这个思路实现第一版代码:
下面的示例省略了头文件和命名空间声明;使用时需要包含 <string>、<vector>、<unordered_map>、<queue> 和 <climits>,并使用相应的 std 命名空间声明。
方法一
class Solution {
private:
unordered_map<string, int> word2Id;
vector<vector<int>> wordGraph;
int ascId = 0;
bool canTransfer(const string& src, const string& dst) {
int diff = 0;
for(size_t i=0; i<src.size(); i++) {
if(src[i] != dst[i] && ++diff > 1) {
return false;
}
}
return (diff == 1);
}
int allocateId(const string& word) {
if(word2Id.count(word) == 0) {
word2Id[word] = ascId++;
wordGraph.emplace_back();
}
return word2Id[word];
}
void addEdge(const string& src, const string& dst) {
if(!canTransfer(src, dst)) return;
int srcId = allocateId(src);
int dstId = allocateId(dst);
wordGraph[srcId].push_back(dstId);
}
public:
int ladderLength_v1(string beginWord, string endWord, vector<string>& wordList) {
allocateId(beginWord);
for(const string& src : wordList) {
for(const string& dst : wordList) {
addEdge(src, dst);
}
addEdge(beginWord, src);
}
if(word2Id.count(endWord) == 0) return 0;
int beginId = word2Id[beginWord];
int endId = word2Id[endWord];
vector<int> dist(ascId, INT_MAX);
dist[beginId] = 0;
queue<int> que;
que.push(beginId);
while (!que.empty()) {
int currId = que.front();
que.pop();
if(currId == endId) {
return dist[currId] + 1;
}
for(int id : wordGraph[currId]) {
if(dist[id] == INT_MAX) {
dist[id] = dist[currId]+1;
que.push(id);
}
}
}
return 0;
}
};
设字典大小为 \(N\),单词长度为 \(L\),邻接表中存储的边数为 \(E\)。以下分析计入字符串比较、哈希和复制的开销,并假设哈希表的操作在平均意义下不会发生严重冲突。
- 时间复杂度: 两两比较字典中的单词需要 \(N^2\) 次检查,每次最多比较 \(L\) 个字符;起点还需要与字典中的每个单词比较一次。因此建图时间为 \(O(N^2L)\)。BFS 中每个节点最多入队一次,每条边最多检查一次,时间为 \(O(N+E)\)。总时间复杂度为 \(O(N^2L)\),主要开销在建图。
- 空间复杂度: 哈希表保存至多 \(N+1\) 个长度为 \(L\) 的单词,需要 \(O(NL)\) 空间;邻接表需要 \(O(N+E)\) 空间,距离数组和队列需要 \(O(N)\) 空间。总辅助空间复杂度为 \(O(NL+E)\)。若粗略地用 \(E=O(N^2)\) 估计边数上界,也可以写成 \(O(NL+N^2)\)。
这一版的代码在 LeetCode 提交中的执行用时只击败了约 15% 的提交,非常糟糕。主要原因在于建图的时候要嵌套循环遍历字典里的单词,如果能够把这部分开销进行优化,或许性能表现会更好。
方法二
我们希望不再两两比较字典中的单词,而是依次处理每个单词,并枚举它的各个字符位置来建图。这就需要用到一点技巧:引入虚拟节点。具体地,对于单词 hit,我们创建三个虚拟节点 *it、h*t、hi*,并让 hit 与这三个虚拟节点分别双向连边。如果另一个单词能够转化为 hit,那么它必然与 hit 共享其中一个虚拟节点。相同的通配模式通过哈希表复用同一个节点;由于原单词只包含小写字母,* 不会与真实单词混淆。对于每一个单词,我们枚举它连接到的虚拟节点,把该单词对应的 id 与这些虚拟节点对应的 id 相连即可,就不需要再回过头枚举每一个实际的单词了。
这样一来,新图中的边只连接真实单词节点和虚拟节点,路径上的节点类型交替出现。原图中的一次单词转换,在新图中对应“真实单词 → 虚拟节点 → 真实单词”两条边。BFS 求最短路径的方法照旧,但如果新图中从起点到终点的最短距离是 \(d\) 条边,答案应为 \(d/2+1\):先还原转换次数,再加上起点这个单词,而不是直接把单词数减半。
每个单词会生成 \(L\) 个通配模式,模式总数为 \(O(NL)\)。不过,下面的实现使用字符串作为哈希表的键,对一个长度为 \(L\) 的模式进行哈希或复制需要 \(O(L)\) 时间,所以建图的平均时间复杂度是 \(O(NL^2)\)。
class Solution {
private:
unordered_map<string, int> word2Id;
vector<vector<int>> wordGraph;
int ascId = 0;
int allocateId(const string& word) {
if(word2Id.count(word) == 0) {
word2Id[word] = ascId++;
wordGraph.emplace_back();
}
return word2Id[word];
}
void addEdge(string& word) {
int id = allocateId(word);
for(char& c : word) {
/* 构建虚拟节点并与实际节点相连 */
char tmp = c;
c = '*';
int vid = allocateId(word);
wordGraph[id].push_back(vid);
wordGraph[vid].push_back(id);
c = tmp;
}
}
public:
int ladderLength_v2(string beginWord, string endWord, vector<string>& wordList) {
for(string& word : wordList) {
addEdge(word);
}
addEdge(beginWord);
if(word2Id.count(endWord) == 0) return 0;
int beginId = word2Id[beginWord];
int endId = word2Id[endWord];
vector<int> dist(ascId, INT_MAX);
dist[beginId] = 0;
queue<int> que;
que.push(beginId);
while (!que.empty()) {
int currId = que.front();
que.pop();
if(currId == endId) {
return dist[currId] / 2 + 1;
}
for(int id : wordGraph[currId]) {
if(dist[id] == INT_MAX) {
dist[id] = dist[currId]+1;
que.push(id);
}
}
}
return 0;
}
};
这一版在我这次提交中的执行用时击败了约 43% 的提交,比前一版有所改善。具体排名仍然只代表这次评测结果。
- 时间复杂度: 真实节点和虚拟节点合计为 \(O(NL)\) 个,每个真实单词连接 \(L\) 个虚拟节点,存储的边数也是 \(O(NL)\)。因此 BFS 时间为 \(O(NL)\);加上字符串哈希、复制所需的建图时间,总平均时间复杂度为 \(O(NL^2)\)。
- 空间复杂度: 哈希表至多保存 \(O(NL)\) 个长度为 \(L\) 的字符串键,需要 \(O(NL^2)\) 空间;邻接表、距离数组和队列共需要 \(O(NL)\) 空间。因此总辅助空间复杂度为 \(O(NL^2)\)。
方法三
根据给定字典构造的图可能会很大,而广度优先搜索实际访问的节点数量与搜索深度和每层新增节点的数量有关。假如每个节点的分支数量相同,搜索空间会随着层数的增长指数级的增加。考虑一个简单的二叉树,每一层都是满二叉树的扩展,节点的数量会以 2 为底数呈指数增长。
在当前题目的约束下,有没有什么方法能进一步减少搜索空间呢?不难发现,方法二构造的是无向图,从 beginWord 到 endWord 与反方向的最短距离相同。因此可以一边从 beginWord 开始,另一边从 endWord 开始,交替进行两次 BFS。下面的实现每次从两边各扩展完整的一层,并在取出节点时检查它是否已经被另一边访问过。按照这种逐层交替扩展的顺序,第一次检测到相遇就可以得到最短路径;两边的距离之和是虚拟图中的边数,还需要除以 2 再加 1 才是答案。
下面的方法沿用方法二的成员变量以及 allocateId、addEdge 辅助函数,放在同一个 Solution 类的 public 区域中:
int ladderLength_v3(string beginWord, string endWord, vector<string>& wordList) {
for(string& word : wordList) {
addEdge(word);
}
addEdge(beginWord);
if(word2Id.count(endWord) == 0) return 0;
int beginId = word2Id[beginWord];
int endId = word2Id[endWord];
vector<int> distBegin(ascId, INT_MAX);
vector<int> distEnd(ascId, INT_MAX);
distBegin[beginId] = 0;
distEnd[endId] = 0;
queue<int> queBegin;
queue<int> queEnd;
queBegin.push(beginId);
queEnd.push(endId);
/* 双向BFS */
while (!queBegin.empty() && !queEnd.empty()) {
/* 一次遍历一层 */
int queBeginSize = queBegin.size();
for(int i=0; i<queBeginSize; i++) {
int currId = queBegin.front();
queBegin.pop();
if(distEnd[currId] != INT_MAX) {
return (distBegin[currId] + distEnd[currId]) / 2 + 1;
}
for(int id : wordGraph[currId]) {
if(distBegin[id] == INT_MAX) {
distBegin[id] = distBegin[currId]+1;
queBegin.push(id);
}
}
}
int queEndSize = queEnd.size();
for(int i=0; i<queEndSize; i++) {
int currId = queEnd.front();
queEnd.pop();
if(distBegin[currId] != INT_MAX) {
return (distBegin[currId] + distEnd[currId]) / 2 + 1;
}
for(int id : wordGraph[currId]) {
if(distEnd[id] == INT_MAX) {
distEnd[id] = distEnd[currId]+1;
queEnd.push(id);
}
}
}
}
return 0;
}
- 时间复杂度: 建图方式与方法二相同,平均时间为 \(O(NL^2)\)。每个节点在每一侧的 BFS 中最多入队一次,每条边也最多被每一侧检查一次,所以最坏搜索时间仍为 \(O(NL)\),总平均时间复杂度仍为 \(O(NL^2)\)。双向 BFS 主要减少实际搜索量,没有降低这里的最坏渐近复杂度,也不会减少提前构造整张图的开销。
- 空间复杂度: 两套距离数组和队列需要 \(O(NL)\) 空间,只比单向 BFS 增加常数倍;加上图和哈希表,总辅助空间复杂度仍为 \(O(NL^2)\)。
浙公网安备 33010602011771号