左哥算法 - 前缀树及应用

203 阅读13分钟

1. 前缀树的通俗理解 🌲

想象一个字典树就像一个文件夹系统:

  • 根节点就像主文件夹
  • 每个分支就像子文件夹
  • 每条路径就像是在拼写一个单词

2. 节点结构详解 📝

public class TrieNode {
    public int end;    // 记录有多少个单词在这里结束
    public int pass;   // 记录有多少个单词经过这个节点
    public TrieNode[] nexts;  // 存储下一个字符节点的数组
    
    public TrieNode() {
        end = 0;
        pass = 0;
        // 创建26个位置的数组,对应26个小写字母
        nexts = new TrieNode[26];
    }
}

举个例子: 如果我们要存储 "cat" 和 "car":

     root(pass=2)
       |
       c(pass=2)
       |
       a(pass=2)
      / \
     t   r
(end=1) (end=1)

3. 核心操作详解 🔍

3.1 插入操作

public void insert(String word) {
    if (word == null) return;
    char[] chars = word.toCharArray();
    TrieNode node = root;
    node.pass++;  // 根节点的pass先加1
    
    // 遍历单词的每个字符
    for (char c : chars) {
        // 将字符转换为数组索引(例如:'a'->0, 'b'->1, ...)
        int index = c - 'a';
        
        // 如果这个字符的节点不存在,就创建一个
        if (node.nexts[index] == null) {
            node.nexts[index] = new TrieNode();
        }
        
        // 移动到下一个节点
        node = node.nexts[index];
        // 经过这个节点的单词数量加1
        node.pass++;
    }
    // 单词结束,end值加1
    node.end++;
}

例如:插入 "cat"

  1. 从根节点开始
  2. 创建或找到 'c' 节点,pass++
  3. 创建或找到 'a' 节点,pass++
  4. 创建或找到 't' 节点,pass++,end++

3.2 查询操作

public int search(String word) {
    if (word == null) return 0;
    char[] chars = word.toCharArray();
    TrieNode node = root;
    
    // 遍历要查找的单词的每个字符
    for (char c : chars) {
        int index = c - 'a';
        // 如果找不到这个字符,说明单词不存在
        if (node.nexts[index] == null) {
            return 0;
        }
        // 继续往下找
        node = node.nexts[index];
    }
    // 返回这个单词出现的次数
    return node.end;
}

例如:查询 "cat"

  1. 从根节点开始
  2. 找到 'c' 节点
  3. 找到 'a' 节点
  4. 找到 't' 节点,返回其end值

3.3 前缀统计

public int prefixNumber(String prefix) {
    if (prefix == null) return 0;
    char[] chars = prefix.toCharArray();
    TrieNode node = root;
    
    // 遍历前缀的每个字符
    for (char c : chars) {
        int index = c - 'a';
        // 如果找不到这个字符,说明没有以这个前缀开头的单词
        if (node.nexts[index] == null) {
            return 0;
        }
        // 继续往下找
        node = node.nexts[index];
    }
    // 返回以这个前缀开头的单词数量
    return node.pass;
}

例如:查询前缀 "ca"

  1. 从根节点开始
  2. 找到 'c' 节点
  3. 找到 'a' 节点,返回其pass值

4. 实际应用示例 🌟

public static void main(String[] args) {
    Trie trie = new Trie();
    
    // 插入单词
    trie.insert("cat");
    trie.insert("car");
    trie.insert("dog");
    
    // 查询单词出现次数
    System.out.println(trie.search("cat")); // 输出:1
    
    // 查询前缀出现次数
    System.out.println(trie.prefixNumber("ca")); // 输出:2
}

5. 优缺点分析 ⚖️

优点:

  1. 查询效率高,时间复杂度是O(L),L是字符串长度
  2. 支持前缀匹配
  3. 节省空间(相比于直接存储所有字符串)

缺点:

  1. 如果字符集很大,每个节点的数组会很大
  2. 不适合存储太长的字符串

6. 常见面试题 📝

  1. 如何统计以某个前缀开头的单词数量?
    • 使用prefixNumber方法
  2. 如何删除一个单词?
    • 类似查询过程,但需要将路径上的pass值减1,最后将end减1

详细讲解几道常见的前缀树面试题。

1. 最长公共前缀问题 👑

给定一个字符串数组,找出这些字符串的最长公共前缀。 例如:["flower", "flow", "flight"] 的最长公共前缀是 "fl"

