前綴樹--打開了我的新思路

故事凌

今天刷算法題, 前綴樹, 字典樹, 真的是一個好東西啊, 在思想上又給自己打開了一個新的思路啊!

前綴樹被廣泛的運用在字典查找中, 也被稱為字典樹

舉例: 給定一系列字符串, 這些字符串構成了一種字典, 要求你在這個字典當中找出所有以"ABC"開頭的字符串

解法一: 暴利搜索

直接遍歷一遍字典, 然後逐個判斷每個字符串是否由"ABC"開頭, 假設字典很大, 有N個單詞, 要對比的不是"ABC", 而是任意的, 那不妨假設所要對比的開頭平均長度為M, 那麼時間複雜度是O(M x N)

解法二: 前綴樹

如果用前綴樹頭幫助對字典的存儲進行優化, 那麼可以把搜索的時間複雜度下降為O(M), 其中M表示字典裡最長的那個單詞的字符個數, 很多情況下, 字典裡的單詞個數N是遠遠大於M的, 因此, 前綴樹在各種場合中是非常高效的.

經典應用

  1. 網站的搜索框會羅列出以搜索文字作為開頭的相關搜索信息, 這裡運用了前綴樹進行後端的快速檢索
  2. 漢字拼音輸入法的聯想輸出功能也是運用了前綴樹

Trie (發音為 "try") 或前綴樹是一種樹數據結構,用於檢索字符串數據集中的鍵。這一高效的數據結構有多種應用:

1. 自動補全

前綴樹--打開了我的新思路

2. 拼寫檢查

前綴樹--打開了我的新思路

3. IP路由(最長前綴匹配)

前綴樹--打開了我的新思路

4. T9(九宮格) 打字預測

前綴樹--打開了我的新思路

5. 單詞遊戲

前綴樹--打開了我的新思路

還有其他的數據結構,如平衡樹和哈希表,使我們能夠在字符串數據集中搜索單詞。為什麼我們還需要 Trie 樹呢?儘管哈希表可以在 O(1)O(1) 時間內尋找鍵值,卻無法高效的完成以下操作:

  • 找到具有同一前綴的全部鍵值。
  • 按詞典序枚舉字符串的數據集。

Trie 樹優於哈希表的另一個理由是,隨著哈希表大小增加,會出現大量的衝突,時間複雜度可能增加到 O(n)O(n),其中 nn 是插入的鍵的數量。與哈希表相比,Trie 樹在存儲多個具有相同前綴的鍵時可以使用較少的空間。此時 Trie 樹只需要 O(m)O(m) 的時間複雜度,其中 mm 為鍵長。而在平衡樹中查找鍵值需要 O(m \\log n)O(mlogn) 時間複雜度。

Trie 樹的結點結構

Trie 樹是一個有根的樹,其結點具有以下字段:。

  • 最多 RR 個指向子結點的鏈接,其中每個鏈接對應字母表數據集中的一個字母。本文中假定 RR 為 26,小寫拉丁字母的數量。
  • 布爾字段,以指定節點是對應鍵的結尾還是隻是鍵前綴。
前綴樹--打開了我的新思路

前綴樹的java代碼:

<code>class TrieNode {

   // R links to node children
// 連接到 R 條子節點
   private TrieNode[] links;

   // 最初只有26個字符, 最多有26條子節點
   private final int R = 26;

// 判斷是否是一個單詞是否結束
   private boolean isEnd;

  // 構造器, 初始化的時候, 都要創建26子節點
   public TrieNode() {
       links = new TrieNode[R];
  }

   // 判斷子節點是否存在, ch - 'a' 是0-25
   public boolean containsKey(char ch) {
       return links[ch -'a'] != null;
  }
   // 獲取到子節點
   public TrieNode get(char ch) {
       return links[ch -'a'];
  }
   // 往子節點中放元素
   public void put(char ch, TrieNode node) {
       links[ch -'a'] = node;
  }
   // 設置為單詞的結束符
   public void setEnd() {
       isEnd = true;
  }
   // 獲取單詞的結束符
   public boolean isEnd() {
       return isEnd;
  }

}/<code>

舉例: 假如有一個字典, 字典裡面有下面詞: "A", "to", "tea", "ted", "ten", "i", "in", "inn", 每個單詞還能有自己的一些權重值, 那麼用前綴樹來構建這個字典將會是如下的樣子:

