數(shù)據(jù)結(jié)構(gòu):前綴樹Trie

引子

在刷題的過程中晌梨,經(jīng)常會遇到這樣一種典型問題:

給一組字符串List<String> strs,找出其中前綴為String p的所有字符串缠沈。

樸素的做法就是遍歷strs维蒙,然后每一個看一下是不是有前綴為p,這樣的效率是O(N*L)版保,其中N是strs中字符串的個數(shù)呜笑,L是p的長度。
這樣看來彻犁,其實也不差叫胁。就單次操作而言,這些操作都是必須的汞幢。問題在于驼鹅,當(dāng)出現(xiàn)多次這種操作的時候,每次都需要重新判斷前綴森篷,這樣效率就不高了谤民。
不賣關(guān)子了,相信大家也知道什么數(shù)據(jù)結(jié)構(gòu)好用了——就是今天要介紹的前綴樹(或者叫字典樹疾宏,又稱單詞查找樹或鍵樹)张足,英文叫做Trie,專門用來處理這種操作坎藐。

介紹

借用百度百科的圖:


Trie

最簡單的Trie的話为牍,只需要保持從當(dāng)前節(jié)點到子節(jié)點的映射就行了,子節(jié)點的字符可以從當(dāng)前節(jié)點得到:

import java.util.HashMap;
import java.util.Map;

class TrieNode {

    private Map<Character, TrieNode> children = new HashMap<Character, TrieNode>();

    public TrieNode() {
    }

    public Map<Character, TrieNode> getChildren() {
        return children;
    }

    public void setChildren(HashMap<Character, TrieNode> children) {
        this.children = children;
    }
}

在此基礎(chǔ)上岩馍,我們可以根據(jù)需要來加入新的變量:

  • 可以用一個Boolean來表示當(dāng)前節(jié)點是不是某個字符串的末尾碉咆,從而在路徑重合的時候知道有哪些字符串;
  • 可以用一個Int來表示當(dāng)前節(jié)點下面完整字符串的個數(shù)蛀恩,用于快速計數(shù)某前綴的個數(shù)疫铜;
  • 可以用一個List來存儲當(dāng)前節(jié)點下面的完整字符串,用于快速返回含有某個前綴的所有字符串双谆。

可以列舉的還有很多壳咕,總之Trie的一大優(yōu)勢就是靈活,按照具體需要進行修改顽馋。

那么以最簡單的Trie為基礎(chǔ)谓厘,如何實現(xiàn)基本的增刪查改操作?

  • 增的話寸谜,比較簡單竟稳,基本上是“順藤摸瓜”,有就一直跟著走,什么地方斷了就自己新建TrieNode他爸。時間復(fù)雜度O(n)聂宾,n是增加的字符串的長度:
    public static void add(String word, TrieNode root) {
        TrieNode cur = root;
        for (char ch : word.toCharArray()) {
            if (cur.getChildren().containsKey(ch)) {
                cur = cur.getChildren().get(ch);
            } else {
                TrieNode node = new TrieNode();
                cur.getChildren().put(ch, node);
                cur = node;
            }
        }
    }
  • 查也不難,思路和增類似诊笤,只是不需要自己創(chuàng)建新節(jié)點而是直接返回false系谐。時間復(fù)雜度還是O(n):
    public static boolean hasPrefix(String prefix, TrieNode root) {
        TrieNode cur = root;
        for (char ch : prefix.toCharArray()) {
            if (cur.getChildren().containsKey(ch)) {
                cur = cur.getChildren().get(ch);
            } else {
                return false;
            }
        }
        return true;
    }
  • 刪的話相對復(fù)雜一些,一個例子就是兩個完全一樣的字符串盏混,只刪一個怎么實現(xiàn)。上面寫的數(shù)據(jù)結(jié)構(gòu)是不支持重復(fù)字符串的惜论,因此還需要引入一個新變量來存儲對應(yīng)字符串的個數(shù)许赃。這樣就復(fù)雜許多了。
    因此馆类,這里的實現(xiàn)假設(shè)字符串都是不重復(fù)的混聊。由于搜索的順序和刪的順序其實是相反的,因此遞歸是不錯的選擇乾巧。刪無非2種情況句喜,需要刪除本節(jié)點,和保留本節(jié)點給其他字符串沟于,所以遞歸還是需要一個返回值的咳胃。復(fù)雜度還是O(n)。
    public static void remove(String word, TrieNode root) {
        removeHelper(word, root, 0);
    }

    private static boolean removeHelper(String word, TrieNode node, int idx) {
        if (idx != word.length()) {
            char ch = word.charAt(idx);
            TrieNode next = node.getChildren().get(ch);
            if (removeHelper(word, next, idx + 1)) {
                node.getChildren().remove(ch);
            }
        }
        return node.getChildren().isEmpty();
    }
  • 改的話旷太,就可以轉(zhuǎn)化成增和刪的組合展懈。

回顧

回到剛開始嘗試解決的問題,假如要找到所有前綴為p的字符串供璧,那么就是遍歷到前綴p的最后一個節(jié)點存崖,之后所有標(biāo)記為完整字符串的就都是了。極限情況下睡毒,前綴p為空来惧,那么相當(dāng)于要遍歷整棵樹,而整棵樹最壞情況就是所有字符串都不重合演顾,那么也就是說復(fù)雜度是O(N*S)供搀,N是字符串個數(shù),S是字符串的平均長度钠至〕寐可見此時并不優(yōu)越。
那么怎么辦呢棕洋?Trie是可以根據(jù)需要靈活改變的挡闰。在原有Trie的基礎(chǔ)上,執(zhí)行要求的操作效率不高的原因在于威蕉,就算確定了前綴的節(jié)點逞带,后面的字符串還是要一個個去遍歷出來,從而效率不高肃续。很直接的解決思想就是加一個List來維持節(jié)點所對應(yīng)的字符串奢驯。
這樣改變之后申钩,操作的復(fù)雜度就是O(p),p為前綴p的長度瘪阁,因為只需要找到節(jié)點讀一下List即可撒遣。空間復(fù)雜度的話管跺,其實雖然同樣的字符串在很多節(jié)點出現(xiàn)义黎,但其實都指向同一個對象,其實沒有想象的那么糟糕豁跑×椋或者還可以存字符串List里原來的index。完整代碼如下:

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

class TrieNode {

    private Map<Character, TrieNode> children = new HashMap<Character, TrieNode>();
    private List<String> words = new ArrayList<>();

    public List<String> getWords() {
        return words;
    }

    public void setWords(List<String> words) {
        this.words = words;
    }

    public TrieNode() {
    }

    public Map<Character, TrieNode> getChildren() {
        return children;
    }

    public void setChildren(HashMap<Character, TrieNode> children) {
        this.children = children;
    }

    public static void add(String word, TrieNode root) {
        root.words.add(word);
        TrieNode cur = root;
        for (char ch : word.toCharArray()) {
            if (cur.getChildren().containsKey(ch)) {
                cur = cur.getChildren().get(ch);
            } else {
                TrieNode node = new TrieNode();
                cur.getChildren().put(ch, node);
                cur = node;
            }
            cur.words.add(word);
        }
    }

    public static List<String> getWordsWithPrefix(String prefix, TrieNode root) {
        TrieNode cur = root;
        for (char ch : prefix.toCharArray()) {
            if (cur.getChildren().containsKey(ch)) {
                cur = cur.getChildren().get(ch);
            } else {
                return new ArrayList<>();
            }
        }
        return cur.words;
    }
}

運用

下面看一個例題艇拍,力扣的Word Squares:



值得注意的一點是字符串都是可以重復(fù)使用的狐蜕。

解法與思路

形成Word Squares要求第k行和第k列一致。
第一行的單詞沒有任何限制卸夕。假設(shè)是wall层释。
第二行,k=0快集,那么就是說第0列已經(jīng)確定了湃累,也必須是wall,那么第二行必須以a開始碍讨。假設(shè)是area治力。
第三行,同樣k=0勃黍,k=1都限制了前綴必須是le宵统。假設(shè)是lead。
第四行覆获,限制了前綴必須是lad马澈。
由此可見,每一行都會增加一個限制弄息,前綴必須為第k列的開頭痊班。
因此,一個直接的思路是DFS加back tracking:

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

class Solution {

    public List<List<String>> wordSquares(String[] words) {
        Set<List<String>> ret = new HashSet<>();
        if (words.length == 0) return new ArrayList<>(ret);
        recur(words, new ArrayList<>(), ret);
        return new ArrayList<>(ret);
    }

    private void recur(String[] words, List<String> temp, Set<List<String>> ret) {
        if (temp.size() == words[0].length()) {
            ret.add(new ArrayList<>(temp));
            return;
        }
        for (String w : words) {
            if (meetRequirements(w, temp)) {
                temp.add(w);
                recur(words, temp, ret);
                temp.remove(temp.size() - 1);
            }
        }
    }

    private boolean meetRequirements(String w, List<String> temp) {
        int cnt = temp.size();
        if (cnt == 0) return true;
        for (int i = 0; i < cnt; i++) {
            if (w.charAt(i) != temp.get(i).charAt(cnt)) return false;
        }
        return true;
    }
}

很可惜摹量,這個解法TLE超時涤伐。為何馒胆?因為求下一個單詞時,需要過一遍整個單詞列表來判斷哪些單詞滿足條件凝果,效率不高祝迂。
這時候,我們之前學(xué)的Trie總算派上用場了:可以建一個Trie樹器净,然后需要找前綴的時候在里面搜就好型雳。完整代碼如下(Leetcode AC):

import java.util.*;

class Solution {

    public List<List<String>> wordSquares(String[] words) {
        Set<List<String>> ret = new HashSet<>();
        if (words.length == 0) return new ArrayList<>(ret);
        TrieNode root = buildTrie(words);
        recur(words, new ArrayList<>(), ret, root);
        return new ArrayList<>(ret);
    }