public class LongestCommonPrefix {
    class TrieNode {
        int pass;  // 经过此节点的字符串数量
        TrieNode[] nexts;
        
        public TrieNode() {
            pass = 0;
            nexts = new TrieNode[26];
        }
    }
    
    public String findLongestCommonPrefix(String[] strs) {
        if (strs == null || strs.length == 0) return "";
        
        // 1. 构建前缀树
        TrieNode root = new TrieNode();
        for (String str : strs) {
            if (str.length() == 0) return "";
            
            TrieNode node = root;
            for (char c : str.toCharArray()) {
                int index = c - 'a';
                if (node.nexts[index] == null) {
                    node.nexts[index] = new TrieNode();
                }
                node = node.nexts[index];
                node.pass++; // 记录经过次数
            }
        }
        
        // 2. 查找最长公共前缀
        StringBuilder result = new StringBuilder();
        TrieNode node = root;
        while (true) {
            // 统计当前节点的子节点数量
            int count = 0;
            int index = -1;
            
            // 检查当前节点的所有子节点
            for (int i = 0; i < 26; i++) {
                if (node.nexts[i] != null) {
                    count++;
                    index = i;
                }
            }
            
            // 如果子节点不唯一,或者经过该节点的字符串数量不等于总字符串数量
            // 说明这里不是公共前缀了
            if (count != 1 || node.nexts[index].pass != strs.length) {
                break;
            }
            
            // 将当前字符加入结果
            result.append((char)(index + 'a'));
            node = node.nexts[index];
        }
        
        return result.toString();
    }
}

详细解释

  1. 首先建立前缀树,将所有字符串插入
  2. 在查找过程中:
    • 如果某个节点有多个子节点,说明在这个位置字符不一致
    • 如果某个节点的pass值小于字符串总数,说明不是所有字符串都经过这里
  3. 时间复杂度:O(S),S是所有字符串的总长度

2. 单词搜索问题 🔍

在二维字符网格中搜索是否存在特定单词的路径。

public class WordSearch {
    public boolean exist(char[][] board, String word) {
        if (board == null || board.length == 0 || word == null) {
            return false;
        }
        
        int rows = board.length;
        int cols = board[0].length;
        // 记录已访问的位置
        boolean[][] visited = new boolean[rows][cols];
        
        // 从每个位置开始尝试
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                if (dfs(board, word, 0, i, j, visited)) {
                    return true;
                }
            }
        }
        return false;
    }
    
    private boolean dfs(char[][] board, String word, int index, 
                       int row, int col, boolean[][] visited) {
        // 已找到完整单词
        if (index == word.length()) {
            return true;
        }
        
        // 边界检查和字符匹配检查
        if (row < 0 || row >= board.length || 
            col < 0 || col >= board[0].length || 
            visited[row][col] || 
            board[row][col] != word.charAt(index)) {
            return false;
        }
        
        // 标记当前位置为已访问
        visited[row][col] = true;
        
        // 向四个方向继续搜索
        boolean found = dfs(board, word, index + 1, row + 1, col, visited) || // 下
                       dfs(board, word, index + 1, row - 1, col, visited) || // 上
                       dfs(board, word, index + 1, row, col + 1, visited) || // 右
                       dfs(board, word, index + 1, row, col - 1, visited);   // 左
        
        // 回溯,恢复未访问状态
        visited[row][col] = false;
        
        return found;
    }
}

详细解释

  1. 使用DFS(深度优先搜索)遍历二维网格
  2. 对每个起始位置,尝试匹配单词
  3. 使用visited数组避免重复访问
  4. 使用回溯法恢复状态

3. 电话号码本问题 📱

实现一个电话号码本,支持添加和查找号码。

public class PhoneDirectory {
    class TrieNode {
        boolean isEnd;    // 标记是否是完整号码
        TrieNode[] next;  // 存储数字0-9
        
        public TrieNode() {
            isEnd = false;
            next = new TrieNode[10];
        }
    }
    
    private TrieNode root;
    
    public PhoneDirectory() {
        root = new TrieNode();
    }
    
    // 添加电话号码
    public boolean addNumber(String number) {
        // 如果号码已存在,返回false
        if (search(number)) {
            return false;
        }
        
        TrieNode node = root;
        // 遍历号码的每一位数字
        for (char c : number.toCharArray()) {
            int digit = c - '0';
            if (node.next[digit] == null) {
                node.next[digit] = new TrieNode();
            }
            node = node.next[digit];
        }
        node.isEnd = true;
        return true;
    }
    