前綴樹--打開了我的新思路

性質

  1. 每個節點至少包含兩個基本屬性
  2. Childern: 數組或者集合, 羅列出每個分支當中包含的所有字符
  3. idEnd, 布爾值, 表示是該節點是否為某字符串的結尾
  4. 前綴樹的根節點是空的

所謂空, 即只利用這個節點的children屬性, 即只關心在這個字典裡, 有哪些打頭的字符

  1. 除了根節點, 其他所有節點都有可能是單詞的結尾, 葉子節點一定都是單詞的結尾

實現

前綴叔最基本的操作就是兩個: 創建和搜索

1. 創建

  • 遍歷一遍輸入的字符串, 對每個字符串的字符進行遍歷
  • 從前綴的根節點開始, 將每個字符加入到節點的children字符集當中.
  • 如果字符集已經包含了這個字符, 則跳過
  • 如果當前字符是字符串的最後一個, 則把當前節點的isEnd標記為真.

由上, 創建的方法很直觀


前綴樹真正強大的地方在於, 每個每個節點還能用來保存額外的信息, 比如可以用來記錄擁有相同前綴的所有字符串, 因此, 當用戶輸入某個前綴時, 就能在O(1)的時間內給出對應的推薦字符串

我們通過搜索 Trie 樹來插入一個鍵。我們從根開始搜索它對應於第一個鍵字符的鏈接。有兩種情況:

鏈接存在。沿著鏈接移動到樹的下一個子層。算法繼續搜索下一個鍵字符。鏈接不存在。創建一個新的節點,並將它與父節點的鏈接相連,該鏈接與當前的鍵字符相匹配。重複以上步驟,直到到達鍵的最後一個字符,然後將當前節點標記為結束節點,算法完成。

前綴樹--打開了我的新思路

向前綴樹中插入元素:

<code>class Trie {
   private TrieNode root;

   public Trie() {
       root = new TrieNode();
  }

   // Inserts a word into the trie.
   public void insert(String word) {
       TrieNode node = root;
       for (int i = 0; i            // 獲取到每個字符
           char currentChar = word.charAt(i);
           // 判斷是否存在, 不存在, 直接放入子節點
           if (!node.containsKey(currentChar)) {
               node.put(currentChar, new TrieNode());
          }
           // 子節點變成父節點, 進行下一次的循環
           node = node.get(currentChar);
      }
       // 設置最後的字符為單詞的結束符
       node.setEnd();
  }
}/<code>

2. 搜索

與創建方法類似, 從前綴的根節點出發, 逐個匹配輸入的前綴字符, 如果遇到了就繼續往下一層搜索, 如果沒遇到, 就立即返回.

在 Trie 樹中查找鍵每個鍵在 trie 中表示為從根到內部節點或葉的路徑。我們用第一個鍵字符從根開始,。檢查當前節點中與鍵字符對應的鏈接。有兩種情況:

存在鏈接。我們移動到該鏈接後面路徑中的下一個節點,並繼續搜索下一個鍵字符。不存在鏈接。若已無鍵字符,且當前結點標記為 isEnd,則返回 true。否則有兩種可能,均返回 false :還有鍵字符剩餘,但無法跟隨 Trie 樹的鍵路徑,找不到鍵。沒有鍵字符剩餘,但當前結點沒有標記為 isEnd。也就是說,待查找鍵只是Trie樹中另一個鍵的前綴。

前綴樹--打開了我的新思路

在樹中查找元素

<code>class Trie { 

  ...

   // search a prefix or whole key in trie and
   // returns the node where search ends
   // 搜索前綴
   private TrieNode searchPrefix(String word) {
       TrieNode node = root;
       for (int i = 0; i           // 獲取到單詞的每一個字符
          char curLetter = word.charAt(i);
          // 如果子節點包含, 就把子節點當成新的父節點, 進入下一次循環
          if (node.containsKey(curLetter)) {
              node = node.get(curLetter);
          } else {
            // 否則, 就返回空
              return null;
          }
      }
       // 返回最終的子節點
       return node;
  }

   // Returns if the word is in the trie.
   // 判斷單詞是否存在於前綴樹中
   public boolean search(String word) {
      TrieNode node = searchPrefix(word);
      // 最後的節點不能為空, 而且該節點上必須有單詞的結束符
      return node != null && node.isEnd();
  }
}/<code>

