Skip to main content

Command Palette

Search for a command to run...

前缀树(Trie)

Published
4 min readView as Markdown

前缀树是一种专门处理字符串匹配的树形数据结构,又叫字典树。它的核心思想是空间换时间,利用字符串的公共前缀来减少查询时间,从而提高效率。

前缀树的核心思想

前缀树的每个节点都代表一个字符,从根节点到某一节点的路径连接起来就是一个字符串。通过存储字符之间的层级关系,前缀树可以高效地进行字符串的插入、查找和删除操作。

经典题型:实现一个前缀树

实现一个 Trie 类,包含 insert、search 和 startsWith 三个方法。

方法一:基础数组实现

使用数组来存储子节点,是最直观的实现方式。

class TrieNode {
    // 子节点,这里假设只包含小写字母
    private TrieNode[] children;
    // 标记是否是单词的结尾
    private boolean isEnd;

    public TrieNode() {
        children = new TrieNode[26]; // 26个小写字母
        isEnd = false;
    }

    public boolean containsKey(char ch) {
        return children[ch - 'a'] != null;
    }

    public TrieNode get(char ch) {
        return children[ch - 'a'];
    }

    public void put(char ch, TrieNode node) {
        children[ch - 'a'] = node;
    }

    public void setEnd() {
        isEnd = true;
    }

    public boolean isEnd() {
        return isEnd;
    }
}

class Trie {
    private TrieNode root;

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

    // 插入一个单词
    public void insert(String word) {
        TrieNode node = root;
        for (int i = 0; i < word.length(); i++) {
            char ch = word.charAt(i);
            if (!node.containsKey(ch)) {
                node.put(ch, new TrieNode());
            }
            node = node.get(ch);
        }
        node.setEnd();
    }

    // 查找一个单词是否存在
    public boolean search(String word) {
        TrieNode node = root;
        for (int i = 0; i < word.length(); i++) {
            char ch = word.charAt(i);
            if (!node.containsKey(ch)) {
                return false;
            }
            node = node.get(ch);
        }
        return node.isEnd();
    }

    // 查找是否有以该前缀开头的单词
    public boolean startsWith(String prefix) {
        TrieNode node = root;
        for (int i = 0; i < prefix.length(); i++) {
            char ch = prefix.charAt(i);
            if (!node.containsKey(ch)) {
                return false;
            }
            node = node.get(ch);
        }
        return true;
    }
}

这种方法的时间和空间复杂度:

  • 插入:O(m),其中 m 是单词的长度

  • 查找:O(m)

  • 前缀查找:O(m)

  • 空间:O(26^n),其中 n 是树的深度

方法二:HashMap实现

使用HashMap来存储子节点,可以支持更广泛的字符集,而不仅仅是小写字母。

class TrieNode {
    Map<Character, TrieNode> children;
    boolean isEnd;

    public TrieNode() {
        children = new HashMap<>();
        isEnd = false;
    }
}

class Trie {
    private TrieNode root;

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

    public void insert(String word) {
        TrieNode node = root;
        for (int i = 0; i < word.length(); i++) {
            char ch = word.charAt(i);
            if (!node.children.containsKey(ch)) {
                node.children.put(ch, new TrieNode());
            }
            node = node.children.get(ch);
        }
        node.isEnd = true;
    }

    public boolean search(String word) {
        TrieNode node = root;
        for (int i = 0; i < word.length(); i++) {
            char ch = word.charAt(i);
            if (!node.children.containsKey(ch)) {
                return false;
            }
            node = node.children.get(ch);
        }
        return node.isEnd;
    }

    public boolean startsWith(String prefix) {
        TrieNode node = root;
        for (int i = 0; i < prefix.length(); i++) {
            char ch = prefix.charAt(i);
            if (!node.children.containsKey(ch)) {
                return false;
            }
            node = node.children.get(ch);
        }
        return true;
    }
}

方法三:压缩前缀树(Radix Tree)

压缩前缀树是前缀树的一种优化,它将只有一个子节点的链进行压缩,从而减少空间使用。

class RadixNode {
    Map<String, RadixNode> children;
    boolean isEnd;

    public RadixNode() {
        children = new HashMap<>();
        isEnd = false;
    }
}