    private TrieNode buildTrie(String[] words) {
        TrieNode root = new TrieNode();
        for (String s : words) {
            TrieNode.add(s, root);
        }
        return root;
    }

    private void recur(String[] words, List<String> temp, Set<List<String>> ret, TrieNode root) {
        if (temp.size() == words[0].length()) {
            ret.add(new ArrayList<>(temp));
            return;
        }
        // get prefix
        StringBuilder sb = new StringBuilder(temp.size());
        for (int i = 0; i < temp.size(); i++) {
            sb.append(temp.get(i).charAt(temp.size()));
        }
        for (String w : TrieNode.getWordsWithPrefix(sb.toString(), root)) {
            temp.add(w);
            recur(words, temp, ret, root);
            temp.remove(temp.size() - 1);
        }
    }

    private static class TrieNode {

        private Map<Character, TrieNode> children = new HashMap<Character, TrieNode>();
        private final List<String> words = new ArrayList<>();

        public TrieNode() {
        }

        public Map<Character, TrieNode> getChildren() {
            return children;
        }

        public void setChildren(HashMap<Character, TrieNode> children) {
            this.children = children;
        }

        public static void add(String word, TrieNode root) {
            root.words.add(word);
            TrieNode cur = root;
            for (char ch : word.toCharArray()) {
                if (cur.getChildren().containsKey(ch)) {
                    cur = cur.getChildren().get(ch);
                } else {
                    TrieNode node = new TrieNode();
                    cur.getChildren().put(ch, node);
                    cur = node;
                }
                cur.words.add(word);
            }
        }

        public static List<String> getWordsWithPrefix(String prefix, TrieNode root) {
            TrieNode cur = root;
            for (char ch : prefix.toCharArray()) {
                if (cur.getChildren().containsKey(ch)) {
                    cur = cur.getChildren().get(ch);
                } else {
                    return new ArrayList<>();
                }
            }
            return cur.words;
        }
    }
}
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市山害,隨后出現(xiàn)的幾起案子纠俭,更是在濱河造成了極大的恐慌,老刑警劉巖浪慌,帶你破解...
    沈念sama閱讀 212,080評論 6 493
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件冤荆,死亡現(xiàn)場離奇詭異,居然都是意外死亡眷射,警方通過查閱死者的電腦和手機匙赞,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,422評論 3 385
  • 文/潘曉璐 我一進店門佛掖,熙熙樓的掌柜王于貴愁眉苦臉地迎上來妖碉,“玉大人,你說我怎么就攤上這事芥被∨芬耍” “怎么了?”我有些...
    開封第一講書人閱讀 157,630評論 0 348
  • 文/不壞的土叔 我叫張陵拴魄,是天一觀的道長冗茸。 經(jīng)常有香客問我,道長匹中,這世上最難降的妖魔是什么夏漱? 我笑而不...
    開封第一講書人閱讀 56,554評論 1 284
  • 正文 為了忘掉前任,我火速辦了婚禮顶捷,結(jié)果婚禮上挂绰,老公的妹妹穿的比我還像新娘。我一直安慰自己服赎,他們只是感情好葵蒂,可當(dāng)我...
    茶點故事閱讀 65,662評論 6 386
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著重虑,像睡著了一般践付。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上缺厉,一...
    開封第一講書人閱讀 49,856評論 1 290
  • 那天永高,我揣著相機與錄音隧土,去河邊找鬼。 笑死乏梁,一個胖子當(dāng)著我的面吹牛次洼,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播遇骑,決...
    沈念sama閱讀 39,014評論 3 408
  • 文/蒼蘭香墨 我猛地睜開眼卖毁,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了落萎?” 一聲冷哼從身側(cè)響起亥啦,我...
    開封第一講書人閱讀 37,752評論 0 268
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎练链,沒想到半個月后翔脱,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,212評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡媒鼓,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,541評論 2 327
  • 正文 我和宋清朗相戀三年届吁,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片绿鸣。...
    茶點故事閱讀 38,687評論 1 341
  • 序言:一個原本活蹦亂跳的男人離奇死亡疚沐,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出潮模,到底是詐尸還是另有隱情亮蛔,我是刑警寧澤,帶...
    沈念sama閱讀 34,347評論 4 331
  • 正文 年R本政府宣布擎厢,位于F島的核電站究流,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏动遭。R本人自食惡果不足惜芬探,卻給世界環(huán)境...
    茶點故事閱讀 39,973評論 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望厘惦。 院中可真熱鬧偷仿,春花似錦、人聲如沸绵估。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,777評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽国裳。三九已至形入,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間缝左,已是汗流浹背亿遂。 一陣腳步聲響...
    開封第一講書人閱讀 32,006評論 1 266
  • 我被黑心中介騙來泰國打工浓若, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人蛇数。 一個月前我還...
    沈念sama閱讀 46,406評論 2 360
  • 正文 我出身青樓挪钓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親耳舅。 傳聞我的和親對象是個殘疾皇子碌上,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,576評論 2 349