查找 Trie 樹中的鍵前綴該方法與在 Trie 樹中搜索鍵時使用的方法非常相似。我們從根遍歷 Trie 樹,直到鍵前綴中沒有字符,或者無法用當前的鍵字符繼續 Trie 中的路徑。與上面提到的“搜索鍵”算法唯一的區別是,到達鍵前綴的末尾時,總是返回 true。我們不需要考慮當前 Trie 節點是否用 “isend” 標記,因為我們搜索的是鍵的前綴,而不是整個鍵。

前綴樹--打開了我的新思路

<code>class Trie {
  ...

   // Returns if there is any word in the trie
   // that starts with the given prefix.
   public boolean startsWith(String prefix) {
       TrieNode node = searchPrefix(prefix);
       return node != null;
  }
}/<code>

例題分析

  1. 單詞搜索 II](https://leetcode-cn.com/problems/word-search-ii/)

給定一個二維網格 board 和一個字典中的單詞列表 words,找出所有同時在二維網格和字典中出現的單詞。

單詞必須按照字母順序,通過相鄰的單元格內的字母構成,其中“相鄰”單元格是那些水平相鄰或垂直相鄰的單元格。同一個單元格內的字母在一個單詞中不允許被重複使用。

示例:

<code>輸入:
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"]/<code>


前綴樹--打開了我的新思路

說明:你可以假設所有輸入都由小寫字母 a-z 組成。

提示:

你需要優化回溯算法以通過更大數據量的測試。你能否早點停止回溯?如果當前單詞不存在於所有單詞的前綴中,則可以立即停止回溯。什麼樣的數據結構可以有效地執行這樣的操作?散列表是否可行?為什麼?前綴樹如何?如果你想學習如何實現一個基本的前綴樹,請先查看這個問題:實現Trie(前綴樹)。

<code>import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;

public class findWords_212 {
   public List<string> findWords(char[][] board, String[] words) {
       //構建字典樹
       wordTrie myTrie=new wordTrie();
       trieNode root=myTrie.root;
       for(String s:words)
           myTrie.insert(s);

       //使用set防止重複
       Set<string> result =new HashSet<>();
       int m=board.length;
       int n=board[0].length;
       boolean[][] visited=new boolean[m][n];
       //遍歷整個二維數組
       for(int i=0;i<board.length>           for (int j = 0; j                find(board,visited,i,j,m,n,result,root);
          }
      }
       System.out.print(result);
       return new LinkedList<string>(result);
  }

   private void find(char [] [] board, boolean [][]visited,int i,int j,int m,int n,Set<string> result,trieNode cur){
       //邊界以及是否已經訪問判斷
       if(i<0||i>=m||j<0||j>=n||visited[i][j])
           return;
       cur=cur.child[board[i][j]-'a'];
       visited[i][j]=true;
       if(cur==null)
      {
           //如果單詞不匹配,回退
           visited[i][j]=false;
           return;
      }
       //找到單詞加入
       if(cur.isLeaf)
      {
           result.add(cur.val);
           //找到單詞後不能回退,因為可能是“ad” “addd”這樣的單詞得繼續回溯
//           visited[i][j]=false;
//           return;
      }
     //上下左右去遍歷, 通過遞歸的方法去實現
       find(board,visited,i+1,j,m,n,result,cur);
       find(board,visited,i,j+1,m,n,result,cur);
       find(board,visited,i,j-1,m,n,result,cur);
       find(board,visited,i-1,j,m,n,result,cur);
       //最後要回退,因為下一個起點可能會用到上一個起點的字符
       visited[i][j]=false;
  }


}

//字典樹
class wordTrie{
   public trieNode root=new trieNode();
   public void insert(String s){
       trieNode cur=root;
       for(char c:s.toCharArray()){
           if(cur.child[c-'a']==null){
               cur.child [c-'a'] = new trieNode();

               cur=cur.child[c-'a'];
          }else
               cur=cur.child [c-'a'];
      }
       cur.isLeaf=true;
       cur.val=s;
  }
}
//字典樹結點
class trieNode{
   public String val;
   public trieNode[] child=new trieNode[26];
   public boolean isLeaf=false;

   trieNode(){

  }
}/<string>/<string>/<board.length>/<string>/<string>/<code>


分享到:


相關文章: