LeetCode 472. 连接词
题目描述
✅ 472. 连接词


题意分析
找出字典中能由至少两个较短单词依次拼接而成的连接词。每个片段都必须是字典里的完整单词,同一个单词可以使用多次;不能只把当前单词本身当作一个片段。
解法:字典树 + DFS 计数
核心思路
[!blue]
一个连接词只会使用比自己短的单词,因此先按长度从短到长处理。字典树保存已经处理的词,当前词先判断、后插入,避免整词匹配自身。输入单词互不相同,先插入的等长词也不可能恰好匹配当前整个单词,不会造成误判。
对当前词
w,定义 DFS 从下标idx开始拆分后缀,count记录之前已经选了几段。从字典树根节点沿w[idx..]逐字前进,每遇到一个单词结束标记,就尝试在这里切开,递归拆分剩余后缀;只有走到字符串末尾且count >= 2才成功。某个切分点失败后,还要继续沿字典树找更长的前缀,不能贪心只选最短或最长单词。只有对应字符分支不存在时,才可以停止延伸:既然当前前缀都不存在,更长的前缀也不可能是字典词。
不同切法可能到达相同
idx,用memo[idx]缓存后缀能否拆完。为什么不用同时缓存count:对于尚未结束的内部位置idx > 0,之前至少选了一段,而非空后缀至少还需要一段,所以只要后缀能拆完,就必然满足总段数至少为 2;初始位置则只会以count = 0进入。每个词使用独立缓存,避免混用不同字符串的后缀结果。
解题步骤
- 将单词按长度升序排序,创建空字典树和答案列表。
- 为当前非空单词创建
memo,以 -1 表示未计算,从idx = 0、count = 0开始 DFS。- 沿字典树枚举当前后缀的单词前缀,在每个结束标记处尝试递归;成功则缓存 1 并返回。
- 所有切法都不成功时缓存 0;到达字符串末尾时检查总片段数是否至少为 2。
- 当前词可拆分则加入答案,随后将它插入字典树,供更长单词使用。
代码实现
class Solution {
static class Node {
Node[] next = new Node[26];
boolean end;
}
public List<String> findAllConcatenatedWordsInADict(String[] words) {
Arrays.sort(words, Comparator.comparingInt(String::length));
Node root = new Node();
List<String> res = new ArrayList<>();
for (String w : words) {
if (w.length() == 0) {
continue;
}
int[] memo = new int[w.length() + 1];
Arrays.fill(memo, -1);
// 当前词尚未插入,先判定再登记,避免整词自匹配。
if (dfs(root, w, 0, 0, memo)) {
res.add(w);
}
insert(root, w);
}
return res;
}
private boolean dfs(Node root, String w, int idx, int count, int[] memo) {
if (idx == w.length()) {
return count >= 2;
}
// 每个词单独缓存后缀结果,内部位置无需区分已有片段数量。
if (memo[idx] != -1) {
return memo[idx] == 1;
}
Node cur = root;
for (int i = idx; i < w.length(); i++) {
int p = w.charAt(i) - 'a';
if (cur.next[p] == null) {
break;
}
cur = cur.next[p];
if (cur.end) {
if (dfs(root, w, i + 1, count + 1, memo)) {
memo[idx] = 1;
return true;
}
}
}
memo[idx] = 0;
return false;
}
private void insert(Node root, String w) {
Node cur = root;
for (int i = 0; i < w.length(); i++) {
int p = w.charAt(i) - 'a';
if (cur.next[p] == null) {
cur.next[p] = new Node();
}
cur = cur.next[p];
}
cur.end = true;
}
}
import "sort"
type trieNode struct {
next [26]*trieNode
end bool
}
func findAllConcatenatedWordsInADict(words []string) []string {
sort.Slice(words, func(i, j int) bool {
return len(words[i]) < len(words[j])
})
root := &trieNode{}
res := make([]string, 0)
for _, w := range words {
if len(w) == 0 {
continue
}
memo := make([]int, len(w)+1)
for i := 0; i < len(memo); i++ {
memo[i] = -1
}
// 当前词尚未插入,先判定再登记,避免整词自匹配。
if dfs472(root, w, 0, 0, memo) {
res = append(res, w)
}
insert(root, w)
}
return res
}
func dfs472(root *trieNode, w string, idx int, count int, memo []int) bool {
if idx == len(w) {
return count >= 2
}
// 每个词单独缓存后缀结果,内部位置无需区分已有片段数量。
if memo[idx] != -1 {
return memo[idx] == 1
}
cur := root
for i := idx; i < len(w); i++ {
p := int(w[i] - 'a')
if cur.next[p] == nil {
break
}
cur = cur.next[p]
if cur.end {
if dfs472(root, w, i+1, count+1, memo) {
memo[idx] = 1
return true
}
}
}
memo[idx] = 0
return false
}
func insert(root *trieNode, w string) {
cur := root
for i := 0; i < len(w); i++ {
p := int(w[i] - 'a')
if cur.next[p] == nil {
cur.next[p] = &trieNode{}
}
cur = cur.next[p]
}
cur.end = true
}
复杂度分析
- 时间复杂度:$O(W\log(W+1)+T+\sum L_i^2)$,其中
W为词数、T为总字符数、L_i为每个词的长度。排序和插入之外,每个词的每个后缀最多展开一次,每次最多沿字典树扫描整个后缀。- 空间复杂度:$O(T+W+L)$,其中
L为最长词长;包含字典树、排序及结果列表、当前词的缓存和递归栈。
关键点总结
[!green]
- 每个结束标记都是可尝试的断点,不能只贪心取最短或最长前缀。
- 缓存属于当前单词,不能跨词复用。
- 判定结束后再插入当前词,避免整词自匹配。
易错点总结
[!yellow]
- 先插入全部词,又允许单段成功:每个词都能匹配自身。
- 一个前缀失败就立即放弃:可能还有更长前缀可用。
- 递归不前进下标:没有消耗字符,无法结束。
- 片段数没有加一:终点无法满足至少两段的条件。
相似题目
| 题目 | 难度 | 关联与区别 |
|---|---|---|
| 139. 单词拆分 | 中等 | 同样按字典词切分,本题必须由至少两个更短词组成,不能把当前完整词自身直接当成功。 |
| 140. 单词拆分 II | 困难 | 原题输出全部切分句子,本题只判断一个词是否存在有效多段组合。 |
转载与许可
许可
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!