class RadixTree {
    private RadixNode root;

    public RadixTree() {
        root = new RadixNode();
    }

    public void insert(String word) {
        RadixNode node = root;
        int i = 0;
        while (i < word.length()) {
            boolean found = false;
            for (Map.Entry<String, RadixNode> entry : node.children.entrySet()) {
                String edge = entry.getKey();
                RadixNode child = entry.getValue();

                // 找到共同前缀
                int j = 0;
                while (j < edge.length() && i + j < word.length() && edge.charAt(j) == word.charAt(i + j)) {
                    j++;
                }

                if (j > 0) {
                    found = true;

                    if (j == edge.length() && j == word.length() - i) {
                        // 完全匹配
                        child.isEnd = true;
                        return;
                    } else if (j == edge.length()) {
                        // 沿着当前边继续
                        node = child;
                        i += j;
                        break;
                    } else if (j < edge.length()) {
                        // 需要分裂当前边
                        String prefix = edge.substring(0, j);
                        String suffix = edge.substring(j);

                        // 创建新节点
                        RadixNode newNode = new RadixNode();
                        newNode.children.put(suffix, child);

                        // 更新当前节点的子节点
                        node.children.remove(edge);
                        node.children.put(prefix, newNode);

                        // 处理剩余字符
                        if (j < word.length() - i) {
                            String remaining = word.substring(i + j);
                            RadixNode endNode = new RadixNode();
                            endNode.isEnd = true;
                            newNode.children.put(remaining, endNode);
                        } else {
                            newNode.isEnd = true;
                        }

                        return;
                    }
                }
            }

            if (!found) {
                // 没有匹配的边,直接添加
                node.children.put(word.substring(i), new RadixNode());
                node.children.get(word.substring(i)).isEnd = true;
                return;
            }
        }
    }

    public boolean search(String word) {
        RadixNode node = root;
        int i = 0;
        while (i < word.length()) {
            boolean found = false;
            for (Map.Entry<String, RadixNode> entry : node.children.entrySet()) {
                String edge = entry.getKey();

                if (i + edge.length() <= word.length() && word.substring(i, i + edge.length()).equals(edge)) {
                    i += edge.length();
                    node = entry.getValue();
                    found = true;
                    break;
                }
            }

            if (!found) {
                return false;
            }
        }

        return node.isEnd;
    }

    public boolean startsWith(String prefix) {
        RadixNode node = root;
        int i = 0;
        while (i < prefix.length()) {
            boolean found = false;
            for (Map.Entry<String, RadixNode> entry : node.children.entrySet()) {
                String edge = entry.getKey();

                if (i + edge.length() <= prefix.length() && prefix.substring(i, i + edge.length()).equals(edge)) {
                    i += edge.length();
                    node = entry.getValue();
                    found = true;
                    break;
                }
            }

            if (!found) {
                return false;
            }
        }

        return true;
    }
}

压缩前缀树在最坏情况下的空间复杂度是 O(n),其中 n 是所有字符串的总长度,而在最佳情况下(字符串有大量公共前缀),空间复杂度可以接近 O(m),其中 m 是不同字符串的数量。

前缀树的应用场景

前缀树在以下场景中有广泛应用:

  1. 字符串检索:快速判断一个字符串是否在集合中

  2. 前缀匹配:找出所有具有特定前缀的字符串

  3. 自动补全:根据用户输入的前缀,提示可能的完整单词

  4. IP路由:最长前缀匹配

  5. 拼写检查:检查单词拼写是否正确

  6. 词频统计:统计文本中单词出现的频率

前缀树的解题模板

// 基本前缀树实现
class TrieTemplate {
    private TrieNode root;

    private static class TrieNode {
        Map<Character, TrieNode> children;
        boolean isEnd;

        public TrieNode() {
            children = new HashMap<>();
            isEnd = false;
        }
    }

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

    public void insert(String word) {
        TrieNode node = root;
        for (char ch : word.toCharArray()) {
            if (!node.children.containsKey(ch)) {
                node.children.put(ch, new TrieNode());
            }
            node = node.children.get(ch);
        }
        node.isEnd = true;
    }

    public boolean search(String word) {
        TrieNode node = searchPrefix(word);
        return node != null && node.isEnd;
    }

