面试题 17.13 恢复空格【200709每日一题】
方法:字典树trie + 动态规划
这道题真的是要搞死我了…… 先来搞简单的部分—— 动态规划。
令 \(dp[i]\) 表示句子里前 i 个字符中最少的未识别字符数,此处前 i 个字符对应的是字符串 \(sentence.substring(0, i)\) ,也就是由第 0 ~ i - 1 个字符组成的字符串。当我们用字典中的词条去切割句子获得单词时,剩下没有成词的字符就是所有的未识别字符。由于不能确定哪种切割方式能留下最少的未识别字符,所以在求 \(dp[i]\) 时,需要遍历第 i 个字符前面所有的字符,来比较在哪一处切割能获得最小值。用 j 来表示最后一次成词时的切割位置( \(0 <= j < i\) ),如果 \(sentence.substring(j, i)\) 是字典中的单词,则 \(dp[j]\) (前 j 个字符的最少的未识别字符数)是 \(dp[i]\) 的一个候选值。
\(dp[i]\) 的默认值为 $$dp[i] = dp[i-1] + 1$$ ,可以看作是第 i - 1 个字符无法与前面的字符组合成词时的情况。此外,因为所求为最小值,所以如果当前字符串 \(sentence.substring(j, i)\) 不能组成单词,\(dp[j] + i - j\) 肯定大于或等于 \(dp[i-1] + 1\) ,不会是最小值候补,无需考虑。
考虑 \(dp[0]\) 的情况。显然,\(dp[0] = 0\),用来表示 sentence 中字符数为 0 或还未开始遍历时未识别字符数为 0 。
动态规划部分的代码如下:
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 < i; j++){
// 检查 sentence.substring(j, i) 是否为字典中的单词
if(root.inTrie(sentence.substring(j, i), root)){
dp[i] = Math.min(dp[j],dp[i]);
}
}
}
接下来需要解决的问题就是如何存储 & 查询字典中的词条。此处采用的是字典树 trie 的方式。
本质上,字典树将单词中的每一个字符当作一个节点存储在树中。假定单词中只包含小写字母,则理论上一个节点可以后接26个子节点。在二叉树中,我们可以用 node.left 和 node.right 来表示左右子节点,但在字典树中,如果类似地用 26 个字母来表示子节点就太繁琐太复杂了。为了方便表示,我用了哈希表来存储子节点(也可以用数组或者其他map 结构),其中 Key 值表示子节点对应的字符(可以理解为二叉树中的 node.val),Value 值表示字符对应的子节点。
还需要注意的是,在二叉树中,想要判断当前节点是否为叶子节点,方法是检查当前节点的左右子节点是否均为空,但这种做法并不能解决字典树中如何判断是否检索到词尾的问题。举例来说,假设词典中存有 "fun", "funny" 两个词,如何判断词典中收录了 "fun"? 如果还是用有无子节点来判断,显然是不正确的。所以需要一个属性来判断当前节点是否为一个单词的词尾,也就是代码中的 TrieNode.isWord.
字典树 TrieNode 的代码实现如下:
class TrieNode{
// 字典树
public boolean isWord;
// 感觉官方题解里面用 isEnd 表示更贴切
public Map next;
// 也可以令 next 为数组形式
TrieNode(){
isWord = false;
//只有在当前字符为单词结尾时才令该值为真
next = new HashMap<>();
}
}
在本题中,需要实现字典树的插入和查找操作。首先,要将题目给出的 String[] dictionary 插入到字典中。其实这个 add 函数不难写,只是因为子节点使用 HashMap 表示的,所以需要搞清楚如何在哈希表中查询/插入节点(字符)。最后也不要忘记将词尾处的 isWord 的值改为 true。
public void add(String word, TrieNode root){
// 插入新词
TrieNode current = root;
// 根节点不表示任何字符,首字母应在 root.next 的 map 中查找
int n = word.length();
for(int i = 0; i < n; i++){
char c = word.charAt(i);
TrieNode next = current.next.get(c);
// 在 current.next 这个 map 中查找,获取当前字符对应的节点
if(next == null){
// 若 next == null,即 map 中没有这个节点 --> 没有这个字符
// 在 current.next 这个 map 中添加当前字符 --> 添加
// 此处应创建新的空白节点,等待添加后续字符
current.next.put(c, new TrieNode());
}
// 后移 current 至当前字符对应的节点
// 为了便于理解,此处类比二叉树中 node = node.left 的写法
// 实际可以直接写成 current = next;
current = current.next.get(c);
}
if(!current.isWord){
current.isWord = true;
}
}
字典组建完毕后,就需要实现用于动态规划部分的查找操作了。查找的思路其实和插入是一致的,都是检查:【1】当前字符是否为树中当前节点的子节点【2】词尾处的 isWord 的值,此处就不赘述了。
public boolean inTrie(String word, TrieNode root){
// 查看 word 是否已在树中
TrieNode current = root;
int n = word.length();
for(int i = 0; i < n; i++){
char ch = word.charAt(i);
TrieNode next = current.next.get(ch);
if(next == null){
return false;
}
current = next;
}
return current.isWord;
}
最后给出完整代码:
class Solution {
public int respace(String[] dictionary, String sentence) {
int n = sentence.length();
int[] dp = new int[n + 1];
dp[0] = 0;
TrieNode root = new TrieNode();
if(n != 0){
for(int i = 0; i < dictionary.length; i++){
root.add(dictionary[i], root);
}
}
for(int i = 1; i < n + 1 ; i++){
dp[i] = dp[i-1] + 1;
for(int j = 0; j < i; j++){
if(root.inTrie(sentence.substring(j, i), root)){
dp[i] = Math.min(dp[j],dp[i]);
}
}
}
return dp[n];
}
}
class TrieNode{
// 字典树
public boolean isWord;
// 感觉官方题解里面用 isEnd 表示更贴切
public Map next;
// 也可以令 next 为数组形式
TrieNode(){
isWord = false;
//只有在当前字符为单词结尾时才令该值为真
next = new HashMap<>();
}
public void add(String word, TrieNode root){
// 插入新词
TrieNode current = root;
int n = word.length();
for(int i = 0; i < n; i++){
char c = word.charAt(i);
TrieNode next = current.next.get(c);
// 在 current.next 这个 map 中查找,获取当前字符对应的节点
if(next == null){
// 若 next == null,即 map 中没有这个节点 --> 没有这个字符
// 在 current.next 这个 map 中添加当前字符 --> 添加
// 此处应创建新的空白节点,等待添加后续字符
current.next.put(c, new TrieNode());
}
//后移 current 至当前字符对应的节点
current = current.next.get(c);
// 可以简写成 current = next;
}
if(!current.isWord){
current.isWord = true;
}
}
public boolean inTrie(String word, TrieNode root){
// 查看 word 是否已在树中
TrieNode current = root;
int n = word.length();
for(int i = 0; i < n; i++){
char ch = word.charAt(i);
TrieNode next = current.next.get(ch);
if(next == null){
return false;
}
current = next;
}
return current.isWord;
}
// 本题中只涉及字典树的插入/查找操作
}
数据结构与算法(十一)Trie字典树 :
本题中只涉及字典树的插入/查找操作,这篇文章还给出了字典树删除操作的实现方法,对字典树的讲解非常详细,五星好评:-)
309.最佳买卖股票时机含冷冻期【200710每日一题】
方法:动态规划
刷 leetcode 这些天已经遇到了不少动态规划的题。这一道中等难度的题算是一道质检吧。这道题看起来很复杂,但如果套用到动态规划的思路中,就可以顺利地解决了。
在本题中,我们要求解在最后一天结束后,所能获得的累计最大利润。首先,需要分析出在第 i 天结束后,共有几种可能的状态。根据题意,一共有 3 种可能:
在第 i 天结束后,目前手中持有股票:
--> 可能是第 i 天买入的(则第 i-1 天结束后必处于状态 3),也可能是继承了第 i-1 天持有的股票;
在第 i 天结束后,不持有股票,且目前处于冷冻期:
--> 第 i 天卖出了手中的股票,所以第 i-1 天必然持有股票;
在第 i 天结束后,不持有股票,且可以买入:
--> 第 i 天没有执行买卖操作,且第 i-1 天也不持有股票,但不一定是在第 i - 1 天卖出的.
分别用 \(dp[i][0], dp[i][1], dp[i][2]\) 来表示在第 i 天结束后,且处于上述 3 种对应状态时所积累的最大利润。根据分析,可以得到状态转移方程为:
\[dp[i][0] = MAX(dp[i - 1][2] - prices[i], dp[i - 1][0]),
\]
\[dp[i][1] = dp[i - 1][0] + prices[i],
\]
\[dp[i][2] = MAX(dp[i - 1][1], dp[i - 1][2]).
\]
得到状态转移方程后,考虑边界情况。显然,当 \(i = 0\) 时, 若不执行买入操作,那么累计的最大利润默认值为 0;若执行买入操作,最大利润需要在默认值基础上减去当天的股票价格。因此,当 \(i = 0\) 时,各状态的初始值为:
\[dp[0][0] = -prices[0],
\]
\[dp[0][1] = dp[0][2] = 0.
\]
至此,基本解题思路已完成。但在观察状态转移方程时,可以发现 \(dp[i][...]\) 的值只和第 i-1 天的结果有关。因此,可以使用动态数组来降低 dp 数组维度,实现空间优化。
具体代码如下:
class Solution {
public int maxProfit(int[] prices) {
int[] dp = new int[3];
if(prices.length > 0){
dp[0] = -prices[0];
dp[1] = 0;
dp[2] = 0;
}
for(int i = 1; i < prices.length; i++){
int temp0 = dp[0];
dp[0] = Math.max(dp[0], dp[2] - prices[i]);
dp[2] = Math.max(dp[1], dp[2]);
dp[1] = temp0 + prices[i];
}
return Math.max(dp[1], dp[2]);
}
}