面试题 17.13、恢复空格

题目:你不小心把一个长篇文章中的空格、标点都删掉了,并且大写也弄成了小写。像句子"I reset the computer. It still didn’t boot!" 已经变成了"iresetthecomputeritstilldidntboot"。在处理标点符号和大小写之前,你得先把它断成词语。当然了,你有一本厚厚的词典dictionary,不过,有些词没在词典里。假设文章用sentence表示,设计一个算法,把文章断开,要求未识别的字符最少,返回未识别的字符数。

方法一:动态规划

解题思路:
 dp[i] 表示字符串的前 i 个字符的最少未匹配数。
 假设当前我们已经考虑完了前 i 个字符了,对于前 i + 1 个字符对应的最少未匹配数:

  • 第 i + 1 个字符未匹配,则 dp[i + 1] = dp[i] + 1,即不匹配数加 1;
  • 遍历前 i 个字符,若以其中某一个下标 idx 为开头、以第 i + 1 个字符为结尾的字符串正好在词典里,则 dp[i] = min(dp[i], dp[idx]) 更新 dp[i]。
public int respace(String[] dictionary, String sentence) {
        Set<String> dict = new HashSet<>(Arrays.asList(dictionary));
        int n = sentence.length();
        int[] dp = new int[n + 1];
        for (int i = 1; i <= n; i++) {
            dp[i] = dp[i - 1] + 1;
            //逐段的遍历,寻求为匹配字符的最小个数
            for (int idx = 0; idx < i; idx++) {
                if (dict.contains(sentence.substring(idx, i))) {
                    dp[i] = Math.min(dp[i], dp[idx]);
                }
            }
        }
        return dp[n];
    }

时间复杂度是 O(n^2) , n为待匹配字符串的长度。


方法二:Trie + 动态规划

字典树Trie 优化动态规划时间复杂度

思路和算法:
  定义 dp[i] 表示考虑前 i 个字符最少的未识别的字符数量,从前往后计算 dp 值。

  考虑转移方程,每次转移的时候我们考虑第 j(j≤i) 个到第 i 个字符组成的子串 sentence[j−1⋯i−1] (注意字符串下标从 0 开始)是否能在词典中找到,如果能找到的话按照定义转移方程即为 dp[i]=min(dp[i],dp[j−1])
否则没有找到的话我们可以复用 dp[i−1] 的状态再加上当前未被识别的第 i 个字符,因此此时 dp 值为 dp[i]=dp[i−1]+1

  最后问题化简成了转移的时候如何快速判断当前子串是否存在于词典中,与「单词拆分」类似我们可以选择用哈希表来优化,但笔者实测下来速度很慢,因为用哈希表来实现本身有两个问题,一个是哈希表本身的常数很大,还有一个是我们在枚举子串是否在词典中的时候有些其实是没有必要的枚举。简单举例,如果我们有词典:[‘aabc’, ‘babc’, ‘cbc’] ,但是我们在倒序枚举的时候检查 dc 这个子串没出现在词典中以后我们就没必要再接着往前枚举是否有合法的子串了,因为 dc 本身已经不是词典中「任意一个单词的后缀」,我们再接着枚举 *dc 或者 **dc 判断其是否在词典中都是无用功。

  因此最终笔者选择了用字典树 rie 来优化查找,Trie 是一种最大程度利用多个字符串前缀信息的数据结构,它可以在 O(w) 的时间复杂度内判断一个字符串是否是一个字符串集合中某个字符串的前缀,其中 w 代表字符串的长度。这里具体实现不再展开,我们只讲怎么使用。上文提到了哈希表实现的时候会出现很多冗余的判断,最关键的一点就是当前枚举的子串已经不再是词典中「任意一个单词的后缀」,这点我们可以利用 Trie 来解决。

  我们将词典中所有的单词「反序」插入字典树中,然后每次转移的时候我们从当前的下标 ii 出发倒序遍历 i−1,i−2,⋯,0。在 Trie 上从根节点出发开始走,直到走到当前的字符 sentence[j] 在 Trie 上没有相应的位置,说明 sentence[j⋯i−1] 不存在在词典中,且它已经不是「任意一个单词的后缀」,此时我们直接跳出循环即可。否则,我们需要判断当前的子串是否是一个单词,这里我们直接在插入 Trie 的时候在单词末尾的节点打上一个 isEnd 的标记即可,这样我们在走到某个节点的时候就可以判断是否是一个单词的末尾并根据状态转移方程更新我们的 dp 值

	public int respace2(String[] dictionary, String sentence) {
        int n = sentence.length();

        Trie root = new Trie();
        for (String word: dictionary) {
            root.insert(word);
        }

        int[] dp = new int[n + 1];
        Arrays.fill(dp, Integer.MAX_VALUE);
        dp[0] = 0;
        for (int i = 1; i <= n; ++i) {
            dp[i] = dp[i - 1] + 1;

            Trie curPos = root;
            for (int j = i; j >= 1; --j) {
                int t = sentence.charAt(j - 1) - 'a';
                //单词树中不存在以该字符(串)结尾的单词,则指针右移,开始下一轮的判断
                if (curPos.next[t] == null) {
                    break;
                    //当匹配到单词树中的一个单词后更新dp[]中当前位置最小未匹配字符的个数
                } else if (curPos.next[t].isEnd) {
                    dp[i] = Math.min(dp[i], dp[j - 1]);
                }
                //dp[i] = 0,代表全匹配了,就不用再判断了
                if (dp[i] == 0) {
                    break;
                }
                curPos = curPos.next[t];
            }
        }
        //最后位置得到的最小未匹配字符个数即为所求
        return dp[n];
    }

