由簡入繁--Trie樹實戰

弒曉風發表於2019-03-22
You only get one shot, do not miss your chance to blow. 
你只有一發子彈,不要錯過引爆全場的機會。

日誌

2019年3月24日 trie實戰(一)統計字串中指定字元出現的次數

2019年3月25日 trie實戰 (二)基於AC自動機的敏感詞過濾系統

引言

學習不能只侷限於實現,更重要的是學會自己思考,舉一反三。學的是思想,如何轉化成自己的東西。

trie樹又稱“字典樹”。關鍵詞提示功能在日常生活中非常常用,通常只需要輸出字首,它就會給出相應的提示。呢具體是怎麼實現的呢?本文主要分享了基於trie樹的一個簡易的搜尋提示以及trie樹常用的應用場景。所有原始碼均已上傳至github:連結

ps:Trie 樹的本質,就是利用字串之間的公共字首,將重複的字首合並在一起。

模擬搜尋關鍵詞提示功能

本次實現其實也可以改造一下,將使用者習慣(輸入內容)存成一顆trie樹

以how,hi,her,hello,so,see為例

宣告一個tire類

這裡偷了個小懶,整了一個內部類。

    public class TrieNode {
        /**
         * 字元
         */
        public char data;
        /**
         * 子節點
         */
        TrieNode[] children;
        /**
         * 標識
         */
        boolean isEndingChar;

        TrieNode(char data) {
            children = new TrieNode[26];
            isEndingChar = false;
            this.data = data;
        }
    }複製程式碼

初始化

通常根節點是不儲存任何資訊的,起一個佔位符的作用

 /**
     * 根節點
     */
    private TrieNode root;

    /**
     * 預製單詞數量
     */
    private int count;

    /**
     * 提示詞列表
     */
    private List<String> list;

    /**
     * 輸入值
     */
    private String pattern;

    /**
     * 儲存一個無意義的字元
     */
    private TrieTree() {
        root = new TrieNode('/');
        count = 0;
        list = new ArrayList<>();
    }複製程式碼

插入

這裡儲存的是ASCII碼,相對而言要省記憶體一些。

    private void insert(char[] txt) {
        TrieNode p = root;
        for (char c : txt) {
            //當前字元的ASCII碼 - 'a'的 ASCII碼
            int index = c - 'a';
            if (null == p.children[index]) {
                TrieNode node = new TrieNode(c);
                p.children[index] = node;
            }
            p = p.children[index];
        }
        ++count;
        p.isEndingChar = true;
    }複製程式碼

查詢

    private boolean contains(String pattern) {
        char[] patChars = pattern.toCharArray();
        TrieNode p = root;
        for (char patChar : patChars) {
            int index = patChar - 'a';
            if (null == p.children[index])
                return false;
            p = p.children[index];
        }
        return p.isEndingChar;
    }複製程式碼

模糊提示匹配

    private void match() {
        char[] patChars = pattern.toCharArray();
        TrieNode p = root;
        for (char patChar : patChars) {
            int index = patChar - 'a';
            if (null == p.children[index])
                return;
            p = p.children[index];
        }
        //開始遍歷 p,將所有匹配的字元加入strs
        traversal(p, "");
    }複製程式碼

遞迴遍歷節點

    private void traversal(TrieNode trieNode, String str) {
        if (null != trieNode) {
            str += trieNode.data;
            if (trieNode.isEndingChar) {
                String curStr = pattern.length() == 1 ? 
		str : pattern + str.substring(pattern.length() - 1);
                if (!list.contains(curStr))
                    list.add(curStr);
                return;
            }
            for (int i = 0; i < trieNode.children.length; i++) {
                traversal(trieNode.children[i], str);
            }
        }
    }複製程式碼

測試程式碼

人為構造一個tire樹

ps:這裡的儲存會導致樹很高,比如 l l o,其實可以合成llo,也就是縮點優化。這裡暫時不實現了。

    private void initTries() {
//        how,hi,her,hello,so,see
//                   /
//              h         s
//           e  i  o    o   e
//         l         w        e
//      l
//   o
        char[] how = "how".toCharArray();
        insert(how);
        char[] hi = "hi".toCharArray();
        insert(hi);
        char[] her = "her".toCharArray();
        insert(her);
        char[] hello = "hello".toCharArray();
        insert(hello);
        char[] so = "so".toCharArray();
        insert(so);
        char[] see = "see".toCharArray();
        insert(see);
    }複製程式碼