    // 查找电话号码
    public boolean search(String number) {
        TrieNode node = root;
        // 遍历号码的每一位数字
        for (char c : number.toCharArray()) {
            int digit = c - '0';
            if (node.next[digit] == null) {
                return false;
            }
            node = node.next[digit];
        }
        return node.isEnd;
    }
    
    // 查找前缀匹配的号码数量
    public int findPrefixCount(String prefix) {
        TrieNode node = root;
        int count = 0;
        
        // 找到前缀的最后一个节点
        for (char c : prefix.toCharArray()) {
            int digit = c - '0';
            if (node.next[digit] == null) {
                return 0;
            }
            node = node.next[digit];
        }
        
        // 统计以该节点为根的所有完整号码
        return countNumbers(node);
    }
    
    private int countNumbers(TrieNode node) {
        if (node == null) return 0;
        
        int count = node.isEnd ? 1 : 0;
        for (TrieNode next : node.next) {
            if (next != null) {
                count += countNumbers(next);
            }
        }
        return count;
    }
}

使用示例

public static void main(String[] args) {
    PhoneDirectory directory = new PhoneDirectory();
    
    // 添加号码
    directory.addNumber("1234567890");
    directory.addNumber("1234567891");
    
    // 查找号码
    System.out.println(directory.search("1234567890")); // true
    System.out.println(directory.search("1234567892")); // false
    
    // 查找前缀匹配的号码数量
    System.out.println(directory.findPrefixCount("123")); // 2
}

关键点总结

  1. 前缀树的节点设计要根据具体问题调整
  2. 注意边界条件的处理
  3. 善用递归和回溯
  4. 考虑代码的可扩展性

这些题目都是前缀树在实际应用中的典型案例。在面试中,除了要能写出基本的代码,还要注意:

  1. 代码的健壮性
  2. 边界情况的处理
  3. 性能优化的可能性
  4. 实际应用场景的扩展

如果您对某个具体题目还有疑问,我可以更详细地解释。

7. 使用建议 💡

  1. 当需要进行大量字符串查询时使用
  2. 需要前缀匹配功能时使用
  3. 内存要求不是特别严格的场景

通过这样的讲解,希望您能更好地理解前缀树的工作原理和实现方式。如果有任何不清楚的地方,欢迎继续提问!

8. 前缀树(Trie)和HashMap的优劣势

让我详细对比下前缀树(Trie)和HashMap的优劣势。

1. 基本对比 📊

特性前缀树(Trie)HashMap
时间复杂度O(L) - L是字符串长度O(1) - 理想情况
空间复杂度O(N×L) - N是key的数量,L是平均长度O(N)
前缀匹配支持不支持
实现复杂度相对复杂简单

2. 详细分析 🔍

前缀树(Trie)的优势 👍
// 前缀树实现
class Trie {
    class TrieNode {
        boolean isEnd;
        TrieNode[] children = new TrieNode[26];
    }
    
    private TrieNode root = new TrieNode();
    
    // 1. 支持前缀查找
    public List<String> findByPrefix(String prefix) {
        List<String> result = new ArrayList<>();
        TrieNode node = root;
        
        // 找到前缀的最后一个节点
        for (char c : prefix.toCharArray()) {
            int index = c - 'a';
            if (node.children[index] == null) {
                return result;
            }
            node = node.children[index];
        }
        
        // 收集所有以该前缀开头的字符串
        dfs(node, new StringBuilder(prefix), result);
        return result;
    }
    
    private void dfs(TrieNode node, StringBuilder current, List<String> result) {
        if (node.isEnd) {
            result.add(current.toString());
        }
        
        for (int i = 0; i < 26; i++) {
            if (node.children[i] != null) {
                current.append((char)('a' + i));
                dfs(node.children[i], current, result);
                current.setLength(current.length() - 1);
            }
        }
    }
    
    // 2. 节省空间(共享前缀)
    // 例如:存储 "car", "cat", "care" 时,共享"ca"前缀
}
  1. 支持前缀查找
  2. 节省空间(共享前缀)
  3. 支持字典序遍历
  4. 适合做模糊匹配
  5. 查询时间与字典大小无关
HashMap的优势 👍
// HashMap实现
class DictionaryWithHashMap {
    private HashMap<String, Integer> map = new HashMap<>();
    
    // 1. 简单直接的操作
    public void insert(String word) {
        map.put(word, map.getOrDefault(word, 0) + 1);
    }
    
