LeetCode 212. 单词搜索 II(Word Search II)
题目的👍:3082
在这里插入代码片
题目的👎:130
1、问题描述
给定一个二维网格 board 和一个字典中的单词列表 words,找出所有同时在二维网格和字典中出现的单词。
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母在一个单词中不允许被重复使用。
示例:
输入:
words = ["oath","pea","eat","rain"] and board =
[
['o','a','a','n'],
['e','t','a','e'],
['i','h','k','r'],
['i','f','l','v']
]
输出: ["eat","oath"]
2、思路与方法
这是一个很典型的回溯法的问题,回溯法通过深度优先搜索(DFS)来实现,只不过在退出当前轮的时候需要将状态“回溯”。
回溯法本质上来说就是一个暴力穷举的方法,不过在算法上体现了回溯的特点来解题,然而暴力的方法存在的一个问题便是时间复杂度很高,因此大多数回溯法都需要通过某些剪枝操作来去掉一些不需要遍历的情况,从而提高算法的效率,本题也是如此。
1)回溯法——无剪枝操作
不考虑剪枝操作此题比较简单,我们只需要遍历 words,判断其中每个单词是否能在board中组合而成即可,在此贴出 79. 单词搜索 的部分代码,即是判断某个单词是否能在board中找到。
public boolean traceBack(char[][] board,boolean[][] flag,int x,int y,int pos,int end,String word) {
if(pos == end) return true;
if(x < 0 || x >= board.length || y < 0 || y >= board[0].length)
return false;
char ch = word.charAt(pos);
if(flag[x][y] == true || board[x][y] != ch)
return false;//如果已经记录到了或者不符合,返回假
flag[x][y]= true;
//只要有一个正确即可
if(traceBack(board,flag,x-1,y,pos+1,end,word)) return true;
if(traceBack(board,flag,x+1,y,pos+1,end,word)) return true;
if(traceBack(board,flag,x,y-1,pos+1,end,word)) return true;
if(traceBack(board,flag,x,y+1,pos+1,end,word)) return true;
flag[x][y] = false;//回溯
return false;
}
2)回溯法——字典树剪枝
意外的是,上面这个方法虽然效率比较低,但是仍然通过了,不过只高于个位数百分比的用户,我们接下来考虑剪枝操作。
幸运的是题目已经给出了很明确的提示:
提示:
你需要优化回溯算法以通过更大数据量的测试。你能否早点停止回溯?
如果当前单词不存在于所有单词的前缀中,则可以立即停止回溯。什么样的数据结构可以有效地执行这样的操作?散列表是否可行?为什么? 前缀树如何?如果你想学习如何实现一个基本的前缀树,请先查看这个问题: 实现Trie(前缀树)。
很明显是希望我们实现一个字典树来进行剪枝,字典树的好处上面已经说得比较明白。
- 字典树
本题包含的字符为’a’-‘z’,对于这样的字典树,简单地理解便是一个26叉树,每个节点有26个子节点,按照序列0-25个子节点对应’a’-‘z’,此外节点还可以使用标志记录当前节点是不是某个单词的最后一位。
根据以上定义可以给出字典树节点:
//字典树节点
class TrieNode {
TrieNode[] next = new TrieNode[26];
boolean isEnd = false;
String word;//这里我们增加一个word属性来记录end节点对应的单词
}
对于字典树,需要实现的方法就是根据一个单词,将单词插入进字典树。对于单词中的每个字符,我们都判断当前字典树的节点是否已经包含了这个字符,如果不包含的话便新建。建立好之后,在查询阶段也类似,因为初始化一个字典树节点时,其26个子节点都为null,若想判断当前字典树节点是否包含某个字符,只需判断对应的子节点是否不为空即可。
//构建字典树
class Trie {
private TrieNode root;//初始化一个字典树节点时,其26个子节点都为null,为null的节点说明当前字典树节点不包含null对应的字符
public Trie() {
root = new TrieNode();
}
public void insert(String s) {
TrieNode cur = root;
for(int i = 0;i<s.length();++i) {
int ch = s.charAt(i) - 'a';
if(cur.next[ch] == null) {
cur.next[ch] = new TrieNode();
}
cur = cur.next[ch];
}
cur.word = s; //利用当前节点记录字符串,避免了需要利用StringBuilder不断拼接的问题
cur.isEnd = true;
}
}
例如三个单词:[“ea”,“ear”,“eat”],构建出的字典树如下图:
于是便可以利用字典树剪枝,例如示例中:
输入:
words = ["oath","pea","eat","rain"] and board =
[
['o','a','a','n'],
['e','t','a','e'],
['i','h','k','r'],
['i','f','l','v']
]
例如深搜路径为 【‘o’,‘a’,‘a’】,进行到如此地步,发现字典树不存在如此路径,那也就不需要继续下去了
3、代码
有两个点:
- 在字典树节点中增加String属性,避免深搜时需要不断进行字符串拼接操作
- list添加了当前节点的字符串之后,要将当前节点的isEnd置为false,防止重复加入当前节点,或者是利用Set带来的性能降低
- 此算法还是会不可避免地重复计算了某些路径,例如有两条路径可以得到同一个单词,则会进行两次,是否可以对字典树进行剪枝来避免这个问题?
class Solution {
int[][] dir = {{-1,0},{1,0},{0,-1},{0,1}};
public List<String> findWords(char[][] board, String[] words) {
boolean[][] visit = new boolean[board.length][board[0].length];
Trie trie = new Trie();
for(int i = 0;i<words.length;++i) {
trie.insert(words[i]);
}
List<String> res = new ArrayList<>();
for(int i = 0;i<board.length;++i) {
for(int j = 0;j<board[0].length;++j) {
traceback(board,visit,i,j,trie.root,res);
}
}
return res;
}
public void traceback(char[][] board,boolean[][] visit,int row,
int col,TrieNode root,List<String> res) {
if(root.isEnd == true) {
root.isEnd = false; //1、将已经加入的字符串置为false,防止重复加入
res.add(root.word);
}
if(row<0 || row>=board.length || col<0 || col>=board[0].length || visit[row][col] == true) return;
char ch1 = board[row][col];
if(root.next[ch1-'a'] == null) return;
else {
visit[row][col] = true;
for(int i = 0;i<4;++i) {
int a = row + dir[i][0];
int b = col + dir[i][1];
traceback(board,visit,a,b,root.next[ch1-'a'],res);
}
visit[row][col] = false;
}
}
//构建字典树
class Trie {
private TrieNode root;
public Trie() {
root = new TrieNode();
}
public void insert(String s) {
TrieNode cur = root;
for(int i = 0;i<s.length();++i) {
int ch = s.charAt(i) - 'a';
if(cur.next[ch] == null) {
cur.next[ch] = new TrieNode();
}
cur = cur.next[ch];
}
cur.word = s; //2、利用当前节点记录字符串,避免了需要利用StringBuilder不断拼接的问题
cur.isEnd = true;
}
}
//字典树节点
class TrieNode {
TrieNode[] next = new TrieNode[26];
boolean isEnd = false;
String word;
}
}
4、复杂度
时间复杂度:还真不会算,官方给出的为O(M(4⋅3L−1))
空间复杂度:visit数组的空间为O(n2)