測試程式碼

    public static void main(String[] args) {
        TrieTree trieTree = new TrieTree();
        trieTree.initTries();
        String str = "hello";
        boolean res = trieTree.contains(str);
        System.out.println("trie樹是否包含" + str + "返回結果:" + res);

        trieTree.pattern = "h";
        trieTree.match();
        System.out.println("單字元模糊匹配 " + trieTree.pattern + ":");
        trieTree.printAll();

        trieTree.list.clear();
        trieTree.pattern = "he";
        trieTree.match();
        System.out.println("多字元模糊匹配 " + trieTree.pattern + ":");
        trieTree.printAll();
    }複製程式碼

測試結果

由簡入繁--Trie樹實戰

統計字串中指定字元出現的次數

還是以26個字母為大前提.字典樹正是因為它搜尋快捷的特性,才會深受搜尋引擎的喜愛。只要有空間(確實很耗記憶體),就能為所欲為(快)。

思考

這裡主要是分享這樣的一種思想,如何利用現有程式碼,根據需求,將其進行改造成滿足的需求的程式碼。有時候不需要重複造輪子,但是關鍵時刻需要會用輪子。

改造TireNode類

這裡加了一個frequency屬性,為了統計高頻詞彙。並且將children由陣列改成map,更便於儲存,相當而言,更節省空間。

    private class TrieNode {
        /**
         * 字元
         */
        public char data;
        /**
         * 出現頻率
         */
        int frequency;

        boolean isEndingChar;
        /**
         * 子節點
         */
        Map<Character, TrieNode> children;

        TrieNode(char data) {
            this.data = data;
            children = new HashMap<>();
            isEndingChar = false;
        }
    }複製程式碼

初始化

    /**
     * 根節點
     */
    private TrieNode root;
    /**
     * 計數
     */
    private int count;

    /**
     * 無參構造方法
     */
    private TrieTreeAlgo() {
        root = new TrieNode('/');
        count = 0;
    }複製程式碼

改造插入方法

  • frequency用來計數,計算該字元的頻率
  • isEndingChar和之前一樣,用來判斷是否是該單詞的結尾

    private void insert(String txt) {
        TrieNode p = root;
        char[] txtChar = txt.toCharArray();
        for (Character c : txtChar) {
            if (!p.children.containsKey(c)) {
                TrieNode trieNode = new TrieNode(c);
                p.children.put(c, trieNode);
            }
            p = p.children.get(c);
            ++p.frequency;
        }
        ++count;
        p.isEndingChar = true;
    }複製程式碼

統計方法

增加一個統計方法,計算某一單詞的出現頻率,當isEndingChar==true,說明已經匹配到該單詞了,並且到末尾,然後該字元頻率數量減去子節點的個數即可

    private int frequency(String pattern) {
        char[] patChars = pattern.toCharArray();
        TrieNode p = root;
        for (char patChar : patChars) {
            if (p.children.containsKey(patChar)) {
                p = p.children.get(patChar);
            }
        }
        if (p.isEndingChar) return p.frequency - p.children.size();
        return -1;
    }複製程式碼

測試程式碼

初始化要插入字典樹的單詞(這裡其實可以擴充套件一下下,插入一篇文章,插入使用者常輸入詞彙等等。)

    private void initTries() {
        String txt = "he her hello home so see say just so so hello world";
        String[] strs = txt.split(" ");
        for (String str : strs) {
            insert(str);
        }
    }複製程式碼

測試程式碼

  1. so 一個高頻詞彙
  2. he 一個普通單詞,並且裡面的單詞還有含有它的,比如her,hello
  3. hel一個不存在的單詞

    public static void main(String[] args) {
        TrieTreeAlgo trieTreeAlgo = new TrieTreeAlgo();
        trieTreeAlgo.initTries();
        System.out.println("共計" + trieTreeAlgo.count + "個單詞。");
        String so = "so";
        int soCount = trieTreeAlgo.frequency(so);
        System.out.println(so + "出現的次數為:" + (soCount > 0 ? soCount : 0));
        String he = "he";
        int heCount = trieTreeAlgo.frequency(he);
        System.out.println(he + "出現的次數為:" + (heCount > 0 ? heCount : 0));
        String hel = "hel";
        int helCount = trieTreeAlgo.frequency(hel);
        System.out.println(hel + "出現的次數為:" + (helCount > 0 ? helCount : 0));
    }複製程式碼

測試結果

由簡入繁--Trie樹實戰

基於AC自動機的敏感詞過濾系統

既然有了關鍵詞匹配提示,那麼相對應的,自然也應該有敏感詞過濾,隨著網際網路的日益發達,使用者的素質參差不齊,動不動就罵人,如果這在一個網站上顯示,肯定是不好的,所以對此現象,基於AC自動機的敏感詞過濾系統就誕生了。

ps:偷偷告訴你個祕密:這是一個閹割壓縮版的敏感詞過濾系統

思考

AC 自動機實際上就是在 Trie 樹之上,加了類似 KMP 的 next 陣列(只不過這裡的next陣列是構建在Trie樹上)。還是要改造的,在trie樹的基礎上加了一個fail的指標,當匹配不上的時候,儘可能的在樹上滑動,說人話就是大大減少了遍歷的次數,提升了匹配效率。

ps 這是一種字尾字串匹配演算法

改造

在原有基礎上,加了一個fail的指標,並且AC自動機的跳轉是通過fail指標來實現的。

    private class AcNode {
        /**
         * 字元
         */
        public char data;
        /**
         * 子節點
         */
        Map<Character, AcNode> children;
        /**
         * 結束標識
         */
        boolean isEndingChar;
        /**
         * 失敗指標
         */
        AcNode fail;

        AcNode(char data) {
            this.data = data;
            children = new HashMap<>();
            isEndingChar = false;
        }
    }複製程式碼

初始化

/**
     * 根節點
     */
    private AcNode root;

    private AhoCorasick() {
        root = new AcNode('/');
    }複製程式碼

插入

    private void insert(String txt) {
        AcNode p = root;
        char[] txtChar = txt.toCharArray();
        for (Character c : txtChar) {
            if (!p.children.containsKey(c)) {
                AcNode trieNode = new AcNode(c);
                p.children.put(c, trieNode);
            }
            p = p.children.get(c);
        }
        p.isEndingChar = true;
    }複製程式碼

構建失敗指標

這個方法是關鍵。

    private void buildFailurePointer() {
        Queue<AcNode> queue = new LinkedList<>();
        root.fail = null;
        queue.offer(root);
        while (!queue.isEmpty()) {
            AcNode p = queue.poll();
            for (char c : p.children.keySet()) {
                AcNode pChild = p.children.get(c);
                if (null == pChild) continue;
                if (root == p) {
                    pChild.fail = root;
                } else {
                    AcNode q = p.fail;
                    while (null != q) {
                        AcNode qChild = q.children.get(p.data);
                        if (null != qChild) {
                            pChild.fail = qChild;
                            break;
                        }
                        q = q.fail;
                    }
                    if (null == q) {
                        pChild.fail = root;
                    }
                }
                queue.offer(pChild);
            }
        }
    }複製程式碼

匹配

    private boolean match(String txt) {
        char[] txtChars = txt.toCharArray();
        AcNode p = root;
        for (char c : txtChars) {
            while (p != root && null == p.children.get(c)) {
                p = p.fail;
            }
            p = p.children.get(c);
            //如果沒有匹配,從root重新開始
            if (null == p) p = root;
            AcNode temp = p;
            while (temp != root) {
                if (temp.isEndingChar) {
                    return true;
                }
                temp = temp.fail;
            }
        }
        return false;
    }複製程式碼

構建敏感詞Trie樹

    private void generate() {
        String[] strs = new String[]{"so", "hel", "oh", "llo"};
        for (int i = 0; i < strs.length; i++) {
            insert(strs[i]);
        }
    }複製程式碼

測試程式碼

這裡加了一個Map,用來做快取,如果已經匹配上了,直接替換就可以了,提升效率。mapCache的value就是key出現的次數,起一個計數的作用。

    public static void main(String[] args) {
        AhoCorasick ac = new AhoCorasick();
        ac.generate();
        ac.buildFailurePointer();
        String txt = "he her hello home so see say just so so hello world";
        System.out.println("主串");
        System.out.println("[" + txt + "]");
        System.out.println("敏感詞:");
        System.out.println("so,hel,oh,llo");
        String[] strs = txt.split(" ");
        Map<String, Integer> mapCache = new HashMap<>();
        for (int i = 0; i < strs.length; i++) {
            if (mapCache.containsKey(strs[i])) {
                int index = mapCache.get(strs[i]);
                mapCache.put(strs[i], ++index);
                strs[i] = "****";
            } else {
                boolean res = ac.match(strs[i]);
                //如果匹配到,將其替換成****
                if (res) {
                    mapCache.put(strs[i], 1);
                    strs[i] = "****";
                }
            }
        }
        System.out.println("經過敏感詞系統過濾後...");
        System.out.println(Arrays.toString(strs));
        for (String str:mapCache.keySet()){
            System.out.println(str + "出現的次數為" + mapCache.get(str));
        }
    }複製程式碼

測試結果

由簡入繁--Trie樹實戰

end

由簡入繁--Trie樹實戰

您的點贊和關注是對我最大的支援,謝謝!


相關文章