    public boolean search(String word) {
        return map.containsKey(word);
    }
    
    // 2. 支持任意类型的key
    public void insertAnyType(Object key, Object value) {
        map.put(key, value);
    }
    
    // 3. 常数时间的增删改查
    public void update(String word, int newValue) {
        map.put(word, newValue);
    }
}
  1. 实现简单
  2. 支持任意类型的key
  3. 常数时间的增删改查
  4. 内存占用相对固定
  5. 无需预先知道数据范围

3. 应用场景对比 🎯

适合使用前缀树的场景:
// 自动补全功能示例
class AutoComplete {
    private Trie trie = new Trie();
    
    public List<String> getSuggestions(String prefix) {
        return trie.findByPrefix(prefix);
    }
}

// 字符串匹配系统
class StringMatcher {
    private Trie trie = new Trie();
    
    // 支持通配符查询
    public boolean matchWithWildcard(String pattern) {
        return searchWithWildcard(root, pattern, 0);
    }
}
  1. 需要前缀查找功能
  2. 自动补全系统
  3. 拼写检查器
  4. IP路由表
  5. 电话号码簿
适合使用HashMap的场景:
// 计数器系统
class Counter {
    private HashMap<String, Integer> counts = new HashMap<>();
    
    public void increment(String key) {
        counts.put(key, counts.getOrDefault(key, 0) + 1);
    }
}

// 缓存系统
class Cache {
    private HashMap<String, Object> cache = new HashMap<>();
    
    public Object get(String key) {
        return cache.getOrDefault(key, null);
    }
}
  1. 简单的key-value存储
  2. 缓存系统
  3. 计数器系统
  4. 需要快速查找的场景
  5. 数据量较小的场景

4. 性能对比 📈

// 性能测试示例
public class PerformanceTest {
    public static void main(String[] args) {
        // Trie测试
        Trie trie = new Trie();
        long start = System.currentTimeMillis();
        for (String word : words) {
            trie.insert(word);
        }
        System.out.println("Trie插入时间:" + (System.currentTimeMillis() - start));
        
        // HashMap测试
        HashMap<String, Boolean> map = new HashMap<>();
        start = System.currentTimeMillis();
        for (String word : words) {
            map.put(word, true);
        }
        System.out.println("HashMap插入时间:" + (System.currentTimeMillis() - start));
    }
}
时间性能:
  • Trie:与字符串长度成正比
  • HashMap:理想情况下是O(1),但有哈希冲突时性能会下降
空间性能:
  • Trie:共享前缀节省空间,但基础结构占用空间大
  • HashMap:每个键值对独立存储,但结构简单

5. 选择建议 💡

  1. 选择Trie的情况:

    • 需要前缀匹配功能
    • 数据具有大量相同前缀
    • 内存空间充足
    • 需要字典序遍历
  2. 选择HashMap的情况:

    • 简单的key-value存储
    • 内存空间受限
    • 数据无明显前缀特征
    • 需要最快的查询速度

6. 实际应用示例 🌟

// 混合使用示例
class SearchEngine {
    private Trie trie = new Trie();                    // 用于自动补全
    private HashMap<String, Integer> frequency = new HashMap<>();  // 用于记录搜索频率
    
    public void addSearchTerm(String term) {
        trie.insert(term);
        frequency.put(term, frequency.getOrDefault(term, 0) + 1);
    }
    
    public List<String> getTopSuggestions(String prefix) {
        List<String> suggestions = trie.findByPrefix(prefix);
        suggestions.sort((a, b) -> frequency.get(b) - frequency.get(a));
        return suggestions.subList(0, Math.min(5, suggestions.size()));
    }
}

在实际应用中,经常会结合两种数据结构的优势,根据具体需求选择合适的实现方式。

9. int index = c - 'a' 的处理

让我详细解释 int index = c - 'a' 这个操作。

1. 字符的本质 📝

在计算机中,字符实际上是以ASCII码或Unicode码存储的数字。对于小写字母:

  • 'a' 的ASCII码是 97
  • 'b' 的ASCII码是 98
  • 'c' 的ASCII码是 99 ...以此类推到
  • 'z' 的ASCII码是 122

2. 转换原理 🔄

