前綴樹的實現及應用

一、前綴樹

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個嘛,這裏有多少種選則呢?利用排列組合的基礎知識可以容易得到:CnmC_n^m
  • 現在選出了m個單詞,要按照一定的順序擺放在m行上,這裏又有多少種可能呢?同樣根據排列組合的基礎知識,這裏是:m!m!

m個行,m個單詞
1行,有m種可能
2行,有m-1種可能

所以總的時間複雜度是:Cnmm!C_n^m m!

  • 這個複雜度可以說是非常高的了

其實根據題目的要求,我們不需要在枚舉出每一種可能之後,再去判斷合法性,而是可以利用合法性在枚舉的過程種,就把一些肯定不合法的策略曬出掉,這就是剪枝的思路

在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合法,那麼在剩下的單詞中就一定存在前綴爲:lela的單詞。如果不存在,說明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;
            }
        }
    }
}

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章