    public boolean startsWith(String prefix) {
        return searchPrefix(prefix) != null;
    }

    private TrieNode searchPrefix(String prefix) {
        TrieNode node = root;
        for (char ch : prefix.toCharArray()) {
            if (!node.children.containsKey(ch)) {
                return null;
            }
            node = node.children.get(ch);
        }
        return node;
    }
}

前缀树是字符串处理中的重要数据结构,掌握了前缀树的实现和应用,可以在很多字符串相关问题中找到高效的解决方案。在实际工程中,前缀树的变体如压缩前缀树、后缀树等也有广泛应用。

More from this blog

字典树和后缀数组

14.2 字典树的应用算法设计 字典树,也叫Trie树或者前缀树,顾名思义,它就是一种专门用来处理字符串前缀的树形结构。每个节点代表一个字符,从根节点到任意一个节点的路径,就构成了一个字符串。它的核心思想就是用空间换时间,利用字符串的公共前缀来降低查询时间的开销。 14.2.1 实现Trie(前缀树) 问题描述 实现一个 Trie (前缀树),包含 insert, search, 和 startsWith 这三个操作。 Trie() 初始化前缀树对象。 void insert(String...

Oct 4, 20259 min read

树状数组( Binary Indexed Tree)

树状数组(Fenwick Tree / Binary Indexed Tree)这个东西。这玩意儿听起来挺唬人,但本质上就是个用数组模拟树形结构的工具,专门用来解决一类问题:频繁的单点更新和区间查询。如果我们用普通数组,更新是 O(1),但查区间和就是 O(N);用前缀和数组,查区间和是 O(1),但你更新一个点,后面所有的前缀和都得改,又是 O(N)。两边都不讨好。树状数组就把这两个操作都干到了 O(logN),一下就平衡了。 废话不多说,咱们从题入手,看看这东西到底怎么用,思路是怎么一步步升...

Oct 4, 202510 min read

线段树-树形结构

线段树,顾名思义,就是用来处理区间或线段问题的树形结构。它能高效地解决一类问题:对一个数组进行频繁的区间查询和元素更新。如果你看到一个问题,反复地问你“从L到R这个范围内的xx是什么?”或者“把L到R这个范围内的数都改成xx”,那线札树可能就是你的答案。 它的核心思想就是“分治”。把一个大区间,一分为二,变成两个小区间,直到每个区间只包含一个元素。这样,一个长度为N的数组,就对应了一棵满二叉树(或近似满二叉树),树的深度是 O(logN)。每一次查询和更新,我们都只需要沿着树的一条路径走到底,所...

Oct 4, 20259 min read

优先队列PriorityQueue

很多时候,我们处理数据不只是简单地存进去、取出来,而是希望每次取出的都是当前这批数据里"最"牛的那个,比如最大、最小、最重要等等。普通队列是先进先出,栈是后进先出,都满足不了这个需求。这时候,优先队列就登场了。 你可以把它想象成一个VIP候机室,不管你什么时候来的,只要你的"优先级"最高(比如是头等舱、白金会员),你就能最先登机。在算法里,这个"优先级"通常就是元素的大小。 Java里 PriorityQueue 就是它的标准实现,底层是一棵二叉堆(小顶堆)。这意味着,它能用 O(logN) 的...

Oct 4, 202514 min read

并查集(Disjoint Set Union, DSU)

并查集(Disjoint Set Union, DSU)是一种非常有用的数据结构,主要用来处理一些不相交集合的合并及查询问题。听起来很抽象,但其实核心就两个操作: find:查找一个元素属于哪个集合(通常通过返回该集合的代表元素,也就是根节点)。 union:将两个元素所在的集合合并成一个集合。 为了让find操作更快,我们通常会加上一个“路径压缩”的优化。为了让合并后的树结构更平衡,会加上“按秩合并”或“按大小合并”的优化。 10.2 一维并查集的应用算法设计 10.2.1 以图判树...

Oct 4, 202512 min read

小何的博客

49 posts

欢迎来到小何的博客,25届本科毕业生,河南科技大学软件工程专业,主攻Java,对python、vue、uniapp、大模型等技术都有涉及。