public class CharacterDemo {
    public static void main(String[] args) {
        // 演示字符的ASCII值
        System.out.println("'a'的ASCII值: " + (int)'a');  // 输出97
        System.out.println("'b'的ASCII值: " + (int)'b');  // 输出98
        System.out.println("'z'的ASCII值: " + (int)'z');  // 输出122
        
        // 演示字符转换为索引
        char c = 'c';
        int index = c - 'a';
        System.out.println("字符'" + c + "'转换为索引: " + index);  // 输出2
        
        // 演示所有小写字母的转换
        for (char ch = 'a'; ch <= 'z'; ch++) {
            System.out.printf("字符'%c' -> 索引%d\n", ch, ch - 'a');
        }
    }
}

当我们执行 c - 'a' 时:

  • 如果 c 是 'a',计算结果是 97 - 97 = 0
  • 如果 c 是 'b',计算结果是 98 - 97 = 1
  • 如果 c 是 'c',计算结果是 99 - 97 = 2
  • 以此类推...

3. 在前缀树中的应用 🌲

class TrieNode {
    TrieNode[] children = new TrieNode[26];  // 26个小写字母
    
    public void insert(String word) {
        TrieNode node = this;
        for (char c : word.toCharArray()) {
            // 将字符转换为0-25的索引
            int index = c - 'a';
            
            // 示意图:
            // 'a' -> index 0  -> children[0]
            // 'b' -> index 1  -> children[1]
            // 'c' -> index 2  -> children[2]
            // ...
            // 'z' -> index 25 -> children[25]
            
            if (node.children[index] == null) {
                node.children[index] = new TrieNode();
            }
            node = node.children[index];
        }
    }
}

4. 直观的例子 🌟

public class CharIndexExample {
    public static void main(String[] args) {
        // 示例1:存储单词 "cat"
        String word = "cat";
        System.out.println("存储单词 '" + word + "'的过程:");
        
        for (char c : word.toCharArray()) {
            int index = c - 'a';
            System.out.printf("字符 '%c' -> ASCII值 %d -> 索引 %d\n", 
                            c, (int)c, index);
        }
        
        // 输出:
        // 字符 'c' -> ASCII值 99 -> 索引 2
        // 字符 'a' -> ASCII值 97 -> 索引 0
        // 字符 't' -> ASCII值 116 -> 索引 19
        
        // 示例2:将索引转回字符
        System.out.println("\n将索引转回字符:");
        int[] indices = {2, 0, 19};  // 上面得到的索引
        for (int index : indices) {
            char c = (char)(index + 'a');
            System.out.printf("索引 %d -> 字符 '%c'\n", index, c);
        }
    }
}

5. 为什么要这样做?🤔

  1. 数组索引

    • 数组索引必须是从0开始的整数
    • 通过 c - 'a' 可以将字符完美映射到0-25的索引范围
  2. 节省空间

    • 只需要26个数组位置就能表示所有小写字母
    • 比使用ASCII码(需要128个位置)或Unicode(更多)节省空间
  3. 快速访问

    • 数组访问是O(1)的操作
    • 通过简单的减法就能得到索引,效率很高

6. 实际应用示例 💡

public class TrieExample {
    class TrieNode {
        TrieNode[] children = new TrieNode[26];
        boolean isEnd = false;
    }
    
    TrieNode root = new TrieNode();
    
    public void insert(String word) {
        TrieNode node = root;
        for (char c : word.toCharArray()) {
            int index = c - 'a';
            
            // 打印每个字符的转换过程
            System.out.printf("字符 '%c' 转换为索引 %d\n", c, index);
            
            if (node.children[index] == null) {
                node.children[index] = new TrieNode();
            }
            node = node.children[index];
        }
        node.isEnd = true;
    }
    
    public static void main(String[] args) {
        TrieExample trie = new TrieExample();
        trie.insert("cat");
        
        // 输出:
        // 字符 'c' 转换为索引 2
        // 字符 'a' 转换为索引 0
        // 字符 't' 转换为索引 19
    }
}

7. 注意事项 ⚠️

  1. 这种方法只适用于小写字母
  2. 如果需要处理其他字符,需要相应调整转换方式
  3. 确保输入字符在合法范围内

例如,处理大写字母:

// 处理大写字母
int index = c - 'A';  // 'A'的ASCII值是65

// 处理数字
int index = c - '0';  // '0'的ASCII值是48

// 处理大小写字母
int index;
if (c >= 'a' && c <= 'z') {
    index = c - 'a';
} else if (c >= 'A' && c <= 'Z') {
    index = c - 'A' + 26;  // 大写字母放在小写字母后面
}

这种转换方式是前缀树实现中的一个重要技巧,它既简单高效,又易于理解和实现。