用最简洁的题目出最难的题。
1、来看题
此题不愧是困难题,好在也没难到哪去。
但题目简洁是真的简洁。一共就两句话,还带了些许废话。
2、审题
题目简洁,但处处值得推敲,直接看重点:
- 题目明确了
words[]
不含重复字符串。 - 解释了连接词的含义:
一个完全由给定数组中的至少两个较短单词组成的字符串
。什么意思呢?就是该字符串必须完全words[]
中出现过字符串拼接得来。而又由于本身不能包含,所以不可以用空字符串和本体组成。 - 含义中的
较短
两个词是废话,由于字符串必须由两个及以上的字符串拼接组成,则组成该字符串的其余字符串一定小于本体。不过虽然是废话,但却一定程度上给了启迪。
题目的要点基本上提炼完了。
但思路却没有成形,慢慢来吧。
3、思路
首先,考虑到目标字符串必须是两个及以上的字符串组成,则这些字符串一定比目标字符串短。
所以我想当然地首先考虑到对原本的words[]
进行排序,仅按照字符长度排序。
这样,我们就可以优先判断较短的字符,而较短的字符必然不会由较长的字符发生关联,故遍历时不需要考虑后面的字符串。
剩下的问题就在于如何匹配子串了,我们需要记录已经获取到的所有的字符串,并能够快速地判断其是否是自身的子串。由于是全词匹配,前些日子学习到的字符串匹配方式似乎不太好施展。
但是,我们还有其他招式,标签里提到的字典树。
字典树,或者说前缀树,其基本概念我不做讲解,若是你没有接触过,或是已经忘记,可以另行查看解释。
而之所以使用字典树,一来是自己有相关的经验,二来是可以在节省空间的同时,精确地查询字符串。
于是,整体的想法就成形了。
4、动手
class Solution {
public List<String> findAllConcatenatedWordsInADict(String[] words) {
DictionaryTreeNode root = new DictionaryTreeNode();
Arrays.sort(words, Comparator.comparingInt(String::length));
List<String> results = new ArrayList<>();
for (int i = 0; i < words.length; i++) {
if (words[i].length() < 1) {
continue;
}
if (search(root, words[i], 0)) {
//当前词能匹配成功的话,添加到结果集中
results.add(words[i]);
} else {
//匹配失败的话,说明有字典树不存在的结点,需将当前词添加到字典树中
root.add(words[i]);
}
}
return results;
}
private boolean search(DictionaryTreeNode root, String word, int cur) {
if (cur == word.length()) {
//所有字符匹配完毕
return true;
}
DictionaryTreeNode node = root;
for (int i = cur; i < word.length(); i++) {
Character next = word.charAt(i);
//不包含时,说明当前字符匹配失败,则无法匹配后续
if (!node.childMap.containsKey(next)) {
return false;
}
//移动当下一结点
node = node.childMap.get(next);
//如果此结点为尾结点,说明成功匹配了一个字符串
if (node.isEnd) {
//以当前位置分割,重头匹配子串
if (search(root, word, i + 1)) {
//如果子串能匹配成功,说明此字符能够成功
return true;
}
//如果子串匹配失败,则不以当前分割,再次匹配后续
}
}
//匹配失败时
return false;
}
public class DictionaryTreeNode {
private boolean isEnd;
private Map<Character, DictionaryTreeNode> childMap;
public DictionaryTreeNode() {
this.childMap = new HashMap<>();
}
public void add(String string) {
DictionaryTreeNode node = this;
for (int i = 0; i < string.length(); i++) {
Character next = string.charAt(i);
//当不包含当前字符时
if (!node.childMap.containsKey(next)) {
//新建节点,并添加到对应字符下
DictionaryTreeNode child = new DictionaryTreeNode();
node.childMap.put(next, child);
}
//移向子节点
node = node.childMap.get(next);
}
//所有字符添加完毕后,将isEnd标识置为true
node.isEnd = true;
}
}
}
5、解读
字典树的实现我不做讲解,我的写法也挺一般的。
在节点中,我使用了isEnd
来判断当前节点,是否为某一字符串的末尾。即查询到当前节点时,存在有出现在数组中的较短字符串,可以作为其子串。
在比较了官解后发现,在此处使用map存储子节点,读写耗时会比数组要差些。
核心比较逻辑在search()
中实现。
首先我们从根节点出发,挨个比较字符在字典树中是否存在,如若进行到某个节点时,其子节点中不包涵对应字符,则说明当前字符无法再做查询。
而如果查询到某一节点,发现其isEnd
为true,说明查询到目前时,可以确定了一个子串的存在。当然,该子串并不一定就是最终的解。
在查询到子串后,我们会以当前位置截取字符串,以相同的逻辑递归查询子串。
如果当前截取位置的子串也能成功查询到,则认为该完整字符串为连接词。
否则,回到截取处。继续向后查询,直到再次匹配到一个子串。
在主函数中,你会发现:
if (search(root, words[i], 0)) {
//当前词能匹配成功的话,添加到结果集中
results.add(words[i]);
} else {
//匹配失败的话,说明有字典树不存在的结点,需将当前词添加到字典树中
root.add(words[i]);
}
只有无法作为连接词的字符串会添加到字典树中,因为其可能成为后续的子串。
而已经确认为连接词的字符串,由于其子串已在字典树中出现过,所以无需再度添加。
6、提交
本以为用上字典树了,耗时排名能够做到挺高的,看来还有其他黑科技。
7、咀嚼
额……分析不出来……
由于解法和官解的字典树解法挺像的,所以参考官解的吧
8、看看大牛们
官解在给出字典树解法后,还抛出了一个思考。
我确实也想过通过记忆化来减少截取子串后的递归操作,但结果似乎并不理想。
之后,收集了大牛芸芸的解法,比较亮眼或者说与众不同的果然还是三叶大佬的字符串哈希做法。
9、总结
总之,又是一天困难题。
今天其实是严重超时了。但好在项目已经上线,临近年末了,暂时有些空窗期出现,才有足够的时间来完成这题。
收获肯定会有,但往往不会一朝一夕就呈现。
总之勤能补拙,各位社畜今天也是共勉了。
妈呀怎么今天还是这么冷……