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"
- 从根节点开始
- 创建或找到 'c' 节点,pass++
- 创建或找到 'a' 节点,pass++
- 创建或找到 '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"
- 从根节点开始
- 找到 'c' 节点
- 找到 'a' 节点
- 找到 '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"
- 从根节点开始
- 找到 'c' 节点
- 找到 '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. 优缺点分析 ⚖️
优点:
- 查询效率高,时间复杂度是O(L),L是字符串长度
- 支持前缀匹配
- 节省空间(相比于直接存储所有字符串)
缺点:
- 如果字符集很大,每个节点的数组会很大
- 不适合存储太长的字符串
6. 常见面试题 📝
- 如何统计以某个前缀开头的单词数量?
- 使用prefixNumber方法
- 如何删除一个单词?
- 类似查询过程,但需要将路径上的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();
}
}
详细解释:
- 首先建立前缀树,将所有字符串插入
- 在查找过程中:
- 如果某个节点有多个子节点,说明在这个位置字符不一致
- 如果某个节点的pass值小于字符串总数,说明不是所有字符串都经过这里
- 时间复杂度: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;
}
}
详细解释:
- 使用DFS(深度优先搜索)遍历二维网格
- 对每个起始位置,尝试匹配单词
- 使用visited数组避免重复访问
- 使用回溯法恢复状态
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
}
关键点总结:
- 前缀树的节点设计要根据具体问题调整
- 注意边界条件的处理
- 善用递归和回溯
- 考虑代码的可扩展性
这些题目都是前缀树在实际应用中的典型案例。在面试中,除了要能写出基本的代码,还要注意:
- 代码的健壮性
- 边界情况的处理
- 性能优化的可能性
- 实际应用场景的扩展
如果您对某个具体题目还有疑问,我可以更详细地解释。
7. 使用建议 💡
- 当需要进行大量字符串查询时使用
- 需要前缀匹配功能时使用
- 内存要求不是特别严格的场景
通过这样的讲解,希望您能更好地理解前缀树的工作原理和实现方式。如果有任何不清楚的地方,欢迎继续提问!
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"前缀
}
- 支持前缀查找
- 节省空间(共享前缀)
- 支持字典序遍历
- 适合做模糊匹配
- 查询时间与字典大小无关
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);
}
}
- 实现简单
- 支持任意类型的key
- 常数时间的增删改查
- 内存占用相对固定
- 无需预先知道数据范围
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);
}
}
- 需要前缀查找功能
- 自动补全系统
- 拼写检查器
- IP路由表
- 电话号码簿
适合使用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);
}
}
- 简单的key-value存储
- 缓存系统
- 计数器系统
- 需要快速查找的场景
- 数据量较小的场景
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. 选择建议 💡
-
选择Trie的情况:
- 需要前缀匹配功能
- 数据具有大量相同前缀
- 内存空间充足
- 需要字典序遍历
-
选择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. 为什么要这样做?🤔
-
数组索引:
- 数组索引必须是从0开始的整数
- 通过
c - 'a'可以将字符完美映射到0-25的索引范围
-
节省空间:
- 只需要26个数组位置就能表示所有小写字母
- 比使用ASCII码(需要128个位置)或Unicode(更多)节省空间
-
快速访问:
- 数组访问是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. 注意事项 ⚠️
- 这种方法只适用于小写字母
- 如果需要处理其他字符,需要相应调整转换方式
- 确保输入字符在合法范围内
例如,处理大写字母:
// 处理大写字母
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; // 大写字母放在小写字母后面
}
这种转换方式是前缀树实现中的一个重要技巧,它既简单高效,又易于理解和实现。