字典树构建:

class Trie {
    public Trie[] next;
    public boolean isEnd;

    //用26个小写的英文字母相对 a 的差值代表对应字母存储咋字典树中
    public Trie() {
        next = new Trie[26];
        isEnd = false;
    }

    public void insert(String s) {
        Trie curPos = this;

        //把每个单词以倒序结构插入字典树中
        for (int i = s.length() - 1; i >= 0; --i) {
            int t = s.charAt(i) - 'a';
            if (curPos.next[t] == null) {
                curPos.next[t] = new Trie();
            }
            curPos = curPos.next[t];
        }
        //设置单词的结尾标识
        curPos.isEnd = true;
    }
}

时间复杂度:O(∣dictionary∣+n^2),其中 ∣dictionary∣ 代表词典中的总字符数,n=sentence.length。建字典树的时间复杂度取决于单词的总字符数,即 ∣dictionary∣,因此时间复杂度为 O(∣dictionary∣)。dp 数组一共有 n+1 个状态,每个状态转移的时候最坏需要 O(n)的时间复杂度,因此时间复杂度为 O(n^2)。
空间复杂度:O(∣dictionary∣∗S+n),其中 S 代表字符集大小,这里为小写字母数,因此 S=26。我们可以这样考虑空间复杂度的渐进上界:对于字典树而言,如果节点个数为 ∣node∣,字符集大小为 S,那么空间代价为O(∣node∣∗S);因为这里的节点数一定小于词典中的总字符数,故 O(∣node∣∗S)=O(∣dictionary∣∗S)。dpdp 数组的空间代价为 O(n)。


方法三:字符串哈希

思路和算法
  我们使用字典树的目的是查找某一个串 s 是否在一个串的集合 S 当中,并且当我们知道 s是否在 S 中之后,可以快速的知道在 s 后添加某一个新的字母得到的新串 s’ 是否在 S 中,这个转移的过程是 O(1) 的。这是我们采用字典树而放弃使用 HashMap 类容器的一个理由,这些容器不能实现 s 到 s’ 的 O(1) 转移,但字典树可以。

  其实还用一种字符串哈希的方法也能实现 O(1) 的转移,我们用这种方法替换字典树,时间复杂度不变,空间复杂度可以优化到 O(n+q),其中 n 为 sentence 中元素的个数,q 为词典中单词的个数。

public int respace3(String[] dictionary, String sentence) {
        Set<Long> hashValues = new HashSet<Long>();
        for (String word : dictionary) {
            hashValues.add(getHash(word));
        }

        int[] f = new int[sentence.length() + 1];
        Arrays.fill(f, sentence.length());

        f[0] = 0;
        for (int i = 1; i <= sentence.length(); ++i) {
            f[i] = f[i - 1] + 1;
            long hashValue = 0;
            for (int j = i; j >= 1; --j) {
                int t = sentence.charAt(j - 1) - 'a' + 1;
                hashValue = (hashValue * BASE + t) % P;
                if (hashValues.contains(hashValue)) {
                    f[i] = Math.min(f[i], f[j - 1]);
                }
            }
        }

        return f[sentence.length()];
    }

    public long getHash(String s) {
        long hashValue = 0;
        for (int i = s.length() - 1; i >= 0; --i) {
            hashValue = (hashValue * BASE + s.charAt(i) - 'a' + 1) % P;
        }
        return hashValue;
    }

时间复杂度:O(∣dictionary∣+n^2),同方法一。
空间复杂度:O(n + q),其中 n 为 sentence 中元素的个数,q 为词典中单词的个数。

参考:
题解1
Leetcode官方题解

posted @ 2022-04-08 23:24  FireCode  阅读(56)  评论(0)    收藏  举报