[设计数据结构] 208. 实现前缀树Trie(节点:Trie类/边:类内数组、终结点区分单词和前缀) 211. 添加与搜索单词(前缀树的应用) 212. 单词搜索 II(回溯法 + 前缀树)
208. 实现 Trie (设计前缀树)
题目链接:https://leetcode-cn.com/problems/implement-trie-prefix-tree/
分类:
- 设计数据结构(总结需求、按需求设计数据结构、尽可能简化(节点:类;边:类内数组))
- 前缀树(边表示字母,节点表示阶段、查找单词和前缀:用终结点区分)
题目分析
什么是前缀树?
如下图:
![](https://i-blog.csdnimg.cn/blog_migrate/b6a6be267cfa5975743396b50cb9c333.png)
可以发现前缀树里存放的是一个个节点和边,其中从一个节点进入到下一个节点,经过一条边就代表添加一个边所表示的字母,添加不同的字母,就相当于选择不同的边,进入不同的子树。这些边所表示的字母组合起来就能构成一个字符串。我们就要在这棵前缀树上完成插入、查找单词、查找前缀三种操作。
作用:搜索联想、最长前缀匹配等。
前缀树提供的三个功能函数:
- insert:关键函数,插入一个单词到前缀树中,要将单词的每个字符存入前缀树中,
- search:在前缀树中查找一个单词,
- startsWith:查看前缀树中是否存在指定的前缀。
前缀树基本函数的实现要求
根据上面几个基本函数,可以整理前缀树的几个要求:
-
要能用startsWith判断是否存在指定前缀,所以每存入一个单词,就要依次存入单词的每个字符构成一棵前缀树,以便与指定前缀的每个字符一一比对。
所以拿树的边来表示一个字符,每存入一个字符,就从树的当前节点沿着对应边进入下一层的节点。
-
insert插入的完整单词要能被search找到,因为要同时满足找前缀和找单词,所以单词要能够和前缀在查找时区分开来,因此,我们需要能够在前缀树中分阶段寻找字符串,前面已经拿边来表示字符,所以这里拿节点来表示一个个阶段。表示前缀的节点和表示单词末尾的节点要区分开来,可以设置:表示前缀的节点为常规节点,表示单词末尾的节点为终结节点。
-
查找一个前缀时,能够在前缀树里找到前缀的所有字符就返回true;
-
查找一个单词时,需要同时满足:在前缀树里匹配单词的所有字母,且匹配完单词的最后一个字母后到达的节点是终结点,才说明这个树里存放了这么一个单词,否则它只是其他单词的前缀部分,不能看做一个单词。
-
所以问题转化为:设计一个树,自定义树的节点和边的数据结构,以便能够实现前缀树的要求。
算法设计
1、设计节点和边的数据结构
节点类设置一个标志位flag:flag = true表示这个节点是单词的终结点,flag = false表示是常规节点。
因为每个节点所对应的边是相互独立互不干扰的,且边对应的字母范围在a-z,所以每个节点都开辟一个大小为26的数组:
- 数组下标0~25就对应26个字母;
- 数组每个元素的类型为Trie,所以元素值表示和这条边相连的下一个节点。(类似二叉树节点里的左右孩子left,right)
参考二叉树的构造:二叉树并不需要单独设置一个类表示二叉树整体,直接用一个个节点组合起来,保留根节点,就能在逻辑上构成一棵二叉树。
同理,前缀树也是逻辑上的前缀树,Trie类本身就作为节点类,再在类里创建一个根节点,把Trie类实例化时得到的实例化对象本身作为根节点,因为Trie类实例化的节点是整个前缀树的第一个节点,所以拿它作为根节点root。
实现代码:
//前缀树节点类
class Trie {
boolean flag;//表示节点所在的阶段(true=终结点、false=非终结点)
Trie[] edges;//边所连接的下一个节点的数组(下标0~25对应字母a~z)
Trie root;//前缀树的根节点
/** Initialize your data structure here. */
public Trie() {
flag = false;
edges = new Trie[26];//一个节点最多只可能有26条边
root = this;//获取当前实例对象本身作为根节点
}
...
}
2、设计三个功能函数
insert(word)
向前缀树里插入一个单词word,从根节点开始,提取word的第0个字母,该字母对应一条边,根节点就作为边的起点,还需要再创建一个新节点作为边的终点end,接着向根节点的边数组edges[word.charAt(0) - ‘a’]处插入刚刚创建的end节点,这样才算向前缀树里插入单词的一个字母。
在插入下一个字母之前,先将工作指针指向刚刚创建的end,以end为起点提取word的第1个字母,后面的操作同上。
以此类推,直到到达单词的最后一个字母。到达单词的最后一个字母时,需要将该字母边对应的终点节点的符号位flag置true,表示该节点是一个单词的终结点,为调用search查找单词提供依据。
search(word):(依赖终结点)
从前缀树的根节点开始,查看节点的边数组edges是否存在word的第0个字母对应的边,即判断edges[word.charAt(0) - ‘a’]上是否存在节点:
- 如果不存在节点,说明不存在对应的边,查找失败,返回false;
- 如果存在节点,说明word的第0个字母匹配成功,继续进入下一个节点,查找word的下一个字母;
直到找到word的最后一个字母对应的节点,如果该节点的flag位为true,则说明在前缀树里找到word,返回true;如果flag为false,则说明在前缀树里并没有找到word单词的终结点,返回false。
startsWith(prefix)
查找过程和search一样,但在找到prefix的最后一个字母时,并不需要判断对应的节点的flag位。
实现遇到的问题
1、如何创建前缀树的根节点root?(在类里获取这个类的实例化对象)
在前缀树节点Trie类里创建一个Trie成员变量 root,初始化为 this,就表示它是调用构造器创建Trie对象时这个实例化对象本身,也是整个前缀树创建的第一个节点,所以用这个节点来作为根节点root,而且,root作为类成员变量,可以在类里直接使用。
Trie root = this;
- 注意:如果在构造器里使用Trie root = Trie()初始化root节点会陷入死循环。(一开始犯的错误!!!)
每调用一次insert,search,startsWith,都是从根节点开始工作,所以这几个函数的第一步都是先获取前缀树的根节点:
Trie node = root;//工作节点
2、编写insert时忽略了已经存在的结点
insert可能存在两个单词有相同的一部分前缀,所以并不是每遇到一个字母都需要在对应的边数组上创建一个新节点,也可能是节点本身已经存在,则直接获取该节点即可。
这一问题本身不是什么难点,但我实现时忽略了,在此记录下来,避免以后再犯。
实现代码
class Trie {
boolean flag;//表示节点所在的阶段(true=终结点、false=非终结点)
Trie[] edges;//边所连接的下一个节点的数组(下标0~25对应字母a~z)
Trie root;//前缀树的根节点
/** Initialize your data structure here. */
public Trie() {
flag = false;
edges = new Trie[26];//一个节点最多只可能有26条边
root = this;//获取当前实例对象本身作为根节点
}
/** Inserts a word into the trie. */
public void insert(String word) {
Trie node = root;//以root作为初始的工作节点
int idx = 0;//word上的工作指针
while(idx < word.length()){
char ch = word.charAt(idx);
//节点为空时才创建新节点,否则直接从数组中获取即可(易忽略)
if(node.edges[ch - 'a'] == null) node.edges[ch - 'a'] = new Trie();
//如果当前ch是最后一个字母,则将创建的节点的flag位置true,表示终结点
if(idx == word.length() - 1){
node.edges[ch - 'a'].flag = true;
}
idx++;
node = node.edges[ch - 'a'];
}
}
/** Returns if the word is in the trie. */
public boolean search(String word) {
Trie node = root;//从根节点开始查找
int idx = 0;
while(idx < word.length()){
char ch = word.charAt(idx);
//如果对应边上的节点为空,说明查找失败
if(node.edges[ch - 'a'] == null) return false;
//如果对应边上存在非空节点,说明查找到当前字母
else{
//如果当前字母是最后一个字母,则判断最后一个节点是不是终结点
if(idx == word.length() - 1){
//如果最后一个节点是终结点,则返回true,说明查找成功
if(node.edges[ch - 'a'].flag) return true;
//如果不是终结点,则返回false,表示查找失败
else return false;
}
//如果当前字母不是最后一个字母,则状态变量更新,继续下一轮工作
idx++;
node = node.edges[ch - 'a'];
}
}
return true;
}
/** Returns if there is any word in the trie that starts with the given prefix. */
public boolean startsWith(String prefix) {
Trie node = root;//从根节点开始查找
int idx = 0;
while(idx < prefix.length()){
char ch = prefix.charAt(idx);
//如果对应边上的节点为空,说明查找失败
if(node.edges[ch - 'a'] == null) return false;
//如果对应边上存在非空节点,说明查找到当前字母
else{
//和search相比不需要判断最后一个节点是否为终结点
idx++;
node = node.edges[ch - 'a'];
}
}
return true;
}
}
211. 添加与搜索单词 (前缀树的应用)
题目链接:https://leetcode-cn.com/problems/design-add-and-search-words-data-structure/
分类:
- 设计数据结构(设计前缀树:节点=Trie类;边=Trie类内数组edges)
- 前缀树(边表示字母,节点表示阶段、查找单词:设置终结点)
- 查找单词(递归查找: ‘.’ 字符和非 ‘.’ 字符)
题目分析
做过208.前缀树可以发现211题可以用前缀树的方法来解答。
-
addWord和208的insert相同,加入一个单词时按一个个字符加入。
-
search和208题设计的search函数相对应,查找一个单词也是按一个个字符比对。
但211题在调用search函数查找单词时,还需要增加对"."的判断。
思路:设计数据结构
对于构建前缀树的更详细分析见208题。
1、前缀树节点的设计:
节点 = 阶段,边 = 字母,所以节点内部设置一个边数组edges表示从这个节点出发的所有边,因为规定只能包含a~z,所以edges数组的大小为26.
节点类中增加一个flag:
- flag == true表示该节点是终结点;
- flag == false表该节点不是终结点,作为查找单词的依据。
再设置一个根节点root,每次插入和查找都从根节点开始。
2、函数设计:
addWord
和208的insert相同,从根节点开始,取word的第0个字母ch,找到根节点的边数组的对应下标edges[ch-‘a’],如果数组该位置存在节点,就直接跳过,如果不存在节点,则创建一个新的前缀树节点填充到该位置上。
然后,取该节点作为起点,取word的下一个字母ch,重复上面的操作。
直到到达word的最后一个字母,它对应一条边,将这条边所指向的下一个节点的flag设置为true,表示这个节点是word的终结点。
search:递归函数
递归函数的参数设置:待查找单词,起点节点,单词的下标索引idx
递归函数的返回值:返回true表示查找到指定单词,返回false表示未找到。
递归出口:
- idx == word.length() 且 节点是终结点,返回true;
- idx == word.length() 且 节点不是终结点,返回false;(即使匹配word的所有字母,但对应的节点不是终结点,也不能说明查找成功)
递归主体:
- 如果遇到的是非"."字符,则search和208的search相同,从根节点开始,取word的第0个字母,找到根节点的边数组的对应下标edges[ch-‘a’],如果该位置不存在节点,则返回false;
如果存在节点,则继续以该节点为起点,取word的第1个字母继续查找。
实现上和208不同:
208是迭代,通过变量之间的覆盖更新状态变量;
这题的状态更新是通过递归函数的参数来传递的:
searchHelper(word, node.edges[ch - 'a'], idx + 1);
表示:以node.edges[ch - 'a']为起点,word取下一个字母,进入下一层递归。
- 如果遇到的是".",则当前节点的每一条边都是可选的,for循环枚举每一条存在的边,即枚举edges数组里不为null的元素,然后以这个节点为起点,idx + 1进入下一层递归。
- 如果所有的边都不存在,则返回false。
实现代码
class WordDictionary {
class Trie{
boolean flag;
Trie[] edges;
public Trie(){
flag = false;
edges = new Trie[26];
}
}
Trie root;
/** Initialize your data structure here. */
public WordDictionary() {
root = new Trie();
}
/** Adds a word into the data structure. */
public void addWord(String word) {
Trie node = root;
for(int i = 0; i < word.length(); i++){
char ch = word.charAt(i);
if(node.edges[ch - 'a'] == null) node.edges[ch - 'a'] = new Trie();
node = node.edges[ch - 'a'];
if(i == word.length() - 1){
node.flag = true;
}
}
}
/** Returns if the word is in the data structure. A word could contain the dot character '.' to represent any one letter. */
public boolean search(String word) {
return searchHelper(word, root, 0);//从根节点开始查找
}
//递归函数查找单词:参数(待查单词,起点节点)
public boolean searchHelper(String word, Trie start, int idx){
if(idx == word.length() && start.flag) return true;
if(idx == word.length() && !start.flag) return false;
Trie node = start;
char ch = word.charAt(idx);
if(ch != '.'){
if(node.edges[ch - 'a'] == null) return false;
return searchHelper(word, node.edges[ch - 'a'], idx + 1);
}
else{
//如果是.,则每一条边都可以选择
for(int i = 0; i < 26; i++){
if(node.edges[i] != null && searchHelper(word, node.edges[i], idx + 1))
return true;
}
//所有边不存在,则返回false
return false;
}
}
}
212. 单词搜索 II (回溯法 + 前缀树)
题目链接:https://leetcode-cn.com/problems/word-search-ii/
分类:
- 回溯法(暴力解法)
- 前缀树/字典树(设计、优化回溯算法)
题目分析
在网格里查找单词,网格里存放的是一个个字母,就需要和word的一个个字母匹配,这题是很典型的回溯题,但简单粗暴的回溯效率很低,这题的考点就在于对回溯法的优化:
下面介绍两个解题思路,两个思路的角度是相反的。
- 思路1:回溯暴力解法,在网格里找单词。
- 思路2:用前缀树优化回溯算法,在网格里构造单词判断是否存在于前缀树中。
思路1:暴力回溯法
算法流程
枚举words数组的里每一个单词,每个单词word都做下面的回溯处理:
单词word取第0个字母,在board中找到相同字母作为起点,然后从起点开始调用回溯递归函数,开始在board中查找word。
从起点出发查找下一个字母的方向可以是上下左右,但是同一个单词的寻找过程中,前面使用过的字母后面不能再次使用(字母在同一个单词内不可重用,但单词之间是互不影响的,所以我一直强调"同一个单词"、“当前单词”),因此设置一个二维数组used,表示某个位置的字母是否在当前单词的查找过程被使用过,如果被使用过后面就不能再被使用:
进入当前字母的上下左右方向前,先判断是否越界 && 字母未被访问过:
上:if(x - 1 >= 0 && !used[x - 1][y])
下:if(x + 1 < board.length && !used[x + 1][y])
左:if(y - 1 >= 0 && !used[x][y - 1])
右:if(y + 1 < board[0].length && !used[x][y + 1])
这四个方向分别再继续调用递归函数,进入下一层递归:
- 如果有其中一个方向返回true,说明从该方向去寻找能够找到word单词,可以直接返回true;
- 如果一个方向返回false,说明这个方向行不通,还可以尝试剩下的其他方向;如果四个方向都返回false,说明找不到word,才在最后返回false。
每一个单词匹配完毕,都要将used数组重置回全false,因为单词之间字母的使用情况是互相独立的,前一个单词对字母的使用情况不能影响到后一个单词的匹配。
回溯递归函数的设计:判断一个单词word是否存在于board中
递归函数的返回值:返回word是否能在board里找到;
递归函数的参数:char[][] board, int x, int y, String word, int idx, boolean[][] used
递归出口:
每次进入递归函数,就是拿word[idx]和board[x][y]进行比较,如果相等则进入下一层递归,如果不相等直接返回false;
如果相等,又分两种情况:
- 已经匹配到word的最后一个字母,则说明整个word单词都匹配成功,将word加入res,返回true;
- 还未匹配到最后一个字母,则进入递归主体继续寻找下一个字母。
递归主体:
将当前(x,y)处的字母标记为访问过,即used[x][y]=true
然后进入上下左右四个方向继续匹配下一个字母。
如果四个方向都匹配失败,则说明以这个字母为起点的匹配是不可行的,将used[x][y]置回false,返回false。
实现代码
class Solution {
List<String> res = new ArrayList<>();
public List<String> findWords(char[][] board, String[] words) {
//特殊用例
if(words.length == 0 || board.length == 0 || board[0].length == 0) return res;
int rows = board.length, cols = board[0].length;
//标记字母是否被使用过
boolean[][] used = new boolean[rows][cols];
//遍历words,每个单词都调用backtrack判断是否能在board里找得到
for(String word : words){
//在board中寻找回溯查找的起点
boolean find = false;
for(int i = 0; i < rows; i++){
for(int j = 0; j < cols; j++){
if(board[i][j] == word.charAt(0))
find = backtrack(board, i, j, word, 0, used);
if(find) break;//如果找到一个解,就立即退出寻找过程
}
if(find) break;
}
//一个单词查找结束,要将used数组重置,为下一个单词的查找做准备
for(boolean[] row : used){
Arrays.fill(row, false);
}
}
return res;
}
//回溯函数的递归实现
public boolean backtrack(char[][] board, int x, int y, String word, int idx, boolean[][] used){
if(board[x][y] != word.charAt(idx)) return false;//排除字母不匹配的情况
else if(idx == word.length() - 1 && word.charAt(idx) == board[x][y]){//如果最后一个字母也匹配,说明可以在board上找到整个word单词,加入res中
res.add(word);
return true;
}
//如果word[idx]和board[x][y]匹配,但还未到达word末尾,则进入该分支继续匹配
else{
used[x][y] = true;
//上
if(x - 1 >= 0 && !used[x - 1][y]){
//如果返回值为true,说明整个单词匹配成功,可以直接返回true
//如果返回值为false,说明上方向匹配失败,可以继续尝试其他方向
if(backtrack(board, x - 1, y, word, idx + 1, used)) return true;
}
//下
if(x + 1 < board.length && !used[x + 1][y]){
if(backtrack(board, x + 1, y, word, idx + 1, used)) return true;
}
//左
if(y - 1 >= 0 && !used[x][y - 1]){
if(backtrack(board, x, y - 1, word, idx + 1, used)) return true;
}
//右
if(y + 1 < board[0].length && !used[x][y + 1]){
if(backtrack(board, x, y + 1, word, idx + 1, used)) return true;
}
used[x][y] = false;
//四个方向都没有匹配成功,则返回false
return false;
}
}
}
思路2:回溯法优化 (效率高,但较难写)
算法设计
题目提示可以使用前缀树TrieNode进行优化(前缀树的设计见208.211),
前缀树提供的函数:insert插入单词,startsWith(prefix)判断是否存在prefix前缀,search(word)在前缀树里查找单词word.
前缀树的作用:将words里的单词插入树中,然后在board里构造出一个个单词,构造出来的单词只需要看看能否在前缀树里search到,就能判断board里是否存在该单词;
因为要在board上构造单词,所以设置一个StringBuilder sb用于存放构造中的单词,在构造过程中:
- 如果sb是前缀树里的一个前缀,就可以继续向下递归;
- 如果sb不是前缀树里的前缀,则直接退出。
- 如果sb是前缀树里的一个单词,就将sb加入res中,表示找到一个满足要求的解。接着继续判断:
- 如果sb是前缀树的一个前缀,就在此基础上继续向下执行,例如words=[he,hello],在找到单词he后,还可以以he为前缀继续向下寻找,直到找到hello。
- 如果不是前缀,就直接退出。
上面是算法的整体框架,提到的向下递归,就和思路1一样,以当前字母为起点,检查它的上下左右。
实现代码
class Solution {
//构造前缀树
class TrieNode{
boolean flag;
TrieNode[] edges;
public TrieNode(){
flag = false;
edges = new TrieNode[26];
}
//向前缀树插入单词
public void insert(String word){
TrieNode node = this;
for(int i = 0; i < word.length(); i++){
char ch = word.charAt(i);
if(node.edges[ch - 'a'] == null) node.edges[ch - 'a'] = new TrieNode();
node = node.edges[ch - 'a'];
}
node.flag = true;
}
//判断前缀是否存在于前缀树中
public boolean startsWith(String prefix){
TrieNode node = this;
for(int i = 0; i < prefix.length(); i++){
char ch = prefix.charAt(i);
if(node.edges[ch - 'a'] == null) return false;
node = node.edges[ch - 'a'];
}
return true;
}
//判断单词是否存在于前缀树中
public boolean searchWord(String word){
TrieNode node = this;
for(int i = 0; i < word.length(); i++){
char ch = word.charAt(i);
if(node.edges[ch - 'a'] == null) return false;
node = node.edges[ch - 'a'];
if(i == word.length() - 1 && node.flag) return true;
}
return true;
}
}
//存放最终结果的解集,使用set防止存放重复解
Set<String> res = new HashSet<>();
//前缀树根节点
TrieNode root = new TrieNode();
public List<String> findWords(char[][] board, String[] words) {
//特殊用例
if(words.length == 0 || board.length == 0 || board[0].length == 0) return new ArrayList<String>();
int rows = board.length, cols = board[0].length;
//标记字母是否被使用过
boolean[][] used = new boolean[rows][cols];
//将words所有单词插入前缀树
for(String word : words){
root.insert(word);
}
//在board中寻找回溯查找的起点
for(int i = 0; i < rows; i++){
for(int j = 0; j < cols; j++){
//选择board里的一个字母作为起点,查找是否能得到words里的单词
StringBuilder sb = new StringBuilder();
backtrack(board, i, j, used, sb);
//一个起点查找完,要将used数组重置,为下一个起点的查找做准备
for(boolean[] row : used) Arrays.fill(row, false);
}
}
return new ArrayList<String>(res);
}
//回溯函数的递归实现
public void backtrack(char[][] board, int x, int y, boolean[][] used, StringBuilder sb){
//将当前字母加入sb
sb.append(board[x][y]);
String str = sb.toString();//前缀树提供的函数的参数都要求是String类型
//如果(x,y)被使用过或sb不是前缀树里的前缀,则将sb的末尾字母删除,然后立即退出
if(used[x][y] || !root.startsWith(str)){
sb.deleteCharAt(sb.length() - 1);
return;
}
//如果sb是前缀树里的前缀或单词,则继续后面的工作
else{
used[x][y] = true;
//如果sb是前缀树里的单词,则加入res
if(root.searchWord(str)){
res.add(new String(sb));
//如果sb不是其他单词的前缀,则退出
if(!root.startsWith(str)){
sb.deleteCharAt(sb.length() - 1);
used[x][y] = false;
return;
}
}
//如果sb是前缀树里的前缀,则继续往四个方向查找
//上
if(x - 1 >= 0 && !used[x - 1][y]){
backtrack(board, x - 1, y, used, sb);
}
//下
if(x + 1 < board.length && !used[x + 1][y]){
backtrack(board, x + 1, y, used, sb);
}
//左
if(y - 1 >= 0 && !used[x][y - 1]){
backtrack(board, x, y - 1, used, sb);
}
//右
if(y + 1 < board[0].length && !used[x][y + 1]){
backtrack(board, x, y + 1, used, sb);
}
//恢复现场
used[x][y] = false;
sb.deleteCharAt(sb.length() - 1);
return;
}
}
}