题目来源:208. 实现 Trie (前缀树)、212. 单词搜索 II
大致题意:
给定一个 m×n 的二维字符数组,和一个字符串数组。输出字符串数组中能由字符数组的连续字符组成的字符串。连续字符是指上下左右相邻的字符元素,在一个匹配中,一个字符元素只允许出现一次。
思路
本来我想的就是用 dfs,直接对字符串数组的每个元素,都对字符数组每个位置都进行 dfs,然后找到一个字符串就加进去答案。
理论上可行,但是写出来不对,而且很明显复杂度太高,就去看题解。
然后发现是用前缀树解题,我又不知道前缀树是什么,只好先去把前缀树那道题做了。
前缀树
前缀树就是一种树形数据结构,可以高效的存储和检索字符数据集的键。
这里实现它的初始化、插入和查询方法
class Trie {
private Trie[] children;
private boolean isEnd;
public Trie() {
// 数组长度 26 对应小写字母个数,即每个节点最多可能有 26 个子节点
this.children = new Trie[26];
this.isEnd = false;
}
// 插入一个新词
public void insert(String word) {
// 用 node 指代 this,方便之后字典树的构建
Trie node = this;
for (int i = 0; i < word.length(); i++) {
int ch = word.charAt(i) - 'a';
// 若当前节点还未有 ch 子节点,则 new 一个
if (node.children[ch] == null) {
node.children[ch] = new Trie();
}
// 迭代更新 node
node = node.children[ch];
}
// 表示当前节点是一个词的末尾
node.isEnd = true;
}
// 查找单词
public boolean search(String word) {
Trie node = searchPrefix(word);
return node != null && node.isEnd;
}
// 查找一个前缀
public boolean startsWith(String prefix) {
return searchPrefix(prefix) != null;
}
// 在前缀树中查找是否有该前缀,有则返回前缀的末尾节点
private Trie searchPrefix(String prefix) {
Trie node = this;
for (int i = 0; i < prefix.length(); i++) {
int ch = prefix.charAt(i) - 'a';
// 查找是否有当前字符的子节点
if (node.children[ch] == null) {
return null;
}
node = node.children[ch];
}
return node;
}
}
前缀树 + dfs
- 先遍历字符串数组,将所有的字符串插入前缀树
- 遍历字符数组的所有元素,对每一个元素进行 dfs 遍历
- 在 dfs 遍历时,若当前元素在当前的前缀树子节点中,则继续遍历它的未经过的相邻节点。为了防止经过已遍历的节点,在对当前节点的邻点进行 dfs 前,先将当前元素做标记,比如将当前字符替换为 ‘#’ ;若当前节点不再前缀树子节点,直接返回。遍历过程中,若当前字符对应的前缀树节点表示当前位置是之前放入的某个字符串末尾,则将该字符串放入 Set
- 最后返回 Set 转换后的 List 即可
代码:
public class FindWords {
public List<String> findWords(char[][] board, String[] words) {
Set<String> ans = new HashSet<String>();
// 表示相邻的四个方向
int[][] direction = new int[][]{{0, 1}, {1, 0}, {-1, 0}, {0, -1}};
Trie trie = new Trie();
// 将字符串都放入前缀树
for (String word : words) {
trie.insert(word);
}
// 对每个字符都进行 dfs
for (int i = 0; i < board.length; i++) {
for (int j = 0; j < board[0].length; j++) {
dfs(board, trie, i, j, ans, direction);
}
}
// Set 转 List
return new ArrayList<String>(ans);
}
// dfs 遍历字符数组,查看是否有连续的元素构成前缀树中插入的某个字符串
public void dfs(char[][] board, Trie node, int x, int y, Set<String> ans, int[][] direction) {
// 当前字符不在前缀树子结点中,也就是不再前缀树中,直接返回
if (!node.children.containsKey(board[x][y])) {
return;
}
char ch = board[x][y];
// 将当前字符位置做标记,防止重复遍历和出现多次
board[x][y] = '#';
// 获取字符对应的子节点
node = node.children.get(ch);
// 若该子节点是一个字符串的末尾,放入 Set
if (!node.word.equals("")) {
ans.add(node.word);
}
// 继续 dfs 四个相邻点
for (int[] dir : direction) {
int newX = x + dir[0];
int newY = y + dir[1];
// 防止越界
if (newX >= 0 && newX < board.length && newY >= 0 && newY < board[0].length) {
dfs(board, node, newX, newY, ans, direction);
}
}
// 遍历结束,标记去除
board[x][y] = ch;
}
}
// 前缀树定义
class Trie {
// 存节点对应的字符串
String word;
// 字典树,一种字符对应一个子节点
Map<Character, Trie> children;
// 初始化,构造函数
public Trie() {
this.word = "";
this.children = new HashMap<Character, Trie>();
}
public void insert(String word) {
Trie node = this;
for (int i = 0; i < word.length(); i++) {
char ch = word.charAt(i);
// 若当前节点还未有该子节点,则新建一个
if (!node.children.containsKey(ch)) {
node.children.put(ch, new Trie());
}
node = node.children.get(ch);
}
// 子节点为字符串末尾时,在节点中存下该字符串
node.word = word;
}
}
剪枝优化
在上个方法中,可能会重复遍历到同一个字符串多次。有大量的冗余计算。
优化:
- 每次遍历到前缀树的叶节点,也就是遍历完一个字符串时,就将该节点对应的字符串置空,同时再将该节点从它的父节点中去掉,也就是在 dfs 函数的末尾,将该节点与父节点的链接去除。那么同理,若父节点没有其它的子节点,或者说其子节点都被去除后,它也变成了新的叶节点,会在遍历后去除。
这样就保证了所有字符串的前缀树只会被遍历出一次,大大减少了冗余计算
代码:
class Solution {
public List<String> findWords(char[][] board, String[] words) {
List<String> ans = new ArrayList<String>();
// 表示相邻的四个方向
int[][] direction = new int[][]{{0, 1}, {1, 0}, {-1, 0}, {0, -1}};
Trie trie = new Trie();
// 将字符串都放入前缀树
for (String word : words) {
trie.insert(word);
}
// 对每个字符都进行 dfs
for (int i = 0; i < board.length; i++) {
for (int j = 0; j < board[0].length; j++) {
dfs(board, trie, i, j, ans, direction);
}
}
// Set 转 List
return ans;
}
// dfs 遍历字符数组,查看是否有连续的元素构成前缀树中插入的某个字符串
public void dfs(char[][] board, Trie node, int x, int y, List<String> ans, int[][] direction) {
// 当前字符不在前缀树子结点中,也就是不再前缀树中,直接返回
if (!node.children.containsKey(board[x][y])) {
return;
}
char ch = board[x][y];
// 获取字符对应的子节点
Trie next = node.children.get(ch);
// 若该子节点是一个字符串的末尾,放入 Set
if (!next.word.equals("")) {
ans.add(next.word);
// 删除匹配过的单词
next.word = "";
}
// 当节点有子节点时才进行遍历
if (!next.children.isEmpty()) {
// 将当前字符位置做标记,防止重复遍历和出现多次
board[x][y] = '#';
// 继续 dfs 四个相邻点
for (int[] dir : direction) {
int newX = x + dir[0];
int newY = y + dir[1];
// 防止越界
if (newX >= 0 && newX < board.length && newY >= 0 && newY < board[0].length) {
dfs(board, next, newX, newY, ans, direction);
}
}
// 遍历结束,标记去除
board[x][y] = ch;
}
// 当节点的子节点为空,去除该节点(因为该节点也遍历过了,若有对应的字符串也已经放入Set)
if (next.children.isEmpty()) {
node.children.remove(ch);
}
}
}
// 前缀树定义
class Trie {
// 存节点对应的字符串
String word;
// 字典树,一种字符对应一个子节点
Map<Character, Trie> children;
// 初始化,构造函数
public Trie() {
this.word = "";
this.children = new HashMap<Character, Trie>();
}
public void insert(String word) {
Trie node = this;
for (int i = 0; i < word.length(); i++) {
char ch = word.charAt(i);
// 若当前节点还未有该子节点,则新建一个
if (!node.children.containsKey(ch)) {
node.children.put(ch, new Trie());
}
node = node.children.get(ch);
}
// 子节点为字符串末尾时,在节点中存下该字符串
node.word = word;
}
}