今日签到题,题目如下:
哦,不!你不小心把一个长篇文章中的空格、标点都删掉了,并且大写也弄成了小写。像句子"I reset the computer. It still didn’t boot!"已经变成了"iresetthecomputeritstilldidntboot"。在处理标点符号和大小写之前,你得先把它断成词语。当然了,你有一本厚厚的词典dictionary,不过,有些词没在词典里。假设文章用sentence表示,设计一个算法,把文章断开,要求未识别的字符最少,返回未识别的字符数。
注意:本题相对原题稍作改动,只需返回未识别的字符数
示例:
输入:
dictionary = ["looked","just","like","her","brother"]
sentence = "jesslookedjustliketimherbrother"
输出: 7
解释: 断句后为"jess looked just like tim her brother",共7个未识别字符。
提示:0 <= len(sentence) <= 1000
dictionary中总字符数不超过 150000。
你可以认为dictionary和sentence中只包含小写字母。来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/re-space-lcci
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
首先肯定是考虑动态规划,但是因为我在考虑遍历 sentence 的时候,一直想着从 dictionary 元素首字母开始判断,导致遍历结果的字符串长度不明确。
没有想到解决方法,所以先摸索着尝试使用暴力解题,每次在 sentence 中找到匹配字典的字符串就删除这个长度。但是这是错误的,因为可能出现如下情况:dictionary = ["ab","bcde"],sentence = "abcef"。如果使用上述方法遍历,会先将 "ab" 移出 sentence,这样导致了无法判断更优的 "bcde"。
还是先看官方题解,提供了两种解法:
Trie + 动态规划
字符串哈希
这里只讨论方法一,方法二我也没仔细看。
在此之前并未了解过 Trie 使用,故先理解动态规划部分。题解中很轻易的点出如何令遍历结果的字符串长度固定,即从 dictionary 元素尾字母开始判断。即,创建 int 数组 dp,dp 长度为 n + 1,dp[0] 为空字符串长度的边界状态,dp[i] 为 sentence 前 i 个字符串的未识别字符数。遍历过程每次判断 dp[i] 时,以 sentence[i - 1](下标比长度少1)用作匹配 dictionary 元素的尾字符。如果成功匹配,由于删去了匹配的字符串长度 len,dp[i] 的状态会转移自 dp[i - len],即 dp[i] = dp[i - len]。否则,sentence[i - 1] 就只是一个新的无法识别的字符,dp[i] 的状态会转移自 dp[i-1],并且长度加 1,即 dp[i] = dp[i-1] + 1。
先使用 string.Substring(i - len,len) 截取 sentence 对应长度与字典元素进行匹配。
复杂度分析:
时间复杂度:首先遍历所有 sentence 的所有下标,嵌套遍历 dictionary 中所有元素进行匹配,由于 string 的 equal 实现时遍历 string 中的每个字符,所以时间复杂度为O(N*|dictionary|),N为 sentence 长度,|dictionary| 所有字符串长度。因为 dictionary 的最大长度为 150000,所以本方法时间复杂度较大。
空间复杂度:只使用了一个 dp 数组,故空间复杂度为 O(N)。(空间复杂度正确性我不是很确定,有路过的大佬知道的话欢迎指出)
以下为自己提交的代码:
public class Solution {
public int Respace(string[] dictionary, string sentence) {
int n = sentence.Length;
int[] dp = new int[n + 1];
dp[0] = 0;
for (int i = 1;i < n + 1;i++)
{
dp[i] = dp[i-1] + 1;
for(int j = 0;j < dictionary.Length;j++)
{
int len = dictionary[j].Length;
if (i - len < 0) continue;
string temp = sentence.Substring(i - len,len);
if (temp == dictionary[j])
{
dp[i] = Math.Min(dp[i],dp[i - len]);
}
}
}
return dp[n];
}
}
由于这种方法的空间复杂度较高,官方题解引入了 Trie 减少时间开销。以 sentence[i-1] 为尾字符匹配 dictionary 中的元素实际相当于从 sentence[i-1] 开始逆序匹配 dictionary 中元素对应字符。由于 dictionary 中的元素只由 26 个小写字母组成,所以元素很容易出现相同的后缀,则在逆序匹配的过程中会重复的遍历到这些相同的前缀。
使用 Trie 解决该问题,首先以没有字符表达的 Trie 对象 root 作为根节点,Trie 的成员由一个长度为 26 的 Trie 数组 nextArr 指向子节点,初始化时数组中所有值为空。成员还有一个 bool 值 isEnd,用作标识一个单词是否结束。然后将 dictionary 的所有元素存入 root。存入过程将元素中的字符逆序存入,已存在的后缀会使用相同的子节点类路径,然后在不同的前一字符处节点产生分支。逆序存入的原因是 sentence[i-1] 与 dictionary 中元素的匹配过程是逆序的,匹配过程即是逆序匹配 Trie 树。
如果匹配 Trie 树的过程中,对应字符的子节点为 null,即匹配失败,开始匹配 sentence 的下一个位置。
如果对应字符的子节点的 isEnd 为True,即在该位置匹配成功,转移状态。由于还可能存在更长的匹配字符串(如 "her" 和 "brother"),所以继续匹配知道子节点为null。
初次接触 Trie,上述叙述比较混乱。
复杂度分析直接引用官方题解
以下为自己提交的代码:
public class Solution {
public int Respace(string[] dictionary, string sentence) {
int n = sentence.Length;
Trie root = new Trie();
for(int i = 0; i < dictionary.Length;i++)
{
root.Insert(dictionary[i]);
}
int[] dp = new int[n + 1];
dp[0] = 0;
for (int i = 1;i < n + 1;i++)
{
dp[i] = dp[i-1] + 1;
Trie node = root;
for(int j = i - 1;j >= 0;j--)
{
int index = sentence[j] - 'a';
if (node.nextArr[index] == null)
{
break;
}
else if (node.nextArr[index].isEnd)
{
dp[i] = Math.Min(dp[i],dp[j]);
}
node = node.nextArr[index];
}
}
return dp[n];
}
}
public class Trie{
public Trie[] nextArr = new Trie[26];
public bool isEnd;
public Trie()
{
isEnd = false;
}
public void Insert(string word)
{
Trie root = this;
for(int i = word.Length - 1; i >= 0; i--)
{
int index = word[i] - 'a';
if (root.nextArr[index] == null)
{
root.nextArr[index] = new Trie();
}
root = root.nextArr[index];
}
root.isEnd = true;
}
}
以下为两种实现的提交结果,时间较长的为没有使用 Trie 的结果: