一、前綴樹
1. 概念
前綴樹是一種數據結構,常用來處理字符串/單詞。數據結構的本質是集合加操作,所以我們首先需要知道前綴樹可以提供哪些操作。
- 增加:給定字符串,將其添加到前綴樹中
- 判斷給定前綴是否存在:給定任意字符串,返回該前綴樹中當前是否存在以該字符串爲前綴的單詞
- 判斷給定單詞是否存在:給定單詞,返回該前綴樹中當前是否存在該單詞
上面是前綴樹必須滿足的操作,但是在實際的一些題目中,還需要前綴樹滿足下面的:
- 返回給定前綴對應的所有單詞:給定前綴,返回所有單詞
2. 應用
- 前綴樹最常見的應用是利用前綴查詢和單詞查詢在
字符串搜索類題目
中進行剪枝操作
二、基於哈希表的實現及應用
此節結合一道具體的LeetCode題目進行說明:208. Implement Trie (Prefix Tree)
1. 基於哈希表來實現
基於哈希表的實現非常的簡單和直觀
-
key
:就是前綴 -
value
: 就是每個前綴對應的單詞 -
添加操作的時候,遍歷給定的字符串的所有可能的前綴,分別建立
前綴到字符串的map
例如,給定字符串area
,那麼在建立前綴樹的過程就是這樣:a
—>area
ar
---->area
are
—>area
area
—>area
-
所以這種方式的空間複雜度非常的高,因爲在具有相同的前綴字符串之間沒有建立起有效的聯繫
還有一個重要的問題是,如果表示前綴樹的根節點呢?
- 這裏我們使用
""
一個空字符串表示根節點,它對應的value
就是所有的給定的字符串。
下面我們看具體的代碼實現:
1.1 代碼
class Trie {
Map<String,Set<String>> pre_to_word;
/** Initialize your data structure here. */
public Trie() {
pre_to_word = new HashMap<>();
pre_to_word.put("", new HashSet<>());
}
/** Inserts a word into the trie. */
public void insert(String word) {
//0. 遍歷所有可能的前綴
for (int i = 0; i < word.length(); i++) {
String pre = word.substring(0, i+1);
//1. 判斷該前綴是否已經存在
if (!pre_to_word.containsKey(pre)){
pre_to_word.put(pre, new HashSet<>());
}
pre_to_word.get(pre).add(word);
}
}
/** Returns if the word is in the trie. */
public boolean search(String word) {
if (!pre_to_word.containsKey(word)){
return false;
}
return pre_to_word.get(word).contains(word);
}
/** Returns if there is any word in the trie that starts with the given prefix. */
public boolean startsWith(String prefix) {
return pre_to_word.containsKey(prefix);
}
}
- 可以看到,代碼的實現層面非常的簡單,就是哈希表的簡單操作,但是我們可以看到這樣的方式非常的低效。
1.2 優勢
- 可以非常方便的返回前綴對應的所有單詞
1.3 應用:LintCode634. Word Squares
1.3.1 題意
給定一些不重複單詞,然後要找到一種或多種單詞的排列順序,構成一個單詞矩陣,這些順序滿足每一行和每一列讀出來的單詞相同
例如:給定單詞["ball","area","lead","lady"]
,它對應的一種合法的矩陣排列順序如下:
可以看到,第i
行和第i
列的單詞是相同的
- 每個單詞的長度相同
長度相同其實就已經決定了這個單詞矩陣一定是n*n的
1.3.2 暴力的思路
一種很容易的想法就是枚舉所有可能的單詞的排列的順序,得到所有可能的單詞矩陣,然後逐一篩選出合法的單詞矩陣即可
繼續思考一下,這種方式的時間複雜度呢
- 假設有
n
個單詞,每個單詞長度爲m
,那麼要構成m*m
的單詞矩陣,總共可能的單詞矩陣的個數是多少呢? - 首先,我們肯定要從
n
個單詞中選出m
個嘛,這裏有多少種選則呢?利用排列組合的基礎知識可以容易得到: - 現在選出了
m
個單詞,要按照一定的順序擺放在m
行上,這裏又有多少種可能呢?同樣根據排列組合的基礎知識,這裏是:
m
個行,m
個單詞
第1
行,有m
種可能
第2
行,有m-1
種可能
…
所以總的時間複雜度是:
- 這個複雜度可以說是非常高的了
其實根據題目的要求,我們不需要在枚舉出每一種可能之後,再去判斷合法性,而是可以利用合法性在枚舉的過程種,就把一些肯定不合法的策略曬出掉,這就是剪枝的思路
在DFS中,剪枝是非常重要的減少搜索空間的手段.
1.3.2 剪枝的思路
我們重新回顧整個思路,假設我們手上有一些單詞,每個單詞長度爲m
我們要選出一種擺放的順序,形成一個單詞矩陣,讓其合法。
假設我們手上的單詞就是:["ball","area","lead","lady"]
,現在我們按行逐行來思考。
- 第
0
行:- 假設我們現在正在決定爲第
0
行擺放哪一個單詞,很顯然給定的所有的單詞都可能放在第0
行 - 所以,第
0
行的時候,需要遍歷所有的單詞,讓每一個單詞都在第0
行放置進行嘗試 - 假設我們現在選擇了
ball
放置在第0
行
- 假設我們現在正在決定爲第
- 第
1
行- 現在我們知道第
0
行存放了ball
,現在來決策第1
行該放置哪個單詞 - 因爲題目要求第i行和第i列的單詞相等,那麼第一行的單詞的首字母一定要是
a
才滿足條件 - 那麼我第
1
行的搜索空間就不是剩下的所有單詞了,而是剩下的單詞中以a
爲開頭字母的單詞。 - 那麼如何才能找到以字母
a
開頭的所有可能的單詞呢?。這裏如果我們知道有前綴樹這樣的數據結構,就可以解決這個問題 - 所以,我們可以通過前綴樹找到以字母
a
開頭的所有的單詞,然後作爲第1
行放置單詞的搜索空間,然後遍歷這個空間中的每一個單詞即可。到這裏完成了第一層的剪枝 - 我們繼續往下看,此時找到了以
a
開頭的所有單詞:"area"
- 假設現在第
1
層放置的是單詞area
- 前
2
層放置的單詞分別是: ball
area
- 這裏可以繼續利用題目規則,做後續的進一步的剪枝,現在第
1
層放置了area
,那麼這個area
到底是否合法呢? - 我們這裏豎着看,如果
area
合法,那麼在剩下的單詞中就一定存在前綴爲:le
和la
的單詞。如果不存在,說明area
不合法,那麼就在當前的搜索空間中嘗試下一個單詞,如果所有的單詞都嘗試了,還是不合法,那麼就回到上一層。 - 這裏是第二層剪枝
- 到這裏整個題目的思路就霍然開朗了
- 現在我們知道第
其實到這裏有一個非常重要的觀察,第
i
層的決策,是受到第0
到第i-1
層決策的影響的,這裏的這個問題,讓我想到了N皇后問題
,也是同樣的特性,第i
層皇后放置的合法性受到前i
層的影響,同樣利用這個點去做剪枝。
1.3.3 代碼
class LintCode634 {
class Trie {
Map<String, Set<String>> pre_to_word;
/** Initialize your data structure here. */
public Trie() {
pre_to_word = new HashMap<>();
pre_to_word.put("", new HashSet<>());
}
/** Inserts a word into the trie. */
public void insert(String word) {
pre_to_word.get("").add(word);
//0. 遍歷所有可能的前綴
for (int i = 0; i < word.length(); i++) {
String pre = word.substring(0, i+1);
//1. 判斷該前綴是否已經存在
if (!pre_to_word.containsKey(pre)){
pre_to_word.put(pre, new HashSet<>());
}
pre_to_word.get(pre).add(word);
}
}
/** Returns if the word is in the trie. */
public boolean search(String word) {
if (!pre_to_word.containsKey(word)){
return false;
}
return pre_to_word.get(word).contains(word);
}
/** Returns if there is any word in the trie that starts with the given prefix. */
public boolean startsWith(String prefix) {
return pre_to_word.containsKey(prefix);
}
public Set<String> getAllWords(String pre){
return pre_to_word.get(pre);
}
}
List<List<String>> res = new ArrayList<>();
List<String> item = new ArrayList<>();
int wordLen = 0;
Trie preTree = new Trie();
public List<List<String>> wordSquares(String[] words) {
if (words == null || words.length == 0){
return res;
}
wordLen = words[0].length();
//0. 建立前綴樹
for (String word : words) {
preTree.insert(word);
}
//1.
int l = 0;
dfs(l);
return res;
}
//0. 爲第l層進行決策
private void dfs(int l) {
if (l == wordLen){
res.add(new ArrayList<>(item));
return;
}
//1. 爲當前第l層進行決策,那麼需要知道當前第l層放置的單詞 以什麼開頭
// 當 l = 0,說明所有的單詞都可以,pre = ""
// 當 l = 1, 假設l=0放置的單詞是 ball
// 那麼這一層放置的單詞的開頭應該是 a 那麼這裏的關係應該就是 item.get(i).charAt(l)
// i = 0 - (l-1)
String pre = "";
for (int i = 0; i < l; i++) {
pre += item.get(i).charAt(l);
}
//1.1 利用前綴樹,獲取前綴pre對應的所有單詞,作爲當前第l層的搜索空間
Set<String> allWords = preTree.getAllWords(pre);
//1.2 遍歷所有的單詞
for (String nextWord : allWords) {
//2. 第二層剪枝,判斷第l層放置word是否合法
if (!checkValid(nextWord,l)){
continue;
}
//2.1 說明當前word合法
item.add(nextWord);
dfs(l+1);
item.remove(item.size() - 1);
}
}
private boolean checkValid(String nextWord, int l) {
//0. 假設現在l = 1
// 放置的單詞爲
// ball
// area
// 現在需要判斷剩下的單詞中是否存在 le la
// 那麼首先需要把 le 和 la找出來
//1. 先完成已經放置單詞的第 l+1列 到 wordLen-1列的添加
//1.1 最外層按列遍歷
for (int i = l+1; i < wordLen; i++) {
String pre = "";
//1.2 然後遍歷從第 0 - l-1層的 的i列
for (int j = 0; j < item.size(); j++) {
pre = pre + item.get(j).charAt(i);
}
//1.3 然後添加當前單詞的第i列
pre += nextWord.charAt(i);
//1.4 然後判斷此前綴是否存在
if (!preTree.startsWith(pre)){
return false;
}
}
return true;
}
}
- 代碼看着挺多的,但是思路理解清楚了,其實也是不難寫出來的
三、基於樹型結構的實現及應用
前面哈希表的實現有較高的空間複雜度,是因爲多個相同的前綴重複的成爲了一個key
。而利用樹型結構就不會存在這個問題,例如:給定單詞area
,arec
,利用樹型結構,會構成如下的形狀:
1. TrieNode
首先需要定義一個前綴樹節點
前綴樹節點應當包含如下屬性:
c
:當前字符Map<Character,TrieNode>
:當前節點的孩子節點們isWord
:標記以當前字符結尾的字符是否爲一個完整的單詞s
:當前節點結尾的字符對應的單詞
class TrieNode {
char c;
Map<Character, TrieNode> children = new HashMap<>();
boolean isWord = false;
String s;
TrieNode() {
}
TrieNode(char c) {
this.c = c;
}
}
2. Trie
此節結合一道具體的LeetCode題目進行說明:208. Implement Trie (Prefix Tree)
2.1 代碼
class Trie {
private TrieNode root;
/**
* Initialize your data structure here.
*/
public Trie() {
this.root = new TrieNode();
}
/**
* Inserts a word into the trie.
*/
public void insert(String word) {
//0. 獲取根節點
TrieNode curNode = root;
//2. 遍歷word,逐一添加
for (int i = 0; i < word.length(); i++) {
//1. 獲取根節點的孩子
Map<Character, TrieNode> curChild = curNode.children;
char curChar = word.charAt(i);
//2.1 如果當前字符已經在字典樹中了,則更新
if (curChild.containsKey(curChar)) {
curNode = curChild.get(curChar);
} else {
//2.2 如果不存在,則添加
TrieNode newNode = new TrieNode(curChar);
curChild.put(curChar, newNode);
curNode = newNode;
}
if (i == word.length() - 1) {
curNode.isWord = true;
curNode.s = word;
}
}
}
/**
* Returns if the word is in the trie.
*/
public boolean search(String word) {
if (strEndWith(word) == null) {
return false;
}
return strEndWith(word).isWord;
}
/**
* Returns if there is any word in the trie that starts with the given prefix.
*/
public boolean startsWith(String prefix) {
if (strEndWith(prefix) == null) {
return false;
} else {
return true;
}
}
public TrieNode strEndWith(String str) {
TrieNode curNode = root;
for (int i = 0; i < str.length(); i++) {
Map<Character, TrieNode> curChild = curNode.children;
char curChar = str.charAt(i);
if (!curChild.containsKey(curChar)) {
return null;
} else {
curNode = curChild.get(curChar);
}
}
return curNode;
}
}
3. LeetCode 211. Add and Search Word - Data structure design
- 本質上還是在前綴樹的本身的實現
- 注意帶有
.
這種通配符的搜索即可
class LeetCode211_20200601{
class TrieNode {
char c;
Map<Character, TrieNode> children = new HashMap<>();
boolean isWord = false;
String s;
TrieNode() {
}
TrieNode(char c) {
this.c = c;
}
}
class WordDictionary {
private TrieNode root;
/** Initialize your data structure here. */
public WordDictionary() {
this.root = new TrieNode();
}
/** Adds a word into the data structure. */
public void addWord(String word) {
TrieNode curNode = root;
//2. 遍歷word,逐一添加
for (int i = 0; i < word.length(); i++) {
//1. 獲取根節點的孩子
Map<Character, TrieNode> curChild = curNode.children;
char curChar = word.charAt(i);
//2.1 如果當前字符已經在字典樹中了,則更新
if (curChild.containsKey(curChar)) {
curNode = curChild.get(curChar);
} else {
//2.2 如果不存在,則添加
TrieNode newNode = new TrieNode(curChar);
curChild.put(curChar, newNode);
curNode = newNode;
}
if (i == word.length() - 1) {
curNode.isWord = true;
curNode.s = word;
}
}
}
public boolean search(String word) {
//0. 肯定需要對word遍歷搜索每一個字符
// 那麼需要一個startIndex
// 因爲是在前綴樹種搜索,所以在前綴樹種搜索也需要一個起點,那就是root
int startIndex = 0;
TrieNode node = root;
return dfs(word,startIndex,node);
}
private boolean dfs(String word, int startIndex, TrieNode node) {
//0. 出口
if (startIndex == word.length()){
return node.isWord;
}
//1. 獲取當前字符,和當前node的孩子節點
char curChar = word.charAt(startIndex);
Map<Character, TrieNode> curChildren = node.children;
//2. 判斷當前字符是否爲 '.'
if (curChar != '.'){
//2.1 如果是普通字符,則看該字符是否存在於當前的孩子節點中
if (!curChildren.containsKey(curChar)){
return false;
}
return dfs(word, startIndex+1, curChildren.get(curChar));
}else {
//2.2 如果當前字符是 '.', 那麼需要遍歷當前所有的孩子節點
Set<Character> set = curChildren.keySet();
//2.3 遍歷set,只要有一個字符滿足條件,則可以
for (Character cur_char:set){
if (dfs(word, startIndex + 1, curChildren.get(cur_char))){
return true;
}
}
//2.4 如果所有的都不滿足,則返回false
return false;
}
//3. 如果不是上述兩種字符,返回false
}
}
}
4. LeetCode 212. Word Search II
- 給定一個二維的字符矩陣
- 給定一個字符串數組
- 搜索在二維字符矩陣中出現過的字符
/*
0. 將給定的words中的所有單詞新建爲一顆前綴樹
1. 搜索board,也是同樣的需要遍歷,每一個爲起點,起點的合法性也有前綴樹來判斷
2. 然後以位置爲起點在board中進行dfs,遇到node對應的isWord爲true,就記錄,然後繼續
3. 繼續找到新的起點,看是否存在其他的單詞
*/
class LeetCode212 {
class TrieNode {
char c;
Map<Character, TrieNode> children = new HashMap<>();
boolean isWord = false;
String s;
TrieNode() {
}
TrieNode(char c) {
this.c = c;
}
}
class Trie {
private TrieNode root;
/**
* Initialize your data structure here.
*/
public Trie() {
this.root = new TrieNode();
}
/**
* Inserts a word into the trie.
*/
public void insert(String word) {
//0. 獲取根節點
TrieNode curNode = root;
//2. 遍歷word,逐一添加
for (int i = 0; i < word.length(); i++) {
//1. 獲取根節點的孩子
Map<Character, TrieNode> curChild = curNode.children;
char curChar = word.charAt(i);
//2.1 如果當前字符已經在字典樹中了,則更新
if (curChild.containsKey(curChar)) {
curNode = curChild.get(curChar);
} else {
//2.2 如果不存在,則添加
TrieNode newNode = new TrieNode(curChar);
curChild.put(curChar, newNode);
curNode = newNode;
}
if (i == word.length() - 1) {
curNode.isWord = true;
curNode.s = word;
}
}
}
/**
* Returns if the word is in the trie.
*/
public boolean search(String word) {
if (strEndWith(word) == null) {
return false;
}
return strEndWith(word).isWord;
}
/**
* Returns if there is any word in the trie that starts with the given prefix.
*/
public boolean startsWith(String prefix) {
if (strEndWith(prefix) == null) {
return false;
} else {
return true;
}
}
public TrieNode strEndWith(String str) {
TrieNode curNode = root;
for (int i = 0; i < str.length(); i++) {
Map<Character, TrieNode> curChild = curNode.children;
char curChar = str.charAt(i);
if (!curChild.containsKey(curChar)) {
return null;
} else {
curNode = curChild.get(curChar);
}
}
return curNode;
}
}
//0. 全局變量
List<String> ans = new ArrayList<>();
int[] dx = {1,-1,0,0};
int[] dy = {0,0,1,-1};
public List<String> findWords(char[][] board, String[] words) {
if (board == null || board.length == 0){
return ans;
}
if (words == null || words.length == 0){
return ans;
}
//0. 遍歷words建立前綴樹
Trie preTree = new Trie();
for (String word : words) {
preTree.insert(word);
}
//1. 遍歷board找到合法的起點
for (int i = 0; i < board.length; i++) {
for (int j = 0; j < board[0].length; j++) {
//1.1 如果當前前綴樹中存在以當前字符爲前綴的單詞
if (preTree.startsWith("" + board[i][j])){
//1.2 那麼就以這個點爲起點進行遍歷
// board i j 起點 當前的item 當前的前綴樹的起點
// 訪問標記
boolean[][] vis = new boolean[board.length][board[0].length];
vis[i][j] = true;
TrieNode curNode = preTree.root.children.get(board[i][j]);
String item = "" + board[i][j];
dfs(board,i,j,item,vis,curNode);
}
}
}
return ans;
}
private void dfs(char[][] board, int i, int j, String item, boolean[][] vis, TrieNode curNode) {
//0. 出口
//0.1 這裏一定要注意!!!這裏不一定就要返回
//0.2 因爲這裏完全可以出現什麼呢?完全可以出現 比如 aaa aaab這兩種情況
if (curNode.isWord){
if (!ans.contains(curNode.s)){
ans.add(curNode.s);
}
}
//1. i,j是起點,向4個方向做dfs
for (int k = 0; k < 4; k++) {
int nx = i + dx[k];
int ny = j + dy[k];
if (nx < 0 || nx >= board.length || ny < 0 || ny >= board[0].length){
continue;
}
if (vis[nx][ny]){
continue;
}
if (curNode.children.containsKey(board[nx][ny])){
vis[nx][ny] = true;
dfs(board, nx, ny, item, vis, curNode.children.get(board[nx][ny]));
vis[nx][ny] = false;
}
}
}
}