LeetCode 648. 单词替换
题目描述


题意分析
逐个处理句子中的单词:若字典中有词根是它的前缀,就替换成最短的那个词根;没有则保留原词。各单词的顺序保持不变,匹配必须从单词开头开始。
解法:Trie(前缀树)
核心思路
[!blue]
用字典树共享词根前缀,再沿单词找到第一个完整词根。 从树根出发的一条路径表示一个前缀,26 个孩子分别对应小写字母。插入词根时逐字符创建缺少的节点,并只在最后的节点保存完整词根word;中间路径节点本身不代表字典中的词根。查询单词时,从它的第一个字符开始沿同一条路径下降。每走一步,当前节点就表示已经读到的单词前缀,深度也就是前缀长度。第一次遇到保存了完整词根的节点时,所有更短前缀都已检查过而未命中,更长词根只可能在更深处,所以这个词根一定最短,可以立即停止。
若下一条分支不存在,就不可能再有词根沿当前单词前缀继续匹配;此前若有更短词根也早已返回,因此保留原词。若读完单词仍未遇到词根终点,同样保留原词。词根恰好等于整个单词时,在最后一个字符处就能正常命中。
字典树只构建一次,所有单词独立查询并按原顺序输出。题目保证单词之间只有一个空格,且没有前导、尾随空格,所以按单空格切分后再用单空格连接,正好恢复所需句子格式。
解题步骤
- 将所有词根插入字典树。
- 按原顺序逐个处理句子单词。
- 从树根向下查询,第一个终点即替换结果。
- 使用单个空格重新拼接。
代码实现
class Solution {
private static class TrieNode {
TrieNode[] children = new TrieNode[26];
String word;
}
public String replaceWords(List<String> dictionary, String sentence) {
TrieNode root = new TrieNode();
for (String w : dictionary) {
TrieNode node = root;
for (char c : w.toCharArray()) {
int idx = c - 'a';
if (node.children[idx] == null) {
node.children[idx] = new TrieNode();
}
node = node.children[idx];
}
node.word = w;
}
String[] words = sentence.split(" ");
StringBuilder sb = new StringBuilder();
for (int i = 0; i < words.length; i++) {
if (i > 0) {
sb.append(' ');
}
sb.append(findRoot648(root, words[i]));
}
return sb.toString();
}
private String findRoot648(TrieNode root, String word) {
TrieNode node = root;
for (char c : word.toCharArray()) {
int idx = c - 'a';
if (node.children[idx] == null) {
return word;
}
node = node.children[idx];
// 沿前缀首次遇到完整词根,就是最短可用词根。
if (node.word != null) {
return node.word;
}
}
return word;
}
}
import "strings"
type TrieNode struct {
children [26]*TrieNode
word string
}
func replaceWords(dictionary []string, sentence string) string {
root := &TrieNode{}
for _, root1 := range dictionary {
node := root
for _, c := range root1 {
idx := c - 'a'
if node.children[idx] == nil {
node.children[idx] = &TrieNode{}
}
node = node.children[idx]
}
node.word = root1
}
words := strings.Split(sentence, " ")
for i, word := range words {
node := root
for _, c := range word {
idx := c - 'a'
if node.children[idx] == nil {
break
}
node = node.children[idx]
// 沿前缀首次遇到完整词根,就是最短可用词根。
if node.word != "" {
words[i] = node.word
break
}
}
}
return strings.Join(words, " ")
}
复杂度分析
- 时间复杂度:$O(D+S)$,D 为词根总字符数,S 为句子总字符数。
- 空间复杂度:$O(D+S)$,包含字典树、分词及输出缓冲。
关键点总结
[!green]
- 深度就是词根长度,第一个终点保证最短。
- 缺失分支意味着当前词无法沿此前前缀继续匹配。
- 共享字典树只构建一次。
- 词根非空,Java 的
word != null和 Go 的word != ""都能区分完整词根与普通路径节点。
易错点总结
[!yellow]
- 将普通路径节点当作完整词根:可能返回字典中没有的前缀。
- 命中后继续取更长词根:违反最短要求。
- 断链后跳过当前字符继续查:把前缀匹配变成子序列匹配。
- 遗漏或多加输出空格:改变句子格式。
相似题目
| 题目 | 难度 | 关联与区别 |
|---|---|---|
| 208. 实现 Trie (前缀树) | 中等 | 复用Trie前缀查找,本题遇到第一个词根终点立即停止,以保留最短词根。 |
| 720. 词典中最长的单词 | 中等 | 同样利用前缀终点标记,原题要求构建单词的每一级前缀都存在,本题只需最短匹配词根。 |
| 211. 添加与搜索单词 - 数据结构设计 | 中等 | 用字典树共享字符串前缀;本题沿词前缀找到最短词根,该题通配符查询时分支搜索。 |
| 212. 单词搜索 II | 困难 | 用字典树共享字符串前缀;本题沿词前缀找到最短词根,该题把字典树与网格回溯结合。 |
| 677. 键值映射 | 中等 | 用字典树共享字符串前缀;本题沿词前缀找到最短词根,该题在前缀节点累计键值总和。 |
转载与许可
许可
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!