LeetCode 139. 单词拆分

84 阅读2分钟

题目描述:给定一个字符串 s 和一个字符串集合 wordDict。求解,是否能用 wordDict 中的字符串,拼凑出字符串 s? (wordDict中的字符串可以使用任意次)

s = "leetcode"wordDict = ["leet", "code"],返回 true

s = applepenapplewordDict = ["pen", "apple"],返回 true

通俗的说,就是先给你一堆字符串,问你能否用这一堆字符串,拼接出另一个字符串。

解法一:DFS暴搜

最直观的做法就是使用暴力,把所有可能的情况都枚举一遍。如果字符串s能够拆分成若干个wordDict中的字符串。那从s的最左侧出发往右走,一定能先拆出第一个字符串。

暴力搜索我们一般会用DFS来做,DFS时,中间节点是可能的情况分支,而最终的答案落在叶子节点。

先定义这样一个函数:dfs(int begin),其含义是,求解字符串sbegin下标开始的子串,能否拆分成若干个wordDict中的字符串。

那我们最终的答案就是求 dfs(0)

中间的过程,可以枚举下标i,从begin开始枚举到s的最后一个位置。当s[begin,i]的子串在wordDict中出现,则我们拆出了左侧的第一个字符串,此时再递归的求解 dfs(i + 1)即可。

递归退出的条件是 begin == s.length(),此时已经将整个字符串s全部拆完。

class Solution {

    public boolean wordBreak(String s, List<String> wordDict) {
        return dfs(s, 0, new HashSet<>(wordDict));
    }

    private boolean dfs(String s, int begin, Set<String> wordDict) {
        if (begin == s.length()) return true;
        for (int end = begin + 1; end <= s.length(); end++) {
            String subStr = s.substring(begin, end);
            if (wordDict.contains(subStr) && dfs(s, end, wordDict)) return true;
        }
        return false;
    }
}

需要注意,substring(i,j)方法的第二个参数是exclusive的,若调用的是substring(3,5),子串下标范围是 [3, 5),即[3, 4]

暴力法的时间复杂度非常糟糕,因为它会对相同的状态进行多次重复的计算。无法通过全部的 test case。 在这里插入图片描述 下面要对DFS的过程进行优化。

解法二:DFS + 记忆化

比如上面截图的 test case,会重复计算 dfs(2) 在这里插入图片描述 所以,我们额外开一个Booolean数组st,保存已经计算过的位置的结果,当调用dfs(i)搜索位置i时,如果st[i] != null,则说明i位置已经被搜索过,则直接返回。以此来减少重复的计算。

class Solution {

    public boolean wordBreak(String s, List<String> wordDict) {
        return dfs(s, 0, new HashSet<>(wordDict), new Boolean[s.length()]);
    }

    private boolean dfs(String s, int begin, Set<String> wordDict, Boolean[] st) {
        if (begin == s.length()) return true; // 到达叶子节点
        if (st[begin] != null) return st[begin]; // 记忆化
        for (int end = begin + 1; end <= s.length(); end++) {
            String subStr = s.substring(begin, end);
            if (wordDict.contains(subStr) && dfs(s, end, wordDict, st)) {
                return st[begin] = true;
            }
        }
        return st[begin] = false;
    }
}

在这里插入图片描述

解法三:BFS

上面的DFS过程,也可以用BFS来做,用一个队列来存储当前搜索到的位置即可,用一个boolean数组st,来记录哪些位置已经被访问过,对于已经被访问过的位置i,不需要重复处理。

class Solution {

    public boolean wordBreak(String s, List<String> wordDict) {
        boolean[] st = new boolean[s.length()];
        Set<String> wordSet = new HashSet<>(wordDict);
        Queue<Integer> q = new LinkedList<>();
        q.offer(0); // 先把
        while (!q.isEmpty()) {
            int begin = q.poll(); // 获取上一个节点的起始位置
            if (st[begin]) continue;
            for (int end = begin + 1; end <= s.length(); end++) {
                if (wordSet.contains(s.substring(begin, end))) {
                    // 拆出一个字符串
                    if (end == s.length()) return true; // 结束
                    if (st[end]) continue; // 重复, 跳过
                    q.offer(end);
                }
            }
            st[begin] = true;
        }
        return false;
    }
}

在这里插入图片描述

动态规划

这道题同样适合用动规来做。我们这样来定义状态f(i)

  • 当字符串s的子串[0, i] 能够被拆分,则f(i) = true
  • 当字符串s的子串[0, i]不能被拆分,则f(i) = false

f(i) 代表着字符串s的某个前缀,是否能拆分。

对于状态转移,这样来考虑:我们枚举所有能进行拆分的位置(分割点),看能否转移过去即可。

f(i)时,我们枚举分割点j ∈ [0, i - 1],字符串被切割成了两部分:[0, j][j + 1, i]。只需要满足 f(j) = true 并且子串 [j + 1, i]wordDict 中的字符串,则 f(i) = true(前半部分能够被拆分,且后半部分是wordDict中的字符串)。若枚举了所有的j都无法找到这样的情况,则f(i) = false

具体的代码实现中

由于f(i)的计算需要依赖于i 之前的状态f,所以我们开的数组大小为 s.length() + 1

并且用 f(1) 来表示字符串s[0, 0] 能否拆分(长度为1) f(2) 表示 [0, 1]能否拆分(长度为2)。

这和上面的描述有点不同,主要是为了方便代码实现。

还要注意初始化 f(0) = true,可以理解为wordDict中总是有一个空字符串。(下面的代码中,注意看注释,对于理解很重要

class Solution {

    public boolean wordBreak(String s, List<String> wordDict) {
        boolean[] f = new boolean[s.length() + 1];
        f[0] = true;
        Set<String> wordSet = new HashSet<>(wordDict);
        for (int i = 1; i <= s.length() ; i++) {
            // 计算所有的 f(i)
            for (int j = 0; j < i; j++) {
                // 注意, 【i, j 用来取 f 时】, 与  【i, j 用来对字符串切割时】  含义略有不同, 前者是第几个位置(从1开始), 后者是下标(从0开始)
                // 切割时, 切割点位于最左侧, 左侧是空串, 右侧的子串下标为 [0, i - 1]
                //  切割点位于最右侧时, 左侧的下标为[0, i - 2] (即 f(i - 1) ), 右侧的子串下标为 [i - 1, i - 1]  (右侧只剩一个位置) (右侧至少要剩一个位置, 虽然代码里写成 j <= i 的话, 也不影响答案的正确性, 但是会多进行一次迭代)
                if (f[j] && wordSet.contains(s.substring(j, i))) {
                    f[i] = true;
                    break;
                }
            }
        }
        return f[s.length()];
    }
}

在这里插入图片描述

优化:上面的动规可以加一个小的优化。由于我们在求解f(i)时,是枚举了i之前的所有分割点j ∈ [0, i - 1],分割后,前面的部分直接用f(j)来判断,后面的子串则判断其是否存在于wordDict中。那我们可以先对wordDict中的所有字符串,求一个最大长度。 则在进行分割时,右侧的子串如果超过这个最大长度,则一定不存在了,于是可以提前终止循环。(这种方式,分割点要从最右侧开始取,从最右侧开始取,右侧子串的长度才是递增的,才能在达到最大长度后提前终止)

class Solution {

    public boolean wordBreak(String s, List<String> wordDict) {
        boolean[] f = new boolean[s.length() + 1];
        f[0] = true;
        int maxLen = 0;
        Set<String> wordSet = new HashSet<>();
        for (String w : wordDict) {
            wordSet.add(w);
            maxLen = Math.max(maxLen, w.length());
        }
        for (int i = 1; i <= s.length() ; i++) {
            // 计算所有的 f(i)
            // j >= 0 不能省掉, 因为可能maxLen比当前能切割出的最长子串还长, 那样 j 就会取到 负数
            for (int j = i - 1; j >= 0 && i - j <= maxLen; j--) {
                // 注意, 【i, j 用来取 f 时】, 与  【i, j 用来对字符串切割时】  含义略有不同, 前者是第几个位置(从1开始), 后者是下标(从0开始)
                // f(i) 表示下标的第i个位置 (字符串下标应当是i - 1), 而在 substring 里面用来截取字符串时, 其实应该截取到下标i - 1, 而substring的第二个参数恰好是 exclusive
                // 切割时, 切割点位于最左侧, 左侧是空串, 右侧的子串下标为 [0, i - 1]
                //  切割点位于最右侧时, 左侧的下标为[0, i - 2] (即 f(i - 1) ), 右侧的子串下标为 [i - 1, i - 1]  (右侧只剩一个位置)
                if (f[j] && wordSet.contains(s.substring(j, i))) {
                    f[i] = true;
                    break;
                }
            }
        }
        return f[s.length()];
    }
}

在这里插入图片描述

再狠一点的,也可以再求一个最小长度,把两个最值都用上。

class Solution {

    public boolean wordBreak(String s, List<String> wordDict) {
        boolean[] f = new boolean[s.length() + 1];
        f[0] = true;
        int maxLen = 0, minLen = Integer.MAX_VALUE;
        Set<String> wordSet = new HashSet<>();
        for (String w : wordDict) {
            wordSet.add(w);
            maxLen = Math.max(maxLen, w.length());
            minLen = Math.min(minLen, w.length());
        }
        for (int i = 1; i <= s.length() ; i++) {
            // 计算所有的 f(i)
            // 从最小长度开始
            for (int j = i - minLen; j >= 0 && i - j <= maxLen; j--) {
                // 注意, 【i, j 用来取 f 时】, 与  【i, j 用来对字符串切割时】  含义略有不同, 前者是第几个位置(从1开始), 后者是下标(从0开始)
                // f(i) 表示下标的第i个位置 (字符串下标应当是i - 1), 而在 substring 里面用来截取字符串时, 其实应该截取到下标i - 1, 而substring的第二个参数恰好是 exclusive
                // 切割时, 切割点位于最左侧, 左侧是空串, 右侧的子串下标为 [0, i - 1]
                //  切割点位于最右侧时, 左侧的下标为[0, i - 2] (即 f(i - 1) ), 右侧的子串下标为 [i - 1, i - 1]  (右侧只剩一个位置)
                if (f[j] && wordSet.contains(s.substring(j, i))) {
                    f[i] = true;
                    break;
                }
            }
        }
        return f[s.length()];
    }
}

在这里插入图片描述 但是和只用一个最值的差别并不大。

在这里插入图片描述 (完)